"""Every notification template speaks all three languages, consistently. Helix serves zh-CN / en-US / ja-JP. A template that ships only Chinese doesn't fail loudly — it silently sends Chinese to every recipient, which is exactly how the platform accumulated 70 Chinese-only (and, in the feed domain, English-only) notification flows before this suite existed. These are static assertions over the in-process catalog: no DB, no fixtures, so they run in milliseconds and gate every PR that adds a template. """ from __future__ import annotations import re import string import pytest from app.core.locale import FALLBACK_CHAIN, normalize_lang, pick_localized from app.notifications.service import _DEFAULT_TEMPLATES from app.notifications.templates import BUILTIN_TEMPLATES #: Fields whose localisation matters to a reader. ``body_html`` is included #: because the email channel prefers it over ``body_text`` when present. _LOCALISED_FIELDS = ("subject", "body_text", "body_html", "card_button_label") #: Placeholders the renderer injects itself (``_render_for_recipient``), so a #: template may reference them without the producing call site supplying them. _RENDERER_INJECTED = frozenset({"portal_url", "cta_line"}) #: Suffixes marking a "locale fragment pair" — a context value the *caller* #: supplies in several languages (``provider_label_zh`` / ``provider_label_en``) #: so each language's template can interpolate its own. This is deliberate and #: better than resolving at build time: the stored context keeps every language, #: so the bell still renders correctly if the reader later switches languages. _LOCALE_SUFFIXES = ("_zh", "_en", "_ja") def _placeholders(text: str) -> set[str]: """Named ``{placeholder}`` fields in a format string.""" return { name.split(".")[0].split("[")[0] for _, name, _, _ in string.Formatter().parse(text) if name } def _placeholder_stems(text: str) -> set[str]: """Placeholders with any locale suffix stripped. Lets the cross-language comparison see ``{level_label_zh}`` and ``{level_label_en}`` as the same slot, while still catching a translation that drops a slot entirely or invents a new one. """ stems = set() for name in _placeholders(text): for suffix in _LOCALE_SUFFIXES: if name.endswith(suffix): name = name[: -len(suffix)] break stems.add(name) return stems def _template_ids() -> list[str]: return sorted(_DEFAULT_TEMPLATES) @pytest.mark.parametrize("key", _template_ids()) def test_every_template_has_all_three_languages(key: str) -> None: """A field present in one language must be present in all three. Asserted per-field rather than per-template so a template that legitimately has no ``card_button_label`` isn't forced to invent one — but a template that has ``subject`` + ``subject_en`` and no ``subject_ja`` fails. """ tmpl = _DEFAULT_TEMPLATES[key] missing: list[str] = [] for field in _LOCALISED_FIELDS: present = [ lang for lang in ("zh", "en", "ja") if (tmpl.get(field if lang == "zh" else f"{field}_{lang}") or "").strip() ] if present and len(present) != 3: absent = sorted({"zh", "en", "ja"} - set(present)) missing.append(f"{field}: has {present}, missing {absent}") assert not missing, ( f"template {key!r} is not fully localised:\n " + "\n ".join(missing) + "\nEvery notification must ship zh + en + ja — a missing variant " "silently sends the wrong language rather than failing." ) @pytest.mark.parametrize("key", _template_ids()) def test_placeholders_match_across_languages(key: str) -> None: """Translations must interpolate the same data as the Chinese baseline. A translation that drops a placeholder quietly withholds information from that language's readers (an alert without its amount); one that invents a placeholder renders a literal ``{foo}`` in the user's card, because the producing call site never put that key on the context. """ tmpl = _DEFAULT_TEMPLATES[key] for field in _LOCALISED_FIELDS: base = tmpl.get(field) if not base: continue expected = _placeholder_stems(base) for lang in ("en", "ja"): variant = tmpl.get(f"{field}_{lang}") if not variant: continue assert _placeholder_stems(variant) == expected, ( f"{key}.{field}_{lang} interpolates " f"{sorted(_placeholder_stems(variant))} but the zh baseline " f"interpolates {sorted(expected)} (locale suffixes ignored)" ) @pytest.mark.parametrize("key", _template_ids()) def test_locale_fragments_are_not_cross_wired(key: str) -> None: """A language variant must not interpolate ANOTHER language's fragment. ``body_text_en`` reaching for ``{level_label_zh}`` would splice Chinese into an English card — the failure mode the stem-insensitive comparison above can't see. """ tmpl = _DEFAULT_TEMPLATES[key] for field in _LOCALISED_FIELDS: for lang in ("zh", "en", "ja"): value = tmpl.get(field if lang == "zh" else f"{field}_{lang}") if not value: continue foreign = { name for name in _placeholders(value) for suffix in _LOCALE_SUFFIXES if name.endswith(suffix) and suffix != f"_{lang}" } assert not foreign, ( f"{key}.{field} ({lang}) interpolates {sorted(foreign)} — a " f"fragment in another language. Use the _{lang} variant." ) @pytest.mark.parametrize("key", _template_ids()) def test_inline_markup_is_balanced(key: str) -> None: """``[b]``/``[r]`` emphasis markers must close. An unbalanced marker leaks a literal ``[r]`` into the Feishu card (the renderer only rewrites matched pairs) — the exact regression that shipped ``[r]$140.76[/r]`` into a production title in 2026-05. """ tmpl = _DEFAULT_TEMPLATES[key] for field, value in tmpl.items(): if not isinstance(value, str): continue for marker in ("b", "r"): opens = len(re.findall(rf"\[{marker}\]", value)) closes = len(re.findall(rf"\[/{marker}\]", value)) assert opens == closes, ( f"{key}.{field}: {opens} [{marker}] vs {closes} [/{marker}]" ) def test_domain_catalogs_do_not_shadow_service_literals() -> None: """The per-domain package only ADDS keys. ``service.py`` merges the domain catalogs with ``if key not in _DEFAULT_TEMPLATES``, so a duplicate would be silently ignored and the domain author's copy would never ship. Fail loudly instead. """ from app.notifications import service as svc_mod inline_only = { k for k in svc_mod._DEFAULT_TEMPLATES if k not in BUILTIN_TEMPLATES } overlap = set(BUILTIN_TEMPLATES) & (set(svc_mod._DEFAULT_TEMPLATES) - inline_only) # Anything in both must resolve to the domain catalog's object; if the # inline literal won, the domain copy is dead code. for key in sorted(overlap): assert svc_mod._DEFAULT_TEMPLATES[key] is BUILTIN_TEMPLATES[key], ( f"template {key!r} is defined both inline in service.py and in the " "domain catalog; the inline one wins and the domain copy is dead. " "Delete one." ) #: Template keys composed at the call site rather than written as literals. #: ``prefix`` → every suffix the producer can pass. Declared because no regex #: can expand an f-string, and an unexpanded family is exactly how three #: onboarding escalation keys shipped Chinese-only past a green build (found #: 2026-08-17 by rendering the production outbox for an ``en_US`` reader — #: 330 of 443 active employees read English). _DYNAMIC_KEY_FAMILIES: dict[str, tuple[str, ...]] = { # app/domains/onboarding/service.py::_dispatch_escalation — # REMINDER_DAY0_IT_GAPS / REMINDER_SLA_URGENT / REMINDER_SLA_BREACH. "onboarding.": ("day0_it_gaps", "sla_urgent", "sla_breach"), # app/domains/leaver_blocked/sla_service.py — SLA level ladder. "leaver_blocked_sla_": ("warn", "urgent", "breach"), # app/domains/email_group/service.py — join-request decision. "mail_group_join_": ("requested", "approved", "rejected"), # app/domains/feed/notifications.py — cron-driven celebration cards. The # role suffix is an f-string AND the self variant is a 3-way conditional, # so neither regex sees any of them; the whole family shipped English-only # to every reader (including the 107 zh_CN ones) until 2026-08-17. "feed.celebration.": ( "self.birthday", "self.anniversary", "self.welcome", "manager.birthday", "manager.anniversary", "hrbp.birthday", "hrbp.anniversary", ), # app/domains/asset/service.py — the key is a multi-line conditional # expression, so ``template_key=`` and the literal never share a line. "asset.borrow_extension.": ("borrower", "stakeholder"), } def test_every_sent_template_key_is_registered() -> None: """No production flow may send a ``template_key`` with no template. An unregistered key falls through to the producer's hardcoded envelope copy, which is single-language by construction — the root cause of the "notification centre is half Chinese half English" reports. """ import pathlib root = pathlib.Path(__file__).resolve().parents[2] / "app" # Two spellings, because a literal-only scan has a blind spot that already # cost us one unregistered key: modules that bind the key to a constant # (``_TEMPLATE_KEY = "wrapped_year_end"``) and pass the NAME. That key sat # unregistered — and therefore Chinese-only — while this test was green. literal = re.compile(r'template_key\s*=\s*["\']([\w.\-]+)["\']') # Any UPPER_SNAKE module constant whose NAME contains TEMPLATE. The # previous spelling anchored on a leading ``[A-Z]`` before the word, which # made it miss the two plainest names in the tree: ``_TEMPLATE_KEY`` (the # leading underscore consumed the anchor) and ``_NOTIFY_TEMPLATE``. Kept # name-scoped rather than "any dotted literal" — the latter matches every # platform-setting key in the repo and drowns the signal. # 2026-09-06: widened again, and for the third variant of the same # blind spot. The name-scoped spelling below only caught constants # containing TEMPLATE, so ``TPL_SUBJECT_RECOVERED = "..."`` — passed # to ``template_key=`` by NAME — matched neither pattern. Both of # the automation-exception echo templates sat unregistered, and # therefore Chinese-only for every recipient, while this test was # green. ``TPL`` is now recognised alongside ``TEMPLATE``. # # Still name-scoped rather than "any dotted literal": the latter # matches every platform-setting key in the repo and drowns the # signal, which is why the original author scoped it this way. constant = re.compile( r'^\s*_?[A-Z0-9_]*(?:TEMPLATE|TPL)[A-Z0-9_]*\s*[:=][^=]*?["\']([\w.\-]+)["\']' ) used: dict[str, str] = {} for path in root.rglob("*.py"): for lineno, line in enumerate(path.read_text().splitlines(), 1): for pattern in (literal, constant): for match in pattern.finditer(line): used.setdefault( match.group(1), f"{path.relative_to(root.parent)}:{lineno}" ) # Keys composed at the call site (``template_key=f"onboarding.{kind}"``). # A regex cannot expand those, so the families are declared here: every # member must resolve, and adding a kind without its copy fails the build # rather than shipping single-language copy to whoever receives it. for family, members in _DYNAMIC_KEY_FAMILIES.items(): for member in members: used.setdefault(family + member, f"") # ``template_key=`` is spelled the same on two unrelated payloads, so the # scan needs an exemption list rather than a smarter regex. exempt = { # Dead key: nothing sends it, migration 278 deleted the orphan # ``email_template.welcome`` row. Present only in the admin catalog's # own exclusion list. "welcome", # NOT notification templates — these are ``AssetApprovalSubmitRequest`` # / Feishu *approval* template ids, resolved against # ``ApprovalService.template_config()``. Registering them here would # add catalog entries nothing ever looks up. "offboarding_buyback", "software_addon", "software_seat", # Deliberately unregistered delivery copies of ``hire_day_it_info`` # (which IS registered and fully localised): # * ``.feishu_card`` — the card's stats/sections are composed by # ``_build_welcome_card_fields`` keyed on the labour-contract # entity (HR Ops 2026-07-15, see DECISIONS.md), while a template # would re-render title+body keyed on ``preferred_language``. # Registering it would put two different language rules on one card. # * ``.hr_cc`` — intentionally renders in the *hire's* language # (byte-identical to what the hire received) and carries a # ``[新人入职] {name} ·`` prefix; re-rendering in the HR reader's # language would break exactly that contract. # Both are ``hidden_from_inbox``, so there is no bell to localise. "hire_day_it_info.feishu_card", "hire_day_it_info.hr_cc", # Dead: ``_notify_admins_pending`` was removed 2026-05-12. The DB # override rows survive; nothing reads them. "cloud.scope_request.pending", # Dead as a SEND key: the three procurement reminders were split into # ``procurement.reminder.contract_expiry`` / ``.budget_threshold`` / # ``.order_late`` (all registered). ``_LEGACY_REMINDER_TEMPLATE_KEY`` # survives only inside ``_REMINDER_TEMPLATE_KEYS``, the read-only # ``template_key.in_(...)`` dedup look-back that keeps the first run # after the split from re-nagging anyone whose same-week reminder was # written under the old key. No dispatch path passes it, so a template # here would be copy nothing ever renders. "procurement.reminder", # NOT a template key: ``f"email_template.{key}"`` is the # platform_settings prefix an admin override is stored under # (app/integrations/router.py). The widened constant scan sees the # bare prefix literal. "email_template.", # NOT a notification template: the ISMS assessment catalog's default # bundle id (app/domains/auditor/assessment_catalog.py). "isms_default", # Deliberately non-templated: ``asset/oversight_notifier.py`` composes # the title AND body per recipient behind ``_is_en(person)``, so the # copy is already language-correct at the producer. Registering a # template would put a SECOND language rule on the same card — the # ``hire_day_it_info.feishu_card`` failure mode one domain over. "asset.oversight.operation", } missing = { key: where for key, where in used.items() if key not in _DEFAULT_TEMPLATES and key not in exempt } assert not missing, ( "these template_keys are sent but have no registered template, so " "recipients get the producer's hardcoded language:\n " + "\n ".join(f"{k} — {v}" for k, v in sorted(missing.items())) ) class TestLocaleResolution: """``app.core.locale`` — the values production actually stores.""" @pytest.mark.parametrize( ("raw", "expected"), [ ("zh-CN", "zh"), ("zh_CN", "zh"), ("ZH", "zh"), ("zh", "zh"), ("en-US", "en"), ("en_US", "en"), ("EN", "en"), ("ja-JP", "ja"), ("ja_JP", "ja"), # Free-text values seeded from SuccessFactors' defaultLocale — # these are the ones a naive startswith() test got wrong. ("english", "en"), ("English", "en"), ("chinese", "zh"), ("Chinese", "zh"), ("中文", "zh"), ("japanese", "ja"), ("日本語", "ja"), ("", None), (None, None), ("klingon", None), ], ) def test_normalize_covers_legacy_spellings( self, raw: str | None, expected: str | None, ) -> None: assert normalize_lang(raw) == expected def test_japanese_degrades_to_english_not_chinese(self) -> None: """A JP reader should see English before Chinese when copy is missing.""" assert FALLBACK_CHAIN["ja"] == ("ja", "en", "zh") tmpl = {"subject": "中文", "subject_en": "English"} assert pick_localized(tmpl, "subject", "ja") == "English" def test_exact_variant_wins_over_fallback(self) -> None: tmpl = {"subject": "中文", "subject_en": "English", "subject_ja": "日本語"} assert pick_localized(tmpl, "subject", "ja") == "日本語" def test_missing_everywhere_returns_none(self) -> None: assert pick_localized({}, "subject", "en") is None class TestCountryFallback: """``country`` decides the language when no preference is stored. The column carries alpha-2, alpha-3 AND free text (its own docstring says "typically a 2-letter ISO-3166 code (e.g. CN, US)"). An alpha-3-only table silently routed every ``CN`` / ``HK`` / ``TW`` employee to English — a mass re-targeting of people who had been receiving Chinese, dressed up as a bug-fix. """ class _P: def __init__(self, pl=None, country=None, cn_entity=False): self.preferred_language = pl self.country = country self.is_china_employee = cn_entity @pytest.mark.parametrize( ("country", "expected"), [ ("CN", "zh"), ("CHN", "zh"), ("cn", "zh"), ("china", "zh"), ("HK", "zh"), ("HKG", "zh"), ("TW", "zh"), ("TWN", "zh"), ("MO", "zh"), ("JP", "ja"), ("JPN", "ja"), ("japan", "ja"), ("SG", "en"), ("US", "en"), ("DE", "en"), ], ) def test_both_iso_shapes_resolve(self, country: str, expected: str) -> None: from app.core.locale import resolve_lang assert resolve_lang(self._P(country=country)) == expected def test_china_contract_abroad_keeps_chinese(self) -> None: """Entity is checked BEFORE the English fallback. A China-contract employee posted to Singapore reads Chinese; ordering this check after the country fallback made it unreachable, because ``country`` is nearly always populated. """ from app.core.locale import resolve_lang assert resolve_lang(self._P(country="SGP", cn_entity=True)) == "zh" assert resolve_lang(self._P(country="SGP", cn_entity=False)) == "en" def test_explicit_preference_still_wins(self) -> None: from app.core.locale import resolve_lang assert resolve_lang(self._P("en_US", "CN", True)) == "en" assert resolve_lang(self._P("chinese", "US", False)) == "zh"