discourse/spec/requests/nested_topics_controller_spec.rb
Rafael dos Santos Silva 0e03b4170f
FIX: Show suggested and related topics on nested replies view (#39473)
**Previously**, the nested replies view (`/n/:slug/:id`) was missing the
Suggested/Related Topics widget. The backend was still serializing
`suggested_topics`/`related_topics` on every initial page-0 response —
running the core suggested-topics query and the discourse-ai embedding
similarity query — but the frontend never rendered the data, so the cost
was entirely wasted.

**In this update**, `<MoreTopics>` now renders at the end of the nested
view, and the payload piggybacks `suggested_topics`/`related_topics`
only on whichever response has `has_more_roots: false` — mirroring the
flat view's lazy-load contract so the embedding query runs once, when
the reader has paginated through all roots.
2026-04-23 12:05:20 -03:00

1175 lines
40 KiB
Ruby

# 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) }
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
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 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 "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
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
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 "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 "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).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
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
end
end