@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.
- package/package.json +4 -4
- package/src/index.ts +17 -11
- package/src/notifications/README.md +140 -0
- package/src/notifications/__tests__/README.md +54 -0
- package/src/notifications/__tests__/__snapshots__/README.md +32 -0
- package/src/notifications/__tests__/content.test.ts +186 -0
- package/src/notifications/__tests__/context.test.ts +72 -0
- package/src/notifications/__tests__/definition.test.ts +222 -0
- package/src/notifications/__tests__/kinds.test.ts +81 -0
- package/src/notifications/__tests__/output-parity.golden.ts +363 -0
- package/src/notifications/__tests__/output-parity.test.ts +122 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
- package/src/notifications/__tests__/renderer.test.ts +181 -0
- package/src/notifications/content.ts +249 -0
- package/src/notifications/context.ts +70 -0
- package/src/notifications/definition.ts +82 -0
- package/src/notifications/index.ts +104 -0
- package/src/notifications/kinds/README.md +57 -0
- package/src/notifications/kinds/auth-otp.ts +99 -0
- package/src/notifications/kinds/chat-shared.ts +56 -0
- package/src/notifications/kinds/company-md-access-approved.ts +57 -0
- package/src/notifications/kinds/company-md-access-denied.ts +61 -0
- package/src/notifications/kinds/company-md-access-requested.ts +65 -0
- package/src/notifications/kinds/index.ts +19 -0
- package/src/notifications/kinds/org-invite.ts +62 -0
- package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
- package/src/notifications/kinds/ownership-transfer.ts +68 -0
- package/src/notifications/kinds/security-alert.ts +78 -0
- package/src/notifications/kinds/share-granted.ts +74 -0
- package/src/notifications/kinds/unit-owner-granted.ts +95 -0
- package/src/notifications/kinds.ts +68 -0
- package/src/{email/types.ts → notifications/payloads.ts} +33 -68
- package/src/notifications/registry.ts +107 -0
- package/src/notifications/render.ts +106 -0
- package/src/notifications/renderer.ts +50 -0
- package/src/notifications/renderers/README.md +50 -0
- package/src/notifications/renderers/email/README.md +74 -0
- package/src/notifications/renderers/email/__tests__/README.md +29 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
- package/src/notifications/renderers/email/constants.ts +47 -0
- package/src/notifications/renderers/email/cta.ts +63 -0
- package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
- package/src/notifications/renderers/email/index.ts +73 -0
- package/src/notifications/renderers/email/render.ts +225 -0
- package/src/notifications/renderers/email/shells.ts +60 -0
- package/src/notifications/renderers/slack/README.md +73 -0
- package/src/notifications/renderers/slack/__tests__/README.md +33 -0
- package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
- package/src/notifications/renderers/slack/index.ts +261 -0
- package/src/notifications/renderers/sms/README.md +69 -0
- package/src/notifications/renderers/sms/__tests__/README.md +31 -0
- package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
- package/src/notifications/renderers/sms/index.ts +131 -0
- package/src/notifications/text.ts +52 -0
- package/src/email/README.md +0 -51
- package/src/email/__tests__/registry.test.ts +0 -161
- package/src/email/index.ts +0 -36
- package/src/email/registry.ts +0 -155
- package/src/email/render/auth-otp.ts +0 -71
- package/src/email/render/blocks.ts +0 -281
- package/src/email/render/chat-shared.ts +0 -35
- package/src/email/render/company-md-access-approved.ts +0 -42
- package/src/email/render/company-md-access-denied.ts +0 -43
- package/src/email/render/company-md-access-requested.ts +0 -46
- package/src/email/render/constants.ts +0 -18
- package/src/email/render/index.ts +0 -58
- package/src/email/render/org-invite.ts +0 -40
- package/src/email/render/ownership-transfer-completed.ts +0 -41
- package/src/email/render/ownership-transfer.ts +0 -42
- package/src/email/render/render-email.ts +0 -194
- package/src/email/render/security-alert.ts +0 -61
- package/src/email/render/share-granted.ts +0 -52
- package/src/email/render/unit-owner-granted.ts +0 -60
- /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": "
|
|
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
|
-
"./
|
|
36
|
-
"types": "./src/
|
|
37
|
-
"default": "./src/
|
|
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
|
-
//
|
|
223
|
-
//
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
} from "./
|
|
231
|
+
NotificationKind,
|
|
232
|
+
NotificationPayloads,
|
|
233
|
+
NotificationDefinition,
|
|
234
|
+
NotificationDefinitions,
|
|
235
|
+
} from "./notifications/index";
|
|
230
236
|
|
|
231
237
|
export {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
} from "./
|
|
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
|
+
});
|