@classytic/arc-testkit 0.4.0 → 0.6.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/story.mjs ADDED
@@ -0,0 +1,330 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ // src/story/runner.ts
3
+ /**
4
+ * The step runner of an end-to-end story: one continuous scenario through a booted app, each step a
5
+ * numbered vitest test whose failures name the clauses (requirements, contract lines) it proves.
6
+ *
7
+ * describeStory("checkout", (s) => {
8
+ * s.step("customer places an order", { clauses: ["ORD-1"] }, async (t) => {
9
+ * t.expect(res.status, "the order is accepted").toBe(201); // "[ORD-1] the order is accepted"
10
+ * t.known("DEF-7", rows.length, "one row per line").toBe(2); // soft: reported, the step goes on
11
+ * });
12
+ * });
13
+ *
14
+ * Tests inside a file run in order, so a story's steps hand state to each other through the host's
15
+ * own bag (`need` reads it with a clear error); once a step fails, later steps are skipped by
16
+ * default rather than failing in confusing ways.
17
+ */
18
+ /** `[REQ-1 | Reminders] the reminder reached the holder`. */
19
+ function clauseMessage(clauses, what) {
20
+ const list = clauses === void 0 ? [] : typeof clauses === "string" ? [clauses] : [...clauses];
21
+ return list.length === 0 ? what : `[${list.join(" | ")}] ${what}`;
22
+ }
23
+ /**
24
+ * A test function whose first parameter destructures `skip` and `fixtures`. Vitest reads that pattern
25
+ * from the function's source to decide which fixtures to build, and it supports neither a rest element
26
+ * nor a plain `ctx`, so the pattern is spelled out for the names the host lists.
27
+ */
28
+ function withFixtures(fixtures, run) {
29
+ for (const name of fixtures) if (!/^[A-Za-z_$][\w$]*$/.test(name)) throw new Error(`"${name}" is not a fixture name`);
30
+ const names = ["skip", ...fixtures.filter((n) => n !== "skip")];
31
+ return new Function("run", `return async ({ ${names.join(", ")} }) => run({ ${names.join(", ")} });`)(run);
32
+ }
33
+ function listOf(clauses) {
34
+ return clauses === void 0 ? [] : typeof clauses === "string" ? [clauses] : [...clauses];
35
+ }
36
+ /** Registers a `describe` holding the story's numbered steps. */
37
+ function describeStory(title, define, options = {}) {
38
+ const bail = options.bail ?? true;
39
+ let number = options.firstStep ?? 1;
40
+ let failedAt;
41
+ describe(title, () => {
42
+ define({
43
+ get next() {
44
+ return number;
45
+ },
46
+ step(stepTitle, a, b) {
47
+ const run = typeof a === "function" ? a : b;
48
+ const stepOptions = typeof a === "function" ? {} : a;
49
+ if (run === void 0) throw new Error(`step "${stepTitle}" has no body`);
50
+ const n = number++;
51
+ const clauses = [...listOf(options.clauses), ...listOf(stepOptions.clauses)];
52
+ const t = {
53
+ n,
54
+ title: stepTitle,
55
+ clauses,
56
+ msg: (what, extra) => clauseMessage([...clauses, ...listOf(extra)], what),
57
+ expect: (actual, what, extra) => expect(actual, clauseMessage([...clauses, ...listOf(extra)], what)),
58
+ soft: (actual, what, extra) => expect.soft(actual, clauseMessage([...clauses, ...listOf(extra)], what)),
59
+ known: (defect, actual, what, extra) => expect.soft(actual, clauseMessage([
60
+ `KNOWN DEFECT ${defect}`,
61
+ ...clauses,
62
+ ...listOf(extra)
63
+ ], what))
64
+ };
65
+ (options.test ?? it)(`${n}. ${stepTitle}`, withFixtures(options.fixtures ?? [], async (ctx) => {
66
+ if (bail && failedAt !== void 0) ctx.skip(`step ${failedAt} failed; the story cannot continue`);
67
+ try {
68
+ await run(t, ctx);
69
+ } catch (error) {
70
+ failedAt ??= n;
71
+ throw error;
72
+ }
73
+ }), stepOptions.timeout);
74
+ }
75
+ });
76
+ });
77
+ }
78
+ /** Read a handed-over value, with an error that names the missing step instead of a bare `undefined`. */
79
+ function need(bag, key) {
80
+ const value = bag[key];
81
+ if (value === void 0) throw new Error(`the story needs "${key}" from an earlier step (that step failed or did not run)`);
82
+ return value;
83
+ }
84
+ // src/story/assertions.ts
85
+ /**
86
+ * Story assertions against the places an app leaves evidence: the outbox / event log, a user's
87
+ * in-app feed, the dev mail outbox and the audit rows. The kit knows none of them: the host injects
88
+ * a reader per place (`StoryPorts`) and the assertions read through it. A missing port is an error
89
+ * at the call, not a silent pass.
90
+ */
91
+ /** True when `actual` carries every field of `expected` (RegExp and function leaves are tested, not compared). */
92
+ function subsetMatches(actual, expected) {
93
+ if (expected instanceof RegExp) return typeof actual === "string" && expected.test(actual);
94
+ if (typeof expected === "function") return Boolean(expected(actual));
95
+ if (expected === null || typeof expected !== "object") return Object.is(actual, expected);
96
+ if (Array.isArray(expected)) return Array.isArray(actual) && expected.every((e) => actual.some((a) => subsetMatches(a, e)));
97
+ if (actual === null || typeof actual !== "object") return false;
98
+ return Object.entries(expected).every(([k, v]) => subsetMatches(actual[k], v));
99
+ }
100
+ function matches(row, match, keyField) {
101
+ if (typeof match === "string") return row[keyField] === match;
102
+ if (match instanceof RegExp) return typeof row[keyField] === "string" && match.test(row[keyField]);
103
+ if (typeof match === "function") return Boolean(match(row));
104
+ return subsetMatches(row, match);
105
+ }
106
+ function check(count, spec, what) {
107
+ const message = clauseMessage(spec.clauses, what);
108
+ if (spec.count !== void 0) expect(count, `${message} (expected exactly ${spec.count})`).toBe(spec.count);
109
+ else expect(count, `${message} (expected at least ${spec.min ?? 1})`).toBeGreaterThanOrEqual(spec.min ?? 1);
110
+ }
111
+ function port(value, name) {
112
+ if (value === void 0) throw new Error(`this story asserts on ${name}, but no \`${name}\` port was supplied`);
113
+ return value;
114
+ }
115
+ function createStoryAssertions(ports) {
116
+ const typeKey = ports.eventTypeKey ?? "type";
117
+ const eventsAfter = async (since = 0) => [...await port(ports.events, "events")()].slice(since);
118
+ const matching = async (rows, match, key) => [...await rows].filter((r) => matches(r, match, key));
119
+ const feedKey = () => "title";
120
+ const self = {
121
+ async drain(rounds = 3) {
122
+ const drain = port(ports.drain, "drain");
123
+ for (let i = 0; i < rounds; i += 1) await drain();
124
+ },
125
+ relayed(assertion, options = {}) {
126
+ return vi.waitFor(async () => {
127
+ await self.drain();
128
+ return assertion();
129
+ }, {
130
+ timeout: options.timeout ?? 3e4,
131
+ interval: options.interval ?? 50
132
+ });
133
+ },
134
+ async eventsOf(match, since) {
135
+ return matching(await eventsAfter(since), match, typeKey);
136
+ },
137
+ async expectEvent(match, spec = {}) {
138
+ const found = await self.eventsOf(match, spec.since);
139
+ check(found.length, spec, `event ${describeMatch(match)} was recorded`);
140
+ return found;
141
+ },
142
+ async expectNoEvent(match, spec = {}) {
143
+ const found = await self.eventsOf(match, spec.since);
144
+ expect(found.length, clauseMessage(spec.clauses, `no event ${describeMatch(match)} was recorded`)).toBe(0);
145
+ },
146
+ async feedOf(actor) {
147
+ return port(ports.feed, "feed")(actor);
148
+ },
149
+ async expectFeed(actor, match, spec = {}) {
150
+ const found = (await self.feedOf(actor)).filter((i) => matchesFeed(i, match, feedKey()));
151
+ check(found.length, spec, `the feed holds ${describeMatch(match)}`);
152
+ return found;
153
+ },
154
+ async expectNoFeed(actor, match, spec = {}) {
155
+ const found = (await self.feedOf(actor)).filter((i) => matchesFeed(i, match, feedKey()));
156
+ expect(found.length, clauseMessage(spec.clauses, `the feed holds no ${describeMatch(match)}`)).toBe(0);
157
+ },
158
+ async expectMail(match, spec = {}) {
159
+ const found = await matching(port(ports.mail, "mail")(), match, "subject");
160
+ check(found.length, spec, `a mail ${describeMatch(match)} was sent`);
161
+ return found;
162
+ },
163
+ async expectAudit(filter, match, spec = {}) {
164
+ const found = await matching(port(ports.audit, "audit")(filter), match, "action");
165
+ check(found.length, spec, `an audit row ${describeMatch(match)} exists`);
166
+ return found;
167
+ }
168
+ };
169
+ return self;
170
+ }
171
+ /** A feed item matches a string / RegExp on its title or message. */
172
+ function matchesFeed(item, match, key) {
173
+ if (typeof match === "string" || match instanceof RegExp) return matches(item, match, key) || matches(item, match, "message");
174
+ return matches(item, match, key);
175
+ }
176
+ function describeMatch(match) {
177
+ if (typeof match === "string") return `"${match}"`;
178
+ if (match instanceof RegExp) return String(match);
179
+ if (typeof match === "function") return "(predicate)";
180
+ return JSON.stringify(match);
181
+ }
182
+ /** An in-memory event log fed by a tap on the host's event bus; the `events` port reads it. */
183
+ function createEventLog() {
184
+ const events = [];
185
+ return {
186
+ record: (event) => void events.push(event),
187
+ all: () => events,
188
+ mark: () => events.length,
189
+ clear: () => {
190
+ events.length = 0;
191
+ },
192
+ tap(subscribe, patterns) {
193
+ for (const pattern of patterns) subscribe(pattern, (event) => void events.push(event));
194
+ }
195
+ };
196
+ }
197
+ // src/story/cast.ts
198
+ function createCast(provision) {
199
+ const members = /* @__PURE__ */ new Map();
200
+ const add = async (name, roles, opts) => {
201
+ if (members.has(name)) throw new Error(`cast member "${name}" is already provisioned`);
202
+ const actor = await provision(roles, opts, name);
203
+ members.set(name, actor);
204
+ return actor;
205
+ };
206
+ return {
207
+ add,
208
+ async build(spec) {
209
+ const out = {};
210
+ for (const [name, entry] of Object.entries(spec)) {
211
+ const roles = Array.isArray(entry) ? entry : entry.roles;
212
+ const opts = Array.isArray(entry) ? void 0 : entry.opts;
213
+ out[name] = await add(name, roles, opts);
214
+ }
215
+ return out;
216
+ },
217
+ get(name) {
218
+ const actor = members.get(name);
219
+ if (actor === void 0) throw new Error(`the story needs cast member "${name}" (never provisioned)`);
220
+ return actor;
221
+ },
222
+ names: () => [...members.keys()]
223
+ };
224
+ }
225
+ /** `Authorization` headers for a bearer token. */
226
+ const bearer = (token) => ({ authorization: `Bearer ${token}` });
227
+ // src/story/clock.ts
228
+ const MS_PER_DAY = 864e5;
229
+ /** The civil date (`YYYY-MM-DD`) of an instant in a zone. */
230
+ function civilDateOf(at, zone = "UTC") {
231
+ const parts = new Intl.DateTimeFormat("en-CA", {
232
+ timeZone: zone,
233
+ year: "numeric",
234
+ month: "2-digit",
235
+ day: "2-digit"
236
+ }).formatToParts(at);
237
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
238
+ return `${get("year")}-${get("month")}-${get("day")}`;
239
+ }
240
+ /** A civil date plus whole days. */
241
+ function addCivilDays(day, days) {
242
+ return new Date(Date.parse(`${day}T00:00:00Z`) + days * MS_PER_DAY).toISOString().slice(0, 10);
243
+ }
244
+ /** A civil date plus whole months (same day of month; keep stories off the 29th to 31st). */
245
+ function addCivilMonths(day, months) {
246
+ const [y, m, d] = day.split("-").map(Number);
247
+ return new Date(Date.UTC(y, m - 1 + months, d)).toISOString().slice(0, 10);
248
+ }
249
+ /** Whole days from civil date `a` to civil date `b`. */
250
+ function daysBetween(a, b) {
251
+ return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / MS_PER_DAY);
252
+ }
253
+ function offsetMs(at, zone) {
254
+ const parts = new Intl.DateTimeFormat("en-US", {
255
+ timeZone: zone,
256
+ hourCycle: "h23",
257
+ year: "numeric",
258
+ month: "2-digit",
259
+ day: "2-digit",
260
+ hour: "2-digit",
261
+ minute: "2-digit",
262
+ second: "2-digit"
263
+ }).formatToParts(new Date(at));
264
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
265
+ return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")) - Math.floor(at / 1e3) * 1e3;
266
+ }
267
+ /** The instant at `hour:00` (default noon) on a civil date in a zone: a stable "that day". */
268
+ function instantOn(day, zone = "UTC", hour = 12) {
269
+ const guess = Date.parse(`${day}T00:00:00Z`) + hour * 36e5;
270
+ const first = guess - offsetMs(guess, zone);
271
+ return new Date(guess - offsetMs(first, zone));
272
+ }
273
+ function createStoryClock(options = {}) {
274
+ const zone = options.zone ?? "UTC";
275
+ let current = new Date(options.start ?? Date.now());
276
+ const listeners = /* @__PURE__ */ new Set();
277
+ const move = (to) => {
278
+ current = to;
279
+ for (const listener of listeners) listener(new Date(current));
280
+ };
281
+ return {
282
+ zone,
283
+ now: () => new Date(current),
284
+ set: (at) => move(new Date(at)),
285
+ advanceMs: (ms) => move(new Date(current.getTime() + ms)),
286
+ advanceDays: (days) => move(new Date(current.getTime() + days * MS_PER_DAY)),
287
+ travelTo: (day, hour = 12) => {
288
+ const at = instantOn(day, zone, hour);
289
+ move(at);
290
+ return new Date(at);
291
+ },
292
+ today: () => civilDateOf(current, zone),
293
+ onChange: (listener) => {
294
+ listeners.add(listener);
295
+ return () => listeners.delete(listener);
296
+ }
297
+ };
298
+ }
299
+ // src/story/idempotency.ts
300
+ /**
301
+ * Run-twice idempotency for sweeps: run a scheduled job twice on the same clock day and prove the
302
+ * second run changed nothing (no duplicate assignments, notifications, mails). The host supplies the
303
+ * job and a `snapshot` of whatever must not grow; the kit compares.
304
+ */
305
+ /** Run twice; fail unless the snapshot after the second run equals the one after the first. */
306
+ async function expectIdempotent(opts) {
307
+ const first = await opts.run();
308
+ await opts.settle?.();
309
+ const afterFirst = await opts.snapshot();
310
+ const second = await opts.run();
311
+ await opts.settle?.();
312
+ const afterSecond = await opts.snapshot();
313
+ const label = opts.label ?? "the sweep";
314
+ expect(afterSecond, clauseMessage(opts.clauses, `${label} run twice on one day changes nothing the second time`)).toEqual(afterFirst);
315
+ return {
316
+ first,
317
+ second,
318
+ afterFirst,
319
+ afterSecond
320
+ };
321
+ }
322
+ /** {@link expectIdempotent} for several named sweeps sharing one snapshot. */
323
+ async function expectSweepsIdempotent(sweeps, common) {
324
+ for (const [label, run] of Object.entries(sweeps)) await expectIdempotent({
325
+ ...common,
326
+ run,
327
+ label
328
+ });
329
+ }
330
+ export { addCivilDays, addCivilMonths, bearer, civilDateOf, clauseMessage, createCast, createEventLog, createStoryAssertions, createStoryClock, daysBetween, describeStory, expectIdempotent, expectSweepsIdempotent, instantOn, need, subsetMatches };
package/llms.txt ADDED
@@ -0,0 +1,33 @@
1
+ # @classytic/arc-testkit
2
+
3
+ > Test harness for arc apps: boot modules on in-memory MongoDB, pool servers, cost and plan asserts,
4
+ > conformance guards and a story kit. Needs @classytic/arc >=2.43.0; the guards and stories need vitest >=3.
5
+
6
+ Hand-rolling any row below is a bug. Skill: skills/arc-testkit/SKILL.md (+ references/).
7
+
8
+ | Need | Use | Import |
9
+ |---|---|---|
10
+ | Boot module(s) on a real DB | bootModuleApp | @classytic/arc-testkit |
11
+ | One mongod per run, DB per file | createTestDatabasePool, mongoServerProvider | /pool, /mongo |
12
+ | Worker cap by cores and RAM | safeWorkerCount | /mongo |
13
+ | Wait for index builds | awaitIndexBuilds | /mongo |
14
+ | N+1 detection | countCommands, assertConstantCommands | /mongo-commands |
15
+ | Index plan proof | assertIndexServed, summarizeExplain | /mongo-explain |
16
+ | List routes answer the repo-core envelope | checkListEnvelopes, registerListEnvelopeGuard | /conformance |
17
+ | Actions and write routes have schemas | checkRouteSchemas, registerRouteSchemaGuard | /conformance |
18
+ | Verb and route-family naming | checkNaming, registerNamingGuard | /conformance |
19
+ | OpenAPI shape, security, secrets | checkOpenApiShape, checkSecurity, checkRoutesDocumented, checkAnonymousRefused, checkNoSecrets, registerOpenApiGuard | /conformance |
20
+ | List filters match the route | checkFilterDeclarations | /conformance |
21
+ | Client routes exist in the spec | checkClientDrift, registerClientDriftGuard, dumpOpenApi, loadOpenApiDump | /conformance |
22
+ | Error bodies are ErrorContract | checkErrorContracts, expectErrorContract, registerErrorContractGuard | /conformance |
23
+ | Bare collection paths | checkCollectionRoutes, registerCollectionRoutesGuard | /conformance |
24
+ | Refusal leaves no side effect | registerRefusalDoesNoWorkGuard | /conformance |
25
+ | App as caller, spec, route table | injectCaller, fetchOpenApi, routeTable, expectClean | /conformance |
26
+ | Numbered, clause-tagged steps | describeStory, need, clauseMessage | /story |
27
+ | Users with roles | createCast, bearer | /story |
28
+ | Time travel, civil dates | createStoryClock, civilDateOf, addCivilDays, addCivilMonths, daysBetween, instantOn | /story |
29
+ | Outbox, events, feed, mail, audit asserts | createStoryAssertions, createEventLog | /story |
30
+ | Sweep twice changes nothing | expectIdempotent, expectSweepsIdempotent | /story |
31
+
32
+ Rules: every guard is check* (returns problems) + register* (vitest tests); exception lists fail when stale;
33
+ fixture-based hosts call check* then expectClean inside the test; pass getters, not values, for an app booted in beforeAll.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-testkit",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Boot a real @classytic/arc app around the module(s) under test on in-memory MongoDB — the ecosystem test harness. Thin Mongo-typed wrapper over @classytic/arc/testing (arc 2.22+). If your arc module boots green here, it composes into any arc host.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,14 @@
8
8
  "types": "./dist/index.d.mts",
