@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `compose` is where the whole layer's purity claim lives (ADR-CONTRACTS-085):
|
|
3
|
+
* everything above it is fact, everything below is one channel's opinion. These
|
|
4
|
+
* tests hold it to the four promises a type cannot express.
|
|
5
|
+
*
|
|
6
|
+
* PURITY — the same `(payload, context)` composes the same content, twice, with
|
|
7
|
+
* the clock moved between. The old `blocks.signature()` read
|
|
8
|
+
* `new Date().getFullYear()` mid-render, which is why every render test used to
|
|
9
|
+
* freeze time; the point of `RenderContext` is that these tests do not have to.
|
|
10
|
+
* Moving the clock and getting identical content is the proof that the ambient
|
|
11
|
+
* read is really gone.
|
|
12
|
+
*
|
|
13
|
+
* THE BRAND SEAM — the ten kinds that footer "sent via Company Semantics" must
|
|
14
|
+
* read that name from `context.brand.name`, not from an imported constant. A
|
|
15
|
+
* test that only ever renders the default context cannot tell the difference, so
|
|
16
|
+
* these override the brand and assert the override lands.
|
|
17
|
+
*
|
|
18
|
+
* THE PII GATE — the old `renderAuthOtp` took an `includeRequestMetadata` option
|
|
19
|
+
* and the backend passed an env flag. `compose` has no such parameter: the gate
|
|
20
|
+
* is whether the payload carries the fields. That relocation is a
|
|
21
|
+
* privacy-relevant behaviour change and is pinned here rather than left to the
|
|
22
|
+
* email wiring to discover.
|
|
23
|
+
*
|
|
24
|
+
* CONTENT, NOT MARKUP — the elements carry no HTML. The cheapest possible check
|
|
25
|
+
* for the mistake this layer exists to prevent.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it } from "vitest";
|
|
29
|
+
|
|
30
|
+
import type { NotificationContent, NotificationElement } from "../content";
|
|
31
|
+
import { createRenderContext } from "../context";
|
|
32
|
+
import type { NotificationKind } from "../kinds";
|
|
33
|
+
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
34
|
+
import { NOTIFICATION_FIXTURES } from "./fixtures";
|
|
35
|
+
|
|
36
|
+
const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
37
|
+
|
|
38
|
+
/** Compose a kind against its first parity fixture. */
|
|
39
|
+
function compose(
|
|
40
|
+
kind: NotificationKind,
|
|
41
|
+
context = CONTEXT,
|
|
42
|
+
): NotificationContent {
|
|
43
|
+
const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
|
|
44
|
+
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
45
|
+
const fn = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
46
|
+
p: unknown,
|
|
47
|
+
c: typeof context,
|
|
48
|
+
) => NotificationContent;
|
|
49
|
+
return fn(fixture.payload, context);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
|
|
53
|
+
|
|
54
|
+
/** Every element of a composed notification, flattened across its sections. */
|
|
55
|
+
function elementsOf(content: NotificationContent): NotificationElement[] {
|
|
56
|
+
return content.sections.flatMap((s) => s.elements);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("compose purity", () => {
|
|
60
|
+
it("is deterministic for the same payload and context", () => {
|
|
61
|
+
for (const kind of KINDS) {
|
|
62
|
+
expect(compose(kind)).toEqual(compose(kind));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not read the clock", () => {
|
|
67
|
+
// The impurity this layer retired: the old `blocks.signature()` called
|
|
68
|
+
// `new Date().getFullYear()` mid-render, so its render tests had to freeze
|
|
69
|
+
// time to stay stable. These deliberately do not.
|
|
70
|
+
//
|
|
71
|
+
// The stub fakes ONLY the argless `new Date()` and `Date.now()` — the clock.
|
|
72
|
+
// `new Date(iso)` is left alone because parsing a timestamp the PAYLOAD
|
|
73
|
+
// carries is pure, and two composes rely on it: `formatExpiry` and
|
|
74
|
+
// security.alert's `Time` row. Faking those too would fail the honest code.
|
|
75
|
+
const realDate = Date;
|
|
76
|
+
const before = KINDS.map((kind) => compose(kind));
|
|
77
|
+
try {
|
|
78
|
+
globalThis.Date = class extends realDate {
|
|
79
|
+
constructor(...args: ConstructorParameters<DateConstructor>) {
|
|
80
|
+
if (args.length === 0) super("2099-01-01T00:00:00.000Z");
|
|
81
|
+
else super(...args);
|
|
82
|
+
}
|
|
83
|
+
static now(): number {
|
|
84
|
+
return new realDate("2099-01-01T00:00:00.000Z").getTime();
|
|
85
|
+
}
|
|
86
|
+
} as DateConstructor;
|
|
87
|
+
|
|
88
|
+
expect(KINDS.map((kind) => compose(kind))).toEqual(before);
|
|
89
|
+
} finally {
|
|
90
|
+
globalThis.Date = realDate;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("emits no year — the copyright year is the renderer's, from the context", () => {
|
|
95
|
+
// The old `signature()` baked the year into its bytes. Here `signature` is a
|
|
96
|
+
// fieldless element and the year arrives at render time, which is what makes
|
|
97
|
+
// a composed notification safe to cache, log or compare across a New Year.
|
|
98
|
+
for (const kind of KINDS) {
|
|
99
|
+
const signatures = elementsOf(compose(kind)).filter(
|
|
100
|
+
(e) => e.type === "signature",
|
|
101
|
+
);
|
|
102
|
+
expect(signatures).toEqual([{ type: "signature" }]);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("compose reads ambient state from the context", () => {
|
|
108
|
+
it("takes the brand name from context.brand, not an imported constant", () => {
|
|
109
|
+
const context = createRenderContext({
|
|
110
|
+
brand: { name: "Umbrella Corp", copyrightYear: 2026 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Ten of the eleven kinds footer "sent via <brand>"; security.alert has no
|
|
114
|
+
// notice at all, on purpose.
|
|
115
|
+
const branded = KINDS.filter((kind) => kind !== "security.alert");
|
|
116
|
+
expect(branded).toHaveLength(10);
|
|
117
|
+
|
|
118
|
+
for (const kind of branded) {
|
|
119
|
+
const notices = elementsOf(compose(kind, context)).filter(
|
|
120
|
+
(e) => e.type === "notice",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(notices).toHaveLength(1);
|
|
124
|
+
expect(notices[0].lines[0]).toContain("Umbrella Corp");
|
|
125
|
+
expect(notices[0].lines[0]).not.toContain("Company Semantics");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("still says Company Semantics under an un-overridden context", () => {
|
|
130
|
+
// The default must reproduce today's bytes, or the parity proof moves.
|
|
131
|
+
const notice = elementsOf(compose("org.invite")).find(
|
|
132
|
+
(e) => e.type === "notice",
|
|
133
|
+
);
|
|
134
|
+
expect(notice?.lines).toEqual([
|
|
135
|
+
"This invitation was sent via Company Semantics.",
|
|
136
|
+
"If you weren't expecting this, no action is required.",
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("compose gates PII on the payload, not on an option", () => {
|
|
142
|
+
const otpCompose = NOTIFICATION_DEFINITIONS["auth.otp"].compose;
|
|
143
|
+
|
|
144
|
+
it("omits request details when the payload carries none", () => {
|
|
145
|
+
const content = otpCompose(
|
|
146
|
+
{ otp: "123456", expiresInMinutes: 10 },
|
|
147
|
+
CONTEXT,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(elementsOf(content).map((e) => e.type)).not.toContain("metadata");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("shows request details when the payload carries them", () => {
|
|
154
|
+
const content = otpCompose(
|
|
155
|
+
{
|
|
156
|
+
otp: "246810",
|
|
157
|
+
expiresInMinutes: 5,
|
|
158
|
+
requestIp: "203.0.113.4",
|
|
159
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
160
|
+
},
|
|
161
|
+
CONTEXT,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const metadata = elementsOf(content).find((e) => e.type === "metadata");
|
|
165
|
+
expect(metadata).toEqual({
|
|
166
|
+
type: "metadata",
|
|
167
|
+
label: "Request details",
|
|
168
|
+
rows: [
|
|
169
|
+
{ label: "IP address", value: "203.0.113.4" },
|
|
170
|
+
{
|
|
171
|
+
label: "Device",
|
|
172
|
+
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("truncates an overlong user agent, as the email layer did", () => {
|
|
179
|
+
const content = otpCompose(
|
|
180
|
+
{ otp: "246810", expiresInMinutes: 5, userAgent: "U".repeat(120) },
|
|
181
|
+
CONTEXT,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const metadata = elementsOf(content).find((e) => e.type === "metadata");
|
|
185
|
+
expect(metadata).toEqual({
|
|
186
|
+
type: "metadata",
|
|
187
|
+
label: "Request details",
|
|
188
|
+
rows: [{ label: "Device", value: `${"U".repeat(77)}...` }],
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("shows the code itself as a call to action with no href", () => {
|
|
193
|
+
// `href` absent means the label IS the payload. A renderer must not
|
|
194
|
+
// fabricate a destination for an OTP.
|
|
195
|
+
const chatUnit = elementsOf(
|
|
196
|
+
otpCompose({ otp: "123456", expiresInMinutes: 10 }, CONTEXT),
|
|
197
|
+
).find((e) => e.type === "chatUnit");
|
|
198
|
+
|
|
199
|
+
expect(chatUnit?.items).toContainEqual({
|
|
200
|
+
type: "callToAction",
|
|
201
|
+
label: "123456",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("compose emits content, not markup", () => {
|
|
207
|
+
it("puts no HTML in any element of any kind", () => {
|
|
208
|
+
for (const kind of KINDS) {
|
|
209
|
+
const serialized = JSON.stringify(compose(kind));
|
|
210
|
+
expect(serialized).not.toMatch(/<\/?[a-z]+[\s>]/i);
|
|
211
|
+
expect(serialized).not.toContain("'");
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("carries no spacing — that is a channel's answer, not the notification's", () => {
|
|
216
|
+
for (const kind of KINDS) {
|
|
217
|
+
for (const element of elementsOf(compose(kind))) {
|
|
218
|
+
expect(element).not.toHaveProperty("spacing");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -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;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NotificationKind` is the migration's central claim: that the eleven things
|
|
3
|
+
* the email layer could actually SAY are notifications, not emails
|
|
4
|
+
* (ADR-CONTRACTS-085).
|
|
5
|
+
*
|
|
6
|
+
* WHAT THESE TESTS USED TO DO, AND WHY THEY NO LONGER DO IT. Membership was
|
|
7
|
+
* asserted against `IMPLEMENTED_EMAIL_KINDS`, and the union's deliberate
|
|
8
|
+
* divergence against `EmailKind` — one live union checked against another. Both
|
|
9
|
+
* sides of both comparisons were `src/email/`'s, and that directory is now
|
|
10
|
+
* deleted (ADR-CONTRACTS-086). Those assertions were not rewired, because there
|
|
11
|
+
* is nothing to rewire them to: an assertion with one side left is not a weaker
|
|
12
|
+
* version of itself, it is a tautology that reads like coverage. They were
|
|
13
|
+
* removed rather than allowed to pass vacuously.
|
|
14
|
+
*
|
|
15
|
+
* What still holds the membership claim, in the order it bites:
|
|
16
|
+
* - the `Record<NotificationKind, true>` below, which fails to COMPILE if a
|
|
17
|
+
* union member is added without being named here;
|
|
18
|
+
* - the frozen list in the first test, which fails at runtime if one is removed;
|
|
19
|
+
* - `./registry.test.ts`, which pins a definition for every member;
|
|
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".
|
|
22
|
+
*
|
|
23
|
+
* The DIVERGENCE is the easiest thing in this change to mistake for an omission,
|
|
24
|
+
* so it keeps a test of its own. `auth.magic_link` was a registered subject with
|
|
25
|
+
* no template: the old `renderEmail` accepted it and threw. Excluding it is what
|
|
26
|
+
* makes the notification registry total, so its absence is asserted on purpose —
|
|
27
|
+
* restoring it "for symmetry" would put the runtime hole back.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, expect, it } from "vitest";
|
|
31
|
+
|
|
32
|
+
import type { NotificationKind } from "../kinds";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every notification kind, exhaustively. The `Record` type documents the lock; a
|
|
36
|
+
* new union member must be named here. The assertions below are what enforce it
|
|
37
|
+
* at runtime — `src/tsconfig.json` excludes test files, so the type alone would
|
|
38
|
+
* not fail a build.
|
|
39
|
+
*/
|
|
40
|
+
const ALL_NOTIFICATION_KINDS: Record<NotificationKind, true> = {
|
|
41
|
+
"auth.otp": true,
|
|
42
|
+
"org.invite": true,
|
|
43
|
+
"org.unit_owner_granted": true,
|
|
44
|
+
"org.ownership_transfer": true,
|
|
45
|
+
"org.ownership_transfer_completed": true,
|
|
46
|
+
"security.alert": true,
|
|
47
|
+
"chat.shared": true,
|
|
48
|
+
"share.granted": true,
|
|
49
|
+
"companyMd.access_requested": true,
|
|
50
|
+
"companyMd.access_request_approved": true,
|
|
51
|
+
"companyMd.access_request_denied": true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const KINDS = Object.keys(ALL_NOTIFICATION_KINDS).sort();
|
|
55
|
+
|
|
56
|
+
describe("NotificationKind", () => {
|
|
57
|
+
it("has exactly the eleven kinds with something to say", () => {
|
|
58
|
+
expect(KINDS).toEqual([
|
|
59
|
+
"auth.otp",
|
|
60
|
+
"chat.shared",
|
|
61
|
+
"companyMd.access_request_approved",
|
|
62
|
+
"companyMd.access_request_denied",
|
|
63
|
+
"companyMd.access_requested",
|
|
64
|
+
"org.invite",
|
|
65
|
+
"org.ownership_transfer",
|
|
66
|
+
"org.ownership_transfer_completed",
|
|
67
|
+
"org.unit_owner_granted",
|
|
68
|
+
"security.alert",
|
|
69
|
+
"share.granted",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("excludes auth.magic_link, the one kind email registered without implementing", () => {
|
|
74
|
+
// Deliberate, not an oversight: `renderEmail("auth.magic_link", …)`
|
|
75
|
+
// type-checked and threw. A kind earns membership by having something to
|
|
76
|
+
// say, not by having a subject reserved for it. `./registry.test.ts` asserts
|
|
77
|
+
// `isValidNotificationKind` rejects it at the runtime boundary too.
|
|
78
|
+
expect(KINDS).not.toContain("auth.magic_link");
|
|
79
|
+
});
|
|
80
|
+
});
|