@company-semantics/contracts 34.0.0 → 35.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +17 -11
  3. package/src/notifications/README.md +140 -0
  4. package/src/notifications/__tests__/README.md +54 -0
  5. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  6. package/src/notifications/__tests__/content.test.ts +186 -0
  7. package/src/notifications/__tests__/context.test.ts +72 -0
  8. package/src/notifications/__tests__/definition.test.ts +222 -0
  9. package/src/notifications/__tests__/kinds.test.ts +81 -0
  10. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  11. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  12. package/src/notifications/__tests__/registry.test.ts +184 -0
  13. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  14. package/src/notifications/__tests__/renderer.test.ts +181 -0
  15. package/src/notifications/content.ts +249 -0
  16. package/src/notifications/context.ts +70 -0
  17. package/src/notifications/definition.ts +82 -0
  18. package/src/notifications/index.ts +104 -0
  19. package/src/notifications/kinds/README.md +57 -0
  20. package/src/notifications/kinds/auth-otp.ts +99 -0
  21. package/src/notifications/kinds/chat-shared.ts +56 -0
  22. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  23. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  24. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  25. package/src/notifications/kinds/index.ts +19 -0
  26. package/src/notifications/kinds/org-invite.ts +62 -0
  27. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  28. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  29. package/src/notifications/kinds/security-alert.ts +78 -0
  30. package/src/notifications/kinds/share-granted.ts +74 -0
  31. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  32. package/src/notifications/kinds.ts +68 -0
  33. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  34. package/src/notifications/registry.ts +107 -0
  35. package/src/notifications/render.ts +106 -0
  36. package/src/notifications/renderer.ts +50 -0
  37. package/src/notifications/renderers/README.md +50 -0
  38. package/src/notifications/renderers/email/README.md +74 -0
  39. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  40. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  41. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  42. package/src/notifications/renderers/email/constants.ts +47 -0
  43. package/src/notifications/renderers/email/cta.ts +63 -0
  44. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  45. package/src/notifications/renderers/email/index.ts +73 -0
  46. package/src/notifications/renderers/email/render.ts +225 -0
  47. package/src/notifications/renderers/email/shells.ts +60 -0
  48. package/src/notifications/renderers/slack/README.md +73 -0
  49. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  50. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  51. package/src/notifications/renderers/slack/index.ts +261 -0
  52. package/src/notifications/renderers/sms/README.md +69 -0
  53. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  54. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  55. package/src/notifications/renderers/sms/index.ts +131 -0
  56. package/src/notifications/text.ts +52 -0
  57. package/src/email/README.md +0 -51
  58. package/src/email/__tests__/registry.test.ts +0 -161
  59. package/src/email/index.ts +0 -36
  60. package/src/email/registry.ts +0 -155
  61. package/src/email/render/auth-otp.ts +0 -71
  62. package/src/email/render/blocks.ts +0 -281
  63. package/src/email/render/chat-shared.ts +0 -35
  64. package/src/email/render/company-md-access-approved.ts +0 -42
  65. package/src/email/render/company-md-access-denied.ts +0 -43
  66. package/src/email/render/company-md-access-requested.ts +0 -46
  67. package/src/email/render/constants.ts +0 -18
  68. package/src/email/render/index.ts +0 -58
  69. package/src/email/render/org-invite.ts +0 -40
  70. package/src/email/render/ownership-transfer-completed.ts +0 -41
  71. package/src/email/render/ownership-transfer.ts +0 -42
  72. package/src/email/render/render-email.ts +0 -194
  73. package/src/email/render/security-alert.ts +0 -61
  74. package/src/email/render/share-granted.ts +0 -52
  75. package/src/email/render/unit-owner-granted.ts +0 -60
  76. /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "34.0.0",
3
+ "version": "35.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "types": "./src/execution/index.ts",
33
33
  "default": "./src/execution/index.ts"
34
34
  },
