@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NOTIFICATION_DEFINITIONS` replaces `EMAIL_KINDS` as the place a caller starts
|
|
3
|
+
* from (ADR-CONTRACTS-085), so these tests hold it to the invariants that
|
|
4
|
+
* registry earned — and to the ones it did not.
|
|
5
|
+
*
|
|
6
|
+
* The inherited invariants: exhaustive over the union, keys agreeing with
|
|
7
|
+
* `definition.kind`, lookup returning the identical object.
|
|
8
|
+
*
|
|
9
|
+
* The new one is TOTALITY. `getEmailKindDefinition` could hand back a definition
|
|
10
|
+
* whose kind then threw at render; `getNotificationKindDefinition` cannot,
|
|
11
|
+
* because a kind with no `compose` cannot reach the union. That is the whole
|
|
12
|
+
* reason `NotificationKind` has eleven members where `EmailKind` had twelve, and
|
|
13
|
+
* it is only true as long as every definition here actually composes — which the
|
|
14
|
+
* last test checks by calling all of them rather than trusting the type.
|
|
15
|
+
*
|
|
16
|
+
* And the ABSENCES: `subject`, `plainTextRequired` and `htmlSupported` are gone.
|
|
17
|
+
* Asserting a field is missing looks pedantic until someone re-adds `subject`
|
|
18
|
+
* "just for the registry test" and re-fuses the two layers ADR-CONTRACTS-086
|
|
19
|
+
* split.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
|
|
24
|
+
import { createRenderContext } from "../context";
|
|
25
|
+
import type { NotificationKind } from "../kinds";
|
|
26
|
+
import {
|
|
27
|
+
getNotificationKindDefinition,
|
|
28
|
+
isValidNotificationKind,
|
|
29
|
+
NOTIFICATION_DEFINITIONS,
|
|
30
|
+
} from "../registry";
|
|
31
|
+
import { fixtureKey, NOTIFICATION_FIXTURES } from "./fixtures";
|
|
32
|
+
|
|
33
|
+
const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
|
|
34
|
+
|
|
35
|
+
/** Pinned so no wall-clock year can reach an assertion. */
|
|
36
|
+
const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A representative payload per kind, taken from the parity fixtures rather than
|
|
40
|
+
* written afresh — these definitions exist to say what those exact payloads say,
|
|
41
|
+
* so a second set of inputs would be a second thing to keep true.
|
|
42
|
+
*/
|
|
43
|
+
function payloadFor(kind: NotificationKind): unknown {
|
|
44
|
+
const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
|
|
45
|
+
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
46
|
+
return fixture.payload;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("NOTIFICATION_DEFINITIONS", () => {
|
|
50
|
+
it("has an entry for every notification kind", () => {
|
|
51
|
+
expect([...KINDS].sort()).toEqual([
|
|
52
|
+
"auth.otp",
|
|
53
|
+
"chat.shared",
|
|
54
|
+
"companyMd.access_request_approved",
|
|
55
|
+
"companyMd.access_request_denied",
|
|
56
|
+
"companyMd.access_requested",
|
|
57
|
+
"org.invite",
|
|
58
|
+
"org.ownership_transfer",
|
|
59
|
+
"org.ownership_transfer_completed",
|
|
60
|
+
"org.unit_owner_granted",
|
|
61
|
+
"security.alert",
|
|
62
|
+
"share.granted",
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keys its entries by their own kind", () => {
|
|
67
|
+
for (const [key, definition] of Object.entries(NOTIFICATION_DEFINITIONS)) {
|
|
68
|
+
expect(definition.kind).toBe(key);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("carries no subject — a title is content, and belongs to compose", () => {
|
|
73
|
+
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
74
|
+
expect(definition).not.toHaveProperty("subject");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("carries no rendering capability — that is the renderer's to answer", () => {
|
|
79
|
+
// `plainTextRequired` / `htmlSupported` put capability on the kind. A kind is
|
|
80
|
+
// not what can or cannot draw an image; `Renderer.supports` is.
|
|
81
|
+
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
82
|
+
expect(definition).not.toHaveProperty("plainTextRequired");
|
|
83
|
+
expect(definition).not.toHaveProperty("htmlSupported");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("names no destination for any kind", () => {
|
|
88
|
+
// Capability is asked of a renderer, never declared on a definition —
|
|
89
|
+
// otherwise every new surface is a rewrite of all eleven definitions.
|
|
90
|
+
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
91
|
+
expect(Object.keys(definition).sort()).toEqual(["compose", "kind"]);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("composes for every kind, so the registry is total in practice and not just in type", () => {
|
|
96
|
+
// The claim that makes `getNotificationKindDefinition` safe where
|
|
97
|
+
// `getEmailKindDefinition` was not. Calling all eleven is the only way to
|
|
98
|
+
// know it: a type says a `compose` exists, not that it returns content.
|
|
99
|
+
for (const kind of KINDS) {
|
|
100
|
+
const compose = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
101
|
+
p: unknown,
|
|
102
|
+
c: typeof CONTEXT,
|
|
103
|
+
) => { metadata: { kind: string; title: string } };
|
|
104
|
+
const content = compose(payloadFor(kind), CONTEXT);
|
|
105
|
+
|
|
106
|
+
expect(content.metadata.kind).toBe(kind);
|
|
107
|
+
expect(content.metadata.title).not.toBe("");
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
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.
|
|
119
|
+
//
|
|
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
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("getNotificationKindDefinition", () => {
|
|
141
|
+
it("returns the definition for a kind", () => {
|
|
142
|
+
expect(getNotificationKindDefinition("org.invite").kind).toBe("org.invite");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns the identical registry object, not a copy", () => {
|
|
146
|
+
expect(getNotificationKindDefinition("auth.otp")).toBe(
|
|
147
|
+
NOTIFICATION_DEFINITIONS["auth.otp"],
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("cannot fail for any kind in the union", () => {
|
|
152
|
+
// The improvement on getEmailKindDefinition, which returned a definition for
|
|
153
|
+
// auth.magic_link that then threw when rendered.
|
|
154
|
+
for (const kind of KINDS) {
|
|
155
|
+
expect(getNotificationKindDefinition(kind)).toBeDefined();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("isValidNotificationKind", () => {
|
|
161
|
+
it("accepts every kind in the registry", () => {
|
|
162
|
+
for (const kind of KINDS) {
|
|
163
|
+
expect(isValidNotificationKind(kind)).toBe(true);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects unknown, empty and partial kinds", () => {
|
|
168
|
+
expect(isValidNotificationKind("org.nope")).toBe(false);
|
|
169
|
+
expect(isValidNotificationKind("")).toBe(false);
|
|
170
|
+
expect(isValidNotificationKind("org.")).toBe(false);
|
|
171
|
+
expect(isValidNotificationKind("invite")).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects auth.magic_link, which email registered but never implemented", () => {
|
|
175
|
+
expect(isValidNotificationKind("auth.magic_link")).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("rejects inherited Object properties", () => {
|
|
179
|
+
// `kind in NOTIFICATION_DEFINITIONS` walks the prototype chain, so this is
|
|
180
|
+
// the one input shape the implementation could get wrong at an API boundary.
|
|
181
|
+
expect(isValidNotificationKind("toString")).toBe(false);
|
|
182
|
+
expect(isValidNotificationKind("constructor")).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -1,39 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Locks the rendered output (subject / text / html) of every
|
|
2
|
+
* Locks the rendered output (subject / text / html) of every notification kind ×
|
|
3
3
|
* representative variant. This is production-parity email markup — a snapshot
|
|
4
4
|
* change means a real sent email changed. Review the diff, then `vitest -u`.
|
|
5
|
+
*
|
|
6
|
+
* MOVED (ADR-CONTRACTS-086) from the old `src/email/render/__tests__/`, and
|
|
7
|
+
* rewired from that layer's `renderEmail` onto this one's. The snapshot suite
|
|
8
|
+
* documents the email markup and moved with it; the markup now lives behind the
|
|
9
|
+
* channel seam at `../renderers/email`, reached through `../render`.
|
|
10
|
+
*
|
|
11
|
+
* The recorded VALUES did not change — the `.snap` moved verbatim, and the
|
|
12
|
+
* describe label ("email render") and case labels are deliberately unchanged so
|
|
13
|
+
* every snapshot key still resolves. That is the whole claim of the move: the
|
|
14
|
+
* keys still match, so the bytes are still the bytes.
|
|
15
|
+
*
|
|
16
|
+
* This file used to be the reviewable companion to `./output-parity.test.ts`,
|
|
17
|
+
* which was the load-bearing one: a snapshot can be regenerated with a
|
|
18
|
+
* keystroke, a committed golden cannot, so the golden was the proof and this was
|
|
19
|
+
* the documentation. ADR-CONTRACTS-087 retired that golden once its migration
|
|
20
|
+
* proof shipped in 35.0.0 — so this file is now BOTH. There is no longer a
|
|
21
|
+
* second oracle to catch a snapshot refreshed on autopilot; the diff you read
|
|
22
|
+
* before typing `vitest -u` is the control.
|
|
23
|
+
*
|
|
24
|
+
* The clock is pinned to `FIXTURE_CLOCK`'s year rather than left to run.
|
|
25
|
+
* These snapshots used to be captured under the wall clock — with the copyright
|
|
26
|
+
* year baked into every one of them — so they were due to fail on the next New
|
|
27
|
+
* Year's Day for calendar reasons alone. The year is a value in `RenderContext`
|
|
28
|
+
* now, so it is simply stated.
|
|
5
29
|
*/
|
|
6
30
|
|
|
7
31
|
import { describe, expect, it } from "vitest";
|
|
8
32
|
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
import type { RenderContextOverrides } from "../context";
|
|
34
|
+
import type { NotificationKind } from "../kinds";
|
|
35
|
+
import type { NotificationPayloads } from "../payloads";
|
|
36
|
+
import { renderEmail } from "../render";
|
|
37
|
+
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
38
|
+
import type { RenderedEmail } from "../renderers/email";
|
|
39
|
+
import { FIXTURE_CLOCK } from "./fixtures";
|
|
40
|
+
|
|
41
|
+
/** Pinned so no wall-clock year can reach a snapshot. */
|
|
42
|
+
const CONTEXT: RenderContextOverrides = {
|
|
43
|
+
brand: { copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear() },
|
|
44
|
+
};
|
|
17
45
|
|
|
18
46
|
interface Fixture {
|
|
19
|
-
kind:
|
|
47
|
+
kind: NotificationKind;
|
|
20
48
|
name: string;
|
|
21
49
|
render: () => RenderedEmail;
|
|
22
50
|
}
|
|
23
51
|
|
|
24
52
|
const fixtures: Fixture[] = [];
|
|
25
|
-
function add<K extends
|
|
53
|
+
function add<K extends NotificationKind>(
|
|
26
54
|
kind: K,
|
|
27
55
|
name: string,
|
|
28
|
-
payload:
|
|
29
|
-
options?: RenderEmailOptions,
|
|
56
|
+
payload: NotificationPayloads[K],
|
|
30
57
|
): void {
|
|
31
58
|
// `payload as never`: the add() call sites are type-checked (payload:
|
|
32
|
-
//
|
|
59
|
+
// NotificationPayloads[K]); this only bridges compose's per-kind payload.
|
|
33
60
|
fixtures.push({
|
|
34
61
|
kind,
|
|
35
62
|
name,
|
|
36
|
-
render: () => renderEmail(kind, payload as never,
|
|
63
|
+
render: () => renderEmail(kind, payload as never, CONTEXT),
|
|
37
64
|
});
|
|
38
65
|
}
|
|
39
66
|
|
|
@@ -41,17 +68,15 @@ const APP = "https://app.companysemantics.ai";
|
|
|
41
68
|
|
|
42
69
|
add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
|
|
43
70
|
add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{ includeRequestMetadata: true },
|
|
54
|
-
);
|
|
71
|
+
// No `includeRequestMetadata` any more: `compose` gates request details on
|
|
72
|
+
// whether the payload carries them, and this payload does. The unchanged
|
|
73
|
+
// snapshot is what proves the gate still opens.
|
|
74
|
+
add("auth.otp", "With request metadata", {
|
|
75
|
+
otp: "246810",
|
|
76
|
+
expiresInMinutes: 5,
|
|
77
|
+
requestIp: "203.0.113.4",
|
|
78
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
79
|
+
});
|
|
55
80
|
|
|
56
81
|
add("org.invite", "Admin", {
|
|
57
82
|
inviterName: "Alex Rivera",
|
|
@@ -204,11 +229,19 @@ add("companyMd.access_request_denied", "No reason", {
|
|
|
204
229
|
docTitle: "Engineering Handbook",
|
|
205
230
|
});
|
|
206
231
|
|
|
232
|
+
// "email render", not "notification render": the snapshot keys are
|
|
233
|
+
// `email render > {kind} · {name} 1`, and renaming the describe would orphan
|
|
234
|
+
// every one of them — regenerating the whole .snap to prove nothing changed is
|
|
235
|
+
// exactly the move ADR-CONTRACTS-086 exists to prevent.
|
|
207
236
|
describe("email render", () => {
|
|
208
|
-
it("has a fixture for every
|
|
237
|
+
it("has a fixture for every kind", () => {
|
|
238
|
+
// Sourced from the registry rather than the old `IMPLEMENTED_EMAIL_KINDS`:
|
|
239
|
+
// the registry is what a caller starts from now, and it is total.
|
|
209
240
|
const covered = new Set(fixtures.map((f) => f.kind));
|
|
210
|
-
for (const kind of
|
|
211
|
-
expect(covered.has(kind), `missing
|
|
241
|
+
for (const kind of Object.keys(NOTIFICATION_DEFINITIONS)) {
|
|
242
|
+
expect(covered.has(kind as NotificationKind), `missing ${kind}`).toBe(
|
|
243
|
+
true,
|
|
244
|
+
);
|
|
212
245
|
}
|
|
213
246
|
});
|
|
214
247
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Renderer<Out>` is the channel seam (ADR-CONTRACTS-086). It is a type, so what
|
|
3
|
+
* is worth proving is that the seam actually holds the two properties it was
|
|
4
|
+
* shaped for — using real conforming renderers rather than assertions about the
|
|
5
|
+
* type itself.
|
|
6
|
+
*
|
|
7
|
+
* 1. Capability lives on the renderer. Two channels with DIFFERENT `supports`
|
|
8
|
+
* consume the SAME content and each drop what they cannot depict — with no
|
|
9
|
+
* channel tag anywhere in the content.
|
|
10
|
+
* 2. Each channel returns its natural type. `Out` is a string here and a record
|
|
11
|
+
* there; neither is coerced into a shared shape.
|
|
12
|
+
*
|
|
13
|
+
* Plus the purity invariant: `render` reads the year from `RenderContext`, so
|
|
14
|
+
* these tests need no fake timer — which is the improvement being locked in.
|
|
15
|
+
*
|
|
16
|
+
* `../renderers/sms` and `../renderers/slack` now assert both properties too, with
|
|
17
|
+
* real modules, and they are the load-bearing proof — a claim about the seam is
|
|
18
|
+
* worth more when a shipped channel makes it. The doubles below STAY, and stay
|
|
19
|
+
* local, on purpose: this file tests the TYPE, and pointing it at the stubs would
|
|
20
|
+
* make the seam's proof hostage to their fidelity choices. The first Slack kind
|
|
21
|
+
* that rewrites `slackRenderer`'s wording must not be able to fail the test that
|
|
22
|
+
* says capability lives on the renderer. Two channels answering `supports`
|
|
23
|
+
* differently is all this file needs, and inventing them here is cheaper than
|
|
24
|
+
* coupling to modules whose copy is explicitly provisional.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, it } from "vitest";
|
|
28
|
+
|
|
29
|
+
import type { NotificationContent, NotificationElementType } from "../content";
|
|
30
|
+
import type { RenderContext } from "../context";
|
|
31
|
+
import type { Renderer } from "../renderer";
|
|
32
|
+
|
|
33
|
+
const CONTEXT: RenderContext = {
|
|
34
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const CONTENT: NotificationContent = {
|
|
38
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
39
|
+
sections: [
|
|
40
|
+
{
|
|
41
|
+
elements: [
|
|
42
|
+
{ type: "heroImage", src: "https://example.test/h.png", alt: "Acme" },
|
|
43
|
+
{ type: "greeting", recipientName: "Ada" },
|
|
44
|
+
{ type: "body", text: "Acme uses Company Semantics." },
|
|
45
|
+
{ type: "callToAction", label: "JOIN", href: "https://example.test/j" },
|
|
46
|
+
{ type: "signature" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A text-only channel: no images. Returns a plain string. */
|
|
53
|
+
const TEXT_RENDERER: Renderer<string> = {
|
|
54
|
+
id: "text",
|
|
55
|
+
supports: (elementType) => elementType !== "heroImage",
|
|
56
|
+
render: (content, context) =>
|
|
57
|
+
content.sections
|
|
58
|
+
.flatMap((section) => section.elements)
|
|
59
|
+
.filter((element) => TEXT_RENDERER.supports(element.type))
|
|
60
|
+
.map((element) => {
|
|
61
|
+
switch (element.type) {
|
|
62
|
+
case "greeting":
|
|
63
|
+
return `Hi ${element.recipientName ?? "there"},`;
|
|
64
|
+
case "body":
|
|
65
|
+
return element.text;
|
|
66
|
+
case "callToAction":
|
|
67
|
+
return element.href
|
|
68
|
+
? `>> ${element.label} << ${element.href}`
|
|
69
|
+
: `>> ${element.label} <<`;
|
|
70
|
+
case "signature":
|
|
71
|
+
return `(c) ${context.brand.copyrightYear} - ${element.signer ?? context.brand.name}`;
|
|
72
|
+
default:
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join("\n"),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A rich channel: images fine, no conversations. Returns a record, not a string. */
|
|
81
|
+
const CARD_RENDERER: Renderer<{ title: string; blocks: string[] }> = {
|
|
82
|
+
id: "card",
|
|
83
|
+
supports: (elementType) => elementType !== "chatUnit",
|
|
84
|
+
render: (content) => ({
|
|
85
|
+
title: content.metadata.title,
|
|
86
|
+
blocks: content.sections
|
|
87
|
+
.flatMap((section) => section.elements)
|
|
88
|
+
.filter((element) => CARD_RENDERER.supports(element.type))
|
|
89
|
+
.map((element) => element.type),
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const ALL_TYPES: NotificationElementType[] = [
|
|
94
|
+
"greeting",
|
|
95
|
+
"body",
|
|
96
|
+
"keyValueTable",
|
|
97
|
+
"callToAction",
|
|
98
|
+
"list",
|
|
99
|
+
"divider",
|
|
100
|
+
"warning",
|
|
101
|
+
"metadata",
|
|
102
|
+
"notice",
|
|
103
|
+
"chatUnit",
|
|
104
|
+
"signature",
|
|
105
|
+
"heroImage",
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
describe("Renderer", () => {
|
|
109
|
+
it("declares a stable channel id", () => {
|
|
110
|
+
expect(TEXT_RENDERER.id).toBe("text");
|
|
111
|
+
expect(CARD_RENDERER.id).toBe("card");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("answers supports() for every element type — the predicate is total", () => {
|
|
115
|
+
for (const type of ALL_TYPES) {
|
|
116
|
+
expect(typeof TEXT_RENDERER.supports(type), type).toBe("boolean");
|
|
117
|
+
expect(typeof CARD_RENDERER.supports(type), type).toBe("boolean");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("omits unsupported elements rather than throwing or approximating them", () => {
|
|
122
|
+
// heroImage is in CONTENT and unsupported by the text channel. The contract
|
|
123
|
+
// is that render() drops it silently — supports() and render() must agree.
|
|
124
|
+
expect(TEXT_RENDERER.supports("heroImage")).toBe(false);
|
|
125
|
+
expect(TEXT_RENDERER.render(CONTENT, CONTEXT)).toBe(
|
|
126
|
+
[
|
|
127
|
+
"Hi Ada,",
|
|
128
|
+
"Acme uses Company Semantics.",
|
|
129
|
+
">> JOIN << https://example.test/j",
|
|
130
|
+
"(c) 2026 - Company Semantics",
|
|
131
|
+
].join("\n"),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("renders one content through channels of differing capability", () => {
|
|
136
|
+
// Same CONTENT object, no channel tags on it: each renderer keeps what it
|
|
137
|
+
// can depict. The card channel takes the hero image the text channel drops.
|
|
138
|
+
expect(CARD_RENDERER.render(CONTENT, CONTEXT).blocks).toContain(
|
|
139
|
+
"heroImage",
|
|
140
|
+
);
|
|
141
|
+
expect(CARD_RENDERER.render(CONTENT, CONTEXT).blocks).toEqual([
|
|
142
|
+
"heroImage",
|
|
143
|
+
"greeting",
|
|
144
|
+
"body",
|
|
145
|
+
"callToAction",
|
|
146
|
+
"signature",
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("lets each channel return its natural output type", () => {
|
|
151
|
+
// No common Out shape: a string on one channel, a record on the other.
|
|
152
|
+
expect(typeof TEXT_RENDERER.render(CONTENT, CONTEXT)).toBe("string");
|
|
153
|
+
expect(CARD_RENDERER.render(CONTENT, CONTEXT)).toEqual({
|
|
154
|
+
title: "Join Acme",
|
|
155
|
+
blocks: ["heroImage", "greeting", "body", "callToAction", "signature"],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("reads the brand and year from context, never from a clock", () => {
|
|
160
|
+
// The whole reason RenderContext exists: no fake timer in this file, and
|
|
161
|
+
// swapping the context is enough to move the copyright line.
|
|
162
|
+
const pinned: RenderContext = {
|
|
163
|
+
brand: { name: "Acme", copyrightYear: 1999 },
|
|
164
|
+
};
|
|
165
|
+
expect(TEXT_RENDERER.render(CONTENT, pinned)).toContain("(c) 1999 - Acme");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("is pure — same inputs, same bytes", () => {
|
|
169
|
+
expect(TEXT_RENDERER.render(CONTENT, CONTEXT)).toBe(
|
|
170
|
+
TEXT_RENDERER.render(CONTENT, CONTEXT),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("lets an element's signer override the context brand", () => {
|
|
175
|
+
const onBehalf: NotificationContent = {
|
|
176
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
177
|
+
sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
|
|
178
|
+
};
|
|
179
|
+
expect(TEXT_RENDERER.render(onBehalf, CONTEXT)).toBe("(c) 2026 - Grace");
|
|
180
|
+
});
|
|
181
|
+
});
|