@classytic/arc 1.1.0 → 2.1.2
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/README.md +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
- package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
- package/dist/HookSystem-BsGV-j2l.mjs +405 -0
- package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +82 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +276 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-C3T5DTUx.mjs +141 -0
- package/dist/audited-C3T5DTUx.mjs.map +1 -0
- package/dist/auth/index.d.mts +189 -0
- package/dist/auth/index.d.mts.map +1 -0
- package/dist/auth/index.mjs +1102 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth/redis-session.d.mts +44 -0
- package/dist/auth/redis-session.d.mts.map +1 -0
- package/dist/auth/redis-session.mjs +76 -0
- package/dist/auth/redis-session.mjs.map +1 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
- package/dist/cache/index.d.mts +146 -0
- package/dist/cache/index.d.mts.map +1 -0
- package/dist/cache/index.mjs +92 -0
- package/dist/cache/index.mjs.map +1 -0
- package/dist/caching-Bl28lYsR.mjs +94 -0
- package/dist/caching-Bl28lYsR.mjs.map +1 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
- package/dist/cli/commands/describe.d.mts +19 -0
- package/dist/cli/commands/describe.d.mts.map +1 -0
- package/dist/cli/commands/describe.mjs +239 -0
- package/dist/cli/commands/describe.mjs.map +1 -0
- package/dist/cli/commands/docs.d.mts +14 -0
- package/dist/cli/commands/docs.d.mts.map +1 -0
- package/dist/cli/commands/docs.mjs +53 -0
- package/dist/cli/commands/docs.mjs.map +1 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
- package/dist/cli/commands/generate.d.mts.map +1 -0
- package/dist/cli/commands/generate.mjs +358 -0
- package/dist/cli/commands/generate.mjs.map +1 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
- package/dist/cli/commands/init.d.mts.map +1 -0
- package/dist/cli/commands/{init.js → init.mjs} +807 -616
- package/dist/cli/commands/init.mjs.map +1 -0
- package/dist/cli/commands/introspect.d.mts +11 -0
- package/dist/cli/commands/introspect.d.mts.map +1 -0
- package/dist/cli/commands/introspect.mjs +76 -0
- package/dist/cli/commands/introspect.mjs.map +1 -0
- package/dist/cli/index.d.mts +17 -0
- package/dist/cli/index.d.mts.map +1 -0
- package/dist/cli/index.mjs +157 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/constants-DdXFXQtN.mjs +85 -0
- package/dist/constants-DdXFXQtN.mjs.map +1 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-CUgNqegw.mjs +560 -0
- package/dist/createApp-CUgNqegw.mjs.map +1 -0
- package/dist/defineResource-k0_BDn8v.mjs +2197 -0
- package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
- package/dist/discovery/index.d.mts +47 -0
- package/dist/discovery/index.d.mts.map +1 -0
- package/dist/discovery/index.mjs +110 -0
- package/dist/discovery/index.mjs.map +1 -0
- package/dist/docs/index.d.mts +163 -0
- package/dist/docs/index.d.mts.map +1 -0
- package/dist/docs/index.mjs +73 -0
- package/dist/docs/index.mjs.map +1 -0
- package/dist/elevation-BRy3yFWT.mjs +113 -0
- package/dist/elevation-BRy3yFWT.mjs.map +1 -0
- package/dist/elevation-B_2dRLVP.d.mts +88 -0
- package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
- package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
- package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
- package/dist/errorHandler-C1okiriz.mjs +109 -0
- package/dist/errorHandler-C1okiriz.mjs.map +1 -0
- package/dist/errors-B9bZok84.mjs +212 -0
- package/dist/errors-B9bZok84.mjs.map +1 -0
- package/dist/errors-ChKiFz62.d.mts +125 -0
- package/dist/errors-ChKiFz62.d.mts.map +1 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
- package/dist/eventPlugin-DGR_B2on.mjs +230 -0
- package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
- package/dist/events/index.d.mts +54 -0
- package/dist/events/index.d.mts.map +1 -0
- package/dist/events/index.mjs +52 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +178 -0
- package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
- package/dist/events/transports/redis.d.mts +77 -0
- package/dist/events/transports/redis.d.mts.map +1 -0
- package/dist/events/transports/redis.mjs +125 -0
- package/dist/events/transports/redis.mjs.map +1 -0
- package/dist/externalPaths-DlINfKbP.d.mts +51 -0
- package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
- package/dist/factory/index.d.mts +64 -0
- package/dist/factory/index.d.mts.map +1 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
- package/dist/fields-DyaDVX4J.d.mts +110 -0
- package/dist/fields-DyaDVX4J.d.mts.map +1 -0
- package/dist/fields-iagOozy0.mjs +115 -0
- package/dist/fields-iagOozy0.mjs.map +1 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +97 -0
- package/dist/idempotency/index.d.mts.map +1 -0
- package/dist/idempotency/index.mjs +320 -0
- package/dist/idempotency/index.mjs.map +1 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +115 -0
- package/dist/idempotency/mongodb.mjs.map +1 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +104 -0
- package/dist/idempotency/redis.mjs.map +1 -0
- package/dist/index.d.mts +261 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +105 -0
- package/dist/index.mjs.map +1 -0
- package/dist/integrations/event-gateway.d.mts +47 -0
- package/dist/integrations/event-gateway.d.mts.map +1 -0
- package/dist/integrations/event-gateway.mjs +44 -0
- package/dist/integrations/event-gateway.mjs.map +1 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +104 -0
- package/dist/integrations/jobs.d.mts.map +1 -0
- package/dist/integrations/jobs.mjs +124 -0
- package/dist/integrations/jobs.mjs.map +1 -0
- package/dist/integrations/streamline.d.mts +61 -0
- package/dist/integrations/streamline.d.mts.map +1 -0
- package/dist/integrations/streamline.mjs +126 -0
- package/dist/integrations/streamline.mjs.map +1 -0
- package/dist/integrations/websocket.d.mts +83 -0
- package/dist/integrations/websocket.d.mts.map +1 -0
- package/dist/integrations/websocket.mjs +289 -0
- package/dist/integrations/websocket.mjs.map +1 -0
- package/dist/interface-B01JvPVc.d.mts +78 -0
- package/dist/interface-B01JvPVc.d.mts.map +1 -0
- package/dist/interface-CZe8IkMf.d.mts +55 -0
- package/dist/interface-CZe8IkMf.d.mts.map +1 -0
- package/dist/interface-Ch8HU9uM.d.mts +1098 -0
- package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
- package/dist/keys-BqNejWup.mjs +43 -0
- package/dist/keys-BqNejWup.mjs.map +1 -0
- package/dist/logger-Df2O2WsW.mjs +79 -0
- package/dist/logger-Df2O2WsW.mjs.map +1 -0
- package/dist/memory-cQgelFOj.mjs +144 -0
- package/dist/memory-cQgelFOj.mjs.map +1 -0
- package/dist/migrations/index.d.mts +157 -0
- package/dist/migrations/index.d.mts.map +1 -0
- package/dist/migrations/index.mjs +261 -0
- package/dist/migrations/index.mjs.map +1 -0
- package/dist/mongodb-BfJVlUJH.mjs +94 -0
- package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
- package/dist/mongodb-CGzRbfAK.d.mts +119 -0
- package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
- package/dist/mongodb-JN-9JA7K.d.mts +72 -0
- package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
- package/dist/openapi-G3Cw7XuM.mjs +524 -0
- package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
- package/dist/org/index.d.mts +69 -0
- package/dist/org/index.d.mts.map +1 -0
- package/dist/org/index.mjs +514 -0
- package/dist/org/index.mjs.map +1 -0
- package/dist/org/types.d.mts +83 -0
- package/dist/org/types.d.mts.map +1 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +279 -0
- package/dist/permissions/index.d.mts.map +1 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/permissions/index.mjs.map +1 -0
- package/dist/plugins/index.d.mts +173 -0
- package/dist/plugins/index.d.mts.map +1 -0
- package/dist/plugins/index.mjs +523 -0
- package/dist/plugins/index.mjs.map +1 -0
- package/dist/plugins/response-cache.d.mts +88 -0
- package/dist/plugins/response-cache.d.mts.map +1 -0
- package/dist/plugins/response-cache.mjs +284 -0
- package/dist/plugins/response-cache.mjs.map +1 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +186 -0
- package/dist/plugins/tracing-entry.mjs.map +1 -0
- package/dist/pluralize-CEweyOEm.mjs +87 -0
- package/dist/pluralize-CEweyOEm.mjs.map +1 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -169
- package/dist/policies/index.d.mts.map +1 -0
- package/dist/policies/index.mjs +322 -0
- package/dist/policies/index.mjs.map +1 -0
- package/dist/presets/{index.d.ts → index.d.mts} +63 -131
- package/dist/presets/index.d.mts.map +1 -0
- package/dist/presets/index.mjs +144 -0
- package/dist/presets/index.mjs.map +1 -0
- package/dist/presets/multiTenant.d.mts +25 -0
- package/dist/presets/multiTenant.d.mts.map +1 -0
- package/dist/presets/multiTenant.mjs +114 -0
- package/dist/presets/multiTenant.mjs.map +1 -0
- package/dist/presets-BITljm96.mjs +120 -0
- package/dist/presets-BITljm96.mjs.map +1 -0
- package/dist/presets-DzSMwlKj.d.mts +58 -0
- package/dist/presets-DzSMwlKj.d.mts.map +1 -0
- package/dist/prisma-DJbMt3yf.mjs +628 -0
- package/dist/prisma-DJbMt3yf.mjs.map +1 -0
- package/dist/prisma-Dg9GoVdj.d.mts +275 -0
- package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
- package/dist/redis-D-JAeLtm.d.mts +50 -0
- package/dist/redis-D-JAeLtm.d.mts.map +1 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
- package/dist/registry/index.d.mts +12 -0
- package/dist/registry/index.d.mts.map +1 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-QQD6ROJc.mjs +56 -0
- package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
- package/dist/schemaConverter-BwrmWroW.mjs +99 -0
- package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
- package/dist/schemas/index.d.mts +64 -0
- package/dist/schemas/index.d.mts.map +1 -0
- package/dist/schemas/index.mjs +83 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/dist/scope/index.d.mts +22 -0
- package/dist/scope/index.d.mts.map +1 -0
- package/dist/scope/index.mjs +66 -0
- package/dist/scope/index.mjs.map +1 -0
- package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
- package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
- package/dist/sse-B3c3_yZp.mjs +124 -0
- package/dist/sse-B3c3_yZp.mjs.map +1 -0
- package/dist/testing/index.d.mts +908 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +1977 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/tracing-Cc7vVQPp.d.mts +71 -0
- package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
- package/dist/typeGuards-DhMNLuvU.mjs +10 -0
- package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
- package/dist/types/index.d.mts +947 -0
- package/dist/types/index.d.mts.map +1 -0
- package/dist/types/index.mjs +15 -0
- package/dist/types/index.mjs.map +1 -0
- package/dist/types-Beqn1Un7.mjs +39 -0
- package/dist/types-Beqn1Un7.mjs.map +1 -0
- package/dist/types-CIgB7UUl.d.mts +446 -0
- package/dist/types-CIgB7UUl.d.mts.map +1 -0
- package/dist/types-aYB4V7uN.d.mts +87 -0
- package/dist/types-aYB4V7uN.d.mts.map +1 -0
- package/dist/utils/index.d.mts +748 -0
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1,1977 @@
|
|
|
1
|
+
import { t as CRUD_OPERATIONS } from "../constants-DdXFXQtN.mjs";
|
|
2
|
+
import { n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "../fields-iagOozy0.mjs";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import mongoose, { Model } from "mongoose";
|
|
6
|
+
|
|
7
|
+
//#region src/testing/TestHarness.ts
|
|
8
|
+
/**
|
|
9
|
+
* Resource Test Harness
|
|
10
|
+
*
|
|
11
|
+
* Generates baseline tests for Arc resources automatically.
|
|
12
|
+
* Tests CRUD operations + preset routes with minimal configuration.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { createTestHarness } from '@classytic/arc/testing';
|
|
16
|
+
* import productResource from './product.resource.js';
|
|
17
|
+
*
|
|
18
|
+
* const harness = createTestHarness(productResource, {
|
|
19
|
+
* fixtures: {
|
|
20
|
+
* valid: { name: 'Test Product', price: 100 },
|
|
21
|
+
* update: { name: 'Updated Product' },
|
|
22
|
+
* },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Run all baseline tests (50+ auto-generated)
|
|
26
|
+
* harness.runAll();
|
|
27
|
+
*
|
|
28
|
+
* // Or run specific test suites
|
|
29
|
+
* harness.runCrud();
|
|
30
|
+
* harness.runPresets();
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Test harness for Arc resources
|
|
34
|
+
*
|
|
35
|
+
* Provides automatic test generation for:
|
|
36
|
+
* - CRUD operations (create, read, update, delete)
|
|
37
|
+
* - Schema validation
|
|
38
|
+
* - Preset-specific functionality (softDelete, slugLookup, tree, etc.)
|
|
39
|
+
*/
|
|
40
|
+
var TestHarness = class {
|
|
41
|
+
resource;
|
|
42
|
+
fixtures;
|
|
43
|
+
setupFn;
|
|
44
|
+
teardownFn;
|
|
45
|
+
mongoUri;
|
|
46
|
+
_createdIds = [];
|
|
47
|
+
Model;
|
|
48
|
+
constructor(resource, options) {
|
|
49
|
+
this.resource = resource;
|
|
50
|
+
this.fixtures = options.fixtures;
|
|
51
|
+
this.setupFn = options.setupFn;
|
|
52
|
+
this.teardownFn = options.teardownFn;
|
|
53
|
+
this.mongoUri = options.mongoUri || process.env.MONGO_URI || "mongodb://localhost:27017/test";
|
|
54
|
+
if (!resource.adapter) throw new Error(`TestHarness requires a resource with a database adapter`);
|
|
55
|
+
if (resource.adapter.type !== "mongoose") throw new Error(`TestHarness currently only supports Mongoose adapters`);
|
|
56
|
+
const model = resource.adapter.model;
|
|
57
|
+
if (!model) throw new Error(`Mongoose adapter for ${resource.name} does not have a model`);
|
|
58
|
+
this.Model = model;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run all baseline tests
|
|
62
|
+
*
|
|
63
|
+
* Executes CRUD, validation, and preset tests
|
|
64
|
+
*/
|
|
65
|
+
runAll() {
|
|
66
|
+
this.runCrud();
|
|
67
|
+
this.runValidation();
|
|
68
|
+
this.runPresets();
|
|
69
|
+
this.runFieldPermissions();
|
|
70
|
+
this.runPipeline();
|
|
71
|
+
this.runEvents();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run CRUD operation tests (model-level)
|
|
75
|
+
*
|
|
76
|
+
* Tests: create, read (list + getById), update, delete
|
|
77
|
+
*
|
|
78
|
+
* @deprecated Use `HttpTestHarness.runCrud()` for HTTP-level CRUD tests.
|
|
79
|
+
* This method tests Mongoose models directly and does not exercise
|
|
80
|
+
* HTTP routes, authentication, permissions, or the Arc pipeline.
|
|
81
|
+
*/
|
|
82
|
+
runCrud() {
|
|
83
|
+
const { resource, fixtures, Model } = this;
|
|
84
|
+
describe(`${resource.displayName} CRUD Operations`, () => {
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
await mongoose.connect(this.mongoUri);
|
|
87
|
+
if (this.setupFn) await this.setupFn();
|
|
88
|
+
});
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
if (this._createdIds.length > 0) await Model.deleteMany({ _id: { $in: this._createdIds } });
|
|
91
|
+
if (this.teardownFn) await this.teardownFn();
|
|
92
|
+
await mongoose.disconnect();
|
|
93
|
+
});
|
|
94
|
+
describe("Create", () => {
|
|
95
|
+
it("should create a new document with valid data", async () => {
|
|
96
|
+
const doc = await Model.create(fixtures.valid);
|
|
97
|
+
this._createdIds.push(doc._id);
|
|
98
|
+
expect(doc).toBeDefined();
|
|
99
|
+
expect(doc._id).toBeDefined();
|
|
100
|
+
for (const [key, value] of Object.entries(fixtures.valid)) if (typeof value !== "object") expect(doc[key]).toEqual(value);
|
|
101
|
+
});
|
|
102
|
+
it("should have timestamps", async () => {
|
|
103
|
+
const doc = await Model.findById(this._createdIds[0]);
|
|
104
|
+
expect(doc).toBeDefined();
|
|
105
|
+
expect(doc.createdAt).toBeDefined();
|
|
106
|
+
expect(doc.updatedAt).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("Read", () => {
|
|
110
|
+
it("should find document by ID", async () => {
|
|
111
|
+
expect(await Model.findById(this._createdIds[0])).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
it("should list documents", async () => {
|
|
114
|
+
const docs = await Model.find({});
|
|
115
|
+
expect(Array.isArray(docs)).toBe(true);
|
|
116
|
+
expect(docs.length).toBeGreaterThan(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("Update", () => {
|
|
120
|
+
it("should update document", async () => {
|
|
121
|
+
const updateData = fixtures.update || { updatedAt: /* @__PURE__ */ new Date() };
|
|
122
|
+
expect(await Model.findByIdAndUpdate(this._createdIds[0], updateData, { new: true })).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("Delete", () => {
|
|
126
|
+
it("should delete document", async () => {
|
|
127
|
+
const toDelete = await Model.create(fixtures.valid);
|
|
128
|
+
await Model.findByIdAndDelete(toDelete._id);
|
|
129
|
+
expect(await Model.findById(toDelete._id)).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run validation tests
|
|
136
|
+
*
|
|
137
|
+
* Tests schema validation, required fields, etc.
|
|
138
|
+
*/
|
|
139
|
+
runValidation() {
|
|
140
|
+
const { resource, fixtures, Model } = this;
|
|
141
|
+
describe(`${resource.displayName} Validation`, () => {
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
await mongoose.connect(this.mongoUri);
|
|
144
|
+
});
|
|
145
|
+
afterAll(async () => {
|
|
146
|
+
await mongoose.disconnect();
|
|
147
|
+
});
|
|
148
|
+
it("should reject empty document", async () => {
|
|
149
|
+
await expect(Model.create({})).rejects.toThrow();
|
|
150
|
+
});
|
|
151
|
+
if (fixtures.invalid) it("should reject invalid data", async () => {
|
|
152
|
+
await expect(Model.create(fixtures.invalid)).rejects.toThrow();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Run preset-specific tests
|
|
158
|
+
*
|
|
159
|
+
* Auto-detects applied presets and tests their functionality:
|
|
160
|
+
* - softDelete: deletedAt field, soft delete/restore
|
|
161
|
+
* - slugLookup: slug generation
|
|
162
|
+
* - tree: parent references, displayOrder
|
|
163
|
+
* - multiTenant: organizationId requirement
|
|
164
|
+
* - ownedByUser: userId requirement
|
|
165
|
+
*/
|
|
166
|
+
runPresets() {
|
|
167
|
+
const { resource, fixtures, Model } = this;
|
|
168
|
+
const presets = resource._appliedPresets || [];
|
|
169
|
+
if (presets.length === 0) return;
|
|
170
|
+
describe(`${resource.displayName} Preset Tests`, () => {
|
|
171
|
+
beforeAll(async () => {
|
|
172
|
+
await mongoose.connect(this.mongoUri);
|
|
173
|
+
});
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
await mongoose.disconnect();
|
|
176
|
+
});
|
|
177
|
+
if (presets.includes("softDelete")) describe("Soft Delete", () => {
|
|
178
|
+
let testDoc;
|
|
179
|
+
beforeEach(async () => {
|
|
180
|
+
testDoc = await Model.create(fixtures.valid);
|
|
181
|
+
this._createdIds.push(testDoc._id);
|
|
182
|
+
});
|
|
183
|
+
it("should have deletedAt field", () => {
|
|
184
|
+
expect(testDoc.deletedAt).toBeDefined();
|
|
185
|
+
expect(testDoc.deletedAt).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
it("should soft delete (set deletedAt)", async () => {
|
|
188
|
+
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
|
|
189
|
+
expect((await Model.findById(testDoc._id)).deletedAt).not.toBeNull();
|
|
190
|
+
});
|
|
191
|
+
it("should restore (clear deletedAt)", async () => {
|
|
192
|
+
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
|
|
193
|
+
await Model.findByIdAndUpdate(testDoc._id, { deletedAt: null });
|
|
194
|
+
expect((await Model.findById(testDoc._id)).deletedAt).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
if (presets.includes("slugLookup")) describe("Slug Lookup", () => {
|
|
198
|
+
it("should have slug field", async () => {
|
|
199
|
+
const doc = await Model.create(fixtures.valid);
|
|
200
|
+
this._createdIds.push(doc._id);
|
|
201
|
+
expect(doc.slug).toBeDefined();
|
|
202
|
+
});
|
|
203
|
+
it("should generate slug from name", async () => {
|
|
204
|
+
const doc = await Model.create({
|
|
205
|
+
...fixtures.valid,
|
|
206
|
+
name: "Test Slug Name"
|
|
207
|
+
});
|
|
208
|
+
this._createdIds.push(doc._id);
|
|
209
|
+
expect(doc.slug).toMatch(/test-slug-name/i);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
if (presets.includes("tree")) describe("Tree Structure", () => {
|
|
213
|
+
it("should allow parent reference", async () => {
|
|
214
|
+
const parent = await Model.create(fixtures.valid);
|
|
215
|
+
this._createdIds.push(parent._id);
|
|
216
|
+
const child = await Model.create({
|
|
217
|
+
...fixtures.valid,
|
|
218
|
+
parent: parent._id
|
|
219
|
+
});
|
|
220
|
+
this._createdIds.push(child._id);
|
|
221
|
+
expect(child.parent.toString()).toEqual(parent._id.toString());
|
|
222
|
+
});
|
|
223
|
+
it("should support displayOrder", async () => {
|
|
224
|
+
const doc = await Model.create({
|
|
225
|
+
...fixtures.valid,
|
|
226
|
+
displayOrder: 5
|
|
227
|
+
});
|
|
228
|
+
this._createdIds.push(doc._id);
|
|
229
|
+
expect(doc.displayOrder).toEqual(5);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
if (presets.includes("multiTenant")) describe("Multi-Tenant", () => {
|
|
233
|
+
it("should require organizationId", async () => {
|
|
234
|
+
const docWithoutOrg = { ...fixtures.valid };
|
|
235
|
+
delete docWithoutOrg.organizationId;
|
|
236
|
+
await expect(Model.create(docWithoutOrg)).rejects.toThrow();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
if (presets.includes("ownedByUser")) describe("Owned By User", () => {
|
|
240
|
+
it("should require userId", async () => {
|
|
241
|
+
const docWithoutUser = { ...fixtures.valid };
|
|
242
|
+
delete docWithoutUser.userId;
|
|
243
|
+
await expect(Model.create(docWithoutUser)).rejects.toThrow();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Run field-level permission tests
|
|
250
|
+
*
|
|
251
|
+
* Auto-generates tests for each field permission:
|
|
252
|
+
* - hidden: field is stripped from responses
|
|
253
|
+
* - visibleTo: field only shown to specified roles
|
|
254
|
+
* - writableBy: field stripped from writes by non-privileged users
|
|
255
|
+
* - redactFor: field shows redacted value for specified roles
|
|
256
|
+
*/
|
|
257
|
+
runFieldPermissions() {
|
|
258
|
+
const { resource } = this;
|
|
259
|
+
const fieldPerms = resource.fields;
|
|
260
|
+
if (!fieldPerms || Object.keys(fieldPerms).length === 0) return;
|
|
261
|
+
describe(`${resource.displayName} Field Permissions`, () => {
|
|
262
|
+
for (const [field, perm] of Object.entries(fieldPerms)) switch (perm._type) {
|
|
263
|
+
case "hidden":
|
|
264
|
+
it(`should always hide field '${field}'`, () => {
|
|
265
|
+
const result = applyFieldReadPermissions({
|
|
266
|
+
[field]: "secret",
|
|
267
|
+
otherField: "visible"
|
|
268
|
+
}, fieldPerms, []);
|
|
269
|
+
expect(result[field]).toBeUndefined();
|
|
270
|
+
expect(result.otherField).toBe("visible");
|
|
271
|
+
});
|
|
272
|
+
it(`should strip hidden field '${field}' from writes`, () => {
|
|
273
|
+
const result = applyFieldWritePermissions({
|
|
274
|
+
[field]: "attempt",
|
|
275
|
+
name: "test"
|
|
276
|
+
}, fieldPerms, []);
|
|
277
|
+
expect(result[field]).toBeUndefined();
|
|
278
|
+
expect(result.name).toBe("test");
|
|
279
|
+
});
|
|
280
|
+
break;
|
|
281
|
+
case "visibleTo":
|
|
282
|
+
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
283
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["viewer"])[field]).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
286
|
+
const allowedRole = perm.roles[0];
|
|
287
|
+
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
288
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
case "writableBy":
|
|
293
|
+
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
294
|
+
const result = applyFieldWritePermissions({
|
|
295
|
+
[field]: "new-value",
|
|
296
|
+
name: "test"
|
|
297
|
+
}, fieldPerms, ["viewer"]);
|
|
298
|
+
expect(result[field]).toBeUndefined();
|
|
299
|
+
expect(result.name).toBe("test");
|
|
300
|
+
});
|
|
301
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
302
|
+
const writeRole = perm.roles[0];
|
|
303
|
+
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
304
|
+
expect(applyFieldWritePermissions({ [field]: "new-value" }, fieldPerms, [writeRole])[field]).toBe("new-value");
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
case "redactFor":
|
|
309
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
310
|
+
const redactRole = perm.roles[0];
|
|
311
|
+
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
312
|
+
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
316
|
+
expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, ["unrelated-role"])[field]).toBe("real-value");
|
|
317
|
+
});
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Run pipeline configuration tests
|
|
324
|
+
*
|
|
325
|
+
* Validates that pipeline steps are properly configured:
|
|
326
|
+
* - All steps have names
|
|
327
|
+
* - All steps have valid _type discriminants
|
|
328
|
+
* - Operation filters (if set) use valid CRUD operation names
|
|
329
|
+
*/
|
|
330
|
+
runPipeline() {
|
|
331
|
+
const { resource } = this;
|
|
332
|
+
const pipe = resource.pipe;
|
|
333
|
+
if (!pipe) return;
|
|
334
|
+
const validOps = new Set(CRUD_OPERATIONS);
|
|
335
|
+
describe(`${resource.displayName} Pipeline`, () => {
|
|
336
|
+
const steps = collectPipelineSteps(pipe);
|
|
337
|
+
it("should have at least one pipeline step", () => {
|
|
338
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
339
|
+
});
|
|
340
|
+
for (const step of steps) {
|
|
341
|
+
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
342
|
+
expect([
|
|
343
|
+
"guard",
|
|
344
|
+
"transform",
|
|
345
|
+
"interceptor"
|
|
346
|
+
]).toContain(step._type);
|
|
347
|
+
});
|
|
348
|
+
it(`${step._type} '${step.name}' should have a name`, () => {
|
|
349
|
+
expect(step.name).toBeTruthy();
|
|
350
|
+
expect(typeof step.name).toBe("string");
|
|
351
|
+
});
|
|
352
|
+
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
353
|
+
expect(typeof step.handler).toBe("function");
|
|
354
|
+
});
|
|
355
|
+
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
356
|
+
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Run event definition tests
|
|
363
|
+
*
|
|
364
|
+
* Validates that events are properly defined:
|
|
365
|
+
* - All events have handler functions
|
|
366
|
+
* - Event names follow resource:action convention
|
|
367
|
+
* - Schema definitions (if present) are valid objects
|
|
368
|
+
*/
|
|
369
|
+
runEvents() {
|
|
370
|
+
const { resource } = this;
|
|
371
|
+
const events = resource.events;
|
|
372
|
+
if (!events || Object.keys(events).length === 0) return;
|
|
373
|
+
describe(`${resource.displayName} Events`, () => {
|
|
374
|
+
for (const [action, def] of Object.entries(events)) {
|
|
375
|
+
it(`event '${resource.name}:${action}' should have a handler function`, () => {
|
|
376
|
+
expect(typeof def.handler).toBe("function");
|
|
377
|
+
});
|
|
378
|
+
it(`event '${resource.name}:${action}' should have a name`, () => {
|
|
379
|
+
expect(def.name).toBeTruthy();
|
|
380
|
+
expect(typeof def.name).toBe("string");
|
|
381
|
+
});
|
|
382
|
+
if (def.schema) it(`event '${resource.name}:${action}' schema should be an object`, () => {
|
|
383
|
+
expect(typeof def.schema).toBe("object");
|
|
384
|
+
expect(def.schema).not.toBeNull();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
/**
|
|
391
|
+
* Collect all pipeline steps from a PipelineConfig (flat array or per-operation map)
|
|
392
|
+
*/
|
|
393
|
+
function collectPipelineSteps(pipe) {
|
|
394
|
+
if (Array.isArray(pipe)) return pipe;
|
|
395
|
+
const seen = /* @__PURE__ */ new Set();
|
|
396
|
+
const steps = [];
|
|
397
|
+
for (const opSteps of Object.values(pipe)) if (Array.isArray(opSteps)) for (const step of opSteps) {
|
|
398
|
+
const key = `${step._type}:${step.name}`;
|
|
399
|
+
if (!seen.has(key)) {
|
|
400
|
+
seen.add(key);
|
|
401
|
+
steps.push(step);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return steps;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Create a test harness for an Arc resource
|
|
408
|
+
*
|
|
409
|
+
* @param resource - The Arc resource definition to test
|
|
410
|
+
* @param options - Test harness configuration
|
|
411
|
+
* @returns Test harness instance
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* import { createTestHarness } from '@classytic/arc/testing';
|
|
415
|
+
*
|
|
416
|
+
* const harness = createTestHarness(productResource, {
|
|
417
|
+
* fixtures: {
|
|
418
|
+
* valid: { name: 'Product', price: 100 },
|
|
419
|
+
* update: { name: 'Updated' },
|
|
420
|
+
* },
|
|
421
|
+
* });
|
|
422
|
+
*
|
|
423
|
+
* harness.runAll(); // Generates 50+ baseline tests
|
|
424
|
+
*/
|
|
425
|
+
function createTestHarness(resource, options) {
|
|
426
|
+
return new TestHarness(resource, options);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Generate test file content for a resource
|
|
430
|
+
*
|
|
431
|
+
* Useful for scaffolding new resource tests via CLI
|
|
432
|
+
*
|
|
433
|
+
* @param resourceName - Resource name in kebab-case (e.g., 'product')
|
|
434
|
+
* @param options - Generation options
|
|
435
|
+
* @returns Complete test file content as string
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* const testContent = generateTestFile('product', {
|
|
439
|
+
* presets: ['softDelete'],
|
|
440
|
+
* modulePath: './modules/catalog',
|
|
441
|
+
* });
|
|
442
|
+
* fs.writeFileSync('product.test.js', testContent);
|
|
443
|
+
*/
|
|
444
|
+
function generateTestFile(resourceName, options = {}) {
|
|
445
|
+
const { presets = [], modulePath = "." } = options;
|
|
446
|
+
const className = resourceName.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
447
|
+
const varName = className.charAt(0).toLowerCase() + className.slice(1);
|
|
448
|
+
return `/**
|
|
449
|
+
* ${className} Resource Tests
|
|
450
|
+
*
|
|
451
|
+
* Auto-generated baseline tests. Customize as needed.
|
|
452
|
+
*/
|
|
453
|
+
|
|
454
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
455
|
+
import mongoose from 'mongoose';
|
|
456
|
+
import { createTestHarness } from '@classytic/arc/testing';
|
|
457
|
+
import ${varName}Resource from '${modulePath}/${resourceName}.resource.js';
|
|
458
|
+
import ${className} from '${modulePath}/${resourceName}.model.js';
|
|
459
|
+
|
|
460
|
+
const MONGO_URI = process.env.MONGO_TEST_URI || 'mongodb://localhost:27017/${resourceName}-test';
|
|
461
|
+
|
|
462
|
+
// Test fixtures
|
|
463
|
+
const fixtures = {
|
|
464
|
+
valid: {
|
|
465
|
+
name: 'Test ${className}',
|
|
466
|
+
// Add required fields here
|
|
467
|
+
},
|
|
468
|
+
update: {
|
|
469
|
+
name: 'Updated ${className}',
|
|
470
|
+
},
|
|
471
|
+
invalid: {
|
|
472
|
+
// Empty or invalid data
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Create test harness
|
|
477
|
+
const harness = createTestHarness(${varName}Resource, {
|
|
478
|
+
fixtures,
|
|
479
|
+
mongoUri: MONGO_URI,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Run all baseline tests
|
|
483
|
+
harness.runAll();
|
|
484
|
+
|
|
485
|
+
// Custom tests
|
|
486
|
+
describe('${className} Custom Tests', () => {
|
|
487
|
+
let testId;
|
|
488
|
+
|
|
489
|
+
beforeAll(async () => {
|
|
490
|
+
await mongoose.connect(MONGO_URI);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
afterAll(async () => {
|
|
494
|
+
if (testId) {
|
|
495
|
+
await ${className}.findByIdAndDelete(testId);
|
|
496
|
+
}
|
|
497
|
+
await mongoose.disconnect();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Add your custom tests here
|
|
501
|
+
it('should pass custom validation', async () => {
|
|
502
|
+
// Example: const doc = await ${className}.create(fixtures.valid);
|
|
503
|
+
// testId = doc._id;
|
|
504
|
+
// expect(doc.someField).toBe('expectedValue');
|
|
505
|
+
expect(true).toBe(true);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
`;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Run config-level tests for a resource (no DB required)
|
|
512
|
+
*
|
|
513
|
+
* Tests field permissions, pipeline configuration, and event definitions.
|
|
514
|
+
* Works with any adapter — no Mongoose dependency.
|
|
515
|
+
*
|
|
516
|
+
* @param resource - The Arc resource definition to test
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* ```typescript
|
|
520
|
+
* import { createConfigTestSuite } from '@classytic/arc/testing';
|
|
521
|
+
* import productResource from './product.resource.js';
|
|
522
|
+
*
|
|
523
|
+
* // Generates field permission, pipeline, and event tests
|
|
524
|
+
* createConfigTestSuite(productResource);
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
function createConfigTestSuite(resource) {
|
|
528
|
+
const fieldPerms = resource.fields;
|
|
529
|
+
const pipe = resource.pipe;
|
|
530
|
+
const events = resource.events;
|
|
531
|
+
if (fieldPerms && Object.keys(fieldPerms).length > 0) runFieldPermissionTests(resource.displayName, fieldPerms);
|
|
532
|
+
if (pipe) runPipelineTests(resource.displayName, pipe);
|
|
533
|
+
if (events && Object.keys(events).length > 0) runEventTests(resource.name, resource.displayName, events);
|
|
534
|
+
if (resource.permissions && Object.keys(resource.permissions).length > 0) describe(`${resource.displayName} Permission Config`, () => {
|
|
535
|
+
for (const op of CRUD_OPERATIONS) {
|
|
536
|
+
const check = resource.permissions[op];
|
|
537
|
+
if (check) it(`${op} permission should be a function`, () => {
|
|
538
|
+
expect(typeof check).toBe("function");
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
function runFieldPermissionTests(displayName, fieldPerms) {
|
|
544
|
+
describe(`${displayName} Field Permissions`, () => {
|
|
545
|
+
for (const [field, perm] of Object.entries(fieldPerms)) switch (perm._type) {
|
|
546
|
+
case "hidden":
|
|
547
|
+
it(`should always hide field '${field}'`, () => {
|
|
548
|
+
expect(applyFieldReadPermissions({
|
|
549
|
+
[field]: "secret",
|
|
550
|
+
other: "visible"
|
|
551
|
+
}, fieldPerms, [])[field]).toBeUndefined();
|
|
552
|
+
});
|
|
553
|
+
it(`should strip hidden field '${field}' from writes`, () => {
|
|
554
|
+
expect(applyFieldWritePermissions({
|
|
555
|
+
[field]: "attempt",
|
|
556
|
+
name: "test"
|
|
557
|
+
}, fieldPerms, [])[field]).toBeUndefined();
|
|
558
|
+
});
|
|
559
|
+
break;
|
|
560
|
+
case "visibleTo":
|
|
561
|
+
it(`should hide field '${field}' from non-privileged users`, () => {
|
|
562
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
563
|
+
});
|
|
564
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
565
|
+
const allowedRole = perm.roles[0];
|
|
566
|
+
it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
|
|
567
|
+
expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
case "writableBy":
|
|
572
|
+
it(`should strip field '${field}' from writes by non-privileged users`, () => {
|
|
573
|
+
expect(applyFieldWritePermissions({
|
|
574
|
+
[field]: "v",
|
|
575
|
+
name: "test"
|
|
576
|
+
}, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
|
|
577
|
+
});
|
|
578
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
579
|
+
const writeRole = perm.roles[0];
|
|
580
|
+
it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
|
|
581
|
+
expect(applyFieldWritePermissions({ [field]: "v" }, fieldPerms, [writeRole])[field]).toBe("v");
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
case "redactFor":
|
|
586
|
+
if (perm.roles && perm.roles.length > 0) {
|
|
587
|
+
const redactRole = perm.roles[0];
|
|
588
|
+
it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
|
|
589
|
+
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
it(`should show real value of field '${field}' to non-redacted roles`, () => {
|
|
593
|
+
expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, ["_other_"])[field]).toBe("real");
|
|
594
|
+
});
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
function runPipelineTests(displayName, pipe) {
|
|
600
|
+
const steps = collectPipelineSteps(pipe);
|
|
601
|
+
if (steps.length === 0) return;
|
|
602
|
+
const validOps = new Set(CRUD_OPERATIONS);
|
|
603
|
+
describe(`${displayName} Pipeline`, () => {
|
|
604
|
+
it("should have at least one pipeline step", () => {
|
|
605
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
606
|
+
});
|
|
607
|
+
for (const step of steps) {
|
|
608
|
+
it(`${step._type} '${step.name}' should have a valid type`, () => {
|
|
609
|
+
expect([
|
|
610
|
+
"guard",
|
|
611
|
+
"transform",
|
|
612
|
+
"interceptor"
|
|
613
|
+
]).toContain(step._type);
|
|
614
|
+
});
|
|
615
|
+
it(`${step._type} '${step.name}' should have a handler function`, () => {
|
|
616
|
+
expect(typeof step.handler).toBe("function");
|
|
617
|
+
});
|
|
618
|
+
if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
|
|
619
|
+
for (const op of step.operations) expect(validOps.has(op)).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function runEventTests(resourceName, displayName, events) {
|
|
625
|
+
describe(`${displayName} Events`, () => {
|
|
626
|
+
for (const [action, def] of Object.entries(events)) {
|
|
627
|
+
it(`event '${resourceName}:${action}' should have a handler function`, () => {
|
|
628
|
+
expect(typeof def.handler).toBe("function");
|
|
629
|
+
});
|
|
630
|
+
it(`event '${resourceName}:${action}' should have a name`, () => {
|
|
631
|
+
expect(def.name).toBeTruthy();
|
|
632
|
+
});
|
|
633
|
+
if (def.schema) it(`event '${resourceName}:${action}' schema should be an object`, () => {
|
|
634
|
+
expect(typeof def.schema).toBe("object");
|
|
635
|
+
expect(def.schema).not.toBeNull();
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/testing/dbHelpers.ts
|
|
643
|
+
/**
|
|
644
|
+
* Testing Utilities - Database Helpers
|
|
645
|
+
*
|
|
646
|
+
* Utilities for managing test databases and fixtures
|
|
647
|
+
*/
|
|
648
|
+
/**
|
|
649
|
+
* Test database manager
|
|
650
|
+
*/
|
|
651
|
+
var TestDatabase = class {
|
|
652
|
+
connection;
|
|
653
|
+
dbName;
|
|
654
|
+
constructor(dbName = `test_${Date.now()}`) {
|
|
655
|
+
this.dbName = dbName;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Connect to test database
|
|
659
|
+
*/
|
|
660
|
+
async connect(uri) {
|
|
661
|
+
const fullUri = `${uri || process.env.MONGO_TEST_URI || "mongodb://localhost:27017"}/${this.dbName}`;
|
|
662
|
+
this.connection = await mongoose.createConnection(fullUri).asPromise();
|
|
663
|
+
return this.connection;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Disconnect and cleanup
|
|
667
|
+
*/
|
|
668
|
+
async disconnect() {
|
|
669
|
+
if (this.connection) {
|
|
670
|
+
await this.connection.dropDatabase();
|
|
671
|
+
await this.connection.close();
|
|
672
|
+
this.connection = void 0;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Clear all collections
|
|
677
|
+
*/
|
|
678
|
+
async clear() {
|
|
679
|
+
if (!this.connection?.db) throw new Error("Database not connected");
|
|
680
|
+
const collections = await this.connection.db.collections();
|
|
681
|
+
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Get connection
|
|
685
|
+
*/
|
|
686
|
+
getConnection() {
|
|
687
|
+
if (!this.connection) throw new Error("Database not connected");
|
|
688
|
+
return this.connection;
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
/**
|
|
692
|
+
* Higher-order function to wrap tests with database setup/teardown
|
|
693
|
+
*
|
|
694
|
+
* @example
|
|
695
|
+
* describe('Product Tests', () => {
|
|
696
|
+
* withTestDb(async (db) => {
|
|
697
|
+
* test('create product', async () => {
|
|
698
|
+
* const Product = db.getConnection().model('Product', schema);
|
|
699
|
+
* const product = await Product.create({ name: 'Test' });
|
|
700
|
+
* expect(product.name).toBe('Test');
|
|
701
|
+
* });
|
|
702
|
+
* });
|
|
703
|
+
* });
|
|
704
|
+
*/
|
|
705
|
+
function withTestDb(tests, options = {}) {
|
|
706
|
+
const db = new TestDatabase(options.dbName);
|
|
707
|
+
beforeAll(async () => {
|
|
708
|
+
await db.connect(options.uri);
|
|
709
|
+
});
|
|
710
|
+
afterAll(async () => {
|
|
711
|
+
await db.disconnect();
|
|
712
|
+
});
|
|
713
|
+
afterEach(async () => {
|
|
714
|
+
await db.clear();
|
|
715
|
+
});
|
|
716
|
+
tests(db);
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Create test fixtures
|
|
720
|
+
*
|
|
721
|
+
* @example
|
|
722
|
+
* const fixtures = new TestFixtures(connection);
|
|
723
|
+
*
|
|
724
|
+
* await fixtures.load('products', [
|
|
725
|
+
* { name: 'Product 1', price: 100 },
|
|
726
|
+
* { name: 'Product 2', price: 200 },
|
|
727
|
+
* ]);
|
|
728
|
+
*
|
|
729
|
+
* const products = await fixtures.get('products');
|
|
730
|
+
*/
|
|
731
|
+
var TestFixtures = class {
|
|
732
|
+
fixtures = /* @__PURE__ */ new Map();
|
|
733
|
+
connection;
|
|
734
|
+
constructor(connection) {
|
|
735
|
+
this.connection = connection;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Load fixtures into a collection
|
|
739
|
+
*/
|
|
740
|
+
async load(collectionName, data) {
|
|
741
|
+
const result = await this.connection.collection(collectionName).insertMany(data);
|
|
742
|
+
const insertedDocs = Object.values(result.insertedIds).map((id, index) => ({
|
|
743
|
+
...data[index],
|
|
744
|
+
_id: id
|
|
745
|
+
}));
|
|
746
|
+
this.fixtures.set(collectionName, insertedDocs);
|
|
747
|
+
return insertedDocs;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Get loaded fixtures
|
|
751
|
+
*/
|
|
752
|
+
get(collectionName) {
|
|
753
|
+
return this.fixtures.get(collectionName) || [];
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Get first fixture
|
|
757
|
+
*/
|
|
758
|
+
getFirst(collectionName) {
|
|
759
|
+
return this.get(collectionName)[0] || null;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Clear all fixtures
|
|
763
|
+
*/
|
|
764
|
+
async clear() {
|
|
765
|
+
for (const collectionName of this.fixtures.keys()) {
|
|
766
|
+
const collection = this.connection.collection(collectionName);
|
|
767
|
+
const ids = this.fixtures.get(collectionName)?.map((item) => item._id) || [];
|
|
768
|
+
await collection.deleteMany({ _id: { $in: ids } });
|
|
769
|
+
}
|
|
770
|
+
this.fixtures.clear();
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
/**
|
|
774
|
+
* In-memory MongoDB for ultra-fast tests
|
|
775
|
+
*
|
|
776
|
+
* Requires: mongodb-memory-server
|
|
777
|
+
*
|
|
778
|
+
* @example
|
|
779
|
+
* import { InMemoryDatabase } from '@classytic/arc/testing';
|
|
780
|
+
*
|
|
781
|
+
* describe('Fast Tests', () => {
|
|
782
|
+
* const memoryDb = new InMemoryDatabase();
|
|
783
|
+
*
|
|
784
|
+
* beforeAll(async () => {
|
|
785
|
+
* await memoryDb.start();
|
|
786
|
+
* });
|
|
787
|
+
*
|
|
788
|
+
* afterAll(async () => {
|
|
789
|
+
* await memoryDb.stop();
|
|
790
|
+
* });
|
|
791
|
+
*
|
|
792
|
+
* test('create user', async () => {
|
|
793
|
+
* const uri = memoryDb.getUri();
|
|
794
|
+
* // Use uri for connection
|
|
795
|
+
* });
|
|
796
|
+
* });
|
|
797
|
+
*/
|
|
798
|
+
var InMemoryDatabase = class {
|
|
799
|
+
mongod;
|
|
800
|
+
uri;
|
|
801
|
+
/**
|
|
802
|
+
* Start in-memory MongoDB
|
|
803
|
+
*/
|
|
804
|
+
async start() {
|
|
805
|
+
try {
|
|
806
|
+
const { MongoMemoryServer } = await import("mongodb-memory-server");
|
|
807
|
+
this.mongod = await MongoMemoryServer.create();
|
|
808
|
+
const uri = this.mongod.getUri();
|
|
809
|
+
this.uri = uri;
|
|
810
|
+
return uri;
|
|
811
|
+
} catch {
|
|
812
|
+
throw new Error("mongodb-memory-server not installed. Install with: npm install -D mongodb-memory-server");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Stop in-memory MongoDB
|
|
817
|
+
*/
|
|
818
|
+
async stop() {
|
|
819
|
+
if (this.mongod) {
|
|
820
|
+
await this.mongod.stop();
|
|
821
|
+
this.mongod = void 0;
|
|
822
|
+
this.uri = void 0;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Get connection URI
|
|
827
|
+
*/
|
|
828
|
+
getUri() {
|
|
829
|
+
if (!this.uri) throw new Error("In-memory database not started");
|
|
830
|
+
return this.uri;
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
/**
|
|
834
|
+
* Database transaction helper for testing
|
|
835
|
+
*/
|
|
836
|
+
var TestTransaction = class {
|
|
837
|
+
session;
|
|
838
|
+
connection;
|
|
839
|
+
constructor(connection) {
|
|
840
|
+
this.connection = connection;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Start transaction
|
|
844
|
+
*/
|
|
845
|
+
async start() {
|
|
846
|
+
this.session = await this.connection.startSession();
|
|
847
|
+
this.session.startTransaction();
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Commit transaction
|
|
851
|
+
*/
|
|
852
|
+
async commit() {
|
|
853
|
+
if (!this.session) throw new Error("Transaction not started");
|
|
854
|
+
await this.session.commitTransaction();
|
|
855
|
+
await this.session.endSession();
|
|
856
|
+
this.session = void 0;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Rollback transaction
|
|
860
|
+
*/
|
|
861
|
+
async rollback() {
|
|
862
|
+
if (!this.session) throw new Error("Transaction not started");
|
|
863
|
+
await this.session.abortTransaction();
|
|
864
|
+
await this.session.endSession();
|
|
865
|
+
this.session = void 0;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Get session
|
|
869
|
+
*/
|
|
870
|
+
getSession() {
|
|
871
|
+
if (!this.session) throw new Error("Transaction not started");
|
|
872
|
+
return this.session;
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
/**
|
|
876
|
+
* Seed data helper
|
|
877
|
+
*/
|
|
878
|
+
var TestSeeder = class {
|
|
879
|
+
connection;
|
|
880
|
+
constructor(connection) {
|
|
881
|
+
this.connection = connection;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Seed collection with data
|
|
885
|
+
*/
|
|
886
|
+
async seed(collectionName, generator, count = 10) {
|
|
887
|
+
const data = Array.from({ length: count }, () => generator()).flat();
|
|
888
|
+
const result = await this.connection.collection(collectionName).insertMany(data);
|
|
889
|
+
return Object.values(result.insertedIds).map((id, index) => ({
|
|
890
|
+
...data[index],
|
|
891
|
+
_id: id
|
|
892
|
+
}));
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Clear collection
|
|
896
|
+
*/
|
|
897
|
+
async clear(collectionName) {
|
|
898
|
+
await this.connection.collection(collectionName).deleteMany({});
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Clear all collections
|
|
902
|
+
*/
|
|
903
|
+
async clearAll() {
|
|
904
|
+
if (!this.connection.db) throw new Error("Database not connected");
|
|
905
|
+
const collections = await this.connection.db.collections();
|
|
906
|
+
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
/**
|
|
910
|
+
* Database snapshot helper for rollback testing
|
|
911
|
+
*/
|
|
912
|
+
var DatabaseSnapshot = class {
|
|
913
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
914
|
+
connection;
|
|
915
|
+
constructor(connection) {
|
|
916
|
+
this.connection = connection;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Take snapshot of current database state
|
|
920
|
+
*/
|
|
921
|
+
async take() {
|
|
922
|
+
if (!this.connection.db) throw new Error("Database not connected");
|
|
923
|
+
const collections = await this.connection.db.collections();
|
|
924
|
+
for (const collection of collections) {
|
|
925
|
+
const data = await collection.find({}).toArray();
|
|
926
|
+
this.snapshots.set(collection.collectionName, data);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Restore database to snapshot
|
|
931
|
+
*/
|
|
932
|
+
async restore() {
|
|
933
|
+
if (!this.connection.db) throw new Error("Database not connected");
|
|
934
|
+
const collections = await this.connection.db.collections();
|
|
935
|
+
await Promise.all(collections.map((collection) => collection.deleteMany({})));
|
|
936
|
+
for (const [collectionName, data] of this.snapshots.entries()) if (data.length > 0) await this.connection.collection(collectionName).insertMany(data);
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Clear snapshot
|
|
940
|
+
*/
|
|
941
|
+
clear() {
|
|
942
|
+
this.snapshots.clear();
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/testing/testFactory.ts
|
|
948
|
+
/**
|
|
949
|
+
* Testing Utilities - Test App Factory
|
|
950
|
+
*
|
|
951
|
+
* Create Fastify test instances with Arc configuration
|
|
952
|
+
*/
|
|
953
|
+
/**
|
|
954
|
+
* Create a test application instance with optional in-memory MongoDB
|
|
955
|
+
*
|
|
956
|
+
* **Performance Boost**: Uses in-memory MongoDB by default for 10x faster tests.
|
|
957
|
+
*
|
|
958
|
+
* @example Basic usage with in-memory DB
|
|
959
|
+
* ```typescript
|
|
960
|
+
* import { createTestApp } from '@classytic/arc/testing';
|
|
961
|
+
*
|
|
962
|
+
* describe('API Tests', () => {
|
|
963
|
+
* let testApp: TestAppResult;
|
|
964
|
+
*
|
|
965
|
+
* beforeAll(async () => {
|
|
966
|
+
* testApp = await createTestApp({
|
|
967
|
+
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
968
|
+
* });
|
|
969
|
+
* });
|
|
970
|
+
*
|
|
971
|
+
* afterAll(async () => {
|
|
972
|
+
* await testApp.close(); // Cleans up DB and disconnects
|
|
973
|
+
* });
|
|
974
|
+
*
|
|
975
|
+
* test('GET /health', async () => {
|
|
976
|
+
* const response = await testApp.app.inject({
|
|
977
|
+
* method: 'GET',
|
|
978
|
+
* url: '/health',
|
|
979
|
+
* });
|
|
980
|
+
* expect(response.statusCode).toBe(200);
|
|
981
|
+
* });
|
|
982
|
+
* });
|
|
983
|
+
* ```
|
|
984
|
+
*
|
|
985
|
+
* @example Using external MongoDB
|
|
986
|
+
* ```typescript
|
|
987
|
+
* const testApp = await createTestApp({
|
|
988
|
+
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
989
|
+
* useInMemoryDb: false,
|
|
990
|
+
* mongoUri: 'mongodb://localhost:27017/test-db',
|
|
991
|
+
* });
|
|
992
|
+
* ```
|
|
993
|
+
*
|
|
994
|
+
* @example Accessing MongoDB URI for model connections
|
|
995
|
+
* ```typescript
|
|
996
|
+
* const testApp = await createTestApp({
|
|
997
|
+
* auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
|
|
998
|
+
* });
|
|
999
|
+
* await mongoose.connect(testApp.mongoUri); // Connect your models
|
|
1000
|
+
* ```
|
|
1001
|
+
*/
|
|
1002
|
+
async function createTestApp(options = {}) {
|
|
1003
|
+
const { createApp } = await import("../createApp-CUgNqegw.mjs").then((n) => n.r);
|
|
1004
|
+
const { useInMemoryDb = true, mongoUri: providedMongoUri, ...appOptions } = options;
|
|
1005
|
+
const defaultAuth = {
|
|
1006
|
+
type: "jwt",
|
|
1007
|
+
jwt: { secret: "test-secret-32-chars-minimum-len" }
|
|
1008
|
+
};
|
|
1009
|
+
let inMemoryDb = null;
|
|
1010
|
+
let mongoUri = providedMongoUri;
|
|
1011
|
+
if (useInMemoryDb && !providedMongoUri) try {
|
|
1012
|
+
inMemoryDb = new InMemoryDatabase();
|
|
1013
|
+
mongoUri = await inMemoryDb.start();
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
console.warn("[createTestApp] Failed to start in-memory MongoDB:", err.message, "\nFalling back to external MongoDB or no DB connection.");
|
|
1016
|
+
}
|
|
1017
|
+
const app = await createApp({
|
|
1018
|
+
preset: "testing",
|
|
1019
|
+
logger: false,
|
|
1020
|
+
helmet: false,
|
|
1021
|
+
cors: false,
|
|
1022
|
+
rateLimit: false,
|
|
1023
|
+
underPressure: false,
|
|
1024
|
+
auth: defaultAuth,
|
|
1025
|
+
...appOptions
|
|
1026
|
+
});
|
|
1027
|
+
return {
|
|
1028
|
+
app,
|
|
1029
|
+
mongoUri,
|
|
1030
|
+
async close() {
|
|
1031
|
+
await app.close();
|
|
1032
|
+
if (inMemoryDb) await inMemoryDb.stop();
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Create a minimal Fastify instance for unit tests
|
|
1038
|
+
*
|
|
1039
|
+
* Use when you don't need Arc's full plugin stack
|
|
1040
|
+
*
|
|
1041
|
+
* @example
|
|
1042
|
+
* const app = createMinimalTestApp();
|
|
1043
|
+
* app.get('/test', async () => ({ success: true }));
|
|
1044
|
+
*
|
|
1045
|
+
* const response = await app.inject({ method: 'GET', url: '/test' });
|
|
1046
|
+
* expect(response.json()).toEqual({ success: true });
|
|
1047
|
+
*/
|
|
1048
|
+
function createMinimalTestApp(options = {}) {
|
|
1049
|
+
return Fastify({
|
|
1050
|
+
logger: false,
|
|
1051
|
+
...options
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Test request builder for cleaner tests
|
|
1056
|
+
*
|
|
1057
|
+
* @example
|
|
1058
|
+
* const request = new TestRequestBuilder(app)
|
|
1059
|
+
* .get('/products')
|
|
1060
|
+
* .withAuth(mockUser)
|
|
1061
|
+
* .withQuery({ page: 1, limit: 10 });
|
|
1062
|
+
*
|
|
1063
|
+
* const response = await request.send();
|
|
1064
|
+
* expect(response.statusCode).toBe(200);
|
|
1065
|
+
*/
|
|
1066
|
+
var TestRequestBuilder = class {
|
|
1067
|
+
method = "GET";
|
|
1068
|
+
url = "/";
|
|
1069
|
+
body;
|
|
1070
|
+
query;
|
|
1071
|
+
headers = {};
|
|
1072
|
+
app;
|
|
1073
|
+
constructor(app) {
|
|
1074
|
+
this.app = app;
|
|
1075
|
+
}
|
|
1076
|
+
get(url) {
|
|
1077
|
+
this.method = "GET";
|
|
1078
|
+
this.url = url;
|
|
1079
|
+
return this;
|
|
1080
|
+
}
|
|
1081
|
+
post(url) {
|
|
1082
|
+
this.method = "POST";
|
|
1083
|
+
this.url = url;
|
|
1084
|
+
return this;
|
|
1085
|
+
}
|
|
1086
|
+
put(url) {
|
|
1087
|
+
this.method = "PUT";
|
|
1088
|
+
this.url = url;
|
|
1089
|
+
return this;
|
|
1090
|
+
}
|
|
1091
|
+
patch(url) {
|
|
1092
|
+
this.method = "PATCH";
|
|
1093
|
+
this.url = url;
|
|
1094
|
+
return this;
|
|
1095
|
+
}
|
|
1096
|
+
delete(url) {
|
|
1097
|
+
this.method = "DELETE";
|
|
1098
|
+
this.url = url;
|
|
1099
|
+
return this;
|
|
1100
|
+
}
|
|
1101
|
+
withBody(body) {
|
|
1102
|
+
this.body = body;
|
|
1103
|
+
return this;
|
|
1104
|
+
}
|
|
1105
|
+
withQuery(query) {
|
|
1106
|
+
this.query = query;
|
|
1107
|
+
return this;
|
|
1108
|
+
}
|
|
1109
|
+
withHeader(key, value) {
|
|
1110
|
+
this.headers[key] = value;
|
|
1111
|
+
return this;
|
|
1112
|
+
}
|
|
1113
|
+
withAuth(userOrHeaders) {
|
|
1114
|
+
if ("authorization" in userOrHeaders || "Authorization" in userOrHeaders) {
|
|
1115
|
+
for (const [key, value] of Object.entries(userOrHeaders)) if (typeof value === "string") this.headers[key] = value;
|
|
1116
|
+
} else {
|
|
1117
|
+
const token = this.app.jwt?.sign?.(userOrHeaders) || "mock-token";
|
|
1118
|
+
this.headers["Authorization"] = `Bearer ${token}`;
|
|
1119
|
+
}
|
|
1120
|
+
return this;
|
|
1121
|
+
}
|
|
1122
|
+
withContentType(type) {
|
|
1123
|
+
this.headers["Content-Type"] = type;
|
|
1124
|
+
return this;
|
|
1125
|
+
}
|
|
1126
|
+
async send() {
|
|
1127
|
+
return this.app.inject({
|
|
1128
|
+
method: this.method,
|
|
1129
|
+
url: this.url,
|
|
1130
|
+
payload: this.body,
|
|
1131
|
+
query: this.query,
|
|
1132
|
+
headers: this.headers
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
/**
|
|
1137
|
+
* Helper to create a test request builder
|
|
1138
|
+
*/
|
|
1139
|
+
function request(app) {
|
|
1140
|
+
return new TestRequestBuilder(app);
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Test helper for authentication
|
|
1144
|
+
*/
|
|
1145
|
+
function createTestAuth(app) {
|
|
1146
|
+
return {
|
|
1147
|
+
generateToken(user) {
|
|
1148
|
+
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1149
|
+
return app.jwt.sign(user);
|
|
1150
|
+
},
|
|
1151
|
+
decodeToken(token) {
|
|
1152
|
+
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1153
|
+
return app.jwt.decode(token);
|
|
1154
|
+
},
|
|
1155
|
+
async verifyToken(token) {
|
|
1156
|
+
if (!app.jwt) throw new Error("JWT plugin not registered");
|
|
1157
|
+
return app.jwt.verify(token);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Snapshot testing helper for API responses
|
|
1163
|
+
*/
|
|
1164
|
+
function createSnapshotMatcher() {
|
|
1165
|
+
return { matchStructure(response, expected) {
|
|
1166
|
+
if (typeof response !== typeof expected) return false;
|
|
1167
|
+
if (Array.isArray(response) && Array.isArray(expected)) return response.length === expected.length;
|
|
1168
|
+
if (typeof response === "object" && response !== null) {
|
|
1169
|
+
const responseKeys = Object.keys(response).sort();
|
|
1170
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
1171
|
+
if (JSON.stringify(responseKeys) !== JSON.stringify(expectedKeys)) return false;
|
|
1172
|
+
for (const key of responseKeys) if (!this.matchStructure(response[key], expected[key])) return false;
|
|
1173
|
+
return true;
|
|
1174
|
+
}
|
|
1175
|
+
return true;
|
|
1176
|
+
} };
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Bulk test data loader
|
|
1180
|
+
*/
|
|
1181
|
+
var TestDataLoader = class {
|
|
1182
|
+
data = /* @__PURE__ */ new Map();
|
|
1183
|
+
app;
|
|
1184
|
+
constructor(app) {
|
|
1185
|
+
this.app = app;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Load test data into database
|
|
1189
|
+
*/
|
|
1190
|
+
async load(collection, items) {
|
|
1191
|
+
this.data.set(collection, items);
|
|
1192
|
+
return items;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Clear all loaded test data
|
|
1196
|
+
*/
|
|
1197
|
+
async cleanup() {
|
|
1198
|
+
for (const [collection, items] of this.data.entries());
|
|
1199
|
+
this.data.clear();
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
//#endregion
|
|
1204
|
+
//#region src/testing/mocks.ts
|
|
1205
|
+
/**
|
|
1206
|
+
* Testing Utilities - Mock Factories
|
|
1207
|
+
*
|
|
1208
|
+
* Create mock repositories, controllers, and services for testing.
|
|
1209
|
+
* Uses Vitest for mocking (compatible with Jest API).
|
|
1210
|
+
*/
|
|
1211
|
+
/**
|
|
1212
|
+
* Create a mock repository for testing
|
|
1213
|
+
*
|
|
1214
|
+
* @example
|
|
1215
|
+
* const mockRepo = createMockRepository<Product>({
|
|
1216
|
+
* getById: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
|
|
1217
|
+
* create: vi.fn().mockImplementation(data => Promise.resolve({ id: '1', ...data })),
|
|
1218
|
+
* });
|
|
1219
|
+
*
|
|
1220
|
+
* await mockRepo.getById('1'); // Returns mocked product
|
|
1221
|
+
*/
|
|
1222
|
+
function createMockRepository(overrides = {}) {
|
|
1223
|
+
return {
|
|
1224
|
+
getAll: vi.fn().mockResolvedValue({
|
|
1225
|
+
docs: [],
|
|
1226
|
+
total: 0,
|
|
1227
|
+
page: 1,
|
|
1228
|
+
limit: 20,
|
|
1229
|
+
pages: 0,
|
|
1230
|
+
hasNext: false,
|
|
1231
|
+
hasPrev: false
|
|
1232
|
+
}),
|
|
1233
|
+
getById: vi.fn().mockResolvedValue(null),
|
|
1234
|
+
create: vi.fn().mockImplementation((data) => Promise.resolve({
|
|
1235
|
+
_id: "mock-id",
|
|
1236
|
+
...data
|
|
1237
|
+
})),
|
|
1238
|
+
update: vi.fn().mockImplementation((_id, data) => Promise.resolve({
|
|
1239
|
+
_id: "mock-id",
|
|
1240
|
+
...data
|
|
1241
|
+
})),
|
|
1242
|
+
delete: vi.fn().mockResolvedValue({
|
|
1243
|
+
success: true,
|
|
1244
|
+
message: "Deleted"
|
|
1245
|
+
}),
|
|
1246
|
+
getBySlug: vi.fn().mockResolvedValue(null),
|
|
1247
|
+
getDeleted: vi.fn().mockResolvedValue([]),
|
|
1248
|
+
restore: vi.fn().mockResolvedValue(null),
|
|
1249
|
+
getTree: vi.fn().mockResolvedValue([]),
|
|
1250
|
+
getChildren: vi.fn().mockResolvedValue([]),
|
|
1251
|
+
...overrides
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Create a mock user for authentication testing
|
|
1256
|
+
*/
|
|
1257
|
+
function createMockUser(overrides = {}) {
|
|
1258
|
+
return {
|
|
1259
|
+
_id: "mock-user-id",
|
|
1260
|
+
id: "mock-user-id",
|
|
1261
|
+
email: "test@example.com",
|
|
1262
|
+
roles: ["user"],
|
|
1263
|
+
organizationId: null,
|
|
1264
|
+
...overrides
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Create a mock Fastify request
|
|
1269
|
+
*/
|
|
1270
|
+
function createMockRequest(overrides = {}) {
|
|
1271
|
+
return {
|
|
1272
|
+
body: {},
|
|
1273
|
+
params: {},
|
|
1274
|
+
query: {},
|
|
1275
|
+
headers: {},
|
|
1276
|
+
user: createMockUser(),
|
|
1277
|
+
context: {},
|
|
1278
|
+
log: {
|
|
1279
|
+
info: vi.fn(),
|
|
1280
|
+
warn: vi.fn(),
|
|
1281
|
+
error: vi.fn(),
|
|
1282
|
+
debug: vi.fn()
|
|
1283
|
+
},
|
|
1284
|
+
...overrides
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Create a mock Fastify reply
|
|
1289
|
+
*/
|
|
1290
|
+
function createMockReply() {
|
|
1291
|
+
return {
|
|
1292
|
+
code: vi.fn().mockReturnThis(),
|
|
1293
|
+
send: vi.fn().mockReturnThis(),
|
|
1294
|
+
header: vi.fn().mockReturnThis(),
|
|
1295
|
+
headers: vi.fn().mockReturnThis(),
|
|
1296
|
+
status: vi.fn().mockReturnThis(),
|
|
1297
|
+
type: vi.fn().mockReturnThis(),
|
|
1298
|
+
redirect: vi.fn().mockReturnThis(),
|
|
1299
|
+
callNotFound: vi.fn().mockReturnThis(),
|
|
1300
|
+
sent: false
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Create a mock controller for testing
|
|
1305
|
+
*/
|
|
1306
|
+
function createMockController(repository) {
|
|
1307
|
+
return {
|
|
1308
|
+
repository,
|
|
1309
|
+
list: vi.fn(),
|
|
1310
|
+
get: vi.fn(),
|
|
1311
|
+
create: vi.fn(),
|
|
1312
|
+
update: vi.fn(),
|
|
1313
|
+
delete: vi.fn()
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Create mock data factory
|
|
1318
|
+
*
|
|
1319
|
+
* @example
|
|
1320
|
+
* const productFactory = createDataFactory<Product>({
|
|
1321
|
+
* name: () => faker.commerce.productName(),
|
|
1322
|
+
* price: () => faker.number.int({ min: 10, max: 1000 }),
|
|
1323
|
+
* sku: (i) => `SKU-${i}`,
|
|
1324
|
+
* });
|
|
1325
|
+
*
|
|
1326
|
+
* const product = productFactory.build();
|
|
1327
|
+
* const products = productFactory.buildMany(10);
|
|
1328
|
+
*/
|
|
1329
|
+
function createDataFactory(template) {
|
|
1330
|
+
let counter = 0;
|
|
1331
|
+
return {
|
|
1332
|
+
build(overrides = {}) {
|
|
1333
|
+
const index = counter++;
|
|
1334
|
+
const data = {};
|
|
1335
|
+
for (const [key, generator] of Object.entries(template)) data[key] = generator(index);
|
|
1336
|
+
return {
|
|
1337
|
+
...data,
|
|
1338
|
+
...overrides
|
|
1339
|
+
};
|
|
1340
|
+
},
|
|
1341
|
+
buildMany(count, overrides = {}) {
|
|
1342
|
+
return Array.from({ length: count }, () => this.build(overrides));
|
|
1343
|
+
},
|
|
1344
|
+
reset() {
|
|
1345
|
+
counter = 0;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Create a spy that tracks function calls
|
|
1351
|
+
*
|
|
1352
|
+
* Useful for testing side effects without full mocking
|
|
1353
|
+
*/
|
|
1354
|
+
function createSpy(_name = "spy") {
|
|
1355
|
+
const calls = [];
|
|
1356
|
+
const spy = vi.fn((...args) => {
|
|
1357
|
+
calls.push(args);
|
|
1358
|
+
});
|
|
1359
|
+
spy.getCalls = () => calls;
|
|
1360
|
+
spy.getLastCall = () => calls[calls.length - 1] || [];
|
|
1361
|
+
return spy;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Wait for a condition to be true
|
|
1365
|
+
*
|
|
1366
|
+
* Useful for async testing
|
|
1367
|
+
*/
|
|
1368
|
+
async function waitFor(condition, options = {}) {
|
|
1369
|
+
const { timeout = 5e3, interval = 100 } = options;
|
|
1370
|
+
const startTime = Date.now();
|
|
1371
|
+
while (Date.now() - startTime < timeout) {
|
|
1372
|
+
if (await condition()) return;
|
|
1373
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1374
|
+
}
|
|
1375
|
+
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Create a test timer that can be controlled
|
|
1379
|
+
*/
|
|
1380
|
+
function createTestTimer() {
|
|
1381
|
+
let time = Date.now();
|
|
1382
|
+
return {
|
|
1383
|
+
now: () => time,
|
|
1384
|
+
advance: (ms) => {
|
|
1385
|
+
time += ms;
|
|
1386
|
+
},
|
|
1387
|
+
set: (timestamp) => {
|
|
1388
|
+
time = timestamp;
|
|
1389
|
+
},
|
|
1390
|
+
reset: () => {
|
|
1391
|
+
time = Date.now();
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
//#endregion
|
|
1397
|
+
//#region src/testing/authHelpers.ts
|
|
1398
|
+
/**
|
|
1399
|
+
* Safely parse a JSON response body.
|
|
1400
|
+
* Returns null if parsing fails.
|
|
1401
|
+
*/
|
|
1402
|
+
function safeParseBody(body) {
|
|
1403
|
+
try {
|
|
1404
|
+
return JSON.parse(body);
|
|
1405
|
+
} catch {
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Create stateless Better Auth test helpers.
|
|
1411
|
+
*
|
|
1412
|
+
* All methods take the app instance as a parameter, making them
|
|
1413
|
+
* safe to use across multiple test suites.
|
|
1414
|
+
*/
|
|
1415
|
+
function createBetterAuthTestHelpers(options = {}) {
|
|
1416
|
+
const basePath = options.basePath ?? "/api/auth";
|
|
1417
|
+
return {
|
|
1418
|
+
async signUp(app, data) {
|
|
1419
|
+
const res = await app.inject({
|
|
1420
|
+
method: "POST",
|
|
1421
|
+
url: `${basePath}/sign-up/email`,
|
|
1422
|
+
payload: data
|
|
1423
|
+
});
|
|
1424
|
+
const token = res.headers["set-auth-token"];
|
|
1425
|
+
const body = safeParseBody(res.body);
|
|
1426
|
+
return {
|
|
1427
|
+
statusCode: res.statusCode,
|
|
1428
|
+
token: token || "",
|
|
1429
|
+
user: body?.user || body,
|
|
1430
|
+
body
|
|
1431
|
+
};
|
|
1432
|
+
},
|
|
1433
|
+
async signIn(app, data) {
|
|
1434
|
+
const res = await app.inject({
|
|
1435
|
+
method: "POST",
|
|
1436
|
+
url: `${basePath}/sign-in/email`,
|
|
1437
|
+
payload: data
|
|
1438
|
+
});
|
|
1439
|
+
const token = res.headers["set-auth-token"];
|
|
1440
|
+
const body = safeParseBody(res.body);
|
|
1441
|
+
return {
|
|
1442
|
+
statusCode: res.statusCode,
|
|
1443
|
+
token: token || "",
|
|
1444
|
+
user: body?.user || body,
|
|
1445
|
+
body
|
|
1446
|
+
};
|
|
1447
|
+
},
|
|
1448
|
+
async createOrg(app, token, data) {
|
|
1449
|
+
const res = await app.inject({
|
|
1450
|
+
method: "POST",
|
|
1451
|
+
url: `${basePath}/organization/create`,
|
|
1452
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1453
|
+
payload: data
|
|
1454
|
+
});
|
|
1455
|
+
const body = safeParseBody(res.body);
|
|
1456
|
+
return {
|
|
1457
|
+
statusCode: res.statusCode,
|
|
1458
|
+
orgId: body?.id,
|
|
1459
|
+
body
|
|
1460
|
+
};
|
|
1461
|
+
},
|
|
1462
|
+
async setActiveOrg(app, token, orgId) {
|
|
1463
|
+
const res = await app.inject({
|
|
1464
|
+
method: "POST",
|
|
1465
|
+
url: `${basePath}/organization/set-active`,
|
|
1466
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1467
|
+
payload: { organizationId: orgId }
|
|
1468
|
+
});
|
|
1469
|
+
return {
|
|
1470
|
+
statusCode: res.statusCode,
|
|
1471
|
+
body: safeParseBody(res.body)
|
|
1472
|
+
};
|
|
1473
|
+
},
|
|
1474
|
+
authHeaders(token, orgId) {
|
|
1475
|
+
const h = { authorization: `Bearer ${token}` };
|
|
1476
|
+
if (orgId) h["x-organization-id"] = orgId;
|
|
1477
|
+
return h;
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Set up a complete test organization with users.
|
|
1483
|
+
*
|
|
1484
|
+
* Creates the app, signs up users, creates an org, adds members,
|
|
1485
|
+
* and returns a context object with tokens and a teardown function.
|
|
1486
|
+
*
|
|
1487
|
+
* @example
|
|
1488
|
+
* ```typescript
|
|
1489
|
+
* const ctx = await setupBetterAuthOrg({
|
|
1490
|
+
* createApp: () => createAppInstance(),
|
|
1491
|
+
* org: { name: 'Test Corp', slug: 'test-corp' },
|
|
1492
|
+
* users: [
|
|
1493
|
+
* { key: 'admin', email: 'admin@test.com', password: 'pass', name: 'Admin', role: 'admin', isCreator: true },
|
|
1494
|
+
* { key: 'member', email: 'user@test.com', password: 'pass', name: 'User', role: 'member' },
|
|
1495
|
+
* ],
|
|
1496
|
+
* addMember: async (data) => {
|
|
1497
|
+
* await auth.api.addMember({ body: data });
|
|
1498
|
+
* return { statusCode: 200 };
|
|
1499
|
+
* },
|
|
1500
|
+
* });
|
|
1501
|
+
*
|
|
1502
|
+
* // Use in tests:
|
|
1503
|
+
* const res = await ctx.app.inject({
|
|
1504
|
+
* method: 'GET',
|
|
1505
|
+
* url: '/api/products',
|
|
1506
|
+
* headers: auth.authHeaders(ctx.users.admin.token, ctx.orgId),
|
|
1507
|
+
* });
|
|
1508
|
+
*
|
|
1509
|
+
* // Cleanup:
|
|
1510
|
+
* await ctx.teardown();
|
|
1511
|
+
* ```
|
|
1512
|
+
*/
|
|
1513
|
+
async function setupBetterAuthOrg(options) {
|
|
1514
|
+
const { createApp, org, users: userConfigs, addMember, afterSetup, authHelpers: helpersOptions } = options;
|
|
1515
|
+
const helpers = createBetterAuthTestHelpers(helpersOptions);
|
|
1516
|
+
const creators = userConfigs.filter((u) => u.isCreator);
|
|
1517
|
+
if (creators.length !== 1) throw new Error(`setupBetterAuthOrg: Exactly one user must have isCreator: true (found ${creators.length})`);
|
|
1518
|
+
const app = await createApp();
|
|
1519
|
+
await app.ready();
|
|
1520
|
+
const signups = /* @__PURE__ */ new Map();
|
|
1521
|
+
for (const userConfig of userConfigs) {
|
|
1522
|
+
const signup = await helpers.signUp(app, {
|
|
1523
|
+
email: userConfig.email,
|
|
1524
|
+
password: userConfig.password,
|
|
1525
|
+
name: userConfig.name
|
|
1526
|
+
});
|
|
1527
|
+
if (signup.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to sign up ${userConfig.email} (status ${signup.statusCode})`);
|
|
1528
|
+
signups.set(userConfig.key, signup);
|
|
1529
|
+
}
|
|
1530
|
+
const creatorConfig = creators[0];
|
|
1531
|
+
const creatorSignup = signups.get(creatorConfig.key);
|
|
1532
|
+
const orgResult = await helpers.createOrg(app, creatorSignup.token, org);
|
|
1533
|
+
if (orgResult.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to create org (status ${orgResult.statusCode})`);
|
|
1534
|
+
const orgId = orgResult.orgId;
|
|
1535
|
+
for (const userConfig of userConfigs) {
|
|
1536
|
+
if (userConfig.isCreator) continue;
|
|
1537
|
+
const result = await addMember({
|
|
1538
|
+
organizationId: orgId,
|
|
1539
|
+
userId: signups.get(userConfig.key).user?.id,
|
|
1540
|
+
role: userConfig.role
|
|
1541
|
+
});
|
|
1542
|
+
if (result.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to add member ${userConfig.email} (status ${result.statusCode})`);
|
|
1543
|
+
}
|
|
1544
|
+
await helpers.setActiveOrg(app, creatorSignup.token, orgId);
|
|
1545
|
+
const users = {};
|
|
1546
|
+
for (const userConfig of userConfigs) if (userConfig.isCreator) {
|
|
1547
|
+
const signup = signups.get(userConfig.key);
|
|
1548
|
+
users[userConfig.key] = {
|
|
1549
|
+
token: signup.token,
|
|
1550
|
+
userId: signup.user?.id,
|
|
1551
|
+
email: userConfig.email
|
|
1552
|
+
};
|
|
1553
|
+
} else {
|
|
1554
|
+
const login = await helpers.signIn(app, {
|
|
1555
|
+
email: userConfig.email,
|
|
1556
|
+
password: userConfig.password
|
|
1557
|
+
});
|
|
1558
|
+
await helpers.setActiveOrg(app, login.token, orgId);
|
|
1559
|
+
users[userConfig.key] = {
|
|
1560
|
+
token: login.token,
|
|
1561
|
+
userId: signups.get(userConfig.key).user?.id,
|
|
1562
|
+
email: userConfig.email
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
const ctx = {
|
|
1566
|
+
app,
|
|
1567
|
+
orgId,
|
|
1568
|
+
users,
|
|
1569
|
+
async teardown() {
|
|
1570
|
+
await app.close();
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
if (afterSetup) await afterSetup(ctx);
|
|
1574
|
+
return ctx;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
//#endregion
|
|
1578
|
+
//#region src/testing/HttpTestHarness.ts
|
|
1579
|
+
/**
|
|
1580
|
+
* HTTP Test Harness
|
|
1581
|
+
*
|
|
1582
|
+
* Generates HTTP-level CRUD tests for Arc resources using `app.inject()`.
|
|
1583
|
+
* Unlike TestHarness (which tests Mongoose models directly), this exercises
|
|
1584
|
+
* the full request lifecycle: HTTP routes, auth, permissions, pipeline,
|
|
1585
|
+
* field permissions, and the Arc response envelope.
|
|
1586
|
+
*
|
|
1587
|
+
* Supports both eager and deferred options:
|
|
1588
|
+
* - **Eager**: Pass options directly when app is available at construction time
|
|
1589
|
+
* - **Deferred**: Pass a getter function when app comes from async setup (beforeAll)
|
|
1590
|
+
*
|
|
1591
|
+
* @example Eager (app available at module level)
|
|
1592
|
+
* ```typescript
|
|
1593
|
+
* const harness = createHttpTestHarness(jobResource, {
|
|
1594
|
+
* app,
|
|
1595
|
+
* fixtures: { valid: { title: 'Test' } },
|
|
1596
|
+
* auth: createJwtAuthProvider({ app, users, adminRole: 'admin' }),
|
|
1597
|
+
* });
|
|
1598
|
+
* harness.runAll();
|
|
1599
|
+
* ```
|
|
1600
|
+
*
|
|
1601
|
+
* @example Deferred (app from beforeAll)
|
|
1602
|
+
* ```typescript
|
|
1603
|
+
* let ctx: TestContext;
|
|
1604
|
+
* beforeAll(async () => { ctx = await setupTestOrg(); });
|
|
1605
|
+
* afterAll(async () => { await teardownTestOrg(ctx); });
|
|
1606
|
+
*
|
|
1607
|
+
* const harness = createHttpTestHarness(jobResource, () => ({
|
|
1608
|
+
* app: ctx.app,
|
|
1609
|
+
* fixtures: { valid: { title: 'Test' } },
|
|
1610
|
+
* auth: createBetterAuthProvider({ tokens: { admin: ctx.users.admin.token }, orgId: ctx.orgId, adminRole: 'admin' }),
|
|
1611
|
+
* }));
|
|
1612
|
+
* harness.runAll();
|
|
1613
|
+
* ```
|
|
1614
|
+
*/
|
|
1615
|
+
/**
|
|
1616
|
+
* Create an auth provider for JWT-based apps.
|
|
1617
|
+
*
|
|
1618
|
+
* Generates JWT tokens on the fly using the app's JWT plugin.
|
|
1619
|
+
*
|
|
1620
|
+
* @example
|
|
1621
|
+
* ```typescript
|
|
1622
|
+
* const auth = createJwtAuthProvider({
|
|
1623
|
+
* app,
|
|
1624
|
+
* users: {
|
|
1625
|
+
* admin: { payload: { id: '1', roles: ['admin'] }, organizationId: 'org1' },
|
|
1626
|
+
* viewer: { payload: { id: '2', roles: ['viewer'] } },
|
|
1627
|
+
* },
|
|
1628
|
+
* adminRole: 'admin',
|
|
1629
|
+
* });
|
|
1630
|
+
* ```
|
|
1631
|
+
*/
|
|
1632
|
+
function createJwtAuthProvider(options) {
|
|
1633
|
+
const { app, users, adminRole } = options;
|
|
1634
|
+
return {
|
|
1635
|
+
getHeaders(role) {
|
|
1636
|
+
const user = users[role];
|
|
1637
|
+
if (!user) throw new Error(`createJwtAuthProvider: Unknown role '${role}'. Available: ${Object.keys(users).join(", ")}`);
|
|
1638
|
+
const headers = { authorization: `Bearer ${app.jwt?.sign?.(user.payload) || "mock-token"}` };
|
|
1639
|
+
if (user.organizationId) headers["x-organization-id"] = user.organizationId;
|
|
1640
|
+
return headers;
|
|
1641
|
+
},
|
|
1642
|
+
availableRoles: Object.keys(users),
|
|
1643
|
+
adminRole
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Create an auth provider for Better Auth apps.
|
|
1648
|
+
*
|
|
1649
|
+
* Uses pre-existing tokens (from signUp/signIn) rather than generating them.
|
|
1650
|
+
*
|
|
1651
|
+
* @example
|
|
1652
|
+
* ```typescript
|
|
1653
|
+
* const auth = createBetterAuthProvider({
|
|
1654
|
+
* tokens: {
|
|
1655
|
+
* admin: ctx.users.admin.token,
|
|
1656
|
+
* member: ctx.users.member.token,
|
|
1657
|
+
* },
|
|
1658
|
+
* orgId: ctx.orgId,
|
|
1659
|
+
* adminRole: 'admin',
|
|
1660
|
+
* });
|
|
1661
|
+
* ```
|
|
1662
|
+
*/
|
|
1663
|
+
function createBetterAuthProvider(options) {
|
|
1664
|
+
const { tokens, orgId, adminRole } = options;
|
|
1665
|
+
return {
|
|
1666
|
+
getHeaders(role) {
|
|
1667
|
+
const token = tokens[role];
|
|
1668
|
+
if (!token) throw new Error(`createBetterAuthProvider: No token for role '${role}'. Available: ${Object.keys(tokens).join(", ")}`);
|
|
1669
|
+
return {
|
|
1670
|
+
authorization: `Bearer ${token}`,
|
|
1671
|
+
"x-organization-id": orgId
|
|
1672
|
+
};
|
|
1673
|
+
},
|
|
1674
|
+
availableRoles: Object.keys(tokens),
|
|
1675
|
+
adminRole
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* HTTP-level test harness for Arc resources.
|
|
1680
|
+
*
|
|
1681
|
+
* Generates tests that exercise the full HTTP lifecycle:
|
|
1682
|
+
* routes, auth, permissions, pipeline, and response envelope.
|
|
1683
|
+
*
|
|
1684
|
+
* Supports deferred options via a getter function, which is essential
|
|
1685
|
+
* when the app instance comes from async `beforeAll()` setup.
|
|
1686
|
+
*/
|
|
1687
|
+
var HttpTestHarness = class {
|
|
1688
|
+
resource;
|
|
1689
|
+
optionsOrGetter;
|
|
1690
|
+
baseUrl;
|
|
1691
|
+
enabledRoutes;
|
|
1692
|
+
updateMethod;
|
|
1693
|
+
constructor(resource, optionsOrGetter) {
|
|
1694
|
+
this.resource = resource;
|
|
1695
|
+
this.optionsOrGetter = optionsOrGetter;
|
|
1696
|
+
this.baseUrl = `${typeof optionsOrGetter === "function" ? "/api" : optionsOrGetter.apiPrefix ?? "/api"}${resource.prefix}`;
|
|
1697
|
+
const disabled = new Set(resource.disabledRoutes ?? []);
|
|
1698
|
+
this.enabledRoutes = new Set(resource.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op)));
|
|
1699
|
+
this.updateMethod = resource.updateMethod === "PUT" ? "PUT" : "PATCH";
|
|
1700
|
+
}
|
|
1701
|
+
/** Resolve options (supports both direct and deferred) */
|
|
1702
|
+
getOptions() {
|
|
1703
|
+
return typeof this.optionsOrGetter === "function" ? this.optionsOrGetter() : this.optionsOrGetter;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Run all test suites: CRUD + permissions + validation
|
|
1707
|
+
*/
|
|
1708
|
+
runAll() {
|
|
1709
|
+
this.runCrud();
|
|
1710
|
+
this.runPermissions();
|
|
1711
|
+
this.runValidation();
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Run HTTP-level CRUD tests.
|
|
1715
|
+
*
|
|
1716
|
+
* Tests each enabled CRUD operation through app.inject():
|
|
1717
|
+
* - POST (create) → 200/201 with { success: true, data }
|
|
1718
|
+
* - GET (list) → 200 with array or paginated response
|
|
1719
|
+
* - GET /:id → 200 with { success: true, data }
|
|
1720
|
+
* - PATCH/PUT /:id → 200 with { success: true, data }
|
|
1721
|
+
* - DELETE /:id → 200
|
|
1722
|
+
* - GET /:id with non-existent ID → 404
|
|
1723
|
+
*/
|
|
1724
|
+
runCrud() {
|
|
1725
|
+
const { resource, baseUrl, enabledRoutes, updateMethod } = this;
|
|
1726
|
+
let createdId = null;
|
|
1727
|
+
describe(`${resource.displayName} HTTP CRUD`, () => {
|
|
1728
|
+
afterAll(async () => {
|
|
1729
|
+
if (createdId && enabledRoutes.has("delete")) {
|
|
1730
|
+
const { app, auth } = this.getOptions();
|
|
1731
|
+
await app.inject({
|
|
1732
|
+
method: "DELETE",
|
|
1733
|
+
url: `${baseUrl}/${createdId}`,
|
|
1734
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
if (enabledRoutes.has("create")) it("POST should create a resource", async () => {
|
|
1739
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
1740
|
+
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
1741
|
+
const res = await app.inject({
|
|
1742
|
+
method: "POST",
|
|
1743
|
+
url: baseUrl,
|
|
1744
|
+
headers: adminHeaders,
|
|
1745
|
+
payload: fixtures.valid
|
|
1746
|
+
});
|
|
1747
|
+
expect(res.statusCode).toBeLessThan(300);
|
|
1748
|
+
const body = JSON.parse(res.body);
|
|
1749
|
+
expect(body.success).toBe(true);
|
|
1750
|
+
expect(body.data).toBeDefined();
|
|
1751
|
+
expect(body.data._id).toBeDefined();
|
|
1752
|
+
createdId = body.data._id;
|
|
1753
|
+
});
|
|
1754
|
+
if (enabledRoutes.has("list")) it("GET should list resources", async () => {
|
|
1755
|
+
const { app, auth } = this.getOptions();
|
|
1756
|
+
const res = await app.inject({
|
|
1757
|
+
method: "GET",
|
|
1758
|
+
url: baseUrl,
|
|
1759
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1760
|
+
});
|
|
1761
|
+
expect(res.statusCode).toBe(200);
|
|
1762
|
+
const body = JSON.parse(res.body);
|
|
1763
|
+
expect(body.success).toBe(true);
|
|
1764
|
+
const list = body.data ?? body.docs;
|
|
1765
|
+
expect(list).toBeDefined();
|
|
1766
|
+
expect(Array.isArray(list)).toBe(true);
|
|
1767
|
+
});
|
|
1768
|
+
if (enabledRoutes.has("get")) {
|
|
1769
|
+
it("GET /:id should return the resource", async () => {
|
|
1770
|
+
if (!createdId) return;
|
|
1771
|
+
const { app, auth } = this.getOptions();
|
|
1772
|
+
const res = await app.inject({
|
|
1773
|
+
method: "GET",
|
|
1774
|
+
url: `${baseUrl}/${createdId}`,
|
|
1775
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1776
|
+
});
|
|
1777
|
+
expect(res.statusCode).toBe(200);
|
|
1778
|
+
const body = JSON.parse(res.body);
|
|
1779
|
+
expect(body.success).toBe(true);
|
|
1780
|
+
expect(body.data).toBeDefined();
|
|
1781
|
+
expect(body.data._id).toBe(createdId);
|
|
1782
|
+
});
|
|
1783
|
+
it("GET /:id with non-existent ID should return 404", async () => {
|
|
1784
|
+
const { app, auth } = this.getOptions();
|
|
1785
|
+
const res = await app.inject({
|
|
1786
|
+
method: "GET",
|
|
1787
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
1788
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1789
|
+
});
|
|
1790
|
+
expect(res.statusCode).toBe(404);
|
|
1791
|
+
expect(JSON.parse(res.body).success).toBe(false);
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
if (enabledRoutes.has("update")) {
|
|
1795
|
+
it(`${updateMethod} /:id should update the resource`, async () => {
|
|
1796
|
+
if (!createdId) return;
|
|
1797
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
1798
|
+
const updatePayload = fixtures.update || fixtures.valid;
|
|
1799
|
+
const res = await app.inject({
|
|
1800
|
+
method: updateMethod,
|
|
1801
|
+
url: `${baseUrl}/${createdId}`,
|
|
1802
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
1803
|
+
payload: updatePayload
|
|
1804
|
+
});
|
|
1805
|
+
expect(res.statusCode).toBe(200);
|
|
1806
|
+
const body = JSON.parse(res.body);
|
|
1807
|
+
expect(body.success).toBe(true);
|
|
1808
|
+
expect(body.data).toBeDefined();
|
|
1809
|
+
});
|
|
1810
|
+
it(`${updateMethod} /:id with non-existent ID should return 404`, async () => {
|
|
1811
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
1812
|
+
expect((await app.inject({
|
|
1813
|
+
method: updateMethod,
|
|
1814
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
1815
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
1816
|
+
payload: fixtures.update || fixtures.valid
|
|
1817
|
+
})).statusCode).toBe(404);
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
if (enabledRoutes.has("delete")) {
|
|
1821
|
+
it("DELETE /:id should delete the resource", async () => {
|
|
1822
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
1823
|
+
const adminHeaders = auth.getHeaders(auth.adminRole);
|
|
1824
|
+
let deleteId;
|
|
1825
|
+
if (enabledRoutes.has("create")) {
|
|
1826
|
+
const createRes = await app.inject({
|
|
1827
|
+
method: "POST",
|
|
1828
|
+
url: baseUrl,
|
|
1829
|
+
headers: adminHeaders,
|
|
1830
|
+
payload: fixtures.valid
|
|
1831
|
+
});
|
|
1832
|
+
deleteId = JSON.parse(createRes.body).data?._id;
|
|
1833
|
+
}
|
|
1834
|
+
if (!deleteId) return;
|
|
1835
|
+
expect((await app.inject({
|
|
1836
|
+
method: "DELETE",
|
|
1837
|
+
url: `${baseUrl}/${deleteId}`,
|
|
1838
|
+
headers: adminHeaders
|
|
1839
|
+
})).statusCode).toBe(200);
|
|
1840
|
+
if (enabledRoutes.has("get")) expect((await app.inject({
|
|
1841
|
+
method: "GET",
|
|
1842
|
+
url: `${baseUrl}/${deleteId}`,
|
|
1843
|
+
headers: adminHeaders
|
|
1844
|
+
})).statusCode).toBe(404);
|
|
1845
|
+
});
|
|
1846
|
+
it("DELETE /:id with non-existent ID should return 404", async () => {
|
|
1847
|
+
const { app, auth } = this.getOptions();
|
|
1848
|
+
expect((await app.inject({
|
|
1849
|
+
method: "DELETE",
|
|
1850
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
1851
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1852
|
+
})).statusCode).toBe(404);
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Run permission tests.
|
|
1859
|
+
*
|
|
1860
|
+
* Tests that:
|
|
1861
|
+
* - Unauthenticated requests return 401
|
|
1862
|
+
* - Admin role gets 2xx for all operations
|
|
1863
|
+
*/
|
|
1864
|
+
runPermissions() {
|
|
1865
|
+
const { resource, baseUrl, enabledRoutes, updateMethod } = this;
|
|
1866
|
+
describe(`${resource.displayName} HTTP Permissions`, () => {
|
|
1867
|
+
if (enabledRoutes.has("list")) it("GET list without auth should return 401", async () => {
|
|
1868
|
+
const { app } = this.getOptions();
|
|
1869
|
+
expect((await app.inject({
|
|
1870
|
+
method: "GET",
|
|
1871
|
+
url: baseUrl
|
|
1872
|
+
})).statusCode).toBe(401);
|
|
1873
|
+
});
|
|
1874
|
+
if (enabledRoutes.has("get")) it("GET get without auth should return 401", async () => {
|
|
1875
|
+
const { app } = this.getOptions();
|
|
1876
|
+
expect((await app.inject({
|
|
1877
|
+
method: "GET",
|
|
1878
|
+
url: `${baseUrl}/000000000000000000000000`
|
|
1879
|
+
})).statusCode).toBe(401);
|
|
1880
|
+
});
|
|
1881
|
+
if (enabledRoutes.has("create")) it("POST create without auth should return 401", async () => {
|
|
1882
|
+
const { app, fixtures } = this.getOptions();
|
|
1883
|
+
expect((await app.inject({
|
|
1884
|
+
method: "POST",
|
|
1885
|
+
url: baseUrl,
|
|
1886
|
+
payload: fixtures.valid
|
|
1887
|
+
})).statusCode).toBe(401);
|
|
1888
|
+
});
|
|
1889
|
+
if (enabledRoutes.has("update")) it(`${updateMethod} update without auth should return 401`, async () => {
|
|
1890
|
+
const { app, fixtures } = this.getOptions();
|
|
1891
|
+
expect((await app.inject({
|
|
1892
|
+
method: updateMethod,
|
|
1893
|
+
url: `${baseUrl}/000000000000000000000000`,
|
|
1894
|
+
payload: fixtures.update || fixtures.valid
|
|
1895
|
+
})).statusCode).toBe(401);
|
|
1896
|
+
});
|
|
1897
|
+
if (enabledRoutes.has("delete")) it("DELETE delete without auth should return 401", async () => {
|
|
1898
|
+
const { app } = this.getOptions();
|
|
1899
|
+
expect((await app.inject({
|
|
1900
|
+
method: "DELETE",
|
|
1901
|
+
url: `${baseUrl}/000000000000000000000000`
|
|
1902
|
+
})).statusCode).toBe(401);
|
|
1903
|
+
});
|
|
1904
|
+
if (enabledRoutes.has("list")) it("admin should access list endpoint", async () => {
|
|
1905
|
+
const { app, auth } = this.getOptions();
|
|
1906
|
+
expect((await app.inject({
|
|
1907
|
+
method: "GET",
|
|
1908
|
+
url: baseUrl,
|
|
1909
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1910
|
+
})).statusCode).toBeLessThan(400);
|
|
1911
|
+
});
|
|
1912
|
+
if (enabledRoutes.has("create")) it("admin should access create endpoint", async () => {
|
|
1913
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
1914
|
+
const res = await app.inject({
|
|
1915
|
+
method: "POST",
|
|
1916
|
+
url: baseUrl,
|
|
1917
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
1918
|
+
payload: fixtures.valid
|
|
1919
|
+
});
|
|
1920
|
+
expect(res.statusCode).toBeLessThan(400);
|
|
1921
|
+
const body = JSON.parse(res.body);
|
|
1922
|
+
if (body.data?._id && enabledRoutes.has("delete")) await app.inject({
|
|
1923
|
+
method: "DELETE",
|
|
1924
|
+
url: `${baseUrl}/${body.data._id}`,
|
|
1925
|
+
headers: auth.getHeaders(auth.adminRole)
|
|
1926
|
+
});
|
|
1927
|
+
});
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Run validation tests.
|
|
1932
|
+
*
|
|
1933
|
+
* Tests that invalid payloads return 400.
|
|
1934
|
+
*/
|
|
1935
|
+
runValidation() {
|
|
1936
|
+
const { resource, baseUrl, enabledRoutes } = this;
|
|
1937
|
+
if (!enabledRoutes.has("create")) return;
|
|
1938
|
+
describe(`${resource.displayName} HTTP Validation`, () => {
|
|
1939
|
+
it("POST with invalid payload should not return 2xx", async () => {
|
|
1940
|
+
const { app, auth, fixtures } = this.getOptions();
|
|
1941
|
+
if (!fixtures.invalid) return;
|
|
1942
|
+
const res = await app.inject({
|
|
1943
|
+
method: "POST",
|
|
1944
|
+
url: baseUrl,
|
|
1945
|
+
headers: auth.getHeaders(auth.adminRole),
|
|
1946
|
+
payload: fixtures.invalid
|
|
1947
|
+
});
|
|
1948
|
+
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
|
1949
|
+
expect(JSON.parse(res.body).success).toBe(false);
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
/**
|
|
1955
|
+
* Create an HTTP test harness for an Arc resource.
|
|
1956
|
+
*
|
|
1957
|
+
* Accepts options directly or as a getter function for deferred resolution.
|
|
1958
|
+
*
|
|
1959
|
+
* @example Deferred (recommended for async setup)
|
|
1960
|
+
* ```typescript
|
|
1961
|
+
* let ctx: TestContext;
|
|
1962
|
+
* beforeAll(async () => { ctx = await setupTestOrg(); });
|
|
1963
|
+
*
|
|
1964
|
+
* createHttpTestHarness(jobResource, () => ({
|
|
1965
|
+
* app: ctx.app,
|
|
1966
|
+
* fixtures: { valid: { title: 'Test' } },
|
|
1967
|
+
* auth: createBetterAuthProvider({ ... }),
|
|
1968
|
+
* })).runAll();
|
|
1969
|
+
* ```
|
|
1970
|
+
*/
|
|
1971
|
+
function createHttpTestHarness(resource, optionsOrGetter) {
|
|
1972
|
+
return new HttpTestHarness(resource, optionsOrGetter);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
//#endregion
|
|
1976
|
+
export { DatabaseSnapshot, TestFixtures as DbTestFixtures, HttpTestHarness, InMemoryDatabase, TestDataLoader, TestDatabase, TestHarness, TestRequestBuilder, TestSeeder, TestTransaction, createBetterAuthProvider, createBetterAuthTestHelpers, createConfigTestSuite, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSnapshotMatcher, createSpy, createTestApp, createTestAuth, createTestHarness, createTestTimer, generateTestFile, request, safeParseBody, setupBetterAuthOrg, waitFor, withTestDb };
|
|
1977
|
+
//# sourceMappingURL=index.mjs.map
|