discourse/spec/jobs/process_post_spec.rb
Régis Hanol 73dd5b4da8
FIX: Sync category description when post content changes outside PostRevisor (#39184)
When a group or user is renamed, `GroupMentionsUpdater` and
`Jobs::UpdateUsername` update the post's raw/cooked content but skip
`PostRevisor`, so `categories.description` (a denormalized copy of the
first paragraph) is never refreshed. The Edit Category page and category
banner keep showing the old name, while the actual topic shows the new
one. Same issue with `Post#rebake!` (Rebuild HTML) and `ProcessPost`.

Extract `Post#sync_category_description` as the single source of truth
for deriving `categories.description` from a post's cooked HTML — both
`PostRevisor` and the new `Post#sync_first_post_caches` now delegate to
it. `sync_first_post_caches` also updates `topics.excerpt`, centralizing
all first-post denormalized field updates.

Call `sync_first_post_caches` from every code path that modifies post
content outside PostRevisor: `GroupMentionsUpdater`, `UpdateUsername`,
`ChangeDisplayName`, `ProcessPost`, and `Post#rebake!`.

Skip the update when the description hasn't changed to avoid unnecessary
writes. When it has changed, call `publish_category` and
`Site.clear_cache` so connected clients see the update immediately
(fixes a pre-existing flicker where the old description would briefly
appear then get overwritten by stale cached site data).

Also add a targeted query in `UpdateUsername` for category description
posts authored by the system user, which were missed by the existing
`user_actions`-based and self-mention queries.

Ref - t/181445
2026-05-11 16:13:23 +02:00

129 lines
4.4 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe Jobs::ProcessPost do
it "returns when the post cannot be found" do
expect { Jobs::ProcessPost.new.execute(post_id: 1) }.not_to raise_error
end
context "with a post" do
fab!(:post)
it "does not erase posts when CookedPostProcessor malfunctions" do
# Look kids, an actual reason why you want to use mocks
CookedPostProcessor.any_instance.expects(:html).returns(" ")
cooked = post.cooked
post.reload
expect(post.cooked).to eq(cooked)
Jobs::ProcessPost.new.execute(post_id: post.id, cook: true)
end
it "recooks if needed" do
cooked = post.cooked
post.update_columns(cooked: "frogs")
Jobs::ProcessPost.new.execute(post_id: post.id, cook: true)
post.reload
expect(post.cooked).to eq(cooked)
end
it "processes posts" do
post =
Fabricate(:post, raw: "<img src='#{Discourse.base_url_no_prefix}/awesome/picture.png'>")
expect(post.cooked).to match(/http/)
stub_image_size
Jobs::ProcessPost.new.execute(post_id: post.id)
post.reload
# subtle but cooked post processor strip this stuff, this ensures all the code gets a workout
expect(post.cooked).not_to match(/http/)
end
it "always re-extracts links on post process" do
post.update_columns(raw: "sam has a blog at https://samsaffron.com")
expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { TopicLink.count }.by(1)
end
it "extracts links to quoted posts" do
quoted_post =
Fabricate(
:post,
raw: "This is a post with a link to https://www.discourse.org",
post_number: 42,
)
post.update_columns(
raw:
"This quote is the best\n\n[quote=\"#{quoted_post.user.username}, topic:#{quoted_post.topic_id}, post:#{quoted_post.post_number}\"]\n#{quoted_post.excerpt}\n[/quote]",
)
stub_image_size
# when creating a quote, we also create the reflexion link
expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { TopicLink.count }.by(2)
end
it "extracts links to oneboxed topics" do
oneboxed_post = Fabricate(:post)
post.update_columns(raw: "This post is the best\n\n#{oneboxed_post.full_url}")
stub_image_size
# when creating a quote, we also create the reflexion link
expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { TopicLink.count }.by(2)
end
it "works for posts that belong to no existing user" do
cooked = post.cooked
post.update_columns(cooked: "frogs", user_id: nil)
Jobs::ProcessPost.new.execute(post_id: post.id, cook: true)
post.reload
expect(post.cooked).to eq(cooked)
post.update_columns(cooked: "frogs", user_id: User.maximum("id") + 1)
Jobs::ProcessPost.new.execute(post_id: post.id, cook: true)
post.reload
expect(post.cooked).to eq(cooked)
end
it "updates first post caches only for the OP" do
post = Fabricate(:post, raw: "Some OP content", cooked: "")
post.topic.update_excerpt("Incorrect")
Jobs::ProcessPost.new.execute(post_id: post.id)
expect(post.topic.reload.excerpt).to eq("Some OP content")
post2 = Fabricate(:post, raw: "Some reply content", cooked: "", topic: post.topic)
Jobs::ProcessPost.new.execute(post_id: post2.id)
expect(post.topic.reload.excerpt).to eq("Some OP content")
end
end
describe "#enqueue_pull_hotlinked_images" do
subject(:job) { described_class.new }
fab!(:post) { Fabricate(:post, created_at: 20.days.ago) }
it "runs even when download_remote_images_to_local is disabled" do
# We want to run it to pull hotlinked optimized images
SiteSetting.download_remote_images_to_local = false
expect_enqueued_with(job: :pull_hotlinked_images, args: { post_id: post.id }) do
job.execute({ post_id: post.id })
end
end
context "when download_remote_images_to_local? is enabled" do
before { SiteSetting.download_remote_images_to_local = true }
it "enqueues" do
expect_enqueued_with(job: :pull_hotlinked_images, args: { post_id: post.id }) do
job.execute({ post_id: post.id })
end
end
it "does not run when requested to skip" do
job.execute({ post_id: post.id, skip_pull_hotlinked_images: true })
expect(Jobs::PullHotlinkedImages.jobs.size).to eq(0)
end
end
end
end