mirror of
https://github.com/discourse/discourse.git
synced 2026-08-11 02:59:07 +08:00
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
201 lines
6.6 KiB
Ruby
Vendored
201 lines
6.6 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe Jobs::DiscourseRssPolling::PollFeed do
|
|
subject(:job) { described_class.new }
|
|
|
|
let(:feed_url) { "https://blog.discourse.org/feed/" }
|
|
let(:author) { Fabricate(:user, trust_level: 1) }
|
|
let(:raw_feed) { file_from_fixtures("feed.rss", "feed") }
|
|
|
|
before { SiteSetting.rss_polling_enabled = true }
|
|
|
|
describe "#execute" do
|
|
before do
|
|
Discourse.redis.del("rss-polling-feed-polled:#{Digest::SHA1.hexdigest(feed_url)}")
|
|
stub_request(:get, feed_url).to_return(status: 200, body: raw_feed)
|
|
end
|
|
|
|
it "creates a topic with the right title, content and author" do
|
|
expect { job.execute(feed_url: feed_url, author_username: author.username) }.to change {
|
|
author.topics.count
|
|
}
|
|
|
|
topic = author.topics.last
|
|
|
|
expect(topic.title).to eq("Poll Feed Spec Fixture")
|
|
expect(topic.first_post.raw).to include("<p>This is the body & content. </p>")
|
|
expect(topic.topic_embed.embed_url).to eq(
|
|
"https://blog.discourse.org/2017/09/poll-feed-spec-fixture",
|
|
)
|
|
end
|
|
|
|
context "with use_pubdate set to false" do
|
|
before do
|
|
SiteSetting.rss_polling_use_pubdate = false
|
|
job.execute(feed_url: feed_url, author_username: author.username)
|
|
end
|
|
|
|
it "has a publication date of now" do
|
|
topic = author.topics.last
|
|
expect(topic.created_at.utc).to be_within(1.second).of Time.now
|
|
expect(topic.first_post.created_at.utc).to be_within(1.second).of Time.now
|
|
end
|
|
end
|
|
|
|
context "with use_pubdate set to true" do
|
|
before do
|
|
SiteSetting.rss_polling_use_pubdate = true
|
|
job.execute(feed_url: feed_url, author_username: author.username)
|
|
end
|
|
|
|
it "has a publication date of the feed" do
|
|
topic = author.topics.last
|
|
expect(topic.created_at).to eq_time(DateTime.parse("2017-09-14 15:22:33.000000000 +0000"))
|
|
expect(topic.first_post.created_at).to eq_time(
|
|
DateTime.parse("2017-09-14 15:22:33.000000000 +0000"),
|
|
)
|
|
end
|
|
end
|
|
|
|
context "with a previous poll on a topic with tags" do
|
|
let(:tag1) { Fabricate(:tag, name: "test-from-rss") }
|
|
let(:tag2) { Fabricate(:tag, name: "test-update-from-rss") }
|
|
|
|
before do
|
|
SiteSetting.tagging_enabled = true
|
|
job.execute(
|
|
feed_url: feed_url,
|
|
author_username: author.username,
|
|
discourse_tags: [tag1.name],
|
|
)
|
|
Discourse.redis.del("rss-polling-feed-polled:#{Digest::SHA1.hexdigest(feed_url)}")
|
|
end
|
|
|
|
context "with rss polling set to true" do
|
|
before { SiteSetting.rss_polling_update_tags = true }
|
|
it "updates tags by default" do
|
|
topic = author.topics.last
|
|
job.execute(
|
|
feed_url: feed_url,
|
|
author_username: author.username,
|
|
discourse_tags: [tag2.name],
|
|
)
|
|
topic = author.topics.last.reload
|
|
expect(topic.tags).to match_array([tag2])
|
|
end
|
|
end
|
|
|
|
context "with rss polling set to false" do
|
|
before { SiteSetting.rss_polling_update_tags = false }
|
|
|
|
it "does not update tags" do
|
|
job.execute(
|
|
feed_url: feed_url,
|
|
author_username: author.username,
|
|
discourse_tags: [tag2.name],
|
|
)
|
|
topic = author.topics.last
|
|
expect(topic.tags).to match_array([tag1])
|
|
end
|
|
end
|
|
end
|
|
|
|
it "is rate limited by rss_polling_frequency" do
|
|
2.times { job.execute(feed_url: feed_url, author_username: author.username) }
|
|
|
|
expect(WebMock).to have_requested(:get, feed_url).once
|
|
end
|
|
|
|
it "is not raising error if http request failed" do
|
|
stub_request(:get, feed_url).to_return(status: 500)
|
|
job.execute(feed_url: feed_url, author_username: author.username)
|
|
end
|
|
|
|
it "skips the topic if the category doesn't exist on our side" do
|
|
invalid_discourse_category_id = 99
|
|
|
|
expect {
|
|
job.execute(
|
|
feed_url: feed_url,
|
|
author_username: author.username,
|
|
discourse_category_id: invalid_discourse_category_id,
|
|
)
|
|
}.not_to change { author.topics.count }
|
|
|
|
expect(author.topics.last).to be_nil
|
|
end
|
|
|
|
it "does not raise error for valid xml but non-rss content" do
|
|
stub_request(:get, feed_url).to_return(status: 200, body: "<html><body>tesing</body></html>")
|
|
|
|
expect {
|
|
job.execute(feed_url: feed_url, author_username: author.username)
|
|
}.not_to raise_error
|
|
end
|
|
|
|
it "does not raise error for valid xml but non-rss title" do
|
|
stub_request(:get, feed_url).to_return(
|
|
status: 200,
|
|
body: rss_polling_file_fixture("mastodon.rss").read,
|
|
)
|
|
|
|
expect {
|
|
job.execute(feed_url: feed_url, author_username: author.username)
|
|
}.not_to raise_error
|
|
end
|
|
|
|
it "sends API credentials as headers instead of query parameters" do
|
|
authenticated_url = "#{feed_url}?api_key=test123&api_username=testuser"
|
|
|
|
Discourse.redis.del("rss-polling-feed-polled:#{Digest::SHA1.hexdigest(authenticated_url)}")
|
|
|
|
stub_request(:get, feed_url).with(
|
|
headers: {
|
|
"Api-Key" => "test123",
|
|
"Api-Username" => "testuser",
|
|
},
|
|
).to_return(status: 200, body: file_from_fixtures("feed.rss", "feed"))
|
|
|
|
expect {
|
|
job.execute(feed_url: authenticated_url, author_username: author.username)
|
|
}.to change { author.topics.count }.by(1)
|
|
end
|
|
|
|
context "with user_id" do
|
|
it "creates a topic when given user_id" do
|
|
expect { job.execute(feed_url: feed_url, user_id: author.id) }.to change {
|
|
author.topics.count
|
|
}.by(1)
|
|
end
|
|
|
|
it "keeps working after the user is renamed" do
|
|
UsernameChanger.change(author, "renamed_account")
|
|
|
|
expect { job.execute(feed_url: feed_url, user_id: author.id) }.to change {
|
|
author.reload.topics.count
|
|
}.by(1)
|
|
end
|
|
|
|
it "falls back to the system user and logs when the referenced user no longer exists" do
|
|
deleted_id = author.id
|
|
author.destroy!
|
|
|
|
Rails.logger.expects(:warn).with(includes("not found")).at_least_once
|
|
|
|
expect { job.execute(feed_url: feed_url, user_id: deleted_id) }.to change {
|
|
Discourse.system_user.topics.count
|
|
}.by(1)
|
|
end
|
|
end
|
|
|
|
context "with an unknown author_username (legacy fallback)" do
|
|
it "falls back to the system user and logs a warning" do
|
|
Rails.logger.expects(:warn).with(includes("not found")).at_least_once
|
|
|
|
expect { job.execute(feed_url: feed_url, author_username: "ghost_user") }.to change {
|
|
Discourse.system_user.topics.count
|
|
}.by(1)
|
|
end
|
|
end
|
|
end
|
|
end
|