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