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