From a97a36c202d551fadd5498407986534a8467994c Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 01:43:02 +0300 Subject: [PATCH 01/14] Switch feature post action to verify_moderator callback --- app/controllers/application_controller.rb | 7 +++++-- app/controllers/posts_controller.rb | 4 +--- config/locales/strings/en.users.yml | 2 ++ test/controllers/posts/feature_test.rb | 20 +++++++++++++------- test/test_helper.rb | 4 ++++ 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 62372c684..489c72dfe 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -398,11 +398,14 @@ def authenticate_user!(_fav = nil, **_opts) unless user_signed_in? respond_to do |format| format.html do - flash[:error] = 'You need to sign in or sign up to continue.' + flash[:error] = I18n.t('users.errors.unauthorized') redirect_to_sign_in end format.json do - render json: { error: 'You need to sign in or sign up to continue.' }, status: 401 + render json: { status: 'failed', + success: false, + error: I18n.t('users.errors.unauthorized') }, + status: :unauthorized end end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index eb7aea3a7..509dfea87 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -6,7 +6,7 @@ class PostsController < ApplicationController before_action :authenticate_user!, except: [:document, :help_center, :show] before_action :set_post, only: [:toggle_comments, :feature, :lock, :unlock, :legal_delete] before_action :set_scoped_post, only: [:change_category, :show, :edit, :update, :close, :reopen, :delete, :restore] - before_action :verify_moderator, only: [:toggle_comments] + before_action :verify_moderator, only: [:feature, :toggle_comments] before_action :edit_checks, only: [:edit, :update] before_action :unless_locked, only: [:edit, :update, :close, :reopen, :delete, :restore] before_action :verify_staff, only: [:legal_delete] @@ -612,8 +612,6 @@ def unlock end def feature - return not_found!(errors: ['no_privilege']) unless current_user&.at_least_moderator? - data = { label: @post.parent.nil? ? @post.title : @post.parent.title, link: helpers.generic_show_link(@post), diff --git a/config/locales/strings/en.users.yml b/config/locales/strings/en.users.yml index 5eccc0deb..734a74daa 100644 --- a/config/locales/strings/en.users.yml +++ b/config/locales/strings/en.users.yml @@ -9,6 +9,8 @@ en: Accounts using 2FA cannot be self-deleted. Disable 2FA first. self_delete_wrong_username: > The username you entered was incorrect. + unauthorized: > + You need to sign in or sign up to continue labels: view_abilities_self: View your abilities view_answers_self: View your answers diff --git a/test/controllers/posts/feature_test.rb b/test/controllers/posts/feature_test.rb index eeeaaed0a..fa3b5e69e 100644 --- a/test/controllers/posts/feature_test.rb +++ b/test/controllers/posts/feature_test.rb @@ -6,7 +6,7 @@ class PostsControllerTest < ActionController::TestCase test 'can feature post' do sign_in users(:moderator) before_audits = AuditLog.count - post :feature, params: { id: posts(:question_one).id } + try_feature posts(:question_one) assert_response(:success) assert_not_nil assigns(:post) @@ -15,16 +15,22 @@ class PostsControllerTest < ActionController::TestCase end test 'feature requires authentication' do - post :feature, params: { id: posts(:question_one).id } - assert_redirected_to_sign_in + try_feature posts(:question_one) + + assert_json_failure(:unauthorized) + assert_json_response_field('error', I18n.t('users.errors.unauthorized')) end test 'regular user cannot feature' do sign_in users(:deleter) - post :feature, params: { id: posts(:question_one).id, format: :json } + try_feature posts(:question_one) + + assert_json_failure(:not_found) + assert_json_response_field('errors', ['not_found']) + end - assert_response(:not_found) - assert_valid_json_response - assert_equal ['no_privilege'], JSON.parse(response.body)['errors'] + def try_feature(target) + post :feature, params: { id: target.id, + format: :json } end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 182979fd3..853c2da17 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -202,6 +202,10 @@ def assert_json_success(status: 'success') end end + def assert_json_response_field(field, expected) + assert_equal expected, JSON.parse(response.body)[field] + end + def assert_json_response_message(expected) assert_equal expected, JSON.parse(response.body)['message'] end From 0d5ab0f764dcadbbffd22fc657413a63da03f307 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 02:16:08 +0300 Subject: [PATCH 02/14] Add featured? predicate to Post model --- app/models/post.rb | 7 +++++++ test/models/post_test.rb | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/post.rb b/app/models/post.rb index c610cfd01..7a32286aa 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -7,6 +7,7 @@ class Post < ApplicationRecord include SoftDeletable include Timestamped include UserSortable + include Identity belongs_to :user, optional: true belongs_to :post_type @@ -243,6 +244,12 @@ def deleted_by_owner? deleted_by&.same_as?(user) end + # Is the post currently featured? + # @return [Boolean] check result + def featured? + PinnedLink.current.where(post: self).any? + end + # Is a given user following the post? # @param user [User] user to check # @return [Boolean] check result diff --git a/test/models/post_test.rb b/test/models/post_test.rb index dc3148b54..ff51c6a62 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -171,6 +171,12 @@ class PostTest < ActiveSupport::TestCase end end + test 'featured? should correctly determine if the post is featured' do + posts.each do |post| + assert_equal post.featured?, pinned_links.any? { |l| l.post&.same_as?(post) } + end + end + test 'top_level? should correctly determine if the post is top level' do post_types.each do |type| Post.where(post_type: type).each do |post| From 481facda4e6a9c4805a79e71b6f8cbc0a0f581c8 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 20:27:11 +0300 Subject: [PATCH 03/14] Add audit log helpers (static) to PinnedLink --- app/models/pinned_link.rb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/models/pinned_link.rb b/app/models/pinned_link.rb index 9f3217fe1..9831d4549 100644 --- a/app/models/pinned_link.rb +++ b/app/models/pinned_link.rb @@ -32,6 +32,34 @@ class PinnedLink < ApplicationRecord validate :check_period validate :check_post_or_url + # Creates an audit log for a pinned link create event + # @param user [User] user that created the link + # @param created [PinnedLink] created link + def self.audit_create(user, created) + attrs = @link.attributes_print + comment = "<>" + + AuditLog.moderator_audit(event_type: 'pinned_link_create', + related: created, + user: user, + comment: comment) + end + + # Creates an audit log for a pinned link update event + # @param user [User] user that updated the link + # @param before [PinnedLink] state before the update + # @param after [PinnedLink] state after the update + def self.audit_update(user, before, after) + old_attrs = before.attributes_print + new_attrs = after.attributes_print + comment = "from <>\nto <>" + + AuditLog.moderator_audit(event_type: 'pinned_link_update', + related: after, + user: user, + comment: comment) + end + # Is the link not timed or started in the past & hasn't ended yet? # @param now [DateTime, nil] timestamp to compare to # @return [Boolean] check result From b58c9da8f0ca630a6a81c95d88f8f1c7411ce0b1 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 21:27:24 +0300 Subject: [PATCH 04/14] featured? predicate should only check active links --- app/models/post.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/post.rb b/app/models/post.rb index 7a32286aa..4506da224 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -247,7 +247,7 @@ def deleted_by_owner? # Is the post currently featured? # @return [Boolean] check result def featured? - PinnedLink.current.where(post: self).any? + PinnedLink.current.where(post: self, active: true).any? end # Is a given user following the post? From d127c6b22115ef37521b8bf894790d57780df890 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 21:29:13 +0300 Subject: [PATCH 05/14] Fix typo in audit_create helper method for PinnedLink --- app/models/pinned_link.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/pinned_link.rb b/app/models/pinned_link.rb index 9831d4549..b52cb7480 100644 --- a/app/models/pinned_link.rb +++ b/app/models/pinned_link.rb @@ -36,7 +36,7 @@ class PinnedLink < ApplicationRecord # @param user [User] user that created the link # @param created [PinnedLink] created link def self.audit_create(user, created) - attrs = @link.attributes_print + attrs = created.attributes_print comment = "<>" AuditLog.moderator_audit(event_type: 'pinned_link_create', From bd6b66a55bbb74833a54f06c0960dccba61d2bd3 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 21:29:52 +0300 Subject: [PATCH 06/14] Add a proper partial for rending individual post tools --- app/views/posts/_post_tool.html.erb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/views/posts/_post_tool.html.erb diff --git a/app/views/posts/_post_tool.html.erb b/app/views/posts/_post_tool.html.erb new file mode 100644 index 000000000..2e90abb3d --- /dev/null +++ b/app/views/posts/_post_tool.html.erb @@ -0,0 +1,20 @@ +<%# + Renders a single post tool + + Variables: + post : the Post instance to render the tool for + name : unique name of the tool + method : HTTP method to use for the tool's action + %> + +
+ <%= t("posts.tools.#{name}") %> +

<%= t("posts.tools.#{name}_blurb") %>

+ <%= form_tag send("post_#{name}_url", post), + method: method, + remote: true, + class: "js-#{name}-post" do %> + <%= submit_tag t("posts.tools.#{name}_action"), + class: 'button is-filled' %> + <% end %> +
From 4e5a0509774b2be5a843b60982071437798690e6 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 21:37:51 +0300 Subject: [PATCH 07/14] Allow posts to be unfeatured from post tools --- app/assets/javascripts/moderator.js | 2 +- app/controllers/pinned_links_controller.rb | 23 +++----------- app/controllers/posts_controller.rb | 36 +++++++++++++++++----- app/views/posts/_post_tools.html.erb | 13 +++----- config/locales/strings/en.pinned_links.yml | 9 ++++++ config/locales/strings/en.posts.yml | 26 ++++++++++++++-- config/locales/strings/es.posts.yml | 2 -- config/routes.rb | 1 + test/models/post_test.rb | 2 +- 9 files changed, 74 insertions(+), 40 deletions(-) create mode 100644 config/locales/strings/en.pinned_links.yml diff --git a/app/assets/javascripts/moderator.js b/app/assets/javascripts/moderator.js index b8cd53bdc..5ed921252 100644 --- a/app/assets/javascripts/moderator.js +++ b/app/assets/javascripts/moderator.js @@ -1,5 +1,5 @@ $(() => { - $('.js-convert-to-comment, .js-toggle-comments, .js-feature-post, .js-lock').on('ajax:success', (_ev) => { + $('.js-convert-to-comment, .js-toggle-comments, .js-feature-post, .js-lock, .js-unfeature-post').on('ajax:success', (_ev) => { location.reload(); }); diff --git a/app/controllers/pinned_links_controller.rb b/app/controllers/pinned_links_controller.rb index 014866c06..562575d20 100644 --- a/app/controllers/pinned_links_controller.rb +++ b/app/controllers/pinned_links_controller.rb @@ -45,15 +45,8 @@ def create @link = PinnedLink.new(pinned_link_params) if @link.save - attr = @link.attributes_print - - AuditLog.moderator_audit(event_type: 'pinned_link_create', - related: @link, - user: current_user, - comment: "<>") - - flash[:success] = - 'Your pinned link has been created. Due to caching, it may take some time until it is shown.' + PinnedLink.audit_create(current_user, @link) + flash[:success] = helpers.i18ns('pinned_links.success.create') redirect_to pinned_links_path else render 'pinned_links/new', status: :bad_request @@ -63,17 +56,11 @@ def create def edit; end def update - before = @link.attributes_print + before = @link.dup if @link.update(pinned_link_params) - after = @link.attributes_print - - AuditLog.moderator_audit(event_type: 'pinned_link_update', - related: @link, - user: current_user, - comment: "from <>\nto <>") - - flash[:success] = 'The pinned link has been updated. Due to caching, it may take some time until it is shown.' + PinnedLink.audit_update(current_user, before, @link) + flash[:success] = helpers.i18ns('pinned_links.success.update') redirect_to pinned_links_path else render 'pinned_links/edit', status: :bad_request diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 509dfea87..f5d13a8a0 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -4,9 +4,9 @@ class PostsController < ApplicationController include DraftManagement before_action :authenticate_user!, except: [:document, :help_center, :show] - before_action :set_post, only: [:toggle_comments, :feature, :lock, :unlock, :legal_delete] + before_action :set_post, only: [:toggle_comments, :feature, :unfeature, :lock, :unlock, :legal_delete] before_action :set_scoped_post, only: [:change_category, :show, :edit, :update, :close, :reopen, :delete, :restore] - before_action :verify_moderator, only: [:feature, :toggle_comments] + before_action :verify_moderator, only: [:feature, :toggle_comments, :unfeature] before_action :edit_checks, only: [:edit, :update] before_action :unless_locked, only: [:edit, :update, :close, :reopen, :delete, :restore] before_action :verify_staff, only: [:legal_delete] @@ -619,15 +619,37 @@ def feature active: true, community: RequestContext.community } - @link = PinnedLink.create data - attr = @link.attributes_print - AuditLog.moderator_audit(event_type: 'pinned_link_create', related: @link, user: current_user, - comment: "<>\n(using moderator tools on post)") - flash[:success] = helpers.i18ns('posts.post_has_been_featured') + @link = PinnedLink.create(data) + + PinnedLink.audit_create(current_user, @link) + flash[:success] = helpers.i18ns('posts.success.feature') render json: { status: 'success', success: true } end + def unfeature + success = true + + ApplicationRecord.transaction do + PinnedLink.where(active: true, post: @post).each do |pinned_link| + before = pinned_link.dup + success = pinned_link.update(active: false) + + if success + PinnedLink.audit_update(current_user, before, pinned_link) + else + raise ActiveRecord::Rollback + end + end + end + + flash_key = success ? :success : :danger + i18ns_key = success ? 'success.unfeature' : 'errors.unfeature_generic' + flash[flash_key] = helpers.i18ns("posts.#{i18ns_key}") + status = success ? 'success' : 'failed' + render json: { status: status, success: success } + end + def save_draft base_key = do_save_draft(current_user, params[:path]) render json: { status: 'success', success: true, key: base_key } diff --git a/app/views/posts/_post_tools.html.erb b/app/views/posts/_post_tools.html.erb index 9c603a848..426c359c0 100644 --- a/app/views/posts/_post_tools.html.erb +++ b/app/views/posts/_post_tools.html.erb @@ -102,15 +102,10 @@ <% end %> <% if at_least_moderator? %> -
- Feature post -

- You can feature this post by linking it in the sidebar. You can edit the link options later in the moderator tools. -

- <%= form_tag post_feature_url(post), remote: true, class: 'js-feature-post' do %> - <%= submit_tag 'Feature post', class: 'button is-filled' %> - <% end %> -
+ <%= render 'post_tool', name: 'feature', method: :post, post: post %> + <% if post.featured? %> + <%= render 'post_tool', name: 'unfeature', method: :delete, post: post %> + <% end %> <% end %> <% if current_user&.privilege?('flag_curate') && top_level_post_types.include?(post.post_type_id) %>
diff --git a/config/locales/strings/en.pinned_links.yml b/config/locales/strings/en.pinned_links.yml new file mode 100644 index 000000000..fc5a97b05 --- /dev/null +++ b/config/locales/strings/en.pinned_links.yml @@ -0,0 +1,9 @@ +en: + pinned_links: + success: + create: > + Your pinned link has been created. + It may take some time for the changes to take effect due to caching. + update: > + The pinned link has been updated. + It may take some time for the changes to take effect due to caching. diff --git a/config/locales/strings/en.posts.yml b/config/locales/strings/en.posts.yml index 1a367d654..1e1b65c68 100644 --- a/config/locales/strings/en.posts.yml +++ b/config/locales/strings/en.posts.yml @@ -1,5 +1,29 @@ en: posts: + # TODO: align the rest with other local strings' structure + success: + feature: > + The post has been featured. + It may take some time for the changes to take effect due to caching. + unfeature: > + The post has been unfeatured. + It may take some time for the changes to take effect due to caching. + errors: + feature_generic: > + Failed to feature the post. + unfeature_generic: > + Failed to unfeature the post. + tools: + feature: Feature post + feature_action: Feature + feature_blurb: > + You can feature this post by linking it in the sidebar. + You can edit the link options later in the moderator tools. + unfeature: Unfeature post + unfeature_action: Unfeature + unfeature_blurb: > + This post will no longer be linked in the sidebar. + You can always feature it again in the moderator tools. #alert/notice create_requires_post: > Can't create a post without content. Please try again later or contact us. @@ -21,8 +45,6 @@ en: Can't reopen this post right now. Try again later. no_edit_changes: > No changes were saved because you didn't edit the post. - post_has_been_featured: > - Post has been featured. Due to caching, it may take some time until the changes apply. cant_delete_post: > Can't delete this post right now. Try again later. already_deleted: > diff --git a/config/locales/strings/es.posts.yml b/config/locales/strings/es.posts.yml index d59bb2d56..a786771dc 100644 --- a/config/locales/strings/es.posts.yml +++ b/config/locales/strings/es.posts.yml @@ -17,8 +17,6 @@ es: No se puede reabrir esta publicación ahora mismo. Inténtelo de nuevo más tarde. no_edit_changes: > Ningún cambio ha sido salvado porque usted no ha editado la publicación. - post_has_been_featured: > - La publicación ha sido destacada. Debido a cacheo, podría llevar algo de tiempo hasta que los cambios estén aplicados. cant_delete_post: > No se puede eliminar esta publicación ahora mismo. Inténtelo de nuevo más tarde. already_deleted: > diff --git a/config/routes.rb b/config/routes.rb index 2e5b5ce2a..fc6ac229e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -161,6 +161,7 @@ post ':id/lock', to: 'posts#lock', as: :post_lock post ':id/unlock', to: 'posts#unlock', as: :post_unlock post ':id/feature', to: 'posts#feature', as: :post_feature + delete ':id/feature', to: 'posts#unfeature', as: :post_unfeature post ':id/promote', to: 'moderator#nominate_promotion', as: :promote_post delete ':id/promote', to: 'moderator#remove_promotion', as: :remove_post_promotion delete ':id/legal-delete', to: 'posts#legal_delete', as: :legal_delete_post diff --git a/test/models/post_test.rb b/test/models/post_test.rb index ff51c6a62..e7e348a72 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -173,7 +173,7 @@ class PostTest < ActiveSupport::TestCase test 'featured? should correctly determine if the post is featured' do posts.each do |post| - assert_equal post.featured?, pinned_links.any? { |l| l.post&.same_as?(post) } + assert_equal(post.featured?, pinned_links.any? { |l| l.post&.same_as?(post) }) end end From e0b77f5afc5712a2dcc6dceff088459cda6461cf Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sat, 17 Oct 2026 21:49:15 +0300 Subject: [PATCH 08/14] Add a proper partial for pinned links sidebar widget --- app/views/layouts/_sidebar.html.erb | 33 +--------------------- app/views/pinned_links/_widget.html.erb | 37 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 32 deletions(-) create mode 100644 app/views/pinned_links/_widget.html.erb diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index b2908c621..734b282fa 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -13,39 +13,8 @@ <% end %> <% unless @community.is_fake %> - <%# Featured widget %> <% if Rails.env.development? || @pinned_links.to_a.size > 0 %> - <% has_timed = @pinned_links.any?(&:timed?) %> - <% cache_unless has_timed, @pinned_links do %> -
- <% if Rails.env.development? || @pinned_links.to_a.size > 0 %> -
Featured
- <% @pinned_links.each do |pl| %> -
- <% pl_link = pl.post.nil? ? pl.link : generic_share_link(pl.post) %> - <% pl_label = pl.post.nil? ? pl.label : (pl.post.parent.nil? ? pl.post.title : pl.post.parent.title) %> - <%= link_to pl_link, class: 'h-fw-bold' do %> - <%= pl_label %> - <% end %> - <% unless pl.shown_before.nil? %> -
- — - <% if !pl.shown_after.nil? %> - <% if pl.shown_after < DateTime.now %> - ends in <%= time_ago_in_words(pl.shown_before) %> - <% else %> - starts in <%= time_ago_in_words(pl.shown_after) %> - <% end %> - <% else %> - in <%= time_ago_in_words(pl.shown_before) %> - <% end %> -
- <% end %> -
- <% end %> - <% end %> -
- <% end %> + <%= render 'pinned_links/widget', links: @pinned_links %> <% end %> <%# Related Posts widget %> diff --git a/app/views/pinned_links/_widget.html.erb b/app/views/pinned_links/_widget.html.erb new file mode 100644 index 000000000..60565fed5 --- /dev/null +++ b/app/views/pinned_links/_widget.html.erb @@ -0,0 +1,37 @@ +<%# + Renders a widget listing currently pinned links + + Variables: + links : ActiveRecord::Relation collection of pinned links to display + %> + +<% has_timed = links.any?(&:timed?) %> + +<% cache_unless has_timed, links do %> +
+
Featured
+ <% links.each do |pl| %> +
+ <% pl_link = pl.post.nil? ? pl.link : generic_share_link(pl.post) %> + <% pl_label = pl.post.nil? ? pl.label : (pl.post.parent.nil? ? pl.post.title : pl.post.parent.title) %> + <%= link_to pl_link, class: 'h-fw-bold' do %> + <%= pl_label %> + <% end %> + <% unless pl.shown_before.nil? %> +
+ — + <% if !pl.shown_after.nil? %> + <% if pl.shown_after < DateTime.now %> + ends in <%= time_ago_in_words(pl.shown_before) %> + <% else %> + starts in <%= time_ago_in_words(pl.shown_after) %> + <% end %> + <% else %> + in <%= time_ago_in_words(pl.shown_before) %> + <% end %> +
+ <% end %> +
+ <% end %> +
+<% end %> From ae7e8fa1f481ef52f773f766ca1f83da01b51ff6 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 18 Oct 2026 00:26:30 +0300 Subject: [PATCH 09/14] pull_pinned_links_and_hot_questions should make use of the 'current' scope --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 489c72dfe..757f28cde 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -253,7 +253,7 @@ def setup_user def pull_pinned_links_and_hot_questions @pinned_links = Rails.cache.fetch_collection('pinned_links', expires_in: 2.hours) do Rack::MiniProfiler.step 'pinned_links: cache miss' do - PinnedLink.where(active: true).where('shown_before IS NULL OR shown_before > NOW()').all + PinnedLink.where(active: true).current.all end end From febabb7764cfc8116a819ddc0efb860cd4313f66 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 18 Oct 2026 20:14:08 +0300 Subject: [PATCH 10/14] Add tests for the unfeature action --- test/controllers/posts/unfeature_test.rb | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/controllers/posts/unfeature_test.rb diff --git a/test/controllers/posts/unfeature_test.rb b/test/controllers/posts/unfeature_test.rb new file mode 100644 index 000000000..22008569f --- /dev/null +++ b/test/controllers/posts/unfeature_test.rb @@ -0,0 +1,36 @@ +require 'test_helper' + +class PostsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + + test ':unfeature should require the user to be at least a moderator' do + post = posts(:question_one) + link = pinned_links(:active_with_post) + + users.each do |user| + PinnedLink.where(post: post).update_all(active: true) + sign_in(user) + + try_unfeature(post) + assert_not_equal user.at_least_moderator?, post.featured?, "Unexpected outcome for user '#{user.name}'" + + if user.at_least_moderator? + assert_audit_log('pinned_link_update', related: link) + end + + sign_out :user + end + end + + test ':unfeature should require authentication' do + try_unfeature posts(:question_one) + assert_json_failure(:unauthorized) + assert_json_response_field('error', I18n.t('users.errors.unauthorized')) + end + + # @param target [Post] post to unfeature + def try_unfeature(target) + delete :unfeature, params: { id: target.id, + format: :json } + end +end From 4b8d7720379cd7916b173d15e54615041f800be8 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 18 Oct 2026 22:15:58 +0300 Subject: [PATCH 11/14] Add minitest-stub_any_instance test dependency Needed for ease of testing cases that need internal overrdies --- Gemfile | 1 + Gemfile.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index 9a28e8662..603681f2a 100644 --- a/Gemfile +++ b/Gemfile @@ -92,6 +92,7 @@ gem 'premailer-rails', '~> 1.11' group :test do gem 'minitest', '~> 5.25.2' gem 'minitest-ci', '~> 3.4.0' + gem 'minitest-stub_any_instance', '~> 1.0.3' gem 'rails-controller-testing', '~> 1.0' gem 'simplecov', '~> 0.21' gem 'simplecov_json_formatter', '~> 0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 0dace8dd5..bd1f2dc5c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -268,6 +268,7 @@ GEM minitest (5.25.5) minitest-ci (3.4.0) minitest (>= 5.0.6) + minitest-stub_any_instance (1.0.3) mize (0.6.1) msgpack (1.8.3) mutex_m (0.3.0) @@ -581,6 +582,7 @@ DEPENDENCIES mime-types (~> 3.7) minitest (~> 5.25.2) minitest-ci (~> 3.4.0) + minitest-stub_any_instance (~> 1.0.3) msgpack (~> 1.8) mutex_m (~> 0.3) mysql2 (~> 0.5.4) From 650b7bd69fdfe15da083e321ec84a5494ba8bc1e Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 18 Oct 2026 22:16:44 +0300 Subject: [PATCH 12/14] :unfeature should at least set a generic error status on failure --- app/controllers/posts_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index f5d13a8a0..f39a4a382 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -644,10 +644,13 @@ def unfeature end flash_key = success ? :success : :danger + res_status = success ? :ok : :internal_server_error i18ns_key = success ? 'success.unfeature' : 'errors.unfeature_generic' flash[flash_key] = helpers.i18ns("posts.#{i18ns_key}") status = success ? 'success' : 'failed' - render json: { status: status, success: success } + + render json: { status: status, success: success }, + status: res_status end def save_draft From aa24208a0aa7c8c10ddf8e3c469381bd8a08cdf1 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 18 Oct 2026 22:17:25 +0300 Subject: [PATCH 13/14] Add test covering :unfeature update failures --- test/controllers/posts/unfeature_test.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/controllers/posts/unfeature_test.rb b/test/controllers/posts/unfeature_test.rb index 22008569f..d536480f9 100644 --- a/test/controllers/posts/unfeature_test.rb +++ b/test/controllers/posts/unfeature_test.rb @@ -28,6 +28,20 @@ class PostsControllerTest < ActionController::TestCase assert_json_response_field('error', I18n.t('users.errors.unauthorized')) end + test ':unfeature should correctly handle update failures' do + post = posts(:question_one) + link = pinned_links(:active_with_post) + user = users(:global_admin) + + link.stub(:update, false) do + PinnedLink.stub_any_instance(:update, false) do + sign_in users(:global_admin) + try_unfeature(post) + assert_json_failure(:internal_server_error) + end + end + end + # @param target [Post] post to unfeature def try_unfeature(target) delete :unfeature, params: { id: target.id, From 7248bb4fe2b446eee219a7d2e56913113801956f Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 18 Oct 2026 22:21:56 +0300 Subject: [PATCH 14/14] Minor rubocop fix --- test/controllers/posts/unfeature_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/controllers/posts/unfeature_test.rb b/test/controllers/posts/unfeature_test.rb index d536480f9..8749295c3 100644 --- a/test/controllers/posts/unfeature_test.rb +++ b/test/controllers/posts/unfeature_test.rb @@ -35,7 +35,7 @@ class PostsControllerTest < ActionController::TestCase link.stub(:update, false) do PinnedLink.stub_any_instance(:update, false) do - sign_in users(:global_admin) + sign_in(user) try_unfeature(post) assert_json_failure(:internal_server_error) end @@ -45,6 +45,6 @@ class PostsControllerTest < ActionController::TestCase # @param target [Post] post to unfeature def try_unfeature(target) delete :unfeature, params: { id: target.id, - format: :json } + format: :json } end end