diff --git a/Gemfile b/Gemfile index 48ecdf45..a831e984 100644 --- a/Gemfile +++ b/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" diff --git a/Gemfile.lock b/Gemfile.lock index 94439de4..5e5b0320 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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)! diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index b957e445..0b20d1b2 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -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 diff --git a/app/controllers/inbox_controller.rb b/app/controllers/inbox_controller.rb index d849dafc..f6a1aff2 100644 --- a/app/controllers/inbox_controller.rb +++ b/app/controllers/inbox_controller.rb @@ -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 diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb new file mode 100644 index 00000000..257b8b7e --- /dev/null +++ b/app/controllers/metrics_controller.rb @@ -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 diff --git a/app/helpers/markdown_helper.rb b/app/helpers/markdown_helper.rb index 6d80f255..ef634209 100644 --- a/app/helpers/markdown_helper.rb +++ b/app/helpers/markdown_helper.rb @@ -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) diff --git a/app/javascript/retrospring/features/inbox/index.ts b/app/javascript/retrospring/features/inbox/index.ts index 02206e9a..6eff6cbe 100644 --- a/app/javascript/retrospring/features/inbox/index.ts +++ b/app/javascript/retrospring/features/inbox/index.ts @@ -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 => { diff --git a/app/models/user.rb b/app/models/user.rb index 6370ed01..725582ef 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/flavored_markdown.rb b/app/services/flavored_markdown.rb index a6ecde56..85aecdfb 100644 --- a/app/services/flavored_markdown.rb +++ b/app/services/flavored_markdown.rb @@ -15,10 +15,6 @@ class FlavoredMarkdown < Redcarpet::Render::HTML end def header(text, _header_level) - paragraph text - end - - def paragraph(text) "

#{text}

" end diff --git a/app/services/question_markdown.rb b/app/services/question_markdown.rb index 6ae585ea..6a251641 100644 --- a/app/services/question_markdown.rb +++ b/app/services/question_markdown.rb @@ -6,9 +6,7 @@ class QuestionMarkdown < Redcarpet::Render::StripDown include Rails.application.routes.url_helpers include SharedMarkers - def paragraph(text) - "

#{text}

" - end + def paragraph(text) = "

#{text.gsub("\n", '
')}

" def link(link, _title, _content) process_link(link) diff --git a/app/views/about/about.html.haml b/app/views/about/about.html.haml index 5dd5ba2b..6dcc22a4 100644 --- a/app/views/about/about.html.haml +++ b/app/views/about/about.html.haml @@ -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" diff --git a/app/views/layouts/user/profile.html.haml b/app/views/layouts/user/profile.html.haml index e0673ed2..1af75a29 100644 --- a/app/views/layouts/user/profile.html.haml +++ b/app/views/layouts/user/profile.html.haml @@ -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 diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb new file mode 100644 index 00000000..7d86c92f --- /dev/null +++ b/config/initializers/prometheus.rb @@ -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 diff --git a/config/initializers/redcarpet.rb b/config/initializers/redcarpet.rb index aed5e5ff..484a8b18 100644 --- a/config/initializers/redcarpet.rb +++ b/config/initializers/redcarpet.rb @@ -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'] -] \ No newline at end of file + APP_CONFIG["hostname"], + *APP_CONFIG["allowed_hosts_in_markdown"] +].freeze diff --git a/config/locales/units.en.yml b/config/locales/units.en.yml new file mode 100644 index 00000000..2494fcb1 --- /dev/null +++ b/config/locales/units.en.yml @@ -0,0 +1,7 @@ +en: + short: + unit: "" + thousand: "K" + million: "M" + billion: "B" + trillion: "T" diff --git a/config/locales/views.en.yml b/config/locales/views.en.yml index 351e2c03..d16c9ce5 100644 --- a/config/locales/views.en.yml +++ b/config/locales/views.en.yml @@ -567,7 +567,6 @@ en: formatting: body_html: |

%{app_name} uses Markdown for formatting

-

A blank line starts a new paragraph

*italic text* for italic text

**bold text** for bold text

