@acmekit/acmekit 2.13.83 → 2.13.85

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.
@@ -10,39 +10,58 @@ paths:
10
10
 
11
11
  ## Critical — Read Before Writing Any Test
12
12
 
13
+ **Single unified runner.** Use `integrationTestRunner` from `@acmekit/test-utils` with a `mode` parameter. The old names (`acmekitIntegrationTestRunner`, `moduleIntegrationTestRunner`) are deprecated aliases — NEVER use them.
14
+
13
15
  **Service resolution key = module constant.** For core modules, use `Modules.AUTH`, `Modules.USER`, etc. For custom modules, use the constant from your module definition (e.g., `BLOG_MODULE = "blog"` — the string passed to `Module()`). NEVER guess `"blogModuleService"` or `"postModuleService"` — it won't resolve.
14
16
 
15
17
  **Workflow errors are plain objects, not Error instances.** The distributed transaction engine serializes them. NEVER use `.rejects.toThrow()` on workflows — it always fails with "Received function did not throw". Use `throwOnError: false` + `errors` array, or `.rejects.toEqual(expect.objectContaining({ message }))`.
16
18
 
17
19
  **Error path is `errors[0].error.message`** — NOT `errors[0].message`. Each item in the `errors` array wraps the actual error under `.error`.
18
20
 
19
- **`acmekitIntegrationTestRunner` runs real migrations** — custom modules MUST have migration files or you get `TableNotFoundException`. Run `npx acmekit db:generate <module>` and `npx acmekit db:migrate` before running HTTP integration tests. `moduleIntegrationTestRunner` syncs schema from entities (no migrations needed).
21
+ **`mode: "app"` runs real migrations** — custom modules MUST have migration files or you get `TableNotFoundException`. Run `npx acmekit db:generate <module>` and `npx acmekit db:migrate` before running HTTP or app integration tests. `mode: "module"` syncs schema from entities (no migrations needed).
20
22
 
21
23
  **`.rejects.toThrow()` DOES work for service errors** (e.g., `service.retrievePost("bad-id")`). Services throw real `Error` instances. Only workflow errors are serialized.
22
24
 
25
+ **jest.mock factories are hoisted.** `jest.mock()` runs BEFORE `const`/`let` declarations. Never reference file-level variables inside a `jest.mock()` factory — see "Unit Tests" section below.
26
+
27
+ **Always add `jest.clearAllMocks()` in `beforeEach`.** Mock state leaks between test blocks. Without explicit cleanup, assertions on mock call counts fail from prior-test contamination.
28
+
29
+ **Inline auth setup.** There is no `createAdminUser` helper. Resolve `Modules.USER`, `Modules.AUTH`, `Modules.API_KEY` from the container and create credentials directly in `beforeEach`.
30
+
23
31
  ---
24
32
 
25
33
  ## Test Runner Selection
26
34
 
27
- | What to test | Runner | Fixtures provided | DB setup |
28
- |---|---|---|---|
29
- | Module service CRUD | `moduleIntegrationTestRunner` | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync (no migrations) |
30
- | HTTP API routes end-to-end | `acmekitIntegrationTestRunner` | `api`, `getContainer()`, `dbConnection`, `dbUtils`, `utils` | Runs migrations (files required) |
31
- | Pure functions (no DB) | Plain Jest `describe/it` | none | N/A |
35
+ | What to test | Mode | HTTP? | Fixtures | DB setup |
36
+ |---|---|---|---|---|
37
+ | HTTP API routes end-to-end | `mode: "app"` | yes (default) | `api`, `getContainer()`, `container`, `dbConnection`, `dbUtils`, `utils` | Runs migrations |
38
+ | Workflows, subscribers, jobs (no HTTP) | `mode: "app"` | yes (default) | `getContainer()`, `container`, `utils` | Runs migrations |
39
+ | Module service CRUD in isolation | `mode: "module"` | no | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync (no migrations) |
40
+ | Pure functions (no DB) | Plain Jest `describe/it` | — | none | N/A |
41
+
42
+ ---
32
43
 
33
44
  ## File Locations (must match `jest.config.js` buckets)
34
45
 
35
46
  ```
36
47
  integration-tests/http/<feature>.spec.ts → TEST_TYPE=integration:http
48
+ integration-tests/app/<feature>.spec.ts → TEST_TYPE=integration:app
37
49
  src/modules/<mod>/__tests__/<name>.spec.ts → TEST_TYPE=integration:modules
38
50
  src/**/__tests__/<name>.unit.spec.ts → TEST_TYPE=unit
39
51
  ```
40
52
 
53
+ **`integration-tests/http/`** — tests that need the `api` fixture (HTTP requests to admin/client routes).
54
+
55
+ **`integration-tests/app/`** — tests that only need `getContainer()` (workflows, subscribers, jobs, direct service calls). Same `mode: "app"` runner, same migrations — but no need for auth setup or HTTP assertions.
56
+
57
+ ---
58
+
41
59
  ## Commands
42
60
 
43
61
  ```bash
44
62
  pnpm test:unit # Unit tests
45
63
  pnpm test:integration:modules # Module integration tests
64
+ pnpm test:integration:app # App integration tests (no HTTP)
46
65
  pnpm test:integration:http # HTTP integration tests
47
66
  ```
48
67
 
