@company-semantics/contracts 34.0.0 → 35.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/index.ts +17 -11
- package/src/notifications/README.md +140 -0
- package/src/notifications/__tests__/README.md +54 -0
- package/src/notifications/__tests__/__snapshots__/README.md +32 -0
- package/src/notifications/__tests__/content.test.ts +186 -0
- package/src/notifications/__tests__/context.test.ts +72 -0
- package/src/notifications/__tests__/definition.test.ts +222 -0
- package/src/notifications/__tests__/kinds.test.ts +81 -0
- package/src/notifications/__tests__/output-parity.golden.ts +363 -0
- package/src/notifications/__tests__/output-parity.test.ts +122 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
- package/src/notifications/__tests__/renderer.test.ts +181 -0
- package/src/notifications/content.ts +249 -0
- package/src/notifications/context.ts +70 -0
- package/src/notifications/definition.ts +82 -0
- package/src/notifications/index.ts +104 -0
- package/src/notifications/kinds/README.md +57 -0
- package/src/notifications/kinds/auth-otp.ts +99 -0
- package/src/notifications/kinds/chat-shared.ts +56 -0
- package/src/notifications/kinds/company-md-access-approved.ts +57 -0
- package/src/notifications/kinds/company-md-access-denied.ts +61 -0
- package/src/notifications/kinds/company-md-access-requested.ts +65 -0
- package/src/notifications/kinds/index.ts +19 -0
- package/src/notifications/kinds/org-invite.ts +62 -0
- package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
- package/src/notifications/kinds/ownership-transfer.ts +68 -0
- package/src/notifications/kinds/security-alert.ts +78 -0
- package/src/notifications/kinds/share-granted.ts +74 -0
- package/src/notifications/kinds/unit-owner-granted.ts +95 -0
- package/src/notifications/kinds.ts +68 -0
- package/src/{email/types.ts → notifications/payloads.ts} +33 -68
- package/src/notifications/registry.ts +107 -0
- package/src/notifications/render.ts +106 -0
- package/src/notifications/renderer.ts +50 -0
- package/src/notifications/renderers/README.md +50 -0
- package/src/notifications/renderers/email/README.md +74 -0
- package/src/notifications/renderers/email/__tests__/README.md +29 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
- package/src/notifications/renderers/email/constants.ts +47 -0
- package/src/notifications/renderers/email/cta.ts +63 -0
- package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
- package/src/notifications/renderers/email/index.ts +73 -0
- package/src/notifications/renderers/email/render.ts +225 -0
- package/src/notifications/renderers/email/shells.ts +60 -0
- package/src/notifications/renderers/slack/README.md +73 -0
- package/src/notifications/renderers/slack/__tests__/README.md +33 -0
- package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
- package/src/notifications/renderers/slack/index.ts +261 -0
- package/src/notifications/renderers/sms/README.md +69 -0
- package/src/notifications/renderers/sms/__tests__/README.md +31 -0
- package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
- package/src/notifications/renderers/sms/index.ts +131 -0
- package/src/notifications/text.ts +52 -0
- package/src/email/README.md +0 -51
- package/src/email/__tests__/registry.test.ts +0 -161
- package/src/email/index.ts +0 -36
- package/src/email/registry.ts +0 -155
- package/src/email/render/auth-otp.ts +0 -71
- package/src/email/render/blocks.ts +0 -281
- package/src/email/render/chat-shared.ts +0 -35
- package/src/email/render/company-md-access-approved.ts +0 -42
- package/src/email/render/company-md-access-denied.ts +0 -43
- package/src/email/render/company-md-access-requested.ts +0 -46
- package/src/email/render/constants.ts +0 -18
- package/src/email/render/index.ts +0 -58
- package/src/email/render/org-invite.ts +0 -40
- package/src/email/render/ownership-transfer-completed.ts +0 -41
- package/src/email/render/ownership-transfer.ts +0 -42
- package/src/email/render/render-email.ts +0 -194
- package/src/email/render/security-alert.ts +0 -61
- package/src/email/render/share-granted.ts +0 -52
- package/src/email/render/unit-owner-granted.ts +0 -60
- /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "no output change" proof for the multi-channel notification work
|
|
3
|
+
* (ADR-CONTRACTS-086), now closed.
|
|
4
|
+
*
|
|
5
|
+
* The golden was captured from the old email layer at 32.0.0, before any of this
|
|
6
|
+
* existed. This renders the same fixtures through the entry point that replaced
|
|
7
|
+
* it — `renderToChannel(kind, payload, emailRenderer, ctx)`, the whole
|
|
8
|
+
* `compose → Content → Renderer` pipeline — and asserts the same bytes. That
|
|
9
|
+
* comparison is the point of the golden: it is the only thing standing between a
|
|
10
|
+
* refactor of the code that renders real transactional email and a silently lost
|
|
11
|
+
* blank line. Now that `src/email/` is deleted, it is also the only record of
|
|
12
|
+
* what that layer emitted.
|
|
13
|
+
*
|
|
14
|
+
* `renderers/email/__tests__/render.test.ts` proves the RENDERER reproduces the
|
|
15
|
+
* golden from composed content. This proves the ENTRY POINT does, which is the
|
|
16
|
+
* claim a caller actually depends on.
|
|
17
|
+
*
|
|
18
|
+
* There is no fake timer here any more, and that is the improvement being locked
|
|
19
|
+
* in. The old layer read `new Date().getFullYear()` inside `signature()`, so
|
|
20
|
+
* every parity render had to freeze the clock or fail on New Year's Day for
|
|
21
|
+
* calendar reasons alone. The year is now a value in the `RenderContext`, so the
|
|
22
|
+
* golden's clock is just a number — and this file no longer has an opinion about
|
|
23
|
+
* what day it is.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
|
|
28
|
+
import { createRenderContext } from "../context";
|
|
29
|
+
import type { RenderContext } from "../context";
|
|
30
|
+
import type { NotificationKind } from "../kinds";
|
|
31
|
+
import { renderEmail, renderToChannel } from "../render";
|
|
32
|
+
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
33
|
+
import { emailRenderer } from "../renderers/email";
|
|
34
|
+
import {
|
|
35
|
+
OUTPUT_PARITY_CLOCK,
|
|
36
|
+
OUTPUT_PARITY_FIXTURES,
|
|
37
|
+
OUTPUT_PARITY_GOLDEN,
|
|
38
|
+
fixtureKey,
|
|
39
|
+
} from "./output-parity.golden";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The instant the golden was captured under, stated rather than ticked. Only the
|
|
43
|
+
* year is pinned; the brand name comes from `createRenderContext`'s own default,
|
|
44
|
+
* so the golden keeps asserting the real `COMPANY_NAME` rather than a copy of it
|
|
45
|
+
* written here.
|
|
46
|
+
*/
|
|
47
|
+
const GOLDEN_CONTEXT: RenderContext = createRenderContext({
|
|
48
|
+
brand: { copyrightYear: new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear() },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
|
|
52
|
+
|
|
53
|
+
describe("email output parity", () => {
|
|
54
|
+
it("has a fixture for every kind that can be rendered", () => {
|
|
55
|
+
// Sourced from the registry rather than the old `IMPLEMENTED_EMAIL_KINDS`:
|
|
56
|
+
// the registry is what a caller starts from now, and it is total, so "every
|
|
57
|
+
// kind the entry point accepts" and "every kind with a fixture" must agree.
|
|
58
|
+
const covered = new Set(OUTPUT_PARITY_FIXTURES.map((f) => f.kind));
|
|
59
|
+
for (const kind of KINDS) {
|
|
60
|
+
expect(covered.has(kind), `missing fixture for ${kind}`).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("golden covers exactly the fixtures — no missing or orphaned entries", () => {
|
|
65
|
+
const expected = OUTPUT_PARITY_FIXTURES.map((f) =>
|
|
66
|
+
fixtureKey(f.kind, f.name),
|
|
67
|
+
);
|
|
68
|
+
expect(new Set(expected).size, "fixture keys must be unique").toBe(
|
|
69
|
+
expected.length,
|
|
70
|
+
);
|
|
71
|
+
expect(Object.keys(OUTPUT_PARITY_GOLDEN).sort()).toEqual(expected.sort());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("golden was captured under the frozen clock", () => {
|
|
75
|
+
// Guards the context above: if the golden had been captured under a
|
|
76
|
+
// different year, every case below would fail for calendar reasons.
|
|
77
|
+
const year = new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear();
|
|
78
|
+
for (const [key, rendered] of Object.entries(OUTPUT_PARITY_GOLDEN)) {
|
|
79
|
+
expect(rendered.text, `${key} text`).toContain(`ⓒ ${year} •`);
|
|
80
|
+
expect(rendered.html, `${key} html`).toContain(`ⓒ ${year} •`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const fixture of OUTPUT_PARITY_FIXTURES) {
|
|
85
|
+
const key = fixtureKey(fixture.kind, fixture.name);
|
|
86
|
+
it(`${key} renders byte-for-byte`, () => {
|
|
87
|
+
// `payload as never`: the fixture call sites are type-checked in the
|
|
88
|
+
// golden module; this only bridges compose's per-kind payload.
|
|
89
|
+
const rendered = renderToChannel(
|
|
90
|
+
fixture.kind,
|
|
91
|
+
fixture.payload as never,
|
|
92
|
+
emailRenderer,
|
|
93
|
+
GOLDEN_CONTEXT,
|
|
94
|
+
);
|
|
95
|
+
expect(rendered).toEqual(OUTPUT_PARITY_GOLDEN[key]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("renderEmail", () => {
|
|
101
|
+
it("is renderToChannel through the email renderer, and nothing more", () => {
|
|
102
|
+
const year = new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear();
|
|
103
|
+
for (const fixture of OUTPUT_PARITY_FIXTURES) {
|
|
104
|
+
const key = fixtureKey(fixture.kind, fixture.name);
|
|
105
|
+
expect(
|
|
106
|
+
renderEmail(fixture.kind, fixture.payload as never, {
|
|
107
|
+
brand: { copyrightYear: year },
|
|
108
|
+
}),
|
|
109
|
+
key,
|
|
110
|
+
).toEqual(OUTPUT_PARITY_GOLDEN[key]);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("takes the copyright year from the caller, never from a clock", () => {
|
|
115
|
+
const rendered = renderEmail(
|
|
116
|
+
"auth.otp",
|
|
117
|
+
{ otp: "123456", expiresInMinutes: 10 },
|
|
118
|
+
{ brand: { copyrightYear: 1999 } },
|
|
119
|
+
);
|
|
120
|
+
expect(rendered.text).toContain("ⓒ 1999 •");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -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 {
|
|
32
|
+
fixtureKey,
|
|
33
|
+
OUTPUT_PARITY_FIXTURES,
|
|
34
|
+
OUTPUT_PARITY_GOLDEN,
|
|
35
|
+
} from "./output-parity.golden";
|
|
36
|
+
|
|
37
|
+
const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
|
|
38
|
+
|
|
39
|
+
/** Pinned so no wall-clock year can reach an assertion. */
|
|
40
|
+
const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A representative payload per kind, taken from the parity fixtures rather than
|
|
44
|
+
* written afresh — these definitions exist to say what those exact payloads say,
|
|
45
|
+
* so a second set of inputs would be a second thing to keep true.
|
|
46
|
+
*/
|
|
47
|
+
function payloadFor(kind: NotificationKind): unknown {
|
|
48
|
+
const fixture = OUTPUT_PARITY_FIXTURES.find((f) => f.kind === kind);
|
|
49
|
+
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
50
|
+
return fixture.payload;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("NOTIFICATION_DEFINITIONS", () => {
|
|
54
|
+
it("has an entry for every notification kind", () => {
|
|
55
|
+
expect([...KINDS].sort()).toEqual([
|
|
56
|
+
"auth.otp",
|
|
57
|
+
"chat.shared",
|
|
58
|
+
"companyMd.access_request_approved",
|
|
59
|
+
"companyMd.access_request_denied",
|
|
60
|
+
"companyMd.access_requested",
|
|
61
|
+
"org.invite",
|
|
62
|
+
"org.ownership_transfer",
|
|
63
|
+
"org.ownership_transfer_completed",
|
|
64
|
+
"org.unit_owner_granted",
|
|
65
|
+
"security.alert",
|
|
66
|
+
"share.granted",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keys its entries by their own kind", () => {
|
|
71
|
+
for (const [key, definition] of Object.entries(NOTIFICATION_DEFINITIONS)) {
|
|
72
|
+
expect(definition.kind).toBe(key);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("carries no subject — a title is content, and belongs to compose", () => {
|
|
77
|
+
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
78
|
+
expect(definition).not.toHaveProperty("subject");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("carries no rendering capability — that is the renderer's to answer", () => {
|
|
83
|
+
// `plainTextRequired` / `htmlSupported` put capability on the kind. A kind is
|
|
84
|
+
// not what can or cannot draw an image; `Renderer.supports` is.
|
|
85
|
+
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
86
|
+
expect(definition).not.toHaveProperty("plainTextRequired");
|
|
87
|
+
expect(definition).not.toHaveProperty("htmlSupported");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("names no destination for any kind", () => {
|
|
92
|
+
// Capability is asked of a renderer, never declared on a definition —
|
|
93
|
+
// otherwise every new surface is a rewrite of all eleven definitions.
|
|
94
|
+
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
95
|
+
expect(Object.keys(definition).sort()).toEqual(["compose", "kind"]);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("composes for every kind, so the registry is total in practice and not just in type", () => {
|
|
100
|
+
// The claim that makes `getNotificationKindDefinition` safe where
|
|
101
|
+
// `getEmailKindDefinition` was not. Calling all eleven is the only way to
|
|
102
|
+
// know it: a type says a `compose` exists, not that it returns content.
|
|
103
|
+
for (const kind of KINDS) {
|
|
104
|
+
const compose = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
105
|
+
p: unknown,
|
|
106
|
+
c: typeof CONTEXT,
|
|
107
|
+
) => { metadata: { kind: string; title: string } };
|
|
108
|
+
const content = compose(payloadFor(kind), CONTEXT);
|
|
109
|
+
|
|
110
|
+
expect(content.metadata.kind).toBe(kind);
|
|
111
|
+
expect(content.metadata.title).not.toBe("");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("titles every kind exactly as email subjected it at 32.0.0", () => {
|
|
116
|
+
// The subject MOVED from EMAIL_KINDS into compose — it was not rewritten.
|
|
117
|
+
// Asserted against the frozen golden's subjects, across every parity
|
|
118
|
+
// fixture, so the interpolation the old `resolveSubject`/`subjectData` did
|
|
119
|
+
// (the `{orgName}` fill, and unit_owner_granted's derived `roleWord`) is
|
|
120
|
+
// proven to survive as a template literal rather than assumed to.
|
|
121
|
+
//
|
|
122
|
+
// The oracle used to be the old `renderEmail`'s own output. The golden is
|
|
123
|
+
// what replaced it when that layer was deleted, and it is the stronger of
|
|
124
|
+
// the two: it is the bytes 32.0.0 actually shipped, not live code that could
|
|
125
|
+
// have drifted alongside this registry. This isolates the claim at COMPOSE —
|
|
126
|
+
// `./output-parity.test.ts` proves the same subjects survive the renderer.
|
|
127
|
+
for (const fixture of OUTPUT_PARITY_FIXTURES) {
|
|
128
|
+
const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
|
|
129
|
+
p: unknown,
|
|
130
|
+
c: typeof CONTEXT,
|
|
131
|
+
) => { metadata: { title: string } };
|
|
132
|
+
|
|
133
|
+
expect(compose(fixture.payload, CONTEXT).metadata.title).toBe(
|
|
134
|
+
OUTPUT_PARITY_GOLDEN[fixtureKey(fixture.kind, fixture.name)].subject,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
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,62 @@
|
|
|
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
|
+
* Read this alongside `./output-parity.test.ts`, which is the load-bearing one.
|
|
17
|
+
* A snapshot can be regenerated with a keystroke; the golden cannot. This file
|
|
18
|
+
* exists to make the markup reviewable, not to be the proof.
|
|
19
|
+
*
|
|
20
|
+
* The clock is pinned to `OUTPUT_PARITY_CLOCK`'s year rather than left to run.
|
|
21
|
+
* These snapshots used to be captured under the wall clock — with the copyright
|
|
22
|
+
* year baked into every one of them — so they were due to fail on the next New
|
|
23
|
+
* Year's Day for calendar reasons alone. The year is a value in `RenderContext`
|
|
24
|
+
* now, so it is simply stated.
|
|
5
25
|
*/
|
|
6
26
|
|
|
7
27
|
import { describe, expect, it } from "vitest";
|
|
8
28
|
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
import type { RenderContextOverrides } from "../context";
|
|
30
|
+
import type { NotificationKind } from "../kinds";
|
|
31
|
+
import type { NotificationPayloads } from "../payloads";
|
|
32
|
+
import { renderEmail } from "../render";
|
|
33
|
+
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
34
|
+
import type { RenderedEmail } from "../renderers/email";
|
|
35
|
+
import { OUTPUT_PARITY_CLOCK } from "./output-parity.golden";
|
|
36
|
+
|
|
37
|
+
/** Pinned so no wall-clock year can reach a snapshot. */
|
|
38
|
+
const CONTEXT: RenderContextOverrides = {
|
|
39
|
+
brand: { copyrightYear: new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear() },
|
|
40
|
+
};
|
|
17
41
|
|
|
18
42
|
interface Fixture {
|
|
19
|
-
kind:
|
|
43
|
+
kind: NotificationKind;
|
|
20
44
|
name: string;
|
|
21
45
|
render: () => RenderedEmail;
|
|
22
46
|
}
|
|
23
47
|
|
|
24
48
|
const fixtures: Fixture[] = [];
|
|
25
|
-
function add<K extends
|
|
49
|
+
function add<K extends NotificationKind>(
|
|
26
50
|
kind: K,
|
|
27
51
|
name: string,
|
|
28
|
-
payload:
|
|
29
|
-
options?: RenderEmailOptions,
|
|
52
|
+
payload: NotificationPayloads[K],
|
|
30
53
|
): void {
|
|
31
54
|
// `payload as never`: the add() call sites are type-checked (payload:
|
|
32
|
-
//
|
|
55
|
+
// NotificationPayloads[K]); this only bridges compose's per-kind payload.
|
|
33
56
|
fixtures.push({
|
|
34
57
|
kind,
|
|
35
58
|
name,
|
|
36
|
-
render: () => renderEmail(kind, payload as never,
|
|
59
|
+
render: () => renderEmail(kind, payload as never, CONTEXT),
|
|
37
60
|
});
|
|
38
61
|
}
|
|
39
62
|
|
|
@@ -41,17 +64,15 @@ const APP = "https://app.companysemantics.ai";
|
|
|
41
64
|
|
|
42
65
|
add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
|
|
43
66
|
add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{ includeRequestMetadata: true },
|
|
54
|
-
);
|
|
67
|
+
// No `includeRequestMetadata` any more: `compose` gates request details on
|
|
68
|
+
// whether the payload carries them, and this payload does. The unchanged
|
|
69
|
+
// snapshot is what proves the gate still opens.
|
|
70
|
+
add("auth.otp", "With request metadata", {
|
|
71
|
+
otp: "246810",
|
|
72
|
+
expiresInMinutes: 5,
|
|
73
|
+
requestIp: "203.0.113.4",
|
|
74
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
75
|
+
});
|
|
55
76
|
|
|
56
77
|
add("org.invite", "Admin", {
|
|
57
78
|
inviterName: "Alex Rivera",
|
|
@@ -204,11 +225,19 @@ add("companyMd.access_request_denied", "No reason", {
|
|
|
204
225
|
docTitle: "Engineering Handbook",
|
|
205
226
|
});
|
|
206
227
|
|
|
228
|
+
// "email render", not "notification render": the snapshot keys are
|
|
229
|
+
// `email render > {kind} · {name} 1`, and renaming the describe would orphan
|
|
230
|
+
// every one of them — regenerating the whole .snap to prove nothing changed is
|
|
231
|
+
// exactly the move ADR-CONTRACTS-086 exists to prevent.
|
|
207
232
|
describe("email render", () => {
|
|
208
|
-
it("has a fixture for every
|
|
233
|
+
it("has a fixture for every kind", () => {
|
|
234
|
+
// Sourced from the registry rather than the old `IMPLEMENTED_EMAIL_KINDS`:
|
|
235
|
+
// the registry is what a caller starts from now, and it is total.
|
|
209
236
|
const covered = new Set(fixtures.map((f) => f.kind));
|
|
210
|
-
for (const kind of
|
|
211
|
-
expect(covered.has(kind), `missing
|
|
237
|
+
for (const kind of Object.keys(NOTIFICATION_DEFINITIONS)) {
|
|
238
|
+
expect(covered.has(kind as NotificationKind), `missing ${kind}`).toBe(
|
|
239
|
+
true,
|
|
240
|
+
);
|
|
212
241
|
}
|
|
213
242
|
});
|
|
214
243
|
|
|
@@ -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
|
+
});
|