@classytic/arc-testkit 0.6.0 → 0.7.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.
@@ -8,7 +8,7 @@ import { isDeepStrictEqual } from "node:util";
8
8
  * satisfies it), the `Caller` a suite sends requests through, the route table, and the exception
9
9
  * lists every guard takes (an exception that no longer matches anything fails the guard).
10
10
  */
11
- async function resolve(source) {
11
+ async function resolve$1(source) {
12
12
  return typeof source === "function" ? await source() : source;
13
13
  }
14
14
  function registryEntries(app) {
@@ -145,17 +145,37 @@ function propertyNames(spec, schema, depth = 0) {
145
145
  ]) for (const name of propertyNames(spec, branch, depth + 1)) out.add(name);
146
146
  return out;
147
147
  }
148
+ /**
149
+ * Every literal value a schema accepts: `enum` and `const` at its root, through refs and unions,
150
+ * and on its top-level properties (an action router's `action` discriminator).
151
+ */
152
+ function schemaValues(spec, schema, depth = 0) {
153
+ const out = /* @__PURE__ */ new Set();
154
+ const resolved = resolveRef(spec, schema);
155
+ if (!resolved || depth > 6) return out;
156
+ for (const value of resolved.enum ?? []) out.add(value);
157
+ if (resolved.const !== void 0) out.add(resolved.const);
158
+ for (const branch of [
159
+ ...resolved.oneOf ?? [],
160
+ ...resolved.allOf ?? [],
161
+ ...resolved.anyOf ?? []
162
+ ]) for (const value of schemaValues(spec, branch, depth + 1)) out.add(value);
163
+ if (depth === 0) for (const prop of Object.values(resolved.properties ?? {})) for (const value of schemaValues(spec, prop, depth + 1)) out.add(value);
164
+ return out;
165
+ }
148
166
  function bodyFieldProblems(spec, check) {
149
167
  const label = check.name ?? `${check.method.toUpperCase()} ${check.path}`;
150
168
  const op = spec.paths?.[check.path]?.[check.method.toLowerCase()];
151
169
  if (!op) return [`${label}: not in the document`];
152
- const names = propertyNames(spec, check.in === "response" ? op.responses?.[check.status ?? "200"]?.content?.["application/json"]?.schema : op.requestBody?.content?.["application/json"]?.schema);
170
+ const schema = check.in === "response" ? op.responses?.[check.status ?? "200"]?.content?.["application/json"]?.schema : op.requestBody?.content?.["application/json"]?.schema;
171
+ const names = propertyNames(spec, schema);
153
172
  if (names.size === 0) return [`${label}: the document declares no ${check.in ?? "request"} schema`];
154
- return check.fields.filter((f) => !names.has(f)).map((f) => `${label}: field "${f}" is not in the schema`);
173
+ const accepted = check.values?.length ? schemaValues(spec, schema) : /* @__PURE__ */ new Set();
174
+ return [...check.fields.filter((f) => !names.has(f)).map((f) => `${label}: field "${f}" is not in the schema`), ...(check.values ?? []).filter((v) => !accepted.has(v)).map((v) => `${label}: value ${JSON.stringify(v)} is not one the schema accepts`)];
155
175
  }
