@firedrill-tools/resend 0.1.1
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/LICENSE +201 -0
- package/README.md +198 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +1687 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/contacts-outage.scenario.json +11 -0
- package/firedrill/quota-exhausted.scenario.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/resend-audience.drill.json +407 -0
- package/firedrill/resend-contacts-outage.drill.json +63 -0
- package/firedrill/resend-denied.drill.json +68 -0
- package/firedrill/resend-domains-and-keys.drill.json +307 -0
- package/firedrill/resend-email-flow.drill.json +286 -0
- package/firedrill/resend-fresh-install.drill.json +73 -0
- package/firedrill/resend-invalid-key.drill.json +803 -0
- package/firedrill/resend-large-page.drill.json +53 -0
- package/firedrill/resend-quota-exhausted.drill.json +82 -0
- package/firedrill/resend-rate-limited.drill.json +81 -0
- package/firedrill/resend-restricted-key.drill.json +441 -0
- package/firedrill/resend-tight-limits.drill.json +163 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/resend/app/assets/ATTRIBUTION.md +30 -0
- package/firedrill/tools/resend/app/assets/fonts/Inter-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/app.js +90 -0
- package/firedrill/tools/resend/app/site/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/contact-panel.js +86 -0
- package/firedrill/tools/resend/app/site/icons.js +70 -0
- package/firedrill/tools/resend/app/site/index.html +56 -0
- package/firedrill/tools/resend/app/site/list.js +41 -0
- package/firedrill/tools/resend/app/site/styles.css +67 -0
- package/firedrill/tools/resend/app/site/ui.js +259 -0
- package/firedrill/tools/resend/app/site/view-audience.js +160 -0
- package/firedrill/tools/resend/app/site/view-domains.js +106 -0
- package/firedrill/tools/resend/app/site/view-email-detail.js +126 -0
- package/firedrill/tools/resend/app/site/view-emails.js +83 -0
- package/firedrill/tools/resend/app/site/view-keys.js +91 -0
- package/firedrill/tools/resend/app/site/views.css +141 -0
- package/firedrill/tools/resend/behavior.mjs +114 -0
- package/firedrill/tools/resend/lib/check.mjs +75 -0
- package/firedrill/tools/resend/lib/core.mjs +112 -0
- package/firedrill/tools/resend/lib/json-depth.mjs +45 -0
- package/firedrill/tools/resend/lib/page.mjs +66 -0
- package/firedrill/tools/resend/lib/time.mjs +60 -0
- package/firedrill/tools/resend/lib/wire.mjs +98 -0
- package/firedrill/tools/resend/ops/contacts.mjs +199 -0
- package/firedrill/tools/resend/ops/domains.mjs +125 -0
- package/firedrill/tools/resend/ops/email-model.mjs +100 -0
- package/firedrill/tools/resend/ops/emails.mjs +139 -0
- package/firedrill/tools/resend/ops/keys-segments.mjs +97 -0
- package/firedrill/tools/resend/resend.tool.json +4804 -0
- package/firedrill/world.json +2148 -0
- package/firedrill.json +5 -0
- package/package.json +62 -0
- package/starter.json +1686 -0
- package/test/conformance.mjs +480 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Resend Tool conformance target: a scripted Tool test, not a model-driven agent. Node built-ins only.
|
|
2
|
+
// Calls the Resend-shaped REST routes (JSON bodies, Bearer token, Idempotency-Key) at FIREDRILL_HTTP_URL, plus the
|
|
3
|
+
// canonical operation endpoint for inputs no REST route can express. Each drill instruction names one flow.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
let task = "";
|
|
7
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
8
|
+
const instruction = String(JSON.parse(task).instruction ?? "");
|
|
9
|
+
const flow = /Run the ([a-z-]+) flow/.exec(instruction)?.[1];
|
|
10
|
+
const BASE = process.env.FIREDRILL_HTTP_URL;
|
|
11
|
+
const TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
12
|
+
assert.ok(BASE && TOKEN, "the HTTP binding is required");
|
|
13
|
+
|
|
14
|
+
// Seeded ids (see starter.json): descending 8-hex sequence per resource kind.
|
|
15
|
+
const NS = { emails: 1, domains: 2, apiKeys: 3, segments: 4, contacts: 5 };
|
|
16
|
+
const seed = (kind, seq) => `${(0xffffffff - seq).toString(16).padStart(8, "0")}-0000-4000-8000-${String(NS[kind]).padStart(4, "0")}${String(seq).padStart(8, "0")}`;
|
|
17
|
+
const EMAIL = (n) => seed("emails", n);
|
|
18
|
+
const DOMAIN = { legacy: seed("domains", 1), mail: seed("domains", 2), updates: seed("domains", 3), billing: seed("domains", 4) };
|
|
19
|
+
const KEY = { staging: seed("apiKeys", 1), marketing: seed("apiKeys", 2), production: seed("apiKeys", 3) };
|
|
20
|
+
const SEGMENT = { newsletter: seed("segments", 1), beta: seed("segments", 2), empty: seed("segments", 3) };
|
|
21
|
+
const CONTACT = (n) => seed("contacts", n);
|
|
22
|
+
const UNKNOWN = "0badc0de-0000-4000-8000-000000000000";
|
|
23
|
+
const LONG_KEY = "k".repeat(300);
|
|
24
|
+
const HELLO = "Northwind Labs <hello@mail.northwind.test>";
|
|
25
|
+
const NOW_MS = Date.UTC(2026, 8, 15, 14, 0, 0);
|
|
26
|
+
const inHours = (hours) => new Date(NOW_MS + hours * 3_600_000).toISOString();
|
|
27
|
+
|
|
28
|
+
/** One REST call. Asserts the status; for errors also the Resend envelope name. Returns `{ body, headers }`. */
|
|
29
|
+
async function api(method, path, { body, status = 200, name, headers = {}, raw } = {}) {
|
|
30
|
+
const init = { method, headers: { authorization: `Bearer ${TOKEN}`, ...headers } };
|
|
31
|
+
if (body !== undefined || raw !== undefined) {
|
|
32
|
+
init.headers["content-type"] = "application/json";
|
|
33
|
+
init.body = raw ?? JSON.stringify(body);
|
|
34
|
+
}
|
|
35
|
+
const response = await fetch(`${BASE}${path}`, init);
|
|
36
|
+
const text = await response.text();
|
|
37
|
+
let json;
|
|
38
|
+
try {
|
|
39
|
+
json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
40
|
+
} catch {
|
|
41
|
+
json = text;
|
|
42
|
+
}
|
|
43
|
+
assert.equal(response.status, status, `${method} ${path} → ${response.status} ${text.slice(0, 400)}`);
|
|
44
|
+
if (status >= 400) {
|
|
45
|
+
assert.equal(json?.statusCode, status, `${method} ${path} envelope statusCode: ${text.slice(0, 300)}`);
|
|
46
|
+
assert.equal(typeof json?.message, "string");
|
|
47
|
+
if (name !== undefined) assert.equal(json?.name, name, `${method} ${path} error name: ${text.slice(0, 300)}`);
|
|
48
|
+
}
|
|
49
|
+
return { body: json, headers: response.headers, bytes: Buffer.byteLength(text, "utf8") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Canonical operation call; returns the outcome. */
|
|
53
|
+
async function canonical(operation, args) {
|
|
54
|
+
const response = await fetch(`${BASE}/v1/operations/resend/${operation}`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
|
|
57
|
+
body: JSON.stringify({ arguments: args }),
|
|
58
|
+
});
|
|
59
|
+
const json = await response.json();
|
|
60
|
+
assert.ok(json.outcome, `canonical ${operation}: ${JSON.stringify(json).slice(0, 300)}`);
|
|
61
|
+
return json.outcome;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function canonicalError(operation, args, code) {
|
|
65
|
+
const outcome = await canonical(operation, args);
|
|
66
|
+
assert.equal(outcome.status, "tool_error", `${operation} expected ${code}: ${JSON.stringify(outcome).slice(0, 300)}`);
|
|
67
|
+
assert.equal(outcome.error.code, `tool.${code}`);
|
|
68
|
+
return outcome;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const send = (overrides = {}) => ({ from: HELLO, to: ["ada.okafor@example.com"], subject: "Conformance probe", text: "Hello from the conformance flow.", ...overrides });
|
|
72
|
+
const enc = encodeURIComponent;
|
|
73
|
+
|
|
74
|
+
/** One valid-shaped request per operation (manifest order), used by the key flows. */
|
|
75
|
+
const CALLS = [
|
|
76
|
+
["emails.send", "POST", "/emails", send()],
|
|
77
|
+
["emails.send_batch", "POST", "/emails/batch", [send()]],
|
|
78
|
+
["emails.list", "GET", "/emails"],
|
|
79
|
+
["emails.get", "GET", `/emails/${EMAIL(1)}`],
|
|
80
|
+
["emails.update", "PATCH", `/emails/${EMAIL(21)}`, { scheduled_at: inHours(30) }],
|
|
81
|
+
["emails.cancel", "POST", `/emails/${EMAIL(21)}/cancel`],
|
|
82
|
+
["domains.create", "POST", "/domains", { name: "probe.northwind.test" }],
|
|
83
|
+
["domains.list", "GET", "/domains"],
|
|
84
|
+
["domains.get", "GET", `/domains/${DOMAIN.mail}`],
|
|
85
|
+
["domains.verify", "POST", `/domains/${DOMAIN.billing}/verify`],
|
|
86
|
+
["domains.remove", "DELETE", `/domains/${DOMAIN.billing}`],
|
|
87
|
+
["api_keys.create", "POST", "/api-keys", { name: "Probe" }],
|
|
88
|
+
["api_keys.list", "GET", "/api-keys"],
|
|
89
|
+
["api_keys.remove", "DELETE", `/api-keys/${KEY.staging}`],
|
|
90
|
+
["segments.create", "POST", "/segments", { name: "Probe" }],
|
|
91
|
+
["segments.list", "GET", "/segments"],
|
|
92
|
+
["segments.remove", "DELETE", `/segments/${SEGMENT.empty}`],
|
|
93
|
+
["contacts.create", "POST", "/contacts", { email: "probe@example.com" }],
|
|
94
|
+
["contacts.list", "GET", "/contacts"],
|
|
95
|
+
["contacts.get", "GET", `/contacts/${CONTACT(1)}`],
|
|
96
|
+
["contacts.update", "PATCH", `/contacts/${CONTACT(1)}`, { first_name: "Probe" }],
|
|
97
|
+
["contacts.remove", "DELETE", `/contacts/${CONTACT(9)}`],
|
|
98
|
+
["contacts.add_segment", "POST", `/contacts/${CONTACT(9)}/segments/${SEGMENT.empty}`],
|
|
99
|
+
["contacts.remove_segment", "DELETE", `/contacts/${CONTACT(1)}/segments/${SEGMENT.newsletter}`],
|
|
100
|
+
["contacts.list_segments", "GET", `/contacts/${CONTACT(1)}/segments`],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
async function emailFlow() {
|
|
104
|
+
const a = (await api("POST", "/emails", { body: send(), headers: { "idempotency-key": "email-flow-a" } })).body;
|
|
105
|
+
const replay = (await api("POST", "/emails", { body: send(), headers: { "idempotency-key": "email-flow-a" } })).body;
|
|
106
|
+
assert.equal(replay.id, a.id, "an Idempotency-Key replay returns the same email");
|
|
107
|
+
await api("POST", "/emails", { body: send({ subject: "Different" }), headers: { "idempotency-key": "email-flow-a" }, status: 400, name: "validation_error" });
|
|
108
|
+
const got = (await api("GET", `/emails/${a.id}`)).body;
|
|
109
|
+
assert.equal(got.object, "email");
|
|
110
|
+
assert.equal(got.last_event, "delivered");
|
|
111
|
+
assert.equal(got.created_at, "2026-09-15 14:00:00.000000+00");
|
|
112
|
+
assert.deepEqual(got.to, ["ada.okafor@example.com"]);
|
|
113
|
+
assert.equal(got.html, null);
|
|
114
|
+
assert.deepEqual([got.cc, got.bcc, got.reply_to, got.tags], [[], [], [], []]);
|
|
115
|
+
|
|
116
|
+
const b = (await api("POST", "/emails", { body: send({ to: "former@nowhere.invalid", html: "<p>x</p>" }) })).body;
|
|
117
|
+
assert.equal((await api("GET", `/emails/${b.id}`)).body.last_event, "bounced");
|
|
118
|
+
|
|
119
|
+
const c = (await api("POST", "/emails", { body: send({ scheduled_at: inHours(1), tags: [{ name: "flow", value: "email_flow" }] }) })).body;
|
|
120
|
+
let scheduled = (await api("GET", `/emails/${c.id}`)).body;
|
|
121
|
+
assert.equal(scheduled.last_event, "scheduled");
|
|
122
|
+
assert.equal(scheduled.scheduled_at, "2026-09-15 15:00:00.000000+00");
|
|
123
|
+
assert.deepEqual(await (await api("PATCH", `/emails/${c.id}`, { body: { scheduled_at: inHours(2) } })).body, { object: "email", id: c.id });
|
|
124
|
+
scheduled = (await api("GET", `/emails/${c.id}`)).body;
|
|
125
|
+
assert.equal(scheduled.scheduled_at, "2026-09-15 16:00:00.000000+00");
|
|
126
|
+
assert.deepEqual((await api("POST", `/emails/${c.id}/cancel`)).body, { object: "email", id: c.id });
|
|
127
|
+
assert.equal((await api("GET", `/emails/${c.id}`)).body.last_event, "canceled");
|
|
128
|
+
|
|
129
|
+
// Only a pending scheduled email can change; the seeded 13:30 email is already due.
|
|
130
|
+
await api("POST", `/emails/${a.id}/cancel`, { status: 422, name: "validation_error" });
|
|
131
|
+
await api("POST", `/emails/${EMAIL(18)}/cancel`, { status: 422, name: "validation_error" });
|
|
132
|
+
await api("PATCH", `/emails/${a.id}`, { body: { scheduled_at: inHours(3) }, status: 422, name: "validation_error" });
|
|
133
|
+
await api("PATCH", `/emails/${c.id}`, { body: {}, status: 422, name: "missing_required_field" });
|
|
134
|
+
assert.equal((await api("GET", `/emails/${EMAIL(18)}`)).body.last_event, "delivered");
|
|
135
|
+
for (const [method, suffix, body] of [["GET", ""], ["PATCH", "", { scheduled_at: inHours(3) }], ["POST", "/cancel"]]) {
|
|
136
|
+
await api(method, `/emails/not-a-uuid${suffix}`, { body, status: 422, name: "invalid_parameter" });
|
|
137
|
+
await api(method, `/emails/${UNKNOWN}${suffix}`, { body, status: 404, name: "not_found" });
|
|
138
|
+
}
|
|
139
|
+
const badKey = { "idempotency-key": LONG_KEY };
|
|
140
|
+
await api("POST", "/emails", { body: send(), headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
141
|
+
await api("POST", "/emails/batch", { body: [send()], headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
142
|
+
await api("PATCH", `/emails/${EMAIL(21)}`, { body: { scheduled_at: inHours(30) }, headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
143
|
+
await api("POST", `/emails/${EMAIL(21)}/cancel`, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
144
|
+
|
|
145
|
+
const invalid = [
|
|
146
|
+
[send({ from: undefined }), 422, "missing_required_field"],
|
|
147
|
+
[send({ from: "not an address" }), 422, "validation_error"],
|
|
148
|
+
[send({ to: [] }), 422, "missing_required_field"],
|
|
149
|
+
[send({ to: Array.from({ length: 51 }, (_, i) => `r${i}@example.com`) }), 422, "validation_error"],
|
|
150
|
+
[send({ subject: undefined }), 422, "missing_required_field"],
|
|
151
|
+
[send({ text: undefined }), 422, "missing_required_field"],
|
|
152
|
+
[send({ tags: [{ name: "bad tag!", value: "x" }] }), 422, "validation_error"],
|
|
153
|
+
[send({ scheduled_at: inHours(-1) }), 422, "validation_error"],
|
|
154
|
+
[send({ scheduled_at: inHours(24 * 31) }), 422, "validation_error"],
|
|
155
|
+
[send({ scheduled_at: "2026-09-16T10:00:00" }), 422, "validation_error"],
|
|
156
|
+
[send({ attachments: [{ filename: "a.txt", content: "aGk=" }] }), 422, "validation_error"],
|
|
157
|
+
[send({ headers: { __proto__: null, constructor: "x" } }), 422, "validation_error"],
|
|
158
|
+
[send({ from: "Billing <invoices@billing.northwind.test>" }), 403, "validation_error"],
|
|
159
|
+
[send({ from: "someone@unknown-domain.test" }), 403, "validation_error"],
|
|
160
|
+
];
|
|
161
|
+
for (const [body, status, name] of invalid) await api("POST", "/emails", { body, status, name });
|
|
162
|
+
|
|
163
|
+
const batch = (await api("POST", "/emails/batch", { body: [send(), send({ to: "lena.hart@example.com" }), send({ subject: "Third" })] })).body;
|
|
164
|
+
assert.equal(batch.data.length, 3);
|
|
165
|
+
const badBatches = [
|
|
166
|
+
[Array.from({ length: 101 }, () => send()), 422, "validation_error"],
|
|
167
|
+
[[], 422, "validation_error"],
|
|
168
|
+
[[send({ scheduled_at: inHours(1) })], 422, "validation_error"],
|
|
169
|
+
[[send(), send({ from: undefined })], 422, "missing_required_field"],
|
|
170
|
+
[[send({ from: "x <a@billing.northwind.test>" })], 403, "validation_error"],
|
|
171
|
+
[send(), 422, "validation_error"],
|
|
172
|
+
[[send(), 5], 400, "validation_error"],
|
|
173
|
+
];
|
|
174
|
+
for (const [body, status, name] of badBatches) await api("POST", "/emails/batch", { body, status, name });
|
|
175
|
+
await api("POST", "/emails/batch", { body: [send()], headers: { "x-batch-validation": "permissive" }, status: 422, name: "validation_error" });
|
|
176
|
+
// Bodies nested past 512 levels answer 400 before argument validation (including the framework's 3,000-level window).
|
|
177
|
+
const nest = (key, depth, shape) => {
|
|
178
|
+
const inner = `${(shape === "array" ? "[" : '{"a":').repeat(depth)}1${(shape === "array" ? "]" : "}").repeat(depth)}`;
|
|
179
|
+
return key === undefined ? inner : `{"${key}":${inner}}`;
|
|
180
|
+
};
|
|
181
|
+
const rawStatus = async (method, path, text) => {
|
|
182
|
+
const response = await fetch(`${BASE}${path}`, { method, headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: text });
|
|
183
|
+
return { status: response.status, text: await response.text() };
|
|
184
|
+
};
|
|
185
|
+
for (const [method, path, key] of [
|
|
186
|
+
["POST", "/emails", "from"], ["POST", "/emails/batch", undefined], ["POST", "/domains", "name"], ["POST", "/api-keys", "name"],
|
|
187
|
+
["POST", "/segments", "name"], ["POST", "/contacts", "email"], ["PATCH", "/contacts/ada.okafor@example.com", "email"],
|
|
188
|
+
]) {
|
|
189
|
+
for (const depth of [513, 2998, 3152]) {
|
|
190
|
+
for (const shape of ["array", "object"]) {
|
|
191
|
+
const { status, text } = await rawStatus(method, path, nest(key, depth, shape));
|
|
192
|
+
assert.equal(status, 400, `${method} ${path} ${shape}s nested ${depth} -> ${status} ${text.slice(0, 200)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const shallow = await rawStatus("POST", "/domains", nest("name", 510, "array"));
|
|
197
|
+
assert.equal(shallow.status, 400, `510 levels reach Resend's own validation: ${shallow.text.slice(0, 200)}`);
|
|
198
|
+
assert.equal(JSON.parse(shallow.text).name, "validation_error", `510 levels answer the Resend envelope, not the depth guard: ${shallow.text.slice(0, 200)}`);
|
|
199
|
+
|
|
200
|
+
const page1 = (await api("GET", "/emails")).body;
|
|
201
|
+
assert.equal(page1.object, "list");
|
|
202
|
+
assert.equal(page1.data.length, 20);
|
|
203
|
+
assert.equal(page1.has_more, true);
|
|
204
|
+
assert.equal(page1.data[0].id, batch.data[2].id, "newest first");
|
|
205
|
+
const page2 = (await api("GET", `/emails?after=${page1.data[19].id}`)).body;
|
|
206
|
+
assert.equal(page2.data.length, 9);
|
|
207
|
+
assert.equal(page2.has_more, false);
|
|
208
|
+
const back = (await api("GET", `/emails?before=${page2.data[0].id}&limit=5`)).body;
|
|
209
|
+
assert.deepEqual(back.data.map((e) => e.id), page1.data.slice(15).map((e) => e.id));
|
|
210
|
+
assert.equal(back.has_more, true);
|
|
211
|
+
for (const query of ["limit=0", "limit=abc", "after=nope", `after=${UNKNOWN}`, `after=${EMAIL(1)}&before=${EMAIL(2)}`]) {
|
|
212
|
+
await api("GET", `/emails?${query}`, { status: 422, name: "validation_error" });
|
|
213
|
+
}
|
|
214
|
+
const filtered = await canonical("emails.list", { query: "order nw-77", status: "delivered" });
|
|
215
|
+
assert.equal(filtered.value.data.length, 3);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function domainsAndKeysFlow() {
|
|
219
|
+
const badKey = { "idempotency-key": LONG_KEY };
|
|
220
|
+
const created = await api("POST", "/domains", { body: { name: "Shop.Northwind.test", region: "eu-west-1", capabilities: { receiving: "enabled" } }, status: 201 });
|
|
221
|
+
const shop = created.body;
|
|
222
|
+
assert.equal(shop.name, "shop.northwind.test");
|
|
223
|
+
assert.equal(shop.status, "not_started");
|
|
224
|
+
assert.deepEqual(shop.records.map((r) => `${r.record}/${r.type}`), ["SPF/MX", "SPF/TXT", "DKIM/TXT", "Receiving/MX"]);
|
|
225
|
+
assert.equal(shop.records[0].value, "feedback-smtp.eu-west-1.amazonses.com");
|
|
226
|
+
await api("POST", "/domains", { body: { name: "shop.northwind.test" }, status: 422, name: "validation_error" });
|
|
227
|
+
await api("POST", "/domains", { body: { name: "not a domain" }, status: 422, name: "validation_error" });
|
|
228
|
+
await api("POST", "/domains", { body: {}, status: 422, name: "missing_required_field" });
|
|
229
|
+
await api("POST", "/domains", { body: { name: "x.northwind.test", region: "mars-1" }, status: 422, name: "validation_error" });
|
|
230
|
+
await api("POST", "/domains", { body: { name: "x.northwind.test", tracking_subdomain: "links" }, status: 422, name: "validation_error" });
|
|
231
|
+
await api("POST", "/domains", { body: { name: "x.northwind.test" }, headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
232
|
+
|
|
233
|
+
assert.deepEqual((await api("POST", `/domains/${shop.id}/verify`)).body, { object: "domain", id: shop.id });
|
|
234
|
+
const verified = (await api("GET", `/domains/${shop.id}`)).body;
|
|
235
|
+
assert.equal(verified.object, "domain");
|
|
236
|
+
assert.equal(verified.status, "verified");
|
|
237
|
+
assert.ok(verified.records.every((r) => r.status === "verified"));
|
|
238
|
+
await api("POST", `/domains/${DOMAIN.legacy}/verify`);
|
|
239
|
+
assert.equal((await api("GET", `/domains/${DOMAIN.legacy}`)).body.status, "failed");
|
|
240
|
+
|
|
241
|
+
const domains = (await api("GET", "/domains")).body;
|
|
242
|
+
assert.equal(domains.data.length, 5);
|
|
243
|
+
assert.equal(domains.data[0].id, shop.id);
|
|
244
|
+
const first = (await api("GET", "/domains?limit=2")).body;
|
|
245
|
+
assert.equal(first.has_more, true);
|
|
246
|
+
const rest = (await api("GET", `/domains?limit=2&after=${first.data[1].id}`)).body;
|
|
247
|
+
assert.deepEqual(rest.data.map((d) => d.id), domains.data.slice(2, 4).map((d) => d.id));
|
|
248
|
+
await api("GET", "/domains?before=zzz", { status: 422, name: "validation_error" });
|
|
249
|
+
for (const [method, suffix] of [["GET", ""], ["POST", "/verify"], ["DELETE", ""]]) {
|
|
250
|
+
await api(method, `/domains/bad-id${suffix}`, { status: 422, name: "invalid_parameter" });
|
|
251
|
+
await api(method, `/domains/${UNKNOWN}${suffix}`, { status: 404, name: "not_found" });
|
|
252
|
+
}
|
|
253
|
+
await api("POST", `/domains/${shop.id}/verify`, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
254
|
+
await api("DELETE", `/domains/${shop.id}`, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
255
|
+
assert.deepEqual((await api("DELETE", `/domains/${shop.id}`)).body, { object: "domain", id: shop.id, deleted: true });
|
|
256
|
+
await api("GET", `/domains/${shop.id}`, { status: 404, name: "not_found" });
|
|
257
|
+
await api("POST", "/emails", { body: send({ from: "orders@shop.northwind.test" }), status: 403, name: "validation_error" });
|
|
258
|
+
|
|
259
|
+
const full = (await api("POST", "/api-keys", { body: { name: "Probe key" }, status: 201 })).body;
|
|
260
|
+
assert.match(full.token, /^re_[0-9A-Za-z]{32}$/);
|
|
261
|
+
const restricted = (await api("POST", "/api-keys", { body: { name: "Updates only", permission: "sending_access", domain_id: DOMAIN.updates }, status: 201 })).body;
|
|
262
|
+
await api("POST", "/api-keys", { body: { name: "Wrong", domain_id: DOMAIN.updates }, status: 422, name: "validation_error" });
|
|
263
|
+
await api("POST", "/api-keys", { body: { name: "Wrong", permission: "admin" }, status: 422, name: "validation_error" });
|
|
264
|
+
await api("POST", "/api-keys", { body: {}, status: 422, name: "missing_required_field" });
|
|
265
|
+
await api("POST", "/api-keys", { body: { name: "x" }, headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
266
|
+
const keys = (await api("GET", "/api-keys")).body;
|
|
267
|
+
assert.deepEqual(keys.data.slice(0, 2).map((k) => k.id), [restricted.id, full.id]);
|
|
268
|
+
assert.equal(keys.data.length, 5);
|
|
269
|
+
assert.equal(keys.data.find((k) => k.id === KEY.staging).last_used_at, null);
|
|
270
|
+
assert.ok(!JSON.stringify(keys).includes(full.token), "tokens are never listed");
|
|
271
|
+
await api("GET", "/api-keys?limit=101", { status: 422, name: "validation_error" });
|
|
272
|
+
assert.deepEqual((await api("DELETE", `/api-keys/${full.id}`)).body, { object: "api_key", id: full.id, deleted: true });
|
|
273
|
+
await api("DELETE", `/api-keys/${full.id}`, { status: 404, name: "not_found" });
|
|
274
|
+
await api("DELETE", "/api-keys/nope", { status: 422, name: "invalid_parameter" });
|
|
275
|
+
await api("DELETE", `/api-keys/${KEY.production}`, { status: 422, name: "validation_error" });
|
|
276
|
+
await api("DELETE", `/api-keys/${KEY.staging}`, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function audienceFlow() {
|
|
280
|
+
const badKey = { "idempotency-key": LONG_KEY };
|
|
281
|
+
const vip = (await api("POST", "/segments", { body: { name: "VIP" }, status: 201 })).body;
|
|
282
|
+
assert.equal(vip.object, "segment");
|
|
283
|
+
await api("POST", "/segments", { body: {}, status: 422, name: "missing_required_field" });
|
|
284
|
+
await api("POST", "/segments", { body: { name: "Filtered", filter: { and: [] } }, status: 422, name: "validation_error" });
|
|
285
|
+
await api("POST", "/segments", { body: { name: "x" }, headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
286
|
+
const segments = (await api("GET", "/segments")).body;
|
|
287
|
+
assert.deepEqual(segments.data.map((s) => s.name), ["VIP", "Empty segment", "Beta testers", "Newsletter"]);
|
|
288
|
+
await api("GET", "/segments?limit=-1", { status: 422, name: "validation_error" });
|
|
289
|
+
await api("DELETE", `/segments/${vip.id}`);
|
|
290
|
+
await api("DELETE", `/segments/${vip.id}`, { status: 404, name: "not_found" });
|
|
291
|
+
await api("DELETE", "/segments/1234", { status: 422, name: "invalid_parameter" });
|
|
292
|
+
await api("DELETE", `/segments/${SEGMENT.beta}`, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
293
|
+
assert.deepEqual((await api("DELETE", `/segments/${SEGMENT.beta}`)).body, { object: "segment", id: SEGMENT.beta, deleted: true });
|
|
294
|
+
assert.equal((await api("GET", `/contacts/${CONTACT(1)}/segments`)).body.data.length, 1, "Beta testers membership removed");
|
|
295
|
+
|
|
296
|
+
const created = (await api("POST", "/contacts", { body: { email: "new.person@example.com", first_name: "New", segments: [{ id: SEGMENT.newsletter }], properties: { company_name: "Newco", seats: 2 } }, status: 201 })).body;
|
|
297
|
+
assert.equal(created.object, "contact");
|
|
298
|
+
await api("POST", "/contacts", { body: { email: "New.Person@example.com" }, status: 422, name: "validation_error" });
|
|
299
|
+
await api("POST", "/contacts", { body: { email: "nope" }, status: 422, name: "validation_error" });
|
|
300
|
+
await api("POST", "/contacts", { body: {}, status: 422, name: "missing_required_field" });
|
|
301
|
+
await api("POST", "/contacts", { body: { email: "z@example.com", segments: [{ id: UNKNOWN }] }, status: 422, name: "validation_error" });
|
|
302
|
+
await api("POST", "/contacts", { body: { email: "z@example.com", topics: [{ id: UNKNOWN, subscription: "opt_in" }] }, status: 422, name: "validation_error" });
|
|
303
|
+
await api("POST", "/contacts", { body: { email: "z@example.com" }, headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
304
|
+
|
|
305
|
+
const byId = (await api("GET", `/contacts/${created.id}`)).body;
|
|
306
|
+
assert.deepEqual([byId.email, byId.first_name, byId.last_name, byId.unsubscribed, byId.properties.seats], ["new.person@example.com", "New", null, false, 2]);
|
|
307
|
+
const byEmail = (await api("GET", `/contacts/${enc("MARA.QUINN@example.com")}`)).body;
|
|
308
|
+
assert.equal(byEmail.id, CONTACT(1));
|
|
309
|
+
await api("GET", "/contacts/not-a-uuid", { status: 422, name: "invalid_parameter" });
|
|
310
|
+
await api("GET", `/contacts/${UNKNOWN}`, { status: 404, name: "not_found" });
|
|
311
|
+
await api("GET", `/contacts/${enc("nobody@example.com")}`, { status: 404, name: "not_found" });
|
|
312
|
+
await api("GET", `/contacts/${enc("a@b")}`, { status: 422, name: "invalid_parameter" });
|
|
313
|
+
await canonicalError("contacts.get", {}, "MISSING_REQUIRED_FIELD");
|
|
314
|
+
|
|
315
|
+
const theo = `/contacts/${enc("theo.brandt@example.com")}`;
|
|
316
|
+
assert.deepEqual((await api("PATCH", theo, { body: { first_name: "Theodor", unsubscribed: true } })).body, { object: "contact", id: CONTACT(2) });
|
|
317
|
+
const updated = (await api("GET", `/contacts/${CONTACT(2)}`)).body;
|
|
318
|
+
assert.deepEqual([updated.first_name, updated.unsubscribed], ["Theodor", true]);
|
|
319
|
+
await api("PATCH", theo, { body: { properties: { "Bad Key": 1 } }, status: 422, name: "validation_error" });
|
|
320
|
+
await api("PATCH", theo, { body: { email: "mara.quinn@example.com" }, status: 422, name: "validation_error" });
|
|
321
|
+
await api("PATCH", `/contacts/${UNKNOWN}`, { body: { first_name: "x" }, status: 404, name: "not_found" });
|
|
322
|
+
await api("PATCH", "/contacts/xyz", { body: { first_name: "x" }, status: 422, name: "invalid_parameter" });
|
|
323
|
+
await api("PATCH", theo, { body: { first_name: "x" }, headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
324
|
+
await canonicalError("contacts.update", { firstName: "x" }, "MISSING_REQUIRED_FIELD");
|
|
325
|
+
|
|
326
|
+
const all = (await api("GET", "/contacts")).body;
|
|
327
|
+
assert.deepEqual([all.data.length, all.has_more, all.data[0].id], [15, false, created.id]);
|
|
328
|
+
const firstTen = (await api("GET", "/contacts?limit=10")).body;
|
|
329
|
+
assert.equal(firstTen.has_more, true);
|
|
330
|
+
assert.equal((await api("GET", `/contacts?limit=10&after=${firstTen.data[9].id}`)).body.data.length, 5);
|
|
331
|
+
assert.equal((await api("GET", `/contacts?segment_id=${SEGMENT.newsletter}`)).body.data.length, 10);
|
|
332
|
+
const empty = (await api("GET", `/contacts?segment_id=${SEGMENT.empty}`)).body;
|
|
333
|
+
assert.deepEqual([empty.data.length, empty.has_more], [0, false]);
|
|
334
|
+
await api("GET", `/contacts?segment_id=${SEGMENT.beta}`, { status: 404, name: "not_found" });
|
|
335
|
+
await api("GET", "/contacts?segment_id=beta", { status: 422, name: "invalid_parameter" });
|
|
336
|
+
await api("GET", `/contacts?after=${UNKNOWN}`, { status: 422, name: "validation_error" });
|
|
337
|
+
assert.equal((await canonical("contacts.list", { query: "HADDAD" })).value.data.length, 1);
|
|
338
|
+
|
|
339
|
+
const membership = `/contacts/${CONTACT(2)}/segments/${SEGMENT.empty}`;
|
|
340
|
+
const added = { object: "contact_segment", contact_id: CONTACT(2), segment_id: SEGMENT.empty };
|
|
341
|
+
assert.deepEqual((await api("POST", membership)).body, added);
|
|
342
|
+
assert.deepEqual((await api("POST", `/contacts/${enc("theo.brandt@example.com")}/segments/${SEGMENT.empty}`)).body, added);
|
|
343
|
+
await api("POST", `/contacts/${CONTACT(2)}/segments/${UNKNOWN}`, { status: 404, name: "not_found" });
|
|
344
|
+
await api("POST", `/contacts/nope/segments/${SEGMENT.empty}`, { status: 422, name: "invalid_parameter" });
|
|
345
|
+
await api("POST", membership, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
346
|
+
await canonicalError("contacts.add_segment", { segmentId: SEGMENT.empty }, "MISSING_REQUIRED_FIELD");
|
|
347
|
+
const theoSegments = (await api("GET", `/contacts/${CONTACT(2)}/segments`)).body;
|
|
348
|
+
assert.deepEqual(theoSegments.data.map((s) => s.name), ["Empty segment", "Newsletter"]);
|
|
349
|
+
await api("GET", `/contacts/${CONTACT(2)}/segments?after=xyz`, { status: 422, name: "validation_error" });
|
|
350
|
+
await api("GET", `/contacts/${UNKNOWN}/segments`, { status: 404, name: "not_found" });
|
|
351
|
+
await api("GET", "/contacts/nope/segments", { status: 422, name: "invalid_parameter" });
|
|
352
|
+
await canonicalError("contacts.list_segments", {}, "MISSING_REQUIRED_FIELD");
|
|
353
|
+
await api("DELETE", membership, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
354
|
+
assert.deepEqual((await api("DELETE", membership)).body, { ...added, deleted: true });
|
|
355
|
+
await api("DELETE", membership, { status: 404, name: "not_found" });
|
|
356
|
+
await api("DELETE", `/contacts/nope/segments/${SEGMENT.empty}`, { status: 422, name: "invalid_parameter" });
|
|
357
|
+
await canonicalError("contacts.remove_segment", { segmentId: SEGMENT.empty }, "MISSING_REQUIRED_FIELD");
|
|
358
|
+
|
|
359
|
+
await api("DELETE", `/contacts/${CONTACT(1)}`, { headers: badKey, status: 400, name: "invalid_idempotency_key" });
|
|
360
|
+
assert.deepEqual((await api("DELETE", `/contacts/${enc("mara.quinn@example.com")}`)).body, { object: "contact", id: CONTACT(1), deleted: true });
|
|
361
|
+
await api("GET", `/contacts/${enc("mara.quinn@example.com")}`, { status: 404, name: "not_found" });
|
|
362
|
+
await api("DELETE", `/contacts/${CONTACT(1)}`, { status: 404, name: "not_found" });
|
|
363
|
+
await api("DELETE", "/contacts/12", { status: 422, name: "invalid_parameter" });
|
|
364
|
+
await canonicalError("contacts.remove", {}, "MISSING_REQUIRED_FIELD");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function restrictedKeyFlow() {
|
|
368
|
+
for (const [operation, method, path, body] of CALLS) {
|
|
369
|
+
if (operation.startsWith("emails.send")) continue;
|
|
370
|
+
await api(method, path, { body, status: 401, name: "restricted_api_key" });
|
|
371
|
+
}
|
|
372
|
+
const context = await canonical("workspace.context", {});
|
|
373
|
+
assert.equal(context.value.apiKey.name, "Marketing sender");
|
|
374
|
+
await api("POST", "/emails", { body: send({ from: "news@updates.northwind.test" }) });
|
|
375
|
+
await api("POST", "/emails", { body: send(), status: 403, name: "validation_error" });
|
|
376
|
+
await api("POST", "/emails/batch", { body: [send({ from: "Updates <news@updates.northwind.test>" })] });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function invalidKeyFlow() {
|
|
380
|
+
for (const [, method, path, body] of CALLS) await api(method, path, { body, status: 403, name: "invalid_api_key" });
|
|
381
|
+
await canonicalError("workspace.context", {}, "INVALID_API_KEY");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function freshInstallFlow() {
|
|
385
|
+
const context = (await canonical("workspace.context", {})).value;
|
|
386
|
+
assert.deepEqual([context.apiKey.name, context.team.name, context.now, context.team.sentToday], ["Production", "Northwind Labs", "2026-09-15T14:00:00.000000Z", 7]);
|
|
387
|
+
assert.equal((await api("GET", "/emails")).body.data.length, 20);
|
|
388
|
+
assert.equal((await api("GET", "/domains")).body.data.length, 4);
|
|
389
|
+
assert.equal((await api("GET", "/contacts")).body.data.length, 14);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function deniedFlow() {
|
|
393
|
+
for (const [method, path, body] of [["POST", "/emails", send()], ["GET", "/emails"]]) {
|
|
394
|
+
const response = await fetch(`${BASE}${path}`, { method, headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
395
|
+
assert.equal(response.status, 403, `${method} ${path} must be denied`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function rateLimitedFlow() {
|
|
400
|
+
for (let i = 0; i < 2; i += 1) {
|
|
401
|
+
const { headers } = await api("POST", "/emails", { body: send(), status: 429, name: "rate_limit_exceeded" });
|
|
402
|
+
assert.deepEqual([headers.get("retry-after"), headers.get("ratelimit-limit"), headers.get("ratelimit-remaining")], ["1", "10", "0"]);
|
|
403
|
+
}
|
|
404
|
+
await api("POST", "/emails/batch", { body: [send()], status: 429, name: "rate_limit_exceeded" });
|
|
405
|
+
assert.equal((await api("GET", "/emails")).body.data.length, 20);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function contactsOutageFlow() {
|
|
409
|
+
await api("POST", "/contacts", { body: { email: "outage@example.com" }, status: 500, name: "application_error" });
|
|
410
|
+
assert.equal((await api("GET", `/contacts/${enc("outage@example.com")}`)).body.email, "outage@example.com");
|
|
411
|
+
await api("POST", "/contacts", { body: { email: "outage@example.com" }, status: 422, name: "validation_error" });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function quotaExhaustedFlow() {
|
|
415
|
+
await api("POST", "/emails", { body: send() });
|
|
416
|
+
await api("POST", "/emails", { body: send(), status: 429, name: "daily_quota_exceeded" });
|
|
417
|
+
await api("POST", "/emails/batch", { body: [send(), send()], status: 429, name: "daily_quota_exceeded" });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function tightLimitsFlow() {
|
|
421
|
+
for (const path of ["/emails", "/domains", "/api-keys", "/segments", "/contacts", `/contacts/${CONTACT(1)}/segments`]) {
|
|
422
|
+
await api("GET", path, { status: 500, name: "application_error" });
|
|
423
|
+
}
|
|
424
|
+
await api("DELETE", `/segments/${SEGMENT.beta}`, { status: 500, name: "application_error" });
|
|
425
|
+
await api("DELETE", `/contacts/${CONTACT(1)}`, { status: 500, name: "application_error" });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function largePageFlow() {
|
|
429
|
+
// 24 emails of about 50-120 KB each push list pages past the byte cap; every row must stay reachable both ways.
|
|
430
|
+
const addr = (i) => `${"r".repeat(50)}.${String(i).padStart(5, "0")}@${"a".repeat(60)}.${"b".repeat(60)}.${"c".repeat(60)}.example.com`;
|
|
431
|
+
// The second batch uses 190-character CJK display names: three UTF-8 bytes per character, one UTF-16 unit each.
|
|
432
|
+
const cjk = (i) => `${"\u540d".repeat(190)} <r${i}@example.com>`;
|
|
433
|
+
const heavy = (n, mk) => send({ to: Array.from({ length: 50 }, (_, i) => mk(n * 1000 + i)), cc: Array.from({ length: 50 }, (_, i) => mk(n * 1000 + 50 + i)), bcc: Array.from({ length: 50 }, (_, i) => mk(n * 1000 + 100 + i)), reply_to: Array.from({ length: 50 }, (_, i) => mk(n * 1000 + 150 + i)) });
|
|
434
|
+
// CJK emails carry 100 recipients (to + cc) so the batch request stays under 1 MiB; the resulting page is about 1.3 MB
|
|
435
|
+
// of UTF-8 but under 900k UTF-16 units, so a character-counted budget would overflow the response cap.
|
|
436
|
+
const light = (n) => send({ to: Array.from({ length: 50 }, (_, i) => cjk(n * 1000 + i)), cc: Array.from({ length: 50 }, (_, i) => cjk(n * 1000 + 50 + i)) });
|
|
437
|
+
await api("POST", "/emails/batch", { body: Array.from({ length: 12 }, (_, i) => heavy(i, addr)) });
|
|
438
|
+
await api("POST", "/emails/batch", { body: Array.from({ length: 12 }, (_, i) => light(12 + i)) });
|
|
439
|
+
const MIB = 1024 * 1024;
|
|
440
|
+
const cjkPage = await api("GET", "/emails?limit=100");
|
|
441
|
+
assert.ok(cjkPage.bytes < MIB, `non-ASCII page is ${cjkPage.bytes} UTF-8 bytes, over the 1 MiB cap`);
|
|
442
|
+
const forward = [];
|
|
443
|
+
let capped = false;
|
|
444
|
+
let page = cjkPage.body;
|
|
445
|
+
for (;;) {
|
|
446
|
+
forward.push(...page.data.map((e) => e.id));
|
|
447
|
+
if (page.has_more && page.data.length < 100) capped = true;
|
|
448
|
+
if (!page.has_more) break;
|
|
449
|
+
const next = await api("GET", `/emails?limit=100&after=${page.data.at(-1).id}`);
|
|
450
|
+
assert.ok(next.bytes < MIB, `forward page is ${next.bytes} bytes`);
|
|
451
|
+
page = next.body;
|
|
452
|
+
}
|
|
453
|
+
assert.ok(capped, "a forward page was byte-capped");
|
|
454
|
+
assert.equal(forward.length, 47);
|
|
455
|
+
assert.equal(new Set(forward).size, 47);
|
|
456
|
+
const backward = [];
|
|
457
|
+
let cursor = forward.at(-1);
|
|
458
|
+
let cappedBack = false;
|
|
459
|
+
for (;;) {
|
|
460
|
+
const backResponse = await api("GET", `/emails?limit=100&before=${cursor}`);
|
|
461
|
+
assert.ok(backResponse.bytes < MIB, `backward page is ${backResponse.bytes} bytes`);
|
|
462
|
+
const back = backResponse.body;
|
|
463
|
+
backward.unshift(...back.data.map((e) => e.id));
|
|
464
|
+
if (back.has_more && back.data.length < 100) cappedBack = true;
|
|
465
|
+
if (!back.has_more) break;
|
|
466
|
+
cursor = back.data[0].id;
|
|
467
|
+
}
|
|
468
|
+
assert.ok(cappedBack, "a backward page was byte-capped");
|
|
469
|
+
assert.deepEqual(backward, forward.slice(0, -1), "paging back from the last row reaches every earlier row in order");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const FLOWS = {
|
|
473
|
+
"email-flow": emailFlow, "domains-and-keys": domainsAndKeysFlow, audience: audienceFlow, "restricted-key": restrictedKeyFlow,
|
|
474
|
+
"invalid-key": invalidKeyFlow, "fresh-install": freshInstallFlow, denied: deniedFlow, "rate-limited": rateLimitedFlow,
|
|
475
|
+
"contacts-outage": contactsOutageFlow, "quota-exhausted": quotaExhaustedFlow, "tight-limits": tightLimitsFlow,
|
|
476
|
+
"large-page": largePageFlow,
|
|
477
|
+
};
|
|
478
|
+
assert.ok(flow !== undefined && Object.hasOwn(FLOWS, flow), `unknown conformance flow in instruction: ${instruction}`);
|
|
479
|
+
await FLOWS[flow]();
|
|
480
|
+
process.stdout.write(JSON.stringify({ completed: true, flow }));
|