discourse/plugins/discourse-rss-polling/spec/requests/feed_settings_controller_spec.rb
Régis Hanol 25bd06dadd
FIX: RSS polling breaks silently after a username rename (#39460)
The discourse-rss-polling plugin stored the feed author as a plain
`author` string with no link to the users table. When a user was
renamed, the polling job's `User.find_by_username(old_name)` returned
nil, the job bailed with no log, and the feed silently stopped importing
posts. We've hit this repeatedly on enterprise sites, and every time the
fix is the same manual cleanup in the admin UI.

This commit moves the feed -> user association onto a proper `user_id`
foreign key, so renames (and user deletion) no longer break polling. It
also brings the plugin up to current Discourse conventions: a service
object for the update action, an ApplicationSerializer for the admin
list view, and a fabricator for specs.

What's in this commit:

* Migration adds `user_id` and an index, backfills from the legacy
`author` string via a case-insensitive match, defaults the rest to the
system user, drops the default on `author`, and marks the column
readonly so it cannot drift again. Follow-up post-deploy migration will
drop the column.

* `RssFeed` gains `belongs_to :user`, delegates `author_username` to the
user, ignores the legacy `author` column, and owns `#poll` (moved here
from the deleted `FeedSetting` DTO).

* `PollFeed` job now looks up the author by `user_id` first and keeps a
short-lived `author_username` fallback for jobs enqueued before deploy.
When the user cannot be resolved it falls back to the system user and
logs a warning instead of silently returning.

* `PollAllFeeds` scheduled job iterates `RssFeed.includes(:user)`
directly — no finder indirection.

* `DiscourseRssPolling::FeedSetting::Update` service replaces the
controller's inline parse/resolve/assign/render logic. The contract
validates presence of `feed_url` and `author_username` (previously a
blank username was silently saved as `user_id: nil`), normalizes the
tag-chooser's array-of-hashes shape, and produces the CSV the `tags`
column expects. Unknown usernames surface as an `on_model_not_found`
match with a proper i18n error in the toast.

* `FeedSettingSerializer < ApplicationSerializer` replaces the
`FeedSetting` DTO for the admin list view and reads `author_username`
live from the user association on every request, so the UI never shows a
stale name.

* `FeedSettingFinder` is removed — `by_embed_url` was dead and `.all` is
now a one-liner at its two call sites.

Ref - t/182280
2026-05-05 18:42:56 +02:00

123 lines
3.6 KiB
Ruby

# frozen_string_literal: true
RSpec.describe DiscourseRssPolling::FeedSettingsController do
fab!(:admin)
before do
sign_in(admin)
SiteSetting.rss_polling_enabled = true
end
describe "#show" do
before do
Fabricate(
:rss_feed,
url: "https://blog.discourse.org/feed",
user: Discourse.system_user,
category_id: 4,
tags: nil,
category_filter: "updates",
)
end
it "returns the serialized feed settings" do
get "/admin/plugins/rss_polling/feed_settings.json"
expect(response.status).to eq(200)
body = response.parsed_body
expect(body["feed_settings"].length).to eq(1)
expect(body["feed_settings"].first).to include(
"feed_url" => "https://blog.discourse.org/feed",
"user_id" => Discourse.system_user.id,
"author_username" => Discourse.system_user.username,
"discourse_category_id" => 4,
"feed_category_filter" => "updates",
)
end
end
describe "#update" do
it "creates a feed setting" do
put "/admin/plugins/rss_polling/feed_settings.json",
params: {
feed_setting: {
feed_url: "https://www.newsite.com/feed",
author_username: "system",
feed_category_filter: "updates",
},
}
expect(response.status).to eq(200)
expect(DiscourseRssPolling::RssFeed.count).to eq(1)
end
it "persists the resolved user_id so renames don't break polling" do
user = Fabricate(:user, username: "blogauthor")
put "/admin/plugins/rss_polling/feed_settings.json",
params: {
feed_setting: {
feed_url: "https://www.newsite.com/feed",
author_username: user.username,
feed_category_filter: "updates",
},
}
expect(response.status).to eq(200)
expect(DiscourseRssPolling::RssFeed.last.user_id).to eq(user.id)
end
it "returns 422 with a human-readable error when the author_username does not match a user" do
put "/admin/plugins/rss_polling/feed_settings.json",
params: {
feed_setting: {
feed_url: "https://www.newsite.com/feed",
author_username: "nope_not_real",
feed_category_filter: "updates",
},
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to contain_exactly(match(/nope_not_real/))
end
it "returns 400 when the contract is invalid" do
put "/admin/plugins/rss_polling/feed_settings.json",
params: {
feed_setting: {
feed_url: "",
author_username: "system",
},
}
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to be_present
end
it "allows duplicate rss feed urls" do
put "/admin/plugins/rss_polling/feed_settings.json",
params: {
feed_setting: {
feed_url: "https://blog.discourse.org/feed",
author_username: "system",
discourse_category_id: 2,
feed_category_filter: "updates",
},
}
expect(response.status).to eq(200)
put "/admin/plugins/rss_polling/feed_settings.json",
params: {
feed_setting: {
feed_url: "https://blog.discourse.org/feed",
author_username: "system",
discourse_category_id: 4,
feed_category_filter: "updates",
},
}
expect(response.status).to eq(200)
expect(DiscourseRssPolling::RssFeed.count).to eq(2)
end
end
end