@company-semantics/contracts 34.0.0 → 35.1.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 +142 -0
- package/src/notifications/__tests__/README.md +49 -0
- package/src/notifications/__tests__/__snapshots__/README.md +39 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
- 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__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +80 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -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 +78 -0
- package/src/notifications/renderers/email/__tests__/README.md +32 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
- package/src/notifications/renderers/email/constants.ts +64 -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 +238 -0
- package/src/notifications/renderers/email/shells.ts +61 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "35.1.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,142 @@
|
|
|
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 **semantic model**: the channel-agnostic vocabulary an
|
|
13
|
+
element names, and the renderers that decide what it looks like per channel.
|
|
14
|
+
|
|
15
|
+
It used to hold an **output-parity baseline** too — a byte-exact capture of what
|
|
16
|
+
the email layer rendered at 32.0.0, landed deliberately _before_ the abstraction
|
|
17
|
+
it protected, so every step of the migration had a mechanical answer to "did any
|
|
18
|
+
real email change?". That baseline did its job: the answer was "none", and it
|
|
19
|
+
shipped in 35.0.0. ADR-CONTRACTS-087 retired it, because a proof that the
|
|
20
|
+
migration changed nothing cannot also serve as permission to change something,
|
|
21
|
+
and email design is not frozen. `__tests__/render-snapshot.test.ts` is the fence
|
|
22
|
+
now; the retired bytes are readable in git at `v35.0.0`.
|
|
23
|
+
|
|
24
|
+
- `content.ts` — `NotificationContent { metadata, sections }`, `Section`, and the
|
|
25
|
+
flat `NotificationElement` union (greeting, body, keyValueTable, callToAction,
|
|
26
|
+
list, divider, warning, metadata, notice, chatUnit, signature, heroImage). What
|
|
27
|
+
a notification SAYS, with no markup and no channel names.
|
|
28
|
+
- `context.ts` — `RenderContext { brand: { name, copyrightYear } }`,
|
|
29
|
+
`COMPANY_NAME`, and `createRenderContext()`, whose defaults reproduce the brand
|
|
30
|
+
name + current year the email layer rendered.
|
|
31
|
+
- `renderer.ts` — `Renderer<Out>`: a channel's `id`, its `supports(elementType)`
|
|
32
|
+
capability, and `render(content, context)` returning that channel's own type.
|
|
33
|
+
|
|
34
|
+
And on top of THAT sits the **definition layer** (ADR-CONTRACTS-085) — the kinds
|
|
35
|
+
themselves, moved out of the old email layer:
|
|
36
|
+
|
|
37
|
+
- `kinds.ts` — the `NotificationKind` union. Eleven members: the kinds with
|
|
38
|
+
something to say.
|
|
39
|
+
- `payloads.ts` — `NotificationPayloads`, the facts each kind carries.
|
|
40
|
+
- `definition.ts` — `NotificationDefinition<K> { kind, defaults?, compose }`.
|
|
41
|
+
- `kinds/` — one `compose` per kind. See its README for the block → element map.
|
|
42
|
+
- `text.ts` — the stock wording (`NOTICE`, `ACCESS_PHRASE`) and value formats
|
|
43
|
+
(`titleCase`, `formatExpiry`) `compose` builds copy from. Channel-agnostic:
|
|
44
|
+
what a notification says is the same on every channel.
|
|
45
|
+
- `registry.ts` — `NOTIFICATION_DEFINITIONS` plus
|
|
46
|
+
`getNotificationKindDefinition` / `isValidNotificationKind`.
|
|
47
|
+
|
|
48
|
+
And on top of THAT sit the **channels** (ADR-CONTRACTS-086):
|
|
49
|
+
|
|
50
|
+
- `renderers/email/` — `emailRenderer: Renderer<RenderedEmail>`, the old
|
|
51
|
+
`src/email/render` layer relocated behind the seam. Its own tests prove it
|
|
52
|
+
reproduces the golden from composed content.
|
|
53
|
+
- `renderers/sms/` — `smsRenderer: Renderer<string>`, a non-functional
|
|
54
|
+
placeholder.
|
|
55
|
+
- `renderers/slack/` — `slackRenderer: Renderer<SlackMessage>`, a non-functional
|
|
56
|
+
placeholder.
|
|
57
|
+
|
|
58
|
+
The two placeholders are what make the seam's claims checkable. Email could
|
|
59
|
+
satisfy `Renderer` by construction — the seam was drawn around it — so a layer
|
|
60
|
+
with one channel in it proves only that the layer compiles. SMS (no markup, no
|
|
61
|
+
images, one string) and Slack (a tree of typed blocks) were added without editing
|
|
62
|
+
a single element, kind or definition, which is the property `renderers/README.md`
|
|
63
|
+
says the seam exists for. They do not send and their copy is invented; their shape
|
|
64
|
+
is not provisional.
|
|
65
|
+
|
|
66
|
+
And on top of THAT sits the **entry point** (ADR-CONTRACTS-086):
|
|
67
|
+
|
|
68
|
+
- `render.ts` — `renderToChannel(kind, payload, renderer, context?)`, the whole
|
|
69
|
+
pipeline in one call: look up the definition, `compose` the payload, hand the
|
|
70
|
+
content to a renderer. Plus `renderEmail(kind, payload, overrides?)`, the
|
|
71
|
+
convenience for the channel every caller has today.
|
|
72
|
+
|
|
73
|
+
The pipeline is **closed**: every parity fixture renders through
|
|
74
|
+
`renderToChannel` and lands on the golden byte-for-byte, so the whole
|
|
75
|
+
generalisation is proven to have cost no email a single character. The snapshot
|
|
76
|
+
suite moved here from the old `src/email/render` with the markup it documents —
|
|
77
|
+
its `.snap` came across untouched, keys and all.
|
|
78
|
+
|
|
79
|
+
The old `src/email/` is **deleted** — directory, `./email` subpath, and every
|
|
80
|
+
`Email*` symbol, with no shim. `renderEmail` here is the replacement, same name
|
|
81
|
+
and a different third argument (`RenderContextOverrides`, not
|
|
82
|
+
`RenderEmailOptions`). Consumers migrate against the published major rather than
|
|
83
|
+
against an alias: backend in PRD-00869, app in PRD-00870.
|
|
84
|
+
|
|
85
|
+
Two things left the package with it and have no replacement here, both
|
|
86
|
+
deliberately (ADR-CONTRACTS-086):
|
|
87
|
+
|
|
88
|
+
- `auth.magic_link` — a subject reserved for a kind that never had a template.
|
|
89
|
+
It cannot be composed, so it is not a `NotificationKind`.
|
|
90
|
+
- `SendEmailInput` — `to` / `idempotencyKey` are DELIVERY, and delivery is the
|
|
91
|
+
backend's (PRD-00869). Unlike `EmailKind` / `EmailPayloads`, it was never an
|
|
92
|
+
alias of anything here, so there was nothing to rename.
|
|
93
|
+
|
|
94
|
+
## Invariants
|
|
95
|
+
|
|
96
|
+
- `__tests__/render-snapshot.test.ts` locks what this layer renders. A diff there
|
|
97
|
+
means a real sent email changed: review it, then regenerate. It is a regression
|
|
98
|
+
fence, not a specification — but since ADR-CONTRACTS-087 retired the parity
|
|
99
|
+
golden that used to outrank it, reading the diff is the ONLY control left. A
|
|
100
|
+
snapshot refreshed on sight proves nothing.
|
|
101
|
+
- Every kind in `NOTIFICATION_DEFINITIONS` needs at least one fixture in
|
|
102
|
+
`__tests__/fixtures.ts`, enforced by the tests. The registry is the source of
|
|
103
|
+
that list — it is total, so "every kind the entry point accepts" and "every
|
|
104
|
+
kind with a fixture" are the same claim.
|
|
105
|
+
- Rendering MUST pin the year to `FIXTURE_CLOCK`'s — by passing
|
|
106
|
+
`copyrightYear` in the `RenderContext`, NOT by freezing the clock. `signature`
|
|
107
|
+
used to read `new Date().getFullYear()` mid-render, which is why the fixtures
|
|
108
|
+
once needed fake timers; the year is a value now, and no test in this domain
|
|
109
|
+
has an opinion about what day it is.
|
|
110
|
+
- The captured year is **not** redacted or normalised. It is part of the output
|
|
111
|
+
surface the parity proof exists to protect.
|
|
112
|
+
- Types and **pure** functions only — no classes, no side effects, no external
|
|
113
|
+
imports beyond `zod` (vocabulary-guard enforced), as in the old email layer.
|
|
114
|
+
- `NotificationKind` has ELEVEN members where the old `EmailKind` had twelve. The
|
|
115
|
+
difference is `auth.magic_link`, a registered subject with no template that the
|
|
116
|
+
old `renderEmail` threw on. Excluding it is what makes
|
|
117
|
+
`NOTIFICATION_DEFINITIONS` total; adding it back "for symmetry" restores a
|
|
118
|
+
runtime hole. Give it a `compose` and it graduates.
|
|
119
|
+
- This domain depends on NOTHING outside itself. The `notifications → email`
|
|
120
|
+
imports that existed while both layers did are gone with the directory, and
|
|
121
|
+
nothing may reintroduce one.
|
|
122
|
+
- A kind IS its definition. Every `NotificationKind` has facts in `payloads.ts`
|
|
123
|
+
and a `compose` in `kinds/`, both compiler-enforced via `NotificationPayloads[K]`.
|
|
124
|
+
- Capability belongs to the **renderer** (`Renderer.supports`), never to an
|
|
125
|
+
element or a kind. Elements carry no channel tags — the moment they do, every
|
|
126
|
+
new channel becomes a rewrite of the content.
|
|
127
|
+
- `supports` and `render` MUST agree: an element `supports` declines is omitted,
|
|
128
|
+
not approximated and not thrown on.
|
|
129
|
+
- `renderers/sms` and `renderers/slack` are PLACEHOLDERS: they do not send, and
|
|
130
|
+
their copy is invented rather than relocated from anything. Neither is reachable
|
|
131
|
+
from `renderEmail` and neither touches a parity fixture — a stub cannot change a
|
|
132
|
+
sent email, and must not acquire the ability to. Their SHAPE, however, is not
|
|
133
|
+
provisional: purity, total `supports`, and a natural `Out` are the seam's claims
|
|
134
|
+
and these modules are where two of them stop being test doubles.
|
|
135
|
+
- `createRenderContext()` is the ONLY clock read in this domain, and only to
|
|
136
|
+
default `copyrightYear`. Renderers take the year from `RenderContext` — that is
|
|
137
|
+
what retires the hidden `new Date()` the old `signature()` did mid-render.
|
|
138
|
+
- Channel-agnostic modules MUST NOT import from `renderers/`. `COMPANY_NAME`
|
|
139
|
+
lives in `context.ts` and `NOTICE` / `ACCESS_PHRASE` / `titleCase` /
|
|
140
|
+
`formatExpiry` in `text.ts` for exactly this reason: they are the same answer
|
|
141
|
+
on every channel, and sourcing a default from one channel is how the seam leaks
|
|
142
|
+
back. The arrow runs `kinds/` → `text.ts` / `context.ts`, never into a renderer.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# notifications/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The shared fixture corpus for the multi-channel notification render layer
|
|
6
|
+
(ADR-CONTRACTS-086), and the snapshot suite that documents the markup it renders
|
|
7
|
+
to.
|
|
8
|
+
|
|
9
|
+
- `fixtures.ts` — every implemented kind × representative payload, plus
|
|
10
|
+
`FIXTURE_CLOCK` and `fixtureKey`. Shared by every suite here and by
|
|
11
|
+
`../renderers/email/__tests__/render.test.ts`, so a kind added to the registry
|
|
12
|
+
is covered everywhere by being added once.
|
|
13
|
+
- `render-snapshot.test.ts` + `__snapshots__/` — those fixtures rendered through
|
|
14
|
+
the email channel, as vitest snapshots. This is the fence: **a diff here means
|
|
15
|
+
a real sent email changed.**
|
|
16
|
+
|
|
17
|
+
## The retired output-parity golden (ADR-CONTRACTS-087)
|
|
18
|
+
|
|
19
|
+
`fixtures.ts` was `output-parity.golden.ts`, and it used to carry
|
|
20
|
+
`OUTPUT_PARITY_GOLDEN` — the exact `{ subject, text, html }` the email layer
|
|
21
|
+
emitted at 32.0.0, before any of this existed — asserted byte-for-byte by
|
|
22
|
+
`output-parity.test.ts` at the entry point and by the email renderer's own suite
|
|
23
|
+
at the renderer. Those two tests were the "no output change" contract for the
|
|
24
|
+
generalisation: `src/email/` was deleted on that proof.
|
|
25
|
+
|
|
26
|
+
**The proof passed and shipped in 35.0.0, which discharged it.** It was a claim
|
|
27
|
+
about a migration, not a freeze on email design — and the moment email design is
|
|
28
|
+
deliberately exercised, byte-equality to a deleted layer fails by construction
|
|
29
|
+
and keeps failing.
|
|
30
|
+
|
|
31
|
+
It was **retired rather than regenerated**, and the distinction is the point:
|
|
32
|
+
rewriting those bytes to match a redesign would have made the file assert
|
|
33
|
+
something false about a layer that no longer exists to contradict it. The bytes
|
|
34
|
+
are readable in git at `v35.0.0`; nothing in the tree claims to be them.
|
|
35
|
+
|
|
36
|
+
## Invariants
|
|
37
|
+
|
|
38
|
+
- A snapshot diff means **a real sent email changed**. Review it, then
|
|
39
|
+
`vitest -u` — never the other way round. Regenerating on sight is the whole
|
|
40
|
+
hazard; the review is the control, and it is now the only one.
|
|
41
|
+
- The year MUST be pinned via `FIXTURE_CLOCK` on the `RenderContext`, never by
|
|
42
|
+
freezing the clock. `signature` bakes it into every case, so an unpinned render
|
|
43
|
+
fails on the next New Year's Day for calendar reasons alone. Fake timers are
|
|
44
|
+
not needed anywhere here — that is the impurity ADR-CONTRACTS-086 retired, so
|
|
45
|
+
do not reintroduce them.
|
|
46
|
+
- The pinned year is **not** redacted or normalised. It is part of the output
|
|
47
|
+
surface these suites exist to protect.
|
|
48
|
+
- Every kind in `NOTIFICATION_DEFINITIONS` MUST have a fixture, enforced by
|
|
49
|
+
`./definition.test.ts` and `./registry.test.ts`.
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
The `security.alert` cases have since moved: the `warning` banner is drawn in the
|
|
17
|
+
product's destructive colour (ADR-CONTRACTS-087). That is the first deliberate
|
|
18
|
+
change to this markup since the move, and the reason the parity golden that used
|
|
19
|
+
to guard it is gone.
|
|
20
|
+
|
|
21
|
+
This directory exists because vitest resolves snapshots adjacent to the test
|
|
22
|
+
file, so the `.snap` had to travel with it.
|
|
23
|
+
|
|
24
|
+
## Invariants
|
|
25
|
+
|
|
26
|
+
- This is **production-parity email markup**. A diff here means a real sent email
|
|
27
|
+
changed. Review it, then `vitest -u` — never the other way round.
|
|
28
|
+
- These snapshots are now the **only** automated record of that markup. Until
|
|
29
|
+
ADR-CONTRACTS-087 the migration's proof sat beside them in
|
|
30
|
+
`../output-parity.golden.ts`, a committed module `vitest -u` could not touch,
|
|
31
|
+
and it was the tie-breaker when the two disagreed. That proof was discharged in
|
|
32
|
+
35.0.0 and retired, because it asserted byte-equality with a deleted layer and
|
|
33
|
+
email design is allowed to change. Nothing now catches a snapshot regenerated
|
|
34
|
+
under refactor pressure except reading the diff — so read it.
|
|
35
|
+
- The copyright year is pinned to `FIXTURE_CLOCK`'s, never the wall clock. It is
|
|
36
|
+
baked into every case via `signature`, so an unpinned capture would fail on the
|
|
37
|
+
next New Year's Day for calendar reasons alone.
|
|
38
|
+
- The year is **not** redacted or normalised. It is part of the output surface
|
|
39
|
+
these snapshots exist to protect.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`NOTIFICATION_DEFINITIONS > titles every kind 1`] = `
|
|
4
|
+
{
|
|
5
|
+
"auth.otp · Default": "Your login code for Company Semantics",
|
|
6
|
+
"auth.otp · Short expiry": "Your login code for Company Semantics",
|
|
7
|
+
"auth.otp · With request metadata": "Your login code for Company Semantics",
|
|
8
|
+
"chat.shared · Default": "A chat has been shared with you",
|
|
9
|
+
"chat.shared · Long preview": "A chat has been shared with you",
|
|
10
|
+
"chat.shared · Long title": "A chat has been shared with you",
|
|
11
|
+
"chat.shared · No preview": "A chat has been shared with you",
|
|
12
|
+
"chat.shared · Short": "A chat has been shared with you",
|
|
13
|
+
"companyMd.access_request_approved · Editor": "Your access request was approved",
|
|
14
|
+
"companyMd.access_request_approved · Viewer": "Your access request was approved",
|
|
15
|
+
"companyMd.access_request_denied · No reason": "Your access request was reviewed",
|
|
16
|
+
"companyMd.access_request_denied · With reason": "Your access request was reviewed",
|
|
17
|
+
"companyMd.access_requested · No message": "Someone requested access to a document",
|
|
18
|
+
"companyMd.access_requested · With message": "Someone requested access to a document",
|
|
19
|
+
"org.invite · Admin": "You've been invited to join Acme Corp on Company Semantics",
|
|
20
|
+
"org.invite · Member": "You've been invited to join Acme Corp on Company Semantics",
|
|
21
|
+
"org.ownership_transfer · No note (admin)": "You've been invited to become a workspace owner",
|
|
22
|
+
"org.ownership_transfer · With note + from": "You've been invited to become a workspace owner",
|
|
23
|
+
"org.ownership_transfer_completed · Default": "Workspace ownership has been transferred",
|
|
24
|
+
"org.unit_owner_granted · Delegate with expiry": "You've been added as delegate owner to Platform Engineering in Acme Corp",
|
|
25
|
+
"org.unit_owner_granted · Unit owner + message": "You've been added as owner to Platform Engineering in Acme Corp",
|
|
26
|
+
"security.alert · Excessive OTP requests": "Security alert for your account",
|
|
27
|
+
"security.alert · Unusual login location": "Security alert for your account",
|
|
28
|
+
"share.granted · Document · editor · message": "Something has been shared with you",
|
|
29
|
+
"share.granted · Meeting · viewer · no title": "Something has been shared with you",
|
|
30
|
+
}
|
|
31
|
+
`;
|
package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap
RENAMED
|
@@ -1547,7 +1547,7 @@ exports[`email render > security.alert · Excessive OTP requests 1`] = `
|
|
|
1547
1547
|
<head><meta charset="UTF-8"></head>
|
|
1548
1548
|
<body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; margin: 0; padding: 0;">
|
|
1549
1549
|
<div style="max-width: 520px; margin: 0 auto;">
|
|
1550
|
-
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">🆆🅰🆁🅽🅸🅽🅶</p>
|
|
1550
|
+
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;"><span style="color: #e7000b;">🆆🅰🆁🅽🅸🅽🅶</span></p>
|
|
1551
1551
|
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Unusual login-code activity detected.</p>
|
|
1552
1552
|
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Details: 12 login codes requested in 5 minutes</p>
|
|
1553
1553
|
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">If this was you, no action is needed.</p>
|
|
@@ -1587,7 +1587,7 @@ exports[`email render > security.alert · Unusual login location 1`] = `
|
|
|
1587
1587
|
<head><meta charset="UTF-8"></head>
|
|
1588
1588
|
<body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; margin: 0; padding: 0;">
|
|
1589
1589
|
<div style="max-width: 520px; margin: 0 auto;">
|
|
1590
|
-
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">🆆🅰🆁🅽🅸🅽🅶</p>
|
|
1590
|
+
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;"><span style="color: #e7000b;">🆆🅰🆁🅽🅸🅽🅶</span></p>
|
|
1591
1591
|
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">A login from an unusual location was detected.</p>
|
|
1592
1592
|
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Details: Login from Berlin, DE</p>
|
|
1593
1593
|
<p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">If this was you, no action is needed.</p>
|
|
@@ -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 { FIXTURE_CLOCK } from "./fixtures";
|
|
16
|
+
|
|
17
|
+
const FROZEN_YEAR = new Date(FIXTURE_CLOCK).getUTCFullYear();
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
vi.setSystemTime(new Date(FIXTURE_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(FIXTURE_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(FIXTURE_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
|
+
});
|