@nymbalhq/contract-tests 0.0.0-stage → 1.2.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/chunk-ABEL44IM.js +19 -0
- package/dist/chunk-ABEL44IM.js.map +1 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +2674 -0
- package/dist/index.js.map +1 -0
- package/dist/kinds.d.ts +4 -0
- package/dist/kinds.js +7 -0
- package/dist/kinds.js.map +1 -0
- package/package.json +50 -3
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2674 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTRACT_KINDS
|
|
3
|
+
} from "./chunk-ABEL44IM.js";
|
|
4
|
+
|
|
5
|
+
// src/ai.ts
|
|
6
|
+
import { describe as describe2, expect as expect2, it as it2 } from "vitest";
|
|
7
|
+
|
|
8
|
+
// src/common.ts
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
var BOUND_MS = 6e3;
|
|
11
|
+
var CASE_MS = 3e4;
|
|
12
|
+
async function settle(call, ms = BOUND_MS) {
|
|
13
|
+
const started = Date.now();
|
|
14
|
+
let timer;
|
|
15
|
+
const hung = new Promise((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve({ state: "hung" }), ms);
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
const outcome = await Promise.race([
|
|
20
|
+
Promise.resolve().then(call).then(
|
|
21
|
+
(value) => ({ state: "resolved", value }),
|
|
22
|
+
(error) => ({ state: "rejected", error })
|
|
23
|
+
),
|
|
24
|
+
hung
|
|
25
|
+
]);
|
|
26
|
+
return { ...outcome, ms: Date.now() - started };
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function release(target) {
|
|
32
|
+
const close = target?.close;
|
|
33
|
+
if (typeof close !== "function") return;
|
|
34
|
+
let timer;
|
|
35
|
+
await Promise.race([
|
|
36
|
+
Promise.resolve().then(() => close.call(target)).catch(() => void 0),
|
|
37
|
+
new Promise((resolve) => {
|
|
38
|
+
timer = setTimeout(resolve, 5e3);
|
|
39
|
+
})
|
|
40
|
+
]);
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
function healthOf(target) {
|
|
44
|
+
expect(
|
|
45
|
+
typeof target.healthCheck,
|
|
46
|
+
"healthCheck() is missing: certification needs it, though the type keeps it optional"
|
|
47
|
+
).toBe("function");
|
|
48
|
+
return settle(() => target.healthCheck());
|
|
49
|
+
}
|
|
50
|
+
async function expectHealthy(target) {
|
|
51
|
+
const health = await healthOf(target);
|
|
52
|
+
expect(health.state, `healthCheck() did not resolve within ${BOUND_MS} ms`).toBe("resolved");
|
|
53
|
+
const status = health.state === "resolved" ? health.value?.status : void 0;
|
|
54
|
+
expect(["healthy", "degraded"], `healthCheck() reported ${String(status)}`).toContain(status);
|
|
55
|
+
}
|
|
56
|
+
async function expectDown(target) {
|
|
57
|
+
const health = await healthOf(target);
|
|
58
|
+
expect(health.state, `healthCheck() did not resolve within ${BOUND_MS} ms`).toBe("resolved");
|
|
59
|
+
expect(health.state === "resolved" ? health.value?.status : void 0).toBe("down");
|
|
60
|
+
}
|
|
61
|
+
async function expectRejects(call) {
|
|
62
|
+
const outcome = await settle(call);
|
|
63
|
+
expect(
|
|
64
|
+
outcome.state,
|
|
65
|
+
outcome.state === "hung" ? `still pending after ${BOUND_MS} ms` : "it succeeded"
|
|
66
|
+
).toBe("rejected");
|
|
67
|
+
}
|
|
68
|
+
async function using(build, body) {
|
|
69
|
+
const adapter = await build();
|
|
70
|
+
try {
|
|
71
|
+
return await body(adapter);
|
|
72
|
+
} finally {
|
|
73
|
+
await release(adapter);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function healthAndDowntime(build, unreachable, label, operation) {
|
|
77
|
+
it(
|
|
78
|
+
"health: healthCheck() resolves healthy or degraded within 6 s",
|
|
79
|
+
() => using(build, (adapter) => expectHealthy(adapter)),
|
|
80
|
+
CASE_MS
|
|
81
|
+
);
|
|
82
|
+
if (unreachable === void 0) return;
|
|
83
|
+
describe("unreachable dependency", () => {
|
|
84
|
+
it(
|
|
85
|
+
"down: healthCheck() reports down within 6 s",
|
|
86
|
+
() => using(unreachable, (adapter) => expectDown(adapter)),
|
|
87
|
+
CASE_MS
|
|
88
|
+
);
|
|
89
|
+
it(
|
|
90
|
+
`down: ${label} rejects within 6 s instead of hanging`,
|
|
91
|
+
() => using(unreachable, (adapter) => expectRejects(() => operation(adapter))),
|
|
92
|
+
CASE_MS
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/ai.ts
|
|
98
|
+
function runAiContract(name, build, options = {}) {
|
|
99
|
+
describe2(`AiAdapter contract: ${name}`, () => {
|
|
100
|
+
it2(
|
|
101
|
+
"embed returns embedding vectors with correct dimensions",
|
|
102
|
+
async () => {
|
|
103
|
+
const adapter = await build();
|
|
104
|
+
const embeddings = await adapter.embed(["hello world", "test text"]);
|
|
105
|
+
expect2(Array.isArray(embeddings)).toBe(true);
|
|
106
|
+
expect2(embeddings).toHaveLength(2);
|
|
107
|
+
expect2(Array.isArray(embeddings[0])).toBe(true);
|
|
108
|
+
expect2((embeddings[0] ?? []).length).toBeGreaterThan(0);
|
|
109
|
+
},
|
|
110
|
+
CASE_MS
|
|
111
|
+
);
|
|
112
|
+
it2(
|
|
113
|
+
"complete returns a non-empty string",
|
|
114
|
+
async () => {
|
|
115
|
+
const adapter = await build();
|
|
116
|
+
const result = await adapter.complete("Write one word: hello", { maxTokens: 10 });
|
|
117
|
+
expect2(typeof result).toBe("string");
|
|
118
|
+
expect2(result.length).toBeGreaterThan(0);
|
|
119
|
+
},
|
|
120
|
+
CASE_MS
|
|
121
|
+
);
|
|
122
|
+
healthAndDowntime(
|
|
123
|
+
build,
|
|
124
|
+
options.unreachable,
|
|
125
|
+
"complete()",
|
|
126
|
+
(adapter) => adapter.complete("Write one word: hello", { maxTokens: 10 })
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/analytics.ts
|
|
132
|
+
import { describe as describe3, expect as expect3, it as it3 } from "vitest";
|
|
133
|
+
function runAnalyticsContract(name, build, options = {}) {
|
|
134
|
+
describe3(`AnalyticsAdapter contract: ${name}`, () => {
|
|
135
|
+
it3(
|
|
136
|
+
"track resolves without throwing",
|
|
137
|
+
async () => {
|
|
138
|
+
const adapter = await build();
|
|
139
|
+
await expect3(
|
|
140
|
+
adapter.track("product_viewed", { productId: "prod-1", source: "test" })
|
|
141
|
+
).resolves.not.toThrow();
|
|
142
|
+
},
|
|
143
|
+
CASE_MS
|
|
144
|
+
);
|
|
145
|
+
it3(
|
|
146
|
+
"identify resolves without throwing",
|
|
147
|
+
async () => {
|
|
148
|
+
const adapter = await build();
|
|
149
|
+
await expect3(
|
|
150
|
+
adapter.identify("user-1", { email: "test@example.com", plan: "free" })
|
|
151
|
+
).resolves.not.toThrow();
|
|
152
|
+
},
|
|
153
|
+
CASE_MS
|
|
154
|
+
);
|
|
155
|
+
healthAndDowntime(
|
|
156
|
+
build,
|
|
157
|
+
options.unreachable,
|
|
158
|
+
"track()",
|
|
159
|
+
(adapter) => adapter.track("product_viewed", { productId: "prod-1", source: "contract-down" })
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/document-store.ts
|
|
165
|
+
import { describe as describe5, expect as expect6, it as it6 } from "vitest";
|
|
166
|
+
|
|
167
|
+
// src/document-store-model.ts
|
|
168
|
+
import fc from "fast-check";
|
|
169
|
+
import { describe as describe4, expect as expect4, it as it4 } from "vitest";
|
|
170
|
+
import {
|
|
171
|
+
compareDocumentValues,
|
|
172
|
+
matchesSortKey
|
|
173
|
+
} from "@nymbalhq/types";
|
|
174
|
+
var COLLECTION = "contract-docs";
|
|
175
|
+
var DECOY = "contract-decoy";
|
|
176
|
+
var SLOW = 6e4;
|
|
177
|
+
var valueArb = fc.oneof(
|
|
178
|
+
fc.constantFrom("a", "A", "\xE1", "b", "B", "\xE9", "e", "ab", "Ab", "1", "10", "2"),
|
|
179
|
+
fc.integer({ min: -2, max: 10 }),
|
|
180
|
+
fc.constant(null)
|
|
181
|
+
);
|
|
182
|
+
var fieldArb = fc.option(valueArb, { nil: void 0, freq: 4 });
|
|
183
|
+
var docsArb = (minLength) => fc.uniqueArray(
|
|
184
|
+
fc.record({
|
|
185
|
+
// Random keys, so key order is unrelated to value order.
|
|
186
|
+
key: fc.stringMatching(/^[a-z0-9]{1,6}$/),
|
|
187
|
+
part: fc.constantFrom("p", "q"),
|
|
188
|
+
v: fieldArb,
|
|
189
|
+
n: fieldArb
|
|
190
|
+
}),
|
|
191
|
+
{ minLength, maxLength: 14, selector: (d) => d.key }
|
|
192
|
+
);
|
|
193
|
+
var sortKeyArb = fc.option(
|
|
194
|
+
fc.oneof(
|
|
195
|
+
fc.record({
|
|
196
|
+
field: fc.constant("v"),
|
|
197
|
+
operator: fc.constantFrom("eq", "gt", "lt"),
|
|
198
|
+
value: valueArb
|
|
199
|
+
}),
|
|
200
|
+
fc.record({
|
|
201
|
+
field: fc.constant("v"),
|
|
202
|
+
operator: fc.constant("between"),
|
|
203
|
+
value: fc.tuple(valueArb, valueArb)
|
|
204
|
+
})
|
|
205
|
+
),
|
|
206
|
+
{ nil: void 0 }
|
|
207
|
+
);
|
|
208
|
+
var orderByArb = fc.record({
|
|
209
|
+
field: fc.constantFrom("v", "nested.v"),
|
|
210
|
+
direction: fc.constantFrom("asc", "desc")
|
|
211
|
+
});
|
|
212
|
+
function stored(g) {
|
|
213
|
+
return {
|
|
214
|
+
id: g.key,
|
|
215
|
+
part: g.part,
|
|
216
|
+
...g.v !== void 0 && { v: g.v },
|
|
217
|
+
nested: g.n !== void 0 ? { v: g.n } : {},
|
|
218
|
+
label: "\xDCn\xEFc\xF6d\xE9 \u2713"
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function valueAt(doc, path) {
|
|
222
|
+
let value = doc;
|
|
223
|
+
for (const part of path.split(".")) {
|
|
224
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
225
|
+
value = value[part];
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
var nullish = (v) => v === null || v === void 0;
|
|
230
|
+
function reference(docs, q) {
|
|
231
|
+
const hits = docs.filter(
|
|
232
|
+
(d) => (q.partition === "*" || d.part === q.partition) && (q.sortKey === void 0 || matchesSortKey(d.v, q.sortKey))
|
|
233
|
+
);
|
|
234
|
+
const { orderBy } = q;
|
|
235
|
+
if (orderBy === void 0) return hits.map((d) => d.id).sort();
|
|
236
|
+
return hits.sort((a, b) => {
|
|
237
|
+
const x = valueAt(a, orderBy.field);
|
|
238
|
+
const y = valueAt(b, orderBy.field);
|
|
239
|
+
if (nullish(x) !== nullish(y)) return nullish(x) ? 1 : -1;
|
|
240
|
+
let c = nullish(x) ? 0 : compareDocumentValues(x, y);
|
|
241
|
+
if (orderBy.direction === "desc") c = -c;
|
|
242
|
+
if (c !== 0) return c;
|
|
243
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
244
|
+
}).map((d) => d.id);
|
|
245
|
+
}
|
|
246
|
+
function params(q, limit, cursor) {
|
|
247
|
+
return {
|
|
248
|
+
partitionKey: { field: "part", value: q.partition },
|
|
249
|
+
...q.sortKey !== void 0 && { sortKey: q.sortKey },
|
|
250
|
+
...q.orderBy !== void 0 && { orderBy: { ...q.orderBy, nulls: "last" } },
|
|
251
|
+
limit,
|
|
252
|
+
...cursor !== void 0 && { cursor }
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
async function walk(store, q, limit, maxPages, between) {
|
|
256
|
+
const pages = [];
|
|
257
|
+
let cursor;
|
|
258
|
+
do {
|
|
259
|
+
const page = await store.query(COLLECTION, params(q, limit, cursor));
|
|
260
|
+
pages.push(page.items);
|
|
261
|
+
cursor = page.nextCursor ?? void 0;
|
|
262
|
+
if (between !== void 0) await between(pages.length);
|
|
263
|
+
} while (cursor !== void 0 && pages.length <= maxPages);
|
|
264
|
+
return { pages, ended: cursor === void 0 };
|
|
265
|
+
}
|
|
266
|
+
var idsOf = (docs) => docs.map((d) => d.id);
|
|
267
|
+
function chunk(items, size) {
|
|
268
|
+
const out = [];
|
|
269
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
function withoutTrailingEmpty(pages) {
|
|
273
|
+
return pages.length > 0 && pages[pages.length - 1].length === 0 ? pages.slice(0, -1) : pages;
|
|
274
|
+
}
|
|
275
|
+
async function storeWith(make, generated) {
|
|
276
|
+
const store = await make();
|
|
277
|
+
const docs = generated.map(stored);
|
|
278
|
+
for (const doc of docs) await store.put(COLLECTION, doc.id, doc);
|
|
279
|
+
for (const doc of docs.slice(0, 3)) {
|
|
280
|
+
await store.put(DECOY, doc.id, { ...doc, label: "decoy" });
|
|
281
|
+
}
|
|
282
|
+
return { store, docs };
|
|
283
|
+
}
|
|
284
|
+
async function closing(store, body) {
|
|
285
|
+
try {
|
|
286
|
+
return await body();
|
|
287
|
+
} finally {
|
|
288
|
+
await store.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function rejection(promise) {
|
|
292
|
+
return promise.then(
|
|
293
|
+
() => null,
|
|
294
|
+
(error) => error ?? new Error("rejected with nothing")
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
var TTL_SECONDS = 1;
|
|
298
|
+
function documentStoreModelCases(make, opts) {
|
|
299
|
+
describe4("agrees with the reference model", () => {
|
|
300
|
+
it4(
|
|
301
|
+
"every query\u2019s page sequence equals the reference: filter by partition and sortKey, order with nulls last and ties by key, slice by limit",
|
|
302
|
+
async () => {
|
|
303
|
+
await fc.assert(
|
|
304
|
+
fc.asyncProperty(
|
|
305
|
+
docsArb(0),
|
|
306
|
+
fc.constantFrom("p", "*"),
|
|
307
|
+
sortKeyArb,
|
|
308
|
+
orderByArb,
|
|
309
|
+
fc.integer({ min: 1, max: 5 }),
|
|
310
|
+
async (generated, partition, sortKey, orderBy, limit) => {
|
|
311
|
+
const { store, docs } = await storeWith(make, generated);
|
|
312
|
+
await closing(store, async () => {
|
|
313
|
+
const q = { partition, sortKey, orderBy };
|
|
314
|
+
const expected = reference(docs, q);
|
|
315
|
+
const { pages, ended } = await walk(store, q, limit, docs.length + 2);
|
|
316
|
+
expect4(ended, "the cursor walk never ended").toBe(true);
|
|
317
|
+
for (const page of pages) expect4(page.length).toBeLessThanOrEqual(limit);
|
|
318
|
+
expect4(withoutTrailingEmpty(pages.map(idsOf))).toEqual(chunk(expected, limit));
|
|
319
|
+
const byId = new Map(docs.map((d) => [d.id, d]));
|
|
320
|
+
for (const item of pages.flat()) expect4(item).toEqual(byId.get(item.id));
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
),
|
|
324
|
+
{ numRuns: 50 }
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
SLOW
|
|
328
|
+
);
|
|
329
|
+
it4(
|
|
330
|
+
"without orderBy, a cursor walk returns exactly the reference\u2019s matches, each once",
|
|
331
|
+
async () => {
|
|
332
|
+
await fc.assert(
|
|
333
|
+
fc.asyncProperty(
|
|
334
|
+
docsArb(0),
|
|
335
|
+
fc.constantFrom("p", "*"),
|
|
336
|
+
sortKeyArb,
|
|
337
|
+
fc.integer({ min: 1, max: 5 }),
|
|
338
|
+
async (generated, partition, sortKey, limit) => {
|
|
339
|
+
const { store, docs } = await storeWith(make, generated);
|
|
340
|
+
await closing(store, async () => {
|
|
341
|
+
const q = { partition, sortKey, orderBy: void 0 };
|
|
342
|
+
const { pages, ended } = await walk(store, q, limit, docs.length + 2);
|
|
343
|
+
const walked = idsOf(pages.flat());
|
|
344
|
+
expect4(ended, "the cursor walk never ended").toBe(true);
|
|
345
|
+
expect4(walked.length, `duplicates in ${walked.join(",")}`).toBe(
|
|
346
|
+
new Set(walked).size
|
|
347
|
+
);
|
|
348
|
+
expect4([...walked].sort()).toEqual(reference(docs, q));
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
),
|
|
352
|
+
{ numRuns: 40 }
|
|
353
|
+
);
|
|
354
|
+
},
|
|
355
|
+
SLOW
|
|
356
|
+
);
|
|
357
|
+
it4(
|
|
358
|
+
"a cursor walk across a mid-walk delete returns every remaining match exactly once, in the reference order",
|
|
359
|
+
async () => {
|
|
360
|
+
await fc.assert(
|
|
361
|
+
fc.asyncProperty(
|
|
362
|
+
docsArb(3),
|
|
363
|
+
fc.option(orderByArb, { nil: void 0 }),
|
|
364
|
+
fc.integer({ min: 1, max: 3 }),
|
|
365
|
+
fc.nat(),
|
|
366
|
+
async (generated, orderBy, limit, pick) => {
|
|
367
|
+
const { store, docs } = await storeWith(make, generated);
|
|
368
|
+
await closing(store, async () => {
|
|
369
|
+
const q = { partition: "*", sortKey: void 0, orderBy };
|
|
370
|
+
const expected = reference(docs, q);
|
|
371
|
+
fc.pre(expected.length > limit);
|
|
372
|
+
const deleted = expected[pick % expected.length];
|
|
373
|
+
const { pages, ended } = await walk(store, q, limit, docs.length + 2, async (n) => {
|
|
374
|
+
if (n === 1) await store.delete(COLLECTION, deleted);
|
|
375
|
+
});
|
|
376
|
+
const walked = idsOf(pages.flat());
|
|
377
|
+
expect4(ended, "the cursor walk never ended").toBe(true);
|
|
378
|
+
expect4(walked.length, `duplicates in ${walked.join(",")}`).toBe(
|
|
379
|
+
new Set(walked).size
|
|
380
|
+
);
|
|
381
|
+
expect4(walked.filter((id) => !expected.includes(id))).toEqual([]);
|
|
382
|
+
expect4(expected.filter((id) => id !== deleted && !walked.includes(id))).toEqual([]);
|
|
383
|
+
if (orderBy !== void 0) {
|
|
384
|
+
expect4(walked).toEqual(expected.filter((id) => walked.includes(id)));
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
),
|
|
389
|
+
{ numRuns: 50 }
|
|
390
|
+
);
|
|
391
|
+
},
|
|
392
|
+
SLOW
|
|
393
|
+
);
|
|
394
|
+
it4(
|
|
395
|
+
"a cursor issued under one orderBy is refused under another, and continues the walk under its own",
|
|
396
|
+
async () => {
|
|
397
|
+
const generated = ["k1", "k2", "k3", "k4", "k5"].map((key, i) => ({
|
|
398
|
+
key,
|
|
399
|
+
part: "p",
|
|
400
|
+
v: 5 - i,
|
|
401
|
+
n: i
|
|
402
|
+
}));
|
|
403
|
+
const { store, docs } = await storeWith(make, generated);
|
|
404
|
+
await closing(store, async () => {
|
|
405
|
+
const asc = {
|
|
406
|
+
partition: "p",
|
|
407
|
+
sortKey: void 0,
|
|
408
|
+
orderBy: { field: "v", direction: "asc" }
|
|
409
|
+
};
|
|
410
|
+
const first = await store.query(COLLECTION, params(asc, 2));
|
|
411
|
+
expect4(first.nextCursor).not.toBeNull();
|
|
412
|
+
const cursor = first.nextCursor;
|
|
413
|
+
const otherDirection = { ...asc, orderBy: { field: "v", direction: "desc" } };
|
|
414
|
+
const otherField = { ...asc, orderBy: { field: "nested.v", direction: "asc" } };
|
|
415
|
+
expect4(
|
|
416
|
+
await rejection(store.query(COLLECTION, params(otherDirection, 2, cursor)))
|
|
417
|
+
).toBeInstanceOf(Error);
|
|
418
|
+
expect4(
|
|
419
|
+
await rejection(store.query(COLLECTION, params(otherField, 2, cursor)))
|
|
420
|
+
).toBeInstanceOf(Error);
|
|
421
|
+
const next = await store.query(COLLECTION, params(asc, 2, cursor));
|
|
422
|
+
expect4(idsOf(next.items)).toEqual(reference(docs, asc).slice(2, 4));
|
|
423
|
+
});
|
|
424
|
+
},
|
|
425
|
+
SLOW
|
|
426
|
+
);
|
|
427
|
+
it4(
|
|
428
|
+
"a document put with a TTL is returned until it expires, then by no get, query or batchGet; one put without stays",
|
|
429
|
+
async () => {
|
|
430
|
+
const store = await make();
|
|
431
|
+
await closing(store, async () => {
|
|
432
|
+
const short = { id: "short", part: "p", nested: {}, label: "ttl" };
|
|
433
|
+
const long = { id: "long", part: "p", nested: {}, label: "no ttl" };
|
|
434
|
+
const putAt = Date.now();
|
|
435
|
+
await store.put(COLLECTION, short.id, short, { ttl: TTL_SECONDS });
|
|
436
|
+
await store.put(COLLECTION, long.id, long);
|
|
437
|
+
expect4(await store.get(COLLECTION, short.id)).toEqual(short);
|
|
438
|
+
if (opts.clock !== void 0) {
|
|
439
|
+
await opts.clock.advance(TTL_SECONDS * 1e3 - 1);
|
|
440
|
+
expect4(await store.get(COLLECTION, short.id)).toEqual(short);
|
|
441
|
+
await opts.clock.advance(2);
|
|
442
|
+
} else {
|
|
443
|
+
const deadline = putAt + TTL_SECONDS * 1e3 + 3e3;
|
|
444
|
+
while (await store.get(COLLECTION, short.id) !== null && Date.now() < deadline) {
|
|
445
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
446
|
+
}
|
|
447
|
+
expect4(Date.now() - putAt, "expired before its TTL").toBeGreaterThanOrEqual(
|
|
448
|
+
TTL_SECONDS * 1e3 - 100
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
expect4(await store.get(COLLECTION, short.id)).toBeNull();
|
|
452
|
+
const all = await store.query(COLLECTION, {
|
|
453
|
+
partitionKey: { field: "part", value: "*" },
|
|
454
|
+
limit: 100
|
|
455
|
+
});
|
|
456
|
+
expect4(idsOf(all.items)).toEqual([long.id]);
|
|
457
|
+
const batch = await store.batchGet(COLLECTION, [short.id, long.id]);
|
|
458
|
+
expect4([...batch.keys()]).toEqual([long.id]);
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
SLOW
|
|
462
|
+
);
|
|
463
|
+
it4("a conditional put applies when the field matches, and otherwise fails condition-failed and changes nothing", async () => {
|
|
464
|
+
const store = await make();
|
|
465
|
+
await closing(store, async () => {
|
|
466
|
+
const v1 = {
|
|
467
|
+
id: "c",
|
|
468
|
+
part: "p",
|
|
469
|
+
nested: {},
|
|
470
|
+
label: "v1",
|
|
471
|
+
version: 1
|
|
472
|
+
};
|
|
473
|
+
const v2 = { ...v1, label: "v2", version: 2 };
|
|
474
|
+
await store.put(COLLECTION, "c", v1);
|
|
475
|
+
await store.put(COLLECTION, "c", v2, { conditionKey: "version", conditionValue: 1 });
|
|
476
|
+
expect4(await store.get(COLLECTION, "c")).toEqual(v2);
|
|
477
|
+
const stale = await rejection(
|
|
478
|
+
store.put(
|
|
479
|
+
COLLECTION,
|
|
480
|
+
"c",
|
|
481
|
+
{ ...v1, label: "v3", version: 3 },
|
|
482
|
+
{ conditionKey: "version", conditionValue: 1 }
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
expect4(stale?.code).toBe("adapter.condition-failed");
|
|
486
|
+
expect4(await store.get(COLLECTION, "c")).toEqual(v2);
|
|
487
|
+
const absent = await rejection(
|
|
488
|
+
store.put(COLLECTION, "none", v1, { conditionKey: "version", conditionValue: 1 })
|
|
489
|
+
);
|
|
490
|
+
expect4(absent?.code).toBe("adapter.condition-failed");
|
|
491
|
+
expect4(await store.get(COLLECTION, "none")).toBeNull();
|
|
492
|
+
});
|
|
493
|
+
}, CASE_MS);
|
|
494
|
+
it4(
|
|
495
|
+
"batchPut stores every item and batchGet returns exactly the stored keys, equal to what was put",
|
|
496
|
+
async () => {
|
|
497
|
+
await fc.assert(
|
|
498
|
+
fc.asyncProperty(docsArb(1), async (generated) => {
|
|
499
|
+
const store = await make();
|
|
500
|
+
await closing(store, async () => {
|
|
501
|
+
const docs = generated.map(stored);
|
|
502
|
+
await store.batchPut(COLLECTION, new Map(docs.map((d) => [d.id, d])));
|
|
503
|
+
const asked = [...docs.map((d) => d.id), "contract-absent"];
|
|
504
|
+
const got = await store.batchGet(COLLECTION, asked);
|
|
505
|
+
expect4(got.size).toBe(docs.length);
|
|
506
|
+
for (const doc of docs) {
|
|
507
|
+
expect4(got.get(doc.id)).toEqual(doc);
|
|
508
|
+
expect4(await store.get(COLLECTION, doc.id)).toEqual(doc);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
}),
|
|
512
|
+
{ numRuns: 15 }
|
|
513
|
+
);
|
|
514
|
+
},
|
|
515
|
+
SLOW
|
|
516
|
+
);
|
|
517
|
+
it4("collections are isolated for get, query and delete", async () => {
|
|
518
|
+
const store = await make();
|
|
519
|
+
await closing(store, async () => {
|
|
520
|
+
const a = { id: "same", part: "p", nested: {}, label: "A" };
|
|
521
|
+
const b = { ...a, label: "B" };
|
|
522
|
+
const onlyA = { ...a, id: "only-a" };
|
|
523
|
+
await store.put("contract-col-a", a.id, a);
|
|
524
|
+
await store.put("contract-col-a", onlyA.id, onlyA);
|
|
525
|
+
await store.put("contract-col-b", b.id, b);
|
|
526
|
+
expect4(await store.get("contract-col-a", "same")).toEqual(a);
|
|
527
|
+
expect4(await store.get("contract-col-b", "same")).toEqual(b);
|
|
528
|
+
const inB = await store.query("contract-col-b", {
|
|
529
|
+
partitionKey: { field: "part", value: "*" },
|
|
530
|
+
limit: 100
|
|
531
|
+
});
|
|
532
|
+
expect4(idsOf(inB.items)).toEqual([b.id]);
|
|
533
|
+
await store.delete("contract-col-a", "same");
|
|
534
|
+
expect4(await store.get("contract-col-b", "same")).toEqual(b);
|
|
535
|
+
expect4(await store.get("contract-col-a", "only-a")).toEqual(onlyA);
|
|
536
|
+
});
|
|
537
|
+
}, CASE_MS);
|
|
538
|
+
if (opts.isolation !== void 0) {
|
|
539
|
+
const isolation = opts.isolation;
|
|
540
|
+
it4("two instances with different prefixes never see each other\u2019s documents", async () => {
|
|
541
|
+
const [one, two] = await isolation();
|
|
542
|
+
try {
|
|
543
|
+
const mine = { id: "shared-key", part: "p", nested: {}, label: "one" };
|
|
544
|
+
const theirs = { ...mine, label: "two" };
|
|
545
|
+
await one.put(COLLECTION, mine.id, mine);
|
|
546
|
+
await one.put(COLLECTION, "one-only", { ...mine, id: "one-only" });
|
|
547
|
+
await two.put(COLLECTION, theirs.id, theirs);
|
|
548
|
+
expect4(await one.get(COLLECTION, mine.id)).toEqual(mine);
|
|
549
|
+
expect4(await two.get(COLLECTION, theirs.id)).toEqual(theirs);
|
|
550
|
+
expect4(await two.get(COLLECTION, "one-only")).toBeNull();
|
|
551
|
+
const inTwo = await two.query(COLLECTION, {
|
|
552
|
+
partitionKey: { field: "part", value: "*" },
|
|
553
|
+
limit: 100
|
|
554
|
+
});
|
|
555
|
+
expect4(idsOf(inTwo.items)).toEqual([theirs.id]);
|
|
556
|
+
const batch = await two.batchGet(COLLECTION, [mine.id, "one-only"]);
|
|
557
|
+
expect4([...batch.values()]).toEqual([theirs]);
|
|
558
|
+
await one.delete(COLLECTION, mine.id);
|
|
559
|
+
expect4(await two.get(COLLECTION, theirs.id)).toEqual(theirs);
|
|
560
|
+
} finally {
|
|
561
|
+
await one.close();
|
|
562
|
+
await two.close();
|
|
563
|
+
}
|
|
564
|
+
}, CASE_MS);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/document-store-replace.ts
|
|
570
|
+
import fc2 from "fast-check";
|
|
571
|
+
import { expect as expect5, it as it5 } from "vitest";
|
|
572
|
+
var SLOW2 = 6e4;
|
|
573
|
+
var KEYS = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
|
574
|
+
var macrotask = () => new Promise((resolve) => setImmediate(resolve));
|
|
575
|
+
async function* entriesOf(docs, pause = false) {
|
|
576
|
+
for (const doc of docs) {
|
|
577
|
+
if (pause) await macrotask();
|
|
578
|
+
yield [doc.id, doc];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function snapshot(store, collection) {
|
|
582
|
+
const all = await store.query(collection, { partitionKey: { field: "part", value: "*" } });
|
|
583
|
+
return all.items.map((d) => `${d.id}:${d.v}`);
|
|
584
|
+
}
|
|
585
|
+
var shape = (docs) => [...docs].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((d) => `${d.id}:${d.v}`);
|
|
586
|
+
async function putAll(store, collection, docs) {
|
|
587
|
+
for (const doc of docs) await store.put(collection, doc.id, doc);
|
|
588
|
+
}
|
|
589
|
+
var docsFrom = (keys, v) => keys.map((id) => ({ id, part: "p", v }));
|
|
590
|
+
function documentStoreReplaceCases(make, opts) {
|
|
591
|
+
it5(
|
|
592
|
+
"replaceCollection leaves exactly the new set, whatever the old and new sets: no stale key of the old set survives, and another collection is untouched (property)",
|
|
593
|
+
async () => {
|
|
594
|
+
await using(make, async (store) => {
|
|
595
|
+
let run = 0;
|
|
596
|
+
await fc2.assert(
|
|
597
|
+
fc2.asyncProperty(
|
|
598
|
+
fc2.uniqueArray(fc2.constantFrom(...KEYS)),
|
|
599
|
+
fc2.uniqueArray(fc2.constantFrom(...KEYS)),
|
|
600
|
+
async (oldKeys, newKeys) => {
|
|
601
|
+
run += 1;
|
|
602
|
+
const collection = `contract-replace-${run}`;
|
|
603
|
+
const decoy = `contract-replace-decoy-${run}`;
|
|
604
|
+
await putAll(store, collection, docsFrom(oldKeys, 1));
|
|
605
|
+
await putAll(store, decoy, docsFrom(oldKeys, 3));
|
|
606
|
+
const next = docsFrom(newKeys, 2);
|
|
607
|
+
await store.replaceCollection(collection, entriesOf(next));
|
|
608
|
+
expect5(await snapshot(store, collection)).toEqual(shape(next));
|
|
609
|
+
for (const key of oldKeys.filter((k) => !newKeys.includes(k))) {
|
|
610
|
+
expect5(await store.get(collection, key), `stale key ${key}`).toBeNull();
|
|
611
|
+
}
|
|
612
|
+
expect5(await snapshot(store, decoy)).toEqual(shape(docsFrom(oldKeys, 3)));
|
|
613
|
+
}
|
|
614
|
+
),
|
|
615
|
+
{ numRuns: 25 }
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
},
|
|
619
|
+
SLOW2
|
|
620
|
+
);
|
|
621
|
+
it5(
|
|
622
|
+
"replaceCollection is atomic to a concurrent reader: polling while the replace runs sees only the full old set or the full new set, never a partial set",
|
|
623
|
+
async () => {
|
|
624
|
+
await using(make, async (store) => {
|
|
625
|
+
const old = docsFrom(["a", "b", "c", "d", "e", "f"], 1);
|
|
626
|
+
const next = docsFrom(["d", "e", "f", "g", "h", "i"], 2);
|
|
627
|
+
await putAll(store, "contract-replace-reader", old);
|
|
628
|
+
const allowed = /* @__PURE__ */ new Set([JSON.stringify(shape(old)), JSON.stringify(shape(next))]);
|
|
629
|
+
let done = false;
|
|
630
|
+
const seen = [];
|
|
631
|
+
const reader = (async () => {
|
|
632
|
+
while (!done) {
|
|
633
|
+
seen.push(await snapshot(store, "contract-replace-reader"));
|
|
634
|
+
await macrotask();
|
|
635
|
+
}
|
|
636
|
+
})();
|
|
637
|
+
await store.replaceCollection("contract-replace-reader", entriesOf(next, true)).finally(() => {
|
|
638
|
+
done = true;
|
|
639
|
+
});
|
|
640
|
+
await reader;
|
|
641
|
+
expect5(seen.length, "the reader read while the replace ran").toBeGreaterThan(0);
|
|
642
|
+
for (const observed of seen) {
|
|
643
|
+
expect5(
|
|
644
|
+
allowed.has(JSON.stringify(observed)),
|
|
645
|
+
`a partial set: ${observed.join(" ")}`
|
|
646
|
+
).toBe(true);
|
|
647
|
+
}
|
|
648
|
+
expect5(await snapshot(store, "contract-replace-reader")).toEqual(shape(next));
|
|
649
|
+
});
|
|
650
|
+
},
|
|
651
|
+
CASE_MS
|
|
652
|
+
);
|
|
653
|
+
it5(
|
|
654
|
+
"replaceCollection whose source throws part-way rejects with the source\u2019s error and leaves the old set intact",
|
|
655
|
+
async () => {
|
|
656
|
+
await using(make, async (store) => {
|
|
657
|
+
const old = docsFrom(["a", "b", "c"], 1);
|
|
658
|
+
await putAll(store, "contract-replace-failing", old);
|
|
659
|
+
async function* failing() {
|
|
660
|
+
yield ["a", { id: "a", part: "p", v: 2 }];
|
|
661
|
+
yield ["z", { id: "z", part: "p", v: 2 }];
|
|
662
|
+
throw new Error("contract: the source failed");
|
|
663
|
+
}
|
|
664
|
+
const failed = await store.replaceCollection("contract-replace-failing", failing()).then(
|
|
665
|
+
() => null,
|
|
666
|
+
(error) => error
|
|
667
|
+
);
|
|
668
|
+
expect5(String(failed)).toContain("contract: the source failed");
|
|
669
|
+
expect5(await snapshot(store, "contract-replace-failing")).toEqual(shape(old));
|
|
670
|
+
});
|
|
671
|
+
},
|
|
672
|
+
CASE_MS
|
|
673
|
+
);
|
|
674
|
+
it5(
|
|
675
|
+
"replaceCollection with an empty source empties the collection",
|
|
676
|
+
async () => {
|
|
677
|
+
await using(make, async (store) => {
|
|
678
|
+
await putAll(store, "contract-replace-empty", docsFrom(["a", "b"], 1));
|
|
679
|
+
await store.replaceCollection("contract-replace-empty", entriesOf([]));
|
|
680
|
+
expect5(await snapshot(store, "contract-replace-empty")).toEqual([]);
|
|
681
|
+
expect5(await store.get("contract-replace-empty", "a")).toBeNull();
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
CASE_MS
|
|
685
|
+
);
|
|
686
|
+
const clock = opts.clock;
|
|
687
|
+
if (clock !== void 0) {
|
|
688
|
+
it5(
|
|
689
|
+
"replaceCollection writes documents without a TTL: a key whose old document had one is still there after that TTL passes",
|
|
690
|
+
async () => {
|
|
691
|
+
await using(make, async (store) => {
|
|
692
|
+
await store.put("contract-replace-ttl", "a", { id: "a", part: "p", v: 1 }, { ttl: 60 });
|
|
693
|
+
await store.replaceCollection("contract-replace-ttl", entriesOf(docsFrom(["a", "b"], 2)));
|
|
694
|
+
await clock.advance(61e3);
|
|
695
|
+
expect5(await snapshot(store, "contract-replace-ttl")).toEqual(["a:2", "b:2"]);
|
|
696
|
+
});
|
|
697
|
+
},
|
|
698
|
+
CASE_MS
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/document-store.ts
|
|
704
|
+
var SLOW3 = 3e4;
|
|
705
|
+
async function using2(make, body) {
|
|
706
|
+
const store = await make();
|
|
707
|
+
try {
|
|
708
|
+
await body(store);
|
|
709
|
+
} finally {
|
|
710
|
+
await store.close();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async function rejection2(promise) {
|
|
714
|
+
return promise.then(
|
|
715
|
+
() => null,
|
|
716
|
+
(error) => error ?? new Error("rejected with nothing")
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
async function putAll2(store, collection, docs) {
|
|
720
|
+
for (const doc of docs) await store.put(collection, doc.id, doc);
|
|
721
|
+
}
|
|
722
|
+
var ids = (docs) => docs.map((d) => d.id);
|
|
723
|
+
async function walk2(store, collection, params2, between) {
|
|
724
|
+
const pages = [];
|
|
725
|
+
let cursor;
|
|
726
|
+
do {
|
|
727
|
+
const page = await store.query(collection, {
|
|
728
|
+
...params2,
|
|
729
|
+
...cursor !== void 0 && { cursor }
|
|
730
|
+
});
|
|
731
|
+
pages.push(page.items);
|
|
732
|
+
cursor = page.nextCursor ?? void 0;
|
|
733
|
+
if (between !== void 0) await between(pages.length);
|
|
734
|
+
} while (cursor !== void 0 && pages.length < 100);
|
|
735
|
+
return pages;
|
|
736
|
+
}
|
|
737
|
+
function runDocumentStoreContract(name, make, opts = {}) {
|
|
738
|
+
describe5(`DocumentStoreAdapter contract: ${name}`, () => {
|
|
739
|
+
it6(
|
|
740
|
+
"get is null for a missing key; put then get round-trips; a second put overwrites",
|
|
741
|
+
async () => {
|
|
742
|
+
await using2(make, async (store) => {
|
|
743
|
+
expect6(await store.get("col", "missing")).toBeNull();
|
|
744
|
+
await store.put("col", "k1", { name: "test", value: 42, nested: { list: [1, "two"] } });
|
|
745
|
+
expect6(await store.get("col", "k1")).toEqual({
|
|
746
|
+
name: "test",
|
|
747
|
+
value: 42,
|
|
748
|
+
nested: { list: [1, "two"] }
|
|
749
|
+
});
|
|
750
|
+
await store.put("col", "k1", { v: 2 });
|
|
751
|
+
expect6(await store.get("col", "k1")).toEqual({ v: 2 });
|
|
752
|
+
});
|
|
753
|
+
},
|
|
754
|
+
CASE_MS
|
|
755
|
+
);
|
|
756
|
+
it6(
|
|
757
|
+
"delete removes the document, and deleting a missing key does not throw",
|
|
758
|
+
async () => {
|
|
759
|
+
await using2(make, async (store) => {
|
|
760
|
+
await store.put("col", "k1", { v: 1 });
|
|
761
|
+
await store.delete("col", "k1");
|
|
762
|
+
expect6(await store.get("col", "k1")).toBeNull();
|
|
763
|
+
await expect6(store.delete("col", "nope")).resolves.toBeUndefined();
|
|
764
|
+
});
|
|
765
|
+
},
|
|
766
|
+
CASE_MS
|
|
767
|
+
);
|
|
768
|
+
it6(
|
|
769
|
+
"query narrows by partition, and '*' returns the whole collection",
|
|
770
|
+
async () => {
|
|
771
|
+
await using2(make, async (store) => {
|
|
772
|
+
await putAll2(store, "col", [
|
|
773
|
+
{ id: "a", part: "p" },
|
|
774
|
+
{ id: "b", part: "q" },
|
|
775
|
+
{ id: "c", part: "p" }
|
|
776
|
+
]);
|
|
777
|
+
const p = await store.query("col", { partitionKey: { field: "part", value: "p" } });
|
|
778
|
+
expect6(ids(p.items).sort()).toEqual(["a", "c"]);
|
|
779
|
+
const all = await store.query("col", { partitionKey: { field: "part", value: "*" } });
|
|
780
|
+
expect6(ids(all.items).sort()).toEqual(["a", "b", "c"]);
|
|
781
|
+
expect6(all.nextCursor).toBeNull();
|
|
782
|
+
});
|
|
783
|
+
},
|
|
784
|
+
CASE_MS
|
|
785
|
+
);
|
|
786
|
+
it6(
|
|
787
|
+
"sortKey eq is strict (1 is not '1', 'a' is not 'A'); gt, lt and between follow compareDocumentValues",
|
|
788
|
+
async () => {
|
|
789
|
+
await using2(make, async (store) => {
|
|
790
|
+
await putAll2(store, "col", [
|
|
791
|
+
{ id: "n1", part: "p", v: 1 },
|
|
792
|
+
{ id: "s1", part: "p", v: "1" },
|
|
793
|
+
{ id: "n5", part: "p", v: 5 },
|
|
794
|
+
{ id: "sa", part: "p", v: "a" },
|
|
795
|
+
{ id: "sA", part: "p", v: "A" },
|
|
796
|
+
{ id: "nul", part: "p", v: null }
|
|
797
|
+
]);
|
|
798
|
+
const where = async (sortKey) => ids(
|
|
799
|
+
(await store.query("col", {
|
|
800
|
+
partitionKey: { field: "part", value: "p" },
|
|
801
|
+
sortKey
|
|
802
|
+
})).items
|
|
803
|
+
).sort();
|
|
804
|
+
expect6(await where({ field: "v", operator: "eq", value: 1 })).toEqual(["n1"]);
|
|
805
|
+
expect6(await where({ field: "v", operator: "eq", value: "a" })).toEqual(["sa"]);
|
|
806
|
+
expect6(await where({ field: "v", operator: "lt", value: "0" })).toEqual(["n1", "n5"]);
|
|
807
|
+
expect6(await where({ field: "v", operator: "gt", value: 5 })).toEqual(
|
|
808
|
+
["nul", "s1", "sA", "sa"].sort()
|
|
809
|
+
);
|
|
810
|
+
expect6(await where({ field: "v", operator: "between", value: [2, "a"] })).toEqual(
|
|
811
|
+
["n5", "s1", "sA", "sa"].sort()
|
|
812
|
+
);
|
|
813
|
+
});
|
|
814
|
+
},
|
|
815
|
+
CASE_MS
|
|
816
|
+
);
|
|
817
|
+
it6(
|
|
818
|
+
"orderBy orders by value with the collator, nulls and missing last, ties by key, on a dotted path too",
|
|
819
|
+
async () => {
|
|
820
|
+
await using2(make, async (store) => {
|
|
821
|
+
await putAll2(store, "col", [
|
|
822
|
+
{ id: "k4", part: "p", nested: { v: "b" } },
|
|
823
|
+
{ id: "k1", part: "p", nested: { v: "B" } },
|
|
824
|
+
{ id: "k2", part: "p", nested: { v: "a" } },
|
|
825
|
+
{ id: "k3", part: "p", nested: {} },
|
|
826
|
+
{ id: "k5", part: "p", nested: { v: 10 } },
|
|
827
|
+
{ id: "k6", part: "p", nested: { v: 9 } },
|
|
828
|
+
{ id: "k0", part: "p", nested: { v: null } }
|
|
829
|
+
]);
|
|
830
|
+
const asc = await store.query("col", {
|
|
831
|
+
partitionKey: { field: "part", value: "p" },
|
|
832
|
+
orderBy: { field: "nested.v", direction: "asc", nulls: "last" }
|
|
833
|
+
});
|
|
834
|
+
expect6(ids(asc.items)).toEqual(["k6", "k5", "k2", "k1", "k4", "k0", "k3"]);
|
|
835
|
+
const desc = await store.query("col", {
|
|
836
|
+
partitionKey: { field: "part", value: "p" },
|
|
837
|
+
orderBy: { field: "nested.v", direction: "desc", nulls: "last" }
|
|
838
|
+
});
|
|
839
|
+
expect6(ids(desc.items)).toEqual(["k1", "k4", "k2", "k5", "k6", "k0", "k3"]);
|
|
840
|
+
});
|
|
841
|
+
},
|
|
842
|
+
CASE_MS
|
|
843
|
+
);
|
|
844
|
+
it6(
|
|
845
|
+
"a limit and cursor walk returns every match exactly once, also when a document is deleted mid-walk",
|
|
846
|
+
async () => {
|
|
847
|
+
await using2(make, async (store) => {
|
|
848
|
+
const docs = Array.from({ length: 9 }, (_, i) => ({ id: `k${i}`, part: "p", v: i % 3 }));
|
|
849
|
+
await putAll2(store, "col", docs);
|
|
850
|
+
for (const orderBy of [void 0, { field: "v", direction: "asc" }]) {
|
|
851
|
+
const params2 = {
|
|
852
|
+
partitionKey: { field: "part", value: "p" },
|
|
853
|
+
limit: 2,
|
|
854
|
+
...orderBy !== void 0 && { orderBy }
|
|
855
|
+
};
|
|
856
|
+
const whole = (await walk2(store, "col", params2)).flat();
|
|
857
|
+
expect6(ids(whole).sort()).toEqual(ids(docs).sort());
|
|
858
|
+
const anchor = whole[1];
|
|
859
|
+
const walked = (await walk2(store, "col", params2, async (page) => {
|
|
860
|
+
if (page === 1) await store.delete("col", anchor.id);
|
|
861
|
+
})).flat();
|
|
862
|
+
expect6(new Set(ids(walked)).size).toBe(walked.length);
|
|
863
|
+
expect6(ids(walked).sort()).toEqual(ids(docs).sort());
|
|
864
|
+
await store.put("col", anchor.id, anchor);
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
},
|
|
868
|
+
SLOW3
|
|
869
|
+
);
|
|
870
|
+
it6(
|
|
871
|
+
"a cursor issued under one orderBy is refused under another, and continues under its own",
|
|
872
|
+
async () => {
|
|
873
|
+
await using2(make, async (store) => {
|
|
874
|
+
await putAll2(
|
|
875
|
+
store,
|
|
876
|
+
"col",
|
|
877
|
+
Array.from({ length: 5 }, (_, i) => ({ id: `k${i}`, part: "p", v: i }))
|
|
878
|
+
);
|
|
879
|
+
const base = {
|
|
880
|
+
partitionKey: { field: "part", value: "p" },
|
|
881
|
+
orderBy: { field: "v", direction: "asc" },
|
|
882
|
+
limit: 2
|
|
883
|
+
};
|
|
884
|
+
const first = await store.query("col", base);
|
|
885
|
+
expect6(first.nextCursor).not.toBeNull();
|
|
886
|
+
const cursor = first.nextCursor;
|
|
887
|
+
const other = await rejection2(
|
|
888
|
+
store.query("col", { ...base, orderBy: { field: "v", direction: "desc" }, cursor })
|
|
889
|
+
);
|
|
890
|
+
expect6(other).toBeInstanceOf(Error);
|
|
891
|
+
const next = await store.query("col", { ...base, cursor });
|
|
892
|
+
expect6(ids(next.items)).toEqual(["k2", "k3"]);
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
CASE_MS
|
|
896
|
+
);
|
|
897
|
+
it6(
|
|
898
|
+
"a document put with a ttl (seconds) is gone from get, query and batchGet once it expires",
|
|
899
|
+
async () => {
|
|
900
|
+
await using2(make, async (store) => {
|
|
901
|
+
await store.put("col", "short", { id: "short", part: "p" }, { ttl: 1 });
|
|
902
|
+
await store.put("col", "kept", { id: "kept", part: "p" });
|
|
903
|
+
expect6(await store.get("col", "short")).toEqual({ id: "short", part: "p" });
|
|
904
|
+
if (opts.clock !== void 0) {
|
|
905
|
+
await opts.clock.advance(1001);
|
|
906
|
+
} else {
|
|
907
|
+
const deadline = Date.now() + 4e3;
|
|
908
|
+
while (await store.get("col", "short") !== null && Date.now() < deadline) {
|
|
909
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
expect6(await store.get("col", "short")).toBeNull();
|
|
913
|
+
const all = await store.query("col", { partitionKey: { field: "part", value: "*" } });
|
|
914
|
+
expect6(ids(all.items)).toEqual(["kept"]);
|
|
915
|
+
expect6([...(await store.batchGet("col", ["short", "kept"])).keys()]).toEqual(["kept"]);
|
|
916
|
+
});
|
|
917
|
+
},
|
|
918
|
+
SLOW3
|
|
919
|
+
);
|
|
920
|
+
it6(
|
|
921
|
+
"a conditional put applies when the field is strictly equal, and otherwise fails condition-failed",
|
|
922
|
+
async () => {
|
|
923
|
+
await using2(make, async (store) => {
|
|
924
|
+
await store.put("col", "c", { version: 1, label: "v1" });
|
|
925
|
+
await store.put(
|
|
926
|
+
"col",
|
|
927
|
+
"c",
|
|
928
|
+
{ version: 2, label: "v2" },
|
|
929
|
+
{ conditionKey: "version", conditionValue: 1 }
|
|
930
|
+
);
|
|
931
|
+
expect6(await store.get("col", "c")).toEqual({ version: 2, label: "v2" });
|
|
932
|
+
for (const conditionValue of [1, "2"]) {
|
|
933
|
+
const failed = await rejection2(
|
|
934
|
+
store.put("col", "c", { version: 3 }, { conditionKey: "version", conditionValue })
|
|
935
|
+
);
|
|
936
|
+
expect6(failed?.code).toBe("adapter.condition-failed");
|
|
937
|
+
}
|
|
938
|
+
const absent = await rejection2(
|
|
939
|
+
store.put(
|
|
940
|
+
"col",
|
|
941
|
+
"none",
|
|
942
|
+
{ version: 1 },
|
|
943
|
+
{ conditionKey: "version", conditionValue: 1 }
|
|
944
|
+
)
|
|
945
|
+
);
|
|
946
|
+
expect6(absent?.code).toBe("adapter.condition-failed");
|
|
947
|
+
expect6(await store.get("col", "c")).toEqual({ version: 2, label: "v2" });
|
|
948
|
+
expect6(await store.get("col", "none")).toBeNull();
|
|
949
|
+
});
|
|
950
|
+
},
|
|
951
|
+
CASE_MS
|
|
952
|
+
);
|
|
953
|
+
it6(
|
|
954
|
+
"batchPut stores every item; batchGet returns exactly the stored keys",
|
|
955
|
+
async () => {
|
|
956
|
+
await using2(make, async (store) => {
|
|
957
|
+
await store.batchPut(
|
|
958
|
+
"col",
|
|
959
|
+
/* @__PURE__ */ new Map([
|
|
960
|
+
["x", { n: 10 }],
|
|
961
|
+
["y", { n: 20 }]
|
|
962
|
+
])
|
|
963
|
+
);
|
|
964
|
+
const got = await store.batchGet("col", ["x", "y", "missing"]);
|
|
965
|
+
expect6([...got.keys()].sort()).toEqual(["x", "y"]);
|
|
966
|
+
expect6(got.get("x")).toEqual({ n: 10 });
|
|
967
|
+
expect6(await store.get("col", "y")).toEqual({ n: 20 });
|
|
968
|
+
expect6((await store.batchGet("col", [])).size).toBe(0);
|
|
969
|
+
});
|
|
970
|
+
},
|
|
971
|
+
CASE_MS
|
|
972
|
+
);
|
|
973
|
+
it6(
|
|
974
|
+
"collections are isolated for get, query and delete",
|
|
975
|
+
async () => {
|
|
976
|
+
await using2(make, async (store) => {
|
|
977
|
+
await store.put("colA", "key", { id: "key", part: "p", from: "A" });
|
|
978
|
+
await store.put("colB", "key", { id: "key", part: "p", from: "B" });
|
|
979
|
+
expect6(await store.get("colA", "key")).toEqual({ id: "key", part: "p", from: "A" });
|
|
980
|
+
const inB = await store.query("colB", { partitionKey: { field: "part", value: "*" } });
|
|
981
|
+
expect6(inB.items).toEqual([{ id: "key", part: "p", from: "B" }]);
|
|
982
|
+
await store.delete("colA", "key");
|
|
983
|
+
expect6(await store.get("colB", "key")).toEqual({ id: "key", part: "p", from: "B" });
|
|
984
|
+
});
|
|
985
|
+
},
|
|
986
|
+
CASE_MS
|
|
987
|
+
);
|
|
988
|
+
const isolation = opts.isolation;
|
|
989
|
+
if (isolation !== void 0) {
|
|
990
|
+
it6(
|
|
991
|
+
"two stores under different prefixes never see each other\u2019s documents",
|
|
992
|
+
async () => {
|
|
993
|
+
const [one, two] = await isolation();
|
|
994
|
+
try {
|
|
995
|
+
await one.put("col", "shared", { id: "shared", part: "p", owner: "one" });
|
|
996
|
+
await one.put("col", "one-only", { id: "one-only", part: "p", owner: "one" });
|
|
997
|
+
await two.put("col", "shared", { id: "shared", part: "p", owner: "two" });
|
|
998
|
+
expect6(await one.get("col", "shared")).toEqual({
|
|
999
|
+
id: "shared",
|
|
1000
|
+
part: "p",
|
|
1001
|
+
owner: "one"
|
|
1002
|
+
});
|
|
1003
|
+
expect6(await two.get("col", "one-only")).toBeNull();
|
|
1004
|
+
const inTwo = await two.query("col", { partitionKey: { field: "part", value: "*" } });
|
|
1005
|
+
expect6(inTwo.items).toEqual([{ id: "shared", part: "p", owner: "two" }]);
|
|
1006
|
+
await one.delete("col", "shared");
|
|
1007
|
+
expect6(await two.get("col", "shared")).toEqual({
|
|
1008
|
+
id: "shared",
|
|
1009
|
+
part: "p",
|
|
1010
|
+
owner: "two"
|
|
1011
|
+
});
|
|
1012
|
+
} finally {
|
|
1013
|
+
await one.close();
|
|
1014
|
+
await two.close();
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
CASE_MS
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
documentStoreModelCases(make, opts);
|
|
1021
|
+
documentStoreReplaceCases(make, opts);
|
|
1022
|
+
healthAndDowntime(make, opts.unreachable, "get()", (store) => store.get("contract-down", "k"));
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/email.ts
|
|
1027
|
+
import { describe as describe6, expect as expect7, it as it7 } from "vitest";
|
|
1028
|
+
var MAIL = {
|
|
1029
|
+
template: "order-confirmation",
|
|
1030
|
+
to: "test@example.com",
|
|
1031
|
+
vars: { orderNumber: "ORD-001" }
|
|
1032
|
+
};
|
|
1033
|
+
function runEmailContract(name, build, options = {}) {
|
|
1034
|
+
describe6(`EmailAdapter contract: ${name}`, () => {
|
|
1035
|
+
it7(
|
|
1036
|
+
"sendTransactional resolves with accepted or messageId",
|
|
1037
|
+
async () => {
|
|
1038
|
+
const adapter = await build();
|
|
1039
|
+
const result = await adapter.sendTransactional(MAIL);
|
|
1040
|
+
expect7(typeof result.accepted).toBe("boolean");
|
|
1041
|
+
},
|
|
1042
|
+
CASE_MS
|
|
1043
|
+
);
|
|
1044
|
+
it7(
|
|
1045
|
+
"syncCustomerToList resolves without throwing",
|
|
1046
|
+
async () => {
|
|
1047
|
+
const adapter = await build();
|
|
1048
|
+
await expect7(
|
|
1049
|
+
adapter.syncCustomerToList({
|
|
1050
|
+
customerId: "cust-1",
|
|
1051
|
+
orderCount: 0,
|
|
1052
|
+
totalSpentMinor: 0,
|
|
1053
|
+
segment: "new",
|
|
1054
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1055
|
+
})
|
|
1056
|
+
).resolves.not.toThrow();
|
|
1057
|
+
},
|
|
1058
|
+
CASE_MS
|
|
1059
|
+
);
|
|
1060
|
+
it7(
|
|
1061
|
+
"removeCustomerFromList resolves without throwing",
|
|
1062
|
+
async () => {
|
|
1063
|
+
const adapter = await build();
|
|
1064
|
+
await expect7(adapter.removeCustomerFromList("cust-1")).resolves.not.toThrow();
|
|
1065
|
+
},
|
|
1066
|
+
CASE_MS
|
|
1067
|
+
);
|
|
1068
|
+
healthAndDowntime(
|
|
1069
|
+
build,
|
|
1070
|
+
options.unreachable,
|
|
1071
|
+
"sendTransactional()",
|
|
1072
|
+
(adapter) => adapter.sendTransactional(MAIL)
|
|
1073
|
+
);
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/event-bus.ts
|
|
1078
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1079
|
+
import { describe as describe8, expect as expect9, it as it9 } from "vitest";
|
|
1080
|
+
|
|
1081
|
+
// src/event-bus-delivery.ts
|
|
1082
|
+
import { randomUUID } from "crypto";
|
|
1083
|
+
import fc3 from "fast-check";
|
|
1084
|
+
import { describe as describe7, expect as expect8, it as it8 } from "vitest";
|
|
1085
|
+
var SLOW4 = 6e4;
|
|
1086
|
+
function event(type, payload = {}) {
|
|
1087
|
+
return {
|
|
1088
|
+
id: `evt-${randomUUID()}`,
|
|
1089
|
+
type,
|
|
1090
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1091
|
+
source: "contract-contract",
|
|
1092
|
+
correlationId: `corr-${randomUUID()}`,
|
|
1093
|
+
payload,
|
|
1094
|
+
metadata: { storeId: "contract", environment: "development", version: "0.0.0" }
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1098
|
+
async function until(check, label, ms) {
|
|
1099
|
+
const deadline = Date.now() + ms;
|
|
1100
|
+
while (!check()) {
|
|
1101
|
+
if (Date.now() > deadline) throw new Error(`timed out after ${ms} ms waiting for ${label}`);
|
|
1102
|
+
await delay(10);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
function gate() {
|
|
1106
|
+
let open;
|
|
1107
|
+
const wait = new Promise((r) => {
|
|
1108
|
+
open = r;
|
|
1109
|
+
});
|
|
1110
|
+
return { wait, open };
|
|
1111
|
+
}
|
|
1112
|
+
async function closingAll(buses, body) {
|
|
1113
|
+
try {
|
|
1114
|
+
await body();
|
|
1115
|
+
} finally {
|
|
1116
|
+
for (const bus of buses) await bus.close();
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
var TYPES = ["order.created.v1", "order.paid.v1", "cart.updated.v1"];
|
|
1120
|
+
function eventBusDeliveryCases(make, opts) {
|
|
1121
|
+
describe7("keeps the delivery contract", () => {
|
|
1122
|
+
it8(
|
|
1123
|
+
"publish resolves only after every matching handler has finished: a 200 ms handler, named and unnamed, is done first",
|
|
1124
|
+
async () => {
|
|
1125
|
+
const bus = await make();
|
|
1126
|
+
await closingAll([bus], async () => {
|
|
1127
|
+
const finished = [];
|
|
1128
|
+
await bus.subscribe("contract.slow.v1", async () => {
|
|
1129
|
+
await delay(200);
|
|
1130
|
+
finished.push("unnamed");
|
|
1131
|
+
});
|
|
1132
|
+
await bus.subscribe(
|
|
1133
|
+
"contract.slow.v1",
|
|
1134
|
+
async () => {
|
|
1135
|
+
await delay(200);
|
|
1136
|
+
finished.push("named");
|
|
1137
|
+
},
|
|
1138
|
+
{ name: "contract-slow" }
|
|
1139
|
+
);
|
|
1140
|
+
const startedAt = Date.now();
|
|
1141
|
+
await bus.publish(event("contract.slow.v1"));
|
|
1142
|
+
const elapsed = Date.now() - startedAt;
|
|
1143
|
+
expect8([...finished].sort()).toEqual(["named", "unnamed"]);
|
|
1144
|
+
expect8(elapsed).toBeGreaterThanOrEqual(190);
|
|
1145
|
+
});
|
|
1146
|
+
},
|
|
1147
|
+
SLOW4
|
|
1148
|
+
);
|
|
1149
|
+
it8(
|
|
1150
|
+
"a throwing handler leaves publish resolved and every other matching handler called, event after event",
|
|
1151
|
+
async () => {
|
|
1152
|
+
const bus = await make();
|
|
1153
|
+
await closingAll([bus], async () => {
|
|
1154
|
+
const received = [];
|
|
1155
|
+
await bus.subscribe(
|
|
1156
|
+
"contract.boom.v1",
|
|
1157
|
+
async () => {
|
|
1158
|
+
throw new Error("contract handler failure");
|
|
1159
|
+
},
|
|
1160
|
+
{ name: "contract-throws" }
|
|
1161
|
+
);
|
|
1162
|
+
await bus.subscribe("contract.boom.v1", async (e) => {
|
|
1163
|
+
received.push(e.id);
|
|
1164
|
+
});
|
|
1165
|
+
const events = [event("contract.boom.v1"), event("contract.boom.v1"), event("contract.boom.v1")];
|
|
1166
|
+
for (const e of events) await expect8(bus.publish(e)).resolves.toBeUndefined();
|
|
1167
|
+
expect8(received).toEqual(events.map((e) => e.id));
|
|
1168
|
+
});
|
|
1169
|
+
},
|
|
1170
|
+
SLOW4
|
|
1171
|
+
);
|
|
1172
|
+
it8(
|
|
1173
|
+
"replay reaches derived subscriptions only, in order; a later publish still reaches every subscription",
|
|
1174
|
+
async () => {
|
|
1175
|
+
const bus = await make();
|
|
1176
|
+
await closingAll([bus], async () => {
|
|
1177
|
+
const derived = [];
|
|
1178
|
+
const sideNamed = [];
|
|
1179
|
+
const sideUnnamed = [];
|
|
1180
|
+
await bus.subscribe("contract.replay.v1", async (e) => void derived.push(e.id), {
|
|
1181
|
+
name: "contract-projection",
|
|
1182
|
+
derived: true
|
|
1183
|
+
});
|
|
1184
|
+
await bus.subscribe("contract.replay.v1", async (e) => void sideNamed.push(e.id), {
|
|
1185
|
+
name: "contract-side-effect"
|
|
1186
|
+
});
|
|
1187
|
+
await bus.subscribe("contract.replay.v1", async (e) => void sideUnnamed.push(e.id));
|
|
1188
|
+
const replayed = [event("contract.replay.v1"), event("contract.replay.v1")];
|
|
1189
|
+
await bus.replay(replayed);
|
|
1190
|
+
expect8(derived).toEqual(replayed.map((e) => e.id));
|
|
1191
|
+
expect8(sideNamed).toEqual([]);
|
|
1192
|
+
expect8(sideUnnamed).toEqual([]);
|
|
1193
|
+
const live = event("contract.replay.v1");
|
|
1194
|
+
await bus.publish(live);
|
|
1195
|
+
expect8(derived).toEqual([...replayed, live].map((e) => e.id));
|
|
1196
|
+
expect8(sideNamed).toEqual([live.id]);
|
|
1197
|
+
expect8(sideUnnamed).toEqual([live.id]);
|
|
1198
|
+
});
|
|
1199
|
+
},
|
|
1200
|
+
SLOW4
|
|
1201
|
+
);
|
|
1202
|
+
it8(
|
|
1203
|
+
"publishBatch reaches each subscription with exactly its matching events, in batch order (wildcards and pattern arrays)",
|
|
1204
|
+
async () => {
|
|
1205
|
+
await fc3.assert(
|
|
1206
|
+
fc3.asyncProperty(
|
|
1207
|
+
fc3.array(fc3.constantFrom(...TYPES), { minLength: 1, maxLength: 12 }),
|
|
1208
|
+
async (types) => {
|
|
1209
|
+
const bus = await make();
|
|
1210
|
+
await closingAll([bus], async () => {
|
|
1211
|
+
const fence = event("contract.fence.v1");
|
|
1212
|
+
const subs = [
|
|
1213
|
+
{ patterns: ["order.*", fence.type], name: void 0 },
|
|
1214
|
+
{ patterns: ["cart.updated.v1", "order.paid.v1", fence.type], name: "contract-arr" },
|
|
1215
|
+
{ patterns: ["order.created.v1", fence.type], name: "contract-one" }
|
|
1216
|
+
];
|
|
1217
|
+
const seen = subs.map(() => []);
|
|
1218
|
+
for (const [i, sub] of subs.entries()) {
|
|
1219
|
+
await bus.subscribe(
|
|
1220
|
+
sub.patterns,
|
|
1221
|
+
async (e) => void seen[i].push(e.id),
|
|
1222
|
+
sub.name === void 0 ? void 0 : { name: sub.name }
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
const events = types.map((type, seq) => event(type, { seq }));
|
|
1226
|
+
await bus.publishBatch(events);
|
|
1227
|
+
await bus.publish(fence);
|
|
1228
|
+
const matches = (patterns, type) => patterns.some(
|
|
1229
|
+
(p) => p.endsWith("*") ? type.startsWith(p.slice(0, -1)) : p === type
|
|
1230
|
+
);
|
|
1231
|
+
for (const [i, sub] of subs.entries()) {
|
|
1232
|
+
const expected = events.filter((e) => matches(sub.patterns, e.type));
|
|
1233
|
+
expect8(seen[i], sub.patterns.join("|")).toEqual([
|
|
1234
|
+
...expected.map((e) => e.id),
|
|
1235
|
+
fence.id
|
|
1236
|
+
]);
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
),
|
|
1241
|
+
{ numRuns: 15 }
|
|
1242
|
+
);
|
|
1243
|
+
},
|
|
1244
|
+
SLOW4
|
|
1245
|
+
);
|
|
1246
|
+
it8(
|
|
1247
|
+
"an unsubscribed handler, by bus.unsubscribe or by its own unsubscribe(), receives nothing more",
|
|
1248
|
+
async () => {
|
|
1249
|
+
const bus = await make();
|
|
1250
|
+
await closingAll([bus], async () => {
|
|
1251
|
+
const gone = [];
|
|
1252
|
+
const goneToo = [];
|
|
1253
|
+
const kept = [];
|
|
1254
|
+
const a = await bus.subscribe("contract.unsub.v1", async (e) => void gone.push(e.id), {
|
|
1255
|
+
name: "contract-unsub"
|
|
1256
|
+
});
|
|
1257
|
+
const b = await bus.subscribe("contract.unsub.v1", async (e) => void goneToo.push(e.id));
|
|
1258
|
+
await bus.subscribe("contract.unsub.v1", async (e) => void kept.push(e.id));
|
|
1259
|
+
const before = event("contract.unsub.v1");
|
|
1260
|
+
await bus.publish(before);
|
|
1261
|
+
await bus.unsubscribe(a);
|
|
1262
|
+
await b.unsubscribe();
|
|
1263
|
+
const after = [event("contract.unsub.v1"), event("contract.unsub.v1")];
|
|
1264
|
+
for (const e of after) await bus.publish(e);
|
|
1265
|
+
expect8(kept).toEqual([before, ...after].map((e) => e.id));
|
|
1266
|
+
expect8(gone).toEqual([before.id]);
|
|
1267
|
+
expect8(goneToo).toEqual([before.id]);
|
|
1268
|
+
});
|
|
1269
|
+
},
|
|
1270
|
+
SLOW4
|
|
1271
|
+
);
|
|
1272
|
+
it8(
|
|
1273
|
+
"on one instance, a named and an unnamed subscription each handle every event once",
|
|
1274
|
+
async () => {
|
|
1275
|
+
const bus = await make();
|
|
1276
|
+
await closingAll([bus], async () => {
|
|
1277
|
+
const named = [];
|
|
1278
|
+
const unnamed = [];
|
|
1279
|
+
await bus.subscribe("contract.both.v1", async (e) => void named.push(e.id), {
|
|
1280
|
+
name: "contract-named"
|
|
1281
|
+
});
|
|
1282
|
+
await bus.subscribe("contract.both.v1", async (e) => void unnamed.push(e.id));
|
|
1283
|
+
const events = Array.from({ length: 6 }, () => event("contract.both.v1"));
|
|
1284
|
+
for (const e of events) await bus.publish(e);
|
|
1285
|
+
expect8(named).toEqual(events.map((e) => e.id));
|
|
1286
|
+
expect8(unnamed).toEqual(named);
|
|
1287
|
+
});
|
|
1288
|
+
},
|
|
1289
|
+
SLOW4
|
|
1290
|
+
);
|
|
1291
|
+
const second = opts.second;
|
|
1292
|
+
if (second === void 0) return;
|
|
1293
|
+
describe7("two instances on the same streams", () => {
|
|
1294
|
+
const N = 12;
|
|
1295
|
+
it8(
|
|
1296
|
+
"one named subscription on both instances handles each of N events exactly once in total",
|
|
1297
|
+
async () => {
|
|
1298
|
+
const a = await make();
|
|
1299
|
+
const b = await second();
|
|
1300
|
+
await closingAll([a, b], async () => {
|
|
1301
|
+
const byA = [];
|
|
1302
|
+
const byB = [];
|
|
1303
|
+
await a.subscribe("contract.named.*", async (e) => void byA.push(e.id), {
|
|
1304
|
+
name: "contract-shared"
|
|
1305
|
+
});
|
|
1306
|
+
await b.subscribe("contract.named.*", async (e) => void byB.push(e.id), {
|
|
1307
|
+
name: "contract-shared"
|
|
1308
|
+
});
|
|
1309
|
+
const events = Array.from({ length: N }, (_, seq) => event("contract.named.x", { seq }));
|
|
1310
|
+
for (const [i, e] of events.entries()) await (i % 2 === 0 ? a : b).publish(e);
|
|
1311
|
+
await b.publish(event("contract.named.fence"));
|
|
1312
|
+
await a.publish(event("contract.named.fence"));
|
|
1313
|
+
const ids2 = new Set(events.map((e) => e.id));
|
|
1314
|
+
const handled = [...byA, ...byB].filter((id) => ids2.has(id));
|
|
1315
|
+
expect8(handled.length).toBe(events.length);
|
|
1316
|
+
expect8([...handled].sort()).toEqual(events.map((e) => e.id).sort());
|
|
1317
|
+
});
|
|
1318
|
+
},
|
|
1319
|
+
SLOW4
|
|
1320
|
+
);
|
|
1321
|
+
it8(
|
|
1322
|
+
"an unnamed subscription on each instance receives all N events, in order, on both",
|
|
1323
|
+
async () => {
|
|
1324
|
+
const a = await make();
|
|
1325
|
+
const b = await second();
|
|
1326
|
+
await closingAll([a, b], async () => {
|
|
1327
|
+
const onA = [];
|
|
1328
|
+
const onB = [];
|
|
1329
|
+
await a.subscribe("contract.fanout.*", async (e) => void onA.push(e.id));
|
|
1330
|
+
await b.subscribe("contract.fanout.*", async (e) => void onB.push(e.id));
|
|
1331
|
+
const events = Array.from({ length: N }, (_, seq) => event("contract.fanout.x", { seq }));
|
|
1332
|
+
for (const e of events) await a.publish(e);
|
|
1333
|
+
const fenceB = event("contract.fanout.fence");
|
|
1334
|
+
await b.publish(fenceB);
|
|
1335
|
+
const ids2 = events.map((e) => e.id);
|
|
1336
|
+
expect8(onA.slice(0, N)).toEqual(ids2);
|
|
1337
|
+
expect8(onB.slice(0, N)).toEqual(ids2);
|
|
1338
|
+
expect8(onB[N]).toBe(fenceB.id);
|
|
1339
|
+
});
|
|
1340
|
+
},
|
|
1341
|
+
SLOW4
|
|
1342
|
+
);
|
|
1343
|
+
it8(
|
|
1344
|
+
"replay on one instance reaches its own derived handlers and nothing on the other instance",
|
|
1345
|
+
async () => {
|
|
1346
|
+
const a = await make();
|
|
1347
|
+
const b = await second();
|
|
1348
|
+
await closingAll([a, b], async () => {
|
|
1349
|
+
const derivedA = [];
|
|
1350
|
+
const derivedB = [];
|
|
1351
|
+
const sideB = [];
|
|
1352
|
+
await a.subscribe("contract.rebuild.*", async (e) => void derivedA.push(e.id), {
|
|
1353
|
+
name: "contract-projection",
|
|
1354
|
+
derived: true
|
|
1355
|
+
});
|
|
1356
|
+
await b.subscribe("contract.rebuild.*", async (e) => void derivedB.push(e.id), {
|
|
1357
|
+
name: "contract-projection",
|
|
1358
|
+
derived: true
|
|
1359
|
+
});
|
|
1360
|
+
await b.subscribe("contract.rebuild.*", async (e) => void sideB.push(e.id), {
|
|
1361
|
+
name: "contract-side-effect"
|
|
1362
|
+
});
|
|
1363
|
+
const replayed = [event("contract.rebuild.x"), event("contract.rebuild.x")];
|
|
1364
|
+
await a.replay(replayed);
|
|
1365
|
+
const fence = event("contract.rebuild.fence");
|
|
1366
|
+
await b.publish(fence);
|
|
1367
|
+
expect8(derivedA.slice(0, replayed.length)).toEqual(replayed.map((e) => e.id));
|
|
1368
|
+
const ids2 = new Set(replayed.map((e) => e.id));
|
|
1369
|
+
expect8(derivedB.filter((id) => ids2.has(id))).toEqual([]);
|
|
1370
|
+
expect8(sideB).toEqual([fence.id]);
|
|
1371
|
+
});
|
|
1372
|
+
},
|
|
1373
|
+
SLOW4
|
|
1374
|
+
);
|
|
1375
|
+
const timing = opts.timing;
|
|
1376
|
+
if (timing === void 0) return;
|
|
1377
|
+
const { claimIdleMs, ackTimeoutMs } = timing;
|
|
1378
|
+
it8(
|
|
1379
|
+
"groups left by a stopped instance (never unsubscribed) do not delay publish on the other instance",
|
|
1380
|
+
async () => {
|
|
1381
|
+
const a = await make();
|
|
1382
|
+
const b = await second();
|
|
1383
|
+
await closingAll([a], async () => {
|
|
1384
|
+
await b.subscribe("contract.left.*", async () => void 0, { name: "contract-left-behind" });
|
|
1385
|
+
await b.subscribe("contract.left.*", async () => void 0);
|
|
1386
|
+
await b.close();
|
|
1387
|
+
const handled = [];
|
|
1388
|
+
await a.subscribe("contract.left.*", async (e2) => void handled.push(e2.id), {
|
|
1389
|
+
name: "contract-live"
|
|
1390
|
+
});
|
|
1391
|
+
const e = event("contract.left.x");
|
|
1392
|
+
const startedAt = Date.now();
|
|
1393
|
+
await a.publish(e);
|
|
1394
|
+
const elapsed = Date.now() - startedAt;
|
|
1395
|
+
expect8(handled).toEqual([e.id]);
|
|
1396
|
+
expect8(elapsed).toBeLessThan(ackTimeoutMs / 2);
|
|
1397
|
+
});
|
|
1398
|
+
},
|
|
1399
|
+
SLOW4
|
|
1400
|
+
);
|
|
1401
|
+
it8(
|
|
1402
|
+
"an entry pending on a stopped consumer of a named group is handled by the live one after claimIdleMs and before ackTimeoutMs",
|
|
1403
|
+
async () => {
|
|
1404
|
+
const a = await make();
|
|
1405
|
+
const b = await second();
|
|
1406
|
+
const held = gate();
|
|
1407
|
+
let closingB;
|
|
1408
|
+
try {
|
|
1409
|
+
let receivedByB;
|
|
1410
|
+
await b.subscribe(
|
|
1411
|
+
"contract.claim.*",
|
|
1412
|
+
async () => {
|
|
1413
|
+
receivedByB = Date.now();
|
|
1414
|
+
await held.wait;
|
|
1415
|
+
},
|
|
1416
|
+
{ name: "contract-claimed" }
|
|
1417
|
+
);
|
|
1418
|
+
const stranded = event("contract.claim.x");
|
|
1419
|
+
await a.publish(stranded);
|
|
1420
|
+
await until(() => receivedByB !== void 0, "b to receive the entry", ackTimeoutMs);
|
|
1421
|
+
closingB = b.close();
|
|
1422
|
+
const byA = [];
|
|
1423
|
+
await a.subscribe(
|
|
1424
|
+
"contract.claim.*",
|
|
1425
|
+
async (e) => void byA.push({ id: e.id, at: Date.now() }),
|
|
1426
|
+
{
|
|
1427
|
+
name: "contract-claimed"
|
|
1428
|
+
}
|
|
1429
|
+
);
|
|
1430
|
+
const next = event("contract.claim.x");
|
|
1431
|
+
const startedAt = Date.now();
|
|
1432
|
+
await a.publish(next);
|
|
1433
|
+
const publishMs = Date.now() - startedAt;
|
|
1434
|
+
await until(
|
|
1435
|
+
() => byA.some((h) => h.id === stranded.id),
|
|
1436
|
+
"a to take over the stranded entry",
|
|
1437
|
+
ackTimeoutMs * 2
|
|
1438
|
+
);
|
|
1439
|
+
const claimed = byA.find((h) => h.id === stranded.id);
|
|
1440
|
+
const idle = claimed.at - receivedByB;
|
|
1441
|
+
expect8(idle, "taken over before claimIdleMs").toBeGreaterThanOrEqual(claimIdleMs - 50);
|
|
1442
|
+
expect8(idle, "taken over only after ackTimeoutMs").toBeLessThan(ackTimeoutMs);
|
|
1443
|
+
expect8(byA.filter((h) => h.id === stranded.id).length).toBe(1);
|
|
1444
|
+
expect8(publishMs, "a publish on the group waited out the timeout").toBeLessThan(
|
|
1445
|
+
ackTimeoutMs
|
|
1446
|
+
);
|
|
1447
|
+
} finally {
|
|
1448
|
+
held.open();
|
|
1449
|
+
await closingB;
|
|
1450
|
+
await a.close();
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
SLOW4
|
|
1454
|
+
);
|
|
1455
|
+
it8(
|
|
1456
|
+
"an unnamed subscription unsubscribed while publish waits on it lets publish resolve well before ackTimeoutMs",
|
|
1457
|
+
async () => {
|
|
1458
|
+
const a = await make();
|
|
1459
|
+
const held = gate();
|
|
1460
|
+
try {
|
|
1461
|
+
let entered = false;
|
|
1462
|
+
const sub = await a.subscribe("contract.wait.*", async () => {
|
|
1463
|
+
entered = true;
|
|
1464
|
+
await held.wait;
|
|
1465
|
+
});
|
|
1466
|
+
const publishing = a.publish(event("contract.wait.x")).then(
|
|
1467
|
+
() => Date.now(),
|
|
1468
|
+
(error) => error
|
|
1469
|
+
);
|
|
1470
|
+
await until(() => entered, "the handler to start", ackTimeoutMs);
|
|
1471
|
+
const unsubscribedAt = Date.now();
|
|
1472
|
+
const unsubscribing = a.unsubscribe(sub);
|
|
1473
|
+
const outcome = await publishing;
|
|
1474
|
+
expect8(typeof outcome, `publish rejected: ${String(outcome)}`).toBe("number");
|
|
1475
|
+
expect8(outcome - unsubscribedAt).toBeLessThan(ackTimeoutMs / 2);
|
|
1476
|
+
held.open();
|
|
1477
|
+
await unsubscribing;
|
|
1478
|
+
} finally {
|
|
1479
|
+
held.open();
|
|
1480
|
+
await a.close();
|
|
1481
|
+
}
|
|
1482
|
+
},
|
|
1483
|
+
SLOW4
|
|
1484
|
+
);
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// src/event-bus.ts
|
|
1490
|
+
var SLOW5 = 3e4;
|
|
1491
|
+
function makeEvent(type, payload = {}) {
|
|
1492
|
+
return {
|
|
1493
|
+
id: `evt-${randomUUID2()}`,
|
|
1494
|
+
type,
|
|
1495
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1496
|
+
source: "contract-tests",
|
|
1497
|
+
correlationId: `corr-${randomUUID2()}`,
|
|
1498
|
+
payload,
|
|
1499
|
+
metadata: { storeId: "test", environment: "development", version: "0.0.0" }
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
var delay2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1503
|
+
async function using3(buses, body) {
|
|
1504
|
+
try {
|
|
1505
|
+
await body();
|
|
1506
|
+
} finally {
|
|
1507
|
+
for (const bus of buses) await bus.close();
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
function runEventBusContract(name, make, opts = {}) {
|
|
1511
|
+
describe8(`EventBusAdapter contract: ${name}`, () => {
|
|
1512
|
+
it9("publish resolves after the matching handler has finished, and never calls a non-matching one", async () => {
|
|
1513
|
+
const bus = await make();
|
|
1514
|
+
await using3([bus], async () => {
|
|
1515
|
+
const received = [];
|
|
1516
|
+
const other = [];
|
|
1517
|
+
await bus.subscribe("test.event.v1", async (e) => {
|
|
1518
|
+
await delay2(50);
|
|
1519
|
+
received.push(e.payload);
|
|
1520
|
+
});
|
|
1521
|
+
await bus.subscribe("test.other.v1", async (e) => void other.push(e));
|
|
1522
|
+
await bus.publish(makeEvent("test.event.v1", { x: 1 }));
|
|
1523
|
+
expect9(received).toEqual([{ x: 1 }]);
|
|
1524
|
+
expect9(other).toEqual([]);
|
|
1525
|
+
});
|
|
1526
|
+
}, CASE_MS);
|
|
1527
|
+
it9("a handler that throws leaves publish resolved and every other handler called", async () => {
|
|
1528
|
+
const bus = await make();
|
|
1529
|
+
await using3([bus], async () => {
|
|
1530
|
+
const results = [];
|
|
1531
|
+
await bus.subscribe("error.test.v1", async () => {
|
|
1532
|
+
throw new Error("boom");
|
|
1533
|
+
});
|
|
1534
|
+
await bus.subscribe("error.test.v1", async () => void results.push("ok"));
|
|
1535
|
+
await expect9(bus.publish(makeEvent("error.test.v1"))).resolves.toBeUndefined();
|
|
1536
|
+
expect9(results).toEqual(["ok"]);
|
|
1537
|
+
});
|
|
1538
|
+
}, CASE_MS);
|
|
1539
|
+
it9("replay reaches derived subscriptions only", async () => {
|
|
1540
|
+
const bus = await make();
|
|
1541
|
+
await using3([bus], async () => {
|
|
1542
|
+
const derived = [];
|
|
1543
|
+
const sideEffect = [];
|
|
1544
|
+
await bus.subscribe("replay.test.v1", async (e) => void derived.push(e.id), {
|
|
1545
|
+
name: "contract-projection",
|
|
1546
|
+
derived: true
|
|
1547
|
+
});
|
|
1548
|
+
await bus.subscribe("replay.test.v1", async (e) => void sideEffect.push(e.id), {
|
|
1549
|
+
name: "contract-side-effect"
|
|
1550
|
+
});
|
|
1551
|
+
const events = [makeEvent("replay.test.v1"), makeEvent("replay.test.v1")];
|
|
1552
|
+
await bus.replay(events);
|
|
1553
|
+
expect9(derived).toEqual(events.map((e) => e.id));
|
|
1554
|
+
expect9(sideEffect).toEqual([]);
|
|
1555
|
+
});
|
|
1556
|
+
}, CASE_MS);
|
|
1557
|
+
it9("publishBatch reaches each subscription in batch order, through wildcards and pattern arrays", async () => {
|
|
1558
|
+
const bus = await make();
|
|
1559
|
+
await using3([bus], async () => {
|
|
1560
|
+
const wildcard = [];
|
|
1561
|
+
const listed = [];
|
|
1562
|
+
await bus.subscribe("batch.*", async (e) => void wildcard.push(e.id));
|
|
1563
|
+
await bus.subscribe(["batch.a.v1", "batch.c.v1"], async (e) => void listed.push(e.id), {
|
|
1564
|
+
name: "contract-listed"
|
|
1565
|
+
});
|
|
1566
|
+
const events = ["batch.a.v1", "batch.b.v1", "batch.c.v1", "batch.a.v1"].map(
|
|
1567
|
+
(t, seq) => makeEvent(t, { seq })
|
|
1568
|
+
);
|
|
1569
|
+
await bus.publishBatch(events);
|
|
1570
|
+
expect9(wildcard).toEqual(events.map((e) => e.id));
|
|
1571
|
+
expect9(listed).toEqual(events.filter((e) => e.type !== "batch.b.v1").map((e) => e.id));
|
|
1572
|
+
});
|
|
1573
|
+
}, CASE_MS);
|
|
1574
|
+
it9("an unsubscribed handler receives nothing more", async () => {
|
|
1575
|
+
const bus = await make();
|
|
1576
|
+
await using3([bus], async () => {
|
|
1577
|
+
const received = [];
|
|
1578
|
+
const sub = await bus.subscribe("unsub.event.v1", async (e) => void received.push(e.id));
|
|
1579
|
+
const before = makeEvent("unsub.event.v1");
|
|
1580
|
+
await bus.publish(before);
|
|
1581
|
+
await bus.unsubscribe(sub);
|
|
1582
|
+
await bus.publish(makeEvent("unsub.event.v1"));
|
|
1583
|
+
expect9(received).toEqual([before.id]);
|
|
1584
|
+
});
|
|
1585
|
+
}, CASE_MS);
|
|
1586
|
+
it9("on one instance, a named and an unnamed subscription each handle every event once", async () => {
|
|
1587
|
+
const bus = await make();
|
|
1588
|
+
await using3([bus], async () => {
|
|
1589
|
+
const named = [];
|
|
1590
|
+
const unnamed = [];
|
|
1591
|
+
await bus.subscribe("both.event.v1", async (e) => void named.push(e.id), {
|
|
1592
|
+
name: "contract-named"
|
|
1593
|
+
});
|
|
1594
|
+
await bus.subscribe("both.event.v1", async (e) => void unnamed.push(e.id));
|
|
1595
|
+
const events = [makeEvent("both.event.v1"), makeEvent("both.event.v1")];
|
|
1596
|
+
for (const e of events) await bus.publish(e);
|
|
1597
|
+
expect9(named).toEqual(events.map((e) => e.id));
|
|
1598
|
+
expect9(unnamed).toEqual(named);
|
|
1599
|
+
});
|
|
1600
|
+
}, CASE_MS);
|
|
1601
|
+
eventBusDeliveryCases(make, opts);
|
|
1602
|
+
healthAndDowntime(
|
|
1603
|
+
make,
|
|
1604
|
+
opts.unreachable,
|
|
1605
|
+
"publish()",
|
|
1606
|
+
(bus) => bus.publish(makeEvent("contract.down.v1"))
|
|
1607
|
+
);
|
|
1608
|
+
const second = opts.second;
|
|
1609
|
+
if (second === void 0) return;
|
|
1610
|
+
it9(
|
|
1611
|
+
"across two instances, a named subscription handles each event once in total, and an unnamed one on each",
|
|
1612
|
+
async () => {
|
|
1613
|
+
const a = await make();
|
|
1614
|
+
const b = await second();
|
|
1615
|
+
await using3([a, b], async () => {
|
|
1616
|
+
const named = [];
|
|
1617
|
+
const onA = [];
|
|
1618
|
+
const onB = [];
|
|
1619
|
+
await a.subscribe("shared.*", async (e) => void named.push(e.id), {
|
|
1620
|
+
name: "contract-shared"
|
|
1621
|
+
});
|
|
1622
|
+
await b.subscribe("shared.*", async (e) => void named.push(e.id), {
|
|
1623
|
+
name: "contract-shared"
|
|
1624
|
+
});
|
|
1625
|
+
await a.subscribe("shared.*", async (e) => void onA.push(e.id));
|
|
1626
|
+
await b.subscribe("shared.*", async (e) => void onB.push(e.id));
|
|
1627
|
+
const events = Array.from({ length: 6 }, (_, seq) => makeEvent("shared.x", { seq }));
|
|
1628
|
+
for (const [i, e] of events.entries()) await (i % 2 === 0 ? a : b).publish(e);
|
|
1629
|
+
const fenceA = makeEvent("shared.fence");
|
|
1630
|
+
const fenceB = makeEvent("shared.fence");
|
|
1631
|
+
await a.publish(fenceA);
|
|
1632
|
+
await b.publish(fenceB);
|
|
1633
|
+
const ids2 = events.map((e) => e.id);
|
|
1634
|
+
expect9(named.filter((id) => ids2.includes(id)).sort()).toEqual([...ids2].sort());
|
|
1635
|
+
expect9(onA.filter((id) => ids2.includes(id))).toEqual(ids2);
|
|
1636
|
+
expect9(onB.filter((id) => ids2.includes(id))).toEqual(ids2);
|
|
1637
|
+
});
|
|
1638
|
+
},
|
|
1639
|
+
SLOW5
|
|
1640
|
+
);
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// src/media-storage.ts
|
|
1645
|
+
import { describe as describe9, expect as expect10, it as it10 } from "vitest";
|
|
1646
|
+
function runMediaStorageContract(name, make, options) {
|
|
1647
|
+
const { fetchUrl } = options;
|
|
1648
|
+
describe9(`MediaStorage contract: ${name}`, () => {
|
|
1649
|
+
it10("reports put among its capabilities", async () => {
|
|
1650
|
+
const storage = await make();
|
|
1651
|
+
expect10(storage.capabilities()).toContain("put");
|
|
1652
|
+
}, CASE_MS);
|
|
1653
|
+
it10("put then a read of the returned url gives back the same bytes, for nested keys too", async () => {
|
|
1654
|
+
const storage = await make();
|
|
1655
|
+
const bodies = /* @__PURE__ */ new Map([
|
|
1656
|
+
["image.png", Uint8Array.from([137, 80, 78, 71, 0, 255, 1])],
|
|
1657
|
+
["products/42/large.jpg", new TextEncoder().encode("jpeg bytes \u2713")],
|
|
1658
|
+
["empty.txt", new Uint8Array(0)]
|
|
1659
|
+
]);
|
|
1660
|
+
const urls = /* @__PURE__ */ new Set();
|
|
1661
|
+
for (const [key, body] of bodies) {
|
|
1662
|
+
const { url } = await storage.put(key, body, "application/octet-stream");
|
|
1663
|
+
urls.add(url);
|
|
1664
|
+
expect10([...await fetchUrl(url)]).toEqual([...body]);
|
|
1665
|
+
}
|
|
1666
|
+
expect10(urls.size).toBe(bodies.size);
|
|
1667
|
+
}, CASE_MS);
|
|
1668
|
+
it10("a second put under a key replaces what the url serves", async () => {
|
|
1669
|
+
const storage = await make();
|
|
1670
|
+
await storage.put("replaced.bin", Uint8Array.from([1, 2, 3]), "application/octet-stream");
|
|
1671
|
+
const { url } = await storage.put(
|
|
1672
|
+
"replaced.bin",
|
|
1673
|
+
Uint8Array.from([9]),
|
|
1674
|
+
"application/octet-stream"
|
|
1675
|
+
);
|
|
1676
|
+
expect10([...await fetchUrl(url)]).toEqual([9]);
|
|
1677
|
+
}, CASE_MS);
|
|
1678
|
+
healthAndDowntime(
|
|
1679
|
+
make,
|
|
1680
|
+
options.unreachable,
|
|
1681
|
+
"put()",
|
|
1682
|
+
(storage) => storage.put("contract-down.bin", Uint8Array.from([1]), "application/octet-stream")
|
|
1683
|
+
);
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/payments.ts
|
|
1688
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1689
|
+
import { describe as describe10, expect as expect11, it as it11 } from "vitest";
|
|
1690
|
+
var TOTAL = 1999;
|
|
1691
|
+
async function rejectionCode(call) {
|
|
1692
|
+
return call.then(
|
|
1693
|
+
() => null,
|
|
1694
|
+
(err) => err?.code ?? err
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
function runPaymentsContract(name, build, options = {}) {
|
|
1698
|
+
describe10(`PaymentsAdapter contract: ${name}`, () => {
|
|
1699
|
+
it11(
|
|
1700
|
+
"createPaymentIntent returns a clientSecret and id",
|
|
1701
|
+
async () => {
|
|
1702
|
+
const adapter = await build();
|
|
1703
|
+
const intent = await adapter.createPaymentIntent({
|
|
1704
|
+
amountMinor: 1e3,
|
|
1705
|
+
currency: "GBP",
|
|
1706
|
+
orderRef: `contract-${randomUUID3()}`
|
|
1707
|
+
});
|
|
1708
|
+
expect11(intent.id).toBeTruthy();
|
|
1709
|
+
expect11(intent.clientSecret).toBeTruthy();
|
|
1710
|
+
expect11(intent.amountMinor).toBe(1e3);
|
|
1711
|
+
expect11(intent.currency).toBe("GBP");
|
|
1712
|
+
},
|
|
1713
|
+
CASE_MS
|
|
1714
|
+
);
|
|
1715
|
+
it11(
|
|
1716
|
+
"getPaymentStatus returns a valid status",
|
|
1717
|
+
async () => {
|
|
1718
|
+
const adapter = await build();
|
|
1719
|
+
const intent = await adapter.createPaymentIntent({ amountMinor: 500, currency: "GBP" });
|
|
1720
|
+
const status = await adapter.getPaymentStatus(intent.id);
|
|
1721
|
+
expect11([
|
|
1722
|
+
"requires_payment_method",
|
|
1723
|
+
"requires_confirmation",
|
|
1724
|
+
"requires_action",
|
|
1725
|
+
"processing",
|
|
1726
|
+
"requires_capture",
|
|
1727
|
+
"succeeded",
|
|
1728
|
+
"cancelled",
|
|
1729
|
+
"failed"
|
|
1730
|
+
]).toContain(status);
|
|
1731
|
+
},
|
|
1732
|
+
CASE_MS
|
|
1733
|
+
);
|
|
1734
|
+
it11(
|
|
1735
|
+
"idempotency: a refund repeated with the same idempotencyKey returns the same refund and refunds once",
|
|
1736
|
+
async () => {
|
|
1737
|
+
const adapter = await build();
|
|
1738
|
+
const intent = await adapter.createPaymentIntent({
|
|
1739
|
+
amountMinor: TOTAL,
|
|
1740
|
+
currency: "GBP",
|
|
1741
|
+
orderRef: `contract-${randomUUID3()}`
|
|
1742
|
+
});
|
|
1743
|
+
if (intent.status !== "succeeded") await adapter.capturePayment(intent.id);
|
|
1744
|
+
const half = Math.floor(TOTAL / 2);
|
|
1745
|
+
const key = `contract-refund-${randomUUID3()}`;
|
|
1746
|
+
const first = await adapter.refund(intent.id, half, "requested_by_customer", {
|
|
1747
|
+
idempotencyKey: key
|
|
1748
|
+
});
|
|
1749
|
+
const again = await adapter.refund(intent.id, half, "requested_by_customer", {
|
|
1750
|
+
idempotencyKey: key
|
|
1751
|
+
});
|
|
1752
|
+
expect11(again.refundId, "the retry under the same key made another refund").toBe(
|
|
1753
|
+
first.refundId
|
|
1754
|
+
);
|
|
1755
|
+
expect11(again.amountMinor).toBe(first.amountMinor);
|
|
1756
|
+
const rest = await adapter.refund(intent.id, TOTAL - half, "requested_by_customer", {
|
|
1757
|
+
idempotencyKey: `${key}-rest`
|
|
1758
|
+
});
|
|
1759
|
+
expect11(rest.amountMinor + first.amountMinor).toBe(TOTAL);
|
|
1760
|
+
const over = await rejectionCode(
|
|
1761
|
+
adapter.refund(intent.id, 1, "requested_by_customer", { idempotencyKey: `${key}-over` })
|
|
1762
|
+
);
|
|
1763
|
+
expect11(over, "a minor unit past the amount was refunded").toBe("payment.refund_rejected");
|
|
1764
|
+
},
|
|
1765
|
+
CASE_MS
|
|
1766
|
+
);
|
|
1767
|
+
const signedWebhook = options.signedWebhook;
|
|
1768
|
+
if (signedWebhook !== void 0) {
|
|
1769
|
+
it11(
|
|
1770
|
+
"idempotency: parsing one signed webhook event twice gives equal results",
|
|
1771
|
+
async () => {
|
|
1772
|
+
const adapter = await build();
|
|
1773
|
+
const { rawBody, signature } = signedWebhook();
|
|
1774
|
+
const first = await adapter.parseWebhook(rawBody, signature);
|
|
1775
|
+
const second = await adapter.parseWebhook(rawBody, signature);
|
|
1776
|
+
expect11(second).toEqual(first);
|
|
1777
|
+
},
|
|
1778
|
+
CASE_MS
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
healthAndDowntime(
|
|
1782
|
+
build,
|
|
1783
|
+
options.unreachable,
|
|
1784
|
+
"createPaymentIntent()",
|
|
1785
|
+
(adapter) => adapter.createPaymentIntent({ amountMinor: 100, currency: "GBP" })
|
|
1786
|
+
);
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// src/reviews.ts
|
|
1791
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1792
|
+
import fc4 from "fast-check";
|
|
1793
|
+
import { describe as describe11, expect as expect12, it as it12 } from "vitest";
|
|
1794
|
+
async function seed(adapter, count) {
|
|
1795
|
+
const productId = `contract-reviews-${randomUUID4()}`;
|
|
1796
|
+
const seeded = [];
|
|
1797
|
+
for (let i = 0; i < count; i++) {
|
|
1798
|
+
const { review } = await adapter.submitReview({
|
|
1799
|
+
productId,
|
|
1800
|
+
customerId: null,
|
|
1801
|
+
orderId: null,
|
|
1802
|
+
rating: i % 5 + 1,
|
|
1803
|
+
title: `review ${i}`,
|
|
1804
|
+
body: `body ${i}`
|
|
1805
|
+
});
|
|
1806
|
+
seeded.push(review);
|
|
1807
|
+
}
|
|
1808
|
+
return seeded;
|
|
1809
|
+
}
|
|
1810
|
+
async function walk3(adapter, first, limit, maxPages) {
|
|
1811
|
+
const pages = [];
|
|
1812
|
+
let cursor;
|
|
1813
|
+
do {
|
|
1814
|
+
const page = await adapter.getProductReviews(first.productId, {
|
|
1815
|
+
limit,
|
|
1816
|
+
status: first.moderationStatus,
|
|
1817
|
+
...cursor !== void 0 && { cursor }
|
|
1818
|
+
});
|
|
1819
|
+
pages.push(page);
|
|
1820
|
+
cursor = page.nextCursor ?? void 0;
|
|
1821
|
+
} while (cursor !== void 0 && pages.length < maxPages);
|
|
1822
|
+
return pages;
|
|
1823
|
+
}
|
|
1824
|
+
function runReviewsContract(name, build, options = {}) {
|
|
1825
|
+
describe11(`ReviewsAdapter contract: ${name}`, () => {
|
|
1826
|
+
it12(
|
|
1827
|
+
"submitReview returns the created review",
|
|
1828
|
+
async () => {
|
|
1829
|
+
const adapter = await build();
|
|
1830
|
+
const productId = `contract-reviews-${randomUUID4()}`;
|
|
1831
|
+
const { review } = await adapter.submitReview({
|
|
1832
|
+
productId,
|
|
1833
|
+
customerId: null,
|
|
1834
|
+
orderId: null,
|
|
1835
|
+
rating: 5,
|
|
1836
|
+
title: "Great!",
|
|
1837
|
+
body: "Really liked it."
|
|
1838
|
+
});
|
|
1839
|
+
expect12(review.productId).toBe(productId);
|
|
1840
|
+
expect12(review.rating).toBe(5);
|
|
1841
|
+
expect12(review.id).toBeTruthy();
|
|
1842
|
+
},
|
|
1843
|
+
CASE_MS
|
|
1844
|
+
);
|
|
1845
|
+
it12(
|
|
1846
|
+
"getProductReviews returns the submitted review, read under the status submitReview reported",
|
|
1847
|
+
async () => {
|
|
1848
|
+
const adapter = await build();
|
|
1849
|
+
const [review] = await seed(adapter, 1);
|
|
1850
|
+
const { items } = await adapter.getProductReviews(review.productId, {
|
|
1851
|
+
status: review.moderationStatus
|
|
1852
|
+
});
|
|
1853
|
+
expect12(items.map((r) => r.id)).toEqual([review.id]);
|
|
1854
|
+
expect12(items[0].productId).toBe(review.productId);
|
|
1855
|
+
},
|
|
1856
|
+
CASE_MS
|
|
1857
|
+
);
|
|
1858
|
+
it12(
|
|
1859
|
+
"getAggregateRating is consistent: its histogram sums to its count, and an approved review is counted",
|
|
1860
|
+
async () => {
|
|
1861
|
+
const adapter = await build();
|
|
1862
|
+
const [review] = await seed(adapter, 1);
|
|
1863
|
+
const agg = await adapter.getAggregateRating(review.productId);
|
|
1864
|
+
expect12(agg.productId).toBe(review.productId);
|
|
1865
|
+
expect12(Object.values(agg.histogram).reduce((a, b) => a + b, 0)).toBe(agg.count);
|
|
1866
|
+
if (agg.count === 0) expect12(agg.average).toBe(0);
|
|
1867
|
+
else {
|
|
1868
|
+
expect12(agg.average).toBeGreaterThanOrEqual(1);
|
|
1869
|
+
expect12(agg.average).toBeLessThanOrEqual(5);
|
|
1870
|
+
}
|
|
1871
|
+
if (review.moderationStatus === "approved") expect12(agg.count).toBeGreaterThanOrEqual(1);
|
|
1872
|
+
},
|
|
1873
|
+
CASE_MS
|
|
1874
|
+
);
|
|
1875
|
+
it12(
|
|
1876
|
+
"pagination: over seeds larger than a page, the cursor walk from page 1 yields each review once, the walk is the seed, no page exceeds limit and nextCursor is null exactly on the last page",
|
|
1877
|
+
async () => {
|
|
1878
|
+
const adapter = await build();
|
|
1879
|
+
await fc4.assert(
|
|
1880
|
+
fc4.asyncProperty(
|
|
1881
|
+
fc4.integer({ min: 1, max: 5 }),
|
|
1882
|
+
fc4.integer({ min: 1, max: 8 }),
|
|
1883
|
+
async (limit, extra) => {
|
|
1884
|
+
const seeded = await seed(adapter, limit + extra);
|
|
1885
|
+
const status = seeded[0].moderationStatus;
|
|
1886
|
+
const expected = seeded.filter((r) => r.moderationStatus === status).map((r) => r.id);
|
|
1887
|
+
const pages = await walk3(
|
|
1888
|
+
adapter,
|
|
1889
|
+
seeded[0],
|
|
1890
|
+
limit,
|
|
1891
|
+
Math.ceil(expected.length / limit) + 2
|
|
1892
|
+
);
|
|
1893
|
+
const walked = pages.flatMap((p) => p.items.map((r) => r.id));
|
|
1894
|
+
expect12(new Set(walked).size, "a review came back on two pages").toBe(walked.length);
|
|
1895
|
+
expect12([...walked].sort()).toEqual([...expected].sort());
|
|
1896
|
+
for (const page of pages) expect12(page.items.length).toBeLessThanOrEqual(limit);
|
|
1897
|
+
expect12(pages.map((p) => p.nextCursor === null)).toEqual(
|
|
1898
|
+
pages.map((_, i) => i === pages.length - 1)
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
),
|
|
1902
|
+
{ numRuns: 15 }
|
|
1903
|
+
);
|
|
1904
|
+
},
|
|
1905
|
+
12e4
|
|
1906
|
+
);
|
|
1907
|
+
healthAndDowntime(
|
|
1908
|
+
build,
|
|
1909
|
+
options.unreachable,
|
|
1910
|
+
"getProductReviews()",
|
|
1911
|
+
(adapter) => adapter.getProductReviews(`contract-reviews-${randomUUID4()}`, { limit: 1 })
|
|
1912
|
+
);
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// src/search.ts
|
|
1917
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1918
|
+
import fc5 from "fast-check";
|
|
1919
|
+
import { describe as describe12, expect as expect13, it as it13 } from "vitest";
|
|
1920
|
+
|
|
1921
|
+
// src/search-oracle.ts
|
|
1922
|
+
function searchTerms(text) {
|
|
1923
|
+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
1924
|
+
}
|
|
1925
|
+
function toSearchDocument(p) {
|
|
1926
|
+
return {
|
|
1927
|
+
id: p.id,
|
|
1928
|
+
type: "product",
|
|
1929
|
+
fields: {
|
|
1930
|
+
slug: p.id,
|
|
1931
|
+
name: p.name,
|
|
1932
|
+
description: p.description,
|
|
1933
|
+
status: "active",
|
|
1934
|
+
categoryIds: p.categoryIds,
|
|
1935
|
+
variants: p.variants.map((v) => ({ sku: v.sku, name: v.name, priceMinor: v.priceMinor }))
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
function prefixed(queryTerms, docTerms) {
|
|
1940
|
+
return queryTerms.every((q) => docTerms.some((d) => d.startsWith(q)));
|
|
1941
|
+
}
|
|
1942
|
+
function strongTerms(p) {
|
|
1943
|
+
return [
|
|
1944
|
+
...searchTerms(p.name),
|
|
1945
|
+
...p.variants.flatMap((v) => [...searchTerms(v.sku), ...searchTerms(v.name)])
|
|
1946
|
+
];
|
|
1947
|
+
}
|
|
1948
|
+
function matchesQuery(p, query) {
|
|
1949
|
+
const q = searchTerms(query);
|
|
1950
|
+
return q.length > 0 && prefixed(q, [...strongTerms(p), ...searchTerms(p.description)]);
|
|
1951
|
+
}
|
|
1952
|
+
function tierOf(p, query) {
|
|
1953
|
+
const q = searchTerms(query);
|
|
1954
|
+
if (prefixed(q, searchTerms(p.name))) return 2;
|
|
1955
|
+
if (prefixed(q, strongTerms(p))) return 1;
|
|
1956
|
+
return 0;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// src/search.ts
|
|
1960
|
+
function runToken() {
|
|
1961
|
+
return `t${randomUUID5().replace(/-/g, "").slice(0, 15)}`;
|
|
1962
|
+
}
|
|
1963
|
+
var word = fc5.string({
|
|
1964
|
+
unit: fc5.constantFrom("a", "b", "c", "d", "1", "2"),
|
|
1965
|
+
minLength: 1,
|
|
1966
|
+
maxLength: 3
|
|
1967
|
+
});
|
|
1968
|
+
var words = (max) => fc5.array(word, { maxLength: max }).map((ws) => ws.join(" "));
|
|
1969
|
+
var CATEGORIES = 3;
|
|
1970
|
+
var genProduct = fc5.record({
|
|
1971
|
+
name: words(3),
|
|
1972
|
+
description: words(6),
|
|
1973
|
+
variants: fc5.array(
|
|
1974
|
+
fc5.record({ sku: word, name: words(2), priceMinor: fc5.integer({ min: 1, max: 2222 }) }),
|
|
1975
|
+
{ maxLength: 3 }
|
|
1976
|
+
),
|
|
1977
|
+
categories: fc5.subarray([0, 1, 2])
|
|
1978
|
+
});
|
|
1979
|
+
function runCategories(token) {
|
|
1980
|
+
return Array.from({ length: CATEGORIES }, (_, k) => `c${k}${token.slice(1)}`);
|
|
1981
|
+
}
|
|
1982
|
+
function materialise(token, gen, i, cats) {
|
|
1983
|
+
return {
|
|
1984
|
+
id: `${token}-${i}`,
|
|
1985
|
+
name: gen.name === "" ? token : `${token} ${gen.name}`,
|
|
1986
|
+
description: gen.description,
|
|
1987
|
+
variants: gen.variants,
|
|
1988
|
+
categoryIds: gen.categories.map((k) => cats[k])
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
async function walk4(adapter, query, limit, expectedMatches, filters) {
|
|
1992
|
+
const maxPages = Math.ceil(expectedMatches / limit) + 2;
|
|
1993
|
+
const pages = [];
|
|
1994
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1995
|
+
let cursor;
|
|
1996
|
+
for (; ; ) {
|
|
1997
|
+
const page = await adapter.search(query, {
|
|
1998
|
+
limit,
|
|
1999
|
+
...cursor !== void 0 && { cursor },
|
|
2000
|
+
...filters !== void 0 && { filters }
|
|
2001
|
+
});
|
|
2002
|
+
pages.push(page);
|
|
2003
|
+
const again = page.items.map((item) => item.id).filter((id) => seen.has(id));
|
|
2004
|
+
expect13(again, `the walk for "${query}": an id came back on page ${pages.length}`).toEqual([]);
|
|
2005
|
+
for (const item of page.items) seen.add(item.id);
|
|
2006
|
+
if (page.nextCursor === null) return pages;
|
|
2007
|
+
expect13(
|
|
2008
|
+
pages.length,
|
|
2009
|
+
`the walk for "${query}" did not end within ${maxPages} pages`
|
|
2010
|
+
).toBeLessThan(maxPages);
|
|
2011
|
+
cursor = page.nextCursor;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
var idsOf2 = (pages) => pages.flatMap((p) => p.items.map((item) => item.id));
|
|
2015
|
+
function expectExactWalk(pages, expected, limit, label) {
|
|
2016
|
+
const ids2 = idsOf2(pages);
|
|
2017
|
+
expect13(new Set(ids2).size, `${label}: an id came back on two pages`).toBe(ids2.length);
|
|
2018
|
+
expect13([...ids2].sort(), `${label}: the walk's union is not the oracle's match set`).toEqual(
|
|
2019
|
+
[...expected].sort()
|
|
2020
|
+
);
|
|
2021
|
+
for (const [i, page] of pages.entries()) {
|
|
2022
|
+
expect13(page.total, `${label}: total on page ${i + 1}`).toBe(expected.length);
|
|
2023
|
+
expect13(page.items.length, `${label}: page ${i + 1} exceeds limit`).toBeLessThanOrEqual(limit);
|
|
2024
|
+
expect13(
|
|
2025
|
+
page.nextCursor === null,
|
|
2026
|
+
`${label}: nextCursor is null on page ${i + 1} of ${pages.length}`
|
|
2027
|
+
).toBe(i === pages.length - 1);
|
|
2028
|
+
if (expected.length > 0)
|
|
2029
|
+
expect13(page.items.length, `${label}: page ${i + 1} is empty`).toBeGreaterThan(0);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
var SLOW6 = 18e4;
|
|
2033
|
+
function runSearchContract(name, build, options = {}) {
|
|
2034
|
+
const numRuns = options.numRuns ?? 25;
|
|
2035
|
+
describe12(`SearchAdapter contract: ${name}`, () => {
|
|
2036
|
+
it13(
|
|
2037
|
+
"index returns count of indexed documents",
|
|
2038
|
+
async () => {
|
|
2039
|
+
const adapter = await build();
|
|
2040
|
+
const result = await adapter.index([
|
|
2041
|
+
{ id: "doc-1", type: "product", fields: { name: "Widget" } },
|
|
2042
|
+
{ id: "doc-2", type: "product", fields: { name: "Gadget" } }
|
|
2043
|
+
]);
|
|
2044
|
+
expect13(result.indexed).toBe(2);
|
|
2045
|
+
},
|
|
2046
|
+
CASE_MS
|
|
2047
|
+
);
|
|
2048
|
+
it13(
|
|
2049
|
+
"search returns indexed documents",
|
|
2050
|
+
async () => {
|
|
2051
|
+
const adapter = await build();
|
|
2052
|
+
await adapter.index([
|
|
2053
|
+
{ id: "search-doc-1", type: "product", fields: { name: "Searchable Item" } }
|
|
2054
|
+
]);
|
|
2055
|
+
const result = await adapter.search("Searchable");
|
|
2056
|
+
expect13(result.items.length).toBeGreaterThanOrEqual(1);
|
|
2057
|
+
expect13(result.total).toBeGreaterThanOrEqual(1);
|
|
2058
|
+
},
|
|
2059
|
+
CASE_MS
|
|
2060
|
+
);
|
|
2061
|
+
it13(
|
|
2062
|
+
"remove deletes documents from index",
|
|
2063
|
+
async () => {
|
|
2064
|
+
const adapter = await build();
|
|
2065
|
+
const tag = `remove-${randomUUID5()}`;
|
|
2066
|
+
await adapter.index([{ id: tag, type: "product", fields: { name: "To Remove" } }]);
|
|
2067
|
+
await adapter.remove([tag]);
|
|
2068
|
+
const result = await adapter.search("To Remove");
|
|
2069
|
+
expect13(result.items.find((i) => i.id === tag)).toBeUndefined();
|
|
2070
|
+
},
|
|
2071
|
+
CASE_MS
|
|
2072
|
+
);
|
|
2073
|
+
it13(
|
|
2074
|
+
"facet returns values for a field",
|
|
2075
|
+
async () => {
|
|
2076
|
+
const adapter = await build();
|
|
2077
|
+
await adapter.index([
|
|
2078
|
+
{ id: "facet-doc-1", type: "product", fields: { category: "shirts" } },
|
|
2079
|
+
{ id: "facet-doc-2", type: "product", fields: { category: "trousers" } },
|
|
2080
|
+
{ id: "facet-doc-3", type: "product", fields: { category: "shirts" } }
|
|
2081
|
+
]);
|
|
2082
|
+
const facet = await adapter.facet("category");
|
|
2083
|
+
expect13(typeof facet.field).toBe("string");
|
|
2084
|
+
expect13(typeof facet.label).toBe("string");
|
|
2085
|
+
expect13(Array.isArray(facet.values)).toBe(true);
|
|
2086
|
+
if (facet.values.length > 0) {
|
|
2087
|
+
expect13(typeof facet.values[0].value).toBe("string");
|
|
2088
|
+
}
|
|
2089
|
+
},
|
|
2090
|
+
CASE_MS
|
|
2091
|
+
);
|
|
2092
|
+
healthAndDowntime(
|
|
2093
|
+
build,
|
|
2094
|
+
options.unreachable,
|
|
2095
|
+
"search()",
|
|
2096
|
+
(adapter) => adapter.search("contract", { limit: 1 })
|
|
2097
|
+
);
|
|
2098
|
+
describe12("pages, ranking, facets and hostile input", () => {
|
|
2099
|
+
it13(
|
|
2100
|
+
"over generated catalogues and page sizes, the cursor walk yields each oracle match exactly once, total is exact on every page, nextCursor is null exactly on the last page and no page exceeds limit, with and without a category filter, after removals and re-indexes",
|
|
2101
|
+
async () => {
|
|
2102
|
+
const adapter = await build();
|
|
2103
|
+
await fc5.assert(
|
|
2104
|
+
fc5.asyncProperty(
|
|
2105
|
+
// The page size first: fast-check shrinks the arguments in order, so a failing walk shrinks its limit
|
|
2106
|
+
// before its catalogue, and the catalogue's products are shrunk once there are few of them.
|
|
2107
|
+
fc5.integer({ min: 1, max: 40 }),
|
|
2108
|
+
fc5.array(fc5.record({ first: genProduct, second: fc5.option(genProduct) }), {
|
|
2109
|
+
maxLength: 300,
|
|
2110
|
+
size: "large"
|
|
2111
|
+
}),
|
|
2112
|
+
fc5.array(word, { maxLength: 2 }),
|
|
2113
|
+
fc5.integer({ min: 0, max: CATEGORIES - 1 }),
|
|
2114
|
+
fc5.func(fc5.boolean()),
|
|
2115
|
+
async (limit, catalogue, queryWords, filterCategory, removeIt) => {
|
|
2116
|
+
const token = runToken();
|
|
2117
|
+
const cats = runCategories(token);
|
|
2118
|
+
const first = catalogue.map((c, i) => materialise(token, c.first, i, cats));
|
|
2119
|
+
try {
|
|
2120
|
+
const indexed = await adapter.index(first.map(toSearchDocument));
|
|
2121
|
+
expect13(indexed.indexed).toBe(first.length);
|
|
2122
|
+
const current = catalogue.map(
|
|
2123
|
+
(c, i) => c.second === null ? first[i] : materialise(token, c.second, i, cats)
|
|
2124
|
+
);
|
|
2125
|
+
const rewritten = current.filter((_, i) => catalogue[i].second !== null);
|
|
2126
|
+
if (rewritten.length > 0) await adapter.index(rewritten.map(toSearchDocument));
|
|
2127
|
+
const removed = current.filter((p) => removeIt(p.id));
|
|
2128
|
+
if (removed.length > 0) await adapter.remove(removed.map((p) => p.id));
|
|
2129
|
+
const gone = new Set(removed.map((p) => p.id));
|
|
2130
|
+
const live = current.filter((p) => !gone.has(p.id));
|
|
2131
|
+
const query = [token, ...queryWords].join(" ");
|
|
2132
|
+
const matches = live.filter((p) => matchesQuery(p, query));
|
|
2133
|
+
const all = await walk4(adapter, query, limit, matches.length);
|
|
2134
|
+
expectExactWalk(
|
|
2135
|
+
all,
|
|
2136
|
+
matches.map((p) => p.id),
|
|
2137
|
+
limit,
|
|
2138
|
+
`"${query}" limit ${limit}`
|
|
2139
|
+
);
|
|
2140
|
+
const category = cats[filterCategory];
|
|
2141
|
+
const inCategory = matches.filter((p) => p.categoryIds.includes(category));
|
|
2142
|
+
const filtered = await walk4(adapter, query, limit, inCategory.length, {
|
|
2143
|
+
categoryIds: category
|
|
2144
|
+
});
|
|
2145
|
+
expectExactWalk(
|
|
2146
|
+
filtered,
|
|
2147
|
+
inCategory.map((p) => p.id),
|
|
2148
|
+
limit,
|
|
2149
|
+
`"${query}" in ${category} limit ${limit}`
|
|
2150
|
+
);
|
|
2151
|
+
const shown = [...idsOf2(all), ...idsOf2(filtered)];
|
|
2152
|
+
expect13(
|
|
2153
|
+
shown.filter((id) => gone.has(id)),
|
|
2154
|
+
"a removed id came back"
|
|
2155
|
+
).toEqual([]);
|
|
2156
|
+
} finally {
|
|
2157
|
+
await adapter.remove(first.map((p) => p.id));
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
),
|
|
2161
|
+
{ numRuns }
|
|
2162
|
+
);
|
|
2163
|
+
},
|
|
2164
|
+
SLOW6
|
|
2165
|
+
);
|
|
2166
|
+
if (options.ranking) {
|
|
2167
|
+
it13(
|
|
2168
|
+
"for any query, every all-terms-in-name match ranks above every other match and every name/SKU/variant-name match above every description-only match, however often the description repeats the term, and the same query walks in the same order twice",
|
|
2169
|
+
async () => {
|
|
2170
|
+
const adapter = await build();
|
|
2171
|
+
const placed = fc5.record({
|
|
2172
|
+
base: genProduct,
|
|
2173
|
+
inName: fc5.boolean(),
|
|
2174
|
+
inSku: fc5.boolean(),
|
|
2175
|
+
inVariant: fc5.boolean(),
|
|
2176
|
+
descriptionRepeats: fc5.integer({ min: 0, max: 10 })
|
|
2177
|
+
});
|
|
2178
|
+
await fc5.assert(
|
|
2179
|
+
fc5.asyncProperty(
|
|
2180
|
+
fc5.array(placed, { minLength: 1, maxLength: 60 }),
|
|
2181
|
+
word,
|
|
2182
|
+
fc5.option(word),
|
|
2183
|
+
fc5.integer({ min: 1, max: 30 }),
|
|
2184
|
+
fc5.integer({ min: 1, max: 30 }),
|
|
2185
|
+
async (catalogue, term, second, limitA, limitB) => {
|
|
2186
|
+
const token = runToken();
|
|
2187
|
+
const cats = runCategories(token);
|
|
2188
|
+
const products = catalogue.map((c, i) => {
|
|
2189
|
+
const p = materialise(token, c.base, i, cats);
|
|
2190
|
+
const variants = p.variants.length > 0 ? [...p.variants] : [{ sku: "zz", name: "", priceMinor: 1 }];
|
|
2191
|
+
if (c.inSku) variants[0] = { ...variants[0], sku: `${term}9` };
|
|
2192
|
+
if (c.inVariant)
|
|
2193
|
+
variants[0] = { ...variants[0], name: `${variants[0].name} ${term}` };
|
|
2194
|
+
return {
|
|
2195
|
+
...p,
|
|
2196
|
+
name: c.inName ? `${p.name} ${term}` : p.name,
|
|
2197
|
+
description: `${p.description}${` ${term}`.repeat(c.descriptionRepeats)}`,
|
|
2198
|
+
variants
|
|
2199
|
+
};
|
|
2200
|
+
});
|
|
2201
|
+
try {
|
|
2202
|
+
await adapter.index(products.map(toSearchDocument));
|
|
2203
|
+
const query = [token, term, ...second === null ? [] : [second]].join(" ");
|
|
2204
|
+
const byId = new Map(products.map((p) => [p.id, p]));
|
|
2205
|
+
const matches = products.filter((p) => matchesQuery(p, query));
|
|
2206
|
+
const order = idsOf2(await walk4(adapter, query, limitA, matches.length));
|
|
2207
|
+
expect13([...order].sort(), "the walk is not the match set").toEqual(
|
|
2208
|
+
matches.map((p) => p.id).sort()
|
|
2209
|
+
);
|
|
2210
|
+
const tiers = order.map((id) => tierOf(byId.get(id), query));
|
|
2211
|
+
for (let i = 1; i < tiers.length; i++) {
|
|
2212
|
+
expect13(
|
|
2213
|
+
tiers[i - 1],
|
|
2214
|
+
`"${query}": ${order[i - 1]} (tier ${tiers[i - 1]}) ranked above ${order[i]} (tier ${tiers[i]})`
|
|
2215
|
+
).toBeGreaterThanOrEqual(tiers[i]);
|
|
2216
|
+
}
|
|
2217
|
+
const again = idsOf2(await walk4(adapter, query, limitB, matches.length));
|
|
2218
|
+
expect13(again, `"${query}" walked at limit ${limitA}, then ${limitB}`).toEqual(
|
|
2219
|
+
order
|
|
2220
|
+
);
|
|
2221
|
+
} finally {
|
|
2222
|
+
await adapter.remove(products.map((p) => p.id));
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
),
|
|
2226
|
+
{ numRuns }
|
|
2227
|
+
);
|
|
2228
|
+
},
|
|
2229
|
+
SLOW6
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
if (options.facets) {
|
|
2233
|
+
it13(
|
|
2234
|
+
"facet('categoryIds', {query, filters}) counts, per category id, exactly the oracle's matches in that category under the filters, lists no category without one, and orders by count descending, then value",
|
|
2235
|
+
async () => {
|
|
2236
|
+
const adapter = await build();
|
|
2237
|
+
expect13(adapter.capabilities()).toContain("facet");
|
|
2238
|
+
await fc5.assert(
|
|
2239
|
+
fc5.asyncProperty(
|
|
2240
|
+
fc5.array(genProduct, { maxLength: 150, size: "medium" }),
|
|
2241
|
+
fc5.array(word, { maxLength: 2 }),
|
|
2242
|
+
fc5.option(fc5.integer({ min: 0, max: CATEGORIES - 1 })),
|
|
2243
|
+
async (catalogue, queryWords, filterCategory) => {
|
|
2244
|
+
const token = runToken();
|
|
2245
|
+
const cats = runCategories(token);
|
|
2246
|
+
const products = catalogue.map((g, i) => materialise(token, g, i, cats));
|
|
2247
|
+
try {
|
|
2248
|
+
await adapter.index(products.map(toSearchDocument));
|
|
2249
|
+
const query = [token, ...queryWords].join(" ");
|
|
2250
|
+
const filter = filterCategory === null ? void 0 : cats[filterCategory];
|
|
2251
|
+
const matches = products.filter(
|
|
2252
|
+
(p) => matchesQuery(p, query) && (filter === void 0 || p.categoryIds.includes(filter))
|
|
2253
|
+
);
|
|
2254
|
+
const expected = /* @__PURE__ */ new Map();
|
|
2255
|
+
for (const p of matches)
|
|
2256
|
+
for (const c of p.categoryIds) expected.set(c, (expected.get(c) ?? 0) + 1);
|
|
2257
|
+
const facet = await adapter.facet("categoryIds", {
|
|
2258
|
+
query,
|
|
2259
|
+
...filter !== void 0 && { filters: { categoryIds: filter } }
|
|
2260
|
+
});
|
|
2261
|
+
const label = `facet for "${query}"${filter === void 0 ? "" : ` in ${filter}`}`;
|
|
2262
|
+
expect13(
|
|
2263
|
+
Object.fromEntries(facet.values.map((v) => [v.value, v.count])),
|
|
2264
|
+
label
|
|
2265
|
+
).toEqual(Object.fromEntries(expected));
|
|
2266
|
+
const ordered = [...expected.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(([value]) => value);
|
|
2267
|
+
expect13(
|
|
2268
|
+
facet.values.map((v) => v.value),
|
|
2269
|
+
`${label}: order`
|
|
2270
|
+
).toEqual(ordered);
|
|
2271
|
+
} finally {
|
|
2272
|
+
await adapter.remove(products.map((p) => p.id));
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
),
|
|
2276
|
+
{ numRuns }
|
|
2277
|
+
);
|
|
2278
|
+
},
|
|
2279
|
+
SLOW6
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
describe12("hostile input", () => {
|
|
2283
|
+
const operator = fc5.constantFrom(
|
|
2284
|
+
'"',
|
|
2285
|
+
"*",
|
|
2286
|
+
"NEAR",
|
|
2287
|
+
"NEAR(",
|
|
2288
|
+
":",
|
|
2289
|
+
"&",
|
|
2290
|
+
"|",
|
|
2291
|
+
"!",
|
|
2292
|
+
"(",
|
|
2293
|
+
")",
|
|
2294
|
+
"-",
|
|
2295
|
+
"^",
|
|
2296
|
+
"AND",
|
|
2297
|
+
"OR",
|
|
2298
|
+
"NOT",
|
|
2299
|
+
"'",
|
|
2300
|
+
"\\",
|
|
2301
|
+
"{",
|
|
2302
|
+
"}",
|
|
2303
|
+
"<->",
|
|
2304
|
+
":*",
|
|
2305
|
+
"+",
|
|
2306
|
+
"%",
|
|
2307
|
+
"_",
|
|
2308
|
+
";",
|
|
2309
|
+
"\0"
|
|
2310
|
+
);
|
|
2311
|
+
const fragment = fc5.oneof(
|
|
2312
|
+
fc5.string({ unit: "binary", maxLength: 6 }),
|
|
2313
|
+
operator,
|
|
2314
|
+
word,
|
|
2315
|
+
word.map((w) => w.toUpperCase())
|
|
2316
|
+
);
|
|
2317
|
+
const hostile = fc5.array(fragment, { maxLength: 6 }).map((fs) => fs.join(""));
|
|
2318
|
+
const longTerm = "a".repeat(2047);
|
|
2319
|
+
const plain = { name: "a b", description: "c", variants: [], categories: [0] };
|
|
2320
|
+
it13(
|
|
2321
|
+
"any string after the run token, full unicode and FTS operators included, never throws and returns exactly the oracle's matches for the query's normalised terms",
|
|
2322
|
+
async () => {
|
|
2323
|
+
const adapter = await build();
|
|
2324
|
+
await fc5.assert(
|
|
2325
|
+
fc5.asyncProperty(
|
|
2326
|
+
fc5.array(genProduct, { maxLength: 40 }),
|
|
2327
|
+
hostile,
|
|
2328
|
+
async (catalogue, raw) => {
|
|
2329
|
+
const token = runToken();
|
|
2330
|
+
const cats = runCategories(token);
|
|
2331
|
+
const products = catalogue.map((g, i) => materialise(token, g, i, cats));
|
|
2332
|
+
try {
|
|
2333
|
+
await adapter.index(products.map(toSearchDocument));
|
|
2334
|
+
const query = `${token} ${raw}`;
|
|
2335
|
+
const expected = products.filter((p) => matchesQuery(p, query)).map((p) => p.id);
|
|
2336
|
+
const result = await adapter.search(query, { limit: 100 });
|
|
2337
|
+
expect13(result.items.map((d) => d.id).sort(), JSON.stringify(query)).toEqual(
|
|
2338
|
+
expected.sort()
|
|
2339
|
+
);
|
|
2340
|
+
expect13(result.total, JSON.stringify(query)).toBe(expected.length);
|
|
2341
|
+
} finally {
|
|
2342
|
+
await adapter.remove(products.map((p) => p.id));
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
),
|
|
2346
|
+
{
|
|
2347
|
+
numRuns: numRuns * 2,
|
|
2348
|
+
// A term one byte past Postgres's longest lexeme (2046 bytes; terms are ASCII, so bytes are
|
|
2349
|
+
// characters): in the query, in a description, and in both.
|
|
2350
|
+
examples: [
|
|
2351
|
+
[[plain], longTerm],
|
|
2352
|
+
[[{ ...plain, description: `c ${longTerm}` }], ""],
|
|
2353
|
+
[[{ ...plain, description: longTerm }], longTerm]
|
|
2354
|
+
]
|
|
2355
|
+
}
|
|
2356
|
+
);
|
|
2357
|
+
},
|
|
2358
|
+
SLOW6
|
|
2359
|
+
);
|
|
2360
|
+
it13(
|
|
2361
|
+
"input with no terms returns no items, total 0 and no next page, over an index that holds products",
|
|
2362
|
+
async () => {
|
|
2363
|
+
const adapter = await build();
|
|
2364
|
+
const token = runToken();
|
|
2365
|
+
const products = Array.from({ length: 5 }, (_, i) => ({
|
|
2366
|
+
id: `${token}-${i}`,
|
|
2367
|
+
name: `${token} a b`,
|
|
2368
|
+
description: "c d",
|
|
2369
|
+
variants: [{ sku: "a1", name: "b", priceMinor: 100 }],
|
|
2370
|
+
categoryIds: []
|
|
2371
|
+
}));
|
|
2372
|
+
await adapter.index(products.map(toSearchDocument));
|
|
2373
|
+
try {
|
|
2374
|
+
const termless = fc5.array(
|
|
2375
|
+
fc5.oneof(
|
|
2376
|
+
operator,
|
|
2377
|
+
fc5.string({ unit: "binary", maxLength: 4 }),
|
|
2378
|
+
fc5.constantFrom(" ", " ", "\n")
|
|
2379
|
+
),
|
|
2380
|
+
{ maxLength: 6 }
|
|
2381
|
+
).map((fs) => fs.join("")).filter((s) => searchTerms(s).length === 0);
|
|
2382
|
+
await fc5.assert(
|
|
2383
|
+
fc5.asyncProperty(termless, async (raw) => {
|
|
2384
|
+
const result = await adapter.search(raw, { limit: 20 });
|
|
2385
|
+
expect13(result.items.length, JSON.stringify(raw)).toBe(0);
|
|
2386
|
+
expect13(result.total, JSON.stringify(raw)).toBe(0);
|
|
2387
|
+
expect13(result.nextCursor, JSON.stringify(raw)).toBeNull();
|
|
2388
|
+
}),
|
|
2389
|
+
{
|
|
2390
|
+
numRuns: numRuns * 2,
|
|
2391
|
+
examples: [[""], [" "], [" "], ["*"], ['"'], ["-"], ["\xE9"]]
|
|
2392
|
+
}
|
|
2393
|
+
);
|
|
2394
|
+
} finally {
|
|
2395
|
+
await adapter.remove(products.map((p) => p.id));
|
|
2396
|
+
}
|
|
2397
|
+
},
|
|
2398
|
+
SLOW6
|
|
2399
|
+
);
|
|
2400
|
+
it13(
|
|
2401
|
+
"a malformed cursor (undecodable, negative, non-integer) rejects, and a cursor at or past total, as after a removal between two pages, is an empty page with nextCursor null",
|
|
2402
|
+
async () => {
|
|
2403
|
+
const adapter = await build();
|
|
2404
|
+
const token = runToken();
|
|
2405
|
+
const products = Array.from({ length: 6 }, (_, i) => ({
|
|
2406
|
+
id: `${token}-${i}`,
|
|
2407
|
+
name: `${token} cursor ${i}`,
|
|
2408
|
+
description: "",
|
|
2409
|
+
variants: [],
|
|
2410
|
+
categoryIds: []
|
|
2411
|
+
}));
|
|
2412
|
+
await adapter.index(products.map(toSearchDocument));
|
|
2413
|
+
try {
|
|
2414
|
+
const first = await adapter.search(token, { limit: 4 });
|
|
2415
|
+
expect13(first.total).toBe(products.length);
|
|
2416
|
+
expect13(first.nextCursor, "six matches at limit 4 have a second page").not.toBeNull();
|
|
2417
|
+
for (const cursor of ["not a cursor", "%%%", "!!"]) {
|
|
2418
|
+
await expect13(
|
|
2419
|
+
adapter.search(token, { limit: 2, cursor }),
|
|
2420
|
+
`cursor ${cursor}`
|
|
2421
|
+
).rejects.toThrow();
|
|
2422
|
+
}
|
|
2423
|
+
if (options.cursorAt !== void 0) {
|
|
2424
|
+
for (const offset of [-1, -20, 1.5, 0.25]) {
|
|
2425
|
+
await expect13(
|
|
2426
|
+
adapter.search(token, { limit: 2, cursor: options.cursorAt(offset) }),
|
|
2427
|
+
`cursor at offset ${offset}`
|
|
2428
|
+
).rejects.toThrow();
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
const kept = products.slice(0, 2);
|
|
2432
|
+
await adapter.remove(products.slice(2).map((p) => p.id));
|
|
2433
|
+
const stale = await adapter.search(token, { limit: 4, cursor: first.nextCursor });
|
|
2434
|
+
expect13(stale.items.length).toBe(0);
|
|
2435
|
+
expect13(stale.nextCursor).toBeNull();
|
|
2436
|
+
expect13(stale.total).toBe(kept.length);
|
|
2437
|
+
if (options.cursorAt !== void 0) {
|
|
2438
|
+
for (const offset of [kept.length, kept.length + 7]) {
|
|
2439
|
+
const past = await adapter.search(token, {
|
|
2440
|
+
limit: 4,
|
|
2441
|
+
cursor: options.cursorAt(offset)
|
|
2442
|
+
});
|
|
2443
|
+
expect13(past.items.length, `offset ${offset}`).toBe(0);
|
|
2444
|
+
expect13(past.nextCursor, `offset ${offset}`).toBeNull();
|
|
2445
|
+
expect13(past.total, `offset ${offset}`).toBe(kept.length);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
} finally {
|
|
2449
|
+
await adapter.remove(products.map((p) => p.id));
|
|
2450
|
+
}
|
|
2451
|
+
},
|
|
2452
|
+
SLOW6
|
|
2453
|
+
);
|
|
2454
|
+
});
|
|
2455
|
+
});
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// src/shipping.ts
|
|
2460
|
+
import { describe as describe13, expect as expect14, it as it14 } from "vitest";
|
|
2461
|
+
var REQUEST = {
|
|
2462
|
+
fromPostalCode: "SW1A 1AA",
|
|
2463
|
+
toPostalCode: "EC1A 1BB",
|
|
2464
|
+
toCountry: "GB",
|
|
2465
|
+
weightGrams: 500,
|
|
2466
|
+
currency: "GBP"
|
|
2467
|
+
};
|
|
2468
|
+
function runShippingContract(name, build, options = {}) {
|
|
2469
|
+
describe13(`ShippingAdapter contract: ${name}`, () => {
|
|
2470
|
+
it14(
|
|
2471
|
+
"rate returns an array of shipping options",
|
|
2472
|
+
async () => {
|
|
2473
|
+
const adapter = await build();
|
|
2474
|
+
const rates = await adapter.rate(REQUEST);
|
|
2475
|
+
expect14(Array.isArray(rates)).toBe(true);
|
|
2476
|
+
if (rates.length > 0) {
|
|
2477
|
+
const rate = rates[0];
|
|
2478
|
+
expect14(typeof rate.amountMinor).toBe("number");
|
|
2479
|
+
expect14(typeof rate.carrier).toBe("string");
|
|
2480
|
+
expect14(typeof rate.estimatedDays).toBe("number");
|
|
2481
|
+
}
|
|
2482
|
+
},
|
|
2483
|
+
CASE_MS
|
|
2484
|
+
);
|
|
2485
|
+
healthAndDowntime(build, options.unreachable, "rate()", (adapter) => adapter.rate(REQUEST));
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// src/tax.ts
|
|
2490
|
+
import { describe as describe14, expect as expect15, it as it15 } from "vitest";
|
|
2491
|
+
var REQUEST2 = {
|
|
2492
|
+
currency: "GBP",
|
|
2493
|
+
shippingCountry: "GB",
|
|
2494
|
+
shippingRegion: "England",
|
|
2495
|
+
lines: [{ amountMinor: 1e3, qty: 2 }]
|
|
2496
|
+
};
|
|
2497
|
+
function runTaxContract(name, build, options = {}) {
|
|
2498
|
+
describe14(`TaxAdapter contract: ${name}`, () => {
|
|
2499
|
+
it15(
|
|
2500
|
+
"calculate returns totalTaxMinor >= 0",
|
|
2501
|
+
async () => {
|
|
2502
|
+
const adapter = await build();
|
|
2503
|
+
const result = await adapter.calculate(REQUEST2);
|
|
2504
|
+
expect15(typeof result.totalTaxMinor).toBe("number");
|
|
2505
|
+
expect15(result.totalTaxMinor).toBeGreaterThanOrEqual(0);
|
|
2506
|
+
expect15(result.ratePerLine).toHaveLength(1);
|
|
2507
|
+
expect15(typeof result.ratePerLine[0].taxMinor).toBe("number");
|
|
2508
|
+
},
|
|
2509
|
+
CASE_MS
|
|
2510
|
+
);
|
|
2511
|
+
it15(
|
|
2512
|
+
"calculate with zero amount returns zero tax",
|
|
2513
|
+
async () => {
|
|
2514
|
+
const adapter = await build();
|
|
2515
|
+
const result = await adapter.calculate({
|
|
2516
|
+
...REQUEST2,
|
|
2517
|
+
lines: [{ amountMinor: 0, qty: 1 }]
|
|
2518
|
+
});
|
|
2519
|
+
expect15(result.totalTaxMinor).toBe(0);
|
|
2520
|
+
},
|
|
2521
|
+
CASE_MS
|
|
2522
|
+
);
|
|
2523
|
+
healthAndDowntime(
|
|
2524
|
+
build,
|
|
2525
|
+
options.unreachable,
|
|
2526
|
+
"calculate()",
|
|
2527
|
+
(adapter) => adapter.calculate(REQUEST2)
|
|
2528
|
+
);
|
|
2529
|
+
});
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// src/adapter-context.ts
|
|
2533
|
+
var silent = {
|
|
2534
|
+
trace: () => void 0,
|
|
2535
|
+
debug: () => void 0,
|
|
2536
|
+
info: () => void 0,
|
|
2537
|
+
warn: () => void 0,
|
|
2538
|
+
error: () => void 0,
|
|
2539
|
+
child: () => silent
|
|
2540
|
+
};
|
|
2541
|
+
function adapterContext(slot, config, env = {}) {
|
|
2542
|
+
return { slot, config, logger: silent, env };
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// src/security.ts
|
|
2546
|
+
import { describe as describe15, expect as expect16, it as it16 } from "vitest";
|
|
2547
|
+
var testConfig = {
|
|
2548
|
+
rateLimit: {
|
|
2549
|
+
api: { requests: 100, window: "1m", action: "block" }
|
|
2550
|
+
},
|
|
2551
|
+
csrf: false,
|
|
2552
|
+
headers: {
|
|
2553
|
+
hsts: true,
|
|
2554
|
+
contentSecurityPolicy: "default-src 'self'",
|
|
2555
|
+
referrerPolicy: "strict-origin-when-cross-origin",
|
|
2556
|
+
xFrameOptions: "DENY"
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
function runSecurityContract(name, build, options = {}) {
|
|
2560
|
+
describe15(`SecurityAdapter contract: ${name}`, () => {
|
|
2561
|
+
it16(
|
|
2562
|
+
"validate accepts a valid config",
|
|
2563
|
+
async () => {
|
|
2564
|
+
const adapter = await build();
|
|
2565
|
+
const result = await adapter.validate(testConfig);
|
|
2566
|
+
expect16(typeof result.valid).toBe("boolean");
|
|
2567
|
+
expect16(Array.isArray(result.errors)).toBe(true);
|
|
2568
|
+
},
|
|
2569
|
+
CASE_MS
|
|
2570
|
+
);
|
|
2571
|
+
it16(
|
|
2572
|
+
"validate of empty config reports errors",
|
|
2573
|
+
async () => {
|
|
2574
|
+
const adapter = await build();
|
|
2575
|
+
const result = await adapter.validate({});
|
|
2576
|
+
if (!result.valid) {
|
|
2577
|
+
expect16(result.errors.length).toBeGreaterThan(0);
|
|
2578
|
+
}
|
|
2579
|
+
},
|
|
2580
|
+
CASE_MS
|
|
2581
|
+
);
|
|
2582
|
+
healthAndDowntime(
|
|
2583
|
+
build,
|
|
2584
|
+
options.unreachable,
|
|
2585
|
+
"validate()",
|
|
2586
|
+
async (adapter) => adapter.validate(testConfig)
|
|
2587
|
+
);
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// src/projection.ts
|
|
2592
|
+
import { describe as describe16, it as it17, expect as expect17 } from "vitest";
|
|
2593
|
+
function makeEvent2(id) {
|
|
2594
|
+
return {
|
|
2595
|
+
id,
|
|
2596
|
+
type: "test.event.v1",
|
|
2597
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2598
|
+
source: "test",
|
|
2599
|
+
correlationId: "corr-1",
|
|
2600
|
+
payload: { id },
|
|
2601
|
+
metadata: { storeId: "test", environment: "development", version: "0.0.0" }
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
function runProjectionContract(opts) {
|
|
2605
|
+
describe16("Projection idempotency contract", () => {
|
|
2606
|
+
it17("processing the same event twice applies the mutation only once", async () => {
|
|
2607
|
+
const ctx = await opts.build();
|
|
2608
|
+
const event2 = makeEvent2("dedup-evt-001");
|
|
2609
|
+
await ctx.handle(event2);
|
|
2610
|
+
await ctx.handle(event2);
|
|
2611
|
+
expect17(await ctx.applicationCount()).toBe(1);
|
|
2612
|
+
});
|
|
2613
|
+
it17("distinct events each apply their mutation", async () => {
|
|
2614
|
+
const ctx = await opts.build();
|
|
2615
|
+
await ctx.handle(makeEvent2("distinct-evt-001"));
|
|
2616
|
+
await ctx.handle(makeEvent2("distinct-evt-002"));
|
|
2617
|
+
expect17(await ctx.applicationCount()).toBe(2);
|
|
2618
|
+
});
|
|
2619
|
+
it17("after reprocessing a batch the count equals unique event count", async () => {
|
|
2620
|
+
const ctx = await opts.build();
|
|
2621
|
+
const events = [makeEvent2("batch-001"), makeEvent2("batch-002"), makeEvent2("batch-003")];
|
|
2622
|
+
for (const e of events) await ctx.handle(e);
|
|
2623
|
+
for (const e of events) await ctx.handle(e);
|
|
2624
|
+
expect17(await ctx.applicationCount()).toBe(3);
|
|
2625
|
+
});
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// src/index.ts
|
|
2630
|
+
async function fetchOverHttp(url) {
|
|
2631
|
+
const response = await fetch(url);
|
|
2632
|
+
if (!response.ok) throw new Error(`GET ${url} answered ${response.status}`);
|
|
2633
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
2634
|
+
}
|
|
2635
|
+
var suites = {
|
|
2636
|
+
payments: runPaymentsContract,
|
|
2637
|
+
email: runEmailContract,
|
|
2638
|
+
reviews: runReviewsContract,
|
|
2639
|
+
search: runSearchContract,
|
|
2640
|
+
analytics: runAnalyticsContract,
|
|
2641
|
+
shipping: runShippingContract,
|
|
2642
|
+
tax: runTaxContract,
|
|
2643
|
+
ai: runAiContract,
|
|
2644
|
+
documentStore: runDocumentStoreContract,
|
|
2645
|
+
eventBus: runEventBusContract,
|
|
2646
|
+
media: (name, build, options = {}) => runMediaStorageContract(name, build, {
|
|
2647
|
+
fetchUrl: fetchOverHttp,
|
|
2648
|
+
...options
|
|
2649
|
+
})
|
|
2650
|
+
};
|
|
2651
|
+
var contractSuites = suites;
|
|
2652
|
+
export {
|
|
2653
|
+
BOUND_MS,
|
|
2654
|
+
CONTRACT_KINDS,
|
|
2655
|
+
adapterContext,
|
|
2656
|
+
contractSuites,
|
|
2657
|
+
matchesQuery,
|
|
2658
|
+
runAiContract,
|
|
2659
|
+
runAnalyticsContract,
|
|
2660
|
+
runDocumentStoreContract,
|
|
2661
|
+
runEmailContract,
|
|
2662
|
+
runEventBusContract,
|
|
2663
|
+
runMediaStorageContract,
|
|
2664
|
+
runPaymentsContract,
|
|
2665
|
+
runProjectionContract,
|
|
2666
|
+
runReviewsContract,
|
|
2667
|
+
runSearchContract,
|
|
2668
|
+
runSecurityContract,
|
|
2669
|
+
runShippingContract,
|
|
2670
|
+
runTaxContract,
|
|
2671
|
+
searchTerms,
|
|
2672
|
+
toSearchDocument
|
|
2673
|
+
};
|
|
2674
|
+
//# sourceMappingURL=index.js.map
|