@classytic/arc 2.3.0 → 2.4.1

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