Merge commit '6b976e0f'
This commit is contained in:
commit
d0b8e8a82a
2
Gemfile
2
Gemfile
|
@ -117,3 +117,5 @@ gem "openssl", "~> 3.1"
|
|||
|
||||
# mail 2.8.0 breaks sendmail usage: https://github.com/mikel/mail/issues/1538
|
||||
gem "mail", "~> 2.7.1"
|
||||
|
||||
gem "prometheus-client", "~> 4.0"
|
||||
|
|
|
@ -285,6 +285,7 @@ GEM
|
|||
pg (1.4.5)
|
||||
pghero (3.1.0)
|
||||
activerecord (>= 6)
|
||||
prometheus-client (4.0.0)
|
||||
public_suffix (4.0.7)
|
||||
puma (6.1.0)
|
||||
nio4r (~> 2.0)
|
||||
|
@ -527,6 +528,7 @@ DEPENDENCIES
|
|||
openssl (~> 3.1)
|
||||
pg
|
||||
pghero
|
||||
prometheus-client (~> 4.0)
|
||||
puma
|
||||
pundit (~> 2.3)
|
||||
questiongenerator (~> 1.2)!
|
||||
|
|
|
@ -4,26 +4,27 @@ class AboutController < ApplicationController
|
|||
def index; end
|
||||
|
||||
def about
|
||||
user_count = User
|
||||
.where.not(confirmed_at: nil)
|
||||
.where("answered_count > 0")
|
||||
.count
|
||||
|
||||
current_ban_count = UserBan
|
||||
.current
|
||||
.joins(:user)
|
||||
.where.not("users.confirmed_at": nil)
|
||||
.where("users.answered_count > 0")
|
||||
.count
|
||||
|
||||
@users = user_count - current_ban_count
|
||||
@questions = Question.count
|
||||
@answers = Answer.count
|
||||
@comments = Comment.count
|
||||
@smiles = Appendable::Reaction.count
|
||||
@users = Rails.cache.fetch("about_count_users", expires_in: 1.hour) { user_count - current_ban_count }
|
||||
@questions = Rails.cache.fetch("about_count_questions", expires_in: 1.hour) { Question.count(:id) }
|
||||
@answers = Rails.cache.fetch("about_count_answers", expires_in: 1.hour) { Answer.count(:id) }
|
||||
@comments = Rails.cache.fetch("about_count_comments", expires_in: 1.hour) { Comment.count(:id) }
|
||||
end
|
||||
|
||||
def privacy_policy; end
|
||||
|
||||
def terms; end
|
||||
|
||||
private
|
||||
|
||||
def user_count = User
|
||||
.where.not(confirmed_at: nil)
|
||||
.where("answered_count > 0")
|
||||
.count
|
||||
|
||||
def current_ban_count = UserBan
|
||||
.current
|
||||
.joins(:user)
|
||||
.where.not("users.confirmed_at": nil)
|
||||
.where("users.answered_count > 0")
|
||||
.count
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ class InboxController < ApplicationController
|
|||
|
||||
after_action :mark_inbox_entries_as_read, only: %i[show]
|
||||
|
||||
def show # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
||||
def show # rubocop:disable Metrics/MethodLength
|
||||
find_author
|
||||
find_inbox_entries
|
||||
|
||||
|
@ -37,6 +37,7 @@ class InboxController < ApplicationController
|
|||
user: current_user)
|
||||
|
||||
inbox = Inbox.create!(user: current_user, question_id: question.id, new: true)
|
||||
increment_metric
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
|
@ -85,4 +86,14 @@ class InboxController < ApplicationController
|
|||
# using .dup to not modify @inbox -- useful in tests
|
||||
@inbox&.dup&.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def increment_metric
|
||||
Retrospring::Metrics::QUESTIONS_ASKED.increment(
|
||||
labels: {
|
||||
anonymous: true,
|
||||
followers: false,
|
||||
generated: true,
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "prometheus/client/formats/text"
|
||||
|
||||
class MetricsController < ActionController::API
|
||||
include ActionController::MimeResponds
|
||||
|
||||
def show
|
||||
fetch_sidekiq_metrics
|
||||
|
||||
render plain: metrics
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
SIDEKIQ_STATS_METHODS = %i[
|
||||
processed
|
||||
failed
|
||||
scheduled_size
|
||||
retry_size
|
||||
dead_size
|
||||
processes_size
|
||||
].freeze
|
||||
|
||||
def fetch_sidekiq_metrics
|
||||
stats = Sidekiq::Stats.new
|
||||
SIDEKIQ_STATS_METHODS.each do |key|
|
||||
Retrospring::Metrics::SIDEKIQ[key].set stats.public_send(key)
|
||||
end
|
||||
|
||||
stats.queues.each do |queue, value|
|
||||
Retrospring::Metrics::SIDEKIQ[:queue_enqueued].set value, labels: { queue: }
|
||||
end
|
||||
end
|
||||
|
||||
def metrics
|
||||
Prometheus::Client::Formats::Text.marshal(Retrospring::Metrics::PROMETHEUS)
|
||||
end
|
||||
end
|
|
@ -1,36 +1,42 @@
|
|||
module MarkdownHelper
|
||||
# frozen_string_literal: true
|
||||
|
||||
module MarkdownHelper
|
||||
def markdown(content)
|
||||
md = Redcarpet::Markdown.new(FlavoredMarkdown, MARKDOWN_OPTS)
|
||||
Sanitize.fragment(md.render(content), EVIL_TAGS).html_safe
|
||||
renderer = FlavoredMarkdown.new(**MARKDOWN_RENDERER_OPTS)
|
||||
md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS)
|
||||
# As the string has been sanitized we can mark it as HTML safe
|
||||
Sanitize.fragment(md.render(content), EVIL_TAGS).strip.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def strip_markdown(content)
|
||||
md = Redcarpet::Markdown.new(Redcarpet::Render::StripDown, MARKDOWN_OPTS)
|
||||
renderer = Redcarpet::Render::StripDown.new
|
||||
md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS)
|
||||
CGI.unescape_html(Sanitize.fragment(CGI.escape_html(md.render(content)), EVIL_TAGS)).strip
|
||||
end
|
||||
|
||||
def twitter_markdown(content)
|
||||
md = Redcarpet::Markdown.new(TwitteredMarkdown, MARKDOWN_OPTS)
|
||||
renderer = TwitteredMarkdown.new
|
||||
md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS)
|
||||
CGI.unescape_html(Sanitize.fragment(CGI.escape_html(md.render(content)), EVIL_TAGS)).strip
|
||||
end
|
||||
|
||||
def question_markdown(content)
|
||||
md = Redcarpet::Markdown.new(QuestionMarkdown.new, MARKDOWN_OPTS)
|
||||
Sanitize.fragment(md.render(content), EVIL_TAGS).html_safe
|
||||
renderer = QuestionMarkdown.new
|
||||
md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS)
|
||||
# As the string has been sanitized we can mark it as HTML safe
|
||||
Sanitize.fragment(md.render(content), EVIL_TAGS).strip.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def raw_markdown(content)
|
||||
md = Redcarpet::Markdown.new(Redcarpet::Render::HTML, RAW_MARKDOWN_OPTS)
|
||||
renderer = Redcarpet::Render::HTML.new(**MARKDOWN_RENDERER_OPTS)
|
||||
md = Redcarpet::Markdown.new(renderer, **MARKDOWN_OPTS)
|
||||
raw md.render content
|
||||
end
|
||||
|
||||
def get_markdown(path, relative_to = Rails.root)
|
||||
begin
|
||||
File.read relative_to.join(path)
|
||||
rescue Errno::ENOENT
|
||||
"# Error reading #{relative_to.join(path)}"
|
||||
end
|
||||
File.read relative_to.join(path)
|
||||
rescue Errno::ENOENT
|
||||
"# Error reading #{relative_to.join(path)}"
|
||||
end
|
||||
|
||||
def markdown_io(path, relative_to = Rails.root)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import registerEvents from 'utilities/registerEvents';
|
||||
import registerInboxEntryEvents from './entry';
|
||||
import { authorSearchHandler } from './author';
|
||||
import { deleteAllAuthorQuestionsHandler, deleteAllQuestionsHandler } from './delete';
|
||||
|
||||
export default (): void => {
|
||||
|
|
|
@ -115,6 +115,8 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
raise Errors::AnsweringSelfBlockedOther if self.blocking?(question.user)
|
||||
# rubocop:enable Style/RedundantSelf
|
||||
|
||||
Retrospring::Metrics::QUESTIONS_ANSWERED.increment
|
||||
|
||||
Answer.create!(content:, user: self, question:)
|
||||
end
|
||||
|
||||
|
@ -128,6 +130,8 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
raise Errors::CommentingOtherBlockedSelf if answer.user.blocking?(self)
|
||||
# rubocop:enable Style/RedundantSelf
|
||||
|
||||
Retrospring::Metrics::COMMENTS_CREATED.increment
|
||||
|
||||
Comment.create!(user: self, answer:, content:)
|
||||
end
|
||||
|
||||
|
|
|
@ -15,10 +15,6 @@ class FlavoredMarkdown < Redcarpet::Render::HTML
|
|||
end
|
||||
|
||||
def header(text, _header_level)
|
||||
paragraph text
|
||||
end
|
||||
|
||||
def paragraph(text)
|
||||
"<p>#{text}</p>"
|
||||
end
|
||||
|
||||
|
|
|
@ -6,9 +6,7 @@ class QuestionMarkdown < Redcarpet::Render::StripDown
|
|||
include Rails.application.routes.url_helpers
|
||||
include SharedMarkers
|
||||
|
||||
def paragraph(text)
|
||||
"<p>#{text}</p>"
|
||||
end
|
||||
def paragraph(text) = "<p>#{text.gsub("\n", '<br>')}</p>"
|
||||
|
||||
def link(link, _title, _content)
|
||||
process_link(link)
|
||||
|
|
|
@ -17,13 +17,13 @@
|
|||
%h2= t(".statistics.header")
|
||||
%p= t(".statistics.body", app_name: APP_CONFIG["site_name"])
|
||||
.entry
|
||||
.entry__value= @questions
|
||||
.entry__value{ title: number_to_human(@questions) }= number_to_human @questions, units: :short, format: "%n%u"
|
||||
%h4.entry__description= Question.model_name.human(count: @questions)
|
||||
.entry
|
||||
.entry__value= @answers
|
||||
.entry__value{ title: number_to_human(@answers) }= number_to_human @answers, units: :short, format: "%n%u"
|
||||
%h4.entry__description= Answer.model_name.human(count: @answers)
|
||||
.entry
|
||||
.entry__value= @users
|
||||
.entry__value{ title: number_to_human(@users) }= number_to_human @users, units: :short, format: "%n%u"
|
||||
%h4.entry__description= User.model_name.human(count: @users)
|
||||
|
||||
= render "shared/links"
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
.profile__header-container
|
||||
%img.profile__header-image{ src: @user.profile_header.url(:web) }
|
||||
.row
|
||||
.col-md-3.col-xs-12.col-sm-4
|
||||
.col-lg-3.col-md-4.col-xs-12.col-sm-4
|
||||
= render 'user/profile', user: @user
|
||||
.d-none.d-sm-block= render 'shared/links'
|
||||
.col-md-9.col-xs-12.col-sm-8
|
||||
.col-lg-9.col-md-8.col-xs-12.col-sm-8
|
||||
= render 'questionbox', user: @user
|
||||
= render 'tabs/profile', user: @user
|
||||
= yield
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
return if Rails.env.test? # no need for the direct file store in testing
|
||||
|
||||
require "prometheus/client/data_stores/direct_file_store"
|
||||
|
||||
Rails.application.config.before_configuration do
|
||||
dir = Rails.root.join("tmp/prometheus_metrics")
|
||||
FileUtils.mkdir_p dir
|
||||
|
||||
Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir:)
|
||||
end
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
# ensure the version metric is populated
|
||||
Retrospring::Metrics::VERSION_INFO
|
||||
end
|
|
@ -1,32 +1,28 @@
|
|||
require 'redcarpet/render_strip'
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "redcarpet/render_strip"
|
||||
|
||||
MARKDOWN_OPTS = {
|
||||
filter_html: true,
|
||||
escape_html: true,
|
||||
no_images: true,
|
||||
no_styles: true,
|
||||
safe_links_only: true,
|
||||
xhtml: false,
|
||||
hard_wrap: true,
|
||||
no_intra_emphasis: true,
|
||||
tables: true,
|
||||
fenced_code_blocks: true,
|
||||
autolink: true,
|
||||
disable_indented_code_blocks: true,
|
||||
strikethrough: true,
|
||||
superscript: false
|
||||
}
|
||||
|
||||
RAW_MARKDOWN_OPTS = {
|
||||
tables: true,
|
||||
fenced_code_blocks: true,
|
||||
autolink: true,
|
||||
no_intra_emphasis: true,
|
||||
tables: true,
|
||||
fenced_code_blocks: true,
|
||||
autolink: true,
|
||||
disable_indented_code_blocks: true,
|
||||
strikethrough: true,
|
||||
superscript: false
|
||||
}
|
||||
strikethrough: true,
|
||||
superscript: false,
|
||||
}.freeze
|
||||
|
||||
MARKDOWN_RENDERER_OPTS = {
|
||||
filter_html: true,
|
||||
escape_html: true,
|
||||
no_images: true,
|
||||
no_styles: true,
|
||||
safe_links_only: true,
|
||||
xhtml: false,
|
||||
hard_wrap: true,
|
||||
}.freeze
|
||||
|
||||
ALLOWED_HOSTS_IN_MARKDOWN = [
|
||||
APP_CONFIG['hostname'],
|
||||
*APP_CONFIG['allowed_hosts_in_markdown']
|
||||
]
|
||||
APP_CONFIG["hostname"],
|
||||
*APP_CONFIG["allowed_hosts_in_markdown"]
|
||||
].freeze
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
en:
|
||||
short:
|
||||
unit: ""
|
||||
thousand: "K"
|
||||
million: "M"
|
||||
billion: "B"
|
||||
trillion: "T"
|
|
@ -567,7 +567,6 @@ en:
|
|||
formatting:
|
||||
body_html: |
|
||||
<p>%{app_name} uses <b>Markdown</b> for formatting</p>
|
||||
<p>A blank line starts a new paragraph</p>
|
||||
<p><code>*italic text*</code> for <i>italic text</i></p>
|
||||
<p><code>**bold text**</code> for <b>bold text</b></p>
|
||||
<p><code>[link](https://example.com)</code> for <a href="https://example.com">link</a></p>
|
||||
|
|
|
@ -49,6 +49,10 @@ Rails.application.routes.draw do
|
|||
get "/linkfilter", to: "link_filter#index", as: :linkfilter
|
||||
get "/manifest.json", to: "manifests#show", as: :webapp_manifest
|
||||
|
||||
constraints(Constraints::LocalNetwork) do
|
||||
get "/metrics", to: "metrics#show"
|
||||
end
|
||||
|
||||
# Devise routes
|
||||
devise_for :users, path: "user", skip: %i[sessions registrations]
|
||||
as :user do
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddIndexForCreatedAtOnAnswers < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_index :answers, :created_at, order: :desc
|
||||
end
|
||||
end
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2023_02_12_181044) do
|
||||
ActiveRecord::Schema.define(version: 2023_02_18_142952) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -49,6 +49,7 @@ ActiveRecord::Schema.define(version: 2023_02_12_181044) do
|
|||
t.datetime "updated_at"
|
||||
t.integer "smile_count", default: 0, null: false
|
||||
t.datetime "pinned_at"
|
||||
t.index ["created_at"], name: "index_answers_on_created_at", order: :desc
|
||||
t.index ["question_id"], name: "index_answers_on_question_id"
|
||||
t.index ["user_id", "created_at"], name: "index_answers_on_user_id_and_created_at"
|
||||
t.index ["user_id", "pinned_at"], name: "index_answers_on_user_id_and_pinned_at"
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Constraints
|
||||
module LocalNetwork
|
||||
module_function
|
||||
|
||||
SUBNETS = %w[
|
||||
10.0.0.0/8
|
||||
127.0.0.0/8
|
||||
172.16.0.0/12
|
||||
192.168.0.0/16
|
||||
].map { IPAddr.new(_1) }.freeze
|
||||
|
||||
def matches?(request)
|
||||
SUBNETS.find do |net|
|
||||
net.include? request.remote_ip
|
||||
rescue
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,79 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Retrospring
|
||||
module Metrics
|
||||
PROMETHEUS = Prometheus::Client.registry
|
||||
|
||||
# avoid re-registering metrics to make autoreloader happy during dev:
|
||||
class << self
|
||||
%i[counter gauge histogram summary].each do |meth|
|
||||
define_method meth do |name, *args, **kwargs|
|
||||
PROMETHEUS.public_send(meth, name, *args, **kwargs)
|
||||
rescue Prometheus::Client::Registry::AlreadyRegisteredError
|
||||
raise unless Rails.env.development?
|
||||
|
||||
PROMETHEUS.unregister name
|
||||
retry
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
VERSION_INFO = gauge(
|
||||
:retrospring_version_info,
|
||||
docstring: "Information about the currently running version",
|
||||
labels: [:version],
|
||||
preset_labels: {
|
||||
version: Retrospring::Version.to_s,
|
||||
}
|
||||
).tap { _1.set 1 }
|
||||
|
||||
QUESTIONS_ASKED = counter(
|
||||
:retrospring_questions_asked_total,
|
||||
docstring: "How many questions got asked",
|
||||
labels: %i[anonymous followers generated]
|
||||
)
|
||||
|
||||
QUESTIONS_ANSWERED = counter(
|
||||
:retrospring_questions_answered_total,
|
||||
docstring: "How many questions got answered"
|
||||
)
|
||||
|
||||
COMMENTS_CREATED = counter(
|
||||
:retrospring_comments_created_total,
|
||||
docstring: "How many comments got created"
|
||||
)
|
||||
|
||||
# metrics from Sidekiq::Stats.new
|
||||
SIDEKIQ = {
|
||||
processed: gauge(
|
||||
:sidekiq_processed,
|
||||
docstring: "Number of jobs processed by Sidekiq"
|
||||
),
|
||||
failed: gauge(
|
||||
:sidekiq_failed,
|
||||
docstring: "Number of jobs that failed"
|
||||
),
|
||||
scheduled_size: gauge(
|
||||
:sidekiq_scheduled_jobs,
|
||||
docstring: "Number of jobs that are enqueued"
|
||||
),
|
||||
retry_size: gauge(
|
||||
:sidekiq_retried_jobs,
|
||||
docstring: "Number of jobs that are being retried"
|
||||
),
|
||||
dead_size: gauge(
|
||||
:sidekiq_dead_jobs,
|
||||
docstring: "Number of jobs that are dead"
|
||||
),
|
||||
processes_size: gauge(
|
||||
:sidekiq_processes,
|
||||
docstring: "Number of active Sidekiq processes"
|
||||
),
|
||||
queue_enqueued: gauge(
|
||||
:sidekiq_queues_enqueued,
|
||||
docstring: "Number of enqueued jobs per queue",
|
||||
labels: %i[queue]
|
||||
),
|
||||
}.freeze
|
||||
end
|
||||
end
|
|
@ -15,11 +15,11 @@ module Retrospring
|
|||
|
||||
def year = 2023
|
||||
|
||||
def month = 1
|
||||
def month = 2
|
||||
|
||||
def day = 31
|
||||
def day = 19
|
||||
|
||||
def patch = 1
|
||||
def patch = 2
|
||||
|
||||
def suffix = ""
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ module UseCase
|
|||
return if filtered?(question)
|
||||
|
||||
increment_asked_count
|
||||
increment_metric
|
||||
|
||||
inbox = ::Inbox.create!(user: target_user, question:, new: true)
|
||||
notify(inbox)
|
||||
|
@ -26,8 +27,8 @@ module UseCase
|
|||
status: 201,
|
||||
resource: question,
|
||||
extra: {
|
||||
inbox:
|
||||
}
|
||||
inbox:,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -92,6 +93,16 @@ module UseCase
|
|||
source_user.save
|
||||
end
|
||||
|
||||
def increment_metric
|
||||
Retrospring::Metrics::QUESTIONS_ASKED.increment(
|
||||
labels: {
|
||||
anonymous:,
|
||||
followers: false,
|
||||
generated: false,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def filtered?(question)
|
||||
target_user.mute_rules.any? { |rule| rule.applies_to? question } ||
|
||||
(anonymous && AnonymousBlock.where(identifier: question.author_identifier, user_id: [target_user.id, nil]).any?) ||
|
||||
|
|
|
@ -9,20 +9,21 @@ module UseCase
|
|||
|
||||
def call
|
||||
question = ::Question.create!(
|
||||
content: content,
|
||||
content:,
|
||||
author_is_anonymous: false,
|
||||
author_identifier: author_identifier,
|
||||
author_identifier:,
|
||||
user: source_user,
|
||||
direct: false
|
||||
)
|
||||
|
||||
increment_asked_count
|
||||
increment_metric
|
||||
|
||||
QuestionWorker.perform_async(source_user_id, question.id)
|
||||
|
||||
{
|
||||
status: 201,
|
||||
resource: question
|
||||
resource: question,
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -33,6 +34,16 @@ module UseCase
|
|||
source_user.save
|
||||
end
|
||||
|
||||
def increment_metric
|
||||
Retrospring::Metrics::QUESTIONS_ASKED.increment(
|
||||
labels: {
|
||||
anonymous: false,
|
||||
followers: true,
|
||||
generated: false,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def source_user
|
||||
@source_user ||= ::User.find(source_user_id)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe MetricsController, type: :controller do
|
||||
describe "#show" do
|
||||
subject { get :show }
|
||||
|
||||
it "returns the metrics" do
|
||||
expect(subject.body).to include "retrospring_version_info"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -31,6 +31,10 @@ describe MarkdownHelper, type: :helper do
|
|||
it "should escape HTML tags" do
|
||||
expect(markdown("I'm <h1>a test</h1>")).to eq("<p>I'm <h1>a test</h1></p>")
|
||||
end
|
||||
|
||||
it "should turn line breaks into <br> tags" do
|
||||
expect(markdown("Some\ntext")).to eq("<p>Some<br>\ntext</p>")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#strip_markdown" do
|
||||
|
@ -70,6 +74,10 @@ describe MarkdownHelper, type: :helper do
|
|||
it "should not process invalid links" do
|
||||
expect(question_markdown("https://example.com/example.質問")).to eq("<p>https://example.com/example.質問</p>")
|
||||
end
|
||||
|
||||
it "should turn line breaks into <br> tags" do
|
||||
expect(markdown("Some\ntext")).to eq("<p>Some<br>\ntext</p>")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#raw_markdown" do
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe Constraints::LocalNetwork do
|
||||
describe ".matches?" do
|
||||
let(:request) { double("Rack::Request", remote_ip:) }
|
||||
|
||||
subject { described_class.matches?(request) }
|
||||
|
||||
context "with a private address from the 10.0.0.0/8 range" do
|
||||
let(:remote_ip) { "10.0.2.100" }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context "with a private address from the 127.0.0.0/8 range" do
|
||||
let(:remote_ip) { "127.0.0.1" }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context "with a private address from the 172.16.0.0/12 range" do
|
||||
let(:remote_ip) { "172.31.33.7" }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context "with a private address from the 192.168.0.0/16 range" do
|
||||
let(:remote_ip) { "192.168.123.45" }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context "with a non-private/loopback address" do
|
||||
let(:remote_ip) { "193.186.6.83" }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context "with some fantasy address" do
|
||||
let(:remote_ip) { "fe80:3::1ff:fe23:4567:890a" }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context "with an actual invalid address" do
|
||||
let(:remote_ip) { "herbert" }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# This file is copied to spec/ when you run 'rails generate rspec:install'
|
||||
ENV["RAILS_ENV"] ||= "test"
|
||||
require File.expand_path("../../config/environment", __FILE__)
|
||||
require File.expand_path("../config/environment", __dir__)
|
||||
# Prevent database truncation if the environment is production
|
||||
abort("The Rails environment is running in production mode!") if Rails.env.production?
|
||||
require "spec_helper"
|
||||
|
@ -26,6 +26,7 @@ require "rspec-sidekiq"
|
|||
# require only the support files necessary.
|
||||
#
|
||||
# Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
|
||||
require "support/nokogiri_matchers"
|
||||
|
||||
# Checks for pending migration and applies them before tests are run.
|
||||
# If you are not using ActiveRecord, you can remove this line.
|
||||
|
@ -33,7 +34,7 @@ ActiveRecord::Migration.maintain_test_schema!
|
|||
|
||||
RSpec.configure do |config|
|
||||
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
|
||||
config.fixture_path = "#{::Rails.root}/spec/fixtures"
|
||||
config.fixture_path = Rails.root.join("spec/fixtures")
|
||||
|
||||
# If you're not using ActiveRecord, or you'd prefer not to run each of your
|
||||
# examples within a transaction, remove the following line or assign false
|
||||
|
@ -66,6 +67,7 @@ RSpec.configure do |config|
|
|||
|
||||
config.include Devise::Test::ControllerHelpers, type: :controller
|
||||
config.include Devise::Test::ControllerHelpers, type: :helper
|
||||
config.include Devise::Test::ControllerHelpers, type: :view
|
||||
end
|
||||
|
||||
Shoulda::Matchers.configure do |config|
|
||||
|
@ -75,4 +77,4 @@ Shoulda::Matchers.configure do |config|
|
|||
end
|
||||
end
|
||||
|
||||
Dir[Rails.root.join "spec", "shared_examples", "*.rb"].sort.each { |f| require f }
|
||||
Dir[Rails.root.join("spec/shared_examples/*.rb")].each { |f| require f }
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module NokogiriMatchers
|
||||
RSpec::Matchers.matcher :have_css do |css|
|
||||
description { %(have at least one element matching the CSS selector #{css.inspect}) }
|
||||
|
||||
match do |rendered|
|
||||
Nokogiri::HTML.parse(rendered).css(css).size.positive?
|
||||
end
|
||||
end
|
||||
|
||||
RSpec::Matchers.matcher :have_attribute do |expected_attribute|
|
||||
description do
|
||||
case expected_attribute
|
||||
when Hash
|
||||
raise ArgumentError.new("have_attribute only wants one key=>value pair") unless expected_attribute.size == 1
|
||||
|
||||
key = expected_attribute.keys.first
|
||||
value = expected_attribute.values.first
|
||||
%(have an attribute named #{key.inspect} with a value of #{value.inspect})
|
||||
else
|
||||
%(have an attribute named #{expected_attribute.inspect})
|
||||
end
|
||||
end
|
||||
|
||||
match do |element|
|
||||
case expected_attribute
|
||||
when Hash
|
||||
raise ArgumentError.new("have_attribute only wants one key=>value pair") unless expected_attribute.size == 1
|
||||
|
||||
key = expected_attribute.keys.first
|
||||
value = expected_attribute.values.first
|
||||
|
||||
element.attr(key.to_s).value == value
|
||||
else
|
||||
!element.attr(expected_attribute.to_s).nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.configure do |c|
|
||||
c.include NokogiriMatchers, view: true
|
||||
end
|
|
@ -0,0 +1,49 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe "inbox/_actions.html.haml", type: :view do
|
||||
let(:delete_id) { "ib-delete-all" }
|
||||
let(:disabled) { false }
|
||||
|
||||
let(:user) { FactoryBot.create(:user) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
subject(:rendered) do
|
||||
render partial: "inbox/actions", locals: {
|
||||
delete_id:,
|
||||
disabled:,
|
||||
inbox_count: 4020,
|
||||
}
|
||||
end
|
||||
|
||||
it "has a button for deleting all inbox entries" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
button = html.css("button#ib-delete-all")
|
||||
expect(button).not_to have_attribute(:disabled)
|
||||
end
|
||||
|
||||
context "with disabled = true" do
|
||||
let(:disabled) { true }
|
||||
|
||||
it "has a button for deleting all inbox entries" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
button = html.css("button#ib-delete-all")
|
||||
expect(button).to have_attribute(:disabled)
|
||||
end
|
||||
end
|
||||
|
||||
context "with delete_id = ib-delete-all-author" do
|
||||
let(:delete_id) { "ib-delete-all-author" }
|
||||
|
||||
it "has a button for deleting all inbox entries" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
button = html.css("button#ib-delete-all-author")
|
||||
|
||||
expect(button).to have_attribute("data-ib-count" => "4020")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,121 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe "inbox/_entry.html.haml", type: :view do
|
||||
let(:inbox_entry) { Inbox.create(user: inbox_user, question:, new:) }
|
||||
let(:inbox_user) { user }
|
||||
let(:user) { FactoryBot.create(:user, sharing_enabled:, sharing_custom_url:) }
|
||||
let(:sharing_enabled) { true }
|
||||
let(:sharing_custom_url) { nil }
|
||||
let(:question) { FactoryBot.create(:question, content: "owo what's this?", author_is_anonymous:, user: question_user, answer_count:) }
|
||||
let(:author_is_anonymous) { true }
|
||||
let(:question_user) { nil }
|
||||
let(:answer_count) { 0 }
|
||||
let(:new) { false }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
subject(:rendered) do
|
||||
render partial: "inbox/entry", locals: {
|
||||
i: inbox_entry,
|
||||
}
|
||||
end
|
||||
|
||||
it "does not set the inbox-entry--new class on non-new inbox entries" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
classes = html.css("#inbox_#{inbox_entry.id}").attr("class").value
|
||||
expect(classes).not_to include "inbox-entry--new"
|
||||
end
|
||||
|
||||
context "when the inbox entry is new" do
|
||||
let(:new) { true }
|
||||
|
||||
it "sets the inbox-entry--new class" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
classes = html.css("#inbox_#{inbox_entry.id}").attr("class").value
|
||||
expect(classes).to include "inbox-entry--new"
|
||||
end
|
||||
end
|
||||
|
||||
context "when question author is not anonymous" do
|
||||
let(:question_user) { FactoryBot.create(:user) }
|
||||
let(:author_is_anonymous) { false }
|
||||
|
||||
it "has an avatar" do
|
||||
expect(rendered).to have_css(%(img.answerbox__question-user-avatar))
|
||||
end
|
||||
|
||||
it "does not have an icon indicating the author is anonymous" do
|
||||
expect(rendered).not_to have_css(%(i.fas.fa-user-secret))
|
||||
end
|
||||
|
||||
context "when the question already has some answers" do
|
||||
let(:answer_count) { 9001 }
|
||||
|
||||
it "has a link to the question view" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
selector = %(a[href="/@#{question_user.screen_name}/q/#{question.id}"])
|
||||
expect(rendered).to have_css(selector)
|
||||
expect(html.css(selector).text.strip).to eq "9001 answers"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "has an icon indicating the author is anonymous" do
|
||||
expect(rendered).to have_css(%(i.fas.fa-user-secret))
|
||||
end
|
||||
|
||||
it "contains the question text" do
|
||||
expect(rendered).to match "owo what's this?"
|
||||
end
|
||||
|
||||
it "has interactive elements" do
|
||||
expect(rendered).to have_css(%(textarea[name="ib-answer"][data-id="#{inbox_entry.id}"]))
|
||||
expect(rendered).to have_css(%(button[name="ib-answer"][data-ib-id="#{inbox_entry.id}"]))
|
||||
expect(rendered).to have_css(%(button[name="ib-destroy"][data-ib-id="#{inbox_entry.id}"]))
|
||||
end
|
||||
|
||||
it "has a hidden sharing bit" do
|
||||
expect(rendered).to have_css(%(.inbox-entry__sharing.d-none))
|
||||
end
|
||||
|
||||
it "has a link-button to share to tumblr" do
|
||||
expect(rendered).to have_css(%(.inbox-entry__sharing a.btn[data-inbox-sharing-target="tumblr"]))
|
||||
end
|
||||
|
||||
it "has a link-button to share to twitter" do
|
||||
expect(rendered).to have_css(%(.inbox-entry__sharing a.btn[data-inbox-sharing-target="twitter"]))
|
||||
end
|
||||
|
||||
it "does not have a link-button to share to a custom site" do
|
||||
expect(rendered).not_to have_css(%(.inbox-entry__sharing a.btn[data-inbox-sharing-target="custom"]))
|
||||
end
|
||||
|
||||
context "when the user has a custom share url set" do
|
||||
let(:sharing_custom_url) { "https://pounced-on.me/share?text=" }
|
||||
|
||||
it "has a link-button to share to a custom site" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
selector = %(.inbox-entry__sharing a.btn[data-inbox-sharing-target="custom"])
|
||||
expect(rendered).to have_css(selector)
|
||||
expect(html.css(selector).text.strip).to eq "pounced-on.me"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the inbox entry does not belong to the current user" do
|
||||
let(:inbox_user) { FactoryBot.create(:user) }
|
||||
|
||||
it "does not have any interactive elements" do
|
||||
expect(rendered).not_to have_css(%(textarea[name="ib-answer"][data-id="#{inbox_entry.id}"]))
|
||||
expect(rendered).not_to have_css(%(button[name="ib-answer"][data-ib-id="#{inbox_entry.id}"]))
|
||||
expect(rendered).not_to have_css(%(button[name="ib-destroy"][data-ib-id="#{inbox_entry.id}"]))
|
||||
end
|
||||
|
||||
it "does not have the sharing bit" do
|
||||
expect(rendered).not_to have_css(%(.inbox-entry__sharing.d-none))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe "inbox/_push_settings.haml", type: :view do
|
||||
subject(:rendered) { render }
|
||||
|
||||
it "has a button to enable push notifications" do
|
||||
expect(rendered).to have_css(%(button[data-action="push-enable"]))
|
||||
end
|
||||
|
||||
it "has a button to dismiss the view" do
|
||||
expect(rendered).to have_css(%(button[data-action="push-dismiss"]))
|
||||
end
|
||||
end
|
|
@ -0,0 +1,75 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe "inbox/show.html.haml", type: :view do
|
||||
let(:user) { FactoryBot.create(:user) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
subject(:rendered) { render }
|
||||
|
||||
context "with an empty inbox" do
|
||||
before do
|
||||
assign :inbox, []
|
||||
end
|
||||
|
||||
it "displays an 'inbox is empty' message" do
|
||||
html = Nokogiri::HTML.parse(rendered)
|
||||
selector = "p.empty"
|
||||
expect(rendered).to have_css(selector)
|
||||
expect(html.css(selector).text.strip).to eq "Nothing to see here."
|
||||
end
|
||||
end
|
||||
|
||||
context "with some inbox entries" do
|
||||
let(:inbox_entry1) { Inbox.create(user:, question: FactoryBot.create(:question)) }
|
||||
let(:inbox_entry2) { Inbox.create(user:, question: FactoryBot.create(:question)) }
|
||||
|
||||
before do
|
||||
assign :inbox, [inbox_entry2, inbox_entry1]
|
||||
end
|
||||
|
||||
it "renders inbox entries" do
|
||||
expect(rendered).to have_css("#inbox_#{inbox_entry1.id}")
|
||||
expect(rendered).to have_css("#inbox_#{inbox_entry2.id}")
|
||||
end
|
||||
|
||||
it "does not contain the empty inbox message" do
|
||||
expect(rendered).not_to have_css("p.empty")
|
||||
end
|
||||
|
||||
it "does not render the paginator" do
|
||||
expect(rendered).not_to have_css("#paginator")
|
||||
end
|
||||
|
||||
context "when more data is available" do
|
||||
before do
|
||||
assign :more_data_available, true
|
||||
assign :inbox_last_id, 1337
|
||||
end
|
||||
|
||||
it "renders the paginator" do
|
||||
expect(rendered).to have_css("#paginator")
|
||||
end
|
||||
|
||||
it "has the correct params on the button" do
|
||||
expect(rendered).to have_css(%(input[type="hidden"][name="last_id"][value="1337"]))
|
||||
expect(rendered).not_to have_css(%(input[type="hidden"][name="author"]))
|
||||
end
|
||||
|
||||
context "when passed an author" do
|
||||
before do
|
||||
assign :author, "jyrki"
|
||||
end
|
||||
|
||||
it "has the correct params on the button" do
|
||||
expect(rendered).to have_css(%(input[type="hidden"][name="last_id"][value="1337"]))
|
||||
expect(rendered).to have_css(%(input[type="hidden"][name="author"]))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe "inbox/show.turbo_stream.haml", type: :view do
|
||||
let(:user) { FactoryBot.create(:user) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
subject(:rendered) { render }
|
||||
|
||||
context "with some inbox entries" do
|
||||
let(:inbox_entry1) { Inbox.create(user:, question: FactoryBot.create(:question)) }
|
||||
let(:inbox_entry2) { Inbox.create(user:, question: FactoryBot.create(:question)) }
|
||||
|
||||
before do
|
||||
assign :inbox, [inbox_entry2, inbox_entry1]
|
||||
end
|
||||
|
||||
it "appends to entries" do
|
||||
expect(rendered).to have_css(%(turbo-stream[action="append"][target="entries"]))
|
||||
end
|
||||
|
||||
it "renders inbox entries" do
|
||||
expect(rendered).to have_css("#inbox_#{inbox_entry1.id}")
|
||||
expect(rendered).to have_css("#inbox_#{inbox_entry2.id}")
|
||||
end
|
||||
|
||||
it "updates the paginator" do
|
||||
expect(rendered).to have_css(%(turbo-stream[action="update"][target="paginator"]))
|
||||
end
|
||||
|
||||
context "when more data is available" do
|
||||
before do
|
||||
assign :more_data_available, true
|
||||
assign :inbox_last_id, 1337
|
||||
end
|
||||
|
||||
it "has the correct params on the button" do
|
||||
expect(rendered).to have_css(%(input[type="hidden"][name="last_id"][value="1337"]))
|
||||
expect(rendered).not_to have_css(%(input[type="hidden"][name="author"]))
|
||||
end
|
||||
|
||||
context "when passed an author" do
|
||||
before do
|
||||
assign :author, "jyrki"
|
||||
end
|
||||
|
||||
it "has the correct params on the button" do
|
||||
expect(rendered).to have_css(%(input[type="hidden"][name="last_id"][value="1337"]))
|
||||
expect(rendered).to have_css(%(input[type="hidden"][name="author"]))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue