@classytic/arc 1.1.0 → 2.1.2

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