0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-04 10:39:43 +08:00
discourse/spec/requests/nested_topics_controller_spec.rb
Mark VanLandingham acbdfa445f
FIX: Add small action controls to nested activity log (#42108)
Previously, small actions in nested replies' Activity Log modal could
not be edited, deleted, or recovered, and refreshes collapsed the modal.

This change renders the activity log as proper small action posts with
all the actions you expect from flat view.
2026-07-29 10:19:47 -05:00

1963 lines
69 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe NestedTopicsController, type: :request do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:admin)
fab!(:topic) { Fabricate(:topic, user: user, last_posted_at: Time.current) }
fab!(:op) { Fabricate(:post, topic: topic, user: user, post_number: 1) }
before { SiteSetting.nested_replies_enabled = true }
def show_url(topic, page: 0, sort: "top")
"/n/#{topic.slug}/#{topic.id}.json?page=#{page}&sort=#{sort}"
end
def children_url(topic, post_number, page: 0, sort: "top", depth: 1)
"/n/#{topic.slug}/#{topic.id}/children/#{post_number}.json?page=#{page}&sort=#{sort}&depth=#{depth}"
end
def context_url(topic, post_number, sort: "top", context: nil)
url = "/n/#{topic.slug}/#{topic.id}/context/#{post_number}.json?sort=#{sort}"
url += "&context=#{context}" if context
url
end
def set_cached_hot_scores(score_by_post)
score_by_post.each { |post, score| DB.exec(<<~SQL, post_id: post.id, score: score) }
UPDATE nested_hot_post_scores
SET hot_score = :score,
thread_hot_score = :score
WHERE post_id = :post_id
SQL
end
describe "GET respond" do
it "redirects crawlers to the flat topic view" do
get "/n/#{topic.slug}/#{topic.id}", headers: { "HTTP_USER_AGENT" => "Googlebot" }
expect(response).to redirect_to("/t/#{topic.slug}/#{topic.id}")
expect(response.status).to eq(301)
end
it "redirects browser requests to the canonical topic route" do
get "/n/#{topic.slug}/#{topic.id}"
expect(response).to redirect_to("/t/#{topic.slug}/#{topic.id}")
expect(response.status).to eq(302)
end
it "redirects browser context requests to the canonical topic route and preserves nested query params" do
get "/n/#{topic.slug}/#{topic.id}/5",
params: {
sort: "new",
context: "0",
collapse_replies: "true",
embed_mode: "true",
class_name: "lee-af",
ignored: "drop-me",
}
expect(response).to redirect_to(
"/t/#{topic.slug}/#{topic.id}/5?class_name=lee-af&collapse_replies=true&context=0&embed_mode=true&sort=new",
)
expect(response.status).to eq(302)
end
it "redirects crawlers to the flat topic view with post number" do
get "/n/#{topic.slug}/#{topic.id}/5", headers: { "HTTP_USER_AGENT" => "Googlebot" }
expect(response).to redirect_to("/t/#{topic.slug}/#{topic.id}/5")
expect(response.status).to eq(301)
end
it "returns 404 for anonymous users on private topics" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
private_topic = Fabricate(:topic, category: private_category)
Fabricate(:post, topic: private_topic, post_number: 1)
get "/n/#{private_topic.slug}/#{private_topic.id}"
expect(response.status).to eq(404)
end
it "redirects private messages to flat view" do
pm = Fabricate(:private_message_topic, user: user)
Fabricate(:post, topic: pm, user: user, post_number: 1)
sign_in(user)
get "/n/#{pm.slug}/#{pm.id}"
expect(response).to redirect_to("/t/#{pm.slug}/#{pm.id}")
expect(response.status).to eq(302)
end
it "redirects private messages to flat view with post number" do
pm = Fabricate(:private_message_topic, user: user)
Fabricate(:post, topic: pm, user: user, post_number: 1)
sign_in(user)
get "/n/#{pm.slug}/#{pm.id}/5"
expect(response).to redirect_to("/t/#{pm.slug}/#{pm.id}/5")
expect(response.status).to eq(302)
end
end
describe "GET show" do
it "returns 404 when plugin is disabled" do
SiteSetting.nested_replies_enabled = false
sign_in(user)
get show_url(topic)
expect(response.status).to eq(404)
end
it "redirects private messages to flat view" do
pm = Fabricate(:private_message_topic, user: user)
Fabricate(:post, topic: pm, user: user, post_number: 1)
sign_in(user)
get show_url(pm)
expect(response).to redirect_to("/t/#{pm.slug}/#{pm.id}")
end
it "returns 404 for anonymous users on private topics" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
private_topic = Fabricate(:topic, category: private_category)
Fabricate(:post, topic: private_topic, post_number: 1)
get show_url(private_topic)
expect(response.status).to eq(404)
end
it "returns 404 for signed-in users who cannot see the topic" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
private_topic = Fabricate(:topic, category: private_category)
Fabricate(:post, topic: private_topic, post_number: 1)
sign_in(user)
get show_url(private_topic)
expect(response.status).to eq(404)
end
it "returns topic metadata and OP on initial load (page 0)" do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
sign_in(user)
get show_url(topic, page: 0)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json).to have_key("topic")
expect(json).to have_key("op_post")
expect(json).to have_key("sort")
expect(json).to have_key("message_bus_last_id")
expect(json["roots"].length).to eq(2)
expect(json["page"]).to eq(0)
end
it "keeps hot selected while a missing snapshot safely uses top and requests a refresh" do
SiteSetting.nested_replies_hot_sort_enabled = true
Fabricate(:nested_topic, topic: topic)
low_root =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 1)
high_root =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 10)
3.times { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
topic.update_columns(posts_count: 6)
NestedReplies::HotScoreQueue.clear
sign_in(user)
get show_url(topic, sort: "hot")
expect(response.status).to eq(200)
expect(response.parsed_body["sort"]).to eq("hot")
expect(response.parsed_body["effective_sort"]).to eq("top")
expect(response.parsed_body["roots"].map { |root| root["id"] }.first(2)).to eq(
[high_root.id, low_root.id],
)
expect(NestedReplies::HotScoreQueue.pop).to eq(topic.id)
ensure
NestedReplies::HotScoreQueue.clear
end
it "orders a deleted placeholder by the heat of its public descendant" do
SiteSetting.nested_replies_hot_sort_enabled = true
Fabricate(:nested_topic, topic: topic)
deleted_root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
hot_child =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: deleted_root.post_number)
liked_root =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 20)
2.times { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
deleted_root.update_columns(deleted_at: Time.current)
hot_child.update_columns(like_score: 100, created_at: 1.hour.ago)
liked_root.update_columns(like_score: 10, created_at: 1.hour.ago)
topic.update_columns(posts_count: 6)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
sign_in(user)
get show_url(topic, sort: "hot")
expect(response.status).to eq(200)
deleted_root_json = response.parsed_body["roots"].first
expect(deleted_root_json["id"]).to eq(deleted_root.id)
expect(deleted_root_json["deleted_post_placeholder"]).to eq(true)
expect(deleted_root_json["children"].map { |child| child["id"] }).to eq([hot_child.id])
end
it "spends the hot preload budget on the strongest branch" do
SiteSetting.nested_replies_hot_sort_enabled = true
Fabricate(:nested_topic, topic: topic)
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
hot_child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
cold_child =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
hot_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: hot_child.post_number)
hot_great_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: hot_grandchild.post_number)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: cold_child.post_number)
topic.update_columns(posts_count: 7)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
set_cached_hot_scores(
root => 100.0,
hot_child => 100.0,
cold_child => 40.0,
hot_grandchild => 100.0,
hot_great_grandchild => 100.0,
)
sign_in(user)
stub_const(NestedReplies::TreeLoader, :HOT_PRELOAD_POST_BUDGET, 4) do
stub_const(NestedReplies::TreeLoader, :HOT_PRELOAD_PER_ROOT_BUDGET, 4) do
get show_url(topic, sort: "hot")
end
end
root_json = response.parsed_body["roots"].find { |json_root| json_root["id"] == root.id }
hot_child_json = root_json["children"].find { |child| child["id"] == hot_child.id }
cold_child_json = root_json["children"].find { |child| child["id"] == cold_child.id }
hot_grandchild_json =
hot_child_json["children"].find { |child| child["id"] == hot_grandchild.id }
expect(root_json["children"].map { |child| child["id"] }).to eq([hot_child.id, cold_child.id])
expect(hot_child_json["children"].map { |child| child["id"] }).to eq([hot_grandchild.id])
expect(hot_grandchild_json["children"].map { |child| child["id"] }).to eq(
[hot_great_grandchild.id],
)
expect(cold_child_json["children"]).to eq([])
end
it "uses depth penalty to return to another hot sibling branch" do
SiteSetting.nested_replies_hot_sort_enabled = true
SiteSetting.nested_replies_hot_preload_depth_penalty = 1.0
Fabricate(:nested_topic, topic: topic)
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
first_child =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
second_child =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
first_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: first_child.post_number)
second_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: second_child.post_number)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: first_grandchild.post_number)
topic.update_columns(posts_count: 7)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
set_cached_hot_scores(
root => 100.0,
first_child => 100.0,
second_child => 99.9,
first_grandchild => 100.0,
second_grandchild => 99.9,
)
sign_in(user)
stub_const(NestedReplies::TreeLoader, :HOT_PRELOAD_POST_BUDGET, 4) do
stub_const(NestedReplies::TreeLoader, :HOT_PRELOAD_PER_ROOT_BUDGET, 4) do
get show_url(topic, sort: "hot")
end
end
root_json = response.parsed_body["roots"].find { |json_root| json_root["id"] == root.id }
first_child_json = root_json["children"].find { |child| child["id"] == first_child.id }
second_child_json = root_json["children"].find { |child| child["id"] == second_child.id }
first_grandchild_json =
first_child_json["children"].find { |child| child["id"] == first_grandchild.id }
expect(first_grandchild_json["children"]).to eq([])
expect(second_child_json["children"].map { |child| child["id"] }).to eq(
[second_grandchild.id],
)
end
it "caps each hot root branch and spends the remaining budget on other roots" do
SiteSetting.nested_replies_hot_sort_enabled = true
Fabricate(:nested_topic, topic: topic)
hottest_root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
other_root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
hottest_child =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: hottest_root.post_number)
hottest_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: hottest_child.post_number)
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: hottest_grandchild.post_number,
)
other_child =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: other_root.post_number)
other_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: other_child.post_number)
topic.update_columns(posts_count: 8)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
set_cached_hot_scores(
hottest_root => 100.0,
hottest_child => 100.0,
hottest_grandchild => 100.0,
other_root => 90.0,
other_child => 90.0,
other_grandchild => 90.0,
)
sign_in(user)
stub_const(NestedReplies::TreeLoader, :HOT_PRELOAD_POST_BUDGET, 4) do
stub_const(NestedReplies::TreeLoader, :HOT_PRELOAD_PER_ROOT_BUDGET, 2) do
get show_url(topic, sort: "hot")
end
end
roots = response.parsed_body["roots"]
hottest_root_json = roots.find { |json_root| json_root["id"] == hottest_root.id }
hottest_child_json =
hottest_root_json["children"].find { |child| child["id"] == hottest_child.id }
hottest_grandchild_json =
hottest_child_json["children"].find { |child| child["id"] == hottest_grandchild.id }
other_root_json = roots.find { |json_root| json_root["id"] == other_root.id }
other_child_json = other_root_json["children"].find { |child| child["id"] == other_child.id }
expect(hottest_child_json["children"].map { |child| child["id"] }).to eq(
[hottest_grandchild.id],
)
expect(hottest_grandchild_json["children"]).to eq([])
expect(other_child_json["children"].map { |child| child["id"] }).to eq([other_grandchild.id])
end
it "preloads replies created after the hot snapshot" do
SiteSetting.nested_replies_hot_sort_enabled = true
Fabricate(:nested_topic, topic: topic)
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
4.times { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
topic.update_columns(posts_count: 6)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
new_child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get show_url(topic, sort: "hot")
root_json = response.parsed_body["roots"].find { |json_root| json_root["id"] == root.id }
expect(root_json["children"].map { |child| child["id"] }).to eq([new_child.id])
end
it "preloads visible whisper branches for whisperers when sorting by hot" do
SiteSetting.nested_replies_hot_sort_enabled = true
SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}"
Fabricate(:nested_topic, topic: topic)
regular_root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
whisper_child =
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: regular_root.post_number,
post_type: Post.types[:whisper],
)
whisper_root =
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: nil,
post_type: Post.types[:whisper],
)
whisper_grandchild =
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: whisper_root.post_number,
post_type: Post.types[:whisper],
)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
topic.update_columns(posts_count: 6)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
sign_in(admin)
get show_url(topic, sort: "hot")
roots = response.parsed_body["roots"]
regular_root_json = roots.find { |root| root["id"] == regular_root.id }
whisper_root_json = roots.find { |root| root["id"] == whisper_root.id }
expect(regular_root_json["children"].map { |child| child["id"] }).to eq([whisper_child.id])
expect(whisper_root_json["children"].map { |child| child["id"] }).to eq(
[whisper_grandchild.id],
)
end
it "piggybacks suggested topics at the top level when the first page is the last page" do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
suggested = Fabricate(:post).topic
sign_in(user)
get show_url(topic, page: 0)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["has_more_roots"]).to eq(false)
expect(json["topic"]).not_to have_key("suggested_topics")
expect(json).to have_key("suggested_topics")
expect(json["suggested_topics"].map { |t| t["id"] }).to include(suggested.id)
end
it "omits suggested topics on page 0 when there are more pages to load" do
(NestedReplies::TreeLoader::ROOTS_PER_PAGE + 1).times do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
end
Fabricate(:post).topic
sign_in(user)
get show_url(topic, page: 0)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["has_more_roots"]).to eq(true)
expect(json).not_to have_key("suggested_topics")
expect(json["topic"]).not_to have_key("suggested_topics")
end
it "piggybacks suggested topics on the final loadMore page" do
(NestedReplies::TreeLoader::ROOTS_PER_PAGE + 1).times do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
end
suggested = Fabricate(:post).topic
sign_in(user)
get show_url(topic, page: 1)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["has_more_roots"]).to eq(false)
expect(json).not_to have_key("topic")
expect(json).to have_key("suggested_topics")
expect(json["suggested_topics"].map { |t| t["id"] }).to include(suggested.id)
end
it "does not include topic metadata on subsequent pages" do
25.times { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
sign_in(user)
get show_url(topic, page: 1)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json).not_to have_key("topic")
expect(json).not_to have_key("op_post")
expect(json["page"]).to eq(1)
end
describe "topic.has_activity_log" do
it "is false when the topic has no small actions or whispers with action codes" do
sign_in(user)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(false)
end
it "is true when the topic has a visible small_action post" do
topic.add_small_action(admin, "closed.enabled")
sign_in(user)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(true)
end
it "ignores whisper action-code posts for non-whisperers" do
SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}"
topic.add_moderator_post(
admin,
nil,
post_type: Post.types[:whisper],
action_code: "assigned",
custom_fields: {
"action_code_who" => user.username,
},
)
sign_in(user)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(false)
end
it "is true when whisperers have a whisper action-code post" do
SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}"
topic.add_moderator_post(
admin,
nil,
post_type: Post.types[:whisper],
action_code: "assigned",
custom_fields: {
"action_code_who" => user.username,
},
)
sign_in(admin)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(true)
end
it "does not leak the existence of hidden small_actions to non-staff" do
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "closed.enabled",
hidden: true,
hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached],
)
sign_in(user)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(false)
end
it "is true for staff when the only small_action is hidden" do
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "closed.enabled",
hidden: true,
hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached],
)
sign_in(admin)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(true)
end
it "stays true for staff when the only small_action is deleted and recoverable" do
action = Fabricate(:small_action, topic: topic, user: admin, action_code: "closed.enabled")
action.trash!(admin)
sign_in(admin)
get show_url(topic, page: 0)
expect(response.parsed_body["topic"]["has_activity_log"]).to eq(true)
end
end
it "paginates with has_more_roots" do
NestedReplies::TreeLoader::ROOTS_PER_PAGE.times do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
end
sign_in(user)
get show_url(topic, page: 0)
json = response.parsed_body
expect(json["has_more_roots"]).to eq(true)
expect(json["roots"].length).to eq(NestedReplies::TreeLoader::ROOTS_PER_PAGE)
end
it "returns has_more_roots false on last page" do
5.times { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
sign_in(user)
get show_url(topic, page: 0)
json = response.parsed_body
expect(json["has_more_roots"]).to eq(false)
end
it "validates sort parameter and falls back to default" do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
sign_in(user)
get show_url(topic, sort: "invalid")
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["sort"]).to eq(SiteSetting.nested_replies_default_sort)
end
it "uses site setting default when no sort param is provided" do
SiteSetting.nested_replies_default_sort = "old"
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
sign_in(user)
get "/n/#{topic.slug}/#{topic.id}.json"
json = response.parsed_body
expect(json["sort"]).to eq("old")
end
it "sorts by top (like_count desc)" do
low = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 1)
high = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 10)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids).to eq([high.id, low.id])
end
it "sorts by new (created_at desc)" do
old_post =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: nil,
created_at: 2.days.ago,
)
new_post =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: nil,
created_at: 1.hour.ago,
)
sign_in(user)
get show_url(topic, sort: "new")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids).to eq([new_post.id, old_post.id])
end
it "sorts by old (post_number asc)" do
first = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
second = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
sign_in(user)
get show_url(topic, sort: "old")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids).to eq([first.id, second.id])
end
it "preloads children in the response" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
root_json = json["roots"].first
expect(root_json["children"]).to be_an(Array)
expect(root_json["children"].length).to eq(1)
expect(root_json["children"].first["id"]).to eq(child.id)
end
it "sorts preloaded children consistently with roots" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
low_child =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
like_count: 1,
)
high_child =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
like_count: 10,
)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
children_ids = json["roots"].first["children"].map { |c| c["id"] }
expect(children_ids).to eq([high_child.id, low_child.id])
get show_url(topic, sort: "old")
json = response.parsed_body
children_ids = json["roots"].first["children"].map { |c| c["id"] }
expect(children_ids).to eq([low_child.id, high_child.id])
end
it "includes direct_reply_count and total_descendant_count" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get show_url(topic)
json = response.parsed_body
root_json = json["roots"].first
expect(root_json).to have_key("direct_reply_count")
expect(root_json["direct_reply_count"]).to eq(1)
end
describe "deleted post placeholders" do
it "shows deleted root as placeholder for non-staff" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
root.update!(deleted_at: Time.current)
sign_in(user)
get show_url(topic)
json = response.parsed_body
root_json = json["roots"].find { |r| r["id"] == root.id }
expect(root_json).to be_present
expect(root_json["deleted_post_placeholder"]).to eq(true)
expect(root_json["cooked"]).to eq("")
expect(root_json["raw"]).to be_nil
expect(root_json["actions_summary"]).to eq([])
end
it "preserves children under deleted root for non-staff" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
root.update!(deleted_at: Time.current)
sign_in(user)
get show_url(topic)
json = response.parsed_body
root_json = json["roots"].find { |r| r["id"] == root.id }
expect(root_json["children"]).to be_an(Array)
expect(root_json["children"].length).to eq(1)
expect(root_json["children"].first["id"]).to eq(child.id)
end
it "shows deleted root as placeholder for staff but preserves content" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
root.update!(deleted_at: Time.current)
sign_in(admin)
get show_url(topic)
json = response.parsed_body
root_json = json["roots"].find { |r| r["id"] == root.id }
expect(root_json).to be_present
expect(root_json["deleted_post_placeholder"]).to eq(true)
expect(root_json["cooked"]).to be_present
expect(root_json["cooked"]).not_to eq("")
end
it "lets staff view a fully-deleted topic so they can recover it" do
PostDestroyer.new(admin, op).destroy
topic.reload
expect(topic.deleted_at).to be_present
sign_in(admin)
get show_url(topic)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["op_post"]).to be_present
expect(json["op_post"]["deleted_post_placeholder"]).to eq(true)
end
it "returns 404 for non-staff on a fully-deleted topic" do
PostDestroyer.new(admin, op).destroy
topic.reload
sign_in(user)
get show_url(topic)
expect(response.status).to eq(404)
end
end
describe "pinned replies" do
fab!(:low_post) do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 1)
end
fab!(:high_post) do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 10)
end
fab!(:nested_topic_record) { Fabricate(:nested_topic, topic: topic) }
def pin_posts(*posts)
nested_topic_record.update!(pinned_post_ids: posts.map(&:id))
end
it "places pinned replies first regardless of sort" do
pin_posts(low_post)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids.first).to eq(low_post.id)
expect(json["pinned_post_ids"]).to contain_exactly(low_post.id)
end
it "does not include pinned_post_ids when none are pinned" do
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
expect(json).not_to have_key("pinned_post_ids")
end
it "fetches a pinned reply even when it would be on a later page" do
19.times do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 5)
end
pin_posts(low_post)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids.first).to eq(low_post.id)
end
it "does not promote a deleted post to pinned position" do
low_post.update!(deleted_at: Time.current)
pin_posts(low_post)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids.first).not_to eq(low_post.id)
end
it "ignores a pinned post_id that does not exist" do
nested_topic_record.update!(pinned_post_ids: [99_999])
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
expect(response.status).to eq(200)
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids.first).to eq(high_post.id)
end
it "does not pin on subsequent pages" do
25.times do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 5)
end
pin_posts(low_post)
sign_in(user)
get show_url(topic, page: 1, sort: "top")
json = response.parsed_body
expect(json).not_to have_key("pinned_post_ids")
end
it "places multiple pinned replies first in pin order" do
pin_posts(low_post, high_post)
sign_in(user)
get show_url(topic, sort: "top")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids[0]).to eq(low_post.id)
expect(root_ids[1]).to eq(high_post.id)
end
end
end
describe "PUT pin" do
fab!(:root_post) { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
before { Fabricate(:nested_topic, topic: topic) }
def pin_url(topic)
"/n/#{topic.slug}/#{topic.id}/pin.json"
end
it "returns 403 for non-staff users" do
sign_in(user)
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(403)
end
it "allows moderators to pin a post" do
sign_in(Fabricate(:moderator))
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(200)
expect(response.parsed_body["pinned_post_ids"]).to contain_exactly(root_post.id)
end
it "allows staff to pin a post" do
sign_in(admin)
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["pinned_post_ids"]).to contain_exactly(root_post.id)
topic.reload
expect(topic.nested_topic.pinned_post_ids).to contain_exactly(root_post.id)
end
it "allows staff to unpin a post by toggling" do
topic.reload.nested_topic.update!(pinned_post_ids: [root_post.id])
sign_in(admin)
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["pinned_post_ids"]).to eq([])
topic.reload
expect(topic.nested_topic.pinned_post_ids).to eq([])
end
it "returns 404 for a nonexistent post_id" do
sign_in(admin)
put pin_url(topic), params: { post_id: 99_999 }
expect(response.status).to eq(404)
end
it "returns 404 when no post_id is provided" do
sign_in(admin)
put pin_url(topic)
expect(response.status).to eq(404)
end
it "returns 400 when pinning a non-root post" do
child_post =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root_post.post_number)
sign_in(admin)
put pin_url(topic), params: { post_id: child_post.id }
expect(response.status).to eq(400)
end
it "persists the pin so that roots returns it first" do
high_post =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 10)
sign_in(admin)
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(200)
get show_url(topic, sort: "top")
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids.first).to eq(root_post.id)
expect(json["pinned_post_ids"]).to contain_exactly(root_post.id)
end
it "allows pinning multiple posts" do
second_root =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil, like_count: 10)
sign_in(admin)
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(200)
put pin_url(topic), params: { post_id: second_root.id }
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["pinned_post_ids"]).to contain_exactly(root_post.id, second_root.id)
end
it "rejects pinning when 10 posts are already pinned" do
posts = 10.times.map { Fabricate(:post, topic: topic, reply_to_post_number: nil) }
topic.nested_topic.update!(pinned_post_ids: posts.map(&:id))
extra = Fabricate(:post, topic: topic, reply_to_post_number: nil)
sign_in(admin)
put pin_url(topic), params: { post_id: extra.id }
expect(response.status).to eq(400)
end
it "lazily creates a NestedTopic record when nested_replies_default is on" do
topic.nested_topic.destroy!
SiteSetting.nested_replies_default = true
sign_in(admin)
put pin_url(topic), params: { post_id: root_post.id }
expect(response.status).to eq(200)
topic.reload
expect(topic.nested_topic).to be_present
expect(topic.nested_topic.pinned_post_ids).to contain_exactly(root_post.id)
end
end
describe "whisper visibility" do
before { SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}" }
fab!(:whisper) do
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: nil,
post_type: Post.types[:whisper],
)
end
it "excludes whispers for regular users" do
sign_in(user)
get show_url(topic)
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids).not_to include(whisper.id)
end
it "includes whispers for staff" do
sign_in(admin)
get show_url(topic)
json = response.parsed_body
root_ids = json["roots"].map { |r| r["id"] }
expect(root_ids).to include(whisper.id)
end
it "excludes whisper children for regular users" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
whisper_child =
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: root.post_number,
post_type: Post.types[:whisper],
)
sign_in(user)
get children_url(topic, root.post_number)
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).not_to include(whisper_child.id)
end
it "includes whisper children for staff" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
whisper_child =
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: root.post_number,
post_type: Post.types[:whisper],
)
sign_in(admin)
get children_url(topic, root.post_number)
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).to include(whisper_child.id)
end
end
describe "whisper reply count visibility" do
before { SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}" }
it "excludes whisper from reply counts for regular users" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: root.post_number,
post_type: Post.types[:whisper],
)
sign_in(user)
get show_url(topic)
json = response.parsed_body
root_json = json["roots"].find { |r| r["id"] == root.id }
expect(root_json["direct_reply_count"]).to eq(1)
expect(root_json["total_descendant_count"]).to eq(1)
end
it "includes whisper in reply counts for staff" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
Fabricate(
:post,
topic: topic,
user: admin,
reply_to_post_number: root.post_number,
post_type: Post.types[:whisper],
)
sign_in(admin)
get show_url(topic)
json = response.parsed_body
root_json = json["roots"].find { |r| r["id"] == root.id }
expect(root_json["direct_reply_count"]).to eq(2)
expect(root_json["total_descendant_count"]).to eq(2)
end
end
describe "GET children" do
fab!(:root) { Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil) }
it "returns children of a post" do
child1 = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
child2 = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get children_url(topic, root.post_number)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["children"].length).to eq(2)
expect(json["page"]).to eq(0)
end
it "paginates children" do
50.times do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
end
sign_in(user)
get children_url(topic, root.post_number, page: 0)
json = response.parsed_body
expect(json["has_more"]).to eq(true)
expect(json["children"].length).to eq(50)
end
it "returns has_more false when fewer than page size" do
3.times { Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number) }
sign_in(user)
get children_url(topic, root.post_number)
json = response.parsed_body
expect(json["has_more"]).to eq(false)
end
describe "sorting" do
it "sorts children by top (like_count desc)" do
low =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
like_count: 1,
)
high =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
like_count: 10,
)
sign_in(user)
get children_url(topic, root.post_number, sort: "top")
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).to eq([high.id, low.id])
end
it "sorts children by new (created_at desc)" do
old_child =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
created_at: 2.days.ago,
)
new_child =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
created_at: 1.hour.ago,
)
sign_in(user)
get children_url(topic, root.post_number, sort: "new")
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).to eq([new_child.id, old_child.id])
end
it "sorts children by old (post_number asc)" do
first = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
second = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get children_url(topic, root.post_number, sort: "old")
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).to eq([first.id, second.id])
end
it "sorts children by a fresh hot snapshot" do
SiteSetting.nested_replies_hot_sort_enabled = true
Fabricate(:nested_topic, topic: topic)
liked_child =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: root.post_number,
like_count: 20,
)
hot_branch =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
hot_grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: hot_branch.post_number)
liked_child.update_columns(like_score: 10, created_at: 1.hour.ago)
hot_grandchild.update_columns(like_score: 100, created_at: 1.hour.ago)
topic.update_columns(posts_count: 6)
NestedReplies::HotScoreCalculator.recalculate_topic(topic.id)
sign_in(user)
get children_url(topic, root.post_number, sort: "hot")
child_ids = response.parsed_body["children"].map { |child| child["id"] }
expect(child_ids).to eq([hot_branch.id, liked_child.id])
end
it "respects sort at max nesting depth" do
SiteSetting.nested_replies_max_depth = 2
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
low_grandchild =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: child.post_number,
like_count: 1,
)
high_grandchild =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: child.post_number,
like_count: 10,
)
sign_in(user)
get children_url(topic, child.post_number, sort: "top", depth: 2)
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).to eq([high_grandchild.id, low_grandchild.id])
end
it "sorts flattened descendants when cap is enabled" do
SiteSetting.nested_replies_cap_nesting_depth = true
SiteSetting.nested_replies_max_depth = 2
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
low_gc =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: child.post_number,
like_count: 1,
)
high_gc =
Fabricate(
:post,
topic: topic,
user: user,
reply_to_post_number: child.post_number,
like_count: 10,
)
sign_in(user)
get children_url(topic, child.post_number, sort: "top", depth: 2)
json = response.parsed_body
child_ids = json["children"].map { |c| c["id"] }
expect(child_ids).to eq([high_gc.id, low_gc.id])
end
end
it "flattens descendants at max depth when cap is enabled" do
SiteSetting.nested_replies_cap_nesting_depth = true
SiteSetting.nested_replies_max_depth = 2
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
sign_in(user)
get children_url(topic, child.post_number, depth: 2)
json = response.parsed_body
child_json = json["children"].find { |c| c["id"] == grandchild.id }
expect(child_json).to be_present
expect(child_json["children"]).to eq([])
end
it "uses live descendant counts when stat rows are missing" do
SiteSetting.nested_replies_cap_nesting_depth = true
SiteSetting.nested_replies_max_depth = 3
parent = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: parent.post_number)
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
NestedViewPostStat.where(post_id: [parent.id, child.id]).delete_all
sign_in(user)
get children_url(topic, root.post_number, depth: 2)
json = response.parsed_body
parent_json = json["children"].find { |post_json| post_json["id"] == parent.id }
child_json = parent_json["children"].find { |post_json| post_json["id"] == child.id }
expect(parent_json["total_descendant_count"]).to eq(2)
expect(child_json["direct_reply_count"]).to eq(1)
expect(child_json["total_descendant_count"]).to eq(1)
expect(child_json["children"]).to eq([])
end
it "paginates flattened descendants inside the CTE" do
SiteSetting.nested_replies_cap_nesting_depth = true
SiteSetting.nested_replies_max_depth = 2
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
grandchildren =
3.times.map do
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
end
sign_in(user)
stub_const(NestedReplies::TreeLoader, :CHILDREN_PER_PAGE, 2) do
get children_url(topic, child.post_number, depth: 2, page: 0)
page0 = response.parsed_body
expect(page0["children"].length).to eq(2)
expect(page0["has_more"]).to eq(true)
get children_url(topic, child.post_number, depth: 2, page: 1)
page1 = response.parsed_body
expect(page1["children"].length).to eq(1)
all_ids = page0["children"].map { |c| c["id"] } + page1["children"].map { |c| c["id"] }
expect(all_ids).to match_array(grandchildren.map(&:id))
end
end
describe "deleted post placeholders" do
it "shows deleted child as placeholder for non-staff" do
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
child.update!(deleted_at: Time.current)
sign_in(user)
get children_url(topic, root.post_number)
json = response.parsed_body
child_json = json["children"].find { |c| c["id"] == child.id }
expect(child_json).to be_present
expect(child_json["deleted_post_placeholder"]).to eq(true)
expect(child_json["cooked"]).to eq("")
expect(child_json["raw"]).to be_nil
expect(child_json["actions_summary"]).to eq([])
end
it "preserves children of a deleted post" do
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
child.update!(deleted_at: Time.current)
sign_in(user)
get children_url(topic, root.post_number)
json = response.parsed_body
child_json = json["children"].find { |c| c["id"] == child.id }
expect(child_json).to be_present
expect(child_json["deleted_post_placeholder"]).to eq(true)
expect(child_json["children"]).to be_an(Array)
expect(child_json["children"].length).to eq(1)
expect(child_json["children"].first["id"]).to eq(grandchild.id)
end
it "shows deleted child as placeholder for staff but preserves content" do
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
child.update!(deleted_at: Time.current)
sign_in(admin)
get children_url(topic, root.post_number)
json = response.parsed_body
child_json = json["children"].find { |c| c["id"] == child.id }
expect(child_json).to be_present
expect(child_json["deleted_post_placeholder"]).to eq(true)
expect(child_json["cooked"]).to be_present
expect(child_json["cooked"]).not_to eq("")
end
end
it "returns 404 when plugin is disabled" do
SiteSetting.nested_replies_enabled = false
sign_in(user)
get children_url(topic, root.post_number)
expect(response.status).to eq(404)
end
it "returns 404 for unauthorized topic" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
private_topic = Fabricate(:topic, category: private_category)
Fabricate(:post, topic: private_topic, post_number: 1)
private_root = Fabricate(:post, topic: private_topic, reply_to_post_number: nil)
sign_in(user)
get children_url(private_topic, private_root.post_number)
expect(response.status).to eq(404)
end
end
describe "GET context" do
it "returns ancestor chain, target post, and siblings" do
chain = [op]
3.times do |i|
reply_to = i == 0 ? nil : chain.last.post_number
chain << Fabricate(
:post,
topic: topic,
user: Fabricate(:user),
reply_to_post_number: reply_to,
)
end
target = chain.last
sign_in(user)
get context_url(topic, target.post_number)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json).to have_key("topic")
expect(json).to have_key("op_post")
expect(json).to have_key("ancestor_chain")
expect(json).to have_key("siblings")
expect(json).to have_key("target_post")
expect(json["effective_sort"]).to eq("top")
expect(json).to have_key("message_bus_last_id")
end
it "returns empty ancestors when context=0" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get context_url(topic, child.post_number, context: 0)
json = response.parsed_body
expect(json["ancestor_chain"]).to be_empty
end
it "returns 404 for nonexistent post_number" do
sign_in(user)
get context_url(topic, 99_999)
expect(response.status).to eq(404)
end
it "returns 404 when plugin disabled" do
SiteSetting.nested_replies_enabled = false
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
sign_in(user)
get context_url(topic, root.post_number)
expect(response.status).to eq(404)
end
it "returns 404 for unauthorized topic" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
private_topic = Fabricate(:topic, category: private_category)
Fabricate(:post, topic: private_topic, post_number: 1)
root = Fabricate(:post, topic: private_topic, reply_to_post_number: nil)
sign_in(user)
get context_url(private_topic, root.post_number)
expect(response.status).to eq(404)
end
it "includes target post children" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
sign_in(user)
get context_url(topic, root.post_number)
json = response.parsed_body
expect(json["target_post"]["children"]).to be_an(Array)
expect(json["target_post"]["children"].length).to eq(1)
end
describe "deleted post placeholders" do
it "shows deleted ancestor as placeholder for non-staff" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
child.update!(deleted_at: Time.current)
sign_in(user)
get context_url(topic, grandchild.post_number)
json = response.parsed_body
ancestor = json["ancestor_chain"].find { |a| a["id"] == child.id }
expect(ancestor).to be_present
expect(ancestor["deleted_post_placeholder"]).to eq(true)
expect(ancestor["cooked"]).to eq("")
expect(ancestor["raw"]).to be_nil
expect(ancestor["actions_summary"]).to eq([])
end
it "preserves tree structure through deleted ancestors" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
child.update!(deleted_at: Time.current)
sign_in(user)
get context_url(topic, grandchild.post_number)
json = response.parsed_body
expect(json["ancestor_chain"].map { |a| a["id"] }).to include(child.id)
expect(json["target_post"]["id"]).to eq(grandchild.id)
end
it "shows deleted ancestor as placeholder for staff but preserves content" do
root = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
child = Fabricate(:post, topic: topic, user: user, reply_to_post_number: root.post_number)
grandchild =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: child.post_number)
child.update!(deleted_at: Time.current)
sign_in(admin)
get context_url(topic, grandchild.post_number)
json = response.parsed_body
ancestor = json["ancestor_chain"].find { |a| a["id"] == child.id }
expect(ancestor).to be_present
expect(ancestor["deleted_post_placeholder"]).to eq(true)
expect(ancestor["cooked"]).to be_present
expect(ancestor["cooked"]).not_to eq("")
end
it "lets staff load the context view of a fully-deleted topic" do
reply = Fabricate(:post, topic: topic, user: user, reply_to_post_number: nil)
PostDestroyer.new(admin, op).destroy
topic.reload
expect(topic.deleted_at).to be_present
sign_in(admin)
get context_url(topic, reply.post_number)
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["op_post"]).to be_present
expect(json["op_post"]["deleted_post_placeholder"]).to eq(true)
end
end
end
describe "PUT toggle" do
def toggle_url(topic)
"/n/#{topic.slug}/#{topic.id}/toggle.json"
end
it "returns 403 for non-staff users" do
sign_in(user)
put toggle_url(topic), params: { enabled: true }
expect(response.status).to eq(403)
end
it "allows moderators to toggle nested view" do
sign_in(Fabricate(:moderator))
put toggle_url(topic), params: { enabled: true }
expect(response.status).to eq(200)
expect(response.parsed_body["is_nested_view"]).to eq(true)
end
it "allows staff to enable nested view" do
sign_in(admin)
put toggle_url(topic), params: { enabled: true }
expect(response.status).to eq(200)
expect(response.parsed_body["is_nested_view"]).to eq(true)
topic.reload
expect(topic.reload.nested_topic).to be_present
end
it "allows staff to disable nested view" do
Fabricate(:nested_topic, topic: topic)
sign_in(admin)
put toggle_url(topic), params: { enabled: false }
expect(response.status).to eq(200)
expect(response.parsed_body["is_nested_view"]).to eq(false)
topic.reload
expect(topic.reload.nested_topic).to be_nil
end
it "returns 404 for private messages" do
pm = Fabricate(:private_message_topic, user: admin)
Fabricate(:post, topic: pm, user: admin, post_number: 1)
sign_in(admin)
put toggle_url(pm), params: { enabled: true }
expect(response.status).to eq(404)
end
end
describe "visit tracking" do
fab!(:root_reply) { Fabricate(:post, topic: topic, user: user) }
it "tracks a visit on show" do
sign_in(user)
get show_url(topic), params: { track_visit: true }
expect(response.status).to eq(200)
Scheduler::Defer.do_all_work
expect(TopicUser.find_by(topic: topic, user: user).first_visited_at).to be_present
expect(TopicViewItem.exists?(topic_id: topic.id, user_id: user.id)).to eq(true)
end
it "tracks a visit on context" do
sign_in(user)
get context_url(topic, root_reply.post_number), params: { track_visit: true }
expect(response.status).to eq(200)
Scheduler::Defer.do_all_work
expect(TopicUser.find_by(topic: topic, user: user).first_visited_at).to be_present
expect(TopicViewItem.exists?(topic_id: topic.id, user_id: user.id)).to eq(true)
end
it "does not track a user visit for anonymous users" do
topic_user_count = TopicUser.count
get show_url(topic)
expect(response.status).to eq(200)
Scheduler::Defer.do_all_work
expect(TopicUser.count).to eq(topic_user_count)
end
describe "catching up on visit" do
fab!(:reader) { Fabricate(:user, refresh_auto_groups: true) }
before do
Fabricate(:nested_topic, topic: topic)
topic.update!(highest_post_number: 2, highest_staff_post_number: 2)
end
it "advances last_read_post_number to highest_post_number for a nested topic" do
sign_in(reader)
get show_url(topic), params: { track_visit: true }
expect(response.status).to eq(200)
Scheduler::Defer.do_all_work
topic_user = TopicUser.find_by(topic: topic, user: reader)
expect(topic_user.last_read_post_number).to eq(2)
end
it "marks the topic's unread notifications as read" do
reply_notification =
Fabricate(
:replied_notification,
user: reader,
topic: topic,
post: root_reply,
read: false,
)
sign_in(reader)
get show_url(topic), params: { track_visit: true }
expect(response.status).to eq(200)
Scheduler::Defer.do_all_work
expect(reply_notification.reload.read).to eq(true)
end
it "does nothing for non-nested topics opened via /n/" do
flat_topic = Fabricate(:topic, user: user)
Fabricate(:post, topic: flat_topic, user: user, post_number: 1)
flat_topic.update!(highest_post_number: 1, highest_staff_post_number: 1)
sign_in(reader)
get show_url(flat_topic), params: { track_visit: true }
Scheduler::Defer.do_all_work
topic_user = TopicUser.find_by(topic: flat_topic, user: reader)
expect(topic_user&.last_read_post_number).to be_blank
end
end
end
describe "#activity" do
def activity_url(topic)
"/n/#{topic.slug}/#{topic.id}/activity.json"
end
it "returns 404 when nested replies is disabled" do
SiteSetting.nested_replies_enabled = false
sign_in(user)
get activity_url(topic)
expect(response.status).to eq(404)
end
it "includes a synthetic topic_created entry first" do
sign_in(user)
get activity_url(topic)
expect(response.status).to eq(200)
actions = response.parsed_body["small_actions"]
expect(actions.length).to eq(1)
expect(actions[0]["synthetic"]).to eq(true)
expect(actions[0]["action_code"]).to eq("topic_created")
expect(actions[0]["username"]).to eq(user.username)
expect(response.parsed_body["has_more"]).to eq(false)
end
it "returns small action posts in chronological order after topic_created" do
sign_in(user)
topic.add_small_action(admin, "closed.enabled")
topic.add_small_action(admin, "opened.enabled")
topic.add_small_action(admin, "invited_user", "testuser")
get activity_url(topic)
expect(response.status).to eq(200)
actions = response.parsed_body["small_actions"]
expect(actions.length).to eq(4)
expect(actions[0]["action_code"]).to eq("topic_created")
expect(actions[1]["action_code"]).to eq("closed.enabled")
expect(actions[2]["action_code"]).to eq("opened.enabled")
expect(actions[3]["action_code"]).to eq("invited_user")
expect(actions[3]["action_code_who"]).to eq("testuser")
expect(actions[1]["username"]).to eq(admin.username)
end
it "returns post permissions needed by the activity controls" do
action =
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "closed.enabled",
raw: "Staff-editable activity body",
)
sign_in(user)
get activity_url(topic)
serialized_action =
response.parsed_body["small_actions"].find { |item| item["id"] == action.id }
expect(serialized_action).to include(
"can_edit" => false,
"can_delete" => false,
"can_recover" => false,
)
sign_in(admin)
get activity_url(topic)
serialized_action =
response.parsed_body["small_actions"].find { |item| item["id"] == action.id }
expect(serialized_action).to include(
"can_edit" => true,
"can_delete" => true,
"can_recover" => false,
)
expect(serialized_action).to include(
"post_number" => action.post_number,
"post_type" => Post.types[:small_action],
"topic_id" => topic.id,
"user_id" => admin.id,
)
end
it "keeps deleted activity available to staff for recovery" do
deleted_action =
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "closed.enabled",
raw: "Recoverable activity body",
)
deleted_action.trash!(admin)
sign_in(user)
get activity_url(topic)
expect(response.parsed_body["small_actions"].map { |item| item["id"] }).not_to include(
deleted_action.id,
)
sign_in(admin)
get activity_url(topic)
serialized_action =
response.parsed_body["small_actions"].find { |item| item["id"] == deleted_action.id }
expect(serialized_action).to include(
"can_edit" => true,
"can_delete" => true,
"can_recover" => true,
)
expect(serialized_action["deleted_at"]).to be_present
end
it "paginates activity without repeating the synthetic entry" do
stub_const(described_class, :ACTIVITY_PAGE_SIZE, 2) do
actions =
3.times.map do |index|
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "closed.enabled",
raw: "Activity body #{index}",
)
end
sign_in(user)
get activity_url(topic), params: { page: 0 }
first_page = response.parsed_body
expect(first_page["small_actions"].map { |item| item["id"] }).to eq(
[nil, actions[0].id, actions[1].id],
)
expect(first_page["small_actions"].first["synthetic"]).to eq(true)
expect(first_page["has_more"]).to eq(true)
get activity_url(topic), params: { page: 1 }
second_page = response.parsed_body
expect(second_page["small_actions"].map { |item| item["id"] }).to eq([actions[2].id])
expect(second_page["small_actions"]).to all(exclude("synthetic"))
expect(second_page["has_more"]).to eq(false)
end
end
it "does not expose hidden small-action posts to users who cannot see them" do
visible_action =
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "closed.enabled",
raw: "visible activity body",
)
hidden_action =
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "opened.enabled",
raw: "hidden activity secret",
hidden: true,
hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached],
)
sign_in(user)
get activity_url(topic)
expect(response.status).to eq(200)
actions = response.parsed_body["small_actions"]
ids = actions.map { |action| action["id"] }
expect(ids).to include(visible_action.id)
expect(ids).not_to include(hidden_action.id)
expect(response.body).not_to include("hidden activity secret")
end
it "does not expose hidden small-action posts to anonymous users" do
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "opened.enabled",
raw: "hidden activity secret",
hidden: true,
hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached],
)
get activity_url(topic)
expect(response.status).to eq(200)
expect(response.body).not_to include("hidden activity secret")
end
it "still exposes hidden small-action posts to staff" do
hidden_action =
Fabricate(
:small_action,
topic: topic,
user: admin,
action_code: "opened.enabled",
raw: "hidden activity secret",
hidden: true,
hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached],
)
sign_in(admin)
get activity_url(topic)
expect(response.status).to eq(200)
actions = response.parsed_body["small_actions"]
expect(actions.map { |action| action["id"] }).to include(hidden_action.id)
end
it "excludes whisper action-code posts for non-whisperers" do
topic.add_moderator_post(
admin,
nil,
post_type: Post.types[:whisper],
action_code: "assigned",
custom_fields: {
"action_code_who" => user.username,
},
)
sign_in(user)
get activity_url(topic)
actions = response.parsed_body["small_actions"]
expect(actions.map { |a| a["action_code"] }).not_to include("assigned")
end
it "includes whisper action-code posts for whisperers" do
SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}"
topic.add_moderator_post(
admin,
nil,
post_type: Post.types[:whisper],
action_code: "assigned",
custom_fields: {
"action_code_who" => user.username,
},
)
sign_in(admin)
get activity_url(topic)
actions = response.parsed_body["small_actions"]
expect(actions.map { |a| a["action_code"] }).to include("assigned")
end
end
describe "embed mode" do
before { SiteSetting.embed_full_app = true }
it "preserves class_name when redirecting embed_mode to the canonical topic route" do
SiteSetting.embed_any_origin = true
get("/n/#{topic.slug}/#{topic.id}", params: { embed_mode: "true", class_name: "lee-af" })
expect(response).to redirect_to(
"/t/#{topic.slug}/#{topic.id}?class_name=lee-af&embed_mode=true",
)
end
it "strips X-Frame-Options when embed_mode is allowed" do
SiteSetting.embed_any_origin = true
get("/n/#{topic.slug}/#{topic.id}", params: { embed_mode: "true" })
expect(response.headers).not_to include("X-Frame-Options")
end
it "ignores class_name when embed_mode is not allowed" do
get("/n/#{topic.slug}/#{topic.id}", params: { class_name: "lee-af" })
expect(response.body).not_to match(/<html[^>]*\blee-af\b/)
expect(response.headers["X-Frame-Options"]).to eq("SAMEORIGIN")
end
end
end