diff --git a/.changeset/27257-localization-read-failed.md b/.changeset/27257-localization-read-failed.md new file mode 100644 index 00000000000..162cbdb0288 --- /dev/null +++ b/.changeset/27257-localization-read-failed.md @@ -0,0 +1,13 @@ +--- +'@objectstack/core': patch +'@objectstack/service-messaging': patch +--- + +fix(core, service-messaging): a failed settings read no longer dead-letters a templated mail as `TEMPLATE_INVALID`; only a read that finds no default currency is permanent (#27257) + +Clause-②: yes (widening) — `resolveLocalizationContext`'s result (`@objectstack/core`) gains one optional member, `readFailed?: true`. Nothing is narrowed. +Metadata-protocol: none + +- **Before.** `resolveLocalizationContext` answered `currency: undefined` both when the organization has no default currency and when its `sys_setting` read failed (the failure is memoized for 30 s). So a messaging delivery whose template has a `{{ amount | currency }}` hole with no argument was refused `TEMPLATE_INVALID` during a transient settings fault, graded permanent and never sent, although a retry would have worked. +- **Now.** The answer carries `readFailed: true` when the direct `sys_setting` read threw, on the fresh read and on a hit of the failure memo alike. A settings-service refusal does not count, and the memo is not lengthened. Messaging's tenant-currency read rejects on it; the email and inbox channels then render without a currency. When that render answers `TEMPLATE_INVALID`, they render the template once more without sending, with the no-currency code `XXX` as the default: if that passes, the missing default was the only refusal, and the delivery is recorded without a `TEMPLATE_*` code and retried; if it still refuses, that refusal stands and is graded permanent, as before. Once the read succeeds, an organization with no default currency gets `TEMPLATE_INVALID` with its remedy, graded permanent, as before. +- **Unchanged.** A template with no no-argument `currency` hole is sent during a settings fault, as before. Every other template failure keeps its grade. The other callers of `resolveLocalizationContext` see the same values; the new member is optional and absent on every successful read. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 67543e2136d..714ff6b5aa5 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -455,7 +455,8 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022 const second = await resolveLocalizationContext({ ql, organizationId: 'o1' }); // Functional behavior is unchanged — still the built-in defaults — the // second call just doesn't repeat the failing query (and its log line). - expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined }); + // [#27257] Both say the read failed, the memoized one included. + expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined, readFailed: true }); expect(second).toEqual(first); expect(ql.counts.sys_setting).toBe(1); }); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index d34d5b8ff94..839cc0ff281 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -2211,7 +2211,22 @@ export interface ResolveLocalizationInput { userId?: string; } -type LocalizationResult = { timezone: string; locale: string; currency?: string }; +type LocalizationResult = { + timezone: string; + locale: string; + currency?: string; + /** + * Present only when the direct `sys_setting` read THREW (a backend fault), + * on a fresh read and on a memoized one alike: every value above is then the + * built-in default, not a stored answer. Absent, a missing `currency` means + * the read succeeded and the organization has no default, which is permanent + * until someone sets one; present, it means nothing was learned, and a caller + * that would refuse work for want of a default retries instead. A + * settings-service refusal never sets it (#11877), and it does not lengthen + * the failure memo. (#27257) + */ + readFailed?: true; +}; /** * Process-local TTL cache for the FAILED-READ outcome of @@ -2630,7 +2645,9 @@ function putLocalizationEntry(ql: object, key: string, entry: LocalizationCacheE * {@link LOCALIZATION_FAILURE_CACHE_TTL_MS} per `(ql, organizationId, userId)` so * the failing query — and the driver's log line for it — does not repeat * every request (#10221). A settings-service refusal is not a backend fault - * and never populates that memo (#11877). + * and never populates that memo (#11877). Such an answer carries + * `readFailed: true`, fresh and memoized alike, so a caller can tell "the read + * failed" from "the read found no default" (#27257). * * A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only * when the engine carries the write-epoch and middleware seams, and only until @@ -2667,7 +2684,10 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput } } - const { value, backendFailed } = await resolveLocalizationContextUncached(input); + const { value: read, backendFailed } = await resolveLocalizationContextUncached(input); + // [#27257] The kind travels ON the answer, and the memo stores that same + // answer, so a hit inside the failure TTL still says the read failed. + const value: LocalizationResult = backendFailed ? { ...read, readFailed: true } : read; if (!cacheable) return value; if (backendFailed) { diff --git a/packages/core/src/security/resolve-localization-cache.test.ts b/packages/core/src/security/resolve-localization-cache.test.ts index a985c7a9a03..3648630f5aa 100644 --- a/packages/core/src/security/resolve-localization-cache.test.ts +++ b/packages/core/src/security/resolve-localization-cache.test.ts @@ -463,6 +463,7 @@ describe('resolveLocalizationContext — the failure memo survives what retires timezone: 'UTC', locale: 'en-US', currency: undefined, + readFailed: true, }); expect(ql.counts.sys_setting).toBe(1); diff --git a/packages/core/src/security/resolve-localization-read-failed.test.ts b/packages/core/src/security/resolve-localization-read-failed.test.ts new file mode 100644 index 00000000000..5805ae16864 --- /dev/null +++ b/packages/core/src/security/resolve-localization-read-failed.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27257] `resolveLocalizationContext` tells "the read failed" from "the read +// found no default". A messaging channel refuses a mail whose +// `{{ amount | currency }}` hole has no default, and grades that refusal +// permanent; it may do so only when the read succeeded. So the answer carries +// `readFailed: true` exactly when the direct `sys_setting` read threw, on the +// fresh read AND on the hit the failure memo serves inside its TTL, and never +// otherwise. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveLocalizationContext } from './resolve-authz-context.js'; + +/** A `sys_setting` read answering `rows`, recording every read. */ +function makeQl(rows: Array>) { + const reads: string[] = []; + return { + reads, + async find(object: string, o: any) { + reads.push(object); + const where = o?.where ?? {}; + const matched = rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + return r[k] === v; + }), + ); + // Hold the caller's bound, after the filter and by presence. + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + }; +} + +/** A `sys_setting` read that throws: the backend fault the failure memo is for. */ +function makeBrokenQl() { + const reads: string[] = []; + return { + reads, + async find(object: string): Promise { + reads.push(object); + throw new Error('connection reset reading sys_setting'); + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('resolveLocalizationContext — a failed read says so (#27257)', () => { + it('a direct read that throws answers the built-ins WITH `readFailed: true`', async () => { + const ql = makeBrokenQl(); + const answer = await resolveLocalizationContext({ ql, organizationId: 'o1' }); + expect(answer).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined, readFailed: true }); + expect(ql.reads).toHaveLength(1); + }); + + it('the failure memo\'s hit keeps the kind: no second read, still `readFailed: true`', async () => { + const ql = makeBrokenQl(); + await resolveLocalizationContext({ ql, organizationId: 'o1' }); + const hit = await resolveLocalizationContext({ ql, organizationId: 'o1' }); + expect(ql.reads).toHaveLength(1); // served by the memo, not a fresh read + expect(hit.readFailed).toBe(true); + expect(hit.currency).toBeUndefined(); + }); + + it('a read that SUCCEEDS and finds no default carries no `readFailed` at all', async () => { + const ql = makeQl([ + { namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' }, + ]); + const answer = await resolveLocalizationContext({ ql, organizationId: 'o1' }); + expect(answer.timezone).toBe('Asia/Tokyo'); + expect(answer.currency).toBeUndefined(); + expect(answer).not.toHaveProperty('readFailed'); + }); + + it('a settings-service refusal in front of a SUCCESSFUL direct read is not a failed read (#11877)', async () => { + const settings = { + get: async () => { throw new Error('unused'); }, + getMany: async () => { throw new Error("unknown settings namespace 'localization'"); }, + }; + const ql = makeQl([]); + const answer = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' }); + expect(answer.currency).toBeUndefined(); + expect(answer).not.toHaveProperty('readFailed'); + }); + + it('the memo is not lengthened: past its TTL a recovered read answers the stored default, no kind', async () => { + vi.useFakeTimers(); + const broken = makeBrokenQl(); + const healthy = makeQl([ + { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'o1', value: 'CNY' }, + ]); + // ONE engine (the memo is keyed on it), whose backend recovers mid-test. + let backend: { find(object: string, o?: any): Promise } = broken; + const ql = { find: (object: string, o: any) => backend.find(object, o) }; + expect((await resolveLocalizationContext({ ql, organizationId: 'o1' })).readFailed).toBe(true); + backend = healthy; + await vi.advanceTimersByTimeAsync(30_001); + const recovered = await resolveLocalizationContext({ ql, organizationId: 'o1' }); + expect(recovered.currency).toBe('CNY'); + expect(recovered).not.toHaveProperty('readFailed'); + expect(broken.reads.length + healthy.reads.length).toBe(2); + }); +}); diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 0769f53a753..b0128a4ff33 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -117,10 +117,58 @@ export interface EmailChannelOptions { * hole with no argument formats in. `undefined` (no default set, or not * wired) leaves the render without one, so such a hole refuses the delivery * with `TEMPLATE_INVALID` instead of formatting in a guessed currency. + * REJECTS when the read itself failed: the render then runs without one, + * and a `TEMPLATE_INVALID` that a default would cure is graded retryable + * ({@link templateRefusalAfterCurrencyReadFault}). */ getTenantCurrency?(organizationId: string | undefined): Promise; } +/** + * The ISO 4217 code for "no currency" (`XXX`, formatted `¤1,234.50`): the + * stand-in default {@link templateRefusalAfterCurrencyReadFault} renders with + * to ask whether a default would cure a refusal. Never sent: the probe only + * renders. + */ +const NO_CURRENCY_PROBE = 'XXX'; + +/** + * [#27257] The row error of a templated delivery whose render failed. A read + * that FAILED is not a read that found no default: when `getTenantCurrency` + * rejected and the render then answered `TEMPLATE_INVALID`, the template is + * rendered once more WITHOUT sending (`renderWithDefault`), with + * {@link NO_CURRENCY_PROBE} as the default. + * + * - The probe renders: the only refusal was the missing default, which the + * read would have supplied. The error is rewritten WITHOUT a `TEMPLATE_*` + * code, so `classifyError` grades it retryable. + * - The probe still answers `TEMPLATE_INVALID`: a default cannot cure the + * template (a hole the grammar refuses, an `@path` value that is not a + * code). That refusal is returned as it stands, graded permanent. + * - No probe (an email service without `renderTemplate`) or a probe failing + * any other way: the cause cannot be told apart, and the read fault is + * transient, so it is rewritten and retried. + * + * Any other failure, and every refusal after a read that succeeded, is + * returned unchanged. Shared by the email and inbox channels. + */ +export async function templateRefusalAfterCurrencyReadFault( + message: string, + currencyReadFault: string | undefined, + renderWithDefault: ((currency: string) => Promise) | undefined, +): Promise { + if (currencyReadFault === undefined || !/\bTEMPLATE_INVALID\b/.test(message)) return message; + if (renderWithDefault) { + try { + await renderWithDefault(NO_CURRENCY_PROBE); + } catch (err) { + const standing = (err as Error)?.message ?? String(err); + if (/\bTEMPLATE_INVALID\b/.test(standing)) return standing; + } + } + return `the template was not rendered: the organization's default currency could not be read (${currencyReadFault}); the delivery is retried until that read succeeds`; +} + /** * The ONE token both members of this channel use for "there is no transport" * (commit 879b51270) — the reason `isAvailable()` already returns, reused verbatim so the @@ -351,8 +399,16 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel } const templateLocale = recipientLocale; const data = (payload.templateData ?? undefined) as Record | undefined; + let currency: string | undefined; + let currencyReadFault: string | undefined; + try { + currency = await opts.getTenantCurrency?.(n.organizationId); + } catch (err) { + // The read failed and learned nothing: render without a + // currency, and grade a refusal below (#27257). + currencyReadFault = (err as Error)?.message ?? String(err); + } try { - const currency = await opts.getTenantCurrency?.(n.organizationId); const result = (await email.sendTemplate({ template: templateName, to: address, @@ -377,8 +433,21 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel // TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_INVALID) // arrives as a thrown Error — keep the code at the front of // the row's error so classifyError() below can grade it - // permanent. - return { ok: false, error: (err as Error)?.message ?? String(err) }; + // permanent — unless the currency read failed first. + const renderTemplate = email.renderTemplate?.bind(email); + return { + ok: false, + error: await templateRefusalAfterCurrencyReadFault( + (err as Error)?.message ?? String(err), + currencyReadFault, + renderTemplate && ((probe) => renderTemplate({ + template: templateName, + ...(data !== undefined ? { data } : {}), + ...(templateLocale ? { locale: templateLocale } : {}), + ...{ currency: probe }, + })), + ), + }; } } diff --git a/packages/services/service-messaging/src/inbox-channel.ts b/packages/services/service-messaging/src/inbox-channel.ts index 50107504259..00f2a7a0fee 100644 --- a/packages/services/service-messaging/src/inbox-channel.ts +++ b/packages/services/service-messaging/src/inbox-channel.ts @@ -8,7 +8,7 @@ import type { MessagingChannelContext, SendResult, } from './channel.js'; -import type { EmailSenderSurface } from './email-channel.js'; +import { templateRefusalAfterCurrencyReadFault, type EmailSenderSurface } from './email-channel.js'; import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; import { assertActorReferenceResolves } from './actor-reference.js'; @@ -61,6 +61,9 @@ export interface InboxChannelOptions { * hole with no argument formats in. `undefined` (no default set, or not * wired) leaves the render without one, so such a hole refuses the delivery * with `TEMPLATE_INVALID` instead of formatting in a guessed currency. + * REJECTS when the read itself failed: the render then runs without one, + * and a `TEMPLATE_INVALID` that a default would cure is graded retryable + * (`templateRefusalAfterCurrencyReadFault` in `email-channel.ts`). */ getTenantCurrency?(organizationId: string | undefined): Promise; /** User identity object the recipient's locale is read from (default `sys_user`). */ @@ -194,8 +197,16 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel deploymentLocale, ); const templateData = (payload.templateData ?? undefined) as Record | undefined; + let currency: string | undefined; + let currencyReadFault: string | undefined; + try { + currency = await opts.getTenantCurrency?.(n.organizationId); + } catch (err) { + // The read failed and learned nothing: render without a + // currency, and grade a refusal below (#27257). + currencyReadFault = (err as Error)?.message ?? String(err); + } try { - const currency = await opts.getTenantCurrency?.(n.organizationId); const rendered = await email.renderTemplate({ template: templateName, ...(templateData !== undefined ? { data: templateData } : {}), @@ -209,8 +220,20 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel // TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_INVALID) // arrives as a thrown Error — keep the code at the front of // the row's error so classifyError() below can grade it - // permanent. - return { ok: false, error: (err as Error)?.message ?? String(err) }; + // permanent — unless the currency read failed first. + return { + ok: false, + error: await templateRefusalAfterCurrencyReadFault( + (err as Error)?.message ?? String(err), + currencyReadFault, + (probe) => email.renderTemplate!({ + template: templateName, + ...(templateData !== undefined ? { data: templateData } : {}), + ...(templateLocale ? { locale: templateLocale } : {}), + ...{ currency: probe }, + }), + ), + }; } } diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 7a0043bb8e6..a408130a1d8 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -268,17 +268,20 @@ export class MessagingServicePlugin implements Plugin { // formats in. Read through `resolveLocalizationContext`, the reader a // request resolves `ExecutionContext.currency` with, so a mail and the // record's currency field show the same currency. Probed at delivery - // time like the locale above; a failed read leaves it unset, and the - // render then refuses such a hole by name rather than guess one. + // time like the locale above. A read that found no default answers + // `undefined`, and the render then refuses such a hole by name rather + // than guess one. [#27257] A read that FAILED rejects instead: it + // learned nothing, so the channel renders without a currency and, if + // the render refuses for want of one, grades the delivery retryable. const getTenantCurrency = async (organizationId: string | undefined): Promise => { let settings: unknown; try { settings = ctx.getService('settings'); } catch { settings = undefined; } - try { - const localization = await resolveLocalizationContext({ ql: getData(), settings, organizationId }); - return localization.currency; - } catch { - return undefined; + const localization = await resolveLocalizationContext({ ql: getData(), settings, organizationId }); + if (localization.readFailed) { + const of = organizationId ? ` of organization '${organizationId}'` : ''; + throw new Error(`the localization settings${of} could not be read`); } + return localization.currency; }; // [#24978] The `i18n` service whose catalog a keyed notification text diff --git a/packages/services/service-messaging/src/template-tenant-currency.test.ts b/packages/services/service-messaging/src/template-tenant-currency.test.ts index 24c137b43c3..0f17027da80 100644 --- a/packages/services/service-messaging/src/template-tenant-currency.test.ts +++ b/packages/services/service-messaging/src/template-tenant-currency.test.ts @@ -13,6 +13,8 @@ import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineU import { createEmailChannel } from './email-channel.js'; import { createInboxChannel } from './inbox-channel.js'; import { NotificationTemplateStore } from './template-renderer.js'; +import { MessagingServicePlugin } from './messaging-service-plugin.js'; +import type { MessagingService } from './messaging-service.js'; import type { Delivery } from './channel.js'; function silentCtx() { @@ -159,3 +161,178 @@ describe('inbox channel: the template arm carries the organization\'s default cu expect(r.calls[0]).not.toHaveProperty('currency'); }); }); + +// [#27257] Through the plugin's own wiring: `getTenantCurrency` is +// `resolveLocalizationContext` over the `data` engine, and a `sys_setting` read +// that FAILED must not be graded like one that found no default. The first +// learned nothing, so a refusal it caused is retried; only the second is +// permanent, with the render's prescription on the row. +describe('a failed settings read is retried; a read that finds no default is permanent', () => { + const REFUSAL = + 'TEMPLATE_INVALID: email template crm.large_deal_won (locale=en-US): the hole {{ amount | currency }} has no currency argument and the organization has no default currency; set localization.currency, or write currency:EUR'; + + /** The plugin's `data` engine: its `sys_setting` read answers `rows`, or throws when `broken`. */ + function settingsEngine(opts: { broken: boolean }, rows: Array> = []) { + const base = fakeData(); + const reads: string[] = []; + const healthyFind = async (object: string, query: any) => { + if (object !== 'sys_setting') return []; + reads.push(object); + const where = query?.where ?? {}; + const matched = rows.filter((r) => r.organization_id === where.organization_id && where.key?.$in?.includes(r.key)); + // Hold the caller's bound, after the filter and by presence. + return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; + }; + const brokenFind = async (object: string): Promise => { + if (object !== 'sys_setting') return []; + reads.push(object); + throw new Error('connection reset reading sys_setting'); + }; + return { reads, engine: { ...base, find: opts.broken ? brokenFind : healthyFind } }; + } + + /** The render the real `email` service does: a no-argument hole needs a `currency`. */ + /** A refusal no default cures: a `currency:@path` hole whose value is not a code. */ + const PATH_REFUSAL = + "TEMPLATE_INVALID: email template crm.large_deal_won (locale=en-US): the hole {{ amount | currency:@deal.code }} reads 'dollars' at deal.code, which is not an ISO 4217 code"; + + /** + * The render the real `email` service does. `missing-currency`: a no-argument + * hole needs a `currency`. `path-value`: an `@path` hole refuses whatever the + * default. `both`: the first refusal met is the missing default, and with a + * default the `@path` one still stands. + */ + function mailService(opts: { refuses?: 'missing-currency' | 'path-value' | 'both' | 'not-found' } = {}) { + const calls: any[] = []; + let sent = 0; + const render = (input: any) => { + calls.push(input); + if (opts.refuses === 'not-found') throw new Error('TEMPLATE_NOT_FOUND: email template crm.large_deal_won'); + const needsDefault = opts.refuses === 'missing-currency' || opts.refuses === 'both'; + if (needsDefault && !input.currency) throw new Error(REFUSAL); + if (opts.refuses === 'path-value' || opts.refuses === 'both') throw new Error(PATH_REFUSAL); + return { subject: 'Deal won', html: '

x

', text: `Won ${input.currency ?? ''}` }; + }; + return { + calls, + sent: () => sent, + service: { + async send() { return { id: 'x' }; }, + async sendTemplate(input: any) { render(input); sent += 1; return { id: 'email_row_1', status: 'sent' }; }, + async renderTemplate(input: any) { return render(input); }, + }, + }; + } + + async function bootPlugin(engine: unknown, email: unknown) { + const services = new Map([['data', engine], ['email', email], ['manifest', { register() {} }]]); + const readyHooks: Array<() => Promise | void> = []; + const ctx = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => ctx.logger }, + getService(name: string) { + if (!services.has(name)) throw new Error(`service '${name}' is not registered`); + return services.get(name); + }, + registerService(name: string, svc: unknown) { services.set(name, svc); }, + hook(event: string, fn: () => Promise | void) { if (event === 'kernel:ready') readyHooks.push(fn); }, + } as any; + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); + for (const fn of readyHooks) await fn(); + return { ctx, messaging: services.get('messaging') as MessagingService }; + } + + for (const channelId of ['email', 'inbox'] as const) { + describe(`${channelId} channel`, () => { + it('a failed read renders without a currency; the refusal it causes is graded retryable, never TEMPLATE_*', async () => { + const data = settingsEngine({ broken: true }); + const mail = mailService({ refuses: 'missing-currency' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel(channelId)!; + const r = await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(data.reads).toHaveLength(1); + expect(mail.calls[0]).not.toHaveProperty('currency'); + // The probe: rendered once more with the "no currency" code, never sent. + expect(mail.calls).toHaveLength(2); + expect(mail.calls[1].currency).toBe('XXX'); + expect(mail.sent()).toBe(0); + expect(r.ok).toBe(false); + expect(r.error).not.toMatch(/TEMPLATE_/); + expect(r.error).toContain("organization 'org_apex'"); + expect(channel.classifyError?.(r.error)).toBe('retryable'); + }); + + it('a failed read served from the failure memo keeps its kind: retryable again, with no second read', async () => { + const data = settingsEngine({ broken: true }); + const mail = mailService({ refuses: 'missing-currency' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel(channelId)!; + await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + const again = await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(data.reads).toHaveLength(1); + expect(channel.classifyError?.(again.error)).toBe('retryable'); + }); + + it('a read that SUCCEEDS and finds no default is refused TEMPLATE_INVALID, permanent, with the prescription', async () => { + const data = settingsEngine({ broken: false }); + const mail = mailService({ refuses: 'missing-currency' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel(channelId)!; + const r = await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(data.reads).toHaveLength(1); + expect(r).toEqual({ ok: false, error: REFUSAL }); + expect(channel.classifyError?.(r.error)).toBe('permanent'); + }); + + it('a failed read leaves a refusal no default cures as it stands: permanent, its own sentence on the row', async () => { + const data = settingsEngine({ broken: true }); + const mail = mailService({ refuses: 'path-value' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel(channelId)!; + const r = await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(r).toEqual({ ok: false, error: PATH_REFUSAL }); + expect(channel.classifyError?.(r.error)).toBe('permanent'); + }); + + it('a failed read, a template with the default hole AND a refusal no default cures: the standing refusal, permanent', async () => { + const data = settingsEngine({ broken: true }); + const mail = mailService({ refuses: 'both' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel(channelId)!; + const r = await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(r).toEqual({ ok: false, error: PATH_REFUSAL }); + expect(channel.classifyError?.(r.error)).toBe('permanent'); + expect(mail.sent()).toBe(0); + }); + + it('a failed read does not stop a template that needs no default: it is delivered', async () => { + const data = settingsEngine({ broken: true }); + const mail = mailService(); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const r = await messaging.getChannel(channelId)!.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(r.ok).toBe(true); + expect(mail.calls[0]).not.toHaveProperty('currency'); + }); + + it('a failed read leaves every other template failure as it was: TEMPLATE_NOT_FOUND stays permanent', async () => { + const data = settingsEngine({ broken: true }); + const mail = mailService({ refuses: 'not-found' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel(channelId)!; + const r = await channel.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(r.error).toMatch(/^TEMPLATE_NOT_FOUND: /); + expect(channel.classifyError?.(r.error)).toBe('permanent'); + }); + + it('a read that finds the default formats in it', async () => { + const data = settingsEngine({ broken: false }, [ + { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'org_apex', value: 'CNY' }, + ]); + const mail = mailService({ refuses: 'missing-currency' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const r = await messaging.getChannel(channelId)!.send(ctx, templateDelivery(channelId, 'org_apex')); + expect(r.ok).toBe(true); + expect(mail.calls[0].currency).toBe('CNY'); + }); + }); + } +});