9
9
  "default": "./dist/index.mjs"
10
10
  },
11
+ "./conformance": {
12
+ "types": "./dist/conformance.d.mts",
13
+ "default": "./dist/conformance.mjs"
14
+ },
15
+ "./story": {
16
+ "types": "./dist/story.d.mts",
17
+ "default": "./dist/story.mjs"
18
+ },
11
19
  "./pool": {
12
20
  "types": "./dist/pool.d.mts",
13
21
  "default": "./dist/pool.mjs"
@@ -15,12 +23,22 @@
15
23
  "./mongo": {
16
24
  "types": "./dist/mongo.d.mts",
17
25
  "default": "./dist/mongo.mjs"
26
+ },
27
+ "./mongo-commands": {
28
+ "types": "./dist/mongo-commands.d.mts",
29
+ "default": "./dist/mongo-commands.mjs"
30
+ },
31
+ "./mongo-explain": {
32
+ "types": "./dist/mongo-explain.d.mts",
33
+ "default": "./dist/mongo-explain.mjs"
18
34
  }
19
35
  },
20
36
  "main": "./dist/index.mjs",
21
37
  "types": "./dist/index.d.mts",
22
38
  "sideEffects": false,
23
39
  "files": [
40
+ "skills",
41
+ "llms.txt",
24
42
  "dist",
25
43
  "README.md"
26
44
  ],
