@acmekit/acmekit 2.13.83 → 2.13.84

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,54 @@ 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
+ **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`.
26
+
23
27
  ---
24
28
 
25
29
  ## Test Runner Selection
26
30
 
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 |
31
+ | What to test | Mode | HTTP? | Fixtures | DB setup |
32
+ |---|---|---|---|---|
33
+ | HTTP API routes end-to-end | `mode: "app"` | yes (default) | `api`, `getContainer()`, `container`, `dbConnection`, `dbUtils`, `utils` | Runs migrations |
34
+ | Workflows, subscribers, jobs (no HTTP) | `mode: "app"` | yes (default) | `getContainer()`, `container`, `utils` | Runs migrations |
35
+ | Module service CRUD in isolation | `mode: "module"` | no | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync (no migrations) |
36
+ | Pure functions (no DB) | Plain Jest `describe/it` | — | none | N/A |
37
+
38
+ ---
32
39
 
33
40
  ## File Locations (must match `jest.config.js` buckets)
34
41
 
35
42
  ```
36
43
  integration-tests/http/<feature>.spec.ts → TEST_TYPE=integration:http
44
+ integration-tests/app/<feature>.spec.ts → TEST_TYPE=integration:app
37
45
  src/modules/<mod>/__tests__/<name>.spec.ts → TEST_TYPE=integration:modules
38
46
  src/**/__tests__/<name>.unit.spec.ts → TEST_TYPE=unit
39
47
  ```
40
48
 
49
+ **`integration-tests/http/`** — tests that need the `api` fixture (HTTP requests to admin/client routes).
50
+
51
+ **`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.
52
+
53
+ ---
54
+
41
55
  ## Commands
42
56
 
43
57
  ```bash
44
58
  pnpm test:unit # Unit tests
45
59
  pnpm test:integration:modules # Module integration tests
60
+ pnpm test:integration:app # App integration tests (no HTTP)
46
61
  pnpm test:integration:http # HTTP integration tests
47
62
  ```
48
63
 