@@ -50,27 +69,254 @@ All integration tests require `NODE_OPTIONS=--experimental-vm-modules` (set in p
50
69
 
51
70
  ---
52
71
 
53
- ## Module Integration Tests
72
+ ## HTTP Integration Tests (`integration-tests/http/`)
73
+
74
+ **IMPORTANT:** `api` is a standard axios instance. Axios throws on non-2xx status codes. For error-case tests, use `.catch((e: any) => e)` to capture the error response.
54
75
 
55
76
  ```typescript
56
- import { moduleIntegrationTestRunner, MockEventBusService } from "@acmekit/test-utils"
57
- import { Modules } from "@acmekit/framework/utils"
77
+ import { integrationTestRunner } from "@acmekit/test-utils"
78
+ import {
79
+ ApiKeyType,
80
+ CLIENT_API_KEY_HEADER,
81
+ ContainerRegistrationKeys,
82
+ generateJwtToken,
83
+ Modules,
84
+ } from "@acmekit/framework/utils"
58
85
 
59
- jest.setTimeout(30000)
86
+ jest.setTimeout(60 * 1000)
87
+
88
+ integrationTestRunner({
89
+ mode: "app",
90
+ testSuite: ({ api, getContainer }) => {
91
+ let adminHeaders: Record<string, any>
92
+ let clientHeaders: Record<string, any>
93
+
94
+ beforeEach(async () => {
95
+ const container = getContainer()
96
+ const userModule = container.resolve(Modules.USER)
97
+ const authModule = container.resolve(Modules.AUTH)
98
+ const apiKeyModule = container.resolve(Modules.API_KEY)
99
+
100
+ // Create admin user
101
+ const user = await userModule.createUsers({
102
+ email: "admin@test.js",
103
+ })
104
+
105
+ // Create auth identity
106
+ const authIdentity = await authModule.createAuthIdentities({
107
+ provider_identities: [
108
+ { provider: "emailpass", entity_id: "admin@test.js" },
109
+ ],
110
+ app_metadata: { user_id: user.id },
111
+ })
112
+
113
+ // Generate JWT from project config — NEVER hardcode the secret
114
+ const config = container.resolve(
115
+ ContainerRegistrationKeys.CONFIG_MODULE
116
+ )
117
+ const { jwtSecret, jwtOptions } = config.projectConfig.http
118
+
119
+ const token = generateJwtToken(
120
+ {
121
+ actor_id: user.id,
122
+ actor_type: "user",
123
+ auth_identity_id: authIdentity.id,
124
+ app_metadata: { user_id: user.id },
125
+ },
126
+ { secret: jwtSecret, expiresIn: "1d", jwtOptions }
127
+ )
128
+
129
+ adminHeaders = {
130
+ headers: { authorization: `Bearer ${token}` },
131
+ }
132
+
133
+ // Create client API key
134
+ const apiKey = await apiKeyModule.createApiKeys({
135
+ title: "Test Client Key",
136
+ type: ApiKeyType.CLIENT,
137
+ created_by: "test",
138
+ })
139
+
140
+ clientHeaders = {
141
+ headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
142
+ }
143
+ })
144
+
145
+ describe("GET /admin/posts", () => {
146
+ it("should list posts", async () => {
147
+ const response = await api.get("/admin/posts", adminHeaders)
148
+ expect(response.status).toEqual(200)
149
+ expect(response.data.posts).toBeDefined()
150
+ })
151
+ })
152
+
153
+ describe("POST /admin/posts", () => {
154
+ it("should create a post", async () => {
155
+ const response = await api.post(
156
+ "/admin/posts",
157
+ { title: "Launch Announcement" },
158
+ adminHeaders
159
+ )
160
+ expect(response.status).toEqual(200)
161
+ expect(response.data.post).toEqual(
162
+ expect.objectContaining({
163
+ id: expect.any(String),
164
+ title: "Launch Announcement",
165
+ })
166
+ )
167
+ })
168
+
169
+ it("should reject missing required fields with 400", async () => {
170
+ const { response } = await api
171
+ .post("/admin/posts", {}, adminHeaders)
172
+ .catch((e: any) => e)
173
+ expect(response.status).toEqual(400)
174
+ })
175
+ })
176
+
177
+ describe("DELETE /admin/posts/:id", () => {
178
+ it("should delete and return confirmation", async () => {
179
+ const created = (
180
+ await api.post("/admin/posts", { title: "To Remove" }, adminHeaders)
181
+ ).data.post
182
+
183
+ const response = await api.delete(
184
+ `/admin/posts/${created.id}`,
185
+ adminHeaders
186
+ )
187
+ expect(response.status).toEqual(200)
188
+ expect(response.data).toEqual({
189
+ id: created.id,
190
+ object: "post",
191
+ deleted: true,
192
+ })
193
+ })
194
+ })
195
+
196
+ describe("Client routes", () => {
197
+ it("should return 200 with client API key", async () => {
198
+ const response = await api.get("/client/posts", clientHeaders)
199
+ expect(response.status).toEqual(200)
200
+ })
60
201
 
61
- moduleIntegrationTestRunner<IMyModuleService>({
62
- moduleName: Modules.MY_MODULE,
63
- resolve: "./src/modules/my-module", // path from CWD to module root (for model auto-discovery)
64
- // moduleModels: [Post, Comment], // OR pass models explicitly
65
- // moduleOptions: { jwt_secret: "test" },
66
- injectedDependencies: {
67
- [Modules.EVENT_BUS]: new MockEventBusService(),
202
+ it("should return 400 without API key", async () => {
203
+ const error = await api.get("/client/posts").catch((e: any) => e)
204
+ expect(error.response.status).toEqual(400)
205
+ })
206
+ })
68
207
  },
69
- testSuite: ({ service }) => {
70
- afterEach(() => {
71
- jest.restoreAllMocks()
208
+ })
209
+ ```
210
+
211
+ ### `integrationTestRunner` Options (app mode)
212
+
213
+ | Option | Type | Default | Description |
214
+ |---|---|---|---|
215
+ | `mode` | `"app"` | **(required)** | Selects app mode |
216
+ | `testSuite` | `(options) => void` | **(required)** | Callback containing `describe`/`it` blocks |
217
+ | `cwd` | `string` | `process.cwd()` | Project root directory |
218
+ | `acmekitConfigFile` | `string` | from `cwd` | Path to directory with `acmekit-config.ts` |
219
+ | `env` | `Record<string, any>` | `{}` | Values written to `process.env` before app starts |
220
+ | `dbName` | `string` | auto-generated | Override the computed DB name |
221
+ | `schema` | `string` | `"public"` | Postgres schema |
222
+ | `debug` | `boolean` | `false` | Enables DB query logging |
223
+ | `disableAutoTeardown` | `boolean` | `false` | Skips table TRUNCATE in `beforeEach` |
224
+ | `hooks` | `RunnerHooks` | `{}` | Lifecycle hooks (see below) |
225
+
226
+ ### Fixtures (`testSuite` callback)
227
+
228
+ - `api` — axios instance pointed at `http://localhost:<port>` (random port per run)
229
+ - `getContainer()` — returns the live `AcmeKitContainer`
230
+ - `container` — proxy to the live container (auto-refreshed each `beforeEach`)
231
+ - `dbConnection` — proxy to the knex connection
232
+ - `dbUtils` — `{ create, teardown, shutdown }` for manual DB control
233
+ - `dbConfig` — `{ dbName, schema, clientUrl }`
234
+ - `getAcmeKitApp()` — returns the running `AcmeKitApp`
235
+ - `utils.waitWorkflowExecutions()` — polls until all in-flight workflows complete (60s timeout)
236
+
237
+ ### Lifecycle hooks
238
+
239
+ ```typescript
240
+ integrationTestRunner({
241
+ mode: "app",
242
+ hooks: {
243
+ beforeSetup: async () => { /* before pipeline setup */ },
244
+ afterSetup: async ({ container, api }) => { /* after pipeline setup */ },
245
+ beforeReset: async () => { /* before each test reset */ },
246
+ afterReset: async () => { /* after each test reset */ },
247
+ },
248
+ testSuite: ({ api, getContainer }) => { ... },
249
+ })
250
+ ```
251
+
252
+ ### HTTP test lifecycle
253
+
254
+ - `beforeAll`: boots full Express app (resolves plugins, runs migrations, starts HTTP server)
255
+ - `beforeEach`: **automatically calls `waitWorkflowExecutions()`** from the previous test, then truncates all tables, re-runs module loaders, runs `createDefaultsWorkflow` (first test skips reset — state is already fresh after setup)
256
+ - `afterAll`: drops DB, shuts down Express
257
+
258
+ **IMPORTANT:** The runner calls `waitWorkflowExecutions()` automatically during the reset phase (before each subsequent test). You do NOT need to call it in your tests unless you need workflow results BETWEEN two API calls in the same test.
259
+
260
+ ---
261
+
262
+ ## App Integration Tests (`integration-tests/app/`)
263
+
264
+ For workflows, subscribers, and jobs that only need the container — no auth setup, no `api` fixture:
265
+
266
+ ```typescript
267
+ import { integrationTestRunner } from "@acmekit/test-utils"
268
+ import { createBlogsWorkflow } from "../../src/workflows/workflows"
269
+ import { BLOG_MODULE } from "../../src/modules/blog"
270
+
271
+ jest.setTimeout(60 * 1000)
272
+
273
+ integrationTestRunner({
274
+ mode: "app",
275
+ testSuite: ({ getContainer }) => {
276
+ describe("createBlogsWorkflow", () => {
277
+ it("should create a blog with defaults", async () => {
278
+ const { result } = await createBlogsWorkflow(getContainer()).run({
279
+ input: { blogs: [{ title: "My First Blog" }] },
280
+ })
281
+
282
+ expect(result).toHaveLength(1)
283
+ expect(result[0]).toEqual(
284
+ expect.objectContaining({
285
+ id: expect.any(String),
286
+ title: "My First Blog",
287
+ status: "draft",
288
+ })
289
+ )
290
+ })
291
+
292
+ it("should reject invalid status via validation step", async () => {
293
+ const { errors } = await createBlogsWorkflow(getContainer()).run({
294
+ input: { blogs: [{ title: "Bad", status: "invalid_status" }] },
295
+ throwOnError: false,
296
+ })
297
+
298
+ expect(errors).toHaveLength(1)
299
+ expect(errors[0].error.message).toContain("Invalid blog status")
300
+ })
72
301
  })
302
+ },
303
+ })
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Module Integration Tests
309
+
310
+ ```typescript
311
+ import { integrationTestRunner } from "@acmekit/test-utils"
73
312
 
313
+ jest.setTimeout(30000)
314
+
315
+ integrationTestRunner<IPostModuleService>({
316
+ mode: "module",
317
+ moduleName: "post",
318
+ resolve: process.cwd() + "/src/modules/post",
319
+ testSuite: ({ service }) => {
74
320
  describe("createPosts", () => {
75
321
  it("should create a post", async () => {
76
322
  const result = await service.createPosts([
@@ -93,25 +339,21 @@ moduleIntegrationTestRunner<IMyModuleService>({
93
339
  })
94
340
  ```
95
341
 
96
- ### `moduleIntegrationTestRunner` Options
342
+ ### Module mode options
97
343
 
98
344
  | Option | Type | Default | Description |
99
345
  |---|---|---|---|
100
- | `moduleName` | `string` | **(required)** | Module key use `Modules.*` constant |
101
- | `resolve` | `string` | `undefined` | Path to module root for model discovery. Omit if tests run from the module's own directory. |
346
+ | `mode` | `"module"` | **(required)** | Selects module mode |
347
+ | `moduleName` | `string` | **(required)** | Module key the string passed to `Module()` |
348
+ | `resolve` | `string` | `undefined` | Absolute path to module root for model discovery |
102
349
  | `moduleModels` | `any[]` | auto-discovered | Explicit model list; overrides auto-discovery |
103
- | `moduleOptions` | `Record<string, any>` | `{}` | Module configuration (merged with DB config) |
350
+ | `moduleOptions` | `Record<string, any>` | `{}` | Module configuration |
104
351
  | `moduleDependencies` | `string[]` | `undefined` | Other module names this module depends on |
105
352
  | `joinerConfig` | `any[]` | `[]` | Module joiner configuration |
106
- | `injectedDependencies` | `Record<string, any>` | `{}` | Override container registrations (e.g., MockEventBusService) |
107
- | `schema` | `string` | `"public"` | Postgres schema |
108
- | `dbName` | `string` | auto-generated | Override the computed DB name |
109
- | `debug` | `boolean` | `false` | Enables DB query logging |
110
- | `cwd` | `string` | `process.cwd()` | Working directory for model discovery |
111
- | `hooks` | `{ beforeModuleInit?, afterModuleInit? }` | `{}` | Lifecycle hooks |
112
- | `testSuite` | `(options: SuiteOptions<TService>) => void` | **(required)** | Test callback |
353
+ | `injectedDependencies` | `Record<string, any>` | `{}` | Override container registrations |
354
+ | `testSuite` | `(options) => void` | **(required)** | Test callback |
113
355
 
114
- ### `SuiteOptions<TService>` fields
356
+ ### Module mode fixtures
115
357
 
116
358
  - `service` — proxy to the module service (auto-refreshed each `beforeEach`)
117
359
  - `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
@@ -125,14 +367,12 @@ Each `it` block gets: schema drop + recreate → fresh module boot → test runs
125
367
  ### CRUD test patterns
126
368
 
127
369
  ```typescript
128
- // --- Create (single object → single result, array → array result) ---
370
+ // --- Create ---
129
371
  const [post] = await service.createPosts([{ title: "Test" }])
130
372
 
131
- // --- List ---
132
- const posts = await service.listPosts()
373
+ // --- List with filters ---
133
374
  const filtered = await service.listPosts({ status: "published" })
134
375
  const withRelations = await service.listPosts({}, { relations: ["comments"] })
135
- const withSelect = await service.listPosts({}, { select: ["id", "title"] })
136
376
 
137
377
  // --- List and count ---
138
378
  const [posts, count] = await service.listAndCountPosts()
@@ -140,115 +380,38 @@ expect(count).toEqual(2)
140
380
 
141
381
  // --- Retrieve ---
142
382
  const post = await service.retrievePost(id)
143
- const withSelect = await service.retrievePost(id, { select: ["id"] })
144
- const serialized = JSON.parse(JSON.stringify(withSelect))
145
- expect(serialized).toEqual({ id }) // verifies no extra fields
146
383
 
147
- // --- Update (single or batch) ---
384
+ // --- Update ---
148
385
  const updated = await service.updatePosts(id, { title: "New Title" })
149
- const batchUpdated = await service.updatePosts([{ id, title: "New" }])
150
386
 
151
387
  // --- Soft delete / restore ---
152
388
  await service.softDeletePosts([id])
153
389
  const listed = await service.listPosts({ id })
154
390
  expect(listed).toHaveLength(0)
155
- const withDeleted = await service.listPosts({ id }, { withDeleted: true })
156
- expect(withDeleted[0].deleted_at).toBeDefined()
157
391
  await service.restorePosts([id])
158
392
 
159
393
  // --- Hard delete ---
160
394
  await service.deletePosts([id])
161
- const remaining = await service.listPosts({ id: [id] })
162
- expect(remaining).toHaveLength(0)
163
395
  ```
164
396
 
165
397
  ### Error handling in module tests
166
398
 
167
399
  ```typescript
168
- // Style 1: .catch((e) => e) — preferred when checking message
169
- const error = await service.retrievePost("nonexistent").catch((e) => e)
400
+ // Style 1: .catch((e: any) => e) — preferred when checking message
401
+ const error = await service.retrievePost("nonexistent").catch((e: any) => e)
170
402
  expect(error.message).toEqual("Post with id: nonexistent was not found")
171
403
 
172
404
  // Style 2: rejects.toThrow() — when only checking it throws
173
405
  await expect(service.createPosts([{}])).rejects.toThrow()
174
-
175
- // Style 3: try/catch — when checking error.type or multiple fields
176
- let error
177
- try {
178
- await service.deleteApiKeys([unrevokedKey.id])
179
- } catch (e) {
180
- error = e
181
- }
182
- expect(error.type).toEqual("not_allowed")
183
- expect(error.message).toContain("Cannot delete api keys that are not revoked")
184
- ```
185
-
186
- ### Related entity testing
187
-
188
- ```typescript
189
- // Create parent → create child with parent ID → fetch with relations
190
- const [category] = await service.createCategories([{ name: "Tech" }])
191
- const [post] = await service.createPosts([{ title: "Test", category_id: category.id }])
192
-
193
- const withRelation = await service.retrievePost(post.id, {
194
- relations: ["category"],
195
- })
196
- expect(withRelation.category.name).toBe("Tech")
197
406
  ```
198
407
 
199
408
  ---
200
409
 
201
- ## HTTP Integration Tests
202
-
203
- **IMPORTANT:** `api` is a standard axios instance. Axios throws on non-2xx status codes. For error-case tests, use `.catch((e) => e)` to capture the error response.
204
-
205
- ### `acmekitIntegrationTestRunner` Options
206
-
207
- | Option | Type | Default | Description |
208
- |---|---|---|---|
209
- | `testSuite` | `(options) => void` | **(required)** | Callback containing `describe`/`it` blocks |
210
- | `moduleName` | `string` | `ulid()` | Derives the test DB name |
211
- | `dbName` | `string` | from `moduleName` | Overrides the computed DB name |
212
- | `acmekitConfigFile` | `string` | `process.cwd()` | Path to directory with `acmekit-config.ts` |
213
- | `schema` | `string` | `"public"` | Postgres schema |
214
- | `env` | `Record<string, any>` | `{}` | Values written to `process.env` before app starts |
215
- | `debug` | `boolean` | `false` | Enables DB query logging |
216
- | `hooks` | `{ beforeServerStart? }` | `{}` | Called with container before HTTP server starts |
217
- | `cwd` | `string` | from `acmekitConfigFile` | Working directory for config resolution |
218
- | `disableAutoTeardown` | `boolean` | `false` | Skips `dbUtils.teardown()` in `afterEach` |
219
-
220
- **Do NOT use options not listed above.** `inApp` is accepted but has no effect.
221
-
222
- ### `AcmeKitSuiteOptions` fields
223
-
224
- - `api` — axios instance pointed at `http://localhost:<port>` (random port per run)
225
- - `getContainer()` — returns the live `AcmeKitContainer`
226
- - `dbConnection` — proxy to the knex connection
227
- - `dbUtils` — `{ create, teardown, shutdown }` for manual DB control
228
- - `dbConfig` — `{ dbName, schema, clientUrl }`
229
- - `getAcmeKitApp()` — returns the running `AcmeKitApp`
230
- - `utils.waitWorkflowExecutions()` — polls until all in-flight workflows complete (60s timeout)
231
-
232
- ### HTTP test lifecycle
233
-
234
- - `beforeAll`: boots full Express app (resolves plugins, runs migrations, starts HTTP server)
235
- - `beforeEach`: truncates all tables, re-runs module loaders, runs `createDefaultsWorkflow`
236
- - `afterEach`: **automatically calls `waitWorkflowExecutions()`** then `dbUtils.teardown()`
237
- - `afterAll`: drops DB, shuts down Express
238
-
239
- **IMPORTANT:** The runner calls `waitWorkflowExecutions()` automatically in `afterEach`. You do NOT need to call it in your tests unless you need workflow results BETWEEN two API calls in the same test.
240
-
241
- ### Admin Auth Setup
410
+ ## Auth Setup
242
411
 
243
- `createAdminUser` resolves `Modules.USER` and `Modules.AUTH` from the container, creates a user, hashes a password, generates a JWT, and **mutates `adminHeaders` by reference** adding `authorization: Bearer <jwt>` to `adminHeaders.headers`.
412
+ **MANDATORY for `/admin/*` routes** every HTTP test MUST have a `beforeEach` that creates admin credentials. Without it, admin routes return 401.
244
413
 
245
- ```typescript
246
- import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user"
247
- ```
248
-
249
- `adminHeaders` starts as `{ headers: { "x-acmekit-access-token": "test_token" } }`. After `createAdminUser` runs, it also has `authorization: Bearer <jwt>`.
250
-
251
- `createAdminUser` returns `{ user, authIdentity }` — capture these when you need user IDs in tests.
414
+ **MANDATORY for `/client/*` routes** — every HTTP test MUST also create a client API key. Without it, client routes return 400.
252
415
 
253
416
  ### JWT Token Generation
254
417
 
@@ -261,14 +424,13 @@ import {
261
424
  Modules,
262
425
  } from "@acmekit/framework/utils"
263
426
 
264
- // Resolve the JWT secret from the project config — NEVER hardcode "supersecret"
265
427
  const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
266
428
  const { jwtSecret, jwtOptions } = config.projectConfig.http
267
429
 
268
430
  const token = generateJwtToken(
269
431
  {
270
432
  actor_id: user.id,
271
- actor_type: "user", // or "customer" for customer auth
433
+ actor_type: "user",
272
434
  auth_identity_id: authIdentity.id,
273
435
  app_metadata: { user_id: user.id },
274
436
  },
@@ -291,7 +453,7 @@ const apiKeyModule = container.resolve(Modules.API_KEY)
291
453
  const apiKey = await apiKeyModule.createApiKeys({
292
454
  title: "Test Client Key",
293
455
  type: ApiKeyType.CLIENT,
294
- created_by: "system",
456
+ created_by: "test",
295
457
  })
296
458
 
297
459
  const clientHeaders = {
@@ -299,485 +461,490 @@ const clientHeaders = {
299
461
  }
300
462
  ```
301
463
 
302
- ### Service Resolution
464
+ ---
303
465
 
304
- **ALWAYS use `Modules.*` constants** to resolve core services. NEVER use string literals like `"auth"`, `"user"`, `"customer"`.
466
+ ## Error Handling in HTTP Tests
305
467
 
306
468
  ```typescript
307
- // RIGHT
308
- const authModule = container.resolve(Modules.AUTH)
309
- const userModule = container.resolve(Modules.USER)
310
- const apiKeyModule = container.resolve(Modules.API_KEY)
469
+ // 400 — validation error (axios throws on non-2xx)
470
+ const { response } = await api
471
+ .post("/admin/posts", {}, adminHeaders)
472
+ .catch((e: any) => e)
473
+ expect(response.status).toEqual(400)
474
+
475
+ // 404 — not found (also check type and message)
476
+ const { response } = await api
477
+ .get("/admin/posts/invalid-id", adminHeaders)
478
+ .catch((e: any) => e)
479
+ expect(response.status).toEqual(404)
480
+ expect(response.data.type).toEqual("not_found")
311
481
 
312
- // WRONGstring literals are fragile and may not match container keys
313
- const authModule = container.resolve("auth") //
314
- const userModule = container.resolve("user") // ❌
482
+ // 401unauthorized
483
+ const error = await api.get("/admin/posts").catch((e: any) => e)
484
+ expect(error.response.status).toEqual(401)
315
485
  ```
316
486
 
317
- ### Admin Route Tests (`/admin/*`)
487
+ ---
318
488
 
319
- **MANDATORY:** Every `/admin/*` route test MUST have `beforeEach` with `createAdminUser`. Without it, admin routes return 401.
489
+ ## Response Shape Reference
320
490
 
321
491
  ```typescript
322
- import { acmekitIntegrationTestRunner } from "@acmekit/test-utils"
323
- import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user"
492
+ // Single resource nested under singular key
493
+ expect(response.data.post).toEqual(
494
+ expect.objectContaining({
495
+ id: expect.any(String),
496
+ title: "Launch Announcement",
497
+ created_at: expect.any(String),
498
+ updated_at: expect.any(String),
499
+ })
500
+ )
324
501
 
325
- jest.setTimeout(50000)
502
+ // List resource — nested under plural key with pagination
503
+ expect(response.data).toEqual(
504
+ expect.objectContaining({
505
+ posts: expect.arrayContaining([
506
+ expect.objectContaining({ title: "Post A" }),
507
+ ]),
508
+ })
509
+ )
326
510
 
327
- acmekitIntegrationTestRunner({
328
- testSuite: ({ api, getContainer, dbConnection }) => {
329
- let user: any
511
+ // Delete response — exact match
512
+ expect(response.data).toEqual({
513
+ id: created.id,
514
+ object: "post",
515
+ deleted: true,
516
+ })
517
+ ```
330
518
 
331
- beforeEach(async () => {
332
- const result = await createAdminUser(
333
- dbConnection,
334
- adminHeaders,
335
- getContainer()
336
- )
337
- user = result.user
338
- })
519
+ ---
339
520
 
340
- describe("GET /admin/posts", () => {
341
- it("should list posts", async () => {
342
- const response = await api.get("/admin/posts", adminHeaders)
343
- expect(response.status).toEqual(200)
344
- expect(response.data).toEqual({
345
- count: 0,
346
- limit: 20,
347
- offset: 0,
348
- posts: [],
349
- })
350
- })
351
- })
521
+ ## Asserting Domain Events
352
522
 
353
- describe("POST /admin/posts", () => {
354
- it("should create a post", async () => {
355
- const response = await api.post(
356
- "/admin/posts",
357
- { title: "Launch Announcement", body: "We are live." },
358
- adminHeaders
359
- )
360
- expect(response.status).toEqual(200)
361
- expect(response.data.post).toEqual(
362
- expect.objectContaining({
363
- id: expect.any(String),
364
- title: "Launch Announcement",
365
- created_at: expect.any(String),
366
- updated_at: expect.any(String),
367
- })
368
- )
369
- })
523
+ Both runners inject `MockEventBusService` under `Modules.EVENT_BUS` in module mode. Spy on the **prototype**, not an instance.
370
524
 
371
- it("should reject invalid body with 400", async () => {
372
- const { response } = await api
373
- .post("/admin/posts", {}, adminHeaders)
374
- .catch((e) => e)
375
- expect(response.status).toEqual(400)
376
- })
525
+ ```typescript
526
+ import { MockEventBusService } from "@acmekit/test-utils"
377
527
 
378
- it("should reject unknown fields when .strict()", async () => {
379
- const { response } = await api
380
- .post(
381
- "/admin/posts",
382
- { title: "Test", unknown_field: "bad" },
383
- adminHeaders
384
- )
385
- .catch((e) => e)
386
- expect(response.status).toEqual(400)
387
- })
388
- })
528
+ let eventBusSpy: jest.SpyInstance
389
529
 
390
- describe("DELETE /admin/posts/:id", () => {
391
- it("should delete and return confirmation", async () => {
392
- const created = (
393
- await api.post(
394
- "/admin/posts",
395
- { title: "To Remove" },
396
- adminHeaders
397
- )
398
- ).data.post
530
+ beforeEach(() => {
531
+ eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
532
+ })
399
533
 
400
- const response = await api.delete(
401
- `/admin/posts/${created.id}`,
402
- adminHeaders
403
- )
404
- expect(response.status).toEqual(200)
405
- expect(response.data).toEqual({
406
- id: created.id,
407
- object: "post",
408
- deleted: true,
409
- })
410
- })
534
+ afterEach(() => {
535
+ eventBusSpy.mockClear()
536
+ })
411
537
 
412
- it("should return 404 for non-existent post", async () => {
413
- const { response } = await api
414
- .delete("/admin/posts/non-existent-id", adminHeaders)
415
- .catch((e) => e)
416
- expect(response.status).toEqual(404)
417
- expect(response.data.type).toEqual("not_found")
418
- expect(response.data.message).toContain("not found")
419
- })
420
- })
421
- },
538
+ it("should emit post.created event", async () => {
539
+ await service.createPosts([{ title: "Event Test" }])
540
+
541
+ const events = eventBusSpy.mock.calls[0][0]
542
+ expect(events).toEqual(
543
+ expect.arrayContaining([
544
+ expect.objectContaining({
545
+ name: "post.created",
546
+ data: expect.objectContaining({ id: expect.any(String) }),
547
+ }),
548
+ ])
549
+ )
422
550
  })
423
551
  ```
424
552
 
425
- ### Client Route Tests (`/client/*`)
553
+ ---
426
554
 
427
- **MANDATORY:** Every `/client/*` route test MUST have this `beforeEach` setup. Without `clientHeaders`, client route requests return 400 (`NOT_ALLOWED`).
555
+ ## Waiting for Subscribers
428
556
 
429
- ```typescript
430
- import { acmekitIntegrationTestRunner } from "@acmekit/test-utils"
431
- import {
432
- adminHeaders,
433
- createAdminUser,
434
- generateClientKey,
435
- generateClientHeaders,
436
- } from "../../helpers/create-admin-user"
557
+ Use `TestEventUtils.waitSubscribersExecution` when testing subscriber side-effects. **CRITICAL: create the promise BEFORE triggering the event.**
437
558
 
438
- jest.setTimeout(50000)
559
+ ### Pattern 1: Event bus driven (app mode with real event bus)
439
560
 
440
- acmekitIntegrationTestRunner({
441
- testSuite: ({ api, getContainer, dbConnection }) => {
442
- let clientHeaders: Record<string, any>
561
+ ```typescript
562
+ import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
563
+ import { Modules } from "@acmekit/framework/utils"
564
+ import { BLOG_MODULE } from "../../src/modules/blog"
443
565
 
444
- beforeEach(async () => {
566
+ integrationTestRunner({
567
+ mode: "app",
568
+ testSuite: ({ getContainer }) => {
569
+ it("should execute subscriber side-effect", async () => {
445
570
  const container = getContainer()
446
- await createAdminUser(dbConnection, adminHeaders, container)
571
+ const service: any = container.resolve(BLOG_MODULE)
572
+ const eventBus = container.resolve(Modules.EVENT_BUS)
447
573
 
448
- const clientKey = await generateClientKey(container)
449
- clientHeaders = generateClientHeaders({ publishableKey: clientKey })
574
+ const [blog] = await service.createBlogs([
575
+ { title: "Test", content: "Original", status: "published" },
576
+ ])
450
577
 
451
- // Seed test data via admin API if needed
452
- await api.post("/admin/products", { title: "Widget" }, adminHeaders)
453
- })
578
+ // Create promise BEFORE emitting event
579
+ const subscriberDone = TestEventUtils.waitSubscribersExecution(
580
+ "blog.published",
581
+ eventBus
582
+ )
583
+
584
+ // Emit event — single object { name, data } format
585
+ await eventBus.emit({ name: "blog.published", data: { id: blog.id } })
586
+ await subscriberDone
454
587
 
455
- it("should list products", async () => {
456
- const response = await api.get("/client/products", clientHeaders)
457
- expect(response.status).toEqual(200)
458
- expect(response.data.products).toHaveLength(1)
588
+ // Verify subscriber side-effect
589
+ const updated = await service.retrieveBlog(blog.id)
590
+ expect(updated.content).toBe("Original [notified]")
459
591
  })
460
592
  },
461
593
  })
462
594
  ```
463
595
 
464
- If the public route needs no client key (rare), you can skip `generateClientKey`/`generateClientHeaders` — but most tests need them.
596
+ ---
597
+
598
+ ## Testing Jobs
465
599
 
466
- ### Feature flags via `env` option
600
+ Import the job function directly and call it with the container:
467
601
 
468
602
  ```typescript
469
- acmekitIntegrationTestRunner({
470
- env: { ACMEKIT_FF_RBAC: true },
471
- testSuite: ({ api, getContainer, dbConnection }) => { ... },
472
- })
473
- ```
603
+ import { integrationTestRunner } from "@acmekit/test-utils"
604
+ import archiveOldBlogsJob from "../../src/jobs/archive-old-blogs"
605
+ import { BLOG_MODULE } from "../../src/modules/blog"
474
606
 
475
- ---
607
+ jest.setTimeout(60 * 1000)
476
608
 
477
- ## Error Handling in HTTP Tests
609
+ integrationTestRunner({
610
+ mode: "app",
611
+ testSuite: ({ getContainer }) => {
612
+ it("should soft-delete archived blogs", async () => {
613
+ const container = getContainer()
614
+ const service: any = container.resolve(BLOG_MODULE)
478
615
 
479
- ```typescript
480
- // Error case destructure response from caught error
481
- const { response } = await api
482
- .post("/admin/posts", {}, adminHeaders)
483
- .catch((e) => e)
484
- expect(response.status).toEqual(400)
485
- expect(response.data.message).toContain("is required")
616
+ await service.createBlogs([
617
+ { title: "Archived 1", status: "archived" },
618
+ { title: "Active", status: "published" },
619
+ ])
486
620
 
487
- // 404 case — also check type field
488
- const { response } = await api
489
- .get("/admin/posts/invalid-id", adminHeaders)
490
- .catch((e) => e)
491
- expect(response.status).toEqual(404)
492
- expect(response.data.type).toEqual("not_found")
493
- expect(response.data.message).toEqual("Post with id: invalid-id not found")
621
+ await archiveOldBlogsJob(container)
494
622
 
495
- // Alternative: capture full error for .message check (Axios error message)
496
- const error = await api
497
- .post("/admin/posts", {}, { headers: {} })
498
- .catch((e) => e)
499
- expect(error.response.status).toEqual(401)
623
+ const remaining = await service.listBlogs()
624
+ expect(remaining).toHaveLength(1)
625
+ expect(remaining[0].title).toBe("Active")
626
+ })
627
+ },
628
+ })
500
629
  ```
501
630
 
502
631
  ---
503
632
 
504
- ## Response Shape Reference
633
+ ## Workflow Testing
634
+
635
+ ### Direct execution (no HTTP)
505
636
 
506
637
  ```typescript
507
- // Single resource nested under singular key
508
- expect(response.data.post).toEqual(
509
- expect.objectContaining({
510
- id: expect.any(String),
511
- title: "Launch Announcement",
512
- created_at: expect.any(String),
513
- updated_at: expect.any(String),
514
- })
515
- )
638
+ import { createPostWorkflow } from "../../src/workflows/workflows"
516
639
 
517
- // List resource — nested under plural key with pagination
518
- expect(response.data).toEqual({
519
- count: 2,
520
- limit: 20,
521
- offset: 0,
522
- posts: expect.arrayContaining([
523
- expect.objectContaining({ title: "Post A" }),
524
- expect.objectContaining({ title: "Post B" }),
525
- ]),
526
- })
640
+ integrationTestRunner({
641
+ mode: "app",
642
+ testSuite: ({ getContainer }) => {
643
+ it("should execute the workflow", async () => {
644
+ const { result } = await createPostWorkflow(getContainer()).run({
645
+ input: { title: "Launch Announcement" },
646
+ })
647
+ expect(result).toEqual(
648
+ expect.objectContaining({
649
+ id: expect.any(String),
650
+ title: "Launch Announcement",
651
+ })
652
+ )
653
+ })
527
654
 
528
- // Delete response exact match, no objectContaining
529
- expect(response.data).toEqual({
530
- id: created.id,
531
- object: "post",
532
- deleted: true,
655
+ it("should reject invalid input", async () => {
656
+ const { errors } = await createPostWorkflow(getContainer()).run({
657
+ input: {},
658
+ throwOnError: false,
659
+ })
660
+ expect(errors).toHaveLength(1)
661
+ expect(errors[0].error.message).toContain("title")
662
+ })
663
+
664
+ // Workflow engine serializes errors as plain objects —
665
+ // use .rejects.toEqual(), NOT .rejects.toThrow()
666
+ it("should throw by default on error", async () => {
667
+ await expect(
668
+ updatePostWorkflow(getContainer()).run({
669
+ input: { id: "nonexistent", title: "Nope" },
670
+ })
671
+ ).rejects.toEqual(
672
+ expect.objectContaining({
673
+ message: expect.stringContaining("not found"),
674
+ })
675
+ )
676
+ })
677
+ },
533
678
  })
679
+ ```
534
680
 
535
- // Nested relations
536
- expect(response.data.post).toEqual(
537
- expect.objectContaining({
538
- comments: expect.arrayContaining([
539
- expect.objectContaining({ body: "Great post" }),
540
- ]),
541
- })
542
- )
681
+ ### Via HTTP (when route triggers the workflow)
543
682
 
544
- // Negative assertion — item NOT in list
545
- expect(response.data.posts).toEqual(
546
- expect.not.arrayContaining([
547
- expect.objectContaining({ status: "archived" }),
548
- ])
549
- )
683
+ ```typescript
684
+ it("should process via workflow", async () => {
685
+ await api.post("/admin/orders", { items: [...] }, adminHeaders)
686
+ // Only needed if next request depends on workflow completion
687
+ await utils.waitWorkflowExecutions()
688
+ const response = await api.get("/admin/orders", adminHeaders)
689
+ expect(response.data.orders[0].status).toBe("processed")
690
+ })
550
691
  ```
551
692
 
552
693
  ---
553
694
 
554
- ## Query Parameter Testing
695
+ ## Service Resolution
555
696
 
556
697
  ```typescript
557
- // Field selection`*` prefix expands relations
558
- api.get(`/admin/posts/${id}?fields=*comments`, adminHeaders)
698
+ // Custom modulesuse module constant (matches the string passed to Module())
699
+ import { BLOG_MODULE } from "../../src/modules/blog" // BLOG_MODULE = "blog"
700
+ const service = getContainer().resolve(BLOG_MODULE)
559
701
 
560
- // Pagination
561
- api.get("/admin/posts?limit=2&offset=1", adminHeaders)
702
+ // Core modules — use Modules.* constants
703
+ const userModule = getContainer().resolve(Modules.USER)
704
+ const authModule = getContainer().resolve(Modules.AUTH)
562
705
 
563
- // Ordering — `-` prefix for descending
564
- api.get("/admin/posts?order=-created_at", adminHeaders)
706
+ // Framework services
707
+ const query = getContainer().resolve(ContainerRegistrationKeys.QUERY)
565
708
 
566
- // Free-text search
567
- api.get("/admin/posts?q=quarterly", adminHeaders)
709
+ // WRONG — string literals are fragile
710
+ getContainer().resolve("auth") //
711
+ getContainer().resolve("blogModuleService") // ❌
712
+ ```
568
713
 
569
- // Array filters
570
- api.get(`/admin/posts?status[]=published`, adminHeaders)
571
- api.get(`/admin/posts?id[]=${id1},${id2}`, adminHeaders)
714
+ ---
572
715
 
573
- // Boolean filters
574
- api.get("/admin/posts?is_featured=true", adminHeaders)
716
+ ## Environment
575
717
 
576
- // With deleted
577
- api.get(`/admin/posts?with_deleted=true`, adminHeaders)
578
- ```
718
+ - `jest.config.js` loads `.env.test` via `@acmekit/utils` `loadEnv("test", process.cwd())`
719
+ - `integration-tests/setup.js` clears `MetadataStorage` between test files
720
+ - DB defaults: `DB_HOST=localhost`, `DB_USERNAME=postgres`, `DB_PASSWORD=""`, `DB_PORT=5432`
721
+ - Each test run creates a unique DB: `acmekit-<module>-integration-<JEST_WORKER_ID>`
579
722
 
580
723
  ---
581
724
 
582
- ## Asserting Domain Events
725
+ ## Unit Tests (No Framework Bootstrap)
583
726
 
584
- Both runners inject `MockEventBusService` under `Modules.EVENT_BUS`. Spy on the **prototype**, not an instance.
727
+ For providers, utility functions, and standalone classes that don't need the database or AcmeKit container. Uses plain Jest — no `integrationTestRunner`.
585
728
 
586
- ```typescript
587
- import { MockEventBusService } from "@acmekit/test-utils"
729
+ **File naming:** `src/**/__tests__/<name>.unit.spec.ts` — matches `TEST_TYPE=unit` in `jest.config.js`.
588
730
 
589
- let eventBusSpy: jest.SpyInstance
731
+ ### jest.mock Hoisting (Temporal Dead Zone)
590
732
 
591
- beforeEach(() => {
592
- eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
593
- })
733
+ `jest.mock()` factories are **hoisted above all `const`/`let` declarations** by SWC/Babel. Referencing a file-level `const` inside a `jest.mock()` factory causes `ReferenceError: Cannot access before initialization`.
594
734
 
595
- afterEach(() => {
596
- eventBusSpy.mockClear()
735
+ ```typescript
736
+ // WRONG — TDZ error: mockSign is not yet initialized when factory runs
737
+ const mockSign = jest.fn()
738
+ jest.mock("tronweb", () => ({
739
+ TronWeb: jest.fn().mockImplementation(() => ({ trx: { sign: mockSign } })),
740
+ }))
741
+
742
+ // RIGHT — create mocks INSIDE the factory, expose via module return
743
+ jest.mock("tronweb", () => {
744
+ const mocks = {
745
+ sign: jest.fn(),
746
+ isAddress: jest.fn().mockReturnValue(true),
747
+ }
748
+ const MockTronWeb = jest.fn().mockImplementation(() => ({
749
+ trx: { sign: mocks.sign },
750
+ }))
751
+ MockTronWeb.isAddress = mocks.isAddress
752
+ return { TronWeb: MockTronWeb, __mocks: mocks }
597
753
  })
598
754
 
599
- it("should emit post.created event", async () => {
600
- await service.createPosts([{ title: "Event Test" }])
755
+ // Access mocks after jest.mock via require()
756
+ const { __mocks: tronMocks } = require("tronweb")
757
+ ```
601
758
 
602
- // Direct access to mock.calls for precise assertions
603
- const events = eventBusSpy.mock.calls[0][0] // first call, first arg = events array
604
- expect(events).toHaveLength(1)
605
- expect(events).toEqual(
606
- expect.arrayContaining([
607
- expect.objectContaining({
608
- name: "post.created",
609
- data: expect.objectContaining({ id: expect.any(String) }),
610
- }),
611
- ])
612
- )
759
+ **Rule:** All mock state must live INSIDE the `jest.mock()` factory or be accessed via `require()` after the mock is set up. Never reference file-level `const`/`let` from inside a `jest.mock()` factory.
760
+
761
+ ### Provider Unit Test Pattern
762
+
763
+ Providers have a specific structure: constructor receives `(container, options)`, static `identifier`, optional static `validateOptions`. Test each part:
613
764
 
614
- // Second argument is always { internal: true }
615
- expect(eventBusSpy.mock.calls[0][1]).toEqual({ internal: true })
765
+ ```typescript
766
+ jest.mock("external-sdk", () => {
767
+ const mocks = {
768
+ doThing: jest.fn(),
769
+ }
770
+ const MockClient = jest.fn().mockImplementation(() => ({
771
+ doThing: mocks.doThing,
772
+ }))
773
+ return { Client: MockClient, __mocks: mocks }
616
774
  })
617
- ```
618
775
 
619
- ---
776
+ const { __mocks: sdkMocks } = require("external-sdk")
620
777
 
621
- ## Waiting for Async Workflows
778
+ import MyProvider from "../my-provider"
622
779
 
623
- The runner calls `waitWorkflowExecutions()` automatically in `afterEach`. Only call it explicitly when you need workflow results **between** two API calls in the same test:
780
+ describe("MyProvider", () => {
781
+ let provider: MyProvider
624
782
 
625
- ```typescript
626
- it("should process order via workflow", async () => {
627
- await api.post("/admin/orders", { items: [...] }, adminHeaders)
783
+ const mockContainer = {} as any
784
+ const defaultOptions = { apiKey: "test-key" }
628
785
 
629
- // ONLY needed because the next request depends on workflow completion
630
- await utils.waitWorkflowExecutions()
786
+ beforeEach(() => {
787
+ jest.clearAllMocks()
788
+ provider = new MyProvider(mockContainer, defaultOptions)
789
+ })
631
790
 
632
- const response = await api.get("/admin/orders", adminHeaders)
633
- expect(response.data.orders[0].status).toBe("processed")
791
+ describe("static identifier", () => {
792
+ it("should have correct identifier", () => {
793
+ expect(MyProvider.identifier).toBe("my-provider")
794
+ })
795
+ })
796
+
797
+ describe("validateOptions", () => {
798
+ it("should accept valid options", () => {
799
+ expect(() =>
800
+ MyProvider.validateOptions({ apiKey: "key" })
801
+ ).not.toThrow()
802
+ })
803
+
804
+ it("should reject missing required option", () => {
805
+ expect(() => MyProvider.validateOptions({})).toThrow()
806
+ })
807
+ })
808
+
809
+ describe("doSomething", () => {
810
+ it("should delegate to SDK", async () => {
811
+ sdkMocks.doThing.mockResolvedValue({ success: true })
812
+ const result = await provider.doSomething({ input: "test" })
813
+ expect(result.success).toBe(true)
814
+ expect(sdkMocks.doThing).toHaveBeenCalledWith(
815
+ expect.objectContaining({ input: "test" })
816
+ )
817
+ })
818
+ })
634
819
  })
635
820
  ```
636
821
 
637
- ---
822
+ ### Mock Cleanup Between Tests
638
823
 
639
- ## Waiting for Subscribers
824
+ Mock state leaks between `describe` and `it` blocks. **Always add cleanup:**
640
825
 
641
- Use `TestEventUtils.waitSubscribersExecution` when testing subscriber side-effects. **CRITICAL: create the promise BEFORE triggering the event.**
826
+ ```typescript
827
+ // Recommended: file-level cleanup
828
+ beforeEach(() => {
829
+ jest.clearAllMocks()
830
+ })
831
+
832
+ // Alternative: per-describe when different describes need different setups
833
+ describe("feature A", () => {
834
+ beforeEach(() => {
835
+ jest.clearAllMocks()
836
+ mockFn.mockResolvedValue("A result")
837
+ })
838
+ })
839
+ ```
840
+
841
+ Without `jest.clearAllMocks()`, a mock called in one test still shows those calls in the next test:
642
842
 
643
843
  ```typescript
644
- import { TestEventUtils } from "@acmekit/test-utils"
844
+ expect(mockSign).not.toHaveBeenCalled() // FAILS called by prior test
845
+ ```
645
846
 
646
- it("should handle the event", async () => {
647
- const eventBus = getContainer().resolve(Modules.EVENT_BUS)
847
+ ### Testing Code with Timers
648
848
 
649
- // Create promise BEFORE triggering event
650
- const subscriberExecution = TestEventUtils.waitSubscribersExecution(
651
- "post.created",
652
- eventBus
653
- )
654
- await api.post("/admin/posts", { title: "Trigger" }, adminHeaders)
655
- await subscriberExecution
656
- // now assert subscriber side-effect
849
+ When code under test calls `setTimeout`, `setInterval`, or a `sleep()` function, tests time out or run slowly.
850
+
851
+ ```typescript
852
+ // Option 1: Fake timers (for setTimeout/setInterval)
853
+ beforeEach(() => {
854
+ jest.useFakeTimers()
855
+ })
856
+ afterEach(() => {
857
+ jest.useRealTimers()
858
+ })
859
+ it("should retry after delay", async () => {
860
+ const promise = provider.retryOperation()
861
+ await jest.advanceTimersByTimeAsync(3000)
862
+ const result = await promise
863
+ expect(result).toBeDefined()
864
+ })
865
+
866
+ // Option 2: Mock the sleep method (for custom sleep/delay functions)
867
+ it("should complete without waiting", async () => {
868
+ jest.spyOn(provider as any, "sleep_").mockResolvedValue(undefined)
869
+ const result = await provider.longRunningOperation()
870
+ expect(result).toBeDefined()
657
871
  })
658
872
  ```
659
873
 
660
- ---
874
+ ### SWC Regex Limitation
661
875
 
662
- ## Resolving Services in Tests
876
+ SWC's regex parser fails on certain complex regex literals. If you get a `Syntax Error` from SWC on a line with a regex:
663
877
 
664
878
  ```typescript
665
- // In acmekitIntegrationTestRunner use module constant (matches Module() key)
666
- import { BLOG_MODULE } from "../../src/modules/blog" // BLOG_MODULE = "blog"
667
- const blogService = getContainer().resolve(BLOG_MODULE)
668
- const query = getContainer().resolve(ContainerRegistrationKeys.QUERY)
879
+ // WRONGSWC may fail to parse this
880
+ const pattern = /^(\*|[0-9]+)(\/[0-9]+)?$|^\*\/[0-9]+$/
669
881
 
670
- // Core modules — use Modules.* constants
671
- const userModule = getContainer().resolve(Modules.USER)
672
- const authModule = getContainer().resolve(Modules.AUTH)
882
+ // RIGHT — use RegExp constructor
883
+ const pattern = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$|^\\*\\/[0-9]+$")
884
+ ```
673
885
 
674
- // Resolve in beforeAll for reuse (container proxy stays current)
675
- let container: AcmeKitContainer
676
- beforeAll(() => {
677
- container = getContainer()
678
- })
886
+ ### Verifying Error Paths
679
887
 
680
- // In moduleIntegrationTestRunner use the `service` fixture directly
681
- const result = await service.listPosts()
888
+ When testing error cases, **read the implementation** to determine whether the method throws or returns an error object. Don't assume from the return type alone.
682
889
 
683
- // NEVER guess the key — "blogModuleService", "postModuleService" are all WRONG
684
- // The key is EXACTLY the string passed to Module() in your module definition
685
- ```
890
+ ```typescript
891
+ // If the method catches errors and returns { success: false }:
892
+ const result = await provider.process(badInput)
893
+ expect(result.success).toBe(false)
686
894
 
687
- ---
895
+ // If the method throws (no internal try/catch on that path):
896
+ await expect(provider.process(badInput)).rejects.toThrow("invalid")
688
897
 
689
- ## Environment
898
+ // If validation runs BEFORE a try/catch block:
899
+ // validateInput() throws → not caught by the try/catch in process()
900
+ await expect(provider.process(badInput)).rejects.toThrow("validation")
901
+ ```
690
902
 
691
- - `jest.config.js` loads `.env.test` via `@acmekit/utils` `loadEnv("test", process.cwd())`
692
- - `integration-tests/setup.js` clears `MetadataStorage` between test files (prevents MikroORM entity bleed)
693
- - DB defaults: `DB_HOST=localhost`, `DB_USERNAME=postgres`, `DB_PASSWORD=""`, `DB_PORT=5432`
694
- - Each test run creates a unique DB: `acmekit-<module>-integration-<JEST_WORKER_ID>`
903
+ **Tip:** Look for `try/catch` blocks in the implementation. Code that runs BEFORE or OUTSIDE a `try/catch` throws directly. Code INSIDE a `try/catch` may return an error result instead.
695
904
 
696
905
  ---
697
906
 
698
907
  ## Anti-Patterns — NEVER Do These
699
908
 
700
909
  ```typescript
701
- // WRONG — no beforeEach in HTTP tests
702
- acmekitIntegrationTestRunner({
703
- testSuite: ({ api }) => {
704
- it("should list", async () => {
705
- await api.get("/admin/posts") // 401 no createAdminUser, no adminHeaders
706
- })
707
- },
910
+ // WRONG — using deprecated runner names
911
+ import { acmekitIntegrationTestRunner } from "@acmekit/test-utils" // ❌
912
+ import { moduleIntegrationTestRunner } from "@acmekit/test-utils" // ❌
913
+ // RIGHT use unified runner with mode
914
+ import { integrationTestRunner } from "@acmekit/test-utils"
915
+ integrationTestRunner({ mode: "app", testSuite: ... })
916
+ integrationTestRunner({ mode: "module", moduleName: "post", ... })
917
+
918
+ // WRONG — using createAdminUser helper (does not exist)
919
+ import { createAdminUser } from "../../helpers/create-admin-user" // ❌
920
+ // RIGHT — inline auth setup in beforeEach (see Auth Setup section)
921
+
922
+ // WRONG — no auth in HTTP tests
923
+ it("should list", async () => {
924
+ await api.get("/admin/posts") // ❌ 401 — no authorization header
708
925
  })
709
926
 
710
927
  // WRONG — client route without clientHeaders
711
928
  await api.get("/client/products") // ❌ 400 — no client API key
712
- await api.post("/client/orders", body) // ❌ same
713
- // RIGHT
714
- await api.get("/client/products", clientHeaders)
715
- await api.post("/client/orders", body, clientHeaders)
716
-
717
- // WRONG — forgetting adminHeaders on admin routes
718
- await api.get("/admin/posts") // ❌ 401
719
- // RIGHT
720
- await api.get("/admin/posts", adminHeaders)
721
929
 
722
- // WRONG — asserting error responses without catching (axios throws on 4xx/5xx!)
930
+ // WRONG — asserting error responses without catching (axios throws!)
723
931
  const response = await api.post("/admin/posts", {}, adminHeaders)
724
932
  expect(response.status).toEqual(400) // ❌ never reached — axios threw
725
933
  // RIGHT
726
- const { response } = await api.post("/admin/posts", {}, adminHeaders).catch((e) => e)
727
- expect(response.status).toEqual(400)
728
-
729
- // WRONG — mixing success/error response access
730
- const result = await api.post("/admin/posts", body).catch((e) => e)
731
- const status = result.status ?? result.response?.status // ❌ confused pattern
732
- // RIGHT — success: result.status / result.data; error: result.response.status / result.response.data
733
-
734
- // WRONG — silently passes without asserting
735
- const result = await api.post("/client/execute", body).catch((e) => e)
736
- if (result.status !== 200) return // ❌ test passes with zero assertions!
737
-
738
- // WRONG — vague range hides which error actually occurred
739
- expect(response.status).toBeGreaterThanOrEqual(400) // ❌
740
- // RIGHT
934
+ const { response } = await api.post("/admin/posts", {}, adminHeaders).catch((e: any) => e)
741
935
  expect(response.status).toEqual(400)
742
936
 
743
- // WRONG — using non-standard Jest matchers (not available without jest-extended)
744
- expect(value).toBeOneOf([expect.any(String), null]) // ❌
745
- expect(value).toSatisfy(fn) // ❌
746
- // RIGHT
747
- expect(value === null || typeof value === "string").toBe(true)
748
-
749
- // WRONG — typeof checks on response fields
750
- expect(typeof result.data.id).toBe("string") // ❌
751
- // RIGHT
752
- expect(result.data.id).toEqual(expect.any(String))
753
-
754
- // WRONG — JSDoc comment block at file top (test files never have these)
755
- /** POST /admin/posts — validates body, creates post */ // ❌
756
-
757
- // WRONG — type casts in tests
758
- const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
759
-
760
- // WRONG — wrapping body in { body: ... }
761
- await api.post("/admin/posts", { body: { title: "Test" } }) // ❌
762
- // RIGHT
763
- await api.post("/admin/posts", { title: "Test" }, adminHeaders)
764
-
765
937
  // WRONG — calling waitWorkflowExecutions in every test
766
938
  await api.post("/admin/posts", body, adminHeaders)
767
939
  await utils.waitWorkflowExecutions() // ❌ unnecessary — runner does this in afterEach
768
- expect(response.data.post.id).toBeDefined()
769
- // RIGHT — only call it when the NEXT request in the same test needs workflow results
770
940
 
771
941
  // WRONG — calling waitSubscribersExecution AFTER triggering event
772
- await api.post("/admin/posts", body, adminHeaders)
942
+ await eventBus.emit({ name: "post.created", data: { id } })
773
943
  await TestEventUtils.waitSubscribersExecution("post.created", eventBus) // ❌ may miss it
774
944
  // RIGHT — capture promise BEFORE triggering
775
- const execution = TestEventUtils.waitSubscribersExecution("post.created", eventBus)
776
- await api.post("/admin/posts", body, adminHeaders)
777
- await execution
778
-
779
- // WRONG — using `inApp: true` (accepted but no-op)
780
- acmekitIntegrationTestRunner({ inApp: true, testSuite: ... }) // ❌
945
+ const done = TestEventUtils.waitSubscribersExecution("post.created", eventBus)
946
+ await eventBus.emit({ name: "post.created", data: { id } })
947
+ await done
781
948
 
782
949
  // WRONG — asserting exact objects (timestamps/IDs change)
783
950
  expect(result).toEqual({ id: "123", title: "Test", created_at: "2024-01-01" }) // ❌
@@ -787,44 +954,27 @@ expect(result).toEqual(expect.objectContaining({
787
954
  title: "Test",
788
955
  }))
789
956
 
790
- // WRONG — passing module object to resolve (type error: expects string)
791
- import MyModule from "../index"
792
- moduleIntegrationTestRunner({ resolve: MyModule, ... }) //
793
-
794
- // WRONG — relative path from test file (model discovery uses CWD, not test file dir!)
795
- moduleIntegrationTestRunner({ resolve: "../index", ... }) // ❌ hangs — models not found
796
- // RIGHT — path from project root (CWD)
797
- moduleIntegrationTestRunner({ resolve: "./src/modules/my-module", ... })
798
-
799
- // WRONG — unused imports
800
- import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if never used
801
- // RIGHT — only import what you use
802
-
803
- // WRONG — guessing service resolution key (AwilixResolutionError!)
804
- getContainer().resolve("myModuleService") // ❌ wrong key — no such registration
805
- getContainer().resolve("my-module-service") // ❌ also wrong
806
- // RIGHT — use module constant (matches the string passed to Module())
807
- import { MY_MODULE } from "../../src/modules/my-module" // MY_MODULE = "my-module"
808
- getContainer().resolve(MY_MODULE)
957
+ // WRONG — using non-standard Jest matchers
958
+ expect(value).toBeOneOf([expect.any(String), null]) //
959
+ // RIGHT
960
+ expect(value === null || typeof value === "string").toBe(true)
809
961
 
810
- // WRONG — putting test files in non-standard locations
811
- // src/modules/post/tests/post.test.ts won't be picked up
962
+ // WRONG — typeof checks on result fields
963
+ expect(typeof result.id).toBe("string") //
812
964
  // RIGHT
813
- // src/modules/post/__tests__/post.spec.ts
965
+ expect(result.id).toEqual(expect.any(String))
814
966
 
815
- // WRONG — calling workflow directly in tests without container
967
+ // WRONG — calling workflow without passing container
816
968
  await createPostWorkflow.run({ input: { title: "Test" } }) // ❌
817
969
  // RIGHT
818
970
  await createPostWorkflow(getContainer()).run({ input: { title: "Test" } })
819
971
 
820
972
  // WRONG — using .rejects.toThrow() on workflow errors
821
- // The distributed transaction engine serializes errors into plain objects
822
- // (not Error instances), so .toThrow() never matches.
823
973
  await expect(
824
974
  createPostWorkflow(getContainer()).run({ input: {} })
825
- ).rejects.toThrow() // ❌ always fails — "Received function did not throw"
975
+ ).rejects.toThrow() // ❌ always fails — plain object, not Error instance
826
976
 
827
- // RIGHT — Option 1: use throwOnError: false + errors array (recommended)
977
+ // RIGHT — Option 1: throwOnError: false + errors array (recommended)
828
978
  const { errors } = await createPostWorkflow(getContainer()).run({
829
979
  input: {},
830
980
  throwOnError: false,
@@ -832,7 +982,7 @@ const { errors } = await createPostWorkflow(getContainer()).run({
832
982
  expect(errors).toHaveLength(1)
833
983
  expect(errors[0].error.message).toContain("title")
834
984
 
835
- // RIGHT — Option 2: use .rejects.toEqual() for plain object matching
985
+ // RIGHT — Option 2: .rejects.toEqual() for plain object matching
836
986
  await expect(
837
987
  createPostWorkflow(getContainer()).run({ input: {} })
838
988
  ).rejects.toEqual(
@@ -841,364 +991,81 @@ await expect(
841
991
  })
842
992
  )
843
993
 
844
- // NOTE: .rejects.toThrow() DOES work for service-level errors (e.g.,
845
- // service.retrievePost("bad-id")) because services throw real Error
846
- // instances. The serialization only happens in the workflow engine.
847
-
848
- // WRONG — asserting batch results without checking each operation type
849
- expect(response.data).toBeDefined() // ❌ too vague
994
+ // WRONG wrapping body in { body: ... }
995
+ await api.post("/admin/posts", { body: { title: "Test" } }) // ❌
850
996
  // RIGHT
851
- expect(response.data.created).toHaveLength(1)
852
- expect(response.data.updated).toHaveLength(1)
853
- expect(response.data.deleted).toHaveLength(1)
854
-
855
- // WRONG — sorting assertion without reliable comparison
856
- expect(response.data.posts[0].title).toBe("Alpha") // ❌ fragile
857
- // RIGHT — verify entire order
858
- const titles = response.data.posts.map((p) => p.title)
859
- expect(titles).toEqual([...titles].sort())
860
- ```
861
-
862
- ---
863
-
864
- ## Testing Workflows via HTTP
865
-
866
- Workflows triggered by API routes are tested end-to-end. The runner calls `waitWorkflowExecutions()` in `afterEach`, so simple create/update flows just work.
867
-
868
- ### Direct workflow execution (no HTTP)
869
-
870
- For testing workflow logic in isolation within `acmekitIntegrationTestRunner`:
871
-
872
- ```typescript
873
- import { createPostWorkflow } from "../../src/workflows"
874
-
875
- acmekitIntegrationTestRunner({
876
- testSuite: ({ getContainer, dbConnection, api }) => {
877
- beforeEach(async () => {
878
- await createAdminUser(dbConnection, adminHeaders, getContainer())
879
- })
880
-
881
- it("should execute the workflow", async () => {
882
- const { result } = await createPostWorkflow(getContainer()).run({
883
- input: {
884
- title: "Launch Announcement",
885
- author_id: "auth_123",
886
- },
887
- })
888
- expect(result.post).toEqual(
889
- expect.objectContaining({
890
- id: expect.any(String),
891
- title: "Launch Announcement",
892
- })
893
- )
894
- })
895
-
896
- it("should reject invalid input via schema", async () => {
897
- const { errors } = await createPostWorkflow(getContainer()).run({
898
- input: {},
899
- throwOnError: false,
900
- })
901
- expect(errors).toHaveLength(1)
902
- expect(errors[0].error.message).toContain("title")
903
- })
904
- },
905
- })
906
- ```
907
-
908
- ### Long-running / async workflow testing
909
-
910
- For workflows with async steps (e.g., `createStep` with `async: true`), use `subscribe` + `setStepSuccess`:
911
-
912
- ```typescript
913
- import { Modules } from "@acmekit/framework/utils"
914
-
915
- it("should complete async workflow", async () => {
916
- const container = getContainer()
917
- const workflowEngine = container.resolve(Modules.WORKFLOW_ENGINE)
918
-
919
- // Start the workflow
920
- const { transaction } = await processOrderWorkflow(container).run({
921
- input: { orderId: order.id },
922
- throwOnError: false,
923
- })
924
-
925
- // Subscribe to completion
926
- const workflowCompletion = new Promise<void>((resolve) => {
927
- workflowEngine.subscribe({
928
- workflowId: "process-order",
929
- transactionId: transaction.transactionId,
930
- subscriber: (event) => {
931
- if (event.eventType === "onFinish") {
932
- resolve()
933
- }
934
- },
935
- })
936
- })
937
-
938
- // Complete the async step
939
- await workflowEngine.setStepSuccess({
940
- idempotencyKey: {
941
- action: "invoke",
942
- stepId: "external-payment-step",
943
- workflowId: "process-order",
944
- transactionId: transaction.transactionId,
945
- },
946
- stepResponse: { paymentId: "pay_123" },
947
- })
948
-
949
- await workflowCompletion
950
-
951
- // Verify final state
952
- const processedOrder = await api.get(
953
- `/admin/orders/${order.id}`,
954
- adminHeaders
955
- )
956
- expect(processedOrder.data.order.status).toBe("processed")
957
- })
958
- ```
959
-
960
- ---
961
-
962
- ## Batch Operations
963
-
964
- Routes that accept `{ create, update, delete }` as input and return `{ created, updated, deleted }`:
965
-
966
- ```typescript
967
- it("should handle batch operations", async () => {
968
- // Seed existing data
969
- const existing = (
970
- await api.post(
971
- "/admin/posts",
972
- { title: "Existing Post" },
973
- adminHeaders
974
- )
975
- ).data.post
976
-
977
- const response = await api.post(
978
- "/admin/posts/batch",
979
- {
980
- create: [{ title: "New Post" }],
981
- update: [{ id: existing.id, title: "Updated Post" }],
982
- delete: [existing.id],
983
- },
984
- adminHeaders
985
- )
986
-
987
- expect(response.status).toEqual(200)
988
- expect(response.data.created).toHaveLength(1)
989
- expect(response.data.created[0]).toEqual(
990
- expect.objectContaining({ title: "New Post" })
991
- )
992
- expect(response.data.updated).toHaveLength(1)
993
- expect(response.data.updated[0].title).toBe("Updated Post")
994
- expect(response.data.deleted).toHaveLength(1)
995
- expect(response.data.deleted[0].id).toBe(existing.id)
996
- })
997
- ```
998
-
999
- ---
1000
-
1001
- ## File Uploads
1002
-
1003
- Use `FormData` with `Buffer.from()` for file upload testing:
1004
-
1005
- ```typescript
1006
- import FormData from "form-data"
1007
-
1008
- it("should upload a file", async () => {
1009
- const form = new FormData()
1010
- form.append(
1011
- "files",
1012
- Buffer.from("file-content"),
1013
- { filename: "report.pdf" }
1014
- )
997
+ await api.post("/admin/posts", { title: "Test" }, adminHeaders)
1015
998
 
1016
- const response = await api.post(
1017
- "/admin/uploads",
1018
- form,
1019
- {
1020
- ...adminHeaders,
1021
- headers: {
1022
- ...adminHeaders.headers,
1023
- ...form.getHeaders(),
1024
- },
1025
- }
1026
- )
1027
- expect(response.status).toEqual(200)
1028
- expect(response.data.files).toHaveLength(1)
1029
- expect(response.data.files[0]).toEqual(
1030
- expect.objectContaining({
1031
- id: expect.any(String),
1032
- url: expect.any(String),
1033
- })
1034
- )
1035
- })
1036
- ```
999
+ // WRONG vague range hides which error actually occurred
1000
+ expect(response.status).toBeGreaterThanOrEqual(400) // ❌
1001
+ // RIGHT
1002
+ expect(response.status).toEqual(400)
1037
1003
 
1038
- ---
1004
+ // WRONG — resolve path as relative from test file
1005
+ integrationTestRunner({ mode: "module", moduleName: "post", resolve: "../index" }) // ❌
1006
+ // RIGHT — absolute path from CWD
1007
+ integrationTestRunner({ mode: "module", moduleName: "post", resolve: process.cwd() + "/src/modules/post" })
1039
1008
 
1040
- ## Link / Relation Testing
1009
+ // WRONG guessing service resolution key
1010
+ getContainer().resolve("myModuleService") // ❌
1011
+ getContainer().resolve("my-module-service") // ❌
1012
+ // RIGHT
1013
+ import { MY_MODULE } from "../../src/modules/my-module"
1014
+ getContainer().resolve(MY_MODULE)
1041
1015
 
1042
- Test cross-module links with `remoteLink` and `query.graph()`:
1016
+ // WRONG using jsonwebtoken directly
1017
+ import jwt from "jsonwebtoken"
1018
+ const token = jwt.sign({ user_id: user.id }, "supersecret") // ❌
1019
+ // RIGHT — use generateJwtToken with config-resolved secret
1043
1020
 
1044
- ```typescript
1045
- import { ContainerRegistrationKeys, Modules } from "@acmekit/framework/utils"
1046
-
1047
- it("should create and query a link", async () => {
1048
- const container = getContainer()
1049
- const remoteLink = container.resolve(ContainerRegistrationKeys.LINK)
1050
- const query = container.resolve(ContainerRegistrationKeys.QUERY)
1051
-
1052
- // Create linked entities
1053
- const post = (
1054
- await api.post("/admin/posts", { title: "Linked Post" }, adminHeaders)
1055
- ).data.post
1056
- const category = (
1057
- await api.post(
1058
- "/admin/categories",
1059
- { name: "Tech" },
1060
- adminHeaders
1061
- )
1062
- ).data.category
1063
-
1064
- // Create the link
1065
- await remoteLink.create([{
1066
- [Modules.POST]: { post_id: post.id },
1067
- [Modules.CATEGORY]: { category_id: category.id },
1068
- }])
1069
-
1070
- // Query with graph
1071
- const { data: [linkedPost] } = await query.graph({
1072
- entity: "post",
1073
- fields: ["id", "title", "category.*"],
1074
- filters: { id: post.id },
1075
- })
1021
+ // WRONG — using old publishable API key names
1022
+ await apiKeyModule.createApiKeys({ type: "publishable" }) //
1023
+ headers: { "x-publishable-api-key": token } // ❌
1024
+ // RIGHT
1025
+ await apiKeyModule.createApiKeys({ type: ApiKeyType.CLIENT })
1026
+ headers: { [CLIENT_API_KEY_HEADER]: token }
1076
1027
 
1077
- expect(linkedPost.category.name).toBe("Tech")
1078
- })
1079
- ```
1028
+ // WRONG — unused imports
1029
+ import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if never used
1080
1030
 
1081
- ---
1031
+ // WRONG — JSDoc comment blocks at file top (test files never have these)
1032
+ /** POST /admin/posts — validates body */ // ❌
1082
1033
 
1083
- ## Sorting / Ordering Verification
1034
+ // WRONG type casts in tests
1035
+ const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
1084
1036
 
1085
- ```typescript
1086
- it("should sort by name ascending", async () => {
1087
- await api.post("/admin/posts", { title: "Zeta" }, adminHeaders)
1088
- await api.post("/admin/posts", { title: "Alpha" }, adminHeaders)
1089
- await api.post("/admin/posts", { title: "Mu" }, adminHeaders)
1090
-
1091
- const response = await api.get(
1092
- "/admin/posts?order=title",
1093
- adminHeaders
1094
- )
1095
- const titles = response.data.posts.map((p) => p.title)
1096
- expect(titles).toEqual(["Alpha", "Mu", "Zeta"])
1037
+ // WRONG — referencing file-level const/let inside jest.mock factory (TDZ)
1038
+ const mockFn = jest.fn()
1039
+ jest.mock("lib", () => ({ thing: mockFn })) // ❌ ReferenceError
1040
+ // RIGHT create mocks inside factory, access via require()
1041
+ jest.mock("lib", () => {
1042
+ const mocks = { thing: jest.fn() }
1043
+ return { ...mocks, __mocks: mocks }
1097
1044
  })
1098
-
1099
- it("should sort by created_at descending", async () => {
1100
- const response = await api.get(
1101
- "/admin/posts?order=-created_at",
1102
- adminHeaders
1103
- )
1104
- const dates = response.data.posts.map((p) => new Date(p.created_at))
1105
- for (let i = 1; i < dates.length; i++) {
1106
- expect(dates[i - 1].getTime()).toBeGreaterThanOrEqual(dates[i].getTime())
1107
- }
1045
+ const { __mocks } = require("lib")
1046
+
1047
+ // WRONG no mock cleanup between tests
1048
+ describe("A", () => { it("calls mock", () => { mockFn() }) })
1049
+ describe("B", () => { it("mock is clean", () => {
1050
+ expect(mockFn).not.toHaveBeenCalled() // ❌ fails — leaked from A
1051
+ }) })
1052
+ // RIGHT add beforeEach(() => jest.clearAllMocks())
1053
+
1054
+ // WRONG — real timers in unit tests cause timeouts
1055
+ it("should process", async () => {
1056
+ await relay.process(data) // ❌ hangs — code calls sleep(3000) internally
1108
1057
  })
1109
- ```
1110
-
1111
- ---
1112
-
1113
- ## Complex Seeding in beforeEach
1058
+ // RIGHT — mock timers or sleep method
1059
+ jest.useFakeTimers()
1060
+ // or: jest.spyOn(relay as any, "sleep_").mockResolvedValue(undefined)
1114
1061
 
1115
- When tests need multiple related entities, seed them in `beforeEach` using admin API calls:
1062
+ // WRONG complex regex literal that SWC can't parse
1063
+ const re = /^(\*|[0-9]+)(\/[0-9]+)?$/ // ❌ SWC Syntax Error
1064
+ // RIGHT
1065
+ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
1116
1066
 
1117
- ```typescript
1118
- let order: any
1119
- let product: any
1120
- let customer: any
1121
-
1122
- beforeEach(async () => {
1123
- await createAdminUser(dbConnection, adminHeaders, getContainer())
1124
-
1125
- // Seed product
1126
- product = (
1127
- await api.post(
1128
- "/admin/products",
1129
- { title: "Widget", status: "published" },
1130
- adminHeaders
1131
- )
1132
- ).data.product
1133
-
1134
- // Seed customer via admin
1135
- customer = (
1136
- await api.post(
1137
- "/admin/customers",
1138
- { email: "test@example.com", first_name: "Jane" },
1139
- adminHeaders
1140
- )
1141
- ).data.customer
1142
-
1143
- // Seed order linking both
1144
- order = (
1145
- await api.post(
1146
- "/admin/orders",
1147
- {
1148
- customer_id: customer.id,
1149
- items: [{ product_id: product.id, quantity: 2 }],
1150
- },
1151
- adminHeaders
1152
- )
1153
- ).data.order
1154
- })
1067
+ // WRONG — assuming method returns error without reading implementation
1068
+ const result = await provider.process(bad)
1069
+ expect(result.success).toBe(false) // actually throws
1070
+ // RIGHT — read implementation first to check if it throws or returns
1155
1071
  ```
1156
-
1157
- ---
1158
-
1159
- ## Multi-Step Flow Testing
1160
-
1161
- Chain multiple API calls in one test to verify end-to-end flows:
1162
-
1163
- ```typescript
1164
- it("should complete the full order lifecycle", async () => {
1165
- // 1. Create draft order
1166
- const created = (
1167
- await api.post(
1168
- "/admin/orders",
1169
- { customer_id: customer.id, items: [{ product_id: product.id, quantity: 1 }] },
1170
- adminHeaders
1171
- )
1172
- ).data.order
1173
- expect(created.status).toBe("draft")
1174
-
1175
- // 2. Confirm order
1176
- const confirmed = (
1177
- await api.post(
1178
- `/admin/orders/${created.id}/confirm`,
1179
- {},
1180
- adminHeaders
1181
- )
1182
- ).data.order
1183
- expect(confirmed.status).toBe("confirmed")
1184
-
1185
- // 3. Fulfill order
1186
- const fulfilled = (
1187
- await api.post(
1188
- `/admin/orders/${created.id}/fulfill`,
1189
- { tracking_number: "TRACK-123" },
1190
- adminHeaders
1191
- )
1192
- ).data.order
1193
- expect(fulfilled.status).toBe("fulfilled")
1194
-
1195
- // 4. Verify final state with relations
1196
- const final = (
1197
- await api.get(
1198
- `/admin/orders/${created.id}?fields=*fulfillments`,
1199
- adminHeaders
1200
- )
1201
- ).data.order
1202
- expect(final.fulfillments).toHaveLength(1)
1203
- expect(final.fulfillments[0].tracking_number).toBe("TRACK-123")
1204
- })