@@ -33,6 +51,7 @@
33
51
  "scripts": {
34
52
  "build": "tsdown",
35
53
  "typecheck": "tsc --noEmit",
54
+ "check:skills": "vitest run --root ../.. packages/arc-testkit/tests/skills.test.ts",
36
55
  "test": "vitest run --root ../.. packages/arc-testkit/tests",
37
56
  "lint": "biome check src/",
38
57
  "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
@@ -41,8 +60,17 @@
41
60
  "mongodb-memory-server": ">=10.0.0"
42
61
  },
43
62
  "peerDependencies": {
44
- "@classytic/arc": ">=2.33.0",
45
- "mongoose": ">=9.4.1"
63
+ "vitest": ">=5.0.5",
64
+ "@classytic/arc": ">=2.46.0",
65
+ "mongoose": ">=9.11.4"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "mongoose": {
69
+ "optional": true
70
+ },
71
+ "vitest": {
72
+ "optional": true
73
+ }
46
74
  },
47
75
  "keywords": [
48
76
  "arc",
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: arc-testkit
3
+ description: |
4
+ @classytic/arc-testkit - test harness for arc apps: boot modules on in-memory MongoDB, pool servers, count wire commands, assert index plans, conformance guards (list envelope, route schemas, naming, OpenAPI, client drift, error contract), story kit (cast, clock, steps, evidence asserts). Triggers: arc-testkit, bootModuleApp, conformance, story test.
5
+ license: MIT
6
+ metadata:
7
+ author: Classytic
8
+ minVersion: "0.6.0"
9
+ ---
10
+
11
+ # @classytic/arc-testkit
12
+
13
+ Needs `@classytic/arc >=2.43.0`; `mongoose >=9.4.1` for root, `/pool`, `/mongo*`; `/conformance` and `/story` need `vitest >=3` only. Guards are `check*` (returns problems) plus `register*` (vitest tests).
14
+
15
+ ## Do not hand-roll
16
+
17
+ | Need | Use | Import |
18
+ |---|---|---|
19
+ | Boot module(s) on a real DB | `bootModuleApp(modules, options)` | `@classytic/arc-testkit` |
20
+ | One mongod per run, one DB per file | `createTestDatabasePool`, `mongoServerProvider` | `/pool`, `/mongo` |
21
+ | Worker cap by cores and RAM | `safeWorkerCount(envVar)` | `/mongo` |
22
+ | Wait for index builds | `awaitIndexBuilds` | `/mongo` |
23
+ | Count driver commands (N+1) | `countCommands`, `assertConstantCommands` | `/mongo-commands` |
24
+ | Prove a query uses an index | `assertIndexServed`, `summarizeExplain` | `/mongo-explain` |
25
+ | Every list answers the repo-core envelope | `checkListEnvelopes`, `registerListEnvelopeGuard` | `/conformance` |
26
+ | Every action/write route has a schema | `checkRouteSchemas`, `registerRouteSchemaGuard` | `/conformance` |
27
+ | Verb and route-family naming | `checkNaming`, `registerNamingGuard` | `/conformance` |
28
+ | OpenAPI shape, security, secrets | `checkOpenApiShape`, `checkSecurity`, `checkRoutesDocumented`, `checkAnonymousRefused`, `checkNoSecrets`, `registerOpenApiGuard` | `/conformance` |
29
+ | Client routes exist in the spec | `checkClientDrift`, `registerClientDriftGuard`, `dumpOpenApi`, `loadOpenApiDump` | `/conformance` |
30
+ | List filters match the route | `checkFilterDeclarations` | `/conformance` |
31
+ | Error bodies are `ErrorContract` | `checkErrorContracts`, `expectErrorContract`, `registerErrorContractGuard` | `/conformance` |
32
+ | Bare collection paths | `checkCollectionRoutes`, `registerCollectionRoutesGuard` | `/conformance` |
33
+ | Refusal leaves no side effect | `registerRefusalDoesNoWorkGuard` | `/conformance` |
34
+ | App as caller, spec, routes | `injectCaller(app, headers)`, `fetchOpenApi(caller)`, `routeTable(app)`, `expectClean(what, problems)` | `/conformance` |
35
+ | Numbered, clause-tagged story steps | `describeStory`, `need`, `clauseMessage` | `/story` |
36
+ | Named users with roles | `createCast`, `bearer` | `/story` |
37
+ | Time travel, civil dates | `createStoryClock`, `civilDateOf`, `addCivilDays`, `addCivilMonths`, `daysBetween`, `instantOn` | `/story` |
38
+ | Outbox, events, feed, mail, audit asserts | `createStoryAssertions`, `createEventLog` | `/story` |
39
+ | Sweep run twice changes nothing | `expectIdempotent`, `expectSweepsIdempotent` | `/story` |
40
+
41
+ ## Golden path
42
+
43
+ Boot (`replset: true` only for transactions):
44
+
45
+ ```ts
46
+ import { bootModuleApp } from "@classytic/arc-testkit";
47
+
48
+ const t = await bootModuleApp(({ connection }) => [createThingModule({ connection, permissions })], { replset: true });
49
+ const res = await t.app.inject({ method: "GET", url: "/things" });
50
+ await t.close();
51
+ ```
52
+
53
+ Pool (vitest `globalSetup` exports `setup`/`teardown`; suites boot through `withPool`):
54
+
55
+ ```ts
56
+ import { mongoServerProvider } from "@classytic/arc-testkit/mongo";
57
+ import { createTestDatabasePool } from "@classytic/arc-testkit/pool";
58
+ import mongoose from "mongoose";
59
+
60
+ export const dbPool = createTestDatabasePool(mongoServerProvider({ mongoose }), { envPrefix: "APP_TEST", preboot: ["transactional"] });
61
+ export const setup = () => dbPool.globalSetup.setup();
62
+ export const teardown = () => dbPool.globalSetup.teardown();
63
+ // suite: const boot = dbPool.withPool(bootModuleApp); vitest config: maxWorkers: safeWorkerCount("TEST_MAX_WORKERS")
64
+ ```
65
+
66
+ Guards inside a test with the app and an admin caller:
67
+
68
+ ```ts
69
+ import { checkListEnvelopes, checkSecurity, expectClean, fetchOpenApi } from "@classytic/arc-testkit/conformance";
70
+
71
+ test("contract", async () => {
72
+ expectClean("list envelope", (await checkListEnvelopes({ app: t.app, caller: admin, prefix: "/api", notLists: { "/api/me": "one document" } })).problems);
73
+ expectClean("security", await checkSecurity({ spec: await fetchOpenApi(admin), publicOperations: [{ path: "/api/health", why: "probe" }] }));
74
+ });
75
+ ```
76
+
77
+ Story:
78
+
79
+ ```ts
80
+ import { createCast, describeStory } from "@classytic/arc-testkit/story";
81
+
82
+ describeStory("checkout", (s) => {
83
+ s.step("customer places an order", { clauses: ["ORD-1"] }, async (t) => {
84
+ const res = await cast.get("ann").post("/orders", {});
85
+ t.expect(res.status, "the order is accepted").toBe(201);
86
+ });
87
+ });
88
+ ```
89
+
90
+ ## Gotchas
91
+
92
+ - Exception lists (`notLists`, `customShapes`, `actionExceptions`, `routeExceptions`, `exemptVerbs`, `publicOperations`) fail when an entry matches nothing: delete the entry, never keep it "just in case".
93
+ - `register*` runs at describe time with getters (`app: () => t.app`, app booted in `beforeAll`). Fixture-based hosts (`test.extend`) use `check*` + `expectClean` inside the test, or pass `test` and `fixtures` to `describeStory`.
94
+ - `checkListEnvelopes` skips non-200 GETs and paths with params; set `minRoutes` and `expectLists` so an empty probe cannot pass.
95
+ - `checkErrorContracts` needs a probe per class (`require` narrows them); its unknown-route probe also fails when the query string is echoed.
96
+ - Fastify's own 404 body fails the contract: set a not-found handler.
97
+ - `checkRouteSchemas` reads `app.arc.registry`: the app must be booted by arc.
98
+ - A pool needs `globalSetup` to preboot, else every worker boots its own mongod. `countCommands` needs `monitorCommands: true`.
99
+ - `loadOpenApiDump` throws when the dump is older than the `.ts` under `sources`: regenerate it.
100
+
101
+ ## Never
102
+
103
+ - Hand-roll a route-table parser, OpenAPI walker or per-file `MongoMemoryServer`.
104
+ - Import from `dist/` or `src/` paths; use the subpaths above only.
105
+ - Assert `ErrorContract` bodies field by field; use `expectErrorContract`.
106
+ - Put domain rules in the kit.
107
+
108
+ ## References
109
+
110
+ - `references/fixtures.md` - read when booting apps, pooling servers, capping workers, counting commands or asserting plans.
111
+ - `references/conformance.md` - read when adding a guard: what it enforces, every parameter, how to read its failure.
112
+ - `references/story.md` - read when writing a story test: cast, clock, steps, ports, idempotency.
113
+ - `references/list-filters.md` - read when a UI sends list filters, search or sorts.
@@ -0,0 +1,104 @@
1
+ # Conformance guards (`@classytic/arc-testkit/conformance`)
2
+
3
+ Common shapes. `Source<T>` is a value or `() => T | Promise<T>`. `app` is the booted Fastify instance (anything with
4
+ `inject` and `printRoutes`). `Caller` is `{ get(path) }` returning `{ status, body }`; a host test client fits, or build
5
+ one with `injectCaller(app, { authorization: "Bearer ..." })` (adds `post`, `put`, `patch`, `delete`, `request`).
6
+ `Exceptions` is `string[]`, a `Set`, or `Record<key, reason>`. Each `check*` returns `string[]` (problems);
7
+ `register*` wraps it in `it(...)`; `expectClean(what, problems)` fails with one problem per line.
8
+
9
+ ## List envelope
10
+
11
+ `checkListEnvelopes(opts)` returns `{ problems, lists, probed }`. Calls every static GET (no `:` or `*`) as `caller`; a
12
+ 200 body must be an offset page (`method: "offset"`, numeric `page limit total pages`, boolean `hasNext hasPrev`), a
13
+ keyset page (`limit`, boolean `hasMore`, `next` string or null) or exactly `{ data: [...] }`.
14
+
15
+ | Option | Meaning |
16
+ |---|---|
17
+ | `app`, `caller` | required; caller must be allowed to read every list |
18
+ | `prefix` | only GET paths starting with it |
19
+ | `notLists` | GETs that are not lists (single documents, tables), with reason |
20
+ | `customShapes` | GETs that answer their own shape (package routes) |
21
+ | `skip` | RegExp of paths never probed; default `/(stream\|export\|content\|download)(/\|$)` |
22
+ | `forbiddenKeys` | keys a list never carries; default `docs rows items pagination totalPages` |
23
+ | `methods` | accepted `method` values; default offset and keyset |
24
+ | `minRoutes`, `expectLists` | probe size floor; paths that must have answered a list |
25
+
26
+ Failures: `PATH: a bare array`; `PATH: not a list and not in notLists (keys)`; `PATH: carries docs`; `PATH: offset page:
27
+ \`total\` is not a number`; `stale exception PATH: no such GET route is registered`. Non-200 answers are skipped, so
28
+ a route needing a filter is never reported: assert it separately.
29
+
30
+ ## Route schemas
31
+
32
+ `checkRouteSchemas({ app, actionExceptions, routeExceptions, bodyMethods, minActions })` returns
33
+ `{ problems, actions, routes }`. Reads `app.arc.registry.getAll()`. Every action needs `schema`; every custom route with
34
+ method in `bodyMethods` (default POST PUT PATCH) needs `schema.body`. Keys: actions `resource.action`, routes
35
+ `resource METHOD /path` (path as declared on the resource, e.g. `thing POST /import`). Failures: `action thing.archive
36
+ declares no schema (add it to actionSchemas)`, `route thing POST /recompute declares no body schema`, `stale action
37
+ exception KEY`, `stale route exception KEY`. Use an exception only for package-mounted routes and routes without a
38
+ JSON body (multipart), with the reason as the record value.
39
+
40
+ ## Naming
41
+
42
+ `checkNaming({ app, verbPattern, exemptVerbs, requiredVerbs, minVerbs, pathRules, forbiddenPaths })` returns problems.
43
+ `verbPattern` default `/^[a-z][a-zA-Z0-9]*$/` (camelCase action verbs). `pathRules`: `[{ name, match: RegExp, allowed:
44
+ ["POST /api/x/sweep"] }]`; every route whose path matches belongs to the family and the set must equal `allowed`
45
+ (HEAD and OPTIONS ignored). `forbiddenPaths`: RegExps no route may match. Failures: `action r.set_due does not match
46
+ ...`, `sweeps: unexpected route POST ...`, `sweeps: missing route ...`, `stale exempt verb v`.
47
+
48
+ ## OpenAPI
49
+
50
+ Options (`OpenApiOptions`): `spec` (document or getter; `fetchOpenApi(caller, path = "/openapi.json")`), `app`,
51
+ `anonymous` (unauthenticated caller), `prefix`, `publicOperations: [{ path: string | RegExp | fn, why }]`,
52
+ `undocumented` (RegExp of route-table paths not required in the spec), `skipAnonymous` (default `/\{|\/stream$/`),
53
+ `minOperations`, `secretValues` (strings of length 6 or more), `secretPatterns` (default mongodb and redis URLs).
54
+
55
+ | Function | Enforces | Failure text |
56
+ |---|---|---|
57
+ | `checkOpenApiShape` | `openapi` 3.x, paths under `prefix` (public ones exempt), no trailing slash, `minOperations` | `PATH is outside the /api prefix` |
58
+ | `checkRoutesDocumented` | needs `app`; every route-table method+path (shape-normalised) is in the spec | `GET /x is not in the document` |
59
+ | `checkSecurity` | each operation has `security` (own, else document-level) unless a public rule matches; unused rule fails | `GET /x has no security requirement`, `stale public operation (why): ...` |
60
+ | `checkAnonymousRefused(opts, 401)` | needs `anonymous`; GET of each protected, param-free path answers 401 | `GET /x -> 200, expected 401` |
61
+ | `checkNoSecrets` | no `secretValues` entry or pattern in the serialised spec | `a configuration value is in the document` |
62
+
63
+ `registerOpenApiGuard(opts)` registers all five (the two with optional input only when `app` / `anonymous` is given).
64
+
65
+ ## Client drift
66
+
67
+ `checkClientDrift({ spec, routes, bodyFields, minRoutes })`. `routes`: `[{ method, path, source? }]` the client can emit;
68
+ a path may be concrete (`/api/x/507f...`), a template (`/api/x/:id`, `/api/x/{id}`), with a query or trailing slash.
69
+ `bodyFields`: `[{ method, path: "/api/x/{id}/action", fields: [...], in: "request" | "response", status: "200", name? }]`
70
+ (path spelled as the spec does; fields resolved through `$ref`, `oneOf`, `allOf`, `anyOf`). Failures: `client route
71
+ missing from the document: DELETE /api/x/:id/archive (source)`, `NAME: field "f" is not in the schema`, `NAME: the
72
+ document declares no request schema`. Carry the spec between processes: `dumpOpenApi(spec, file)` on the server,
73
+ `loadOpenApiDump(file, { sources: [dirs], regenerate: "cd apps/server && npm run openapi:dump" })` on the client; it throws
74
+ when the dump is missing or older than a `.ts` under `sources`. Exported helpers: `specMatcher`, `missingClientRoutes`,
75
+ `bodyFieldProblems`, `propertyNames`, `resolveRef`. Needs the node environment (reads files).
76
+
77
+ ## Error contract
78
+
79
+ `checkErrorContracts({ probes, caller, require, allowedKeys })`. Classes: `validation unauthorized forbidden notFound
80
+ unknownRoute conflict duplicateKey`. A probe is `{ provoke: () => Promise<Reply>, status?, code?: string | RegExp,
81
+ details?: "some" | "none", detailCode? }` or an array of them. Defaults: status 400/401/403/404/404/409/409, `details:
82
+ "some"` for validation and duplicateKey, `detailCode: "duplicate_key"` for duplicateKey, `code` any non-empty string.
83
+ `require` (default all seven) lists classes that must have a probe; `unknownRoute` has a built-in probe when `caller`
84
+ is given. The body may carry only `code message status details correlationId meta` (details only `path code message
85
+ meta`). Failures are prefixed with the class: `unknownRoute: foreign keys on the body: error, statusCode`, `validation:
86
+ code "x", expected "y"`, `duplicateKey: a detail has code "z", expected "duplicate_key"`. Single replies:
87
+ `errorContractProblems(reply, expected)` and `expectErrorContract(reply, { status, code, details })` (returns the body).
88
+
89
+ ## Collection routes
90
+
91
+ `checkCollectionRoutes({ app, collections: [{ path: "/api/x", methods: ["GET", "POST"] }], served: { caller, paths } })`.
92
+ The bare path must be registered with each verb; `served` GETs must answer 200. Failures: `POST /api/x is not
93
+ registered`, `/api/x/: a collection is addressed by its bare path`, `GET /api/x answered 401`.
94
+
95
+ ## Refusal does no work
96
+
97
+ `checkRefusalDoesNoWork({ app, anonymous, unprivileged, snapshot, routes, prefix, exclude, paramValue, refusedStatuses, settleMs, minRoutes })`.
98
+ Calls every mutating route (default: POST PUT PATCH DELETE of the route table, `:param` filled with `paramValue`; or
99
+ `routes: [{ method, path, body? }]`) as `anonymous` and as `unprivileged` (`InjectCaller`s). Each call must answer
100
+ 401/403 and leave `snapshot()` (host state: row counts, a phase, an outbox length) deep-equal before and after. A gate
101
+ that only calls `reply.send` still answers 403 when 2+ async `preSerialization`/`onSend` hooks defer the write, yet the
102
+ handler runs: only the snapshot shows it. `exclude`: `METHOD /path` keys that accept the caller, with the reason.
103
+ Failures: `anonymous POST /x: answered 403 but the snapshot changed: the handler ran after the refusal`, `unprivileged
104
+ PATCH /x/1: answered 200, expected a refusal (401/403)`, `stale exclusion KEY`. Fix: `return reply.send(...)` or throw.