@@ -50,27 +65,255 @@ All integration tests require `NODE_OPTIONS=--experimental-vm-modules` (set in p
50
65
 
51
66
  ---
52
67
 
53
- ## Module Integration Tests
68
+ ## HTTP Integration Tests (`integration-tests/http/`)
69
+
70
+ **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
71
 
55
72
  ```typescript
56
- import { moduleIntegrationTestRunner, MockEventBusService } from "@acmekit/test-utils"
57
- import { Modules } from "@acmekit/framework/utils"
73
+ import { integrationTestRunner } from "@acmekit/test-utils"
74
+ import {
75
+ ApiKeyType,
76
+ CLIENT_API_KEY_HEADER,
77
+ ContainerRegistrationKeys,
78
+ generateJwtToken,
79
+ Modules,
80
+ } from "@acmekit/framework/utils"
58
81
 
59
- jest.setTimeout(30000)
82
+ jest.setTimeout(60 * 1000)
83
+
84
+ integrationTestRunner({
85
+ mode: "app",
86
+ testSuite: ({ api, getContainer }) => {
87
+ let adminHeaders: Record<string, any>
88
+ let clientHeaders: Record<string, any>
89
+
90
+ beforeEach(async () => {
91
+ const container = getContainer()
92
+ const userModule = container.resolve(Modules.USER)
93
+ const authModule = container.resolve(Modules.AUTH)
94
+ const apiKeyModule = container.resolve(Modules.API_KEY)
95
+
96
+ // Create admin user
97
+ const user = await userModule.createUsers({
98
+ email: "admin@test.js",
99
+ })
100
+
101
+ // Create auth identity
102
+ const authIdentity = await authModule.createAuthIdentities({
103
+ provider_identities: [
104
+ { provider: "emailpass", entity_id: "admin@test.js" },
105
+ ],
106
+ app_metadata: { user_id: user.id },
107
+ })
108
+
109
+ // Generate JWT from project config — NEVER hardcode the secret
110
+ const config = container.resolve(
111
+ ContainerRegistrationKeys.CONFIG_MODULE
112
+ )
113
+ const { jwtSecret, jwtOptions } = config.projectConfig.http
114
+
115
+ const token = generateJwtToken(
116
+ {
117
+ actor_id: user.id,
118
+ actor_type: "user",
119
+ auth_identity_id: authIdentity.id,
120
+ app_metadata: { user_id: user.id },
121
+ },
122
+ { secret: jwtSecret, expiresIn: "1d", jwtOptions }
123
+ )
124
+
125
+ adminHeaders = {
126
+ headers: { authorization: `Bearer ${token}` },
127
+ }
128
+
129
+ // Create client API key
130
+ const apiKey = await apiKeyModule.createApiKeys({
131
+ title: "Test Client Key",
132
+ type: ApiKeyType.CLIENT,
133
+ created_by: "test",
134
+ })
135
+
136
+ clientHeaders = {
137
+ headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
138
+ }
139
+ })
140
+
141
+ describe("GET /admin/posts", () => {
142
+ it("should list posts", async () => {
143
+ const response = await api.get("/admin/posts", adminHeaders)
144
+ expect(response.status).toEqual(200)
145
+ expect(response.data.posts).toBeDefined()
146
+ })
147
+ })
148
+
149
+ describe("POST /admin/posts", () => {
150
+ it("should create a post", async () => {
151
+ const response = await api.post(
152
+ "/admin/posts",
153
+ { title: "Launch Announcement" },
154
+ adminHeaders
155
+ )
156
+ expect(response.status).toEqual(200)
157
+ expect(response.data.post).toEqual(
158
+ expect.objectContaining({
159
+ id: expect.any(String),
160
+ title: "Launch Announcement",
161
+ })
162
+ )
163
+ })
164
+
165
+ it("should reject missing required fields with 400", async () => {
166
+ const { response } = await api
167
+ .post("/admin/posts", {}, adminHeaders)
168
+ .catch((e: any) => e)
169
+ expect(response.status).toEqual(400)
170
+ })
171
+ })
172
+
173
+ describe("DELETE /admin/posts/:id", () => {
174
+ it("should delete and return confirmation", async () => {
175
+ const created = (
176
+ await api.post("/admin/posts", { title: "To Remove" }, adminHeaders)
177
+ ).data.post
178
+
179
+ const response = await api.delete(
180
+ `/admin/posts/${created.id}`,
181
+ adminHeaders
182
+ )
183
+ expect(response.status).toEqual(200)
184
+ expect(response.data).toEqual({
185
+ id: created.id,
186
+ object: "post",
187
+ deleted: true,
188
+ })
189
+ })
190
+ })
60
191
 
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(),
192
+ describe("Client routes", () => {
193
+ it("should return 200 with client API key", async () => {
194
+ const response = await api.get("/client/posts", clientHeaders)
195
+ expect(response.status).toEqual(200)
196
+ })
197
+
198
+ it("should return 400 without API key", async () => {
199
+ const error = await api.get("/client/posts").catch((e: any) => e)
200
+ expect(error.response.status).toEqual(400)
201
+ })
202
+ })
68
203
  },
69
- testSuite: ({ service }) => {
70
- afterEach(() => {
71
- jest.restoreAllMocks()
204
+ })
205
+ ```
206
+
207
+ ### `integrationTestRunner` Options (app mode)
208
+
209
+ | Option | Type | Default | Description |
210
+ |---|---|---|---|
211
+ | `mode` | `"app"` | **(required)** | Selects app mode |
212
+ | `testSuite` | `(options) => void` | **(required)** | Callback containing `describe`/`it` blocks |
213
+ | `cwd` | `string` | `process.cwd()` | Project root directory |
214
+ | `acmekitConfigFile` | `string` | from `cwd` | Path to directory with `acmekit-config.ts` |
215
+ | `env` | `Record<string, any>` | `{}` | Values written to `process.env` before app starts |
216
+ | `dbName` | `string` | auto-generated | Override the computed DB name |
217
+ | `schema` | `string` | `"public"` | Postgres schema |
218
+ | `debug` | `boolean` | `false` | Enables DB query logging |
219
+ | `disableAutoTeardown` | `boolean` | `false` | Skips table TRUNCATE in `beforeEach` |
220
+ | `hooks` | `RunnerHooks` | `{}` | Lifecycle hooks (see below) |
221
+
222
+ ### Fixtures (`testSuite` callback)
223
+
224
+ - `api` — axios instance pointed at `http://localhost:<port>` (random port per run)
225
+ - `getContainer()` — returns the live `AcmeKitContainer`
226
+ - `container` — proxy to the live container (auto-refreshed each `beforeEach`)
227
+ - `dbConnection` — proxy to the knex connection
228
+ - `dbUtils` — `{ create, teardown, shutdown }` for manual DB control
229
+ - `dbConfig` — `{ dbName, schema, clientUrl }`
230
+ - `getAcmeKitApp()` — returns the running `AcmeKitApp`
231
+ - `utils.waitWorkflowExecutions()` — polls until all in-flight workflows complete (60s timeout)
232
+
233
+ ### Lifecycle hooks
234
+
235
+ ```typescript
236
+ integrationTestRunner({
237
+ mode: "app",
238
+ hooks: {
239
+ beforeSetup: async () => { /* before pipeline setup */ },
240
+ afterSetup: async ({ container, api }) => { /* after pipeline setup */ },
241
+ beforeReset: async () => { /* before each test reset */ },
242
+ afterReset: async () => { /* after each test reset */ },
243
+ },
244
+ testSuite: ({ api, getContainer }) => { ... },
245
+ })
246
+ ```
247
+
248
+ ### HTTP test lifecycle
249
+
250
+ - `beforeAll`: boots full Express app (resolves plugins, runs migrations, starts HTTP server)
251
+ - `beforeEach`: truncates all tables, re-runs module loaders, runs `createDefaultsWorkflow`
252
+ - `afterEach`: **automatically calls `waitWorkflowExecutions()`** then `dbUtils.teardown()`
253
+ - `afterAll`: drops DB, shuts down Express
254
+
255
+ **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.
256
+
257
+ ---
258
+
259
+ ## App Integration Tests (`integration-tests/app/`)
260
+
261
+ For workflows, subscribers, and jobs that only need the container — no auth setup, no `api` fixture:
262
+
263
+ ```typescript
264
+ import { integrationTestRunner } from "@acmekit/test-utils"
265
+ import { createBlogsWorkflow } from "../../src/workflows/workflows"
266
+ import { BLOG_MODULE } from "../../src/modules/blog"
267
+
268
+ jest.setTimeout(60 * 1000)
269
+
270
+ integrationTestRunner({
271
+ mode: "app",
272
+ testSuite: ({ getContainer }) => {
273
+ describe("createBlogsWorkflow", () => {
274
+ it("should create a blog with defaults", async () => {
275
+ const { result } = await createBlogsWorkflow(getContainer()).run({
276
+ input: { blogs: [{ title: "My First Blog" }] },
277
+ })
278
+
279
+ expect(result).toHaveLength(1)
280
+ expect(result[0]).toEqual(
281
+ expect.objectContaining({
282
+ id: expect.any(String),
283
+ title: "My First Blog",
284
+ status: "draft",
285
+ })
286
+ )
287
+ })
288
+
289
+ it("should reject invalid status via validation step", async () => {
290
+ const { errors } = await createBlogsWorkflow(getContainer()).run({
291
+ input: { blogs: [{ title: "Bad", status: "invalid_status" }] },
292
+ throwOnError: false,
293
+ })
294
+
295
+ expect(errors).toHaveLength(1)
296
+ expect(errors[0].error.message).toContain("Invalid blog status")
297
+ })
72
298
  })
299
+ },
300
+ })
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Module Integration Tests
306
+
307
+ ```typescript
308
+ import { integrationTestRunner } from "@acmekit/test-utils"
309
+
310
+ jest.setTimeout(30000)
73
311
 
312
+ integrationTestRunner<IPostModuleService>({
313
+ mode: "module",
314
+ moduleName: "post",
315
+ resolve: process.cwd() + "/src/modules/post",
316
+ testSuite: ({ service }) => {
74
317
  describe("createPosts", () => {
75
318
  it("should create a post", async () => {
76
319
  const result = await service.createPosts([
@@ -93,25 +336,21 @@ moduleIntegrationTestRunner<IMyModuleService>({
93
336
  })
94
337
  ```
95
338
 
96
- ### `moduleIntegrationTestRunner` Options
339
+ ### Module mode options
97
340
 
98
341
  | Option | Type | Default | Description |
99
342
  |---|---|---|---|
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. |
343
+ | `mode` | `"module"` | **(required)** | Selects module mode |
344
+ | `moduleName` | `string` | **(required)** | Module key the string passed to `Module()` |
345
+ | `resolve` | `string` | `undefined` | Absolute path to module root for model discovery |
102
346
  | `moduleModels` | `any[]` | auto-discovered | Explicit model list; overrides auto-discovery |
103
- | `moduleOptions` | `Record<string, any>` | `{}` | Module configuration (merged with DB config) |
347
+ | `moduleOptions` | `Record<string, any>` | `{}` | Module configuration |
104
348
  | `moduleDependencies` | `string[]` | `undefined` | Other module names this module depends on |
105
349
  | `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 |
350
+ | `injectedDependencies` | `Record<string, any>` | `{}` | Override container registrations |
351
+ | `testSuite` | `(options) => void` | **(required)** | Test callback |
113
352
 
114
- ### `SuiteOptions<TService>` fields
353
+ ### Module mode fixtures
115
354
 
116
355
  - `service` — proxy to the module service (auto-refreshed each `beforeEach`)
117
356
  - `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
@@ -125,14 +364,12 @@ Each `it` block gets: schema drop + recreate → fresh module boot → test runs
125
364
  ### CRUD test patterns
126
365
 
127
366
  ```typescript
128
- // --- Create (single object → single result, array → array result) ---
367
+ // --- Create ---
129
368
  const [post] = await service.createPosts([{ title: "Test" }])
130
369
 
131
- // --- List ---
132
- const posts = await service.listPosts()
370
+ // --- List with filters ---
133
371
  const filtered = await service.listPosts({ status: "published" })
134
372
  const withRelations = await service.listPosts({}, { relations: ["comments"] })
135
- const withSelect = await service.listPosts({}, { select: ["id", "title"] })
136
373
 
137
374
  // --- List and count ---
138
375
  const [posts, count] = await service.listAndCountPosts()
@@ -140,115 +377,38 @@ expect(count).toEqual(2)
140
377
 
141
378
  // --- Retrieve ---
142
379
  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
380
 
147
- // --- Update (single or batch) ---
381
+ // --- Update ---
148
382
  const updated = await service.updatePosts(id, { title: "New Title" })
149
- const batchUpdated = await service.updatePosts([{ id, title: "New" }])
150
383
 
151
384
  // --- Soft delete / restore ---
152
385
  await service.softDeletePosts([id])
153
386
  const listed = await service.listPosts({ id })
154
387
  expect(listed).toHaveLength(0)
155
- const withDeleted = await service.listPosts({ id }, { withDeleted: true })
156
- expect(withDeleted[0].deleted_at).toBeDefined()
157
388
  await service.restorePosts([id])
158
389
 
159
390
  // --- Hard delete ---
160
391
  await service.deletePosts([id])
161
- const remaining = await service.listPosts({ id: [id] })
162
- expect(remaining).toHaveLength(0)
163
392
  ```
164
393
 
165
394
  ### Error handling in module tests
166
395
 
167
396
  ```typescript
168
- // Style 1: .catch((e) => e) — preferred when checking message
169
- const error = await service.retrievePost("nonexistent").catch((e) => e)
397
+ // Style 1: .catch((e: any) => e) — preferred when checking message
398
+ const error = await service.retrievePost("nonexistent").catch((e: any) => e)
170
399
  expect(error.message).toEqual("Post with id: nonexistent was not found")
171
400
 
172
401
  // Style 2: rejects.toThrow() — when only checking it throws
173
402
  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
403
  ```
198
404
 
199
405
  ---
200
406
 
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
242
-
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`.
244
-
245
- ```typescript
246
- import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user"
247
- ```
407
+ ## Auth Setup
248
408
 
249
- `adminHeaders` starts as `{ headers: { "x-acmekit-access-token": "test_token" } }`. After `createAdminUser` runs, it also has `authorization: Bearer <jwt>`.
409
+ **MANDATORY for `/admin/*` routes** every HTTP test MUST have a `beforeEach` that creates admin credentials. Without it, admin routes return 401.
250
410
 
251
- `createAdminUser` returns `{ user, authIdentity }` capture these when you need user IDs in tests.
411
+ **MANDATORY for `/client/*` routes** every HTTP test MUST also create a client API key. Without it, client routes return 400.
252
412
 
253
413
  ### JWT Token Generation
254
414
 
@@ -261,14 +421,13 @@ import {
261
421
  Modules,
262
422
  } from "@acmekit/framework/utils"
263
423
 
264
- // Resolve the JWT secret from the project config — NEVER hardcode "supersecret"
265
424
  const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
266
425
  const { jwtSecret, jwtOptions } = config.projectConfig.http
267
426
 
268
427
  const token = generateJwtToken(
269
428
  {
270
429
  actor_id: user.id,
271
- actor_type: "user", // or "customer" for customer auth
430
+ actor_type: "user",
272
431
  auth_identity_id: authIdentity.id,
273
432
  app_metadata: { user_id: user.id },
274
433
  },
@@ -291,185 +450,12 @@ const apiKeyModule = container.resolve(Modules.API_KEY)
291
450
  const apiKey = await apiKeyModule.createApiKeys({
292
451
  title: "Test Client Key",
293
452
  type: ApiKeyType.CLIENT,
294
- created_by: "system",
295
- })
296
-
297
- const clientHeaders = {
298
- headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
299
- }
300
- ```
301
-
302
- ### Service Resolution
303
-
304
- **ALWAYS use `Modules.*` constants** to resolve core services. NEVER use string literals like `"auth"`, `"user"`, `"customer"`.
305
-
306
- ```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)
311
-
312
- // WRONG — string literals are fragile and may not match container keys
313
- const authModule = container.resolve("auth") // ❌
314
- const userModule = container.resolve("user") // ❌
315
- ```
316
-
317
- ### Admin Route Tests (`/admin/*`)
318
-
319
- **MANDATORY:** Every `/admin/*` route test MUST have `beforeEach` with `createAdminUser`. Without it, admin routes return 401.
320
-
321
- ```typescript
322
- import { acmekitIntegrationTestRunner } from "@acmekit/test-utils"
323
- import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user"
324
-
325
- jest.setTimeout(50000)
326
-
327
- acmekitIntegrationTestRunner({
328
- testSuite: ({ api, getContainer, dbConnection }) => {
329
- let user: any
330
-
331
- beforeEach(async () => {
332
- const result = await createAdminUser(
333
- dbConnection,
334
- adminHeaders,
335
- getContainer()
336
- )
337
- user = result.user
338
- })
339
-
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
- })
352
-
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
- })
370
-
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
- })
377
-
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
- })
389
-
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
399
-
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
- })
411
-
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
- },
422
- })
423
- ```
424
-
425
- ### Client Route Tests (`/client/*`)
426
-
427
- **MANDATORY:** Every `/client/*` route test MUST have this `beforeEach` setup. Without `clientHeaders`, client route requests return 400 (`NOT_ALLOWED`).
428
-
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"
437
-
438
- jest.setTimeout(50000)
439
-
440
- acmekitIntegrationTestRunner({
441
- testSuite: ({ api, getContainer, dbConnection }) => {
442
- let clientHeaders: Record<string, any>
443
-
444
- beforeEach(async () => {
445
- const container = getContainer()
446
- await createAdminUser(dbConnection, adminHeaders, container)
447
-
448
- const clientKey = await generateClientKey(container)
449
- clientHeaders = generateClientHeaders({ publishableKey: clientKey })
450
-
451
- // Seed test data via admin API if needed
452
- await api.post("/admin/products", { title: "Widget" }, adminHeaders)
453
- })
454
-
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)
459
- })
460
- },
453
+ created_by: "test",
461
454
  })
462
- ```
463
455
 
464
- If the public route needs no client key (rare), you can skip `generateClientKey`/`generateClientHeaders` — but most tests need them.
465
-
466
- ### Feature flags via `env` option
467
-
468
- ```typescript
469
- acmekitIntegrationTestRunner({
470
- env: { ACMEKIT_FF_RBAC: true },
471
- testSuite: ({ api, getContainer, dbConnection }) => { ... },
472
- })
456
+ const clientHeaders = {
457
+ headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
458
+ }
473
459
  ```
474
460
 
475
461
  ---
@@ -477,25 +463,21 @@ acmekitIntegrationTestRunner({
477
463
  ## Error Handling in HTTP Tests
478
464
 
479
465
  ```typescript
480
- // Error case destructure response from caught error
466
+ // 400validation error (axios throws on non-2xx)
481
467
  const { response } = await api
482
468
  .post("/admin/posts", {}, adminHeaders)
483
- .catch((e) => e)
469
+ .catch((e: any) => e)
484
470
  expect(response.status).toEqual(400)
485
- expect(response.data.message).toContain("is required")
486
471
 
487
- // 404 case — also check type field
472
+ // 404 — not found (also check type and message)
488
473
  const { response } = await api
489
474
  .get("/admin/posts/invalid-id", adminHeaders)
490
- .catch((e) => e)
475
+ .catch((e: any) => e)
491
476
  expect(response.status).toEqual(404)
492
477
  expect(response.data.type).toEqual("not_found")
493
- expect(response.data.message).toEqual("Post with id: invalid-id not found")
494
478
 
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)
479
+ // 401 unauthorized
480
+ const error = await api.get("/admin/posts").catch((e: any) => e)
499
481
  expect(error.response.status).toEqual(401)
500
482
  ```
501
483
 
@@ -515,73 +497,27 @@ expect(response.data.post).toEqual(
515
497
  )
516
498
 
517
499
  // 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
- })
500
+ expect(response.data).toEqual(
501
+ expect.objectContaining({
502
+ posts: expect.arrayContaining([
503
+ expect.objectContaining({ title: "Post A" }),
504
+ ]),
505
+ })
506
+ )
527
507
 
528
- // Delete response — exact match, no objectContaining
508
+ // Delete response — exact match
529
509
  expect(response.data).toEqual({
530
510
  id: created.id,
531
511
  object: "post",
532
512
  deleted: true,
533
513
  })
534
-
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
- )
543
-
544
- // Negative assertion — item NOT in list
545
- expect(response.data.posts).toEqual(
546
- expect.not.arrayContaining([
547
- expect.objectContaining({ status: "archived" }),
548
- ])
549
- )
550
- ```
551
-
552
- ---
553
-
554
- ## Query Parameter Testing
555
-
556
- ```typescript
557
- // Field selection — `*` prefix expands relations
558
- api.get(`/admin/posts/${id}?fields=*comments`, adminHeaders)
559
-
560
- // Pagination
561
- api.get("/admin/posts?limit=2&offset=1", adminHeaders)
562
-
563
- // Ordering — `-` prefix for descending
564
- api.get("/admin/posts?order=-created_at", adminHeaders)
565
-
566
- // Free-text search
567
- api.get("/admin/posts?q=quarterly", adminHeaders)
568
-
569
- // Array filters
570
- api.get(`/admin/posts?status[]=published`, adminHeaders)
571
- api.get(`/admin/posts?id[]=${id1},${id2}`, adminHeaders)
572
-
573
- // Boolean filters
574
- api.get("/admin/posts?is_featured=true", adminHeaders)
575
-
576
- // With deleted
577
- api.get(`/admin/posts?with_deleted=true`, adminHeaders)
578
514
  ```
579
515
 
580
516
  ---
581
517
 
582
518
  ## Asserting Domain Events
583
519
 
584
- Both runners inject `MockEventBusService` under `Modules.EVENT_BUS`. Spy on the **prototype**, not an instance.
520
+ Both runners inject `MockEventBusService` under `Modules.EVENT_BUS` in module mode. Spy on the **prototype**, not an instance.
585
521
 
586
522
  ```typescript
587
523
  import { MockEventBusService } from "@acmekit/test-utils"
@@ -599,9 +535,7 @@ afterEach(() => {
599
535
  it("should emit post.created event", async () => {
600
536
  await service.createPosts([{ title: "Event Test" }])
601
537
 
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)
538
+ const events = eventBusSpy.mock.calls[0][0]
605
539
  expect(events).toEqual(
606
540
  expect.arrayContaining([
607
541
  expect.objectContaining({
@@ -610,78 +544,168 @@ it("should emit post.created event", async () => {
610
544
  }),
611
545
  ])
612
546
  )
547
+ })
548
+ ```
549
+
550
+ ---
551
+
552
+ ## Waiting for Subscribers
553
+
554
+ Use `TestEventUtils.waitSubscribersExecution` when testing subscriber side-effects. **CRITICAL: create the promise BEFORE triggering the event.**
555
+
556
+ ### Pattern 1: Event bus driven (app mode with real event bus)
557
+
558
+ ```typescript
559
+ import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
560
+ import { Modules } from "@acmekit/framework/utils"
561
+ import { BLOG_MODULE } from "../../src/modules/blog"
562
+
563
+ integrationTestRunner({
564
+ mode: "app",
565
+ testSuite: ({ getContainer }) => {
566
+ it("should execute subscriber side-effect", async () => {
567
+ const container = getContainer()
568
+ const service: any = container.resolve(BLOG_MODULE)
569
+ const eventBus = container.resolve(Modules.EVENT_BUS)
570
+
571
+ const [blog] = await service.createBlogs([
572
+ { title: "Test", content: "Original", status: "published" },
573
+ ])
613
574
 
614
- // Second argument is always { internal: true }
615
- expect(eventBusSpy.mock.calls[0][1]).toEqual({ internal: true })
575
+ // Create promise BEFORE emitting event
576
+ const subscriberDone = TestEventUtils.waitSubscribersExecution(
577
+ "blog.published",
578
+ eventBus
579
+ )
580
+
581
+ // Emit event — single object { name, data } format
582
+ await eventBus.emit({ name: "blog.published", data: { id: blog.id } })
583
+ await subscriberDone
584
+
585
+ // Verify subscriber side-effect
586
+ const updated = await service.retrieveBlog(blog.id)
587
+ expect(updated.content).toBe("Original [notified]")
588
+ })
589
+ },
616
590
  })
617
591
  ```
618
592
 
619
593
  ---
620
594
 
621
- ## Waiting for Async Workflows
595
+ ## Testing Jobs
622
596
 
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:
597
+ Import the job function directly and call it with the container:
624
598
 
625
599
  ```typescript
626
- it("should process order via workflow", async () => {
627
- await api.post("/admin/orders", { items: [...] }, adminHeaders)
600
+ import { integrationTestRunner } from "@acmekit/test-utils"
601
+ import archiveOldBlogsJob from "../../src/jobs/archive-old-blogs"
602
+ import { BLOG_MODULE } from "../../src/modules/blog"
628
603
 
629
- // ONLY needed because the next request depends on workflow completion
630
- await utils.waitWorkflowExecutions()
604
+ jest.setTimeout(60 * 1000)
631
605
 
632
- const response = await api.get("/admin/orders", adminHeaders)
633
- expect(response.data.orders[0].status).toBe("processed")
606
+ integrationTestRunner({
607
+ mode: "app",
608
+ testSuite: ({ getContainer }) => {
609
+ it("should soft-delete archived blogs", async () => {
610
+ const container = getContainer()
611
+ const service: any = container.resolve(BLOG_MODULE)
612
+
613
+ await service.createBlogs([
614
+ { title: "Archived 1", status: "archived" },
615
+ { title: "Active", status: "published" },
616
+ ])
617
+
618
+ await archiveOldBlogsJob(container)
619
+
620
+ const remaining = await service.listBlogs()
621
+ expect(remaining).toHaveLength(1)
622
+ expect(remaining[0].title).toBe("Active")
623
+ })
624
+ },
634
625
  })
635
626
  ```
636
627
 
637
628
  ---
638
629
 
639
- ## Waiting for Subscribers
630
+ ## Workflow Testing
640
631
 
641
- Use `TestEventUtils.waitSubscribersExecution` when testing subscriber side-effects. **CRITICAL: create the promise BEFORE triggering the event.**
632
+ ### Direct execution (no HTTP)
642
633
 
643
634
  ```typescript
644
- import { TestEventUtils } from "@acmekit/test-utils"
635
+ import { createPostWorkflow } from "../../src/workflows/workflows"
636
+
637
+ integrationTestRunner({
638
+ mode: "app",
639
+ testSuite: ({ getContainer }) => {
640
+ it("should execute the workflow", async () => {
641
+ const { result } = await createPostWorkflow(getContainer()).run({
642
+ input: { title: "Launch Announcement" },
643
+ })
644
+ expect(result).toEqual(
645
+ expect.objectContaining({
646
+ id: expect.any(String),
647
+ title: "Launch Announcement",
648
+ })
649
+ )
650
+ })
645
651
 
646
- it("should handle the event", async () => {
647
- const eventBus = getContainer().resolve(Modules.EVENT_BUS)
652
+ it("should reject invalid input", async () => {
653
+ const { errors } = await createPostWorkflow(getContainer()).run({
654
+ input: {},
655
+ throwOnError: false,
656
+ })
657
+ expect(errors).toHaveLength(1)
658
+ expect(errors[0].error.message).toContain("title")
659
+ })
648
660
 
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
661
+ // Workflow engine serializes errors as plain objects —
662
+ // use .rejects.toEqual(), NOT .rejects.toThrow()
663
+ it("should throw by default on error", async () => {
664
+ await expect(
665
+ updatePostWorkflow(getContainer()).run({
666
+ input: { id: "nonexistent", title: "Nope" },
667
+ })
668
+ ).rejects.toEqual(
669
+ expect.objectContaining({
670
+ message: expect.stringContaining("not found"),
671
+ })
672
+ )
673
+ })
674
+ },
675
+ })
676
+ ```
677
+
678
+ ### Via HTTP (when route triggers the workflow)
679
+
680
+ ```typescript
681
+ it("should process via workflow", async () => {
682
+ await api.post("/admin/orders", { items: [...] }, adminHeaders)
683
+ // Only needed if next request depends on workflow completion
684
+ await utils.waitWorkflowExecutions()
685
+ const response = await api.get("/admin/orders", adminHeaders)
686
+ expect(response.data.orders[0].status).toBe("processed")
657
687
  })
658
688
  ```
659
689
 
660
690
  ---
661
691
 
662
- ## Resolving Services in Tests
692
+ ## Service Resolution
663
693
 
664
694
  ```typescript
665
- // In acmekitIntegrationTestRunner — use module constant (matches Module() key)
695
+ // Custom modules — use module constant (matches the string passed to Module())
666
696
  import { BLOG_MODULE } from "../../src/modules/blog" // BLOG_MODULE = "blog"
667
- const blogService = getContainer().resolve(BLOG_MODULE)
668
- const query = getContainer().resolve(ContainerRegistrationKeys.QUERY)
697
+ const service = getContainer().resolve(BLOG_MODULE)
669
698
 
670
699
  // Core modules — use Modules.* constants
671
700
  const userModule = getContainer().resolve(Modules.USER)
672
701
  const authModule = getContainer().resolve(Modules.AUTH)
673
702
 
674
- // Resolve in beforeAll for reuse (container proxy stays current)
675
- let container: AcmeKitContainer
676
- beforeAll(() => {
677
- container = getContainer()
678
- })
679
-
680
- // In moduleIntegrationTestRunner — use the `service` fixture directly
681
- const result = await service.listPosts()
703
+ // Framework services
704
+ const query = getContainer().resolve(ContainerRegistrationKeys.QUERY)
682
705
 
683
- // NEVER guess the key "blogModuleService", "postModuleService" are all WRONG
684
- // The key is EXACTLY the string passed to Module() in your module definition
706
+ // WRONGstring literals are fragile
707
+ getContainer().resolve("auth") //
708
+ getContainer().resolve("blogModuleService") // ❌
685
709
  ```
686
710
 
687
711
  ---
@@ -689,7 +713,7 @@ const result = await service.listPosts()
689
713
  ## Environment
690
714
 
691
715
  - `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)
716
+ - `integration-tests/setup.js` clears `MetadataStorage` between test files
693
717
  - DB defaults: `DB_HOST=localhost`, `DB_USERNAME=postgres`, `DB_PASSWORD=""`, `DB_PORT=5432`
694
718
  - Each test run creates a unique DB: `acmekit-<module>-integration-<JEST_WORKER_ID>`
695
719
 
@@ -698,86 +722,44 @@ const result = await service.listPosts()
698
722
  ## Anti-Patterns — NEVER Do These
699
723
 
700
724
  ```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
- },
725
+ // WRONG — using deprecated runner names
726
+ import { acmekitIntegrationTestRunner } from "@acmekit/test-utils" // ❌
727
+ import { moduleIntegrationTestRunner } from "@acmekit/test-utils" // ❌
728
+ // RIGHT use unified runner with mode
729
+ import { integrationTestRunner } from "@acmekit/test-utils"
730
+ integrationTestRunner({ mode: "app", testSuite: ... })
731
+ integrationTestRunner({ mode: "module", moduleName: "post", ... })
732
+
733
+ // WRONG — using createAdminUser helper (does not exist)
734
+ import { createAdminUser } from "../../helpers/create-admin-user" // ❌
735
+ // RIGHT — inline auth setup in beforeEach (see Auth Setup section)
736
+
737
+ // WRONG — no auth in HTTP tests
738
+ it("should list", async () => {
739
+ await api.get("/admin/posts") // ❌ 401 — no authorization header
708
740
  })
709
741
 
710
742
  // WRONG — client route without clientHeaders
711
743
  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
744
 
722
- // WRONG — asserting error responses without catching (axios throws on 4xx/5xx!)
745
+ // WRONG — asserting error responses without catching (axios throws!)
723
746
  const response = await api.post("/admin/posts", {}, adminHeaders)
724
747
  expect(response.status).toEqual(400) // ❌ never reached — axios threw
725
748
  // 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
749
+ const { response } = await api.post("/admin/posts", {}, adminHeaders).catch((e: any) => e)
741
750
  expect(response.status).toEqual(400)
742
751
 
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
752
  // WRONG — calling waitWorkflowExecutions in every test
766
753
  await api.post("/admin/posts", body, adminHeaders)
767
754
  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
755
 
771
756
  // WRONG — calling waitSubscribersExecution AFTER triggering event
772
- await api.post("/admin/posts", body, adminHeaders)
757
+ await eventBus.emit({ name: "post.created", data: { id } })
773
758
  await TestEventUtils.waitSubscribersExecution("post.created", eventBus) // ❌ may miss it
774
759
  // 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: ... }) // ❌
760
+ const done = TestEventUtils.waitSubscribersExecution("post.created", eventBus)
761
+ await eventBus.emit({ name: "post.created", data: { id } })
762
+ await done
781
763
 
782
764
  // WRONG — asserting exact objects (timestamps/IDs change)
783
765
  expect(result).toEqual({ id: "123", title: "Test", created_at: "2024-01-01" }) // ❌
@@ -787,44 +769,27 @@ expect(result).toEqual(expect.objectContaining({
787
769
  title: "Test",
788
770
  }))
789
771
 
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)
772
+ // WRONG — using non-standard Jest matchers
773
+ expect(value).toBeOneOf([expect.any(String), null]) //
774
+ // RIGHT
775
+ expect(value === null || typeof value === "string").toBe(true)
809
776
 
810
- // WRONG — putting test files in non-standard locations
811
- // src/modules/post/tests/post.test.ts won't be picked up
777
+ // WRONG — typeof checks on result fields
778
+ expect(typeof result.id).toBe("string") //
812
779
  // RIGHT
813
- // src/modules/post/__tests__/post.spec.ts
780
+ expect(result.id).toEqual(expect.any(String))
814
781
 
815
- // WRONG — calling workflow directly in tests without container
782
+ // WRONG — calling workflow without passing container
816
783
  await createPostWorkflow.run({ input: { title: "Test" } }) // ❌
817
784
  // RIGHT
818
785
  await createPostWorkflow(getContainer()).run({ input: { title: "Test" } })
819
786
 
820
787
  // 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
788
  await expect(
824
789
  createPostWorkflow(getContainer()).run({ input: {} })
825
- ).rejects.toThrow() // ❌ always fails — "Received function did not throw"
790
+ ).rejects.toThrow() // ❌ always fails — plain object, not Error instance
826
791
 
827
- // RIGHT — Option 1: use throwOnError: false + errors array (recommended)
792
+ // RIGHT — Option 1: throwOnError: false + errors array (recommended)
828
793
  const { errors } = await createPostWorkflow(getContainer()).run({
829
794
  input: {},
830
795
  throwOnError: false,
@@ -832,7 +797,7 @@ const { errors } = await createPostWorkflow(getContainer()).run({
832
797
  expect(errors).toHaveLength(1)
833
798
  expect(errors[0].error.message).toContain("title")
834
799
 
835
- // RIGHT — Option 2: use .rejects.toEqual() for plain object matching
800
+ // RIGHT — Option 2: .rejects.toEqual() for plain object matching
836
801
  await expect(
837
802
  createPostWorkflow(getContainer()).run({ input: {} })
838
803
  ).rejects.toEqual(
@@ -841,364 +806,46 @@ await expect(
841
806
  })
842
807
  )
843
808
 
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
809
+ // WRONG wrapping body in { body: ... }
810
+ await api.post("/admin/posts", { body: { title: "Test" } }) // ❌
850
811
  // 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
- )
1015
-
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
- ```
1037
-
1038
- ---
1039
-
1040
- ## Link / Relation Testing
1041
-
1042
- Test cross-module links with `remoteLink` and `query.graph()`:
1043
-
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
- })
1076
-
1077
- expect(linkedPost.category.name).toBe("Tech")
1078
- })
1079
- ```
812
+ await api.post("/admin/posts", { title: "Test" }, adminHeaders)
1080
813
 
1081
- ---
814
+ // WRONG — vague range hides which error actually occurred
815
+ expect(response.status).toBeGreaterThanOrEqual(400) // ❌
816
+ // RIGHT
817
+ expect(response.status).toEqual(400)
1082
818
 
1083
- ## Sorting / Ordering Verification
819
+ // WRONG resolve path as relative from test file
820
+ integrationTestRunner({ mode: "module", moduleName: "post", resolve: "../index" }) // ❌
821
+ // RIGHT — absolute path from CWD
822
+ integrationTestRunner({ mode: "module", moduleName: "post", resolve: process.cwd() + "/src/modules/post" })
1084
823
 
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"])
1097
- })
824
+ // WRONG — guessing service resolution key
825
+ getContainer().resolve("myModuleService") //
826
+ getContainer().resolve("my-module-service") //
827
+ // RIGHT
828
+ import { MY_MODULE } from "../../src/modules/my-module"
829
+ getContainer().resolve(MY_MODULE)
1098
830
 
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
- }
1108
- })
1109
- ```
831
+ // WRONG using jsonwebtoken directly
832
+ import jwt from "jsonwebtoken"
833
+ const token = jwt.sign({ user_id: user.id }, "supersecret") // ❌
834
+ // RIGHT — use generateJwtToken with config-resolved secret
1110
835
 
1111
- ---
836
+ // WRONG — using old publishable API key names
837
+ await apiKeyModule.createApiKeys({ type: "publishable" }) // ❌
838
+ headers: { "x-publishable-api-key": token } // ❌
839
+ // RIGHT
840
+ await apiKeyModule.createApiKeys({ type: ApiKeyType.CLIENT })
841
+ headers: { [CLIENT_API_KEY_HEADER]: token }
1112
842
 
1113
- ## Complex Seeding in beforeEach
843
+ // WRONG unused imports
844
+ import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if never used
1114
845
 
1115
- When tests need multiple related entities, seed them in `beforeEach` using admin API calls:
846
+ // WRONG JSDoc comment blocks at file top (test files never have these)
847
+ /** POST /admin/posts — validates body */ // ❌
1116
848
 
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
- })
849
+ // WRONG — type casts in tests
850
+ const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
1155
851
  ```
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
- })