@classytic/arc 2.10.8 → 2.11.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 (136) hide show
  1. package/dist/{BaseController-DVNKvoX4.mjs → BaseController-JNV08qOT.mjs} +480 -442
  2. package/dist/{queryCachePlugin-Dumka73q.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
  3. package/dist/adapters/index.d.mts +2 -2
  4. package/dist/adapters/index.mjs +1 -1
  5. package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
  6. package/dist/audit/index.d.mts +1 -1
  7. package/dist/auth/index.d.mts +1 -1
  8. package/dist/auth/index.mjs +5 -5
  9. package/dist/{betterAuthOpenApi--rdY15Ld.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
  10. package/dist/cache/index.d.mts +3 -2
  11. package/dist/cache/index.mjs +3 -3
  12. package/dist/cli/commands/docs.mjs +2 -2
  13. package/dist/cli/commands/generate.mjs +37 -27
  14. package/dist/cli/commands/init.mjs +46 -33
  15. package/dist/cli/commands/introspect.mjs +1 -1
  16. package/dist/context/index.mjs +1 -1
  17. package/dist/core/index.d.mts +3 -3
  18. package/dist/core/index.mjs +4 -3
  19. package/dist/core-DXdSSFW-.mjs +1037 -0
  20. package/dist/createActionRouter-BwaSM0No.mjs +166 -0
  21. package/dist/{createApp-BwnEAO2h.mjs → createApp-P1d6rjPy.mjs} +75 -27
  22. package/dist/docs/index.d.mts +1 -1
  23. package/dist/docs/index.mjs +2 -2
  24. package/dist/{elevation-Dci0AYLT.mjs → elevation-DOFoxoDs.mjs} +1 -1
  25. package/dist/{errorHandler-CSxe7KIM.mjs → errorHandler-BQm8ZxTK.mjs} +1 -1
  26. package/dist/{eventPlugin-ByU4Cv0e.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
  27. package/dist/events/index.d.mts +3 -3
  28. package/dist/events/index.mjs +2 -2
  29. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  30. package/dist/factory/index.d.mts +2 -2
  31. package/dist/factory/index.mjs +2 -2
  32. package/dist/hooks/index.d.mts +1 -1
  33. package/dist/hooks/index.mjs +1 -1
  34. package/dist/idempotency/index.d.mts +3 -3
  35. package/dist/idempotency/index.mjs +1 -1
  36. package/dist/idempotency/redis.d.mts +1 -1
  37. package/dist/{index-C_Noptz-.d.mts → index-BYCqHCVu.d.mts} +2 -2
  38. package/dist/{index-BGbpGVyM.d.mts → index-C_bgx9o4.d.mts} +712 -500
  39. package/dist/{index-BziRPS4H.d.mts → index-CvM1e09j.d.mts} +29 -10
  40. package/dist/{index-EqQN6p0W.d.mts → index-pUczGjO0.d.mts} +11 -8
  41. package/dist/index-smCAoA5W.d.mts +1179 -0
  42. package/dist/index.d.mts +6 -38
  43. package/dist/index.mjs +9 -9
  44. package/dist/integrations/event-gateway.d.mts +1 -1
  45. package/dist/integrations/event-gateway.mjs +1 -1
  46. package/dist/integrations/index.d.mts +2 -2
  47. package/dist/integrations/mcp/index.d.mts +2 -2
  48. package/dist/integrations/mcp/index.mjs +1 -1
  49. package/dist/integrations/mcp/testing.d.mts +1 -1
  50. package/dist/integrations/mcp/testing.mjs +1 -1
  51. package/dist/integrations/streamline.d.mts +46 -5
  52. package/dist/integrations/streamline.mjs +50 -21
  53. package/dist/integrations/websocket-redis.d.mts +1 -1
  54. package/dist/integrations/websocket.d.mts +2 -154
  55. package/dist/integrations/websocket.mjs +292 -224
  56. package/dist/{keys-nWQGUTu1.mjs → keys-CARyUjiR.mjs} +2 -0
  57. package/dist/{loadResources-Bksk8ydA.mjs → loadResources-CPpkyKfM.mjs} +32 -8
  58. package/dist/middleware/index.d.mts +1 -1
  59. package/dist/middleware/index.mjs +1 -1
  60. package/dist/{openapi-DpNpqBmo.mjs → openapi-C0L9ar7m.mjs} +4 -4
  61. package/dist/org/index.d.mts +1 -1
  62. package/dist/permissions/index.d.mts +1 -1
  63. package/dist/permissions/index.mjs +2 -4
  64. package/dist/{permissions-wkqRwicB.mjs → permissions-B4vU9L0Q.mjs} +221 -3
  65. package/dist/{pipe-CGJxqDGx.mjs → pipe-DVoIheVC.mjs} +1 -1
  66. package/dist/pipeline/index.d.mts +1 -1
  67. package/dist/pipeline/index.mjs +1 -1
  68. package/dist/plugins/index.d.mts +4 -4
  69. package/dist/plugins/index.mjs +10 -10
  70. package/dist/plugins/response-cache.mjs +1 -1
  71. package/dist/plugins/tracing-entry.d.mts +1 -1
  72. package/dist/plugins/tracing-entry.mjs +42 -24
  73. package/dist/presets/filesUpload.d.mts +1 -1
  74. package/dist/presets/filesUpload.mjs +3 -3
  75. package/dist/presets/index.d.mts +1 -1
  76. package/dist/presets/index.mjs +1 -1
  77. package/dist/presets/multiTenant.d.mts +1 -1
  78. package/dist/presets/multiTenant.mjs +6 -0
  79. package/dist/presets/search.d.mts +1 -1
  80. package/dist/presets/search.mjs +1 -1
  81. package/dist/{presets-CrwOvuXI.mjs → presets-k604Lj99.mjs} +1 -1
  82. package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
  83. package/dist/{queryCachePlugin-ChLNZvFT.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
  84. package/dist/{redis-MXLp1oOf.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
  85. package/dist/registry/index.d.mts +1 -1
  86. package/dist/registry/index.mjs +2 -2
  87. package/dist/{resourceToTools-BhF3JV5p.mjs → resourceToTools--okX6QBr.mjs} +534 -420
  88. package/dist/routerShared-DeESFp4a.mjs +515 -0
  89. package/dist/schemaIR-BlG9bY7v.mjs +137 -0
  90. package/dist/scope/index.mjs +2 -2
  91. package/dist/testing/index.d.mts +367 -711
  92. package/dist/testing/index.mjs +637 -1434
  93. package/dist/{tracing-xqXzWeaf.d.mts → tracing-DokiEsuz.d.mts} +9 -4
  94. package/dist/types/index.d.mts +3 -3
  95. package/dist/types/index.mjs +1 -3
  96. package/dist/{types-CVdgPXBW.d.mts → types-BdA4uMBV.d.mts} +191 -28
  97. package/dist/{types-CVKBssX5.d.mts → types-Bh_gEJBi.d.mts} +1 -1
  98. package/dist/utils/index.d.mts +2 -968
  99. package/dist/utils/index.mjs +5 -6
  100. package/dist/utils-D3Yxnrwr.mjs +1639 -0
  101. package/dist/websocket-CyJ1VIFI.d.mts +186 -0
  102. package/package.json +7 -5
  103. package/skills/arc/SKILL.md +124 -39
  104. package/skills/arc/references/testing.md +212 -183
  105. package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
  106. package/dist/core-3MWJosCH.mjs +0 -1459
  107. package/dist/createActionRouter-C8UUB3Px.mjs +0 -249
  108. package/dist/errors-BI8kEKsO.d.mts +0 -140
  109. package/dist/fields-CTMWOUDt.mjs +0 -126
  110. package/dist/queryParser-NR__Qiju.mjs +0 -419
  111. package/dist/types-CDnTEpga.mjs +0 -27
  112. package/dist/utils-LMwVidKy.mjs +0 -947
  113. /package/dist/{HookSystem-BjFu7zf1.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
  114. /package/dist/{ResourceRegistry-CcN2LVrc.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
  115. /package/dist/{actionPermissions-TUVR3uiZ.mjs → actionPermissions-C8YYU92K.mjs} +0 -0
  116. /package/dist/{caching-3h93rkJM.mjs → caching-CheW3m-S.mjs} +0 -0
  117. /package/dist/{errorHandler-2ii4RIYr.d.mts → errorHandler-Co3lnVmJ.d.mts} +0 -0
  118. /package/dist/{errors-BqdUDja_.mjs → errors-D5c-5BJL.mjs} +0 -0
  119. /package/dist/{eventPlugin-D1ThQ1Pp.d.mts → eventPlugin-CUNjYYRY.d.mts} +0 -0
  120. /package/dist/{interface-B-pe8fhj.d.mts → interface-CkkWm5uR.d.mts} +0 -0
  121. /package/dist/{interface-yhyb_pLY.d.mts → interface-Da0r7Lna.d.mts} +0 -0
  122. /package/dist/{memory-DqI-449b.mjs → memory-DikHSvWa.mjs} +0 -0
  123. /package/dist/{metrics-TuOmguhi.mjs → metrics-Csh4nsvv.mjs} +0 -0
  124. /package/dist/{multipartBody-CUQGVlM_.mjs → multipartBody-CvTR1Un6.mjs} +0 -0
  125. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-BneOJkpi.mjs} +0 -0
  126. /package/dist/{redis-stream-bkO88VHx.d.mts → redis-stream-CM8TXTix.d.mts} +0 -0
  127. /package/dist/{registry-B0Wl7uVV.mjs → registry-D63ee7fl.mjs} +0 -0
  128. /package/dist/{replyHelpers-BLojtuvR.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
  129. /package/dist/{requestContext-C38GskNt.mjs → requestContext-CfRkaxwf.mjs} +0 -0
  130. /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
  131. /package/dist/{sse-D8UeDwis.mjs → sse-V7aXc3bW.mjs} +0 -0
  132. /package/dist/{store-helpers-DYYUQbQN.mjs → store-helpers-BhrzxvyQ.mjs} +0 -0
  133. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  134. /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
  135. /package/dist/{versioning-B6mimogM.mjs → versioning-CGPjkqAg.mjs} +0 -0
  136. /package/dist/{versioning-CeUXHfjw.d.mts → versioning-M9lNLhO8.d.mts} +0 -0
@@ -1,15 +1,255 @@
1
1
  import { t as CRUD_OPERATIONS } from "../constants-BhY1OHoH.mjs";
2
- import { n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "../fields-CTMWOUDt.mjs";
3
2
  import { runStorageContract } from "./storageContract.mjs";
4
3
  import Fastify from "fastify";
5
- import mongoose from "mongoose";
6
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7
- //#region src/testing/authHelpers.ts
4
+ import { afterAll, describe, expect, it, vi } from "vitest";
5
+ //#region src/testing/assertions.ts
8
6
  /**
9
- * Safely parse a JSON response body.
10
- * Returns null if parsing fails.
7
+ * expectArc Arc-specific response assertions
8
+ *
9
+ * Wraps a Fastify `app.inject` response and exposes fluent assertions for
10
+ * the arc response envelope. Replaces the ~6 patterns repeated hundreds of
11
+ * times across the test suite:
12
+ *
13
+ * expect(res.statusCode).toBe(200);
14
+ * expect(JSON.parse(res.body).success).toBe(true);
15
+ * expect(JSON.parse(res.body).data.password).toBeUndefined();
16
+ *
17
+ * becomes
18
+ *
19
+ * expectArc(res).ok().hidesField('password');
20
+ *
21
+ * Every helper returns the assertion object so you can chain. `.body` /
22
+ * `.data` are lazy accessors — they parse once and cache, so repeated access
23
+ * is cheap.
24
+ *
25
+ * Assertions use `vitest`'s `expect` internally — import this only from
26
+ * test files or modules that run under vitest.
27
+ */
28
+ function parseBody(response) {
29
+ if (response.body === "" || response.body === void 0 || response.body === null) return {};
30
+ try {
31
+ return JSON.parse(response.body);
32
+ } catch (err) {
33
+ throw new Error(`expectArc: response body is not valid JSON (statusCode=${response.statusCode}): ${err.message}\nBody: ${response.body.slice(0, 200)}`);
34
+ }
35
+ }
36
+ function expectArc(response) {
37
+ let cachedBody = null;
38
+ const getBody = () => {
39
+ cachedBody ??= parseBody(response);
40
+ return cachedBody;
41
+ };
42
+ const assertion = {
43
+ response,
44
+ get body() {
45
+ return getBody();
46
+ },
47
+ get data() {
48
+ return getBody().data;
49
+ },
50
+ ok(status = 200) {
51
+ expect(response.statusCode, `expected 2xx (${status}) but got ${response.statusCode}. Body: ${response.body.slice(0, 200)}`).toBe(status);
52
+ expect(getBody().success).toBe(true);
53
+ return assertion;
54
+ },
55
+ failed(status) {
56
+ if (status !== void 0) expect(response.statusCode).toBe(status);
57
+ else expect(response.statusCode).toBeGreaterThanOrEqual(400);
58
+ expect(getBody().success).toBe(false);
59
+ return assertion;
60
+ },
61
+ unauthorized() {
62
+ return assertion.failed(401);
63
+ },
64
+ forbidden() {
65
+ return assertion.failed(403);
66
+ },
67
+ notFound() {
68
+ return assertion.failed(404);
69
+ },
70
+ validationError() {
71
+ return assertion.failed(400);
72
+ },
73
+ conflict() {
74
+ return assertion.failed(409);
75
+ },
76
+ hasData() {
77
+ expect(getBody().data, "expected body.data to be defined").toBeDefined();
78
+ return assertion;
79
+ },
80
+ hasStatus(status) {
81
+ expect(response.statusCode).toBe(status);
82
+ return assertion;
83
+ },
84
+ hidesField(field) {
85
+ const data = getBody().data;
86
+ expect(data, "expected body.data to be defined before field check").toBeDefined();
87
+ expect(data, `expected field '${field}' to be hidden from response.data`).not.toHaveProperty(field);
88
+ return assertion;
89
+ },
90
+ showsField(field) {
91
+ const data = getBody().data;
92
+ expect(data, "expected body.data to be defined before field check").toBeDefined();
93
+ expect(data, `expected field '${field}' on response.data`).toHaveProperty(field);
94
+ return assertion;
95
+ },
96
+ paginated(expected) {
97
+ const body = getBody();
98
+ expect(body.success).toBe(true);
99
+ const docs = body.docs ?? (Array.isArray(body.data) ? body.data : void 0);
100
+ expect(Array.isArray(docs), "expected `docs[]` (or `data[]`) on paginated response").toBe(true);
101
+ if (expected?.page !== void 0) expect(body.page).toBe(expected.page);
102
+ if (expected?.limit !== void 0) expect(body.limit).toBe(expected.limit);
103
+ if (expected?.total !== void 0) expect(body.total).toBe(expected.total);
104
+ if (expected?.hasNext !== void 0) expect(body.hasNext).toBe(expected.hasNext);
105
+ if (expected?.hasPrev !== void 0) expect(body.hasPrev).toBe(expected.hasPrev);
106
+ return assertion;
107
+ },
108
+ hasError(matcher) {
109
+ const body = getBody();
110
+ const errorField = body.error ?? body.message;
111
+ expect(errorField, "expected body.error or body.message to be set").toBeDefined();
112
+ if (typeof matcher === "string") expect(errorField).toBe(matcher);
113
+ else expect(errorField).toMatch(matcher);
114
+ return assertion;
115
+ },
116
+ hasMeta(key, value) {
117
+ const body = getBody();
118
+ const flat = body[key];
119
+ const nested = body.meta?.[key];
120
+ const resolved = flat !== void 0 ? flat : nested;
121
+ expect(resolved, `expected meta.${key} on body`).toBeDefined();
122
+ if (value !== void 0) expect(resolved).toEqual(value);
123
+ return assertion;
124
+ }
125
+ };
126
+ return assertion;
127
+ }
128
+ //#endregion
129
+ //#region src/testing/authSession.ts
130
+ function buildHeaders(token, orgId, extra) {
131
+ const headers = { authorization: `Bearer ${token}` };
132
+ if (orgId) headers["x-organization-id"] = orgId;
133
+ if (extra) Object.assign(headers, extra);
134
+ return headers;
135
+ }
136
+ function freezeSession(session) {
137
+ return Object.freeze({
138
+ ...session,
139
+ headers: Object.freeze({ ...session.headers })
140
+ });
141
+ }
142
+ function createProvider(deps) {
143
+ const registry = /* @__PURE__ */ new Map();
144
+ const build = (role, config) => {
145
+ const token = deps.mintToken(role, config);
146
+ const orgId = config.orgId ?? deps.defaultOrgId;
147
+ const headers = buildHeaders(token, orgId, config.extraHeaders);
148
+ const withExtra = (extra) => freezeSession({
149
+ role,
150
+ token,
151
+ orgId,
152
+ user: config.user,
153
+ headers: {
154
+ ...headers,
155
+ ...extra
156
+ },
157
+ withExtra
158
+ });
159
+ return freezeSession({
160
+ role,
161
+ token,
162
+ orgId,
163
+ user: config.user,
164
+ headers,
165
+ withExtra
166
+ });
167
+ };
168
+ return {
169
+ register(role, config) {
170
+ if (!config.user && !config.token) throw new Error(`TestAuthProvider.register('${role}'): must supply either 'user' (JWT payload to sign) or 'token' (pre-signed bearer).`);
171
+ registry.set(role, config);
172
+ },
173
+ as(role) {
174
+ const config = registry.get(role);
175
+ if (!config) throw new Error(`TestAuthProvider.as('${role}'): unknown role. Registered: [${[...registry.keys()].join(", ") || "none"}]`);
176
+ return build(role, config);
177
+ },
178
+ anonymous() {
179
+ const withExtra = (extra) => freezeSession({
180
+ role: "anonymous",
181
+ token: "",
182
+ orgId: void 0,
183
+ user: void 0,
184
+ headers: { ...extra },
185
+ withExtra
186
+ });
187
+ return freezeSession({
188
+ role: "anonymous",
189
+ token: "",
190
+ orgId: void 0,
191
+ user: void 0,
192
+ headers: {},
193
+ withExtra
194
+ });
195
+ },
196
+ get roles() {
197
+ return [...registry.keys()];
198
+ }
199
+ };
200
+ }
201
+ /**
202
+ * JWT provider — signs tokens on-the-fly using `app.jwt.sign()`.
203
+ * Requires `@fastify/jwt` registered on the app.
204
+ *
205
+ * Accepts both `user` (payload to sign) and `token` (pre-signed) role configs,
206
+ * so the same provider handles mixed flows in a single test.
207
+ */
208
+ function createJwtAuthProvider(app, opts = {}) {
209
+ return createProvider({
210
+ defaultOrgId: opts.defaultOrgId,
211
+ mintToken(role, config) {
212
+ if (config.token) return config.token;
213
+ if (!config.user) throw new Error(`[jwt] role '${role}' has neither 'user' nor 'token'`);
214
+ const jwt = app.jwt;
215
+ if (!jwt?.sign) throw new Error(`[jwt] app.jwt.sign() is unavailable — register @fastify/jwt before calling createJwtAuthProvider.`);
216
+ return jwt.sign(config.user);
217
+ }
218
+ });
219
+ }
220
+ /**
221
+ * Better Auth provider — uses pre-signed tokens (from signUp/signIn flows).
222
+ * No signing: role configs MUST carry `token`. A `user` alone will throw.
223
+ */
224
+ function createBetterAuthProvider(opts = {}) {
225
+ return createProvider({
226
+ defaultOrgId: opts.defaultOrgId,
227
+ mintToken(role, config) {
228
+ if (!config.token) throw new Error(`[better-auth] role '${role}' requires a pre-signed 'token' (from signUp/signIn). JWT payloads ('user') are not supported by this provider.`);
229
+ return config.token;
230
+ }
231
+ });
232
+ }
233
+ /**
234
+ * Custom provider — plug in your own token minting logic. Useful for
235
+ * mocked external issuers, session-cookie flows, or fixtures that pre-mint.
236
+ */
237
+ function createCustomAuthProvider(mintToken, opts = {}) {
238
+ return createProvider({
239
+ defaultOrgId: opts.defaultOrgId,
240
+ mintToken
241
+ });
242
+ }
243
+ //#endregion
244
+ //#region src/testing/betterAuth.ts
245
+ const DEFAULT_BASE_PATH = "/api/auth";
246
+ /**
247
+ * Parse a JSON body safely. Returns null when empty or malformed — Better
248
+ * Auth endpoints occasionally emit empty 204 bodies (e.g. set-active) and
249
+ * tests shouldn't crash on the parse.
11
250
  */
12
251
  function safeParseBody(body) {
252
+ if (!body) return null;
13
253
  try {
14
254
  return JSON.parse(body);
15
255
  } catch {
@@ -17,712 +257,397 @@ function safeParseBody(body) {
17
257
  }
18
258
  }
19
259
  /**
20
- * Create stateless Better Auth test helpers.
21
- *
22
- * All methods take the app instance as a parameter, making them
23
- * safe to use across multiple test suites.
260
+ * Extract a Better Auth session token from a response. Different versions
261
+ * return it under different keys (`token`, `session.token`, `data.token`)
262
+ * check all three so the helper keeps working across minor-version
263
+ * bumps without a coordinated update.
264
+ */
265
+ function extractToken(body) {
266
+ if (!body || typeof body !== "object") return null;
267
+ const obj = body;
268
+ if (typeof obj.token === "string") return obj.token;
269
+ const session = obj.session;
270
+ if (session && typeof session.token === "string") return session.token;
271
+ const data = obj.data;
272
+ if (data && typeof data.token === "string") return data.token;
273
+ if (data?.session && typeof data.session.token === "string") return data.session.token;
274
+ return null;
275
+ }
276
+ /**
277
+ * Extract the user id from a response. Same tolerance story as
278
+ * `extractToken` — Better Auth has shuffled this field across versions.
279
+ */
280
+ function extractUserId(body) {
281
+ if (!body || typeof body !== "object") return null;
282
+ const obj = body;
283
+ const userLike = obj.user ?? obj.data ?? obj;
284
+ if (!userLike) return null;
285
+ if (typeof userLike.id === "string") return userLike.id;
286
+ if (typeof userLike.userId === "string") return userLike.userId;
287
+ const nestedUser = userLike.user;
288
+ if (nestedUser && typeof nestedUser.id === "string") return nestedUser.id;
289
+ return null;
290
+ }
291
+ /** Same shape-tolerance for org ids. */
292
+ function extractOrgId(body) {
293
+ if (!body || typeof body !== "object") return null;
294
+ const obj = body;
295
+ if (typeof obj.id === "string") return obj.id;
296
+ const organization = obj.organization;
297
+ if (organization && typeof organization.id === "string") return organization.id;
298
+ const data = obj.data;
299
+ if (data && typeof data.id === "string") return data.id;
300
+ if (data) {
301
+ const nestedOrg = data.organization;
302
+ if (nestedOrg && typeof nestedOrg.id === "string") return nestedOrg.id;
303
+ }
304
+ return null;
305
+ }
306
+ /**
307
+ * Stateless Better Auth helpers. Each function takes the app as a positional
308
+ * argument, so a single helper instance works across multiple test apps in
309
+ * the same suite.
24
310
  */
25
311
  function createBetterAuthTestHelpers(options = {}) {
26
- const basePath = options.basePath ?? "/api/auth";
312
+ const basePath = options.basePath ?? DEFAULT_BASE_PATH;
27
313
  return {
28
- async signUp(app, data) {
314
+ async signUp(app, input) {
29
315
  const res = await app.inject({
30
316
  method: "POST",
31
317
  url: `${basePath}/sign-up/email`,
32
- payload: data
318
+ payload: input,
319
+ headers: { "content-type": "application/json" }
33
320
  });
34
- const token = res.headers["set-auth-token"];
35
321
  const body = safeParseBody(res.body);
322
+ const token = extractToken(body) ?? "";
323
+ const userId = extractUserId(body) ?? "";
36
324
  return {
37
325
  statusCode: res.statusCode,
38
- token: token || "",
39
- user: body?.user || body,
326
+ token,
327
+ userId,
40
328
  body
41
329
  };
42
330
  },
43
- async signIn(app, data) {
331
+ async signIn(app, input) {
44
332
  const res = await app.inject({
45
333
  method: "POST",
46
334
  url: `${basePath}/sign-in/email`,
47
- payload: data
335
+ payload: input,
336
+ headers: { "content-type": "application/json" }
48
337
  });
49
- const token = res.headers["set-auth-token"];
50
338
  const body = safeParseBody(res.body);
339
+ const token = extractToken(body) ?? "";
340
+ const userId = extractUserId(body) ?? "";
51
341
  return {
52
342
  statusCode: res.statusCode,
53
- token: token || "",
54
- user: body?.user || body,
343
+ token,
344
+ userId,
55
345
  body
56
346
  };
57
347
  },
58
- async createOrg(app, token, data) {
348
+ async createOrg(app, token, input) {
59
349
  const res = await app.inject({
60
350
  method: "POST",
61
351
  url: `${basePath}/organization/create`,
62
- headers: { authorization: `Bearer ${token}` },
63
- payload: data
352
+ payload: input,
353
+ headers: {
354
+ "content-type": "application/json",
355
+ authorization: `Bearer ${token}`
356
+ }
64
357
  });
65
358
  const body = safeParseBody(res.body);
359
+ const orgId = extractOrgId(body) ?? "";
66
360
  return {
67
361
  statusCode: res.statusCode,
68
- orgId: body?.id,
362
+ orgId,
69
363
  body
70
364
  };
71
365
  },
72
366
  async setActiveOrg(app, token, orgId) {
73
- const res = await app.inject({
367
+ return app.inject({
74
368
  method: "POST",
75
369
  url: `${basePath}/organization/set-active`,
76
- headers: { authorization: `Bearer ${token}` },
77
- payload: { organizationId: orgId }
370
+ payload: { organizationId: orgId },
371
+ headers: {
372
+ "content-type": "application/json",
373
+ authorization: `Bearer ${token}`
374
+ }
78
375
  });
79
- return {
80
- statusCode: res.statusCode,
81
- body: safeParseBody(res.body)
82
- };
83
376
  },
84
377
  authHeaders(token, orgId) {
85
- const h = { authorization: `Bearer ${token}` };
86
- if (orgId) h["x-organization-id"] = orgId;
87
- return h;
378
+ const headers = { authorization: `Bearer ${token}` };
379
+ if (orgId) headers["x-organization-id"] = orgId;
380
+ return headers;
88
381
  }
89
382
  };
90
383
  }
91
384
  /**
92
- * Set up a complete test organization with users.
93
- *
94
- * Creates the app, signs up users, creates an org, adds members,
95
- * and returns a context object with tokens and a teardown function.
96
- *
97
- * @example
98
- * ```typescript
99
- * const ctx = await setupBetterAuthOrg({
100
- * createApp: () => createAppInstance(),
101
- * org: { name: 'Test Corp', slug: 'test-corp' },
102
- * users: [
103
- * { key: 'admin', email: 'admin@test.com', password: 'pass', name: 'Admin', role: 'admin', isCreator: true },
104
- * { key: 'member', email: 'user@test.com', password: 'pass', name: 'User', role: 'member' },
105
- * ],
106
- * addMember: async (data) => {
107
- * await auth.api.addMember({ body: data });
108
- * return { statusCode: 200 };
109
- * },
110
- * });
111
- *
112
- * // Use in tests:
113
- * const res = await ctx.app.inject({
114
- * method: 'GET',
115
- * url: '/api/products',
116
- * headers: auth.authHeaders(ctx.users.admin.token, ctx.orgId),
117
- * });
118
- *
119
- * // Cleanup:
120
- * await ctx.teardown();
121
- * ```
385
+ * Composite setup for Better Auth apps. Replaces the pre-v2.11
386
+ * `setupBetterAuthOrg` with a tighter contract:
387
+ *
388
+ * 1. Accept an already-built `app` (caller owns its lifecycle arc's
389
+ * `createTestApp` composes naturally, but any built Fastify works).
390
+ * 2. Sign up every user in order.
391
+ * 3. The creator user creates the org; orgId is captured.
392
+ * 4. Every non-creator user is added via the caller-supplied `addMember`
393
+ * (Better Auth's org-member API is app-specific, so arc doesn't
394
+ * hardcode it).
395
+ * 5. Set the active org on every user.
396
+ * 6. Register each user into a fresh `TestAuthProvider` the 2.11
397
+ * `.as(key).headers` pattern works out of the box on the result.
398
+ *
399
+ * Exactly one user must be `isCreator: true`. Throws if zero or multiple
400
+ * creators are supplied (ambiguous ownership is a boot-time bug, not a
401
+ * runtime one).
122
402
  */
123
- async function setupBetterAuthOrg(options) {
124
- const { createApp, org, users: userConfigs, addMember, afterSetup, authHelpers: helpersOptions } = options;
125
- const helpers = createBetterAuthTestHelpers(helpersOptions);
126
- const creators = userConfigs.filter((u) => u.isCreator);
127
- if (creators.length !== 1) throw new Error(`setupBetterAuthOrg: Exactly one user must have isCreator: true (found ${creators.length})`);
128
- const app = await createApp();
129
- await app.ready();
130
- const signups = /* @__PURE__ */ new Map();
131
- for (const userConfig of userConfigs) {
132
- const signup = await helpers.signUp(app, {
133
- email: userConfig.email,
134
- password: userConfig.password,
135
- name: userConfig.name
136
- });
137
- if (signup.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to sign up ${userConfig.email} (status ${signup.statusCode})`);
138
- signups.set(userConfig.key, signup);
139
- }
140
- const creatorConfig = creators[0];
141
- const creatorSignup = signups.get(creatorConfig.key);
142
- const orgResult = await helpers.createOrg(app, creatorSignup.token, org);
143
- if (orgResult.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to create org (status ${orgResult.statusCode})`);
144
- const orgId = orgResult.orgId;
145
- for (const userConfig of userConfigs) {
146
- if (userConfig.isCreator) continue;
147
- const result = await addMember({
148
- organizationId: orgId,
149
- userId: signups.get(userConfig.key).user?.id,
150
- role: userConfig.role
403
+ async function setupBetterAuthTestApp(input) {
404
+ const { app, org, users, addMember, basePath } = input;
405
+ const creators = users.filter((u) => u.isCreator === true);
406
+ if (creators.length !== 1) throw new Error(`[arc-testing] setupBetterAuthTestApp: expected exactly one user with 'isCreator: true', got ${creators.length}. Every composite setup needs a single org owner to resolve ambiguous org membership.`);
407
+ const helpers = createBetterAuthTestHelpers({ basePath });
408
+ const signedUp = {};
409
+ for (const u of users) {
410
+ const res = await helpers.signUp(app, {
411
+ email: u.email,
412
+ password: u.password,
413
+ name: u.name
151
414
  });
152
- if (result.statusCode !== 200) throw new Error(`setupBetterAuthOrg: Failed to add member ${userConfig.email} (status ${result.statusCode})`);
153
- }
154
- await helpers.setActiveOrg(app, creatorSignup.token, orgId);
155
- const users = {};
156
- for (const userConfig of userConfigs) if (userConfig.isCreator) {
157
- const signup = signups.get(userConfig.key);
158
- users[userConfig.key] = {
159
- token: signup.token,
160
- userId: signup.user?.id,
161
- email: userConfig.email
415
+ if (res.statusCode >= 400 || !res.token || !res.userId) throw new Error(`[arc-testing] setupBetterAuthTestApp: signUp failed for '${u.key}' (${u.email}). statusCode=${res.statusCode}, token=${res.token ? "ok" : "missing"}, userId=${res.userId ? "ok" : "missing"}, body=${JSON.stringify(res.body).slice(0, 300)}`);
416
+ signedUp[u.key] = {
417
+ userId: res.userId,
418
+ token: res.token,
419
+ user: u
162
420
  };
163
- } else {
164
- const login = await helpers.signIn(app, {
165
- email: userConfig.email,
166
- password: userConfig.password
421
+ }
422
+ const creatorRec = signedUp[creators[0].key];
423
+ const orgRes = await helpers.createOrg(app, creatorRec.token, org);
424
+ if (orgRes.statusCode >= 400 || !orgRes.orgId) throw new Error(`[arc-testing] setupBetterAuthTestApp: createOrg failed. statusCode=${orgRes.statusCode}, orgId=${orgRes.orgId ? "ok" : "missing"}, body=${JSON.stringify(orgRes.body).slice(0, 300)}`);
425
+ const orgId = orgRes.orgId;
426
+ for (const u of users) {
427
+ if (u.isCreator === true) continue;
428
+ if (!addMember) continue;
429
+ const rec = signedUp[u.key];
430
+ const res = await addMember({
431
+ app,
432
+ creatorToken: creatorRec.token,
433
+ orgId,
434
+ userId: rec.userId,
435
+ role: u.role ?? "member"
167
436
  });
168
- await helpers.setActiveOrg(app, login.token, orgId);
169
- users[userConfig.key] = {
170
- token: login.token,
171
- userId: signups.get(userConfig.key)?.user?.id,
172
- email: userConfig.email
173
- };
437
+ if (res.statusCode >= 400) throw new Error(`[arc-testing] setupBetterAuthTestApp: addMember failed for '${u.key}'. statusCode=${res.statusCode}, body=${res.body.slice(0, 300)}`);
174
438
  }
175
- const ctx = {
439
+ for (const rec of Object.values(signedUp)) await helpers.setActiveOrg(app, rec.token, orgId);
440
+ const auth = createBetterAuthProvider({ defaultOrgId: orgId });
441
+ for (const [key, rec] of Object.entries(signedUp)) auth.register(key, {
442
+ token: rec.token,
443
+ orgId
444
+ });
445
+ return {
176
446
  app,
177
447
  orgId,
178
- users,
448
+ users: Object.fromEntries(Object.entries(signedUp).map(([key, rec]) => [key, {
449
+ userId: rec.userId,
450
+ token: rec.token,
451
+ email: rec.user.email,
452
+ ...rec.user.role ? { role: rec.user.role } : {}
453
+ }])),
454
+ auth,
179
455
  async teardown() {
180
456
  await app.close();
181
457
  }
182
458
  };
183
- if (afterSetup) await afterSetup(ctx);
184
- return ctx;
185
459
  }
186
460
  //#endregion
187
- //#region src/testing/dbHelpers.ts
188
- /**
189
- * Test database manager
190
- */
191
- var TestDatabase = class {
192
- connection;
193
- dbName;
194
- constructor(dbName = `test_${Date.now()}`) {
195
- this.dbName = dbName;
196
- }
197
- /**
198
- * Connect to test database
199
- */
200
- async connect(uri) {
201
- const fullUri = `${uri || process.env.MONGO_TEST_URI || "mongodb://localhost:27017"}/${this.dbName}`;
202
- this.connection = await mongoose.createConnection(fullUri).asPromise();
203
- return this.connection;
204
- }
205
- /**
206
- * Disconnect and cleanup
207
- */
208
- async disconnect() {
209
- if (this.connection) {
210
- await this.connection.dropDatabase();
211
- await this.connection.close();
212
- this.connection = void 0;
213
- }
214
- }
215
- /**
216
- * Clear all collections
217
- */
218
- async clear() {
219
- if (!this.connection?.db) throw new Error("Database not connected");
220
- const collections = await this.connection.db.collections();
221
- await Promise.all(collections.map((collection) => collection.deleteMany({})));
222
- }
223
- /**
224
- * Get connection
225
- */
226
- getConnection() {
227
- if (!this.connection) throw new Error("Database not connected");
228
- return this.connection;
229
- }
230
- };
231
- /**
232
- * Higher-order function to wrap tests with database setup/teardown
233
- *
234
- * @example
235
- * describe('Product Tests', () => {
236
- * withTestDb(async (db) => {
237
- * test('create product', async () => {
238
- * const Product = db.getConnection().model('Product', schema);
239
- * const product = await Product.create({ name: 'Test' });
240
- * expect(product.name).toBe('Test');
241
- * });
242
- * });
243
- * });
244
- */
245
- function withTestDb(tests, options = {}) {
246
- const db = new TestDatabase(options.dbName);
247
- beforeAll(async () => {
248
- await db.connect(options.uri);
249
- });
250
- afterAll(async () => {
251
- await db.disconnect();
252
- });
253
- afterEach(async () => {
254
- await db.clear();
255
- });
256
- tests(db);
257
- }
258
- /**
259
- * Create test fixtures
260
- *
261
- * @example
262
- * const fixtures = new TestFixtures(connection);
263
- *
264
- * await fixtures.load('products', [
265
- * { name: 'Product 1', price: 100 },
266
- * { name: 'Product 2', price: 200 },
267
- * ]);
268
- *
269
- * const products = await fixtures.get('products');
270
- */
271
- var TestFixtures = class {
272
- fixtures = /* @__PURE__ */ new Map();
273
- connection;
274
- constructor(connection) {
275
- this.connection = connection;
276
- }
277
- /**
278
- * Load fixtures into a collection
279
- */
280
- async load(collectionName, data) {
281
- const result = await this.connection.collection(collectionName).insertMany(data);
282
- const insertedDocs = Object.values(result.insertedIds).map((id, index) => ({
283
- ...data[index],
284
- _id: id
285
- }));
286
- this.fixtures.set(collectionName, insertedDocs);
287
- return insertedDocs;
288
- }
289
- /**
290
- * Get loaded fixtures
291
- */
292
- get(collectionName) {
293
- return this.fixtures.get(collectionName) || [];
294
- }
295
- /**
296
- * Get first fixture
297
- */
298
- getFirst(collectionName) {
299
- return this.get(collectionName)[0] || null;
300
- }
301
- /**
302
- * Clear all fixtures
303
- */
304
- async clear() {
305
- for (const collectionName of this.fixtures.keys()) {
306
- const collection = this.connection.collection(collectionName);
307
- const ids = this.fixtures.get(collectionName)?.map((item) => item._id) || [];
308
- await collection.deleteMany({ _id: { $in: ids } });
309
- }
310
- this.fixtures.clear();
311
- }
312
- };
313
- /**
314
- * In-memory MongoDB for ultra-fast tests
315
- *
316
- * Requires: mongodb-memory-server
317
- *
318
- * @example
319
- * import { InMemoryDatabase } from '@classytic/arc/testing';
320
- *
321
- * describe('Fast Tests', () => {
322
- * const memoryDb = new InMemoryDatabase();
323
- *
324
- * beforeAll(async () => {
325
- * await memoryDb.start();
326
- * });
327
- *
328
- * afterAll(async () => {
329
- * await memoryDb.stop();
330
- * });
331
- *
332
- * test('create user', async () => {
333
- * const uri = memoryDb.getUri();
334
- * // Use uri for connection
335
- * });
336
- * });
337
- */
338
- var InMemoryDatabase = class {
339
- mongod;
340
- uri;
341
- /**
342
- * Start in-memory MongoDB
343
- */
344
- async start() {
345
- try {
346
- const { MongoMemoryServer } = await import("mongodb-memory-server");
347
- this.mongod = await MongoMemoryServer.create();
348
- const uri = this.mongod.getUri();
349
- this.uri = uri;
350
- return uri;
351
- } catch {
352
- throw new Error("mongodb-memory-server not installed. Install with: npm install -D mongodb-memory-server");
353
- }
354
- }
355
- /**
356
- * Stop in-memory MongoDB
357
- */
358
- async stop() {
359
- if (this.mongod) {
360
- await this.mongod.stop();
361
- this.mongod = void 0;
362
- this.uri = void 0;
363
- }
364
- }
365
- /**
366
- * Get connection URI
367
- */
368
- getUri() {
369
- if (!this.uri) throw new Error("In-memory database not started");
370
- return this.uri;
371
- }
372
- };
373
- /**
374
- * Database transaction helper for testing
375
- */
376
- var TestTransaction = class {
377
- session;
378
- connection;
379
- constructor(connection) {
380
- this.connection = connection;
381
- }
382
- /**
383
- * Start transaction
384
- */
385
- async start() {
386
- this.session = await this.connection.startSession();
387
- this.session.startTransaction();
388
- }
389
- /**
390
- * Commit transaction
391
- */
392
- async commit() {
393
- if (!this.session) throw new Error("Transaction not started");
394
- await this.session.commitTransaction();
395
- await this.session.endSession();
396
- this.session = void 0;
397
- }
398
- /**
399
- * Rollback transaction
400
- */
401
- async rollback() {
402
- if (!this.session) throw new Error("Transaction not started");
403
- await this.session.abortTransaction();
404
- await this.session.endSession();
405
- this.session = void 0;
406
- }
407
- /**
408
- * Get session
409
- */
410
- getSession() {
411
- if (!this.session) throw new Error("Transaction not started");
412
- return this.session;
413
- }
414
- };
415
- /**
416
- * Seed data helper
417
- */
418
- var TestSeeder = class {
419
- connection;
420
- constructor(connection) {
421
- this.connection = connection;
422
- }
423
- /**
424
- * Seed collection with data
425
- */
426
- async seed(collectionName, generator, count = 10) {
427
- const data = Array.from({ length: count }, () => generator()).flat();
428
- const result = await this.connection.collection(collectionName).insertMany(data);
429
- return Object.values(result.insertedIds).map((id, index) => ({
430
- ...data[index],
431
- _id: id
432
- }));
433
- }
434
- /**
435
- * Clear collection
436
- */
437
- async clear(collectionName) {
438
- await this.connection.collection(collectionName).deleteMany({});
439
- }
440
- /**
441
- * Clear all collections
442
- */
443
- async clearAll() {
444
- if (!this.connection.db) throw new Error("Database not connected");
445
- const collections = await this.connection.db.collections();
446
- await Promise.all(collections.map((collection) => collection.deleteMany({})));
447
- }
448
- };
449
- /**
450
- * Database snapshot helper for rollback testing
451
- */
452
- var DatabaseSnapshot = class {
453
- snapshots = /* @__PURE__ */ new Map();
454
- connection;
455
- constructor(connection) {
456
- this.connection = connection;
457
- }
458
- /**
459
- * Take snapshot of current database state
460
- */
461
- async take() {
462
- if (!this.connection.db) throw new Error("Database not connected");
463
- const collections = await this.connection.db.collections();
464
- for (const collection of collections) {
465
- const data = await collection.find({}).toArray();
466
- this.snapshots.set(collection.collectionName, data);
467
- }
468
- }
469
- /**
470
- * Restore database to snapshot
471
- */
472
- async restore() {
473
- if (!this.connection.db) throw new Error("Database not connected");
474
- const collections = await this.connection.db.collections();
475
- await Promise.all(collections.map((collection) => collection.deleteMany({})));
476
- for (const [collectionName, data] of this.snapshots.entries()) if (data.length > 0) await this.connection.collection(collectionName).insertMany(data);
477
- }
478
- /**
479
- * Clear snapshot
480
- */
481
- clear() {
482
- this.snapshots.clear();
483
- }
484
- };
485
- //#endregion
486
- //#region src/testing/HttpTestHarness.ts
487
- /**
488
- * Create an auth provider for JWT-based apps.
489
- *
490
- * Generates JWT tokens on the fly using the app's JWT plugin.
491
- *
492
- * @example
493
- * ```typescript
494
- * const auth = createJwtAuthProvider({
495
- * app,
496
- * users: {
497
- * admin: { payload: { id: '1', roles: ['admin'] }, organizationId: 'org1' },
498
- * viewer: { payload: { id: '2', roles: ['viewer'] } },
499
- * },
500
- * adminRole: 'admin',
501
- * });
502
- * ```
503
- */
504
- function createJwtAuthProvider(options) {
505
- const { app, users, adminRole } = options;
461
+ //#region src/testing/fixtures.ts
462
+ function createTestFixtures() {
463
+ const registry = /* @__PURE__ */ new Map();
464
+ const tracked = [];
506
465
  return {
507
- getHeaders(role) {
508
- const user = users[role];
509
- if (!user) throw new Error(`createJwtAuthProvider: Unknown role '${role}'. Available: ${Object.keys(users).join(", ")}`);
510
- const headers = { authorization: `Bearer ${app.jwt?.sign?.(user.payload) || "mock-token"}` };
511
- if (user.organizationId) headers["x-organization-id"] = user.organizationId;
512
- return headers;
466
+ register(name, factoryOrRegistration) {
467
+ const registration = typeof factoryOrRegistration === "function" ? { create: factoryOrRegistration } : factoryOrRegistration;
468
+ registry.set(name, registration);
513
469
  },
514
- availableRoles: Object.keys(users),
515
- adminRole
516
- };
517
- }
518
- /**
519
- * Create an auth provider for Better Auth apps.
520
- *
521
- * Uses pre-existing tokens (from signUp/signIn) rather than generating them.
522
- *
523
- * @example
524
- * ```typescript
525
- * const auth = createBetterAuthProvider({
526
- * tokens: {
527
- * admin: ctx.users.admin.token,
528
- * member: ctx.users.member.token,
529
- * },
530
- * orgId: ctx.orgId,
531
- * adminRole: 'admin',
532
- * });
533
- * ```
534
- */
535
- function createBetterAuthProvider(options) {
536
- const { tokens, orgId, adminRole } = options;
537
- return {
538
- getHeaders(role) {
539
- const token = tokens[role];
540
- if (!token) throw new Error(`createBetterAuthProvider: No token for role '${role}'. Available: ${Object.keys(tokens).join(", ")}`);
541
- return {
542
- authorization: `Bearer ${token}`,
543
- "x-organization-id": orgId
544
- };
470
+ async create(name, data) {
471
+ const reg = registry.get(name);
472
+ if (!reg) throw new Error(`TestFixtures.create('${name}'): unknown factory. Registered: [${[...registry.keys()].join(", ") || "none"}]`);
473
+ const record = await reg.create(data ?? {});
474
+ tracked.push({
475
+ name,
476
+ record,
477
+ destroy: reg.destroy
478
+ });
479
+ return record;
480
+ },
481
+ async createMany(name, count, template) {
482
+ if (count < 0) throw new Error(`TestFixtures.createMany: count must be >= 0, got ${count}`);
483
+ const results = [];
484
+ for (let i = 0; i < count; i++) {
485
+ const record = await this.create(name, template);
486
+ results.push(record);
487
+ }
488
+ return results;
545
489
  },
546
- availableRoles: Object.keys(tokens),
547
- adminRole
490
+ async clear() {
491
+ for (let i = tracked.length - 1; i >= 0; i--) {
492
+ const entry = tracked[i];
493
+ if (entry.destroy) await entry.destroy(entry.record).catch(() => {});
494
+ }
495
+ tracked.length = 0;
496
+ },
497
+ all(name) {
498
+ return tracked.filter((t) => t.name === name).map((t) => t.record);
499
+ },
500
+ get names() {
501
+ return [...registry.keys()];
502
+ }
548
503
  };
549
504
  }
505
+ //#endregion
506
+ //#region src/testing/HttpTestHarness.ts
550
507
  /**
551
- * HTTP-level test harness for Arc resources.
552
- *
553
- * Generates tests that exercise the full HTTP lifecycle:
554
- * routes, auth, permissions, pipeline, and response envelope.
555
- *
556
- * Supports deferred options via a getter function, which is essential
557
- * when the app instance comes from async `beforeAll()` setup.
508
+ * An op is "protected" (should 401 without a token) unless the resource
509
+ * explicitly wired `allowPublic()` — that's the same rule arc's router uses
510
+ * via `requiresAuthentication`. Treats absent permission as public (matches
511
+ * the router's behaviour for historical reasons); the harness only emits the
512
+ * unauthenticated 401 assertion when the op is actually protected.
558
513
  */
514
+ function opRequiresAuth(resource, op) {
515
+ const check = resource.permissions?.[op];
516
+ if (!check) return false;
517
+ return check._isPublic !== true;
518
+ }
559
519
  var HttpTestHarness = class {
560
520
  resource;
561
521
  optionsOrGetter;
562
- eagerBaseUrl;
563
522
  enabledRoutes;
564
- updateMethod;
523
+ /**
524
+ * Update verbs exercised by this harness instance. One entry for single-method
525
+ * resources (`"PATCH"` or `"PUT"`), two for `updateMethod: "both"` so both
526
+ * verbs are covered — the framework mounts both, and the harness should
527
+ * probe both.
528
+ */
529
+ updateMethods;
565
530
  constructor(resource, optionsOrGetter) {
566
531
  this.resource = resource;
567
532
  this.optionsOrGetter = optionsOrGetter;
568
- if (typeof optionsOrGetter === "function") this.eagerBaseUrl = null;
569
- else {
570
- const apiPrefix = optionsOrGetter.apiPrefix ?? "/api";
571
- this.eagerBaseUrl = `${apiPrefix}${resource.prefix}`;
572
- }
573
533
  const disabled = new Set(resource.disabledRoutes ?? []);
574
534
  this.enabledRoutes = new Set(resource.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op)));
575
- this.updateMethod = resource.updateMethod === "PUT" ? "PUT" : "PATCH";
535
+ const um = resource.updateMethod;
536
+ this.updateMethods = um === "both" ? ["PATCH", "PUT"] : um === "PUT" ? ["PUT"] : ["PATCH"];
576
537
  }
577
- /** Resolve options (supports both direct and deferred) */
578
538
  getOptions() {
579
539
  return typeof this.optionsOrGetter === "function" ? this.optionsOrGetter() : this.optionsOrGetter;
580
540
  }
581
- /**
582
- * Resolve the base URL for requests.
583
- *
584
- * - Eager mode: uses pre-computed baseUrl from constructor
585
- * - Deferred mode: reads apiPrefix from the getter options at runtime
586
- *
587
- * Must only be called inside it()/afterAll() callbacks (after beforeAll has run).
588
- */
589
541
  getBaseUrl() {
590
- if (this.eagerBaseUrl !== null) return this.eagerBaseUrl;
591
542
  return `${this.getOptions().apiPrefix ?? ""}${this.resource.prefix}`;
592
543
  }
593
- /**
594
- * Run all test suites: CRUD + permissions + validation
595
- */
544
+ adminHeaders() {
545
+ const opts = this.getOptions();
546
+ return { ...opts.auth.as(opts.adminRole).headers };
547
+ }
596
548
  runAll() {
597
549
  this.runCrud();
598
550
  this.runPermissions();
599
551
  this.runValidation();
600
552
  }
601
- /**
602
- * Run HTTP-level CRUD tests.
603
- *
604
- * Tests each enabled CRUD operation through app.inject():
605
- * - POST (create) → 200/201 with { success: true, data }
606
- * - GET (list) → 200 with array or paginated response
607
- * - GET /:id → 200 with { success: true, data }
608
- * - PATCH/PUT /:id → 200 with { success: true, data }
609
- * - DELETE /:id → 200
610
- * - GET /:id with non-existent ID → 404
611
- */
612
553
  runCrud() {
613
- const { resource, enabledRoutes, updateMethod } = this;
554
+ const { resource, enabledRoutes, updateMethods } = this;
614
555
  let createdId = null;
615
556
  describe(`${resource.displayName} HTTP CRUD`, () => {
616
557
  afterAll(async () => {
617
558
  if (createdId && enabledRoutes.has("delete")) {
618
- const { app, auth } = this.getOptions();
619
- const baseUrl = this.getBaseUrl();
559
+ const { app } = this.getOptions();
620
560
  await app.inject({
621
561
  method: "DELETE",
622
- url: `${baseUrl}/${createdId}`,
623
- headers: auth.getHeaders(auth.adminRole)
562
+ url: `${this.getBaseUrl()}/${createdId}`,
563
+ headers: this.adminHeaders()
624
564
  });
625
565
  }
626
566
  });
627
567
  if (enabledRoutes.has("create")) it("POST should create a resource", async () => {
628
- const { app, auth, fixtures } = this.getOptions();
629
- const baseUrl = this.getBaseUrl();
630
- const adminHeaders = auth.getHeaders(auth.adminRole);
568
+ const { app, fixtures } = this.getOptions();
631
569
  const res = await app.inject({
632
570
  method: "POST",
633
- url: baseUrl,
634
- headers: adminHeaders,
571
+ url: this.getBaseUrl(),
572
+ headers: this.adminHeaders(),
635
573
  payload: fixtures.valid
636
574
  });
637
575
  expect(res.statusCode).toBeLessThan(300);
638
576
  const body = JSON.parse(res.body);
639
577
  expect(body.success).toBe(true);
640
- expect(body.data).toBeDefined();
641
- expect(body.data._id).toBeDefined();
578
+ expect(body.data?._id).toBeDefined();
642
579
  createdId = body.data._id;
643
580
  });
644
581
  if (enabledRoutes.has("list")) it("GET should list resources", async () => {
645
- const { app, auth } = this.getOptions();
646
- const baseUrl = this.getBaseUrl();
582
+ const { app } = this.getOptions();
647
583
  const res = await app.inject({
648
584
  method: "GET",
649
- url: baseUrl,
650
- headers: auth.getHeaders(auth.adminRole)
585
+ url: this.getBaseUrl(),
586
+ headers: this.adminHeaders()
651
587
  });
652
588
  expect(res.statusCode).toBe(200);
653
589
  const body = JSON.parse(res.body);
654
590
  expect(body.success).toBe(true);
655
591
  const list = body.data ?? body.docs;
656
- expect(list).toBeDefined();
657
592
  expect(Array.isArray(list)).toBe(true);
658
593
  });
659
594
  if (enabledRoutes.has("get")) {
660
595
  it("GET /:id should return the resource", async () => {
661
596
  if (!createdId) return;
662
- const { app, auth } = this.getOptions();
663
- const baseUrl = this.getBaseUrl();
597
+ const { app } = this.getOptions();
664
598
  const res = await app.inject({
665
599
  method: "GET",
666
- url: `${baseUrl}/${createdId}`,
667
- headers: auth.getHeaders(auth.adminRole)
600
+ url: `${this.getBaseUrl()}/${createdId}`,
601
+ headers: this.adminHeaders()
668
602
  });
669
603
  expect(res.statusCode).toBe(200);
670
- const body = JSON.parse(res.body);
671
- expect(body.success).toBe(true);
672
- expect(body.data).toBeDefined();
673
- expect(body.data._id).toBe(createdId);
604
+ expect(JSON.parse(res.body).data?._id).toBe(createdId);
674
605
  });
675
606
  it("GET /:id with non-existent ID should return 404", async () => {
676
- const { app, auth } = this.getOptions();
677
- const baseUrl = this.getBaseUrl();
607
+ const { app } = this.getOptions();
678
608
  const res = await app.inject({
679
609
  method: "GET",
680
- url: `${baseUrl}/000000000000000000000000`,
681
- headers: auth.getHeaders(auth.adminRole)
610
+ url: `${this.getBaseUrl()}/000000000000000000000000`,
611
+ headers: this.adminHeaders()
682
612
  });
683
613
  expect(res.statusCode).toBe(404);
684
614
  expect(JSON.parse(res.body).success).toBe(false);
685
615
  });
686
616
  }
687
- if (enabledRoutes.has("update")) {
688
- it(`${updateMethod} /:id should update the resource`, async () => {
617
+ if (enabledRoutes.has("update")) for (const verb of updateMethods) {
618
+ it(`${verb} /:id should update the resource`, async () => {
689
619
  if (!createdId) return;
690
- const { app, auth, fixtures } = this.getOptions();
691
- const baseUrl = this.getBaseUrl();
692
- const updatePayload = fixtures.update || fixtures.valid;
620
+ const { app, fixtures } = this.getOptions();
621
+ const payload = fixtures.update ?? fixtures.valid;
693
622
  const res = await app.inject({
694
- method: updateMethod,
695
- url: `${baseUrl}/${createdId}`,
696
- headers: auth.getHeaders(auth.adminRole),
697
- payload: updatePayload
623
+ method: verb,
624
+ url: `${this.getBaseUrl()}/${createdId}`,
625
+ headers: this.adminHeaders(),
626
+ payload
698
627
  });
699
628
  expect(res.statusCode).toBe(200);
700
- const body = JSON.parse(res.body);
701
- expect(body.success).toBe(true);
702
- expect(body.data).toBeDefined();
629
+ expect(JSON.parse(res.body).success).toBe(true);
703
630
  });
704
- it(`${updateMethod} /:id with non-existent ID should return 404`, async () => {
705
- const { app, auth, fixtures } = this.getOptions();
706
- const baseUrl = this.getBaseUrl();
631
+ it(`${verb} /:id with non-existent ID should return 404`, async () => {
632
+ const { app, fixtures } = this.getOptions();
633
+ const payload = fixtures.update ?? fixtures.valid;
707
634
  expect((await app.inject({
708
- method: updateMethod,
709
- url: `${baseUrl}/000000000000000000000000`,
710
- headers: auth.getHeaders(auth.adminRole),
711
- payload: fixtures.update || fixtures.valid
635
+ method: verb,
636
+ url: `${this.getBaseUrl()}/000000000000000000000000`,
637
+ headers: this.adminHeaders(),
638
+ payload
712
639
  })).statusCode).toBe(404);
713
640
  });
714
641
  }
715
642
  if (enabledRoutes.has("delete")) {
716
643
  it("DELETE /:id should delete the resource", async () => {
717
- const { app, auth, fixtures } = this.getOptions();
718
- const baseUrl = this.getBaseUrl();
719
- const adminHeaders = auth.getHeaders(auth.adminRole);
644
+ const { app, fixtures } = this.getOptions();
720
645
  let deleteId;
721
646
  if (enabledRoutes.has("create")) {
722
647
  const createRes = await app.inject({
723
648
  method: "POST",
724
- url: baseUrl,
725
- headers: adminHeaders,
649
+ url: this.getBaseUrl(),
650
+ headers: this.adminHeaders(),
726
651
  payload: fixtures.valid
727
652
  });
728
653
  deleteId = JSON.parse(createRes.body).data?._id;
@@ -730,124 +655,111 @@ var HttpTestHarness = class {
730
655
  if (!deleteId) return;
731
656
  expect((await app.inject({
732
657
  method: "DELETE",
733
- url: `${baseUrl}/${deleteId}`,
734
- headers: adminHeaders
658
+ url: `${this.getBaseUrl()}/${deleteId}`,
659
+ headers: this.adminHeaders()
735
660
  })).statusCode).toBe(200);
736
661
  if (enabledRoutes.has("get")) expect((await app.inject({
737
662
  method: "GET",
738
- url: `${baseUrl}/${deleteId}`,
739
- headers: adminHeaders
663
+ url: `${this.getBaseUrl()}/${deleteId}`,
664
+ headers: this.adminHeaders()
740
665
  })).statusCode).toBe(404);
741
666
  });
742
667
  it("DELETE /:id with non-existent ID should return 404", async () => {
743
- const { app, auth } = this.getOptions();
744
- const baseUrl = this.getBaseUrl();
668
+ const { app } = this.getOptions();
745
669
  expect((await app.inject({
746
670
  method: "DELETE",
747
- url: `${baseUrl}/000000000000000000000000`,
748
- headers: auth.getHeaders(auth.adminRole)
671
+ url: `${this.getBaseUrl()}/000000000000000000000000`,
672
+ headers: this.adminHeaders()
749
673
  })).statusCode).toBe(404);
750
674
  });
751
675
  }
752
676
  });
753
677
  }
754
- /**
755
- * Run permission tests.
756
- *
757
- * Tests that:
758
- * - Unauthenticated requests return 401
759
- * - Admin role gets 2xx for all operations
760
- */
761
678
  runPermissions() {
762
- const { resource, enabledRoutes, updateMethod } = this;
679
+ const { resource, enabledRoutes, updateMethods } = this;
680
+ const protectedOps = {
681
+ list: opRequiresAuth(resource, "list"),
682
+ get: opRequiresAuth(resource, "get"),
683
+ create: opRequiresAuth(resource, "create"),
684
+ update: opRequiresAuth(resource, "update"),
685
+ delete: opRequiresAuth(resource, "delete")
686
+ };
763
687
  describe(`${resource.displayName} HTTP Permissions`, () => {
764
- if (enabledRoutes.has("list")) it("GET list without auth should return 401", async () => {
688
+ if (enabledRoutes.has("list") && protectedOps.list) it("GET list without auth should return 401", async () => {
765
689
  const { app } = this.getOptions();
766
- const baseUrl = this.getBaseUrl();
767
690
  expect((await app.inject({
768
691
  method: "GET",
769
- url: baseUrl
692
+ url: this.getBaseUrl()
770
693
  })).statusCode).toBe(401);
771
694
  });
772
- if (enabledRoutes.has("get")) it("GET get without auth should return 401", async () => {
695
+ if (enabledRoutes.has("get") && protectedOps.get) it("GET /:id without auth should return 401", async () => {
773
696
  const { app } = this.getOptions();
774
- const baseUrl = this.getBaseUrl();
775
697
  expect((await app.inject({
776
698
  method: "GET",
777
- url: `${baseUrl}/000000000000000000000000`
699
+ url: `${this.getBaseUrl()}/000000000000000000000000`
778
700
  })).statusCode).toBe(401);
779
701
  });
780
- if (enabledRoutes.has("create")) it("POST create without auth should return 401", async () => {
702
+ if (enabledRoutes.has("create") && protectedOps.create) it("POST without auth should return 401", async () => {
781
703
  const { app, fixtures } = this.getOptions();
782
- const baseUrl = this.getBaseUrl();
783
704
  expect((await app.inject({
784
705
  method: "POST",
785
- url: baseUrl,
706
+ url: this.getBaseUrl(),
786
707
  payload: fixtures.valid
787
708
  })).statusCode).toBe(401);
788
709
  });
789
- if (enabledRoutes.has("update")) it(`${updateMethod} update without auth should return 401`, async () => {
710
+ if (enabledRoutes.has("update") && protectedOps.update) for (const verb of updateMethods) it(`${verb} without auth should return 401`, async () => {
790
711
  const { app, fixtures } = this.getOptions();
791
- const baseUrl = this.getBaseUrl();
712
+ const payload = fixtures.update ?? fixtures.valid;
792
713
  expect((await app.inject({
793
- method: updateMethod,
794
- url: `${baseUrl}/000000000000000000000000`,
795
- payload: fixtures.update || fixtures.valid
714
+ method: verb,
715
+ url: `${this.getBaseUrl()}/000000000000000000000000`,
716
+ payload
796
717
  })).statusCode).toBe(401);
797
718
  });
798
- if (enabledRoutes.has("delete")) it("DELETE delete without auth should return 401", async () => {
719
+ if (enabledRoutes.has("delete") && protectedOps.delete) it("DELETE without auth should return 401", async () => {
799
720
  const { app } = this.getOptions();
800
- const baseUrl = this.getBaseUrl();
801
721
  expect((await app.inject({
802
722
  method: "DELETE",
803
- url: `${baseUrl}/000000000000000000000000`
723
+ url: `${this.getBaseUrl()}/000000000000000000000000`
804
724
  })).statusCode).toBe(401);
805
725
  });
806
726
  if (enabledRoutes.has("list")) it("admin should access list endpoint", async () => {
807
- const { app, auth } = this.getOptions();
808
- const baseUrl = this.getBaseUrl();
727
+ const { app } = this.getOptions();
809
728
  expect((await app.inject({
810
729
  method: "GET",
811
- url: baseUrl,
812
- headers: auth.getHeaders(auth.adminRole)
730
+ url: this.getBaseUrl(),
731
+ headers: this.adminHeaders()
813
732
  })).statusCode).toBeLessThan(400);
814
733
  });
815
734
  if (enabledRoutes.has("create")) it("admin should access create endpoint", async () => {
816
- const { app, auth, fixtures } = this.getOptions();
817
- const baseUrl = this.getBaseUrl();
735
+ const { app, fixtures } = this.getOptions();
818
736
  const res = await app.inject({
819
737
  method: "POST",
820
- url: baseUrl,
821
- headers: auth.getHeaders(auth.adminRole),
738
+ url: this.getBaseUrl(),
739
+ headers: this.adminHeaders(),
822
740
  payload: fixtures.valid
823
741
  });
824
742
  expect(res.statusCode).toBeLessThan(400);
825
743
  const body = JSON.parse(res.body);
826
744
  if (body.data?._id && enabledRoutes.has("delete")) await app.inject({
827
745
  method: "DELETE",
828
- url: `${baseUrl}/${body.data._id}`,
829
- headers: auth.getHeaders(auth.adminRole)
746
+ url: `${this.getBaseUrl()}/${body.data._id}`,
747
+ headers: this.adminHeaders()
830
748
  });
831
749
  });
832
750
  });
833
751
  }
834
- /**
835
- * Run validation tests.
836
- *
837
- * Tests that invalid payloads return 400.
838
- */
839
752
  runValidation() {
840
753
  const { resource, enabledRoutes } = this;
841
754
  if (!enabledRoutes.has("create")) return;
842
755
  describe(`${resource.displayName} HTTP Validation`, () => {
843
- it("POST with invalid payload should not return 2xx", async () => {
844
- const { app, auth, fixtures } = this.getOptions();
845
- const baseUrl = this.getBaseUrl();
756
+ it("POST with invalid payload should be rejected", async () => {
757
+ const { app, fixtures } = this.getOptions();
846
758
  if (!fixtures.invalid) return;
847
759
  const res = await app.inject({
848
760
  method: "POST",
849
- url: baseUrl,
850
- headers: auth.getHeaders(auth.adminRole),
761
+ url: this.getBaseUrl(),
762
+ headers: this.adminHeaders(),
851
763
  payload: fixtures.invalid
852
764
  });
853
765
  expect(res.statusCode).toBeGreaterThanOrEqual(400);
@@ -857,22 +769,8 @@ var HttpTestHarness = class {
857
769
  }
858
770
  };
859
771
  /**
860
- * Create an HTTP test harness for an Arc resource.
861
- *
862
- * Accepts options directly or as a getter function for deferred resolution.
863
- *
864
- * @example Deferred (recommended for async setup)
865
- * ```typescript
866
- * let ctx: TestContext;
867
- * beforeAll(async () => { ctx = await setupTestOrg(); });
868
- *
869
- * createHttpTestHarness(jobResource, () => ({
870
- * app: ctx.app,
871
- * apiPrefix: '',
872
- * fixtures: { valid: { title: 'Test' } },
873
- * auth: createBetterAuthProvider({ ... }),
874
- * })).runAll();
875
- * ```
772
+ * Create an HTTP test harness. `optionsOrGetter` may be a plain object
773
+ * (for eager app setup) or a getter function (for async `beforeAll` apps).
876
774
  */
877
775
  function createHttpTestHarness(resource, optionsOrGetter) {
878
776
  return new HttpTestHarness(resource, optionsOrGetter);
@@ -1118,682 +1016,133 @@ function pickResource(value) {
1118
1016
  for (const c of candidates) if (c && typeof c === "object" && typeof c.toPlugin === "function") return c;
1119
1017
  }
1120
1018
  //#endregion
1121
- //#region src/testing/TestHarness.ts
1019
+ //#region src/testing/testApp.ts
1122
1020
  /**
1123
- * Resource Test Harness
1124
- *
1125
- * Generates baseline tests for Arc resources automatically.
1126
- * Tests CRUD operations + preset routes with minimal configuration.
1021
+ * createTestApp test app factory for arc
1127
1022
  *
1128
- * @example
1129
- * import { createTestHarness } from '@classytic/arc/testing';
1130
- * import productResource from './product.resource.js';
1023
+ * One call spins up a Fastify instance with arc's standard test defaults,
1024
+ * an in-memory MongoDB (optional), an auth provider (JWT / Better Auth / none),
1025
+ * and a fixture tracker attached to the result. Every piece is optional —
1026
+ * tests that just need a vanilla app skip the extras.
1131
1027
  *
1132
- * const harness = createTestHarness(productResource, {
1133
- * fixtures: {
1134
- * valid: { name: 'Test Product', price: 100 },
1135
- * update: { name: 'Updated Product' },
1136
- * },
1137
- * });
1138
- *
1139
- * // Run all baseline tests (50+ auto-generated)
1140
- * harness.runAll();
1141
- *
1142
- * // Or run specific test suites
1143
- * harness.runPresets();
1144
- * harness.runValidation();
1028
+ * const ctx = await createTestApp({
1029
+ * resources: [jobResource],
1030
+ * authMode: 'jwt',
1031
+ * });
1145
1032
  *
1146
- * // For HTTP-level CRUD coverage (auth, permissions, routes), use
1147
- * // `HttpTestHarness` from `@classytic/arc/testing`.
1033
+ * ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] } });
1034
+ * const admin = ctx.auth.as('admin');
1035
+ * const res = await ctx.app.inject({ url: '/jobs', headers: admin.headers });
1036
+ *
1037
+ * afterAll(() => ctx.close());
1038
+ *
1039
+ * Scope — what this factory does AND doesn't do:
1040
+ * ✓ Starts in-memory Mongo (when `db: 'in-memory'`) and exposes `dbUri`
1041
+ * ✓ Optionally connects Mongoose to that URI via `connectMongoose: true`
1042
+ * ✓ Applies arc's standard test defaults for the Fastify instance
1043
+ * ✓ Applies the matching auth plugin for the chosen `authMode`
1044
+ * ✓ Registers every resource as a plugin (under its own `prefix`)
1045
+ * ✓ Tears everything down in the right order on `close()`
1046
+ * ✗ Does NOT thread `dbUri` into your resource adapters — adapter wiring
1047
+ * (mongokit/prisma/sqlitekit/custom) is app-level concern. Call
1048
+ * `mongoose.connect(ctx.dbUri)` (or use `connectMongoose: true`) before
1049
+ * importing resources whose models need the connection.
1148
1050
  */
1149
- var TestHarness = class {
1150
- resource;
1151
- Model;
1152
- fixtures;
1153
- setupFn;
1154
- teardownFn;
1155
- mongoUri;
1156
- _createdIds = [];
1157
- constructor(resource, options) {
1158
- this.resource = resource;
1159
- this.fixtures = options.fixtures;
1160
- this.setupFn = options.setupFn;
1161
- this.teardownFn = options.teardownFn;
1162
- this.mongoUri = options.mongoUri || process.env.MONGO_URI || "mongodb://localhost:27017/test";
1163
- if (!resource.adapter) throw new Error(`TestHarness requires a resource with a database adapter`);
1164
- if (resource.adapter.type !== "mongoose") throw new Error(`TestHarness currently only supports Mongoose adapters`);
1165
- const model = resource.adapter.model;
1166
- if (!model) throw new Error(`Mongoose adapter for ${resource.name} does not have a model`);
1167
- this.Model = model;
1168
- }
1169
- /**
1170
- * Run all baseline tests (schema, presets, field permissions, pipeline, events).
1171
- *
1172
- * For HTTP-level CRUD coverage (routes, auth, permissions), use
1173
- * {@link HttpTestHarness} instead.
1174
- */
1175
- runAll() {
1176
- this.runValidation();
1177
- this.runPresets();
1178
- this.runFieldPermissions();
1179
- this.runPipeline();
1180
- this.runEvents();
1181
- }
1182
- /**
1183
- * Run validation tests
1184
- *
1185
- * Tests schema validation, required fields, etc.
1186
- */
1187
- runValidation() {
1188
- const { resource, fixtures, Model } = this;
1189
- describe(`${resource.displayName} Validation`, () => {
1190
- beforeAll(async () => {
1191
- await mongoose.connect(this.mongoUri);
1192
- });
1193
- afterAll(async () => {
1194
- await mongoose.disconnect();
1195
- });
1196
- it("should reject empty document", async () => {
1197
- await expect(Model.create({})).rejects.toThrow();
1198
- });
1199
- if (fixtures.invalid) it("should reject invalid data", async () => {
1200
- await expect(Model.create(fixtures.invalid)).rejects.toThrow();
1201
- });
1202
- });
1203
- }
1204
- /**
1205
- * Run preset-specific tests
1206
- *
1207
- * Auto-detects applied presets and tests their functionality:
1208
- * - softDelete: deletedAt field, soft delete/restore
1209
- * - slugLookup: slug generation
1210
- * - tree: parent references, displayOrder
1211
- * - multiTenant: organizationId requirement
1212
- * - ownedByUser: userId requirement
1213
- */
1214
- runPresets() {
1215
- const { resource, fixtures, Model } = this;
1216
- const presets = resource._appliedPresets || [];
1217
- if (presets.length === 0) return;
1218
- describe(`${resource.displayName} Preset Tests`, () => {
1219
- beforeAll(async () => {
1220
- await mongoose.connect(this.mongoUri);
1221
- });
1222
- afterAll(async () => {
1223
- await mongoose.disconnect();
1224
- });
1225
- if (presets.includes("softDelete")) describe("Soft Delete", () => {
1226
- let testDoc;
1227
- beforeEach(async () => {
1228
- testDoc = await Model.create(fixtures.valid);
1229
- this._createdIds.push(testDoc._id);
1230
- });
1231
- it("should have deletedAt field", () => {
1232
- expect(testDoc.deletedAt).toBeDefined();
1233
- expect(testDoc.deletedAt).toBeNull();
1234
- });
1235
- it("should soft delete (set deletedAt)", async () => {
1236
- await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
1237
- expect((await Model.findById(testDoc._id))?.deletedAt).not.toBeNull();
1238
- });
1239
- it("should restore (clear deletedAt)", async () => {
1240
- await Model.findByIdAndUpdate(testDoc._id, { deletedAt: /* @__PURE__ */ new Date() });
1241
- await Model.findByIdAndUpdate(testDoc._id, { deletedAt: null });
1242
- expect((await Model.findById(testDoc._id))?.deletedAt).toBeNull();
1243
- });
1244
- });
1245
- if (presets.includes("slugLookup")) describe("Slug Lookup", () => {
1246
- it("should have slug field", async () => {
1247
- const doc = await Model.create(fixtures.valid);
1248
- this._createdIds.push(doc._id);
1249
- expect(doc.slug).toBeDefined();
1250
- });
1251
- it("should generate slug from name", async () => {
1252
- const doc = await Model.create({
1253
- ...fixtures.valid,
1254
- name: "Test Slug Name"
1255
- });
1256
- this._createdIds.push(doc._id);
1257
- expect(doc.slug).toMatch(/test-slug-name/i);
1258
- });
1259
- });
1260
- if (presets.includes("tree")) describe("Tree Structure", () => {
1261
- it("should allow parent reference", async () => {
1262
- const parent = await Model.create(fixtures.valid);
1263
- this._createdIds.push(parent._id);
1264
- const child = await Model.create({
1265
- ...fixtures.valid,
1266
- parent: parent._id
1267
- });
1268
- this._createdIds.push(child._id);
1269
- expect(String(child.parent)).toEqual(String(parent._id));
1270
- });
1271
- it("should support displayOrder", async () => {
1272
- const doc = await Model.create({
1273
- ...fixtures.valid,
1274
- displayOrder: 5
1275
- });
1276
- this._createdIds.push(doc._id);
1277
- expect(doc.displayOrder).toEqual(5);
1278
- });
1279
- });
1280
- if (presets.includes("multiTenant")) describe("Multi-Tenant", () => {
1281
- it("should require organizationId", async () => {
1282
- const docWithoutOrg = { ...fixtures.valid };
1283
- delete docWithoutOrg.organizationId;
1284
- await expect(Model.create(docWithoutOrg)).rejects.toThrow();
1285
- });
1286
- });
1287
- if (presets.includes("ownedByUser")) describe("Owned By User", () => {
1288
- it("should require userId", async () => {
1289
- const docWithoutUser = { ...fixtures.valid };
1290
- delete docWithoutUser.userId;
1291
- await expect(Model.create(docWithoutUser)).rejects.toThrow();
1292
- });
1293
- });
1294
- });
1295
- }
1296
- /**
1297
- * Run field-level permission tests
1298
- *
1299
- * Auto-generates tests for each field permission:
1300
- * - hidden: field is stripped from responses
1301
- * - visibleTo: field only shown to specified roles
1302
- * - writableBy: field stripped from writes by non-privileged users
1303
- * - redactFor: field shows redacted value for specified roles
1304
- */
1305
- runFieldPermissions() {
1306
- const { resource } = this;
1307
- const fieldPerms = resource.fields;
1308
- if (!fieldPerms || Object.keys(fieldPerms).length === 0) return;
1309
- describe(`${resource.displayName} Field Permissions`, () => {
1310
- for (const [field, rawPerm] of Object.entries(fieldPerms)) {
1311
- const perm = rawPerm;
1312
- switch (perm._type) {
1313
- case "hidden":
1314
- it(`should always hide field '${field}'`, () => {
1315
- const result = applyFieldReadPermissions({
1316
- [field]: "secret",
1317
- otherField: "visible"
1318
- }, fieldPerms, []);
1319
- expect(result[field]).toBeUndefined();
1320
- expect(result.otherField).toBe("visible");
1321
- });
1322
- it(`should strip hidden field '${field}' from writes`, () => {
1323
- const { body: result } = applyFieldWritePermissions({
1324
- [field]: "attempt",
1325
- name: "test"
1326
- }, fieldPerms, []);
1327
- expect(result[field]).toBeUndefined();
1328
- expect(result.name).toBe("test");
1329
- });
1330
- break;
1331
- case "visibleTo":
1332
- it(`should hide field '${field}' from non-privileged users`, () => {
1333
- expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["viewer"])[field]).toBeUndefined();
1334
- });
1335
- if (perm.roles && perm.roles.length > 0) {
1336
- const allowedRole = perm.roles[0];
1337
- it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
1338
- expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
1339
- });
1340
- }
1341
- break;
1342
- case "writableBy":
1343
- it(`should strip field '${field}' from writes by non-privileged users`, () => {
1344
- const { body: result } = applyFieldWritePermissions({
1345
- [field]: "new-value",
1346
- name: "test"
1347
- }, fieldPerms, ["viewer"]);
1348
- expect(result[field]).toBeUndefined();
1349
- expect(result.name).toBe("test");
1350
- });
1351
- if (perm.roles && perm.roles.length > 0) {
1352
- const writeRole = perm.roles[0];
1353
- it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
1354
- const { body: result } = applyFieldWritePermissions({ [field]: "new-value" }, fieldPerms, [writeRole]);
1355
- expect(result[field]).toBe("new-value");
1356
- });
1357
- }
1358
- break;
1359
- case "redactFor":
1360
- if (perm.roles && perm.roles.length > 0) {
1361
- const redactRole = perm.roles[0];
1362
- it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
1363
- expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
1364
- });
1365
- }
1366
- it(`should show real value of field '${field}' to non-redacted roles`, () => {
1367
- expect(applyFieldReadPermissions({ [field]: "real-value" }, fieldPerms, ["unrelated-role"])[field]).toBe("real-value");
1368
- });
1369
- break;
1370
- }
1371
- }
1372
- });
1373
- }
1374
- /**
1375
- * Run pipeline configuration tests
1376
- *
1377
- * Validates that pipeline steps are properly configured:
1378
- * - All steps have names
1379
- * - All steps have valid _type discriminants
1380
- * - Operation filters (if set) use valid CRUD operation names
1381
- */
1382
- runPipeline() {
1383
- const { resource } = this;
1384
- const pipe = resource.pipe;
1385
- if (!pipe) return;
1386
- const validOps = new Set(CRUD_OPERATIONS);
1387
- describe(`${resource.displayName} Pipeline`, () => {
1388
- const steps = collectPipelineSteps(pipe);
1389
- it("should have at least one pipeline step", () => {
1390
- expect(steps.length).toBeGreaterThan(0);
1391
- });
1392
- for (const step of steps) {
1393
- it(`${step._type} '${step.name}' should have a valid type`, () => {
1394
- expect([
1395
- "guard",
1396
- "transform",
1397
- "interceptor"
1398
- ]).toContain(step._type);
1399
- });
1400
- it(`${step._type} '${step.name}' should have a name`, () => {
1401
- expect(step.name).toBeTruthy();
1402
- expect(typeof step.name).toBe("string");
1403
- });
1404
- it(`${step._type} '${step.name}' should have a handler function`, () => {
1405
- expect(typeof step.handler).toBe("function");
1406
- });
1407
- if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
1408
- for (const op of step.operations) expect(validOps.has(op)).toBe(true);
1409
- });
1410
- }
1411
- });
1412
- }
1413
- /**
1414
- * Run event definition tests
1415
- *
1416
- * Validates that events are properly defined:
1417
- * - All events have handler functions
1418
- * - Event names follow resource:action convention
1419
- * - Schema definitions (if present) are valid objects
1420
- */
1421
- runEvents() {
1422
- const { resource } = this;
1423
- const events = resource.events;
1424
- if (!events || Object.keys(events).length === 0) return;
1425
- describe(`${resource.displayName} Events`, () => {
1426
- for (const [action, rawDef] of Object.entries(events)) {
1427
- const def = rawDef;
1428
- it(`event '${resource.name}:${action}' should have a handler function`, () => {
1429
- expect(typeof def.handler).toBe("function");
1430
- });
1431
- it(`event '${resource.name}:${action}' should have a name`, () => {
1432
- expect(def.name).toBeTruthy();
1433
- expect(typeof def.name).toBe("string");
1434
- });
1435
- if (def.schema) it(`event '${resource.name}:${action}' schema should be an object`, () => {
1436
- expect(typeof def.schema).toBe("object");
1437
- expect(def.schema).not.toBeNull();
1438
- });
1051
+ async function startInMemoryMongo() {
1052
+ try {
1053
+ const { MongoMemoryServer } = await import("mongodb-memory-server");
1054
+ const mongod = await MongoMemoryServer.create();
1055
+ return {
1056
+ uri: mongod.getUri(),
1057
+ async stop() {
1058
+ await mongod.stop();
1439
1059
  }
1440
- });
1441
- }
1442
- };
1443
- /**
1444
- * Collect all pipeline steps from a PipelineConfig (flat array or per-operation map)
1445
- */
1446
- function collectPipelineSteps(pipe) {
1447
- if (Array.isArray(pipe)) return pipe;
1448
- const seen = /* @__PURE__ */ new Set();
1449
- const steps = [];
1450
- for (const opSteps of Object.values(pipe)) if (Array.isArray(opSteps)) for (const step of opSteps) {
1451
- const key = `${step._type}:${step.name}`;
1452
- if (!seen.has(key)) {
1453
- seen.add(key);
1454
- steps.push(step);
1455
- }
1060
+ };
1061
+ } catch (err) {
1062
+ throw new Error(`createTestApp({ db: 'in-memory' }): mongodb-memory-server is required. Install with \`npm i -D mongodb-memory-server\`. Root cause: ${err.message}`);
1456
1063
  }
1457
- return steps;
1458
- }
1459
- /**
1460
- * Create a test harness for an Arc resource
1461
- *
1462
- * @param resource - The Arc resource definition to test
1463
- * @param options - Test harness configuration
1464
- * @returns Test harness instance
1465
- *
1466
- * @example
1467
- * import { createTestHarness } from '@classytic/arc/testing';
1468
- *
1469
- * const harness = createTestHarness(productResource, {
1470
- * fixtures: {
1471
- * valid: { name: 'Product', price: 100 },
1472
- * update: { name: 'Updated' },
1473
- * },
1474
- * });
1475
- *
1476
- * harness.runAll(); // Generates 50+ baseline tests
1477
- */
1478
- function createTestHarness(resource, options) {
1479
- return new TestHarness(resource, options);
1480
1064
  }
1481
- /**
1482
- * Generate test file content for a resource
1483
- *
1484
- * Useful for scaffolding new resource tests via CLI
1485
- *
1486
- * @param resourceName - Resource name in kebab-case (e.g., 'product')
1487
- * @param options - Generation options
1488
- * @returns Complete test file content as string
1489
- *
1490
- * @example
1491
- * const testContent = generateTestFile('product', {
1492
- * presets: ['softDelete'],
1493
- * modulePath: './modules/catalog',
1494
- * });
1495
- * fs.writeFileSync('product.test.js', testContent);
1496
- */
1497
- function generateTestFile(resourceName, options = {}) {
1498
- const { presets = [], modulePath = "." } = options;
1499
- const className = resourceName.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
1500
- const varName = className.charAt(0).toLowerCase() + className.slice(1);
1501
- return `/**
1502
- * ${className} Resource Tests
1503
- *
1504
- * Auto-generated baseline tests. Customize as needed.
1505
- */
1506
-
1507
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1508
- import mongoose from 'mongoose';
1509
- import { createTestHarness } from '@classytic/arc/testing';
1510
- import ${varName}Resource from '${modulePath}/${resourceName}.resource.js';
1511
- import ${className} from '${modulePath}/${resourceName}.model.js';
1512
-
1513
- const MONGO_URI = process.env.MONGO_TEST_URI || 'mongodb://localhost:27017/${resourceName}-test';
1514
-
1515
- // Test fixtures
1516
- const fixtures = {
1517
- valid: {
1518
- name: 'Test ${className}',
1519
- // Add required fields here
1520
- },
1521
- update: {
1522
- name: 'Updated ${className}',
1523
- },
1524
- invalid: {
1525
- // Empty or invalid data
1526
- },
1527
- };
1528
-
1529
- // Create test harness
1530
- const harness = createTestHarness(${varName}Resource, {
1531
- fixtures,
1532
- mongoUri: MONGO_URI,
1533
- });
1534
-
1535
- // Run all baseline tests
1536
- harness.runAll();
1537
-
1538
- // Custom tests
1539
- describe('${className} Custom Tests', () => {
1540
- let testId;
1541
-
1542
- beforeAll(async () => {
1543
- await mongoose.connect(MONGO_URI);
1544
- });
1545
-
1546
- afterAll(async () => {
1547
- if (testId) {
1548
- await ${className}.findByIdAndDelete(testId);
1549
- }
1550
- await mongoose.disconnect();
1551
- });
1552
-
1553
- // Add your custom tests here
1554
- it('should pass custom validation', async () => {
1555
- // Example: const doc = await ${className}.create(fixtures.valid);
1556
- // testId = doc._id;
1557
- // expect(doc.someField).toBe('expectedValue');
1558
- expect(true).toBe(true);
1559
- });
1560
- });
1561
- `;
1562
- }
1563
- /**
1564
- * Run config-level tests for a resource (no DB required)
1565
- *
1566
- * Tests field permissions, pipeline configuration, and event definitions.
1567
- * Works with any adapter — no Mongoose dependency.
1568
- *
1569
- * @param resource - The Arc resource definition to test
1570
- *
1571
- * @example
1572
- * ```typescript
1573
- * import { createConfigTestSuite } from '@classytic/arc/testing';
1574
- * import productResource from './product.resource.js';
1575
- *
1576
- * // Generates field permission, pipeline, and event tests
1577
- * createConfigTestSuite(productResource);
1578
- * ```
1579
- */
1580
- function createConfigTestSuite(resource) {
1581
- const fieldPerms = resource.fields;
1582
- const pipe = resource.pipe;
1583
- const events = resource.events;
1584
- if (fieldPerms && Object.keys(fieldPerms).length > 0) runFieldPermissionTests(resource.displayName, fieldPerms);
1585
- if (pipe) runPipelineTests(resource.displayName, pipe);
1586
- if (events && Object.keys(events).length > 0) runEventTests(resource.name, resource.displayName, events);
1587
- if (resource.permissions && Object.keys(resource.permissions).length > 0) describe(`${resource.displayName} Permission Config`, () => {
1588
- for (const op of CRUD_OPERATIONS) {
1589
- const check = resource.permissions[op];
1590
- if (check) it(`${op} permission should be a function`, () => {
1591
- expect(typeof check).toBe("function");
1592
- });
1593
- }
1594
- });
1595
- }
1596
- function runFieldPermissionTests(displayName, fieldPerms) {
1597
- describe(`${displayName} Field Permissions`, () => {
1598
- for (const [field, perm] of Object.entries(fieldPerms)) switch (perm._type) {
1599
- case "hidden":
1600
- it(`should always hide field '${field}'`, () => {
1601
- expect(applyFieldReadPermissions({
1602
- [field]: "secret",
1603
- other: "visible"
1604
- }, fieldPerms, [])[field]).toBeUndefined();
1605
- });
1606
- it(`should strip hidden field '${field}' from writes`, () => {
1607
- const { body: result } = applyFieldWritePermissions({
1608
- [field]: "attempt",
1609
- name: "test"
1610
- }, fieldPerms, []);
1611
- expect(result[field]).toBeUndefined();
1612
- });
1613
- break;
1614
- case "visibleTo":
1615
- it(`should hide field '${field}' from non-privileged users`, () => {
1616
- expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, ["_no_role_"])[field]).toBeUndefined();
1617
- });
1618
- if (perm.roles && perm.roles.length > 0) {
1619
- const allowedRole = perm.roles[0];
1620
- it(`should show field '${field}' to roles: ${[...perm.roles].join(", ")}`, () => {
1621
- expect(applyFieldReadPermissions({ [field]: "sensitive" }, fieldPerms, [allowedRole])[field]).toBe("sensitive");
1622
- });
1623
- }
1624
- break;
1625
- case "writableBy":
1626
- it(`should strip field '${field}' from writes by non-privileged users`, () => {
1627
- const { body: result } = applyFieldWritePermissions({
1628
- [field]: "v",
1629
- name: "test"
1630
- }, fieldPerms, ["_no_role_"]);
1631
- expect(result[field]).toBeUndefined();
1632
- });
1633
- if (perm.roles && perm.roles.length > 0) {
1634
- const writeRole = perm.roles[0];
1635
- it(`should allow writing field '${field}' by roles: ${[...perm.roles].join(", ")}`, () => {
1636
- const { body: result } = applyFieldWritePermissions({ [field]: "v" }, fieldPerms, [writeRole]);
1637
- expect(result[field]).toBe("v");
1638
- });
1639
- }
1640
- break;
1641
- case "redactFor":
1642
- if (perm.roles && perm.roles.length > 0) {
1643
- const redactRole = perm.roles[0];
1644
- it(`should redact field '${field}' for roles: ${[...perm.roles].join(", ")}`, () => {
1645
- expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, [redactRole])[field]).toBe(perm.redactValue ?? "***");
1646
- });
1647
- }
1648
- it(`should show real value of field '${field}' to non-redacted roles`, () => {
1649
- expect(applyFieldReadPermissions({ [field]: "real" }, fieldPerms, ["_other_"])[field]).toBe("real");
1650
- });
1651
- break;
1652
- }
1653
- });
1654
- }
1655
- function runPipelineTests(displayName, pipe) {
1656
- const steps = collectPipelineSteps(pipe);
1657
- if (steps.length === 0) return;
1658
- const validOps = new Set(CRUD_OPERATIONS);
1659
- describe(`${displayName} Pipeline`, () => {
1660
- it("should have at least one pipeline step", () => {
1661
- expect(steps.length).toBeGreaterThan(0);
1662
- });
1663
- for (const step of steps) {
1664
- it(`${step._type} '${step.name}' should have a valid type`, () => {
1665
- expect([
1666
- "guard",
1667
- "transform",
1668
- "interceptor"
1669
- ]).toContain(step._type);
1670
- });
1671
- it(`${step._type} '${step.name}' should have a handler function`, () => {
1672
- expect(typeof step.handler).toBe("function");
1673
- });
1674
- if (step.operations?.length) it(`${step._type} '${step.name}' should target valid operations`, () => {
1675
- for (const op of step.operations) expect(validOps.has(op)).toBe(true);
1676
- });
1677
- }
1678
- });
1679
- }
1680
- function runEventTests(resourceName, displayName, events) {
1681
- describe(`${displayName} Events`, () => {
1682
- for (const [action, def] of Object.entries(events)) {
1683
- it(`event '${resourceName}:${action}' should have a handler function`, () => {
1684
- expect(typeof def.handler).toBe("function");
1685
- });
1686
- it(`event '${resourceName}:${action}' should have a name`, () => {
1687
- expect(def.name).toBeTruthy();
1688
- });
1689
- if (def.schema) it(`event '${resourceName}:${action}' schema should be an object`, () => {
1690
- expect(typeof def.schema).toBe("object");
1691
- expect(def.schema).not.toBeNull();
1692
- });
1693
- }
1694
- });
1065
+ async function connectMongooseToUri(uri) {
1066
+ try {
1067
+ const mongoose = (await import("mongoose")).default;
1068
+ await mongoose.connect(uri);
1069
+ return { async disconnect() {
1070
+ await mongoose.disconnect();
1071
+ } };
1072
+ } catch (err) {
1073
+ throw new Error(`createTestApp({ connectMongoose: true }): failed to connect Mongoose to ${uri}. Root cause: ${err.message}`);
1074
+ }
1695
1075
  }
1696
- //#endregion
1697
- //#region src/testing/testFactory.ts
1698
- /**
1699
- * Testing Utilities - Test App Factory
1700
- *
1701
- * Create Fastify test instances with Arc configuration
1702
- */
1703
- /**
1704
- * Create a test application instance with optional in-memory MongoDB
1705
- *
1706
- * **Performance Boost**: Uses in-memory MongoDB by default for 10x faster tests.
1707
- *
1708
- * @example Basic usage with in-memory DB
1709
- * ```typescript
1710
- * import { createTestApp } from '@classytic/arc/testing';
1711
- *
1712
- * describe('API Tests', () => {
1713
- * let testApp: TestAppResult;
1714
- *
1715
- * beforeAll(async () => {
1716
- * testApp = await createTestApp({
1717
- * auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
1718
- * });
1719
- * });
1720
- *
1721
- * afterAll(async () => {
1722
- * await testApp.close(); // Cleans up DB and disconnects
1723
- * });
1724
- *
1725
- * test('GET /health', async () => {
1726
- * const response = await testApp.app.inject({
1727
- * method: 'GET',
1728
- * url: '/health',
1729
- * });
1730
- * expect(response.statusCode).toBe(200);
1731
- * });
1732
- * });
1733
- * ```
1734
- *
1735
- * @example Using external MongoDB
1736
- * ```typescript
1737
- * const testApp = await createTestApp({
1738
- * auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
1739
- * useInMemoryDb: false,
1740
- * mongoUri: 'mongodb://localhost:27017/test-db',
1741
- * });
1742
- * ```
1743
- *
1744
- * @example Accessing MongoDB URI for model connections
1745
- * ```typescript
1746
- * const testApp = await createTestApp({
1747
- * auth: { type: 'jwt', jwt: { secret: 'test-secret' } },
1748
- * });
1749
- * await mongoose.connect(testApp.mongoUri); // Connect your models
1750
- * ```
1751
- */
1752
- async function createTestApp(options = {}) {
1753
- const { createApp } = await import("../createApp-BwnEAO2h.mjs").then((n) => n.r);
1754
- const { useInMemoryDb = true, mongoUri: providedMongoUri, ...appOptions } = options;
1755
- const defaultAuth = {
1076
+ function pickDefaultAuth(authMode, callerAuth) {
1077
+ if (callerAuth !== void 0) return callerAuth;
1078
+ if (authMode === "jwt") return {
1756
1079
  type: "jwt",
1757
1080
  jwt: { secret: "test-secret-32-chars-minimum-len" }
1758
1081
  };
1759
- let inMemoryDb = null;
1760
- let mongoUri = providedMongoUri;
1761
- if (useInMemoryDb && !providedMongoUri) try {
1762
- inMemoryDb = new InMemoryDatabase();
1763
- mongoUri = await inMemoryDb.start();
1764
- } catch (err) {
1765
- console.warn("[createTestApp] Failed to start in-memory MongoDB:", err.message, "\nFalling back to external MongoDB or no DB connection.");
1082
+ }
1083
+ async function createTestApp(options = {}) {
1084
+ const { createApp } = await import("../createApp-P1d6rjPy.mjs").then((n) => n.r);
1085
+ const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
1086
+ let dbHandle;
1087
+ let dbUri;
1088
+ if (db === "in-memory") {
1089
+ dbHandle = await startInMemoryMongo();
1090
+ dbUri = dbHandle.uri;
1091
+ } else if (db && typeof db === "object" && "uri" in db) dbUri = db.uri;
1092
+ let mongooseHandle;
1093
+ if (connectMongoose) {
1094
+ if (!dbUri) throw new Error(`createTestApp({ connectMongoose: true }): requires db: 'in-memory' or { uri }. Got db: ${JSON.stringify(db)}`);
1095
+ mongooseHandle = await connectMongooseToUri(dbUri);
1766
1096
  }
1767
- const app = await createApp({
1097
+ if (authMode === "better-auth" && callerAuth === void 0) {
1098
+ if (dbHandle) await dbHandle.stop();
1099
+ if (mongooseHandle) await mongooseHandle.disconnect();
1100
+ throw new Error("createTestApp({ authMode: 'better-auth' }): you must also pass `auth: { type: 'better-auth', ... }`. Without it the app has no auth plugin registered and tests would silently bypass Better Auth middleware.");
1101
+ }
1102
+ const resolvedAuth = pickDefaultAuth(authMode, callerAuth);
1103
+ const testDefaults = {
1768
1104
  preset: "testing",
1769
1105
  logger: false,
1770
1106
  helmet: false,
1771
1107
  cors: false,
1772
1108
  rateLimit: false,
1773
1109
  underPressure: false,
1774
- auth: defaultAuth,
1775
- ...appOptions
1110
+ ...resolvedAuth ? { auth: resolvedAuth } : {}
1111
+ };
1112
+ const mergedPlugins = async (fastify) => {
1113
+ if (plugins) await plugins(fastify);
1114
+ for (const resource of resources) await fastify.register(resource.toPlugin());
1115
+ };
1116
+ const app = await createApp({
1117
+ ...testDefaults,
1118
+ ...appOptions,
1119
+ plugins: mergedPlugins
1776
1120
  });
1121
+ let auth;
1122
+ if (authMode === "jwt") auth = createJwtAuthProvider(app, { defaultOrgId });
1123
+ else if (authMode === "better-auth") auth = createBetterAuthProvider({ defaultOrgId });
1124
+ const fixtures = createTestFixtures();
1125
+ let closed = false;
1126
+ const close = async () => {
1127
+ if (closed) return;
1128
+ closed = true;
1129
+ await fixtures.clear().catch(() => {});
1130
+ await app.close();
1131
+ if (mongooseHandle) await mongooseHandle.disconnect();
1132
+ if (dbHandle) await dbHandle.stop();
1133
+ };
1777
1134
  return {
1778
1135
  app,
1779
- mongoUri,
1780
- async close() {
1781
- await app.close();
1782
- if (inMemoryDb) await inMemoryDb.stop();
1783
- }
1136
+ auth,
1137
+ fixtures,
1138
+ dbUri,
1139
+ close
1784
1140
  };
1785
1141
  }
1786
1142
  /**
1787
- * Create a minimal Fastify instance for unit tests
1788
- *
1789
- * Use when you don't need Arc's full plugin stack
1790
- *
1791
- * @example
1792
- * const app = createMinimalTestApp();
1793
- * app.get('/test', async () => ({ success: true }));
1794
- *
1795
- * const response = await app.inject({ method: 'GET', url: '/test' });
1796
- * expect(response.json()).toEqual({ success: true });
1143
+ * Minimal Fastify instance no arc plugins, no auth, no db. Use when a test
1144
+ * needs bare Fastify (e.g. plugin unit tests that manually register their
1145
+ * dependencies).
1797
1146
  */
1798
1147
  function createMinimalTestApp(options = {}) {
1799
1148
  return Fastify({
@@ -1801,151 +1150,5 @@ function createMinimalTestApp(options = {}) {
1801
1150
  ...options
1802
1151
  });
1803
1152
  }
1804
- /**
1805
- * Test request builder for cleaner tests
1806
- *
1807
- * @example
1808
- * const request = new TestRequestBuilder(app)
1809
- * .get('/products')
1810
- * .withAuth(mockUser)
1811
- * .withQuery({ page: 1, limit: 10 });
1812
- *
1813
- * const response = await request.send();
1814
- * expect(response.statusCode).toBe(200);
1815
- */
1816
- var TestRequestBuilder = class {
1817
- method = "GET";
1818
- url = "/";
1819
- body;
1820
- query;
1821
- headers = {};
1822
- app;
1823
- constructor(app) {
1824
- this.app = app;
1825
- }
1826
- get(url) {
1827
- this.method = "GET";
1828
- this.url = url;
1829
- return this;
1830
- }
1831
- post(url) {
1832
- this.method = "POST";
1833
- this.url = url;
1834
- return this;
1835
- }
1836
- put(url) {
1837
- this.method = "PUT";
1838
- this.url = url;
1839
- return this;
1840
- }
1841
- patch(url) {
1842
- this.method = "PATCH";
1843
- this.url = url;
1844
- return this;
1845
- }
1846
- delete(url) {
1847
- this.method = "DELETE";
1848
- this.url = url;
1849
- return this;
1850
- }
1851
- withBody(body) {
1852
- this.body = body;
1853
- return this;
1854
- }
1855
- withQuery(query) {
1856
- this.query = query;
1857
- return this;
1858
- }
1859
- withHeader(key, value) {
1860
- this.headers[key] = value;
1861
- return this;
1862
- }
1863
- withAuth(userOrHeaders) {
1864
- if ("authorization" in userOrHeaders || "Authorization" in userOrHeaders) {
1865
- for (const [key, value] of Object.entries(userOrHeaders)) if (typeof value === "string") this.headers[key] = value;
1866
- } else {
1867
- const token = this.app.jwt?.sign?.(userOrHeaders) || "mock-token";
1868
- this.headers.Authorization = `Bearer ${token}`;
1869
- }
1870
- return this;
1871
- }
1872
- withContentType(type) {
1873
- this.headers["Content-Type"] = type;
1874
- return this;
1875
- }
1876
- async send() {
1877
- return this.app.inject({
1878
- method: this.method,
1879
- url: this.url,
1880
- payload: this.body,
1881
- query: this.query,
1882
- headers: this.headers
1883
- });
1884
- }
1885
- };
1886
- /**
1887
- * Helper to create a test request builder
1888
- */
1889
- function request(app) {
1890
- return new TestRequestBuilder(app);
1891
- }
1892
- /**
1893
- * Test helper for authentication
1894
- */
1895
- function createTestAuth(app) {
1896
- return {
1897
- generateToken(user) {
1898
- if (!app.jwt) throw new Error("JWT plugin not registered");
1899
- return app.jwt.sign(user);
1900
- },
1901
- decodeToken(token) {
1902
- if (!app.jwt) throw new Error("JWT plugin not registered");
1903
- return app.jwt.decode(token);
1904
- },
1905
- async verifyToken(token) {
1906
- if (!app.jwt) throw new Error("JWT plugin not registered");
1907
- return app.jwt.verify(token);
1908
- }
1909
- };
1910
- }
1911
- /**
1912
- * Snapshot testing helper for API responses
1913
- */
1914
- function createSnapshotMatcher() {
1915
- return { matchStructure(response, expected) {
1916
- if (typeof response !== typeof expected) return false;
1917
- if (Array.isArray(response) && Array.isArray(expected)) return response.length === expected.length;
1918
- if (typeof response === "object" && response !== null && typeof expected === "object" && expected !== null) {
1919
- const r = response;
1920
- const e = expected;
1921
- const responseKeys = Object.keys(r).sort();
1922
- const expectedKeys = Object.keys(e).sort();
1923
- if (JSON.stringify(responseKeys) !== JSON.stringify(expectedKeys)) return false;
1924
- for (const key of responseKeys) if (!this.matchStructure(r[key], e[key])) return false;
1925
- return true;
1926
- }
1927
- return true;
1928
- } };
1929
- }
1930
- /**
1931
- * Bulk test data loader
1932
- */
1933
- var TestDataLoader = class {
1934
- data = /* @__PURE__ */ new Map();
1935
- /**
1936
- * Load test data into database
1937
- */
1938
- async load(collection, items) {
1939
- this.data.set(collection, items);
1940
- return items;
1941
- }
1942
- /**
1943
- * Clear all loaded test data
1944
- */
1945
- async cleanup() {
1946
- for (const [_collection, _items] of this.data.entries());
1947
- this.data.clear();
1948
- }
1949
- };
1950
1153
  //#endregion
1951
- 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, preloadResources, preloadResourcesAsync, request, runStorageContract, safeParseBody, setupBetterAuthOrg, waitFor, withTestDb };
1154
+ export { HttpTestHarness, createBetterAuthProvider, createBetterAuthTestHelpers, createCustomAuthProvider, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSpy, createTestApp, createTestFixtures, createTestTimer, expectArc, preloadResources, preloadResourcesAsync, runStorageContract, safeParseBody, setupBetterAuthTestApp, waitFor };