[link](https://example.com) for link

diff --git a/config/routes.rb b/config/routes.rb index 744a8d83..8f485cb7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20230218142952_add_index_for_created_at_on_answers.rb b/db/migrate/20230218142952_add_index_for_created_at_on_answers.rb new file mode 100644 index 00000000..536db607 --- /dev/null +++ b/db/migrate/20230218142952_add_index_for_created_at_on_answers.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index e85a2c7e..0e8fadfd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/lib/constraints/local_network.rb b/lib/constraints/local_network.rb new file mode 100644 index 00000000..2db324a5 --- /dev/null +++ b/lib/constraints/local_network.rb @@ -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 diff --git a/lib/retrospring/metrics.rb b/lib/retrospring/metrics.rb new file mode 100644 index 00000000..7c52dcb6 --- /dev/null +++ b/lib/retrospring/metrics.rb @@ -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 diff --git a/lib/retrospring/version.rb b/lib/retrospring/version.rb index 57250b36..78573924 100644 --- a/lib/retrospring/version.rb +++ b/lib/retrospring/version.rb @@ -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 = "" diff --git a/lib/use_case/question/create.rb b/lib/use_case/question/create.rb index 5cc8f174..17987a1d 100644 --- a/lib/use_case/question/create.rb +++ b/lib/use_case/question/create.rb @@ -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?) || diff --git a/lib/use_case/question/create_followers.rb b/lib/use_case/question/create_followers.rb index 21c2e89d..04fe3844 100644 --- a/lib/use_case/question/create_followers.rb +++ b/lib/use_case/question/create_followers.rb @@ -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 diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb new file mode 100644 index 00000000..89f4316e --- /dev/null +++ b/spec/controllers/metrics_controller_spec.rb @@ -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 diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index 177b42c7..b4c22b76 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -31,6 +31,10 @@ describe MarkdownHelper, type: :helper do it "should escape HTML tags" do expect(markdown("I'm

a test

")).to eq("

I'm <h1>a test</h1>

") end + + it "should turn line breaks into
tags" do + expect(markdown("Some\ntext")).to eq("

Some
\ntext

") + 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("

https://example.com/example.質問

") end + + it "should turn line breaks into
tags" do + expect(markdown("Some\ntext")).to eq("

Some
\ntext

") + end end describe "#raw_markdown" do diff --git a/spec/lib/constraints/local_network_spec.rb b/spec/lib/constraints/local_network_spec.rb new file mode 100644 index 00000000..88326ccb --- /dev/null +++ b/spec/lib/constraints/local_network_spec.rb @@ -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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 43662e83..11da5afa 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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 } diff --git a/spec/support/nokogiri_matchers.rb b/spec/support/nokogiri_matchers.rb new file mode 100644 index 00000000..d1630f60 --- /dev/null +++ b/spec/support/nokogiri_matchers.rb @@ -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 diff --git a/spec/views/inbox/_actions.html.haml_spec.rb b/spec/views/inbox/_actions.html.haml_spec.rb new file mode 100644 index 00000000..21caf679 --- /dev/null +++ b/spec/views/inbox/_actions.html.haml_spec.rb @@ -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 diff --git a/spec/views/inbox/_entry.html.haml_spec.rb b/spec/views/inbox/_entry.html.haml_spec.rb new file mode 100644 index 00000000..72243982 --- /dev/null +++ b/spec/views/inbox/_entry.html.haml_spec.rb @@ -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 diff --git a/spec/views/inbox/_push_settings.haml_spec.rb b/spec/views/inbox/_push_settings.haml_spec.rb new file mode 100644 index 00000000..6fa036f1 --- /dev/null +++ b/spec/views/inbox/_push_settings.haml_spec.rb @@ -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 diff --git a/spec/views/inbox/show.html.haml_spec.rb b/spec/views/inbox/show.html.haml_spec.rb new file mode 100644 index 00000000..fc157874 --- /dev/null +++ b/spec/views/inbox/show.html.haml_spec.rb @@ -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 diff --git a/spec/views/inbox/show.turbo_stream.haml_spec.rb b/spec/views/inbox/show.turbo_stream.haml_spec.rb new file mode 100644 index 00000000..f7a49245 --- /dev/null +++ b/spec/views/inbox/show.turbo_stream.haml_spec.rb @@ -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