Merge commit '6b976e0f'

This commit is contained in:
Kay Faraday 2023-02-20 04:04:04 +00:00
commit d0b8e8a82a
34 changed files with 734 additions and 84 deletions

View File

@ -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"

View File

@ -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)!

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 => {

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
en:
short:
unit: ""
thousand: "K"
million: "M"
billion: "B"
trillion: "T"

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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 = ""

View File

@ -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?) ||

View File

@ -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

View File

@ -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

View File

@ -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 &lt;h1&gt;a test&lt;/h1&gt;</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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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