@company-semantics/contracts 35.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 +1 -1
- package/src/notifications/README.md +21 -19
- package/src/notifications/__tests__/README.md +42 -47
- package/src/notifications/__tests__/__snapshots__/README.md +15 -8
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
- package/src/notifications/__tests__/context.test.ts +5 -5
- package/src/notifications/__tests__/definition.test.ts +2 -2
- package/src/notifications/__tests__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +2 -3
- package/src/notifications/__tests__/registry.test.ts +27 -27
- package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
- package/src/notifications/kinds/README.md +2 -2
- package/src/notifications/render.ts +6 -6
- package/src/notifications/renderers/email/README.md +10 -6
- package/src/notifications/renderers/email/__tests__/README.md +19 -16
- package/src/notifications/renderers/email/__tests__/render.test.ts +44 -36
- package/src/notifications/renderers/email/chat.ts +3 -2
- package/src/notifications/renderers/email/constants.ts +17 -0
- package/src/notifications/renderers/email/index.ts +4 -4
- package/src/notifications/renderers/email/render.ts +15 -2
- package/src/notifications/renderers/email/shells.ts +3 -2
- package/src/notifications/text.ts +2 -2
- package/src/notifications/__tests__/output-parity.golden.ts +0 -363
- package/src/notifications/__tests__/output-parity.test.ts +0 -122
package/package.json
CHANGED
|
@@ -9,15 +9,17 @@ into `Definition → Payload → compose(payload, context) → Content → Rende
|
|
|
9
9
|
This is the package's SOLE notification surface. `@company-semantics/contracts/email`
|
|
10
10
|
and every `Email*` symbol are gone, with no shim and no alias.
|
|
11
11
|
|
|
12
|
-
The domain holds the **
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
migration
|
|
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`.
|
|
21
23
|
|
|
22
24
|
- `content.ts` — `NotificationContent { metadata, sections }`, `Section`, and the
|
|
23
25
|
flat `NotificationElement` union (greeting, body, keyValueTable, callToAction,
|
|
@@ -91,16 +93,16 @@ deliberately (ADR-CONTRACTS-086):
|
|
|
91
93
|
|
|
92
94
|
## Invariants
|
|
93
95
|
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
- Every kind in `NOTIFICATION_DEFINITIONS` needs at least one
|
|
100
|
-
enforced by the
|
|
101
|
-
"every kind the entry point accepts" and "every
|
|
102
|
-
same claim.
|
|
103
|
-
- Rendering
|
|
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
|
|
104
106
|
`copyrightYear` in the `RenderContext`, NOT by freezing the clock. `signature`
|
|
105
107
|
used to read `new Date().getFullYear()` mid-render, which is why the fixtures
|
|
106
108
|
once needed fake timers; the year is a value now, and no test in this domain
|
|
@@ -2,53 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
The
|
|
6
|
-
(ADR-CONTRACTS-086), and the snapshot suite that documents the markup
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- `render-snapshot.test.ts` + `__snapshots__/` —
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
The parity
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
27
35
|
|
|
28
36
|
## Invariants
|
|
29
37
|
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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`.
|
|
@@ -13,6 +13,11 @@ stayed `{kind} · {name}`, so every snapshot key still resolves against the same
|
|
|
13
13
|
file. That is the claim the move is making — the keys matched, so the bytes
|
|
14
14
|
matched.
|
|
15
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
|
+
|
|
16
21
|
This directory exists because vitest resolves snapshots adjacent to the test
|
|
17
22
|
file, so the `.snap` had to travel with it.
|
|
18
23
|
|
|
@@ -20,13 +25,15 @@ file, so the `.snap` had to travel with it.
|
|
|
20
25
|
|
|
21
26
|
- This is **production-parity email markup**. A diff here means a real sent email
|
|
22
27
|
changed. Review it, then `vitest -u` — never the other way round.
|
|
23
|
-
- These snapshots are **
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
31
38
|
- The year is **not** redacted or normalised. It is part of the output surface
|
|
32
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
|
+
`;
|
|
@@ -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>
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
13
13
|
|
|
14
14
|
import { COMPANY_NAME, createRenderContext } from "../context";
|
|
15
|
-
import {
|
|
15
|
+
import { FIXTURE_CLOCK } from "./fixtures";
|
|
16
16
|
|
|
17
|
-
const FROZEN_YEAR = new Date(
|
|
17
|
+
const FROZEN_YEAR = new Date(FIXTURE_CLOCK).getUTCFullYear();
|
|
18
18
|
|
|
19
19
|
beforeAll(() => {
|
|
20
20
|
vi.useFakeTimers();
|
|
21
|
-
vi.setSystemTime(new Date(
|
|
21
|
+
vi.setSystemTime(new Date(FIXTURE_CLOCK));
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
afterAll(() => {
|
|
@@ -38,7 +38,7 @@ describe("createRenderContext", () => {
|
|
|
38
38
|
it("defaults the year from the clock", () => {
|
|
39
39
|
vi.setSystemTime(new Date("2030-03-04T05:06:07.000Z"));
|
|
40
40
|
expect(createRenderContext().brand.copyrightYear).toBe(2030);
|
|
41
|
-
vi.setSystemTime(new Date(
|
|
41
|
+
vi.setSystemTime(new Date(FIXTURE_CLOCK));
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it("overrides each brand field independently", () => {
|
|
@@ -55,7 +55,7 @@ describe("createRenderContext", () => {
|
|
|
55
55
|
const before = createRenderContext(pinned);
|
|
56
56
|
vi.setSystemTime(new Date("2031-01-01T00:00:00.000Z"));
|
|
57
57
|
expect(createRenderContext(pinned)).toEqual(before);
|
|
58
|
-
vi.setSystemTime(new Date(
|
|
58
|
+
vi.setSystemTime(new Date(FIXTURE_CLOCK));
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it("returns a fresh context per call — callers cannot alias each other", () => {
|
|
@@ -31,7 +31,7 @@ import type { NotificationContent, NotificationElement } from "../content";
|
|
|
31
31
|
import { createRenderContext } from "../context";
|
|
32
32
|
import type { NotificationKind } from "../kinds";
|
|
33
33
|
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
34
|
-
import {
|
|
34
|
+
import { NOTIFICATION_FIXTURES } from "./fixtures";
|
|
35
35
|
|
|
36
36
|
const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
37
37
|
|
|
@@ -40,7 +40,7 @@ function compose(
|
|
|
40
40
|
kind: NotificationKind,
|
|
41
41
|
context = CONTEXT,
|
|
42
42
|
): NotificationContent {
|
|
43
|
-
const fixture =
|
|
43
|
+
const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
|
|
44
44
|
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
45
45
|
const fn = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
46
46
|
p: unknown,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification fixture corpus: every implemented kind × representative
|
|
3
|
+
* payload. Shared by `./render-snapshot.test.ts`, `./definition.test.ts`,
|
|
4
|
+
* `./registry.test.ts` and `../renderers/email/__tests__/render.test.ts`, so a
|
|
5
|
+
* kind added to the registry is covered everywhere by being added once here.
|
|
6
|
+
*
|
|
7
|
+
* WAS `output-parity.golden.ts` (ADR-CONTRACTS-087). These same inputs used to
|
|
8
|
+
* carry a frozen `OUTPUT_PARITY_GOLDEN` of the `{ subject, text, html }` the
|
|
9
|
+
* email layer emitted at 32.0.0, asserted byte-for-byte to prove the
|
|
10
|
+
* multi-channel generalisation (ADR-CONTRACTS-086) changed no output. That proof
|
|
11
|
+
* passed and shipped in 35.0.0, which discharged it: it was a claim about a
|
|
12
|
+
* migration, not a freeze on email design. The bytes were retired rather than
|
|
13
|
+
* rewritten — editing them to match a deliberate redesign would have made the
|
|
14
|
+
* file assert something false about a layer that no longer exists. They remain
|
|
15
|
+
* readable in git at `v35.0.0`.
|
|
16
|
+
*
|
|
17
|
+
* `./render-snapshot.test.ts` is now the fence: it locks the same fixtures'
|
|
18
|
+
* rendered output, and unlike the golden it is regenerable on purpose, because
|
|
19
|
+
* changing how email looks is now an ordinary reviewed diff.
|
|
20
|
+
*
|
|
21
|
+
* Capture-time options are not recorded. The old `renderEmail` took a
|
|
22
|
+
* `RenderEmailOptions`, and one fixture set `includeRequestMetadata: true`; the
|
|
23
|
+
* entry point has no equivalent because `auth.otp`'s `compose` gates request
|
|
24
|
+
* details on whether the payload carries them — and that fixture's payload
|
|
25
|
+
* carries both, so the gate opens anyway.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { NotificationKind } from "../kinds";
|
|
29
|
+
import type { NotificationPayloads } from "../payloads";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The instant every fixture renders under.
|
|
33
|
+
*
|
|
34
|
+
* A pinned year, not a frozen clock. The copyright year is a value on
|
|
35
|
+
* `RenderContext`, so a test states this instant instead of stubbing time —
|
|
36
|
+
* which is the impurity ADR-CONTRACTS-086 retired. Pinned rather than left to
|
|
37
|
+
* the wall clock so no snapshot is due to fail on New Year's Day.
|
|
38
|
+
*/
|
|
39
|
+
export const FIXTURE_CLOCK = "2026-07-15T00:00:00.000Z";
|
|
40
|
+
|
|
41
|
+
/** One case: a kind rendered with a representative payload. */
|
|
42
|
+
export interface NotificationFixture {
|
|
43
|
+
readonly kind: NotificationKind;
|
|
44
|
+
/** Variant label, unique within a kind. */
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly payload: NotificationPayloads[NotificationKind];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stable label for a fixture — also its snapshot key. */
|
|
50
|
+
export function fixtureKey(kind: NotificationKind, name: string): string {
|
|
51
|
+
return `${kind} · ${name}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fixtures: NotificationFixture[] = [];
|
|
55
|
+
function add<K extends NotificationKind>(
|
|
56
|
+
kind: K,
|
|
57
|
+
name: string,
|
|
58
|
+
payload: NotificationPayloads[K],
|
|
59
|
+
): void {
|
|
60
|
+
fixtures.push({ kind, name, payload });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const APP = "https://app.companysemantics.ai";
|
|
64
|
+
|
|
65
|
+
add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
|
|
66
|
+
add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
|
|
67
|
+
add("auth.otp", "With request metadata", {
|
|
68
|
+
otp: "246810",
|
|
69
|
+
expiresInMinutes: 5,
|
|
70
|
+
requestIp: "203.0.113.4",
|
|
71
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
add("org.invite", "Admin", {
|
|
75
|
+
inviterName: "Alex Rivera",
|
|
76
|
+
orgName: "Acme Corp",
|
|
77
|
+
role: "admin",
|
|
78
|
+
acceptUrl: `${APP}/invite/abc123`,
|
|
79
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
80
|
+
});
|
|
81
|
+
add("org.invite", "Member", {
|
|
82
|
+
inviterName: "Alex Rivera",
|
|
83
|
+
orgName: "Acme Corp",
|
|
84
|
+
role: "member",
|
|
85
|
+
acceptUrl: `${APP}/invite/def456`,
|
|
86
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
add("org.unit_owner_granted", "Unit owner + message", {
|
|
90
|
+
granterName: "Jordan Lee",
|
|
91
|
+
recipientName: "Sam Chen",
|
|
92
|
+
orgName: "Acme Corp",
|
|
93
|
+
unitName: "Platform Engineering",
|
|
94
|
+
roleLabel: "Unit owner",
|
|
95
|
+
ctaUrl: `${APP}/org/unit/42`,
|
|
96
|
+
message: "Welcome aboard — glad to have you owning this team.",
|
|
97
|
+
});
|
|
98
|
+
add("org.unit_owner_granted", "Delegate with expiry", {
|
|
99
|
+
granterName: "Jordan Lee",
|
|
100
|
+
orgName: "Acme Corp",
|
|
101
|
+
unitName: "Platform Engineering",
|
|
102
|
+
roleLabel: "Delegate",
|
|
103
|
+
ctaUrl: `${APP}/org/unit/42`,
|
|
104
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
add("org.ownership_transfer", "With note + from", {
|
|
108
|
+
orgName: "Acme Corp",
|
|
109
|
+
acceptUrl: `${APP}/owner-transfer/accept/tok123`,
|
|
110
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
111
|
+
note: "Handing this over as I move to an advisory role.",
|
|
112
|
+
fromName: "Jordan Lee",
|
|
113
|
+
});
|
|
114
|
+
add("org.ownership_transfer", "No note (admin)", {
|
|
115
|
+
orgName: "Acme Corp",
|
|
116
|
+
acceptUrl: `${APP}/owner-transfer/accept/tok456`,
|
|
117
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
add("org.ownership_transfer_completed", "Default", {
|
|
121
|
+
orgName: "Acme Corp",
|
|
122
|
+
newOwnerEmail: "new.owner@acme.com",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
add("security.alert", "Excessive OTP requests", {
|
|
126
|
+
alertType: "excessive_otp_requests",
|
|
127
|
+
details: "12 login codes requested in 5 minutes",
|
|
128
|
+
timestamp: "2026-07-12T14:30:00Z",
|
|
129
|
+
});
|
|
130
|
+
add("security.alert", "Unusual login location", {
|
|
131
|
+
alertType: "unusual_login_location",
|
|
132
|
+
details: "Login from Berlin, DE",
|
|
133
|
+
timestamp: "2026-07-12T14:30:00Z",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const CHAT_BASE = {
|
|
137
|
+
sharedByName: "Alex Rivera",
|
|
138
|
+
visibility: "private",
|
|
139
|
+
} as const;
|
|
140
|
+
add("chat.shared", "Default", {
|
|
141
|
+
...CHAT_BASE,
|
|
142
|
+
chatTitle: "API design discussion",
|
|
143
|
+
shareUrl: `${APP}/share/abc123`,
|
|
144
|
+
previewText:
|
|
145
|
+
"Here are my thoughts on the REST API design for the new authentication endpoints.",
|
|
146
|
+
});
|
|
147
|
+
add("chat.shared", "No preview", {
|
|
148
|
+
...CHAT_BASE,
|
|
149
|
+
chatTitle: "Quick sync notes",
|
|
150
|
+
shareUrl: `${APP}/share/xyz789`,
|
|
151
|
+
});
|
|
152
|
+
add("chat.shared", "Long title", {
|
|
153
|
+
...CHAT_BASE,
|
|
154
|
+
visibility: "public",
|
|
155
|
+
chatTitle:
|
|
156
|
+
"This is a very long chat title that should be truncated to fit the layout properly",
|
|
157
|
+
shareUrl: `${APP}/share/long456`,
|
|
158
|
+
previewText: "The assistant response preview text.",
|
|
159
|
+
});
|
|
160
|
+
add("chat.shared", "Long preview", {
|
|
161
|
+
...CHAT_BASE,
|
|
162
|
+
chatTitle: "Code review feedback",
|
|
163
|
+
shareUrl: `${APP}/share/preview789`,
|
|
164
|
+
previewText:
|
|
165
|
+
"I reviewed the pull request and found several areas that need improvement. The authentication logic needs better error handling, and the database queries could be optimized.",
|
|
166
|
+
});
|
|
167
|
+
add("chat.shared", "Short", {
|
|
168
|
+
...CHAT_BASE,
|
|
169
|
+
chatTitle: "Hi",
|
|
170
|
+
shareUrl: `${APP}/share/short123`,
|
|
171
|
+
previewText: "Hello!",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
add("share.granted", "Document · editor · message", {
|
|
175
|
+
granterName: "Jordan Lee",
|
|
176
|
+
recipientName: "Sam Chen",
|
|
177
|
+
entityLabel: "document",
|
|
178
|
+
entityTitle: "Q3 Roadmap",
|
|
179
|
+
accessLevel: "editor",
|
|
180
|
+
ctaUrl: `${APP}/doc/roadmap`,
|
|
181
|
+
message: "Take a look before Thursday's planning.",
|
|
182
|
+
});
|
|
183
|
+
add("share.granted", "Meeting · viewer · no title", {
|
|
184
|
+
granterName: "Jordan Lee",
|
|
185
|
+
entityLabel: "meeting",
|
|
186
|
+
accessLevel: "viewer",
|
|
187
|
+
ctaUrl: `${APP}/meeting/8842`,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
add("companyMd.access_requested", "With message", {
|
|
191
|
+
requesterName: "Sam Chen",
|
|
192
|
+
docTitle: "Engineering Handbook",
|
|
193
|
+
message: "I'd like to contribute the onboarding section.",
|
|
194
|
+
reviewUrl: `${APP}/doc/handbook?request=req_123`,
|
|
195
|
+
});
|
|
196
|
+
add("companyMd.access_requested", "No message", {
|
|
197
|
+
requesterName: "Sam Chen",
|
|
198
|
+
docTitle: "Engineering Handbook",
|
|
199
|
+
reviewUrl: `${APP}/doc/handbook?request=req_456`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
add("companyMd.access_request_approved", "Editor", {
|
|
203
|
+
approverName: "Jordan Lee",
|
|
204
|
+
docTitle: "Engineering Handbook",
|
|
205
|
+
accessLevel: "editor",
|
|
206
|
+
docUrl: `${APP}/doc/handbook`,
|
|
207
|
+
});
|
|
208
|
+
add("companyMd.access_request_approved", "Viewer", {
|
|
209
|
+
approverName: "Jordan Lee",
|
|
210
|
+
docTitle: "Engineering Handbook",
|
|
211
|
+
accessLevel: "viewer",
|
|
212
|
+
docUrl: `${APP}/doc/handbook`,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
add("companyMd.access_request_denied", "With reason", {
|
|
216
|
+
approverName: "Jordan Lee",
|
|
217
|
+
docTitle: "Engineering Handbook",
|
|
218
|
+
reason: "This doc is limited to the platform team for now.",
|
|
219
|
+
});
|
|
220
|
+
add("companyMd.access_request_denied", "No reason", {
|
|
221
|
+
approverName: "Jordan Lee",
|
|
222
|
+
docTitle: "Engineering Handbook",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** The corpus — every implemented kind × representative variants. */
|
|
226
|
+
export const NOTIFICATION_FIXTURES: readonly NotificationFixture[] = fixtures;
|
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
* union member is added without being named here;
|
|
18
18
|
* - the frozen list in the first test, which fails at runtime if one is removed;
|
|
19
19
|
* - `./registry.test.ts`, which pins a definition for every member;
|
|
20
|
-
* - `./
|
|
21
|
-
*
|
|
22
|
-
* kinds email could render": every kind here has bytes that layer produced.
|
|
20
|
+
* - `./render-snapshot.test.ts`, which pins rendered output for every member.
|
|
21
|
+
* That is what carries "these are exactly the kinds email can render".
|
|
23
22
|
*
|
|
24
23
|
* The DIVERGENCE is the easiest thing in this change to mistake for an omission,
|
|
25
24
|
* so it keeps a test of its own. `auth.magic_link` was a registered subject with
|
|
@@ -28,11 +28,7 @@ import {
|
|
|
28
28
|
isValidNotificationKind,
|
|
29
29
|
NOTIFICATION_DEFINITIONS,
|
|
30
30
|
} from "../registry";
|
|
31
|
-
import {
|
|
32
|
-
fixtureKey,
|
|
33
|
-
OUTPUT_PARITY_FIXTURES,
|
|
34
|
-
OUTPUT_PARITY_GOLDEN,
|
|
35
|
-
} from "./output-parity.golden";
|
|
31
|
+
import { fixtureKey, NOTIFICATION_FIXTURES } from "./fixtures";
|
|
36
32
|
|
|
37
33
|
const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
|
|
38
34
|
|
|
@@ -45,7 +41,7 @@ const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
|
45
41
|
* so a second set of inputs would be a second thing to keep true.
|
|
46
42
|
*/
|
|
47
43
|
function payloadFor(kind: NotificationKind): unknown {
|
|
48
|
-
const fixture =
|
|
44
|
+
const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
|
|
49
45
|
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
50
46
|
return fixture.payload;
|
|
51
47
|
}
|
|
@@ -112,28 +108,32 @@ describe("NOTIFICATION_DEFINITIONS", () => {
|
|
|
112
108
|
}
|
|
113
109
|
});
|
|
114
110
|
|
|
115
|
-
it("titles every kind
|
|
116
|
-
// The
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
111
|
+
it("titles every kind", () => {
|
|
112
|
+
// The title is what the email channel turns into a subject, and the
|
|
113
|
+
// interpolation `compose` does (the `{orgName}` fill, and
|
|
114
|
+
// unit_owner_granted's derived `roleWord`) is easy to break silently — a
|
|
115
|
+
// template literal that stops filling yields a plausible-looking title with
|
|
116
|
+
// a hole in it. Snapshotting every fixture's title makes that a reviewable
|
|
117
|
+
// diff, and isolates the claim at COMPOSE: `./render-snapshot.test.ts`
|
|
118
|
+
// covers the same titles once the renderer has had them.
|
|
121
119
|
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
120
|
+
// These titles were asserted against the frozen 32.0.0 golden's subjects
|
|
121
|
+
// until ADR-CONTRACTS-087 retired it — the values below are those subjects,
|
|
122
|
+
// recaptured here as an ordinary snapshot. The migration proof they served
|
|
123
|
+
// was discharged in 35.0.0.
|
|
124
|
+
const titles = Object.fromEntries(
|
|
125
|
+
NOTIFICATION_FIXTURES.map((fixture) => {
|
|
126
|
+
const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
|
|
127
|
+
p: unknown,
|
|
128
|
+
c: typeof CONTEXT,
|
|
129
|
+
) => { metadata: { title: string } };
|
|
130
|
+
return [
|
|
131
|
+
fixtureKey(fixture.kind, fixture.name),
|
|
132
|
+
compose(fixture.payload, CONTEXT).metadata.title,
|
|
133
|
+
];
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
expect(titles).toMatchSnapshot();
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|