mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +08:00
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.
1963 lines
69 KiB
Ruby
Vendored
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
|