|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #17614 — an `emailTemplates` bundle tagged with the stack's OWN |
| 4 | +// `i18n.defaultLocale` has no fallback floor. |
| 5 | +// |
| 6 | +// Measured before this landed, on an unmodified tree: a stack declaring |
| 7 | +// `defaultLocale: 'en'`, `supportedLocales: ['en','zh-CN','ja-JP','es-ES']` |
| 8 | +// and one row per those tags parsed with ZERO warnings and ZERO errors, and |
| 9 | +// the resolver then answered `TEMPLATE_NOT_FOUND` — which classifies |
| 10 | +// PERMANENT, so the delivery dead-letters with no retry — for `de-DE` and for |
| 11 | +// the literal `en-US`. The identical bundle with its English row tagged |
| 12 | +// `en-US` delivered for `de-DE`. `sendTemplate` matches `(name, locale)` |
| 13 | +// exactly and retries exactly one rung, the literal `en-US`; there is no |
| 14 | +// language-subtag folding, and that ladder's shape is a settled ruling this |
| 15 | +// change deliberately does not touch. The remedy is the bundle, so the |
| 16 | +// diagnostic is where the author is standing. |
| 17 | +// |
| 18 | +// These pin the diagnostic ADVISORY: every case asserts the parse still |
| 19 | +// succeeds and the stack comes back unchanged. The diagnostic narrows what |
| 20 | +// passes SILENTLY; it moves no accept set. |
| 21 | + |
| 22 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 23 | +import { defineStack } from './stack.zod'; |
| 24 | +import { EmailTemplateDefinitionSchema, EMAIL_TEMPLATE_FLOOR_LOCALE } from './system/email-template.zod'; |
| 25 | + |
| 26 | +const MANIFEST = { id: 'acme', name: 'acme', version: '1.0.0', namespace: 'acme', type: 'app' as const }; |
| 27 | + |
| 28 | +const tpl = (locale: string | undefined, name = 'acme.welcome') => ({ |
| 29 | + name, |
| 30 | + label: `Welcome ${locale ?? '(default)'}`, |
| 31 | + ...(locale === undefined ? {} : { locale }), |
| 32 | + subject: `[${locale ?? 'default'}] Hi {{name}}`, |
| 33 | + bodyHtml: `<p>[${locale ?? 'default'}] Hi {{name}}</p>`, |
| 34 | +}); |
| 35 | + |
| 36 | +const stack = (tpls: unknown[], i18n?: unknown) => ({ |
| 37 | + manifest: MANIFEST, |
| 38 | + ...(i18n === undefined ? {} : { i18n }), |
| 39 | + emailTemplates: tpls, |
| 40 | +}) as never; |
| 41 | + |
| 42 | +const THE_TRAP = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN', 'ja-JP', 'es-ES'], fallbackLocale: 'en' }; |
| 43 | + |
| 44 | +/** Run `defineStack` capturing every `console.warn` it emits. */ |
| 45 | +function warningsOf(config: unknown): { warns: string[]; value: ReturnType<typeof defineStack> } { |
| 46 | + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 47 | + try { |
| 48 | + const value = defineStack(config as never); |
| 49 | + return { warns: spy.mock.calls.map((c) => c.map(String).join(' ')), value }; |
| 50 | + } finally { |
| 51 | + spy.mockRestore(); |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +const floorWarns = (warns: string[]) => warns.filter((w) => w.includes('no fallback floor')); |
| 56 | + |
| 57 | +afterEach(() => { |
| 58 | + // The reporter is warn-once per (bundle, tags); each test authors its own |
| 59 | + // tag set so the cache cannot mask a case, but keep runs independent. |
| 60 | + vi.restoreAllMocks(); |
| 61 | +}); |
| 62 | + |
| 63 | +describe('#17614 — the floor tag is one named constant, not two spellings', () => { |
| 64 | + it('is the schema default for `locale`, so a row that omits the key HAS the floor', () => { |
| 65 | + expect(EMAIL_TEMPLATE_FLOOR_LOCALE).toBe('en-US'); |
| 66 | + const parsed = EmailTemplateDefinitionSchema.parse({ |
| 67 | + name: 'acme.welcome', label: 'W', subject: 's', bodyHtml: '<p>b</p>', |
| 68 | + }); |
| 69 | + expect(parsed.locale).toBe(EMAIL_TEMPLATE_FLOOR_LOCALE); |
| 70 | + }); |
| 71 | + |
| 72 | + it("states the two facts an author needs in the published `describe` — the floor tag and what a miss costs", () => { |
| 73 | + // The `describe` is the authoring surface (it lands in the generated |
| 74 | + // reference docs). The rule used to live only in a docstring two packages |
| 75 | + // away, under a heading about a different subject — which is the finding. |
| 76 | + const text = String(EmailTemplateDefinitionSchema.shape.locale.description ?? ''); |
| 77 | + expect(text).toContain('en-US'); |
| 78 | + expect(text).toContain('TEMPLATE_NOT_FOUND'); |
| 79 | + }); |
| 80 | +}); |
| 81 | + |
| 82 | +describe('#17614 — defineStack reports a bundle with no `en-US` floor', () => { |
| 83 | + it('reports the trap: every supportedLocales tag authored, none of them the floor', () => { |
| 84 | + const { warns, value } = warningsOf(stack(['en', 'zh-CN', 'ja-JP', 'es-ES'].map((l) => tpl(l)), THE_TRAP)); |
| 85 | + const hits = floorWarns(warns); |
| 86 | + expect(hits).toHaveLength(1); |
| 87 | + expect(hits[0]).toContain("emailTemplates 'acme.welcome'"); |
| 88 | + expect(hits[0]).toContain("none tagged 'en-US'"); |
| 89 | + expect(hits[0]).toContain('TEMPLATE_NOT_FOUND'); |
| 90 | + // ADVISORY — the accept set did not move: the stack parsed and came back |
| 91 | + // carrying exactly the tags the author wrote. |
| 92 | + expect(value.emailTemplates?.map((t) => t.locale)).toEqual(['en', 'zh-CN', 'ja-JP', 'es-ES']); |
| 93 | + }); |
| 94 | + |
| 95 | + it('reports a single-row bundle tagged with the stack default — the consistent thing an author writes', () => { |
| 96 | + const { warns, value } = warningsOf(stack([tpl('en')], THE_TRAP)); |
| 97 | + expect(floorWarns(warns)).toHaveLength(1); |
| 98 | + expect(value.emailTemplates).toHaveLength(1); |
| 99 | + }); |
| 100 | + |
| 101 | + it('reports each bundle separately, by name', () => { |
| 102 | + const { warns } = warningsOf(stack( |
| 103 | + [tpl('en', 'acme.welcome'), tpl('zh-CN', 'acme.welcome'), tpl('en', 'acme.reset')], |
| 104 | + THE_TRAP, |
| 105 | + )); |
| 106 | + const hits = floorWarns(warns); |
| 107 | + expect(hits).toHaveLength(2); |
| 108 | + expect(hits.some((w) => w.includes("'acme.welcome'"))).toBe(true); |
| 109 | + expect(hits.some((w) => w.includes("'acme.reset'"))).toBe(true); |
| 110 | + }); |
| 111 | +}); |
| 112 | + |
| 113 | +describe('#17614 — and stays silent where the bundle HAS a floor (the controls)', () => { |
| 114 | + it('silent when the bundle carries an en-US row beside the supported tags', () => { |
| 115 | + const { warns, value } = warningsOf(stack( |
| 116 | + ['en-US', 'zh-CN', 'ja-JP', 'es-ES'].map((l) => tpl(l)), THE_TRAP, |
| 117 | + )); |
| 118 | + expect(floorWarns(warns)).toEqual([]); |
| 119 | + expect(value.emailTemplates).toHaveLength(4); |
| 120 | + }); |
| 121 | + |
| 122 | + it('silent when the row omits `locale` entirely — the schema default already IS the floor', () => { |
| 123 | + // Measured, by ablation: moving the call pre-parse does NOT break this — |
| 124 | + // the reader mirrors the schema default for a missing key, so the two |
| 125 | + // agree and the case is silent either way. The pin is the behaviour (an |
| 126 | + // omitted `locale` is never reported), not the call site. |
| 127 | + const { warns, value } = warningsOf(stack([tpl(undefined)], THE_TRAP)); |
| 128 | + expect(floorWarns(warns)).toEqual([]); |
| 129 | + expect(value.emailTemplates?.[0]?.locale).toBe(EMAIL_TEMPLATE_FLOOR_LOCALE); |
| 130 | + }); |
| 131 | + |
| 132 | + it('silent when the stack declares no supportedLocales to measure against', () => { |
| 133 | + const { warns } = warningsOf(stack([tpl('en')])); |
| 134 | + expect(floorWarns(warns)).toEqual([]); |
| 135 | + }); |
| 136 | + |
| 137 | + it('silent when no authored tag is one this stack claims to support', () => { |
| 138 | + const { warns } = warningsOf(stack([tpl('fr-CA')], { defaultLocale: 'en', supportedLocales: ['en'] })); |
| 139 | + expect(floorWarns(warns)).toEqual([]); |
| 140 | + }); |
| 141 | + |
| 142 | + it('warns once for one bundle, however many times the same stack is defined', () => { |
| 143 | + const first = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] })); |
| 144 | + const second = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] })); |
| 145 | + expect(floorWarns(first.warns)).toHaveLength(1); |
| 146 | + expect(floorWarns(second.warns)).toEqual([]); |
| 147 | + }); |
| 148 | +}); |
0 commit comments