discourse/spec/models/translation_override_spec.rb
Ted Johansson 9915236e42
FEATURE: Warn about outdated translation overrides in admin dashboard (#22384)
This PR adds a feature to help admins stay up-to-date with their translations. We already have protections preventing admins from problems when they update their overrides. This change adds some protection in the other direction (where translations change in core due to an upgrade) by creating a notice for admins when defaults have changed.

Terms:

- In the case where Discourse core changes the default translation, the translation override is considered "outdated".
- In the case above where interpolation keys were changed from the ones the override is using, it is considered "invalid".
- If none of the above applies, the override is considered "up to date".

How does it work?

There are a few pieces that makes this work:

- When an admin creates or updates a translation override, we store the original translation at the time of write. (This is used to detect changes later on.)
- There is a background job that runs once every day and checks for outdated and invalid overrides, and marks them as such.
- When there are any outdated or invalid overrides, a notice is shown in admin dashboard with a link to the text customization page.

Known limitations

The link from the dashboard links to the default locale text customization page. Given there might be invalid overrides in multiple languages, I'm not sure what we could do here. Consideration for future improvement.
2023-07-10 10:06:40 +08:00

326 lines
12 KiB
Ruby

# frozen_string_literal: true
RSpec.describe TranslationOverride do
describe "Validations" do
describe "#value" do
before do
I18n.backend.store_translations(
I18n.locale,
"user_notifications.user_did_something" => "%{first} %{second}",
)
I18n.backend.store_translations(
:en,
something: {
one: "%{key1} %{key2}",
other: "%{key3} %{key4}",
},
)
end
describe "when interpolation keys are missing" do
it "should not be valid" do
translation_override =
TranslationOverride.upsert!(I18n.locale, "some_key", "%{key} %{omg}")
expect(translation_override.errors.full_messages).to include(
I18n.t(
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
keys: "key, omg",
count: 2,
),
)
end
context "when custom interpolation keys are included" do
%w[
user_notifications.user_did_something
user_notifications.only_reply_by_email
user_notifications.only_reply_by_email_pm
user_notifications.reply_by_email
user_notifications.reply_by_email_pm
user_notifications.visit_link_to_respond
user_notifications.visit_link_to_respond_pm
].each do |i18n_key|
it "should validate keys for #{i18n_key}" do
interpolation_key_names =
described_class::ALLOWED_CUSTOM_INTERPOLATION_KEYS.find do |keys, _|
keys.include?("user_notifications.user_")
end
string_with_interpolation_keys =
interpolation_key_names.map { |x| "%{#{x}}" }.join(" ")
translation_override =
TranslationOverride.upsert!(
I18n.locale,
i18n_key,
"#{string_with_interpolation_keys} %{something}",
)
expect(translation_override.errors.full_messages).to include(
I18n.t(
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
keys: "something",
count: 1,
),
)
end
end
it "should validate keys that shouldn't be used outside of user_notifications" do
I18n.backend.store_translations(:en, "not_a_notification" => "Test %{key1}")
translation_override =
TranslationOverride.upsert!(
I18n.locale,
"not_a_notification",
"Overridden %{key1} %{topic_title_url_encoded}",
)
expect(translation_override.errors.full_messages).to include(
I18n.t(
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
keys: "topic_title_url_encoded",
count: 1,
),
)
end
end
end
describe "with valid custom interpolation keys" do
it "works" do
translation_override =
TranslationOverride.upsert!(
I18n.locale,
"system_messages.welcome_user.text_body_template",
"Hello %{name} %{username} %{name_or_username} and welcome to %{site_name}!",
)
expect(translation_override.errors).to be_empty
end
end
describe "pluralized keys" do
describe "valid keys" do
it "converts zero to other" do
translation_override =
TranslationOverride.upsert!(I18n.locale, "something.zero", "%{key3} %{key4} hello")
expect(translation_override.errors.full_messages).to eq([])
end
it "converts two to other" do
translation_override =
TranslationOverride.upsert!(I18n.locale, "something.two", "%{key3} %{key4} hello")
expect(translation_override.errors.full_messages).to eq([])
end
it "converts few to other" do
translation_override =
TranslationOverride.upsert!(I18n.locale, "something.few", "%{key3} %{key4} hello")
expect(translation_override.errors.full_messages).to eq([])
end
it "converts many to other" do
translation_override =
TranslationOverride.upsert!(I18n.locale, "something.many", "%{key3} %{key4} hello")
expect(translation_override.errors.full_messages).to eq([])
end
end
describe "invalid keys" do
it "does not transform 'tonz'" do
translation_override =
TranslationOverride.upsert!(I18n.locale, "something.tonz", "%{key3} %{key4} hello")
expect(translation_override.errors.full_messages).to include(
I18n.t(
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
keys: "key3, key4",
count: 2,
),
)
end
end
end
end
end
it "upserts values" do
TranslationOverride.upsert!("en", "some.key", "some value")
ovr = TranslationOverride.where(locale: "en", translation_key: "some.key").first
expect(ovr).to be_present
expect(ovr.value).to eq("some value")
end
it "sanitizes values before upsert" do
xss = "<a target='blank' href='%{path}'>Click here</a> <script>alert('TEST');</script>"
TranslationOverride.upsert!("en", "js.themes.error_caused_by", xss)
ovr =
TranslationOverride.where(locale: "en", translation_key: "js.themes.error_caused_by").first
expect(ovr).to be_present
expect(ovr.value).to eq("<a href=\"%{path}\">Click here</a> alert('TEST');")
end
it "stores js for a message format key" do
TranslationOverride.upsert!(
"ru",
"some.key_MF",
"{NUM_RESULTS, plural, one {1 result} other {many} }",
)
ovr = TranslationOverride.where(locale: "ru", translation_key: "some.key_MF").first
expect(ovr).to be_present
expect(ovr.compiled_js).to start_with("function")
expect(ovr.compiled_js).to_not match(/Invalid Format/i)
end
describe "site cache" do
def cached_value(guardian, translation_key, locale:)
types_name, name_key, attribute = translation_key.split(".")
I18n.with_locale(locale) do
json = Site.json_for(guardian)
JSON.parse(json)[types_name].find { |x| x["name_key"] == name_key }[attribute]
end
end
let!(:anon_guardian) { Guardian.new }
let!(:user_guardian) { Guardian.new(Fabricate(:user)) }
shared_examples "resets site text" do
it "resets the site cache when translations of post_action_types are changed" do
I18n.locale = :de
translation_keys.each do |translation_key|
original_value = I18n.t(translation_key, locale: "en")
expect(cached_value(user_guardian, translation_key, locale: "en")).to eq(original_value)
expect(cached_value(anon_guardian, translation_key, locale: "en")).to eq(original_value)
TranslationOverride.upsert!("en", translation_key, "bar")
expect(cached_value(user_guardian, translation_key, locale: "en")).to eq("bar")
expect(cached_value(anon_guardian, translation_key, locale: "en")).to eq("bar")
end
TranslationOverride.revert!("en", translation_keys)
translation_keys.each do |translation_key|
original_value = I18n.t(translation_key, locale: "en")
expect(cached_value(user_guardian, translation_key, locale: "en")).to eq(original_value)
expect(cached_value(anon_guardian, translation_key, locale: "en")).to eq(original_value)
end
end
end
context "with post_action_types" do
let(:translation_keys) { ["post_action_types.off_topic.description"] }
include_examples "resets site text"
end
context "with topic_flag_types" do
let(:translation_keys) { ["topic_flag_types.spam.description"] }
include_examples "resets site text"
end
context "with multiple keys" do
let(:translation_keys) do
%w[post_action_types.off_topic.description topic_flag_types.spam.description]
end
include_examples "resets site text"
end
describe "#reload_all_overrides!" do
it "correctly reloads all translation overrides" do
original_en_topics = I18n.t("topics", locale: :en)
original_en_emoji = I18n.t("js.composer.emoji", locale: :en)
original_en_offtopic_description =
I18n.t("post_action_types.off_topic.description", locale: :en)
original_de_likes = I18n.t("likes", locale: :de)
TranslationOverride.create!(locale: "en", translation_key: "topics", value: "Threads")
TranslationOverride.create!(
locale: "en",
translation_key: "js.composer.emoji",
value: "Smilies",
)
TranslationOverride.create!(
locale: "en",
translation_key: "post_action_types.off_topic.description",
value: "Overridden description",
)
TranslationOverride.create!(
locale: "de",
translation_key: "likes",
value: "„Gefällt mir“-Angaben",
)
expect(I18n.t("topics", locale: :en)).to eq(original_en_topics)
expect(I18n.t("js.composer.emoji", locale: :en)).to eq(original_en_emoji)
expect(
cached_value(anon_guardian, "post_action_types.off_topic.description", locale: :en),
).to eq(original_en_offtopic_description)
expect(I18n.t("likes", locale: :de)).to eq(original_de_likes)
TranslationOverride.reload_all_overrides!
expect(I18n.t("topics", locale: :en)).to eq("Threads")
expect(I18n.t("js.composer.emoji", locale: :en)).to eq("Smilies")
expect(
cached_value(anon_guardian, "post_action_types.off_topic.description", locale: :en),
).to eq("Overridden description")
expect(I18n.t("likes", locale: :de)).to eq("„Gefällt mir“-Angaben")
TranslationOverride.revert!(
:en,
%w[topics js.composer.emoji post_action_types.off_topic.description],
)
TranslationOverride.revert!(:de, ["likes"])
end
end
end
describe "#original_translation_updated?" do
context "when the translation is up to date" do
fab!(:translation) { Fabricate(:translation_override, translation_key: "title") }
it { expect(translation.original_translation_updated?).to eq(false) }
end
context "when the translation is outdated" do
fab!(:translation) do
Fabricate(:translation_override, translation_key: "title", original_translation: "outdated")
end
it { expect(translation.original_translation_updated?).to eq(true) }
end
context "when we can't tell because the translation is too old" do
fab!(:translation) do
Fabricate(:translation_override, translation_key: "title", original_translation: nil)
end
it { expect(translation.original_translation_updated?).to eq(false) }
end
end
describe "invalid_interpolation_keys" do
fab!(:translation) do
Fabricate(
:translation_override,
translation_key: "system_messages.welcome_user.subject_template",
)
end
it "picks out invalid keys and ignores known and custom keys" do
translation.update_attribute("value", "Hello, %{name}! Welcome to %{site_name}. %{foo}")
expect(translation.invalid_interpolation_keys).to contain_exactly("foo")
end
end
end