Skip to content

DEV: Partially refactor themes controller to services #33141

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

martin-brennan
Copy link
Contributor

This commit starts to introduce services to replace actions in the
ThemesController. We will start with the low langing fruit:

  • Create
  • Destroy
  • BulkDestroy
  • GetTranslations

Endpoints like #import and #update will be much harder.
Also, the https://github.com/discourse/discourse-theme-creator plugin
overrides some of these controller actions directly, so we need
to be careful.

This commit starts to introduce services to replace actions in the
ThemesController. We will start with the low langing fruit:

* Create
* Destroy
* BulkDestroy
* GetTranslations

Endpoints like `#import` and `#update` will be much harder.
Also, the https://github.com/discourse/discourse-theme-creator plugin
overrides some of these controller actions directly, so we need
to be careful.
@github-actions github-actions bot added the i18n PRs which update English locale files or i18n related code label Jun 10, 2025
Copy link
Contributor

@Flink Flink left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added back my comments from the other PR that have not been addressed yet (and added a new one).

policy :ensure_remote_themes_are_not_allowlisted

transaction do
try(Theme::InvalidFieldTargetError, Theme::InvalidFieldTypeError) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following a recent change to the model step, we don’t need this try block anymore. If an exception is raised inside a model step, the step will fail and can be matched with the on_model_not_found outcome.
In the controller, it seems we weren’t catching this exception, but I guess we should probably match the on_model_not_found outcome.

fab!(:theme_1) { Fabricate(:theme) }
fab!(:theme_2) { Fabricate(:theme) }
fab!(:admin)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract should be tested, we should add those:

  describe described_class::Contract, type: :model do
    it { is_expected.to validate_presence_of(:theme_ids) }
    it { is_expected.to validate_length_of(:theme_ids).as_array.is_at_least(1).is_at_most(50) }

let(:params) { { theme_ids: [theme_1.id, theme_2.id] } }
let(:dependencies) { { guardian: admin.guardian } }

it "destroys the themes" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I’ve been doing lately is using that way of organizing specs for services: https://meta.discourse.org/t/using-service-objects-in-discourse/333641#p-1635143-testing-17 (example from the docs). I think it makes things a bit clearer (follow the possible failures first, then open a context for the happy path). With that in mind, we should probably do something like:

context "when data is invalid" do
  let(:params) { {} }

  it { is_expected.to fail_a_contract }
end

context "when themes can’t be found" do
  before do
    theme_1.destroy!
    theme_2.destroy!
  end

  it { is_expected.to fail_to_find_a_model(:themes) }
end

context "when everything’s ok" do
  it { is_expected.to run_successfully }

  
end

We can do the same thing for the other specs too.

it "logs the theme destroys" do
expect_any_instance_of(StaffActionLogger).to receive(:log_theme_destroy).with(theme_1).once
expect_any_instance_of(StaffActionLogger).to receive(:log_theme_destroy).with(theme_2).once
expect(result).to be_a_success
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is better to use the dedicated matcher run_successfully instead, as if there is a failure, it will output some debug info to help understand what went wrong.
It’s also simpler to use a dedicated example (it { is_expected.to run_successfully }) and not repeat it in other examples as they share the same context (so it should be a success in each one of them).

fab!(:admin)
fab!(:guardian) { admin.guardian }
fab!(:color_scheme)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here regarding the contract not being tested (we’ll probably have to move all the let/subject/fab calls inside the describe "#call" block).

RSpec.describe Themes::Destroy do
fab!(:theme)
fab!(:admin)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here regarding the contract not being tested.

let(:dependencies) { { guardian: admin.guardian } }

it "destroys the theme" do
expect(result).to be_a_success
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same regarding using run_successfully.

let(:locale) { I18n.available_locales.first.to_s }
let(:params) { { id: theme.id, locale: locale } }

describe described_class::Contract, type: :model do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move this at the top of the main block and move fab/let calls inside the describe "#call" block.

context "when locale is invalid" do
let(:locale) { "invalid_locale" }

it "should be invalid" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t use "should" in the description (see the RSpec Style Guide: https://rspec.rubystyle.guide/#should-in-example-docstrings).

Another way of doing this would be to use allow_value from shoulda matchers:

# at the same level than the other `it`s
it { is_expected.not_to allow_value("invalid_locale").for(:locale).with_message(I18n.t("errors.messages.invalid_locale", invalid_locale: "invalid_locale") }
it { is_expected.to allow_values(:fr, :en, :de).for(:locale) }

describe "#call" do
subject(:result) { described_class.call(params:) }

it { is_expected.to be_a_success }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same regarding using run_successfully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
i18n PRs which update English locale files or i18n related code
Development

Successfully merging this pull request may close these issues.

2 participants