@pplethai/cli 0.4.1 → 0.5.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/dist/bin.js +1 -1
- package/package.json +1 -1
- package/skills/pple-platform/SKILL.md +10 -7
- package/skills/pple-platform/evals/evals.json +2 -2
- package/skills/pple-platform/references/design-system.md +7 -2
- package/skills/pple-platform/references/mini-app.md +67 -31
- package/skills/pple-platform/references/project-scaffold.md +1 -1
- package/templates/golden-path/CLAUDE.md +7 -3
- package/templates/golden-path/README.md +5 -2
- package/templates/golden-path/package.json +1 -0
- package/templates/golden-path/shared/api.ts +84 -3
- package/templates/golden-path/src/index.ts +14 -3
- package/templates/golden-path/src/notify.ts +152 -24
- package/templates/golden-path/test/harness.ts +2 -2
- package/templates/golden-path/test/smoke.test.ts +242 -16
- package/templates/golden-path/web/App.tsx +264 -54
- package/templates/golden-path/web/app.css +13 -22
- package/templates/golden-path/web/main.tsx +1 -1
- package/templates/golden-path/web/pple.ts +13 -2
|
@@ -1,36 +1,82 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Push notifications: Broadcast and Direct.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// ids — plus an optional path-only self-link. The platform resolves the
|
|
5
|
-
// recipients server-side (this app's App Users ∩ its current publication-tier
|
|
6
|
-
// audience), joins `linkPath` to this app's known entry, and enforces the
|
|
7
|
-
// per-app rate limit. This is the whole privacy model: an app can reach the
|
|
8
|
-
// people who use it, and no one else; and it can deep-link into itself, not
|
|
9
|
-
// into another team's mini app.
|
|
3
|
+
// Every send states its audience, and the two kinds follow from that:
|
|
10
4
|
//
|
|
11
|
-
//
|
|
5
|
+
// • Broadcast (การแจ้งเตือนทุกคน) — `{ kind: "all" }`, to everyone this app
|
|
6
|
+
// can reach: its App Users ∩ its current publication-tier audience.
|
|
7
|
+
// • Direct (การแจ้งเตือนรายบุคคล) — `{ kind: "direct", recipients }`, to the
|
|
8
|
+
// people this app names, by PPLE ID `sub` or mobile number.
|
|
9
|
+
//
|
|
10
|
+
// Naming narrows the audience and never widens it — the rule, and what
|
|
11
|
+
// `not_reachable` may be read as, are in shared/api.ts. The privacy model in
|
|
12
|
+
// one line: an app can reach the people who use it, say which of them it
|
|
13
|
+
// means, and reach no one else; and it can deep-link into itself, not into
|
|
14
|
+
// another team's mini app.
|
|
15
|
+
//
|
|
16
|
+
// See docs/integration/direct-notifications.md for the contract and
|
|
17
|
+
// docs/adr/0017 for why naming recipients is safe but per-recipient *reasons*
|
|
18
|
+
// are not.
|
|
12
19
|
//
|
|
13
20
|
// Wiring: the platform provisions NOTIFY_API_BASE and this app's own
|
|
14
21
|
// NotificationApiKey as NOTIFY_API_KEY, so `POST {NOTIFY_API_BASE}/notifications`
|
|
15
|
-
// lands on PPLE Today's
|
|
16
|
-
//
|
|
17
|
-
//
|
|
22
|
+
// lands on PPLE Today's send route. The key is the whole address — PPLE Today
|
|
23
|
+
// resolves it to this app and derives the reachable set from that, so the
|
|
24
|
+
// request carries no app id.
|
|
18
25
|
//
|
|
19
26
|
// The wire shape below is PPLE Today's, not this app's: your code passes the
|
|
20
|
-
// friendlier `{ title, body, linkPath? }` and this is where it
|
|
21
|
-
// `{ content: { header, message }, linkPath? }`. Keeping that
|
|
22
|
-
// one place means the rest of the app never has to know the
|
|
23
|
-
// vocabulary.
|
|
24
|
-
import type { Notification } from "../shared/api";
|
|
27
|
+
// friendlier `{ audience, title, body, linkPath? }` and this is where it
|
|
28
|
+
// becomes `{ audience, content: { header, message }, linkPath? }`. Keeping that
|
|
29
|
+
// translation in one place means the rest of the app never has to know the
|
|
30
|
+
// platform's vocabulary.
|
|
31
|
+
import type { Notification, NotificationDelivery, NotificationRecipient } from "../shared/api";
|
|
25
32
|
|
|
26
33
|
interface NotifyEnv {
|
|
27
34
|
NOTIFY_API_BASE: string;
|
|
28
35
|
NOTIFY_API_KEY?: string;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Every refusal this app can get back, named in terms a Builder can act on.
|
|
40
|
+
*
|
|
41
|
+
* - `notifications_not_configured` — no key bound (e.g. local dev).
|
|
42
|
+
* - `invalid_request` — the send itself was malformed: no audience, an
|
|
43
|
+
* unknown `kind`, a `direct` send with no `recipients` array.
|
|
44
|
+
* - `invalid_recipients` — the list was: empty, over the 200 cap, or an entry
|
|
45
|
+
* naming neither or both of `sub`/`phone`.
|
|
46
|
+
* - `idempotency_key_conflict` — that key was already used with a list of a
|
|
47
|
+
* different length, so its stored outcome cannot be replayed onto this one.
|
|
48
|
+
* - `quota_exceeded` — **nothing was delivered.** Quota is checked up front
|
|
49
|
+
* against the named count, so the call is rejected whole rather than
|
|
50
|
+
* half-sent.
|
|
51
|
+
* - `not_permitted` — this key may not use this route.
|
|
52
|
+
* - `send_failed` — anything else.
|
|
53
|
+
*/
|
|
54
|
+
export type NotifyError =
|
|
55
|
+
| "notifications_not_configured"
|
|
56
|
+
| "invalid_request"
|
|
57
|
+
| "invalid_recipients"
|
|
58
|
+
| "idempotency_key_conflict"
|
|
59
|
+
| "quota_exceeded"
|
|
60
|
+
| "not_permitted"
|
|
61
|
+
| "send_failed";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What the platform reports alongside a `quota_exceeded` refusal, so an app
|
|
65
|
+
* can back off rather than guess. Denominated in **deliveries**, not calls: a
|
|
66
|
+
* Direct send debits one unit per name it addressed (delivered or not), and a
|
|
67
|
+
* Broadcast debits its audience size.
|
|
68
|
+
*/
|
|
69
|
+
export interface NotifyQuota {
|
|
70
|
+
dailyQuota?: number;
|
|
71
|
+
remaining?: number;
|
|
72
|
+
/** When the budget resets (Asia/Bangkok midnight). */
|
|
73
|
+
resetAt?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
31
76
|
export type NotifyResult =
|
|
32
|
-
| { ok: true }
|
|
33
|
-
| { ok:
|
|
77
|
+
| { ok: true; audience: "all" }
|
|
78
|
+
| { ok: true; audience: "direct"; results: NotificationDelivery[] }
|
|
79
|
+
| { ok: false; status: number; error: NotifyError; quota?: NotifyQuota };
|
|
34
80
|
|
|
35
81
|
export async function sendNotification(
|
|
36
82
|
env: NotifyEnv,
|
|
@@ -48,17 +94,99 @@ export async function sendNotification(
|
|
|
48
94
|
authorization: `Bearer ${env.NOTIFY_API_KEY}`,
|
|
49
95
|
"content-type": "application/json",
|
|
50
96
|
},
|
|
51
|
-
// Content (+ optional self-link). The audience is resolved by the platform.
|
|
52
97
|
body: JSON.stringify({
|
|
98
|
+
// Stated on every send, including a broadcast. There is no shape of this
|
|
99
|
+
// request that leaves the audience to be guessed.
|
|
100
|
+
audience: input.audience,
|
|
53
101
|
content: { header: input.title, message: input.body },
|
|
54
102
|
...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
|
|
103
|
+
...(input.idempotencyKey !== undefined
|
|
104
|
+
? { idempotencyKey: input.idempotencyKey }
|
|
105
|
+
: {}),
|
|
55
106
|
}),
|
|
56
107
|
});
|
|
57
108
|
|
|
58
|
-
if (res.ok)
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
const error = errorFor(res.status);
|
|
111
|
+
const quota = error === "quota_exceeded" ? quotaOf(await readJson(res)) : undefined;
|
|
112
|
+
return { ok: false, status: res.status, error, ...(quota ? { quota } : {}) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input.audience.kind === "all") return { ok: true, audience: "all" };
|
|
59
116
|
return {
|
|
60
|
-
ok:
|
|
61
|
-
|
|
62
|
-
|
|
117
|
+
ok: true,
|
|
118
|
+
audience: "direct",
|
|
119
|
+
results: zipResults(input.audience.recipients, await readJson(res)),
|
|
63
120
|
};
|
|
64
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The platform splits its refusals: schema-level ones answer 422 (its whole
|
|
125
|
+
* API validates uniformly, and this route does not get to be the exception),
|
|
126
|
+
* recipient-level ones answer 400. Both are refusals — none is a broadcast,
|
|
127
|
+
* none is a silent no-op — but a client that branches on 400 alone is wrong.
|
|
128
|
+
* The status table lives in docs/integration/direct-notifications.md.
|
|
129
|
+
*/
|
|
130
|
+
function errorFor(status: number): NotifyError {
|
|
131
|
+
switch (status) {
|
|
132
|
+
case 422:
|
|
133
|
+
return "invalid_request";
|
|
134
|
+
case 400:
|
|
135
|
+
return "invalid_recipients";
|
|
136
|
+
case 403:
|
|
137
|
+
return "not_permitted";
|
|
138
|
+
case 409:
|
|
139
|
+
return "idempotency_key_conflict";
|
|
140
|
+
case 429:
|
|
141
|
+
return "quota_exceeded";
|
|
142
|
+
default:
|
|
143
|
+
return "send_failed";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readJson(res: Response): Promise<unknown> {
|
|
148
|
+
return res.json().catch(() => null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The three figures the contract says a 429 carries, read from the top level
|
|
153
|
+
* of the refusal body. If the platform ever nests them under an envelope, this
|
|
154
|
+
* is the one place to adjust — the refusal itself is reported either way.
|
|
155
|
+
*/
|
|
156
|
+
function quotaOf(body: unknown): NotifyQuota | undefined {
|
|
157
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
158
|
+
const { dailyQuota, remaining, resetAt } = body as Record<string, unknown>;
|
|
159
|
+
const quota: NotifyQuota = {
|
|
160
|
+
...(typeof dailyQuota === "number" ? { dailyQuota } : {}),
|
|
161
|
+
...(typeof remaining === "number" ? { remaining } : {}),
|
|
162
|
+
...(typeof resetAt === "string" ? { resetAt } : {}),
|
|
163
|
+
};
|
|
164
|
+
return Object.keys(quota).length > 0 ? quota : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Pair each recipient this app named with what the platform said about it.
|
|
169
|
+
* The results come back in the order they were named, one per entry, so this
|
|
170
|
+
* pairs them by position and hands back the caller's *own* entry — ready to
|
|
171
|
+
* match onto the list the app holds.
|
|
172
|
+
*
|
|
173
|
+
* Two rules, both in the direction of not lying: a list of a different length
|
|
174
|
+
* is not the answer to this send, so none of it is trusted; and anything not
|
|
175
|
+
* affirmatively reported `delivered` is reported `not_reachable`. See
|
|
176
|
+
* shared/api.ts for what that status may and may not be read as.
|
|
177
|
+
*/
|
|
178
|
+
function zipResults(
|
|
179
|
+
recipients: readonly NotificationRecipient[],
|
|
180
|
+
body: unknown,
|
|
181
|
+
): NotificationDelivery[] {
|
|
182
|
+
const reported = (body as { results?: unknown } | null)?.results;
|
|
183
|
+
const results =
|
|
184
|
+
Array.isArray(reported) && reported.length === recipients.length ? reported : [];
|
|
185
|
+
return recipients.map((recipient, i) => ({
|
|
186
|
+
recipient,
|
|
187
|
+
status:
|
|
188
|
+
(results[i] as { status?: unknown } | undefined)?.status === "delivered"
|
|
189
|
+
? "delivered"
|
|
190
|
+
: "not_reachable",
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
@@ -17,8 +17,8 @@ export interface Fakes {
|
|
|
17
17
|
/**
|
|
18
18
|
* A copy of the Worker wired to fake PPLE ID / notification services (via an
|
|
19
19
|
* injected fetch) and the real local D1 + R2 from the test runtime. Records
|
|
20
|
-
* the requests sent to the notification endpoint so tests can assert the
|
|
21
|
-
*
|
|
20
|
+
* the requests sent to the notification endpoint so tests can assert the wire
|
|
21
|
+
* shape — above all that every send states its audience.
|
|
22
22
|
*/
|
|
23
23
|
export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {}) {
|
|
24
24
|
const calls = { notify: [] as Request[] };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import type { Note, UploadedFile } from "../shared/api";
|
|
2
|
+
import type { Note, Notification, UploadedFile } from "../shared/api";
|
|
3
3
|
import { makeHarness } from "./harness";
|
|
4
4
|
|
|
5
5
|
describe("health", () => {
|
|
@@ -174,32 +174,123 @@ describe("R2 upload (files)", () => {
|
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
// Shaped like what the platform actually binds, so these tests exercise the
|
|
178
|
+
// real `${base}/notifications` the Worker will call in production.
|
|
179
|
+
const NOTIFY_ENV = {
|
|
180
|
+
NOTIFY_API_BASE: "https://today.test/external",
|
|
181
|
+
NOTIFY_API_KEY: "app-key",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** A platform that accepts everything and reports every named entry delivered. */
|
|
185
|
+
const accepts = (body: unknown = null) => ({
|
|
186
|
+
notify: () => (body === null ? new Response(null, { status: 202 }) : Response.json(body)),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("notifications: the audience is required", () => {
|
|
190
|
+
it("is a compile error to omit it, not a runtime one", () => {
|
|
191
|
+
// @ts-expect-error — `audience` is required. The dangerous bug in this
|
|
192
|
+
// feature is omission: a dropped field turning a message meant for one
|
|
193
|
+
// person into a message to every App User. The discriminated union kills
|
|
194
|
+
// it at the compiler, which is where it is cheapest.
|
|
195
|
+
const missingAudience: Notification = { title: "Hi", body: "there" };
|
|
196
|
+
expect(missingAudience).toBeTruthy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("refuses a send with no audience before it reaches the platform", async () => {
|
|
200
|
+
const h = makeHarness(accepts(), NOTIFY_ENV);
|
|
201
|
+
const res = await h.request("/api/notify", {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { "content-type": "application/json" },
|
|
204
|
+
body: JSON.stringify({ title: "Hi", body: "there" }),
|
|
205
|
+
});
|
|
206
|
+
expect(res.status).toBe(400);
|
|
207
|
+
expect(await res.json()).toMatchObject({ error: "invalid_body" });
|
|
208
|
+
// Never a broadcast, never a silent no-op.
|
|
209
|
+
expect(h.calls.notify).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("refuses an empty recipient list before it reaches the platform", async () => {
|
|
213
|
+
const h = makeHarness(accepts(), NOTIFY_ENV);
|
|
214
|
+
const res = await h.request("/api/notify", {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "content-type": "application/json" },
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
audience: { kind: "direct", recipients: [] },
|
|
219
|
+
title: "Hi",
|
|
220
|
+
body: "there",
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
expect(res.status).toBe(400);
|
|
224
|
+
expect(h.calls.notify).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("refuses more than 200 recipients rather than truncating them", async () => {
|
|
228
|
+
const h = makeHarness(accepts(), NOTIFY_ENV);
|
|
229
|
+
const res = await h.request("/api/notify", {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { "content-type": "application/json" },
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
audience: {
|
|
234
|
+
kind: "direct",
|
|
235
|
+
recipients: Array.from({ length: 201 }, (_, i) => ({ sub: `u-${i}` })),
|
|
236
|
+
},
|
|
237
|
+
title: "Hi",
|
|
238
|
+
body: "there",
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
expect(res.status).toBe(400);
|
|
242
|
+
expect(h.calls.notify).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("refuses an entry naming both a sub and a phone", async () => {
|
|
246
|
+
const h = makeHarness(accepts(), NOTIFY_ENV);
|
|
247
|
+
const res = await h.request("/api/notify", {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
audience: {
|
|
252
|
+
kind: "direct",
|
|
253
|
+
recipients: [{ sub: "u-1", phone: "0812345678" }],
|
|
254
|
+
},
|
|
255
|
+
title: "Hi",
|
|
256
|
+
body: "there",
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
expect(res.status).toBe(400);
|
|
260
|
+
expect(h.calls.notify).toHaveLength(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("Broadcast Notification (การแจ้งเตือนทุกคน)", () => {
|
|
178
265
|
it("reports not-configured when no key is set", async () => {
|
|
179
266
|
const h = makeHarness();
|
|
180
267
|
const res = await h.request("/api/notify", {
|
|
181
268
|
method: "POST",
|
|
182
269
|
headers: { "content-type": "application/json" },
|
|
183
|
-
body: JSON.stringify({
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
audience: { kind: "all" },
|
|
272
|
+
title: "Hi",
|
|
273
|
+
body: "there",
|
|
274
|
+
}),
|
|
184
275
|
});
|
|
185
276
|
expect(res.status).toBe(503);
|
|
186
277
|
expect(await res.json()).toMatchObject({ error: "notifications_not_configured" });
|
|
187
278
|
});
|
|
188
279
|
|
|
189
|
-
it("
|
|
190
|
-
const h = makeHarness(
|
|
191
|
-
{ notify: () => new Response(null, { status: 202 }) },
|
|
192
|
-
// Shaped like what the platform actually binds, so the test exercises
|
|
193
|
-
// the real `${base}/notifications` the Worker will call in production.
|
|
194
|
-
{ NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
|
|
195
|
-
);
|
|
280
|
+
it("states its audience explicitly and names no one", async () => {
|
|
281
|
+
const h = makeHarness(accepts(), NOTIFY_ENV);
|
|
196
282
|
|
|
197
283
|
const res = await h.request("/api/notify", {
|
|
198
284
|
method: "POST",
|
|
199
285
|
headers: { "content-type": "application/json" },
|
|
200
|
-
body: JSON.stringify({
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
audience: { kind: "all" },
|
|
288
|
+
title: "Event tonight",
|
|
289
|
+
body: "Doors at 7",
|
|
290
|
+
}),
|
|
201
291
|
});
|
|
202
292
|
expect(res.status).toBe(200);
|
|
293
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
203
294
|
|
|
204
295
|
expect(h.calls.notify).toHaveLength(1);
|
|
205
296
|
const sent = h.calls.notify[0]!;
|
|
@@ -207,21 +298,20 @@ describe("audience-bound notifications", () => {
|
|
|
207
298
|
// PPLE Today's wire shape, which src/notify.ts translates into.
|
|
208
299
|
const payload = await sent.json<Record<string, unknown>>();
|
|
209
300
|
expect(payload).toEqual({
|
|
301
|
+
audience: { kind: "all" },
|
|
210
302
|
content: { header: "Event tonight", message: "Doors at 7" },
|
|
211
303
|
});
|
|
212
|
-
expect(JSON.stringify(payload)).not.toMatch(/phone|recipient
|
|
304
|
+
expect(JSON.stringify(payload)).not.toMatch(/phone|recipient/i);
|
|
213
305
|
});
|
|
214
306
|
|
|
215
307
|
it("forwards optional linkPath on the platform wire shape", async () => {
|
|
216
|
-
const h = makeHarness(
|
|
217
|
-
{ notify: () => new Response(null, { status: 202 }) },
|
|
218
|
-
{ NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
|
|
219
|
-
);
|
|
308
|
+
const h = makeHarness(accepts(), NOTIFY_ENV);
|
|
220
309
|
|
|
221
310
|
const res = await h.request("/api/notify", {
|
|
222
311
|
method: "POST",
|
|
223
312
|
headers: { "content-type": "application/json" },
|
|
224
313
|
body: JSON.stringify({
|
|
314
|
+
audience: { kind: "all" },
|
|
225
315
|
title: "Check-in open",
|
|
226
316
|
body: "Tap to open the form",
|
|
227
317
|
linkPath: "/check-in",
|
|
@@ -231,8 +321,144 @@ describe("audience-bound notifications", () => {
|
|
|
231
321
|
|
|
232
322
|
const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
|
|
233
323
|
expect(payload).toEqual({
|
|
324
|
+
audience: { kind: "all" },
|
|
234
325
|
content: { header: "Check-in open", message: "Tap to open the form" },
|
|
235
326
|
linkPath: "/check-in",
|
|
236
327
|
});
|
|
237
328
|
});
|
|
238
329
|
});
|
|
330
|
+
|
|
331
|
+
describe("Direct Notification (การแจ้งเตือนรายบุคคล)", () => {
|
|
332
|
+
const recipients = [{ sub: "u-42" }, { phone: "+66812345678" }];
|
|
333
|
+
|
|
334
|
+
const sendDirect = (h: ReturnType<typeof makeHarness>, extra: object = {}) =>
|
|
335
|
+
h.request("/api/notify", {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: { "content-type": "application/json" },
|
|
338
|
+
body: JSON.stringify({
|
|
339
|
+
audience: { kind: "direct", recipients },
|
|
340
|
+
title: "Reimbursement approved",
|
|
341
|
+
body: "Your claim was approved",
|
|
342
|
+
...extra,
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("names recipients by sub or phone and reports each one's outcome", async () => {
|
|
347
|
+
const h = makeHarness(
|
|
348
|
+
accepts({
|
|
349
|
+
results: [
|
|
350
|
+
{ recipient: { sub: "u-42" }, status: "delivered" },
|
|
351
|
+
{ recipient: { phone: "+66812345678" }, status: "not_reachable" },
|
|
352
|
+
],
|
|
353
|
+
}),
|
|
354
|
+
NOTIFY_ENV,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const res = await sendDirect(h);
|
|
358
|
+
expect(res.status).toBe(200);
|
|
359
|
+
// Every named entry comes back, echoed as the caller named it, so a
|
|
360
|
+
// Builder can match a result straight onto the list it holds.
|
|
361
|
+
expect(await res.json()).toEqual({
|
|
362
|
+
ok: true,
|
|
363
|
+
results: [
|
|
364
|
+
{ recipient: { sub: "u-42" }, status: "delivered" },
|
|
365
|
+
{ recipient: { phone: "+66812345678" }, status: "not_reachable" },
|
|
366
|
+
],
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
|
|
370
|
+
expect(payload).toEqual({
|
|
371
|
+
audience: { kind: "direct", recipients },
|
|
372
|
+
content: {
|
|
373
|
+
header: "Reimbursement approved",
|
|
374
|
+
message: "Your claim was approved",
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("forwards an idempotencyKey so a retry after a timeout is safe", async () => {
|
|
380
|
+
const h = makeHarness(accepts({ results: [] }), NOTIFY_ENV);
|
|
381
|
+
await sendDirect(h, { idempotencyKey: "claim-123" });
|
|
382
|
+
const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
|
|
383
|
+
expect(payload).toMatchObject({ idempotencyKey: "claim-123" });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const unconfirmed = {
|
|
387
|
+
ok: true,
|
|
388
|
+
results: [
|
|
389
|
+
{ recipient: { sub: "u-42" }, status: "not_reachable" },
|
|
390
|
+
{ recipient: { phone: "+66812345678" }, status: "not_reachable" },
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
it("never claims a delivery the platform did not confirm", async () => {
|
|
395
|
+
// A body this helper cannot read is not evidence of delivery.
|
|
396
|
+
const h = makeHarness(accepts({ unexpected: true }), NOTIFY_ENV);
|
|
397
|
+
expect(await (await sendDirect(h)).json()).toEqual(unconfirmed);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("trusts none of a result list that is not one-per-named-entry", async () => {
|
|
401
|
+
// Results are paired by position, so a list of a different length is not
|
|
402
|
+
// the answer to this send — reading it would attribute someone's delivery
|
|
403
|
+
// to the wrong person.
|
|
404
|
+
const h = makeHarness(
|
|
405
|
+
accepts({ results: [{ recipient: { sub: "u-42" }, status: "delivered" }] }),
|
|
406
|
+
NOTIFY_ENV,
|
|
407
|
+
);
|
|
408
|
+
expect(await (await sendDirect(h)).json()).toEqual(unconfirmed);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("notification refusals map to legible errors", () => {
|
|
413
|
+
const refuses = (status: number, body: unknown = null) => ({
|
|
414
|
+
notify: () =>
|
|
415
|
+
body === null
|
|
416
|
+
? new Response(null, { status })
|
|
417
|
+
: Response.json(body, { status }),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const send = (h: ReturnType<typeof makeHarness>) =>
|
|
421
|
+
h.request("/api/notify", {
|
|
422
|
+
method: "POST",
|
|
423
|
+
headers: { "content-type": "application/json" },
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
audience: { kind: "direct", recipients: [{ sub: "u-42" }] },
|
|
426
|
+
title: "Hi",
|
|
427
|
+
body: "there",
|
|
428
|
+
}),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// The platform splits schema-level refusals (422) from recipient-level ones
|
|
432
|
+
// (400); a client that branches on 400 alone is wrong. See the status table
|
|
433
|
+
// in docs/integration/direct-notifications.md.
|
|
434
|
+
it.each([
|
|
435
|
+
[422, "invalid_request"],
|
|
436
|
+
[400, "invalid_recipients"],
|
|
437
|
+
[403, "not_permitted"],
|
|
438
|
+
[409, "idempotency_key_conflict"],
|
|
439
|
+
[429, "quota_exceeded"],
|
|
440
|
+
[500, "send_failed"],
|
|
441
|
+
])("maps %i to %s", async (status, error) => {
|
|
442
|
+
const h = makeHarness(refuses(status), NOTIFY_ENV);
|
|
443
|
+
const res = await send(h);
|
|
444
|
+
expect(res.status).toBe(status);
|
|
445
|
+
expect(await res.json()).toMatchObject({ error });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("passes the quota figures through on a 429 so an app can back off", async () => {
|
|
449
|
+
const h = makeHarness(
|
|
450
|
+
refuses(429, {
|
|
451
|
+
// Deliveries, not calls — the budget's denomination changed with
|
|
452
|
+
// Direct Notifications, and nothing was delivered by this call.
|
|
453
|
+
dailyQuota: 1000,
|
|
454
|
+
remaining: 3,
|
|
455
|
+
resetAt: "2026-08-15T00:00:00+07:00",
|
|
456
|
+
}),
|
|
457
|
+
NOTIFY_ENV,
|
|
458
|
+
);
|
|
459
|
+
expect(await (await send(h)).json()).toMatchObject({
|
|
460
|
+
error: "quota_exceeded",
|
|
461
|
+
quota: { dailyQuota: 1000, remaining: 3, resetAt: "2026-08-15T00:00:00+07:00" },
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
});
|