35
- "./email": {
36
- "types": "./src/email/index.ts",
37
- "default": "./src/email/index.ts"
35
+ "./notifications": {
36
+ "types": "./src/notifications/index.ts",
37
+ "default": "./src/notifications/index.ts"
38
38
  },
39
39
  "./ralph": {
40
40
  "types": "./src/ralph/index.ts",
package/src/index.ts CHANGED
@@ -219,20 +219,26 @@ export type {
219
219
  WriteTierDisabledMeta,
220
220
  } from "./dispatch/index";
221
221
 
222
- // Email domain types
223
- // @see ADR-CONT-034 for design rationale
222
+ // Notification domain types
223
+ // Replaces the Email* vocabulary (ADR-CONT-034), which was deleted with
224
+ // `@company-semantics/contracts/email` — email is now one channel, not the shape
225
+ // of the layer. The render layer itself (content model, renderers,
226
+ // renderToChannel) stays on the `/notifications` subpath; the root carries the
227
+ // vocabulary, as it did for email.
228
+ // @see ADR-CONTRACTS-086 for the multi-channel render layer
229
+ // @see ADR-CONTRACTS-085 for the definition + registry
224
230
  export type {
225
- EmailKind,
226
- EmailPayloads,
227
- SendEmailInput,
228
- EmailKindDefinition,
229
- } from "./email/index";
231
+ NotificationKind,
232
+ NotificationPayloads,
233
+ NotificationDefinition,
234
+ NotificationDefinitions,
235
+ } from "./notifications/index";
230
236
 
231
237
  export {
232
- EMAIL_KINDS,
233
- getEmailKindDefinition,
234
- isValidEmailKind,
235
- } from "./email/index";
238
+ NOTIFICATION_DEFINITIONS,
239
+ getNotificationKindDefinition,
240
+ isValidNotificationKind,
241
+ } from "./notifications/index";
236
242
 
237
243
  // Chat domain types
238
244
  // @see PRD-00142 for share chat design rationale
