# frozen_string_literal: true describe ThemeSettingsMigrationsRunner do fab!(:theme) fab!(:migration_field) { Fabricate(:migration_theme_field, version: 1, theme: theme) } fab!(:settings_field) { Fabricate(:settings_theme_field, theme: theme, value: <<~YAML) } integer_setting: 1 boolean_setting: true string_setting: "" YAML describe "#run" do it "passes values of overridden settings only to migrations" do theme.update_setting(:integer_setting, 1) theme.update_setting(:string_setting, "osama") theme.save! migration_field.update!(value: <<~JS) export default function migrate(settings) { if (settings.get("integer_setting") !== 1) { throw new Error(`expected integer_setting to equal 1, but it's actually ${settings.get("integer_setting")}`); } if (settings.get("string_setting") !== "osama") { throw new Error(`expected string_setting to equal "osama", but it's actually "${settings.get("string_setting")}"`); } if (settings.size !== 2) { throw new Error(`expected the settings map to have only 2 keys, but instead got ${settings.size} keys`); } return settings; } JS results = described_class.new(theme).run expect(results.first[:theme_field_id]).to eq(migration_field.id) expect(results.first[:settings_before]).to eq( { "integer_setting" => 1, "string_setting" => "osama" }, ) end it "passes values of `objects` typed settings to migrations and the values are parsed (not json string)" do settings_field.update!(value: <<~YAML) objects_setting: type: objects default: - text: "hello, default link" url: "https://google.com" - text: "hi, another default link" url: "https://discourse.org" schema: name: link properties: text: type: string url: type: string YAML theme.update_setting( :objects_setting, [{ text: "custom link 1", url: "https://meta.discourse.org" }], ) theme.save! migration_field.update!(value: <<~JS) export default function migrate(settings) { settings.get("objects_setting").push( { text: "another custom link", url: "https://try.discourse.org" } ) return settings; } JS results = described_class.new(theme).run expect(results.first[:settings_before]).to eq( { "objects_setting" => [ { "url" => "https://meta.discourse.org", "text" => "custom link 1" }, ], }, ) expect(results.first[:settings_after]).to eq( { "objects_setting" => [ { "url" => "https://meta.discourse.org", "text" => "custom link 1" }, { "url" => "https://try.discourse.org", "text" => "another custom link" }, ], }, ) end it "passes the output of the previous migration as input to the next one" do theme.update_setting(:integer_setting, 1) migration_field.update!(value: <<~JS) export default function migrate(settings) { settings.set("integer_setting", 111); return settings; } JS another_migration_field = Fabricate(:migration_theme_field, theme: theme, version: 2, value: <<~JS) export default function migrate(settings) { if (settings.get("integer_setting") !== 111) { throw new Error(`expected integer_setting to equal 111, but it's actually ${settings.get("integer_setting")}`); } settings.set("integer_setting", 222); return settings; } JS results = described_class.new(theme).run expect(results.size).to eq(2) expect(results[0][:theme_field_id]).to eq(migration_field.id) expect(results[1][:theme_field_id]).to eq(another_migration_field.id) expect(results[0][:settings_before]).to eq({}) expect(results[0][:settings_after]).to eq({ "integer_setting" => 111 }) expect(results[1][:settings_before]).to eq({ "integer_setting" => 111 }) expect(results[1][:settings_after]).to eq({ "integer_setting" => 222 }) end it "doesn't run migrations that have already been ran" do Fabricate(:theme_settings_migration, theme: theme, theme_field: migration_field) pending_field = Fabricate(:migration_theme_field, theme: theme, version: 23) results = described_class.new(theme).run expect(results.size).to eq(1) expect(results.first[:version]).to eq(23) expect(results.first[:theme_field_id]).to eq(pending_field.id) end it "doesn't error when no migrations have been ran yet" do results = described_class.new(theme).run expect(results.size).to eq(1) expect(results.first[:version]).to eq(1) expect(results.first[:theme_field_id]).to eq(migration_field.id) end it "doesn't error when there are no pending migrations" do Fabricate(:theme_settings_migration, theme: theme, theme_field: migration_field) results = described_class.new(theme).run expect(results.size).to eq(0) end it "raises an error when there are too many pending migrations" do Fabricate(:migration_theme_field, theme: theme, version: 2) expect do described_class.new(theme, limit: 1).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.too_many_pending_migrations"), ) end it "raises an error if a migration field has a badly formatted name" do migration_field.update_attribute(:name, "020-some-name") expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.invalid_filename", filename: "020-some-name"), ) migration_field.update_attribute(:name, "0020some-name") expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.invalid_filename", filename: "0020some-name"), ) migration_field.update_attribute(:name, "0020") expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.invalid_filename", filename: "0020"), ) end it "raises an error if a pending migration has version lower than the last ran migration" do migration_field.update!(name: "0020-some-name") Fabricate(:theme_settings_migration, theme: theme, theme_field: migration_field, version: 20) Fabricate(:migration_theme_field, theme: theme, version: 19, name: "0019-failing-migration") expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t( "themes.import_error.migrations.out_of_sequence", name: "0019-failing-migration", current: 20, ), ) end it "detects bad syntax in migrations and raises an error" do migration_field.update!(value: <<~JS) export default function migrate() { JS expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t( "themes.import_error.migrations.syntax_error", name: "0001-some-name", error: 'SyntaxError: "/discourse/theme/migration: Unexpected token (2:0)\n\n 1 | export default function migrate() {\n> 2 |\n | ^"', ), ) end it "imposes memory limit on migrations and raises an error if they exceed the limit" do migration_field.update!(value: <<~JS) export default function migrate(settings) { let a = new Array(10000); while(true) { a = a.concat(new Array(10000)); } return settings; } JS expect do described_class.new(theme, memory: 10.kilobytes).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.exceeded_memory_limit", name: "0001-some-name"), ) end it "imposes time limit on migrations and raises an error if they exceed the limit" do migration_field.update!(value: <<~JS) export default function migrate(settings) { let a = 1; while(true) { a += 1; } return settings; } JS expect do described_class.new(theme, timeout: 10).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.timed_out", name: "0001-some-name"), ) end it "raises a clear error message when the migration file doesn't export anything" do migration_field.update!(value: <<~JS) function migrate(settings) { return settings; } JS expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.no_exported_function", name: "0001-some-name"), ) end it "raises a clear error message when the migration file exports the default as something that's not a function" do migration_field.update!(value: <<~JS) export function migrate(settings) { return settings; } const AA = 1; export default AA; JS expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t( "themes.import_error.migrations.default_export_not_a_function", name: "0001-some-name", ), ) end it "raises a clear error message when the migration function doesn't return anything" do migration_field.update!(value: <<~JS) export default function migrate(settings) {} JS expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.no_returned_value", name: "0001-some-name"), ) end it "raises a clear error message when the migration function doesn't return a Map" do migration_field.update!(value: <<~JS) export default function migrate(settings) { return {}; } JS expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t("themes.import_error.migrations.wrong_return_type", name: "0001-some-name"), ) end it "surfaces runtime errors that occur within the migration" do migration_field.update!(value: <<~JS) export default function migrate(settings) { null.toString(); return settings; } JS expect do described_class.new(theme).run end.to raise_error( Theme::SettingsMigrationError, I18n.t( "themes.import_error.migrations.runtime_error", name: "0001-some-name", error: "TypeError: Cannot read properties of null (reading 'toString')", ), ) end it "returns a list of objects that each has data representing the migration and the results" do results = described_class.new(theme).run expect(results[0][:version]).to eq(1) expect(results[0][:name]).to eq("some-name") expect(results[0][:original_name]).to eq("0001-some-name") expect(results[0][:theme_field_id]).to eq(migration_field.id) expect(results[0][:settings_before]).to eq({}) expect(results[0][:settings_after]).to eq({}) end it "attaches the isValidUrl() function to the context of the migrations" do theme.update_setting(:string_setting, "https://google.com") theme.save! migration_field.update!(value: <<~JS) export default function migrate(settings, helpers) { if (!helpers.isValidUrl("some_invalid_string")) { settings.set("string_setting", "is_not_valid_string"); } return settings; } JS results = described_class.new(theme).run expect(results[0][:settings_after]).to eq({ "string_setting" => "is_not_valid_string" }) end it "attaches the getCategoryIdByName() function to the context of the migrations" do category = Fabricate(:category, name: "Some Category Name") theme.update_setting(:integer_setting, -10) theme.save! migration_field.update!(value: <<~JS) export default function migrate(settings, helpers) { const categoryId = helpers.getCategoryIdByName("some CatEgory Name"); settings.set("integer_setting", categoryId); return settings; } JS results = described_class.new(theme).run expect(results[0][:settings_after]).to eq({ "integer_setting" => category.id }) end end end