156
176
  async function checkClientDrift(opts) {
157
- const spec = await resolve(opts.spec);
158
- const routes = await resolve(opts.routes);
177
+ const spec = await resolve$1(opts.spec);
178
+ const routes = await resolve$1(opts.routes);
159
179
  const problems = [];
160
180
  const min = opts.minRoutes ?? 1;
161
181
  if (routes.length < min) problems.push(`only ${routes.length} client routes supplied, expected at least ${min}`);
@@ -166,14 +186,14 @@ async function checkClientDrift(opts) {
166
186
  /** Registers the drift guard as vitest tests. */
167
187
  function registerClientDriftGuard(opts) {
168
188
  it("every route the client can emit exists in the OpenAPI document", async () => {
169
- const spec = await resolve(opts.spec);
170
- const routes = await resolve(opts.routes);
189
+ const spec = await resolve$1(opts.spec);
190
+ const routes = await resolve$1(opts.routes);
171
191
  const problems = missingClientRoutes(spec, routes);
172
192
  expect(problems, report("client routes missing from the document", problems)).toEqual([]);
173
193
  expect(routes.length, "client routes supplied").toBeGreaterThanOrEqual(opts.minRoutes ?? 1);
174
194
  });
175
195
  if ((opts.bodyFields ?? []).length > 0) it("the body fields the client relies on are declared in the document", async () => {
176
- const spec = await resolve(opts.spec);
196
+ const spec = await resolve$1(opts.spec);
177
197
  const problems = (opts.bodyFields ?? []).flatMap((c) => bodyFieldProblems(spec, c));
178
198
  expect(problems, report("body field drift", problems)).toEqual([]);
179
199
  });
@@ -223,7 +243,7 @@ function loadOpenApiDump(file, opts = {}) {
223
243
  * form with the verbs the client sends, and serve it.
224
244
  */
225
245
  async function checkCollectionRoutes(opts) {
226
- const table = routeTable(await resolve(opts.app));
246
+ const table = routeTable(await resolve$1(opts.app));
227
247
  const problems = [];
228
248
  for (const { path, methods } of opts.collections) {
229
249
  if (path.length > 1 && path.endsWith("/")) {
@@ -238,7 +258,7 @@ async function checkCollectionRoutes(opts) {
238
258
  for (const method of methods) if (!row.methods.includes(method.toUpperCase())) problems.push(`${method.toUpperCase()} ${path} is not registered`);
239
259
  }
240
260
  if (opts.served) {
241
- const caller = await resolve(opts.served.caller);
261
+ const caller = await resolve$1(opts.served.caller);
242
262
  for (const path of opts.served.paths) {
243
263
  const res = await caller.get(path);
244
264
  if (res.status !== 200) problems.push(`GET ${path} answered ${res.status}`);
@@ -333,7 +353,7 @@ async function checkErrorContracts(opts) {
333
353
  const problems = [];
334
354
  const probes = { ...opts.probes };
335
355
  if (probes.unknownRoute === void 0 && opts.caller !== void 0) {
336
- const caller = await resolve(opts.caller);
356
+ const caller = await resolve$1(opts.caller);
337
357
  probes.unknownRoute = { provoke: () => caller.get("/__no_such_route__?token=secret") };
338
358
  }
339
359
  for (const cls of opts.require ?? ERROR_CLASSES) if (probes[cls] === void 0) problems.push(`${cls}: no probe supplied`);
@@ -375,10 +395,10 @@ function wireOps(op) {
375
395
  return op === "between" ? ["gte", "lte"] : [op];
376
396
  }
377
397
  async function checkFilterDeclarations(opts) {
378
- const spec = await resolve(opts.spec);
398
+ const spec = await resolve$1(opts.spec);
379
399
  const find = specMatcher(spec);
380
400
  const problems = [];
381
- for (const decl of await resolve(opts.declarations)) {
401
+ for (const decl of await resolve$1(opts.declarations)) {
382
402
  const path = find("GET", decl.route);
383
403
  const op = path ? spec.paths?.[path]?.get : void 0;
384
404
  if (!op) {
@@ -454,8 +474,8 @@ function listEnvelopeProblems(body, methods = ["offset", "keyset"], forbidden =
454
474
  return problems;
455
475
  }
456
476
  async function checkListEnvelopes(opts) {
457
- const app = await resolve(opts.app);
458
- const caller = await resolve(opts.caller);
477
+ const app = await resolve$1(opts.app);
478
+ const caller = await resolve$1(opts.caller);
459
479
  const skip = opts.skip ?? DEFAULT_SKIP;
460
480
  const notLists = exceptionKeys(opts.notLists);
461
481
  const custom = exceptionKeys(opts.customShapes);
@@ -516,7 +536,7 @@ function registerListEnvelopeGuard(opts) {
516
536
  * Verbs a package fixes belong in `exemptVerbs`; one that no longer exists fails as stale.
517
537
  */
518
538
  async function checkNaming(opts) {
519
- const app = await resolve(opts.app);
539
+ const app = await resolve$1(opts.app);
520
540
  const pattern = opts.verbPattern ?? /^[a-z][a-zA-Z0-9]*$/;
521
541
  const exempt = exceptionKeys(opts.exemptVerbs);
522
542
  const problems = [];
@@ -593,7 +613,7 @@ function operationsOf(spec) {
593
613
  }
594
614
  /** The document is OpenAPI 3, prefixed, and has no trailing-slash path. */
595
615
  async function checkOpenApiShape(opts) {
596
- const spec = await resolve(opts.spec);
616
+ const spec = await resolve$1(opts.spec);
597
617
  const problems = [];
598
618
  if (!/^3\./.test(String(spec.openapi))) problems.push(`openapi is "${String(spec.openapi)}", expected 3.x`);
599
619
  const ops = operationsOf(spec);
@@ -609,8 +629,8 @@ async function checkOpenApiShape(opts) {
609
629
  /** Every route in the table is documented (once, in its bare form). */
610
630
  async function checkRoutesDocumented(opts) {
611
631
  if (opts.app === void 0) return ["checkRoutesDocumented needs `app`"];
612
- const spec = await resolve(opts.spec);
613
- const app = await resolve(opts.app);
632
+ const spec = await resolve$1(opts.spec);
633
+ const app = await resolve$1(opts.app);
614
634
  const listed = /* @__PURE__ */ new Set();
615
635
  for (const { method, path } of operationsOf(spec)) listed.add(`${method.toUpperCase()} ${shapeOf(path)}`);
616
636
  const problems = [];
@@ -628,7 +648,7 @@ async function checkRoutesDocumented(opts) {
628
648
  * public list; a public rule that matches no operation fails as stale.
629
649
  */
630
650
  async function checkSecurity(opts) {
631
- const spec = await resolve(opts.spec);
651
+ const spec = await resolve$1(opts.spec);
632
652
  const rules = opts.publicOperations ?? [];
633
653
  const used = /* @__PURE__ */ new Set();
634
654
  const problems = [];
@@ -649,8 +669,8 @@ async function checkSecurity(opts) {
649
669
  /** An anonymous GET of every documented, protected, parameterless path is refused with 401. */
650
670
  async function checkAnonymousRefused(opts, expected = 401) {
651
671
  if (opts.anonymous === void 0) return ["checkAnonymousRefused needs `anonymous`"];
652
- const spec = await resolve(opts.spec);
653
- const anon = await resolve(opts.anonymous);
672
+ const spec = await resolve$1(opts.spec);
673
+ const anon = await resolve$1(opts.anonymous);
654
674
  const skip = opts.skipAnonymous ?? /\{|\/stream$/;
655
675
  const rules = opts.publicOperations ?? [];
656
676
  const problems = [];
@@ -665,9 +685,9 @@ async function checkAnonymousRefused(opts, expected = 401) {
665
685
  }
666
686
  /** The serialised document carries no configuration value. */
667
687
  async function checkNoSecrets(opts) {
668
- const text = JSON.stringify(await resolve(opts.spec));
688
+ const text = JSON.stringify(await resolve$1(opts.spec));
669
689
  const problems = [];
670
- const values = opts.secretValues === void 0 ? [] : await resolve(opts.secretValues);
690
+ const values = opts.secretValues === void 0 ? [] : await resolve$1(opts.secretValues);
671
691
  for (const value of values) if (typeof value === "string" && value.length >= 6 && text.includes(value)) problems.push("a configuration value is in the document");
672
692
  for (const re of opts.secretPatterns ?? DEFAULT_SECRET_PATTERNS) if (re.test(text)) problems.push(`the document matches ${re}`);
673
693
  return [...new Set(problems)];
@@ -693,6 +713,11 @@ function registerOpenApiGuard(opts) {
693
713
  * without returning the reply still answers 403 when two or more async `preSerialization` /
694
714
  * `onSend` hooks defer the write, yet Fastify goes on to run the handler. The status is right
695
715
  * and the side effect already happened, so only a before/after comparison catches it.
716
+ *
717
+ * Action routes: arc validates `POST …/:id/action` against the discriminated body (`{ action, …}`)
718
+ * before the verb's permission runs, so a bare `{}` answers 400 and never reaches the gate. With
719
+ * `actionBodies`, each derived action route becomes one call per registered verb, with that verb's
720
+ * body.
696
721
  */
697
722
  const MUTATING = /* @__PURE__ */ new Set([
698
723
  "POST",
@@ -700,73 +725,194 @@ const MUTATING = /* @__PURE__ */ new Set([
700
725
  "PATCH",
701
726
  "DELETE"
702
727
  ]);
703
- async function derive(opts) {
704
- const keys = [];
705
- if (opts.routes !== void 0) return {
706
- routes: [...await resolve(opts.routes)],
707
- keys
728
+ const CALLERS = ["anonymous", "unprivileged"];
729
+ const DEFAULT_NAME = "a refused request does no work (state is unchanged after every 401/403)";
730
+ function isCallerExceptions(value) {
731
+ if (typeof value[Symbol.iterator] === "function") return false;
732
+ const entries = Object.entries(value);
733
+ return entries.length > 0 && entries.every(([key, list]) => (key === "anonymous" || key === "unprivileged") && typeof list !== "string");
734
+ }
735
+ function concrete(path, value) {
736
+ return path.replace(/\{[^}]+\}/g, value).replace(/:[A-Za-z0-9_]+(?:\|:[A-Za-z0-9_]+)*/g, value).replace(/\*/g, "x");
737
+ }
738
+ /** The resource whose prefix the path shape carries at a segment boundary; the longest prefix wins. */
739
+ function resourceOf(shape, entries) {
740
+ let best;
741
+ for (const entry of entries) {
742
+ const prefix = entry.prefix === void 0 ? "" : shapeOf(entry.prefix);
743
+ if (prefix === "" || prefix === "/") continue;
744
+ for (let at = shape.indexOf(prefix); at !== -1; at = shape.indexOf(prefix, at + 1)) {
745
+ const rest = shape.slice(at + prefix.length);
746
+ if (rest !== "" && !rest.startsWith("/")) continue;
747
+ if (best === void 0 || prefix.length > best.length) best = {
748
+ entry,
749
+ rest,
750
+ length: prefix.length
751
+ };
752
+ }
753
+ }
754
+ return best && {
755
+ entry: best.entry,
756
+ rest: best.rest
708
757
  };
758
+ }
759
+ async function derive(opts) {
760
+ if (opts.routes !== void 0) return (await resolve$1(opts.routes)).map((route) => {
761
+ const method = route.method.toUpperCase();
762
+ return {
763
+ method,
764
+ path: route.path,
765
+ keys: [`${method} ${route.path}`],
766
+ body: route.body
767
+ };
768
+ });
709
769
  if (opts.app === void 0) throw new Error("checkRefusalDoesNoWork needs `app` or `routes`");
710
- const table = routeTable(await resolve(opts.app));
711
- const value = opts.paramValue ?? "000000000000000000000001";
712
- const routes = [];
770
+ const app = await resolve$1(opts.app);
771
+ const table = routeTable(app);
772
+ const withActions = opts.actionBodies !== void 0;
773
+ const needsRegistry = withActions || typeof opts.paramValue === "function";
774
+ let entries = [];
775
+ if (withActions) entries = registryEntries(app);
776
+ else if (needsRegistry) try {
777
+ entries = registryEntries(app);
778
+ } catch {}
779
+ const param = async (method, path, resource) => typeof opts.paramValue === "function" ? await opts.paramValue({
780
+ method,
781
+ path,
782
+ resource
783
+ }) : opts.paramValue ?? "000000000000000000000001";
784
+ const planned = [];
713
785
  for (const row of table) {
714
786
  if (opts.prefix !== void 0 && !row.path.startsWith(opts.prefix)) continue;
715
787
  for (const method of row.methods) {
716
788
  if (IMPLICIT_METHODS.has(method) || !MUTATING.has(method)) continue;
717
789
  const key = `${method} ${row.path}`;
718
- keys.push(key);
719
- routes.push({
720
- method,
721
- path: row.path.replace(/\{[^}]+\}/g, value).replace(/:[A-Za-z0-9_]+(?:\|:[A-Za-z0-9_]+)*/g, value).replace(/\*/g, "x")
722
- });
790
+ const owner = needsRegistry ? resourceOf(shapeOf(row.path), entries) : void 0;
791
+ const path = concrete(row.path, await param(method, row.path, owner?.entry.name));
792
+ const keys = key === `${method} ${path}` ? [key] : [key, `${method} ${path}`];
793
+ const mount = owner?.rest === "/:x/action" ? "id" : owner?.rest === "/action" ? "root" : void 0;
794
+ if (!withActions || method !== "POST" || owner === void 0 || mount === void 0) {
795
+ planned.push({
796
+ method,
797
+ path,
798
+ keys
799
+ });
800
+ continue;
801
+ }
802
+ const verbs = (owner.entry.actions ?? []).filter((a) => a.id !== false === (mount === "id"));
803
+ if (verbs.length === 0) {
804
+ planned.push({
805
+ method,
806
+ path,
807
+ keys
808
+ });
809
+ continue;
810
+ }
811
+ for (const action of verbs) {
812
+ const verbKey = `${owner.entry.name}.${action.name}`;
813
+ const source = opts.actionBodies?.[verbKey];
814
+ planned.push({
815
+ method,
816
+ path,
817
+ keys: [...keys, verbKey],
818
+ verb: {
819
+ resource: owner.entry.name,
820
+ action: action.name,
821
+ key: verbKey,
822
+ ...source === void 0 ? {} : { source }
823
+ }
824
+ });
825
+ }
723
826
  }
724
827
  }
725
- return {
726
- routes,
727
- keys
728
- };
828
+ return planned;
729
829
  }
730
830
  /** Problems found; empty when every refusal answered and left the snapshot unchanged. */
731
831
  async function checkRefusalDoesNoWork(opts) {
732
832
  const problems = [];
733
833
  const callers = [];
734
- if (opts.anonymous !== void 0) callers.push(["anonymous", await resolve(opts.anonymous)]);
735
- if (opts.unprivileged !== void 0) callers.push(["unprivileged", await resolve(opts.unprivileged)]);
834
+ if (opts.anonymous !== void 0) callers.push(["anonymous", await resolve$1(opts.anonymous)]);
835
+ if (opts.unprivileged !== void 0) callers.push(["unprivileged", await resolve$1(opts.unprivileged)]);
736
836
  if (callers.length === 0) return ["checkRefusalDoesNoWork needs `anonymous` and/or `unprivileged`"];
737
837
  const refused = opts.refusedStatuses ?? [401, 403];
738
838
  const settle = opts.settleMs ?? 25;
739
- const excluded = exceptionKeys(opts.exclude);
740
- const { routes, keys } = await derive(opts);
741
- const used = /* @__PURE__ */ new Set();
839
+ const perCaller = opts.exclude !== void 0 && isCallerExceptions(opts.exclude);
840
+ const shared = perCaller ? [] : exceptionKeys(opts.exclude);
841
+ const own = {
842
+ anonymous: [],
843
+ unprivileged: []
844
+ };
845
+ if (perCaller) {
846
+ const lists = opts.exclude;
847
+ for (const who of CALLERS) own[who] = exceptionKeys(lists[who]);
848
+ }
849
+ const planned = await derive(opts);
850
+ const usedShared = /* @__PURE__ */ new Set();
851
+ const usedOwn = {
852
+ anonymous: /* @__PURE__ */ new Set(),
853
+ unprivileged: /* @__PURE__ */ new Set()
854
+ };
855
+ const usedBodies = /* @__PURE__ */ new Set();
742
856
  let checked = 0;
743
- for (const [index, route] of routes.entries()) {
744
- const method = route.method.toUpperCase();
745
- const key = keys[index] ?? `${method} ${route.path}`;
746
- if (excluded.includes(key) || excluded.includes(`${method} ${route.path}`)) {
747
- used.add(key);
748
- used.add(`${method} ${route.path}`);
857
+ for (const call of planned) {
858
+ if (call.keys.some((k) => shared.includes(k))) {
859
+ for (const k of call.keys) usedShared.add(k);
749
860
  continue;
750
861
  }
751
- checked++;
862
+ let ran = false;
863
+ let reportedMissing = false;
752
864
  for (const [who, caller] of callers) {
865
+ if (call.keys.some((k) => own[who].includes(k))) {
866
+ for (const k of call.keys) usedOwn[who].add(k);
867
+ continue;
868
+ }
869
+ const label = `${who} ${call.method} ${call.path}${call.verb ? ` (${call.verb.key})` : ""}`;
870
+ let body = call.method === "DELETE" ? void 0 : call.body ?? {};
871
+ if (call.verb !== void 0) {
872
+ const source = call.verb.source;
873
+ if (source === void 0) {
874
+ if (!reportedMissing) problems.push(`no body for action ${call.verb.key} (${call.method} ${call.path}): add it to \`actionBodies\` or exclude it`);
875
+ reportedMissing = true;
876
+ continue;
877
+ }
878
+ usedBodies.add(call.verb.key);
879
+ body = {
880
+ ...typeof source === "function" ? await source({
881
+ resource: call.verb.resource,
882
+ action: call.verb.action,
883
+ caller: who,
884
+ path: call.path
885
+ }) : source,
886
+ action: call.verb.action
887
+ };
888
+ }
889
+ ran = true;
753
890
  const before = await opts.snapshot();
754
- const body = method === "DELETE" ? void 0 : route.body ?? {};
755
- const reply = await caller.request(method, route.path, body);
891
+ const reply = await caller.request(call.method, call.path, body);
756
892
  await new Promise((done) => setTimeout(done, settle));
757
893
  const after = await opts.snapshot();
758
- const label = `${who} ${method} ${route.path}`;
759
894
  if (!refused.includes(reply.status)) problems.push(`${label}: answered ${reply.status}, expected a refusal (${refused.join("/")})`);
760
895
  if (!isDeepStrictEqual(before, after)) problems.push(`${label}: answered ${reply.status} but the snapshot changed: the handler ran after the refusal (a gate must \`return reply\` after \`reply.send\`, or throw)`);
761
896
  }
897
+ if (ran) checked++;
762
898
  }
763
899
  if (checked < (opts.minRoutes ?? 1)) problems.push(`only ${checked} mutating route(s) checked, expected at least ${opts.minRoutes ?? 1}`);
764
- for (const key of staleOf(excluded, used)) problems.push(`stale exclusion ${key}: no such mutating route is checked`);
900
+ for (const key of staleOf(shared, usedShared)) problems.push(`stale exclusion ${key}: no such mutating route is checked`);
901
+ const given = new Set(callers.map(([who]) => who));
902
+ for (const who of CALLERS) {
903
+ if (own[who].length === 0) continue;
904
+ if (!given.has(who)) {
905
+ problems.push(`exclude.${who} is given but no ${who} caller is`);
906
+ continue;
907
+ }
908
+ for (const key of staleOf(own[who], usedOwn[who])) problems.push(`stale exclusion (${who}) ${key}: no such mutating route is checked`);
909
+ }
910
+ for (const key of Object.keys(opts.actionBodies ?? {})) if (!usedBodies.has(key)) problems.push(`stale action body ${key}: no such action verb is checked`);
765
911
  return problems;
766
912
  }
767
- /** Registers the refusal guard as a vitest test. */
913
+ /** Registers the refusal guard as a vitest test (title: `name`, or the default). */
768
914
  function registerRefusalDoesNoWorkGuard(opts) {
769
- it("a refused request does no work (state is unchanged after every 401/403)", async () => {
915
+ it(opts.name ?? DEFAULT_NAME, async () => {
770
916
  const problems = await checkRefusalDoesNoWork(opts);
771
917
  expect(problems, report("refusal violations", problems)).toEqual([]);
772
918
  });
@@ -781,7 +927,7 @@ function registerRefusalDoesNoWorkGuard(opts) {
781
927
  * The exception lists are the only way out, and an entry that no longer matches anything fails too.
782
928
  */
783
929
  async function checkRouteSchemas(opts) {
784
- const app = await resolve(opts.app);
930
+ const app = await resolve$1(opts.app);
785
931
  const actionOk = exceptionKeys(opts.actionExceptions);
786
932
  const routeOk = exceptionKeys(opts.routeExceptions);
787
933
  const methods = (opts.bodyMethods ?? [
@@ -828,4 +974,4 @@ function registerRouteSchemaGuard(opts) {
828
974
  expect(problems, report("route-layer validation gaps", problems)).toEqual([]);
829
975
  });
830
976
  }
831
- export { ERROR_CLASSES, OPENAPI_METHODS, bodyFieldProblems, checkAnonymousRefused, checkClientDrift, checkCollectionRoutes, checkErrorContracts, checkFilterDeclarations, checkListEnvelopes, checkNaming, checkNoSecrets, checkOpenApiShape, checkRefusalDoesNoWork, checkRouteSchemas, checkRoutesDocumented, checkSecurity, dumpOpenApi, errorContractProblems, expectClean, expectErrorContract, fetchOpenApi, injectCaller, listEnvelopeProblems, loadOpenApiDump, missingClientRoutes, propertyNames, registerClientDriftGuard, registerCollectionRoutesGuard, registerErrorContractGuard, registerFilterDeclarationsGuard, registerListEnvelopeGuard, registerNamingGuard, registerOpenApiGuard, registerRefusalDoesNoWorkGuard, registerRouteSchemaGuard, resolveRef, routeTable, shapeOf, specMatcher };
977
+ export { ERROR_CLASSES, OPENAPI_METHODS, bodyFieldProblems, checkAnonymousRefused, checkClientDrift, checkCollectionRoutes, checkErrorContracts, checkFilterDeclarations, checkListEnvelopes, checkNaming, checkNoSecrets, checkOpenApiShape, checkRefusalDoesNoWork, checkRouteSchemas, checkRoutesDocumented, checkSecurity, dumpOpenApi, errorContractProblems, expectClean, expectErrorContract, fetchOpenApi, injectCaller, listEnvelopeProblems, loadOpenApiDump, missingClientRoutes, propertyNames, registerClientDriftGuard, registerCollectionRoutesGuard, registerErrorContractGuard, registerFilterDeclarationsGuard, registerListEnvelopeGuard, registerNamingGuard, registerOpenApiGuard, registerRefusalDoesNoWorkGuard, registerRouteSchemaGuard, resolveRef, routeTable, schemaValues, shapeOf, specMatcher };
package/dist/index.d.mts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { BootModuleAppOptions as BootModuleAppOptions$1, BootModulesInput, ModuleTestApp as ModuleTestApp$1, TestkitContext as TestkitContext$1 } from "@classytic/arc/testing";
2
2
  import { Connection } from "mongoose";
3
- type TestkitContext = TestkitContext$1<Connection>;
4
- type ModulesInput = BootModulesInput<Connection>;
5
- type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
6
- type ModuleTestApp = ModuleTestApp$1<Connection>;
7
- declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
8
- export { BootModuleAppOptions, ModuleTestApp, ModulesInput, TestkitContext, bootModuleApp };
3
+ export type TestkitContext = TestkitContext$1<Connection>;
4
+ export type ModulesInput = BootModulesInput<Connection>;
5
+ export type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
6
+ export type ModuleTestApp = ModuleTestApp$1<Connection>;
7
+ export declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
@@ -1,6 +1,6 @@
1
1
  import { Connection } from "mongoose";
2
2
  type MongoClientLike = ReturnType<Connection["getClient"]>;
3
- interface CountCommandsOptions {
3
+ export interface CountCommandsOptions {
4
4
  /** Count only commands against these collections. */
5
5
  readonly collections?: readonly string[];
6
6
  /** Count only these command names (`find`, `aggregate`, `insert`, …). */
@@ -8,12 +8,12 @@ interface CountCommandsOptions {
8
8
  /** Never count these command names — e.g. `createIndexes` a model may run lazily. */
9
9
  readonly ignore?: readonly string[];
10
10
  }
11
- interface WireCommand {
11
+ export interface WireCommand {
12
12
  readonly command: string;
13
13
  /** The collection the command targets; `undefined` for session, transaction and admin commands. */
14
14
  readonly collection: string | undefined;
15
15
  }
16
- interface CommandCount<T> {
16
+ export interface CommandCount<T> {
17
17
  readonly result: T;
18
18
  readonly total: number;
19
19
  /** Counted commands, in the order the driver sent them. */
@@ -22,6 +22,6 @@ interface CommandCount<T> {
22
22
  readonly byCollection: Readonly<Record<string, number>>;
23
23
  readonly byCommand: Readonly<Record<string, number>>;
24
24
  }
25
- declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
26
- declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
27
- export { CommandCount, CountCommandsOptions, WireCommand, assertConstantCommands, countCommands };
25
+ export declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
26
+ export declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
27
+ export {};
@@ -8,8 +8,17 @@
8
8
  * const explain = await Model.find({ organizationId, state: "posted" }).explain("executionStats");
9
9
  * assertIndexServed(explain, { index: "org_state_date", maxDocsExamined: 40 });
10
10
  * ```
11
+ *
12
+ * `explainCaptured` explains the query a piece of code ACTUALLY sends (a repository method, a
13
+ * plugin-built filter) instead of a copy of it: it runs the code with command monitoring, captures
14
+ * the matching command and explains exactly that.
15
+ *
16
+ * ```ts
17
+ * const { explain } = await explainCaptured(conn, () => timers.claim(now), { collection: "timers", commands: ["findAndModify"] });
18
+ * assertIndexServed(explain, { index: "state_due", maxDocsExamined: 6 });
19
+ * ```
11
20
  */
12
- interface ExplainSummary {
21
+ export interface ExplainSummary {
13
22
  /** Stage names of the winning plan(s), outermost first. */
14
23
  readonly stages: readonly string[];
15
24
  /** Index names the winning plan scans (`_id_` for an id lookup). */
@@ -19,7 +28,7 @@ interface ExplainSummary {
19
28
  readonly keysExamined: number | undefined;
20
29
  readonly returned: number | undefined;
21
30
  }
22
- interface IndexServedOptions {
31
+ export interface IndexServedOptions {
23
32
  /** Every one of these must be scanned. */
24
33
  readonly index?: string | readonly string[];
25
34
  /** Every one of these stages must appear — e.g. `OR` for a `$or` served arm by arm. */
@@ -27,6 +36,49 @@ interface IndexServedOptions {
27
36
  /** Ceiling on `totalDocsExamined`; requires `executionStats` verbosity. */
28
37
  readonly maxDocsExamined?: number;
29
38
  }
30
- declare function summarizeExplain(explain: unknown): ExplainSummary;
31
- declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
32
- export { ExplainSummary, IndexServedOptions, assertIndexServed, summarizeExplain };
39
+ export declare function summarizeExplain(explain: unknown): ExplainSummary;
40
+ export declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
41
+ /** A `commandStarted` event, as far as `explainCaptured` reads it. */
42
+ export interface CommandStartedLike {
43
+ readonly commandName: string;
44
+ readonly databaseName: string;
45
+ readonly command: Readonly<Record<string, unknown>>;
46
+ }
47
+ /** A MongoDB driver client (`connection.getClient()` of mongoose, or a `MongoClient`). */
48
+ export interface MonitoredClientLike {
49
+ readonly options: {
50
+ readonly monitorCommands?: boolean;
51
+ };
52
+ on(event: "commandStarted", listener: (event: CommandStartedLike) => void): unknown;
53
+ off(event: "commandStarted", listener: (event: CommandStartedLike) => void): unknown;
54
+ db(name: string): {
55
+ command(command: Record<string, unknown>): Promise<unknown>;
56
+ };
57
+ }
58
+ export interface ExplainCapturedOptions {
59
+ /** The collection the command targets. */
60
+ readonly collection: string;
61
+ /** Command names to capture. Default: `find`, `aggregate`, `findAndModify`, `count`, `distinct`, `update`, `delete`. */
62
+ readonly commands?: readonly string[];
63
+ /** Which captured command to explain (0-based). Required when more than one matches. */
64
+ readonly index?: number;
65
+ /** Default: `executionStats` (what `maxDocsExamined` needs). */
66
+ readonly verbosity?: "queryPlanner" | "executionStats" | "allPlansExecution";
67
+ }
68
+ export interface ExplainCaptured<T> {
69
+ readonly result: T;
70
+ /** The explain of the chosen command, ready for `assertIndexServed`. */
71
+ readonly explain: unknown;
72
+ /** The chosen command as the driver sent it, minus session and transport fields. */
73
+ readonly command: Readonly<Record<string, unknown>>;
74
+ /** Every matching command, in the order sent (same fields removed). */
75
+ readonly captured: readonly Readonly<Record<string, unknown>>[];
76
+ }
77
+ /**
78
+ * Runs `run` with command monitoring, captures the commands it sends to `collection` and explains
79
+ * the chosen one (the explain never applies a write). Refuses a client without
80
+ * `monitorCommands: true`, a run that sent no matching command, and an ambiguous capture.
81
+ */
82
+ export declare function explainCaptured<T>(source: {
83
+ getClient(): MonitoredClientLike;
84
+ } | MonitoredClientLike, run: () => Promise<T>, options: ExplainCapturedOptions): Promise<ExplainCaptured<T>>;
@@ -57,10 +57,80 @@ function assertIndexServed(explain, options = {}) {
57
57
  }
58
58
  return plan;
59
59
  }
60
+ const EXPLAINABLE = [
61
+ "find",
62
+ "aggregate",
63
+ "findAndModify",
64
+ "count",
65
+ "distinct",
66
+ "update",
67
+ "delete"
68
+ ];
69
+ /** Fields the driver adds to a command that an `explain` wrapper must not carry. */
70
+ const TRANSPORT = /* @__PURE__ */ new Set([
71
+ "lsid",
72
+ "$db",
73
+ "$clusterTime",
74
+ "$readPreference",
75
+ "txnNumber",
76
+ "autocommit",
77
+ "startTransaction",
78
+ "readConcern",
79
+ "writeConcern",
80
+ "apiVersion",
81
+ "apiStrict",
82
+ "apiDeprecationErrors"
83
+ ]);
84
+ /**
85
+ * Runs `run` with command monitoring, captures the commands it sends to `collection` and explains
86
+ * the chosen one (the explain never applies a write). Refuses a client without
87
+ * `monitorCommands: true`, a run that sent no matching command, and an ambiguous capture.
88
+ */
89
+ async function explainCaptured(source, run, options) {
90
+ const client = "getClient" in source ? source.getClient() : source;
91
+ const fail = (msg) => {
92
+ throw new Error(`[arc-testkit] explainCaptured: ${msg}`);
93
+ };
94
+ if (client.options.monitorCommands !== true) fail("the client was created without `monitorCommands: true` — the driver emits no command events, so nothing can be captured");
95
+ const names = options.commands ?? EXPLAINABLE;
96
+ const seen = [];
97
+ const onStarted = (e) => {
98
+ if (!names.includes(e.commandName) || e.command[e.commandName] !== options.collection) return;
99
+ const command = {};
100
+ for (const [key, value] of Object.entries(e.command)) if (!TRANSPORT.has(key)) command[key] = value;
101
+ seen.push({
102
+ db: e.databaseName,
103
+ command
104
+ });
105
+ };
106
+ client.on("commandStarted", onStarted);
107
+ let result;
108
+ try {
109
+ result = await run();
110
+ } finally {
111
+ client.off("commandStarted", onStarted);
112
+ }
113
+ const what = `${names.join("/")} on "${options.collection}"`;
114
+ if (seen.length === 0) fail(`run sent no ${what}`);
115
+ if (options.index === void 0 && seen.length > 1) fail(`run sent ${seen.length} ${what} (${seen.map((s) => Object.keys(s.command)[0]).join(", ")}): pass \`index\` to pick one`);
116
+ const chosen = seen[options.index ?? 0];
117
+ if (chosen === void 0) fail(`no captured command at index ${options.index} (${seen.length} captured)`);
118
+ const { db, command } = chosen;
119
+ const explain = await client.db(db).command({
120
+ explain: command,
121
+ verbosity: options.verbosity ?? "executionStats"
122
+ });
123
+ return {
124
+ result,
125
+ explain,
126
+ command,
127
+ captured: seen.map((s) => s.command)
128
+ };
129
+ }
60
130
  function isRecord(value) {
61
131
  return typeof value === "object" && value !== null;
62
132
  }
63
133
  function numberOr(value) {
64
134
  return typeof value === "number" ? value : void 0;
65
135
  }
66
- export { assertIndexServed, summarizeExplain };
136
+ export { assertIndexServed, explainCaptured, summarizeExplain };