@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.
- package/dist/templates/app/.claude/agents/test-writer.md +287 -379
- package/dist/templates/app/.claude/commands/test.md +7 -1
- package/dist/templates/app/.claude/rules/testing.md +493 -846
- package/dist/templates/app/.claude/skills/write-test/SKILL.md +175 -117
- package/dist/templates/plugin/.claude/agents/test-writer.md +332 -273
- package/dist/templates/plugin/.claude/commands/test.md +7 -1
- package/dist/templates/plugin/.claude/rules/testing.md +397 -653
- package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +233 -153
- package/package.json +39 -39
|
@@ -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
|
-
**`
|
|
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 |
|
|
28
|
-
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
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
|
-
##
|
|
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 {
|
|
57
|
-
import {
|
|
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(
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
###
|
|
339
|
+
### Module mode options
|
|
97
340
|
|
|
98
341
|
| Option | Type | Default | Description |
|
|
99
342
|
|---|---|---|---|
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
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
|
|
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
|
|
107
|
-
| `
|
|
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
|
-
###
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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: "
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
//
|
|
466
|
+
// 400 — validation 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
|
|
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
|
-
//
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
##
|
|
595
|
+
## Testing Jobs
|
|
622
596
|
|
|
623
|
-
|
|
597
|
+
Import the job function directly and call it with the container:
|
|
624
598
|
|
|
625
599
|
```typescript
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
630
|
-
await utils.waitWorkflowExecutions()
|
|
604
|
+
jest.setTimeout(60 * 1000)
|
|
631
605
|
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
##
|
|
630
|
+
## Workflow Testing
|
|
640
631
|
|
|
641
|
-
|
|
632
|
+
### Direct execution (no HTTP)
|
|
642
633
|
|
|
643
634
|
```typescript
|
|
644
|
-
import {
|
|
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
|
|
647
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
"
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
##
|
|
692
|
+
## Service Resolution
|
|
663
693
|
|
|
664
694
|
```typescript
|
|
665
|
-
//
|
|
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
|
|
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
|
-
//
|
|
675
|
-
|
|
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
|
-
//
|
|
684
|
-
|
|
706
|
+
// WRONG — string 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
|
|
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 —
|
|
702
|
-
acmekitIntegrationTestRunner
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
|
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
|
|
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
|
|
776
|
-
await
|
|
777
|
-
await
|
|
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 —
|
|
791
|
-
|
|
792
|
-
|
|
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 —
|
|
811
|
-
|
|
777
|
+
// WRONG — typeof checks on result fields
|
|
778
|
+
expect(typeof result.id).toBe("string") // ❌
|
|
812
779
|
// RIGHT
|
|
813
|
-
|
|
780
|
+
expect(result.id).toEqual(expect.any(String))
|
|
814
781
|
|
|
815
|
-
// WRONG — calling workflow
|
|
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 —
|
|
790
|
+
).rejects.toThrow() // ❌ always fails — plain object, not Error instance
|
|
826
791
|
|
|
827
|
-
// RIGHT — Option 1:
|
|
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:
|
|
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
|
-
//
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
843
|
+
// WRONG — unused imports
|
|
844
|
+
import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if never used
|
|
1114
845
|
|
|
1115
|
-
|
|
846
|
+
// WRONG — JSDoc comment blocks at file top (test files never have these)
|
|
847
|
+
/** POST /admin/posts — validates body */ // ❌
|
|
1116
848
|
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
})
|