@@ -0,0 +1,140 @@
1
+ # notifications/
2
+
3
+ ## Purpose
4
+
5
+ Home of the multi-channel notification render layer (ADR-CONTRACTS-086) — the
6
+ generalisation of the old email-only render layer (`src/email/`, now deleted)
7
+ into `Definition → Payload → compose(payload, context) → Content → Renderer`.
8
+
9
+ This is the package's SOLE notification surface. `@company-semantics/contracts/email`
10
+ and every `Email*` symbol are gone, with no shim and no alias.
11
+
12
+ The domain holds the **output-parity baseline** — a committed, byte-exact
13
+ capture of what the email layer rendered at 32.0.0, plus the test that proves
14
+ this layer still renders exactly that. The baseline was deliberately landed
15
+ _before_ the abstraction it protects, so that every later step in the migration
16
+ had a mechanical answer to "did any real email change?". Now that the old layer
17
+ is deleted, the golden is also the only surviving record of those bytes.
18
+
19
+ On top of it sits the **semantic model**: the channel-agnostic vocabulary the
20
+ migration is heading toward.
21
+
22
+ - `content.ts` — `NotificationContent { metadata, sections }`, `Section`, and the
23
+ flat `NotificationElement` union (greeting, body, keyValueTable, callToAction,
24
+ list, divider, warning, metadata, notice, chatUnit, signature, heroImage). What
25
+ a notification SAYS, with no markup and no channel names.
26
+ - `context.ts` — `RenderContext { brand: { name, copyrightYear } }`,
27
+ `COMPANY_NAME`, and `createRenderContext()`, whose defaults reproduce the brand
28
+ name + current year the email layer rendered.
29
+ - `renderer.ts` — `Renderer<Out>`: a channel's `id`, its `supports(elementType)`
30
+ capability, and `render(content, context)` returning that channel's own type.
31
+
32
+ And on top of THAT sits the **definition layer** (ADR-CONTRACTS-085) — the kinds
33
+ themselves, moved out of the old email layer:
34
+
35
+ - `kinds.ts` — the `NotificationKind` union. Eleven members: the kinds with
36
+ something to say.
37
+ - `payloads.ts` — `NotificationPayloads`, the facts each kind carries.
38
+ - `definition.ts` — `NotificationDefinition<K> { kind, defaults?, compose }`.
39
+ - `kinds/` — one `compose` per kind. See its README for the block → element map.
40
+ - `text.ts` — the stock wording (`NOTICE`, `ACCESS_PHRASE`) and value formats
41
+ (`titleCase`, `formatExpiry`) `compose` builds copy from. Channel-agnostic:
42
+ what a notification says is the same on every channel.
43
+ - `registry.ts` — `NOTIFICATION_DEFINITIONS` plus
44
+ `getNotificationKindDefinition` / `isValidNotificationKind`.
45
+
46
+ And on top of THAT sit the **channels** (ADR-CONTRACTS-086):
47
+
48
+ - `renderers/email/` — `emailRenderer: Renderer<RenderedEmail>`, the old
49
+ `src/email/render` layer relocated behind the seam. Its own tests prove it
50
+ reproduces the golden from composed content.
51
+ - `renderers/sms/` — `smsRenderer: Renderer<string>`, a non-functional
52
+ placeholder.
53
+ - `renderers/slack/` — `slackRenderer: Renderer<SlackMessage>`, a non-functional
54
+ placeholder.
55
+
56
+ The two placeholders are what make the seam's claims checkable. Email could
57
+ satisfy `Renderer` by construction — the seam was drawn around it — so a layer
58
+ with one channel in it proves only that the layer compiles. SMS (no markup, no
59
+ images, one string) and Slack (a tree of typed blocks) were added without editing
60
+ a single element, kind or definition, which is the property `renderers/README.md`
61
+ says the seam exists for. They do not send and their copy is invented; their shape
62
+ is not provisional.
63
+
64
+ And on top of THAT sits the **entry point** (ADR-CONTRACTS-086):
65
+
66
+ - `render.ts` — `renderToChannel(kind, payload, renderer, context?)`, the whole
67
+ pipeline in one call: look up the definition, `compose` the payload, hand the
68
+ content to a renderer. Plus `renderEmail(kind, payload, overrides?)`, the
69
+ convenience for the channel every caller has today.
70
+
71
+ The pipeline is **closed**: every parity fixture renders through
72
+ `renderToChannel` and lands on the golden byte-for-byte, so the whole
73
+ generalisation is proven to have cost no email a single character. The snapshot
74
+ suite moved here from the old `src/email/render` with the markup it documents —
75
+ its `.snap` came across untouched, keys and all.
76
+
77
+ The old `src/email/` is **deleted** — directory, `./email` subpath, and every
78
+ `Email*` symbol, with no shim. `renderEmail` here is the replacement, same name
79
+ and a different third argument (`RenderContextOverrides`, not
80
+ `RenderEmailOptions`). Consumers migrate against the published major rather than
81
+ against an alias: backend in PRD-00869, app in PRD-00870.
82
+
83
+ Two things left the package with it and have no replacement here, both
84
+ deliberately (ADR-CONTRACTS-086):
85
+
86
+ - `auth.magic_link` — a subject reserved for a kind that never had a template.
87
+ It cannot be composed, so it is not a `NotificationKind`.
88
+ - `SendEmailInput` — `to` / `idempotencyKey` are DELIVERY, and delivery is the
89
+ backend's (PRD-00869). Unlike `EmailKind` / `EmailPayloads`, it was never an
90
+ alias of anything here, so there was nothing to rename.
91
+
92
+ ## Invariants
93
+
94
+ - The golden in `__tests__/output-parity.golden.ts` records what the email layer
95
+ rendered at **32.0.0**, and is now the only record of it — there is no longer a
96
+ layer to re-capture it from. It is a regression baseline, not a specification:
97
+ when it and this render layer disagree, the GOLDEN is right and the renderer
98
+ must be fixed, never the golden regenerated to match.
99
+ - Every kind in `NOTIFICATION_DEFINITIONS` needs at least one parity fixture,
100
+ enforced by the test. The registry is the source of that list — it is total, so
101
+ "every kind the entry point accepts" and "every kind with a fixture" are the
102
+ same claim.
103
+ - Rendering for parity MUST pin the year to `OUTPUT_PARITY_CLOCK`'s — by passing
104
+ `copyrightYear` in the `RenderContext`, NOT by freezing the clock. `signature`
105
+ used to read `new Date().getFullYear()` mid-render, which is why the fixtures
106
+ once needed fake timers; the year is a value now, and no test in this domain
107
+ has an opinion about what day it is.
108
+ - The captured year is **not** redacted or normalised. It is part of the output
109
+ surface the parity proof exists to protect.
110
+ - Types and **pure** functions only — no classes, no side effects, no external
111
+ imports beyond `zod` (vocabulary-guard enforced), as in the old email layer.
112
+ - `NotificationKind` has ELEVEN members where the old `EmailKind` had twelve. The
113
+ difference is `auth.magic_link`, a registered subject with no template that the
114
+ old `renderEmail` threw on. Excluding it is what makes
115
+ `NOTIFICATION_DEFINITIONS` total; adding it back "for symmetry" restores a
116
+ runtime hole. Give it a `compose` and it graduates.
117
+ - This domain depends on NOTHING outside itself. The `notifications → email`
118
+ imports that existed while both layers did are gone with the directory, and
119
+ nothing may reintroduce one.
120
+ - A kind IS its definition. Every `NotificationKind` has facts in `payloads.ts`
121
+ and a `compose` in `kinds/`, both compiler-enforced via `NotificationPayloads[K]`.
122
+ - Capability belongs to the **renderer** (`Renderer.supports`), never to an
123
+ element or a kind. Elements carry no channel tags — the moment they do, every
124
+ new channel becomes a rewrite of the content.
125
+ - `supports` and `render` MUST agree: an element `supports` declines is omitted,
126
+ not approximated and not thrown on.
127
+ - `renderers/sms` and `renderers/slack` are PLACEHOLDERS: they do not send, and
128
+ their copy is invented rather than relocated from anything. Neither is reachable
129
+ from `renderEmail` and neither touches a parity fixture — a stub cannot change a
130
+ sent email, and must not acquire the ability to. Their SHAPE, however, is not
131
+ provisional: purity, total `supports`, and a natural `Out` are the seam's claims
132
+ and these modules are where two of them stop being test doubles.
133
+ - `createRenderContext()` is the ONLY clock read in this domain, and only to
134
+ default `copyrightYear`. Renderers take the year from `RenderContext` — that is
135
+ what retires the hidden `new Date()` the old `signature()` did mid-render.
136
+ - Channel-agnostic modules MUST NOT import from `renderers/`. `COMPANY_NAME`
137
+ lives in `context.ts` and `NOTICE` / `ACCESS_PHRASE` / `titleCase` /
138
+ `formatExpiry` in `text.ts` for exactly this reason: they are the same answer
139
+ on every channel, and sourcing a default from one channel is how the seam leaks
140
+ back. The arrow runs `kinds/` → `text.ts` / `context.ts`, never into a renderer.
@@ -0,0 +1,54 @@
1
+ # notifications/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ The output-parity proof for the multi-channel notification render layer
6
+ (ADR-CONTRACTS-086), and the snapshot suite that documents the markup.
7
+
8
+ - `output-parity.golden.ts` — the fixtures (kind + payload) and
9
+ `OUTPUT_PARITY_GOLDEN`, the exact `{ subject, text, html }` each one rendered
10
+ to through the email layer **as it stood before any of this existed**.
11
+ - `output-parity.test.ts` — renders every fixture through `renderToChannel`, the
12
+ new entry point, and asserts byte equality against that golden.
13
+ - `render-snapshot.test.ts` + `__snapshots__/` — the same fixtures as vitest
14
+ snapshots, moved here from the old `src/email/render/__tests__/` with the
15
+ markup they document.
16
+
17
+ The parity test used to pin the old `renderEmail` against itself, which was the
18
+ point: the bytes were captured _before_ the layer they describe was touched. That
19
+ baseline has now done its job — the same fixtures render through
20
+ `compose → Content → Renderer` and produce the same bytes, so the generalisation
21
+ is proven to have changed no email. `src/email/` was deleted on that proof.
22
+
23
+ Prefer the golden when the two suites disagree. `vitest -u` can regenerate a
24
+ snapshot in a keystroke, and a snapshot regenerated under refactor pressure is
25
+ worth nothing; the golden is a committed module, so making it pass is a
26
+ deliberate, reviewable act.
27
+
28
+ ## Invariants
29
+
30
+ - The year MUST be pinned to `OUTPUT_PARITY_CLOCK`'s via the `RenderContext`, not
31
+ by freezing the clock. It is baked into every case by `signature`, so an
32
+ unpinned render fails on the next New Year's Day for calendar reasons alone.
33
+ Fake timers are no longer needed anywhere here, and that is the improvement
34
+ this layer was built to deliver — do not reintroduce them.
35
+ - The captured year is **not** redacted or normalised. It is part of the output
36
+ surface this proof exists to protect.
37
+ - A failure here means a **real sent email changed**. Review the diff before
38
+ regenerating; the golden is not a snapshot to refresh on sight. `src/email/` is
39
+ deleted, so these bytes cannot be re-captured — the golden is the only record
40
+ of what that layer emitted, and regenerating it destroys the proof rather than
41
+ updating it.
42
+ - The golden and the fixtures MUST stay in exact correspondence — no missing and
43
+ no orphaned entries. The test enforces both directions.
44
+ - Every kind in `NOTIFICATION_DEFINITIONS` MUST have a fixture, enforced by both
45
+ suites.
46
+ - The golden's BYTES are frozen; its TYPES were re-anchored onto this domain when
47
+ the `Email*` originals were deleted. `OutputParityFixture.options` went with
48
+ them — it was declared as `RenderEmailOptions` and carried the old layer's
49
+ `includeRequestMetadata`, which `compose` replaced with "does the payload carry
50
+ request details?". It had been left in place as frozen evidence, but its type
51
+ no longer exists and no test ever read it; the one fixture that set it supplies
52
+ both request fields anyway, so the gate opens regardless. The byte equality is
53
+ what proves that — which is exactly why dropping the field was safe and
54
+ re-recording a byte would not be.
@@ -0,0 +1,32 @@
1
+ # notifications/\_\_tests\_\_/\_\_snapshots\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Vitest snapshots for `../render-snapshot.test.ts` — the rendered subject / text /
6
+ html of every notification kind × variant, as the email channel draws them.
7
+
8
+ `render-snapshot.test.ts.snap` moved here verbatim from the old
9
+ `src/email/render/__tests__/__snapshots__/` when the render layer was
10
+ generalised behind the channel seam (ADR-CONTRACTS-086). Not one recorded byte
11
+ changed: the suite's describe label stayed `email render` and its case labels
12
+ stayed `{kind} · {name}`, so every snapshot key still resolves against the same
13
+ file. That is the claim the move is making — the keys matched, so the bytes
14
+ matched.
15
+
16
+ This directory exists because vitest resolves snapshots adjacent to the test
17
+ file, so the `.snap` had to travel with it.
18
+
19
+ ## Invariants
20
+
21
+ - This is **production-parity email markup**. A diff here means a real sent email
22
+ changed. Review it, then `vitest -u` — never the other way round.
23
+ - These snapshots are **not** the migration's proof. That is
24
+ `../output-parity.golden.ts`, a committed module `vitest -u` cannot touch. When
25
+ the two disagree, the golden is right. Regenerating this file to make a
26
+ refactor pass is the exact failure ADR-CONTRACTS-086 exists to prevent, and it
27
+ is one keystroke away — which is why the golden is not a snapshot.
28
+ - The copyright year is pinned to `OUTPUT_PARITY_CLOCK`'s, never the wall clock.
29
+ It is baked into every case via `signature`, so an unpinned capture would fail
30
+ on the next New Year's Day for calendar reasons alone.
31
+ - The year is **not** redacted or normalised. It is part of the output surface
32
+ these snapshots exist to protect.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The semantic model is types-only, so these tests do two jobs a type alone
3
+ * cannot (ADR-CONTRACTS-086).
4
+ *
5
+ * They pin the MEMBERSHIP of the element union: `ALL_ELEMENT_TYPES` is typed
6
+ * `Record<NotificationElementType, true>`, so adding an element without listing
7
+ * it here fails to compile, and the runtime assertion below fails if one is
8
+ * renamed or dropped. The union is the contract every renderer's `supports` is
9
+ * total over — it should not move by accident.
10
+ *
11
+ * And they pin that the union is REACHABLE as content: the fixture composes one
12
+ * element of every kind through `NotificationContent`, which is the check that
13
+ * the model can actually express a notification rather than merely type-check.
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type {
19
+ NotificationContent,
20
+ NotificationElement,
21
+ NotificationElementType,
22
+ } from "../content";
23
+
24
+ /**
25
+ * Every element type, exhaustively. The `Record` type is the lock: a new union
26
+ * member is a compile error until it is named here.
27
+ */
28
+ const ALL_ELEMENT_TYPES: Record<NotificationElementType, true> = {
29
+ greeting: true,
30
+ body: true,
31
+ keyValueTable: true,
32
+ callToAction: true,
33
+ list: true,
34
+ divider: true,
35
+ warning: true,
36
+ metadata: true,
37
+ notice: true,
38
+ chatUnit: true,
39
+ signature: true,
40
+ heroImage: true,
41
+ };
42
+
43
+ /** One element of every kind, in a plausible notification order. */
44
+ const EVERY_ELEMENT: NotificationElement[] = [
45
+ { type: "heroImage", src: "https://example.test/hero.png", alt: "Acme" },
46
+ { type: "greeting", recipientName: "Ada" },
47
+ { type: "warning" },
48
+ { type: "body", text: "Acme uses Company Semantics." },
49
+ {
50
+ type: "chatUnit",
51
+ items: [
52
+ { type: "message", role: "user", text: "Can I join?", from: "Ada" },
53
+ { type: "continuation" },
54
+ { type: "message", role: "assistant", text: "Join to accept." },
55
+ { type: "callToAction", label: "JOIN", href: "https://example.test/j" },
56
+ ],
57
+ },
58
+ { type: "callToAction", label: "123456" },
59
+ { type: "divider" },
60
+ {
61
+ type: "keyValueTable",
62
+ rows: [
63
+ { label: "From", value: "Grace" },
64
+ { label: "Role", value: "Admin" },
65
+ ],
66
+ },
67
+ { type: "list", items: ["Read", "Comment"], ordered: false },
68
+ {
69
+ type: "metadata",
70
+ label: "Request details",
71
+ rows: [{ label: "IP address", value: "203.0.113.4" }],
72
+ },
73
+ {
74
+ type: "notice",
75
+ lines: ["Sent via Company Semantics.", "No action needed."],
76
+ },
77
+ { type: "signature" },
78
+ ];
79
+
80
+ describe("NotificationElement", () => {
81
+ it("has exactly the twelve element types the renderers switch over", () => {
82
+ expect(Object.keys(ALL_ELEMENT_TYPES).sort()).toEqual([
83
+ "body",
84
+ "callToAction",
85
+ "chatUnit",
86
+ "divider",
87
+ "greeting",
88
+ "heroImage",
89
+ "keyValueTable",
90
+ "list",
91
+ "metadata",
92
+ "notice",
93
+ "signature",
94
+ "warning",
95
+ ]);
96
+ });
97
+
98
+ it("covers every element type in the fixture", () => {
99
+ const covered = new Set(EVERY_ELEMENT.map((e) => e.type));
100
+ expect([...covered].sort()).toEqual(Object.keys(ALL_ELEMENT_TYPES).sort());
101
+ });
102
+
103
+ it("discriminates exhaustively on `type`", () => {
104
+ // The `never` guard is the idiom every renderer will rely on: if a member is
105
+ // added to the union, this stops compiling rather than silently falling
106
+ // through to a default.
107
+ const describeElement = (element: NotificationElement): string => {
108
+ switch (element.type) {
109
+ case "greeting":
110
+ return element.recipientName ?? "anonymous";
111
+ case "body":
112
+ return element.text;
113
+ case "keyValueTable":
114
+ return element.rows.map((r) => r.label).join(",");
115
+ case "callToAction":
116
+ return element.href ?? element.label;
117
+ case "list":
118
+ return element.items.join(",");
119
+ case "divider":
120
+ return "divider";
121
+ case "warning":
122
+ return "warning";
123
+ case "metadata":
124
+ return element.label ?? "metadata";
125
+ case "notice":
126
+ return element.lines.join(" ");
127
+ case "chatUnit":
128
+ return element.items.map((i) => i.type).join(",");
129
+ case "signature":
130
+ return element.signer ?? "brand";
131
+ case "heroImage":
132
+ return element.alt;
133
+ default: {
134
+ const _exhaustive: never = element;
135
+ throw new Error(`Unhandled element: ${JSON.stringify(_exhaustive)}`);
136
+ }
137
+ }
138
+ };
139
+
140
+ expect(EVERY_ELEMENT.map(describeElement)).toHaveLength(
141
+ EVERY_ELEMENT.length,
142
+ );
143
+ });
144
+
145
+ it("reuses CallToAction inside a chat unit rather than mirroring it", () => {
146
+ // A CTA in a conversation is the same intent as a standalone one. If these
147
+ // ever diverge, a renderer would need two code paths for one concept.
148
+ const cta: NotificationElement = { type: "callToAction", label: "OPEN" };
149
+ const unit: NotificationElement = { type: "chatUnit", items: [cta] };
150
+ expect(unit).toEqual({
151
+ type: "chatUnit",
152
+ items: [{ type: "callToAction", label: "OPEN" }],
153
+ });
154
+ });
155
+ });
156
+
157
+ describe("NotificationContent", () => {
158
+ it("carries the headline in metadata, not in a channel registry", () => {
159
+ // The subject moving out of EMAIL_KINDS and into Content is the fusion
160
+ // ADR-CONTRACTS-086 splits — it is what lets a second renderer attach
161
+ // without restating the copy.
162
+ const content: NotificationContent = {
163
+ metadata: { kind: "org.invite", title: "Join Acme on Company Semantics" },
164
+ sections: [{ elements: EVERY_ELEMENT }],
165
+ };
166
+
167
+ expect(content.metadata.title).toBe("Join Acme on Company Semantics");
168
+ expect(content.metadata.kind).toBe("org.invite");
169
+ expect(content.sections[0].elements).toHaveLength(EVERY_ELEMENT.length);
170
+ });
171
+
172
+ it("groups elements into sections a channel may split on", () => {
173
+ const content: NotificationContent = {
174
+ metadata: { kind: "auth.otp", title: "Your login code" },
175
+ sections: [
176
+ { elements: [{ type: "greeting" }] },
177
+ { elements: [{ type: "callToAction", label: "123456" }] },
178
+ ],
179
+ };
180
+
181
+ expect(content.sections).toHaveLength(2);
182
+ expect(
183
+ content.sections.flatMap((s) => s.elements).map((e) => e.type),
184
+ ).toEqual(["greeting", "callToAction"]);
185
+ });
186
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `createRenderContext` is the one place in the notifications domain allowed to
3
+ * read the clock (ADR-CONTRACTS-086). These tests pin both halves of that deal:
4
+ * the defaults reproduce today's values exactly (so hoisting the year out of
5
+ * `signature()` changes no bytes), and an override makes the context fully
6
+ * deterministic (so nothing downstream needs a fake timer).
7
+ *
8
+ * The clock is frozen here for the same reason the parity suite freezes it: the
9
+ * default year assertion would otherwise be a New Year's Day time bomb.
10
+ */
11
+
12
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
13
+
14
+ import { COMPANY_NAME, createRenderContext } from "../context";
15
+ import { OUTPUT_PARITY_CLOCK } from "./output-parity.golden";
16
+
17
+ const FROZEN_YEAR = new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear();
18
+
19
+ beforeAll(() => {
20
+ vi.useFakeTimers();
21
+ vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
22
+ });
23
+
24
+ afterAll(() => {
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ describe("createRenderContext", () => {
29
+ it("defaults to the values the email layer rendered", () => {
30
+ // The no-op case must reproduce `signature()`'s inputs exactly: COMPANY_NAME
31
+ // and the current year. If this drifts, hoisting the clock read out of the
32
+ // render path silently changes a real sent email.
33
+ expect(createRenderContext()).toEqual({
34
+ brand: { name: COMPANY_NAME, copyrightYear: FROZEN_YEAR },
35
+ });
36
+ });
37
+
38
+ it("defaults the year from the clock", () => {
39
+ vi.setSystemTime(new Date("2030-03-04T05:06:07.000Z"));
40
+ expect(createRenderContext().brand.copyrightYear).toBe(2030);
41
+ vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
42
+ });
43
+
44
+ it("overrides each brand field independently", () => {
45
+ expect(createRenderContext({ brand: { name: "Acme" } })).toEqual({
46
+ brand: { name: "Acme", copyrightYear: FROZEN_YEAR },
47
+ });
48
+ expect(createRenderContext({ brand: { copyrightYear: 1999 } })).toEqual({
49
+ brand: { name: COMPANY_NAME, copyrightYear: 1999 },
50
+ });
51
+ });
52
+
53
+ it("is deterministic — a pinned year ignores the clock entirely", () => {
54
+ const pinned = { brand: { copyrightYear: 2020 } };
55
+ const before = createRenderContext(pinned);
56
+ vi.setSystemTime(new Date("2031-01-01T00:00:00.000Z"));
57
+ expect(createRenderContext(pinned)).toEqual(before);
58
+ vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
59
+ });
60
+
61
+ it("returns a fresh context per call — callers cannot alias each other", () => {
62
+ const a = createRenderContext();
63
+ const b = createRenderContext();
64
+ expect(a).not.toBe(b);
65
+ expect(a.brand).not.toBe(b.brand);
66
+ });
67
+
68
+ it("treats an empty override object as no override", () => {
69
+ expect(createRenderContext({})).toEqual(createRenderContext());
70
+ expect(createRenderContext({ brand: {} })).toEqual(createRenderContext());
71
+ });
72
+ });