@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,13 +10,15 @@ 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 (`pluginIntegrationTestRunner`, `moduleIntegrationTestRunner`) are deprecated aliases — NEVER use them.
|
|
14
|
+
|
|
13
15
|
**Service resolution key = module constant.** `container.resolve(MY_MODULE)` where `MY_MODULE = "my-module"` (the string passed to `Module()`). NEVER guess `"myModuleService"` — 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
|
-
**Schema sync vs migrations.** `
|
|
21
|
+
**Schema sync vs migrations.** `mode: "plugin"` (without `http: true`) and `mode: "module"` sync DB schema from entities — no migration files needed. `mode: "plugin"` with `http: true` runs full migrations like app mode.
|
|
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
|
|
|
@@ -24,860 +26,602 @@ paths:
|
|
|
24
26
|
|
|
25
27
|
## Test Runner Selection
|
|
26
28
|
|
|
27
|
-
| What to test |
|
|
28
|
-
|
|
29
|
-
| Full plugin (modules + workflows + subscribers + jobs) | `
|
|
30
|
-
|
|
|
31
|
-
|
|
|
29
|
+
| What to test | Mode | HTTP? | Fixtures | DB setup |
|
|
30
|
+
|---|---|---|---|---|
|
|
31
|
+
| Full plugin (modules + workflows + subscribers + jobs) | `mode: "plugin"` | no | `container`, `acmekitApp`, `MikroOrmWrapper`, `dbConfig` | Schema sync |
|
|
32
|
+
| Plugin HTTP routes end-to-end | `mode: "plugin"` + `http: true` | yes | `api`, `container`, `dbConfig` | Runs migrations |
|
|
33
|
+
| Module service CRUD in isolation | `mode: "module"` | no | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync |
|
|
34
|
+
| Pure functions (no DB) | Plain Jest `describe/it` | — | none | N/A |
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
---
|
|
34
37
|
|
|
35
38
|
## File Locations (must match `jest.config.js` buckets)
|
|
36
39
|
|
|
37
40
|
```
|
|
38
|
-
integration-tests/plugin/<feature>.spec.ts
|
|
39
|
-
|
|
40
|
-
src
|
|
41
|
+
integration-tests/plugin/<feature>.spec.ts → TEST_TYPE=integration:plugin
|
|
42
|
+
integration-tests/http/<feature>.spec.ts → TEST_TYPE=integration:http
|
|
43
|
+
src/modules/<mod>/__tests__/<name>.spec.ts → TEST_TYPE=integration:modules
|
|
44
|
+
src/**/__tests__/<name>.unit.spec.ts → TEST_TYPE=unit
|
|
41
45
|
```
|
|
42
46
|
|
|
47
|
+
**`integration-tests/plugin/`** — container-only tests (module CRUD, workflows, subscribers, jobs, event spying). No HTTP server.
|
|
48
|
+
|
|
49
|
+
**`integration-tests/http/`** — full HTTP tests with Express. Boots the entire framework with the plugin installed. Requires auth setup (JWT + client API key).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
43
53
|
## Commands
|
|
44
54
|
|
|
45
55
|
```bash
|
|
46
56
|
pnpm test:unit # Unit tests
|
|
47
57
|
pnpm test:integration:modules # Module integration tests
|
|
48
|
-
pnpm test:integration:plugin #
|
|
58
|
+
pnpm test:integration:plugin # Plugin integration tests (container-only)
|
|
59
|
+
pnpm test:integration:http # Plugin HTTP integration tests
|
|
49
60
|
```
|
|
50
61
|
|
|
51
62
|
All integration tests require `NODE_OPTIONS=--experimental-vm-modules` (set in package.json scripts).
|
|
52
63
|
|
|
53
64
|
---
|
|
54
65
|
|
|
55
|
-
## Plugin Integration Tests
|
|
66
|
+
## Plugin Integration Tests (`integration-tests/plugin/`)
|
|
67
|
+
|
|
68
|
+
No HTTP server — test services, workflows, subscribers, and jobs directly through the container.
|
|
56
69
|
|
|
57
70
|
```typescript
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
71
|
+
import { integrationTestRunner, MockEventBusService } from "@acmekit/test-utils"
|
|
72
|
+
import { Modules } from "@acmekit/framework/utils"
|
|
73
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
61
74
|
|
|
62
75
|
jest.setTimeout(60 * 1000)
|
|
63
76
|
|
|
64
|
-
|
|
77
|
+
integrationTestRunner({
|
|
78
|
+
mode: "plugin",
|
|
65
79
|
pluginPath: process.cwd(),
|
|
66
80
|
pluginOptions: {
|
|
67
81
|
apiKey: "test-api-key",
|
|
68
82
|
},
|
|
69
|
-
|
|
70
|
-
// injectedDependencies: { externalApi: mockExternalApi },
|
|
71
|
-
testSuite: ({ container, acmekitApp, MikroOrmWrapper }) => {
|
|
83
|
+
testSuite: ({ container, acmekitApp }) => {
|
|
72
84
|
describe("Plugin loading", () => {
|
|
73
|
-
it("should load plugin
|
|
85
|
+
it("should load plugin resources", () => {
|
|
74
86
|
expect(acmekitApp.modules).toBeDefined()
|
|
75
87
|
})
|
|
76
88
|
|
|
77
|
-
it("should resolve
|
|
78
|
-
const
|
|
79
|
-
expect(
|
|
89
|
+
it("should resolve EVENT_BUS as MockEventBusService", () => {
|
|
90
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
91
|
+
expect(eventBus).toBeInstanceOf(MockEventBusService)
|
|
80
92
|
})
|
|
81
93
|
})
|
|
82
94
|
|
|
83
|
-
describe("
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it("should create a blog post", async () => {
|
|
91
|
-
const result = await service.createBlogPosts([
|
|
92
|
-
{ title: "Launch Announcement" },
|
|
95
|
+
describe("GreetingModule CRUD", () => {
|
|
96
|
+
it("should create a greeting", async () => {
|
|
97
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
98
|
+
const result = await service.createGreetings([
|
|
99
|
+
{ message: "Hello World" },
|
|
93
100
|
])
|
|
94
101
|
expect(result).toHaveLength(1)
|
|
95
102
|
expect(result[0]).toEqual(
|
|
96
103
|
expect.objectContaining({
|
|
97
104
|
id: expect.any(String),
|
|
98
|
-
|
|
105
|
+
message: "Hello World",
|
|
99
106
|
})
|
|
100
107
|
)
|
|
101
108
|
})
|
|
102
109
|
|
|
103
|
-
it("should list and count", async () => {
|
|
104
|
-
await service.createBlogPosts([
|
|
105
|
-
{ title: "Post A" },
|
|
106
|
-
{ title: "Post B" },
|
|
107
|
-
])
|
|
108
|
-
const [posts, count] = await service.listAndCountBlogPosts()
|
|
109
|
-
expect(count).toBe(2)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it("should soft delete and restore", async () => {
|
|
113
|
-
const [created] = await service.createBlogPosts([
|
|
114
|
-
{ title: "To Delete" },
|
|
115
|
-
])
|
|
116
|
-
await service.softDeleteBlogPosts([created.id])
|
|
117
|
-
await expect(
|
|
118
|
-
service.retrieveBlogPost(created.id)
|
|
119
|
-
).rejects.toThrow()
|
|
120
|
-
|
|
121
|
-
await service.restoreBlogPosts([created.id])
|
|
122
|
-
const restored = await service.retrieveBlogPost(created.id)
|
|
123
|
-
expect(restored.title).toBe("To Delete")
|
|
124
|
-
})
|
|
125
|
-
|
|
126
110
|
it("should throw on missing required field", async () => {
|
|
127
|
-
|
|
111
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
112
|
+
await expect(service.createGreetings([{}])).rejects.toThrow()
|
|
128
113
|
})
|
|
129
114
|
})
|
|
130
115
|
},
|
|
131
116
|
})
|
|
132
117
|
```
|
|
133
118
|
|
|
134
|
-
###
|
|
119
|
+
### Plugin mode options
|
|
135
120
|
|
|
136
121
|
| Option | Type | Default | Description |
|
|
137
122
|
|---|---|---|---|
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
123
|
+
| `mode` | `"plugin"` | **(required)** | Selects plugin mode |
|
|
124
|
+
| `pluginPath` | `string` | **(required)** | Path to plugin root — always use `process.cwd()` |
|
|
125
|
+
| `pluginOptions` | `Record<string, unknown>` | `{}` | Simulates host app plugin config |
|
|
126
|
+
| `http` | `boolean` | `false` | Set `true` to boot full Express server for HTTP tests |
|
|
140
127
|
| `additionalModules` | `Record<string, any>` | `{}` | Extra modules to load alongside the plugin |
|
|
141
128
|
| `injectedDependencies` | `Record<string, any>` | `{}` | Mock services to register in the container |
|
|
142
|
-
| `schema` | `string` | `"public"` | Postgres schema |
|
|
143
129
|
| `dbName` | `string` | auto-generated | Override the computed DB name |
|
|
130
|
+
| `schema` | `string` | `"public"` | Postgres schema |
|
|
144
131
|
| `debug` | `boolean` | `false` | Enables DB query logging |
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `testSuite` | `(options: PluginSuiteOptions) => void` | **(required)** | Test callback |
|
|
132
|
+
| `hooks` | `RunnerHooks` | `{}` | Lifecycle hooks |
|
|
133
|
+
| `testSuite` | `(options) => void` | **(required)** | Test callback |
|
|
148
134
|
|
|
149
|
-
###
|
|
135
|
+
### Plugin mode fixtures (container-only)
|
|
150
136
|
|
|
151
137
|
- `container` — proxy to the shared `AcmeKitContainer` (auto-refreshed each `beforeEach`)
|
|
152
138
|
- `acmekitApp` — proxy to the `AcmeKitApp` instance (has `.modules`, `.sharedContainer`)
|
|
153
139
|
- `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
|
|
154
|
-
- `dbConfig` — `{ schema, clientUrl }`
|
|
140
|
+
- `dbConfig` — `{ schema, clientUrl, dbName }`
|
|
141
|
+
|
|
142
|
+
### Plugin test lifecycle (container-only)
|
|
155
143
|
|
|
156
|
-
|
|
144
|
+
Each `it` block gets: schema drop + recreate → fresh plugin boot (all modules, subscribers, workflows, jobs loaded) → test runs → schema clear → module shutdown. No manual cleanup needed.
|
|
157
145
|
|
|
158
|
-
|
|
146
|
+
---
|
|
159
147
|
|
|
160
|
-
|
|
148
|
+
## Plugin HTTP Integration Tests (`integration-tests/http/`)
|
|
149
|
+
|
|
150
|
+
Full Express server with the plugin installed. Boots the entire framework including all core modules (user, auth, api-key, etc.), runs migrations, and serves plugin routes.
|
|
161
151
|
|
|
162
152
|
```typescript
|
|
163
|
-
import {
|
|
153
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
154
|
+
import {
|
|
155
|
+
ApiKeyType,
|
|
156
|
+
CLIENT_API_KEY_HEADER,
|
|
157
|
+
ContainerRegistrationKeys,
|
|
158
|
+
generateJwtToken,
|
|
159
|
+
Modules,
|
|
160
|
+
} from "@acmekit/framework/utils"
|
|
161
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
162
|
+
|
|
163
|
+
jest.setTimeout(120 * 1000)
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
integrationTestRunner({
|
|
166
|
+
mode: "plugin",
|
|
167
|
+
http: true,
|
|
166
168
|
pluginPath: process.cwd(),
|
|
167
|
-
pluginOptions: { apiKey: "test-key"
|
|
168
|
-
testSuite: ({ container }) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
pluginOptions: { apiKey: "test-api-key" },
|
|
170
|
+
testSuite: ({ api, container }) => {
|
|
171
|
+
let adminHeaders: Record<string, any>
|
|
172
|
+
let clientHeaders: Record<string, any>
|
|
173
|
+
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
const userModule = container.resolve(Modules.USER)
|
|
176
|
+
const authModule = container.resolve(Modules.AUTH)
|
|
177
|
+
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
178
|
+
|
|
179
|
+
const user = await userModule.createUsers({
|
|
180
|
+
email: "admin@test.js",
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const authIdentity = await authModule.createAuthIdentities({
|
|
184
|
+
provider_identities: [
|
|
185
|
+
{ provider: "emailpass", entity_id: "admin@test.js" },
|
|
186
|
+
],
|
|
187
|
+
app_metadata: { user_id: user.id },
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const config = container.resolve(
|
|
191
|
+
ContainerRegistrationKeys.CONFIG_MODULE
|
|
192
|
+
)
|
|
193
|
+
const { jwtSecret, jwtOptions } = config.projectConfig.http
|
|
194
|
+
|
|
195
|
+
const token = generateJwtToken(
|
|
196
|
+
{
|
|
197
|
+
actor_id: user.id,
|
|
198
|
+
actor_type: "user",
|
|
199
|
+
auth_identity_id: authIdentity.id,
|
|
200
|
+
app_metadata: { user_id: user.id },
|
|
201
|
+
},
|
|
202
|
+
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
adminHeaders = {
|
|
206
|
+
headers: { authorization: `Bearer ${token}` },
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const apiKey = await apiKeyModule.createApiKeys({
|
|
210
|
+
title: "Test Client Key",
|
|
211
|
+
type: ApiKeyType.CLIENT,
|
|
212
|
+
created_by: "test",
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
clientHeaders = {
|
|
216
|
+
headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe("Admin plugin routes", () => {
|
|
221
|
+
it("POST /admin/plugin/greetings creates a greeting", async () => {
|
|
222
|
+
const response = await api.post(
|
|
223
|
+
"/admin/plugin/greetings",
|
|
224
|
+
{ message: "Hello from HTTP" },
|
|
225
|
+
adminHeaders
|
|
226
|
+
)
|
|
227
|
+
expect(response.status).toEqual(200)
|
|
228
|
+
expect(response.data.greeting).toEqual(
|
|
229
|
+
expect.objectContaining({
|
|
230
|
+
id: expect.any(String),
|
|
231
|
+
message: "Hello from HTTP",
|
|
232
|
+
})
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("DELETE /admin/plugin/greetings/:id soft-deletes", async () => {
|
|
237
|
+
const created = await api.post(
|
|
238
|
+
"/admin/plugin/greetings",
|
|
239
|
+
{ message: "Delete me" },
|
|
240
|
+
adminHeaders
|
|
241
|
+
)
|
|
242
|
+
const response = await api.delete(
|
|
243
|
+
`/admin/plugin/greetings/${created.data.greeting.id}`,
|
|
244
|
+
adminHeaders
|
|
245
|
+
)
|
|
246
|
+
expect(response.data).toEqual({
|
|
247
|
+
id: created.data.greeting.id,
|
|
248
|
+
object: "greeting",
|
|
249
|
+
deleted: true,
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe("Client plugin routes", () => {
|
|
255
|
+
it("GET /client/plugin/greetings with API key", async () => {
|
|
256
|
+
await api.post(
|
|
257
|
+
"/admin/plugin/greetings",
|
|
258
|
+
{ message: "Public" },
|
|
259
|
+
adminHeaders
|
|
260
|
+
)
|
|
261
|
+
const response = await api.get(
|
|
262
|
+
"/client/plugin/greetings",
|
|
263
|
+
clientHeaders
|
|
264
|
+
)
|
|
265
|
+
expect(response.status).toEqual(200)
|
|
266
|
+
expect(response.data.greetings).toHaveLength(1)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe("Auth enforcement", () => {
|
|
271
|
+
it("admin route rejects without JWT", async () => {
|
|
272
|
+
const error = await api
|
|
273
|
+
.get("/admin/plugin")
|
|
274
|
+
.catch((e: any) => e)
|
|
275
|
+
expect(error.response.status).toEqual(401)
|
|
276
|
+
})
|
|
173
277
|
})
|
|
174
278
|
},
|
|
175
279
|
})
|
|
176
280
|
```
|
|
177
281
|
|
|
282
|
+
### Plugin HTTP fixtures
|
|
283
|
+
|
|
284
|
+
- `api` — axios instance pointed at `http://localhost:<port>`
|
|
285
|
+
- `container` — proxy to the live container (includes all framework modules)
|
|
286
|
+
- `dbConfig` — `{ schema, clientUrl, dbName }`
|
|
287
|
+
|
|
178
288
|
---
|
|
179
289
|
|
|
180
290
|
## Module Integration Tests
|
|
181
291
|
|
|
182
292
|
```typescript
|
|
183
|
-
import {
|
|
184
|
-
import { MY_MODULE } from "../../src/modules/my-module" // MY_MODULE = "my-module" — must match Module() key
|
|
293
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
185
294
|
|
|
186
295
|
jest.setTimeout(30000)
|
|
187
296
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// moduleOptions: { jwt_secret: "test" },
|
|
297
|
+
integrationTestRunner<IGreetingModuleService>({
|
|
298
|
+
mode: "module",
|
|
299
|
+
moduleName: "greeting",
|
|
300
|
+
resolve: process.cwd() + "/src/modules/greeting",
|
|
193
301
|
testSuite: ({ service }) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
describe("createPosts", () => {
|
|
199
|
-
it("should create a post", async () => {
|
|
200
|
-
const result = await service.createPosts([
|
|
201
|
-
{ title: "Quarterly Report" },
|
|
302
|
+
describe("createGreetings", () => {
|
|
303
|
+
it("should create a greeting", async () => {
|
|
304
|
+
const result = await service.createGreetings([
|
|
305
|
+
{ message: "Hello" },
|
|
202
306
|
])
|
|
203
307
|
expect(result).toHaveLength(1)
|
|
204
308
|
expect(result[0]).toEqual(
|
|
205
309
|
expect.objectContaining({
|
|
206
310
|
id: expect.any(String),
|
|
207
|
-
|
|
311
|
+
message: "Hello",
|
|
208
312
|
})
|
|
209
313
|
)
|
|
210
314
|
})
|
|
211
|
-
|
|
212
|
-
it("should throw on missing required field", async () => {
|
|
213
|
-
await expect(service.createPosts([{}])).rejects.toThrow()
|
|
214
|
-
})
|
|
215
315
|
})
|
|
216
316
|
},
|
|
217
317
|
})
|
|
218
318
|
```
|
|
219
319
|
|
|
220
|
-
###
|
|
320
|
+
### Module mode fixtures
|
|
221
321
|
|
|
222
322
|
- `service` — proxy to the module service (auto-refreshed each `beforeEach`)
|
|
223
|
-
- `MikroOrmWrapper` — raw DB access
|
|
323
|
+
- `MikroOrmWrapper` — raw DB access
|
|
224
324
|
- `acmekitApp` — proxy to the `AcmeKitApp` instance
|
|
225
325
|
- `dbConfig` — `{ schema, clientUrl }`
|
|
226
326
|
|
|
227
|
-
###
|
|
327
|
+
### Module test lifecycle
|
|
228
328
|
|
|
229
|
-
|
|
230
|
-
// --- Create ---
|
|
231
|
-
const [post] = await service.createPosts([{ title: "Test" }])
|
|
232
|
-
|
|
233
|
-
// --- List with filters ---
|
|
234
|
-
const filtered = await service.listPosts({ status: "published" })
|
|
235
|
-
const withRelations = await service.listPosts({}, { relations: ["comments"] })
|
|
236
|
-
|
|
237
|
-
// --- List and count ---
|
|
238
|
-
const [posts, count] = await service.listAndCountPosts()
|
|
239
|
-
expect(count).toEqual(2)
|
|
240
|
-
|
|
241
|
-
// --- Retrieve ---
|
|
242
|
-
const post = await service.retrievePost(id)
|
|
243
|
-
|
|
244
|
-
// --- Retrieve with select (verify no extra fields) ---
|
|
245
|
-
const withSelect = await service.retrievePost(id, { select: ["id"] })
|
|
246
|
-
const serialized = JSON.parse(JSON.stringify(withSelect))
|
|
247
|
-
expect(serialized).toEqual({ id })
|
|
248
|
-
|
|
249
|
-
// --- Update ---
|
|
250
|
-
const updated = await service.updatePosts(id, { title: "New Title" })
|
|
251
|
-
|
|
252
|
-
// --- Soft delete / restore ---
|
|
253
|
-
await service.softDeletePosts([id])
|
|
254
|
-
const listed = await service.listPosts({ id })
|
|
255
|
-
expect(listed).toHaveLength(0)
|
|
256
|
-
await service.restorePosts([id])
|
|
257
|
-
const restored = await service.listPosts({ id })
|
|
258
|
-
expect(restored).toHaveLength(1)
|
|
259
|
-
|
|
260
|
-
// --- Hard delete ---
|
|
261
|
-
await service.deletePosts([id])
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
### Error handling in module tests
|
|
265
|
-
|
|
266
|
-
```typescript
|
|
267
|
-
// Style 1: .catch((e) => e) — preferred when checking message
|
|
268
|
-
const error = await service.retrievePost("nonexistent").catch((e) => e)
|
|
269
|
-
expect(error.message).toEqual("Post with id: nonexistent was not found")
|
|
270
|
-
|
|
271
|
-
// Style 2: rejects.toThrow() — when only checking it throws
|
|
272
|
-
await expect(service.createPosts([{}])).rejects.toThrow()
|
|
273
|
-
|
|
274
|
-
// Style 3: check error.type
|
|
275
|
-
let error
|
|
276
|
-
try {
|
|
277
|
-
await service.deleteApiKeys([unrevokedKey.id])
|
|
278
|
-
} catch (e) {
|
|
279
|
-
error = e
|
|
280
|
-
}
|
|
281
|
-
expect(error.type).toEqual("not_allowed")
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
### Related entity testing
|
|
285
|
-
|
|
286
|
-
```typescript
|
|
287
|
-
const [category] = await service.createCategories([{ name: "Tech" }])
|
|
288
|
-
const [post] = await service.createPosts([
|
|
289
|
-
{ title: "Test", category_id: category.id },
|
|
290
|
-
])
|
|
291
|
-
|
|
292
|
-
const withRelation = await service.retrievePost(post.id, {
|
|
293
|
-
relations: ["category"],
|
|
294
|
-
})
|
|
295
|
-
expect(withRelation.category.name).toBe("Tech")
|
|
296
|
-
```
|
|
329
|
+
Each `it` block gets: schema drop + recreate → fresh module boot → test runs → schema clear → module shutdown. No manual cleanup needed.
|
|
297
330
|
|
|
298
331
|
---
|
|
299
332
|
|
|
300
|
-
## Testing Workflows
|
|
333
|
+
## Testing Workflows
|
|
301
334
|
|
|
302
|
-
Workflows are
|
|
335
|
+
Workflows are called via `workflowFn(container).run({ input })`:
|
|
303
336
|
|
|
304
337
|
```typescript
|
|
305
|
-
import {
|
|
338
|
+
import { createGreetingsWorkflow } from "../../src/workflows"
|
|
306
339
|
|
|
307
|
-
|
|
340
|
+
integrationTestRunner({
|
|
341
|
+
mode: "plugin",
|
|
308
342
|
pluginPath: process.cwd(),
|
|
309
343
|
testSuite: ({ container }) => {
|
|
310
|
-
it("should
|
|
311
|
-
const { result } = await
|
|
312
|
-
input: {
|
|
313
|
-
customerId: "cus_123",
|
|
314
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
315
|
-
},
|
|
344
|
+
it("should create a greeting via workflow", async () => {
|
|
345
|
+
const { result } = await createGreetingsWorkflow(container).run({
|
|
346
|
+
input: { greetings: [{ message: "Workflow Hello" }] },
|
|
316
347
|
})
|
|
317
|
-
expect(result
|
|
318
|
-
|
|
319
|
-
)
|
|
348
|
+
expect(result).toHaveLength(1)
|
|
349
|
+
expect(result[0].message).toBe("Workflow Hello")
|
|
320
350
|
})
|
|
321
351
|
|
|
322
|
-
it("should reject invalid
|
|
323
|
-
const { errors } = await
|
|
324
|
-
input: {},
|
|
352
|
+
it("should reject invalid input", async () => {
|
|
353
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
354
|
+
input: { greetings: [{ message: "", status: "invalid" }] },
|
|
325
355
|
throwOnError: false,
|
|
326
356
|
})
|
|
327
357
|
expect(errors).toHaveLength(1)
|
|
358
|
+
expect(errors[0].error.message).toContain("Invalid")
|
|
328
359
|
})
|
|
329
360
|
},
|
|
330
361
|
})
|
|
331
362
|
```
|
|
332
363
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
## Asserting Domain Events
|
|
336
|
-
|
|
337
|
-
Both runners inject `MockEventBusService` under `Modules.EVENT_BUS`. Spy on the **prototype**, not an instance.
|
|
364
|
+
### Step compensation
|
|
338
365
|
|
|
339
366
|
```typescript
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
let eventBusSpy: jest.SpyInstance
|
|
343
|
-
|
|
344
|
-
beforeEach(() => {
|
|
345
|
-
eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
afterEach(() => {
|
|
349
|
-
eventBusSpy.mockClear()
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
it("should emit blog-post.created event", async () => {
|
|
353
|
-
const service = container.resolve(BLOG_MODULE) // "blog" — from Module() key
|
|
354
|
-
await service.createBlogPosts([{ title: "Event Test" }])
|
|
367
|
+
it("should compensate on failure", async () => {
|
|
368
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
355
369
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
expect.objectContaining({
|
|
362
|
-
name: "blog-post.created",
|
|
363
|
-
data: expect.objectContaining({ id: expect.any(String) }),
|
|
364
|
-
}),
|
|
365
|
-
])
|
|
366
|
-
)
|
|
370
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
371
|
+
input: { greetings: [{ message: "Will Fail", trigger_error: true }] },
|
|
372
|
+
throwOnError: false,
|
|
373
|
+
})
|
|
374
|
+
expect(errors).toHaveLength(1)
|
|
367
375
|
|
|
368
|
-
//
|
|
369
|
-
|
|
376
|
+
// Verify compensation ran — greeting rolled back
|
|
377
|
+
const greetings = await service.listGreetings()
|
|
378
|
+
expect(greetings).toHaveLength(0)
|
|
370
379
|
})
|
|
371
380
|
```
|
|
372
381
|
|
|
373
382
|
---
|
|
374
383
|
|
|
375
|
-
##
|
|
384
|
+
## Testing Subscribers
|
|
376
385
|
|
|
377
|
-
|
|
386
|
+
### Direct handler invocation (plugin mode — container-only)
|
|
378
387
|
|
|
379
|
-
|
|
380
|
-
import { TestEventUtils } from "@acmekit/test-utils"
|
|
381
|
-
import { Modules } from "@acmekit/framework/utils"
|
|
382
|
-
|
|
383
|
-
it("should execute subscriber side-effect", async () => {
|
|
384
|
-
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
385
|
-
|
|
386
|
-
// Create promise BEFORE triggering event
|
|
387
|
-
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
|
388
|
-
"blog-post.created",
|
|
389
|
-
eventBus
|
|
390
|
-
)
|
|
391
|
-
const service = container.resolve(BLOG_MODULE) // "blog" — from Module() key
|
|
392
|
-
await service.createBlogPosts([{ title: "Trigger Event" }])
|
|
393
|
-
await subscriberExecution
|
|
394
|
-
// now assert subscriber side-effect
|
|
395
|
-
})
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
---
|
|
399
|
-
|
|
400
|
-
## Resolving Services in Tests
|
|
388
|
+
Import the handler and call it manually with `{ event, container }`:
|
|
401
389
|
|
|
402
390
|
```typescript
|
|
403
|
-
|
|
404
|
-
import
|
|
405
|
-
|
|
406
|
-
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
407
|
-
|
|
408
|
-
// In moduleIntegrationTestRunner — use the service fixture directly
|
|
409
|
-
const result = await service.listBlogPosts()
|
|
410
|
-
|
|
411
|
-
// NEVER guess the key — "blogModuleService", "blog-module", "BlogModule" are all WRONG
|
|
412
|
-
// The key is EXACTLY the string passed to Module() in your module definition
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
---
|
|
416
|
-
|
|
417
|
-
## JWT Token Generation
|
|
418
|
-
|
|
419
|
-
**ALWAYS use `generateJwtToken` from `@acmekit/framework/utils`.** NEVER use `jsonwebtoken` directly or hardcode the JWT secret.
|
|
391
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
392
|
+
import greetingCreatedHandler from "../../src/subscribers/greeting-created"
|
|
393
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
420
394
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
```typescript
|
|
424
|
-
import {
|
|
425
|
-
ContainerRegistrationKeys,
|
|
426
|
-
generateJwtToken,
|
|
427
|
-
Modules,
|
|
428
|
-
} from "@acmekit/framework/utils"
|
|
395
|
+
jest.setTimeout(60 * 1000)
|
|
429
396
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
397
|
+
integrationTestRunner({
|
|
398
|
+
mode: "plugin",
|
|
399
|
+
pluginPath: process.cwd(),
|
|
400
|
+
testSuite: ({ container }) => {
|
|
401
|
+
it("should append ' [notified]' to greeting message", async () => {
|
|
402
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
403
|
+
const [greeting] = await service.createGreetings([
|
|
404
|
+
{ message: "Hello World" },
|
|
405
|
+
])
|
|
406
|
+
|
|
407
|
+
await greetingCreatedHandler({
|
|
408
|
+
event: {
|
|
409
|
+
data: { id: greeting.id },
|
|
410
|
+
name: "greeting.created",
|
|
411
|
+
},
|
|
412
|
+
container,
|
|
413
|
+
})
|
|
433
414
|
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
actor_type: "user", // or "customer" for customer auth
|
|
438
|
-
auth_identity_id: authIdentity.id,
|
|
439
|
-
app_metadata: { user_id: user.id },
|
|
415
|
+
const updated = await service.retrieveGreeting(greeting.id)
|
|
416
|
+
expect(updated.message).toBe("Hello World [notified]")
|
|
417
|
+
})
|
|
440
418
|
},
|
|
441
|
-
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
442
|
-
)
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
---
|
|
446
|
-
|
|
447
|
-
## Client API Key Generation
|
|
448
|
-
|
|
449
|
-
**ALWAYS use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER`.** NEVER use `type: "publishable"` or `"x-publishable-api-key"`.
|
|
450
|
-
|
|
451
|
-
```typescript
|
|
452
|
-
import {
|
|
453
|
-
ApiKeyType,
|
|
454
|
-
CLIENT_API_KEY_HEADER,
|
|
455
|
-
Modules,
|
|
456
|
-
} from "@acmekit/framework/utils"
|
|
457
|
-
|
|
458
|
-
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
459
|
-
const apiKey = await apiKeyModule.createApiKeys({
|
|
460
|
-
title: "Test Client Key",
|
|
461
|
-
type: ApiKeyType.CLIENT,
|
|
462
|
-
created_by: "system",
|
|
463
419
|
})
|
|
464
|
-
|
|
465
|
-
const clientHeaders = {
|
|
466
|
-
headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
|
|
467
|
-
}
|
|
468
420
|
```
|
|
469
421
|
|
|
470
|
-
|
|
422
|
+
### Event bus driven (plugin HTTP mode or app mode)
|
|
471
423
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
**ALWAYS use `Modules.*` constants** to resolve core services. NEVER use string literals like `"auth"`, `"user"`, `"customer"`.
|
|
424
|
+
Use `TestEventUtils.waitSubscribersExecution` with the real event bus:
|
|
475
425
|
|
|
476
426
|
```typescript
|
|
477
|
-
|
|
478
|
-
const authModule = container.resolve(Modules.AUTH)
|
|
479
|
-
const userModule = container.resolve(Modules.USER)
|
|
480
|
-
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
427
|
+
import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
|
|
481
428
|
|
|
482
|
-
//
|
|
483
|
-
const
|
|
484
|
-
|
|
429
|
+
// CRITICAL: create the promise BEFORE emitting the event
|
|
430
|
+
const subscriberDone = TestEventUtils.waitSubscribersExecution(
|
|
431
|
+
"greeting.created",
|
|
432
|
+
eventBus
|
|
433
|
+
)
|
|
434
|
+
await eventBus.emit({ name: "greeting.created", data: { id } })
|
|
435
|
+
await subscriberDone
|
|
485
436
|
```
|
|
486
437
|
|
|
487
438
|
---
|
|
488
439
|
|
|
489
|
-
##
|
|
490
|
-
|
|
491
|
-
- `jest.config.js` loads `.env.test` via `@acmekit/utils` `loadEnv("test", process.cwd())`
|
|
492
|
-
- `integration-tests/setup.js` clears `MetadataStorage` between test files (prevents MikroORM entity bleed)
|
|
493
|
-
- DB defaults: `DB_HOST=localhost`, `DB_USERNAME=postgres`, `DB_PASSWORD=""`, `DB_PORT=5432`
|
|
494
|
-
- Each test run creates a unique DB: `acmekit-<module>-integration-<JEST_WORKER_ID>`
|
|
495
|
-
|
|
496
|
-
---
|
|
440
|
+
## Testing Jobs
|
|
497
441
|
|
|
498
|
-
|
|
442
|
+
Import the job function directly and call with container:
|
|
499
443
|
|
|
500
444
|
```typescript
|
|
501
|
-
|
|
502
|
-
import
|
|
503
|
-
|
|
504
|
-
import { pluginIntegrationTestRunner } from "@acmekit/test-utils"
|
|
505
|
-
|
|
506
|
-
// WRONG — trying to use `api` in plugin tests (no HTTP server!)
|
|
507
|
-
testSuite: ({ api }) => { ... } // ❌ api is not available
|
|
508
|
-
// RIGHT — resolve services from container and call methods directly
|
|
509
|
-
testSuite: ({ container }) => {
|
|
510
|
-
const service = container.resolve(BLOG_MODULE)
|
|
511
|
-
await service.createBlogPosts([{ title: "Test" }])
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// WRONG — silently passes without asserting
|
|
515
|
-
const result = await createWorkflow(container).run({ input: body })
|
|
516
|
-
if (!result.result) return // ❌ test passes with zero assertions!
|
|
517
|
-
// RIGHT
|
|
518
|
-
const { result } = await createWorkflow(container).run({ input: body })
|
|
519
|
-
expect(result).toBeDefined()
|
|
520
|
-
|
|
521
|
-
// WRONG — using non-standard Jest matchers (not available without jest-extended)
|
|
522
|
-
expect(value).toBeOneOf([expect.any(String), null]) // ❌
|
|
523
|
-
expect(value).toSatisfy(fn) // ❌
|
|
524
|
-
// RIGHT
|
|
525
|
-
expect(value === null || typeof value === "string").toBe(true)
|
|
526
|
-
|
|
527
|
-
// WRONG — typeof checks on result fields
|
|
528
|
-
expect(typeof result.id).toBe("string") // ❌
|
|
529
|
-
// RIGHT
|
|
530
|
-
expect(result.id).toEqual(expect.any(String))
|
|
531
|
-
|
|
532
|
-
// WRONG — JSDoc comment block at file top (test files never have these)
|
|
533
|
-
/** BlogModule — tests CRUD, validation, events */ // ❌
|
|
534
|
-
|
|
535
|
-
// WRONG — type casts in tests
|
|
536
|
-
const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
|
|
537
|
-
|
|
538
|
-
// WRONG — vague range assertions
|
|
539
|
-
expect(someStatus).toBeGreaterThanOrEqual(400) // ❌
|
|
540
|
-
// RIGHT
|
|
541
|
-
expect(someStatus).toEqual("voided")
|
|
542
|
-
|
|
543
|
-
// WRONG — asserting exact objects (timestamps/IDs change)
|
|
544
|
-
expect(result).toEqual({ id: "123", title: "Test", created_at: "..." }) // ❌
|
|
545
|
-
// RIGHT
|
|
546
|
-
expect(result).toEqual(expect.objectContaining({
|
|
547
|
-
id: expect.any(String),
|
|
548
|
-
title: "Test",
|
|
549
|
-
}))
|
|
550
|
-
|
|
551
|
-
// WRONG — calling waitSubscribersExecution AFTER triggering event
|
|
552
|
-
await service.createBlogPosts([{ title: "Test" }])
|
|
553
|
-
await TestEventUtils.waitSubscribersExecution("blog-post.created", eventBus) // ❌ may miss it
|
|
554
|
-
// RIGHT — capture promise BEFORE triggering
|
|
555
|
-
const execution = TestEventUtils.waitSubscribersExecution("blog-post.created", eventBus)
|
|
556
|
-
await service.createBlogPosts([{ title: "Test" }])
|
|
557
|
-
await execution
|
|
558
|
-
|
|
559
|
-
// WRONG — putting test files in non-standard locations
|
|
560
|
-
// integration-tests/tests/plugin.test.ts ← won't be picked up
|
|
561
|
-
// RIGHT
|
|
562
|
-
// integration-tests/plugin/plugin.spec.ts
|
|
563
|
-
|
|
564
|
-
// WRONG — hardcoding pluginPath
|
|
565
|
-
pluginIntegrationTestRunner({ pluginPath: "/absolute/path/to/plugin" }) // ❌
|
|
566
|
-
// RIGHT
|
|
567
|
-
pluginIntegrationTestRunner({ pluginPath: process.cwd() })
|
|
568
|
-
|
|
569
|
-
// WRONG — passing module object to resolve (type error: expects string)
|
|
570
|
-
import MyModule from "../index"
|
|
571
|
-
moduleIntegrationTestRunner({ resolve: MyModule, ... }) // ❌
|
|
572
|
-
|
|
573
|
-
// WRONG — relative path from test file (model discovery uses CWD, not test file dir!)
|
|
574
|
-
moduleIntegrationTestRunner({ resolve: "../index", ... }) // ❌ hangs — models not found
|
|
575
|
-
// RIGHT — path from project root (CWD)
|
|
576
|
-
moduleIntegrationTestRunner({ resolve: "./src/modules/my-module", ... })
|
|
577
|
-
|
|
578
|
-
// WRONG — unused imports
|
|
579
|
-
import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if never used
|
|
580
|
-
// RIGHT — only import what you use
|
|
581
|
-
|
|
582
|
-
// WRONG — calling plugin.resolveOptions(container) in module loaders
|
|
583
|
-
// Module container is scoped, not root — resolveOptions needs root container
|
|
584
|
-
// RIGHT — only use plugin.resolveOptions(container) in tests, subscribers, and workflows
|
|
585
|
-
|
|
586
|
-
// WRONG — calling workflow without passing container
|
|
587
|
-
await createOrderWorkflow.run({ input: { ... } }) // ❌
|
|
588
|
-
// RIGHT
|
|
589
|
-
await createOrderWorkflow(container).run({ input: { ... } })
|
|
590
|
-
|
|
591
|
-
// WRONG — using jsonwebtoken directly or hardcoding JWT secrets
|
|
592
|
-
import jwt from "jsonwebtoken"
|
|
593
|
-
const token = jwt.sign({ user_id: user.id }, "supersecret") // ❌
|
|
594
|
-
// RIGHT — use generateJwtToken with config-resolved secret
|
|
595
|
-
import { generateJwtToken, ContainerRegistrationKeys } from "@acmekit/framework/utils"
|
|
596
|
-
const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
|
597
|
-
const token = generateJwtToken(payload, { secret: config.projectConfig.http.jwtSecret })
|
|
598
|
-
|
|
599
|
-
// WRONG — using string "publishable" for API key type
|
|
600
|
-
await apiKeyModule.createApiKeys({ type: "publishable" }) // ❌
|
|
601
|
-
// RIGHT — use ApiKeyType.CLIENT enum
|
|
602
|
-
import { ApiKeyType } from "@acmekit/framework/utils"
|
|
603
|
-
await apiKeyModule.createApiKeys({ type: ApiKeyType.CLIENT })
|
|
604
|
-
|
|
605
|
-
// WRONG — using old publishable header name
|
|
606
|
-
headers: { "x-publishable-api-key": token } // ❌
|
|
607
|
-
// RIGHT — use CLIENT_API_KEY_HEADER constant
|
|
608
|
-
import { CLIENT_API_KEY_HEADER } from "@acmekit/framework/utils"
|
|
609
|
-
headers: { [CLIENT_API_KEY_HEADER]: token }
|
|
610
|
-
|
|
611
|
-
// WRONG — resolving core services with string literals
|
|
612
|
-
container.resolve("auth") // ❌
|
|
613
|
-
container.resolve("user") // ❌
|
|
614
|
-
// RIGHT — use Modules.* constants
|
|
615
|
-
container.resolve(Modules.AUTH)
|
|
616
|
-
container.resolve(Modules.USER)
|
|
445
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
446
|
+
import cleanupGreetingsJob from "../../src/jobs/cleanup-greetings"
|
|
447
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
617
448
|
|
|
618
|
-
|
|
619
|
-
// The distributed transaction engine serializes errors into plain objects
|
|
620
|
-
// (not Error instances), so .toThrow() never matches.
|
|
621
|
-
await expect(
|
|
622
|
-
createOrderWorkflow(container).run({ input: {} })
|
|
623
|
-
).rejects.toThrow() // ❌ always fails — "Received function did not throw"
|
|
624
|
-
|
|
625
|
-
// RIGHT — Option 1: use throwOnError: false + errors array (recommended)
|
|
626
|
-
const { errors } = await createOrderWorkflow(container).run({
|
|
627
|
-
input: {},
|
|
628
|
-
throwOnError: false,
|
|
629
|
-
})
|
|
630
|
-
expect(errors).toHaveLength(1)
|
|
631
|
-
expect(errors[0].error.message).toContain("customerId")
|
|
632
|
-
|
|
633
|
-
// RIGHT — Option 2: use .rejects.toEqual() for plain object matching
|
|
634
|
-
await expect(
|
|
635
|
-
createOrderWorkflow(container).run({ input: {} })
|
|
636
|
-
).rejects.toEqual(
|
|
637
|
-
expect.objectContaining({
|
|
638
|
-
message: expect.stringContaining("customerId"),
|
|
639
|
-
})
|
|
640
|
-
)
|
|
449
|
+
jest.setTimeout(60 * 1000)
|
|
641
450
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
451
|
+
integrationTestRunner({
|
|
452
|
+
mode: "plugin",
|
|
453
|
+
pluginPath: process.cwd(),
|
|
454
|
+
testSuite: ({ container }) => {
|
|
455
|
+
it("should soft-delete greetings with lang='old'", async () => {
|
|
456
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
646
457
|
|
|
647
|
-
|
|
458
|
+
await service.createGreetings([
|
|
459
|
+
{ message: "Old 1", lang: "old" },
|
|
460
|
+
{ message: "Old 2", lang: "old" },
|
|
461
|
+
{ message: "Current", lang: "en" },
|
|
462
|
+
])
|
|
648
463
|
|
|
649
|
-
|
|
464
|
+
await cleanupGreetingsJob(container)
|
|
650
465
|
|
|
651
|
-
|
|
466
|
+
const remaining = await service.listGreetings()
|
|
467
|
+
expect(remaining).toHaveLength(1)
|
|
468
|
+
expect(remaining[0].lang).toBe("en")
|
|
469
|
+
})
|
|
652
470
|
|
|
653
|
-
|
|
654
|
-
|
|
471
|
+
it("should do nothing when no old greetings exist", async () => {
|
|
472
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
473
|
+
await service.createGreetings([{ message: "Hello", lang: "en" }])
|
|
655
474
|
|
|
656
|
-
|
|
657
|
-
pluginPath: process.cwd(),
|
|
658
|
-
testSuite: ({ container }) => {
|
|
659
|
-
it("should execute the workflow", async () => {
|
|
660
|
-
const { result } = await createOrderWorkflow(container).run({
|
|
661
|
-
input: {
|
|
662
|
-
customerId: "cus_123",
|
|
663
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
664
|
-
},
|
|
665
|
-
})
|
|
666
|
-
expect(result.order).toEqual(
|
|
667
|
-
expect.objectContaining({ customerId: "cus_123" })
|
|
668
|
-
)
|
|
669
|
-
})
|
|
475
|
+
await cleanupGreetingsJob(container)
|
|
670
476
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
input: {},
|
|
674
|
-
throwOnError: false,
|
|
675
|
-
})
|
|
676
|
-
expect(errors).toHaveLength(1)
|
|
677
|
-
expect(errors[0].error.message).toContain("customerId")
|
|
477
|
+
const remaining = await service.listGreetings()
|
|
478
|
+
expect(remaining).toHaveLength(1)
|
|
678
479
|
})
|
|
679
480
|
},
|
|
680
481
|
})
|
|
681
482
|
```
|
|
682
483
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
Test that compensation reverses side-effects when a later step fails:
|
|
484
|
+
---
|
|
686
485
|
|
|
687
|
-
|
|
688
|
-
it("should compensate on failure", async () => {
|
|
689
|
-
const service = container.resolve("orderModuleService")
|
|
690
|
-
|
|
691
|
-
// Run workflow that will fail at payment step
|
|
692
|
-
const { errors } = await createOrderWorkflow(container).run({
|
|
693
|
-
input: {
|
|
694
|
-
customerId: "cus_123",
|
|
695
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
696
|
-
paymentMethod: "invalid-method", // triggers payment step failure
|
|
697
|
-
},
|
|
698
|
-
throwOnError: false,
|
|
699
|
-
})
|
|
486
|
+
## Asserting Domain Events
|
|
700
487
|
|
|
701
|
-
|
|
488
|
+
Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
|
|
702
489
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
expect(orders).toHaveLength(0)
|
|
706
|
-
})
|
|
707
|
-
```
|
|
490
|
+
```typescript
|
|
491
|
+
import { MockEventBusService } from "@acmekit/test-utils"
|
|
708
492
|
|
|
709
|
-
|
|
493
|
+
it("should capture events", async () => {
|
|
494
|
+
const spy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
495
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
710
496
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
await createOrderWorkflow(container).run({
|
|
716
|
-
input: {
|
|
717
|
-
customerId: "cus_123",
|
|
718
|
-
items: [{ sku: "SKU-001", qty: 1 }],
|
|
719
|
-
sendNotification: false,
|
|
720
|
-
},
|
|
721
|
-
})
|
|
497
|
+
await eventBus.emit([
|
|
498
|
+
{ name: "greeting.created", data: { id: "g1", message: "Hello" } },
|
|
499
|
+
])
|
|
722
500
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
501
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
502
|
+
expect(spy.mock.calls[0][0]).toEqual(
|
|
503
|
+
expect.arrayContaining([
|
|
504
|
+
expect.objectContaining({
|
|
505
|
+
name: "greeting.created",
|
|
506
|
+
data: expect.objectContaining({ id: "g1" }),
|
|
507
|
+
}),
|
|
728
508
|
])
|
|
729
509
|
)
|
|
730
|
-
eventBusSpy.mockRestore()
|
|
731
|
-
})
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
### `parallelize()` results
|
|
735
510
|
|
|
736
|
-
|
|
737
|
-
it("should run parallel steps", async () => {
|
|
738
|
-
const { result } = await enrichOrderWorkflow(container).run({
|
|
739
|
-
input: { orderId: order.id },
|
|
740
|
-
})
|
|
741
|
-
|
|
742
|
-
// Both parallel branches produce results
|
|
743
|
-
expect(result.customerDetails).toBeDefined()
|
|
744
|
-
expect(result.inventoryCheck).toBeDefined()
|
|
745
|
-
expect(result.customerDetails.name).toBe("Jane Doe")
|
|
746
|
-
expect(result.inventoryCheck.available).toBe(true)
|
|
511
|
+
spy.mockRestore()
|
|
747
512
|
})
|
|
748
513
|
```
|
|
749
514
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
```typescript
|
|
753
|
-
it("should permanently fail without compensation", async () => {
|
|
754
|
-
const { errors } = await validatePaymentWorkflow(container).run({
|
|
755
|
-
input: { paymentId: "fraudulent-id" },
|
|
756
|
-
throwOnError: false,
|
|
757
|
-
})
|
|
758
|
-
|
|
759
|
-
expect(errors).toHaveLength(1)
|
|
760
|
-
expect(errors[0].error.message).toContain("Fraudulent payment detected")
|
|
761
|
-
// permanentFailure skips compensation — verify no rollback happened
|
|
762
|
-
})
|
|
515
|
+
**Note:** MockEventBusService `emit` takes an **array** of events. Real event bus `emit` takes a single `{ name, data }` object.
|
|
763
516
|
|
|
764
|
-
|
|
765
|
-
const { result } = await processOrderWorkflow(container).run({
|
|
766
|
-
input: { orderId: order.id, skipLoyaltyPoints: true },
|
|
767
|
-
})
|
|
768
|
-
|
|
769
|
-
// Workflow completes successfully, loyalty step was skipped
|
|
770
|
-
expect(result.order.status).toBe("processed")
|
|
771
|
-
expect(result.loyaltyPoints).toBeUndefined()
|
|
772
|
-
})
|
|
773
|
-
```
|
|
517
|
+
---
|
|
774
518
|
|
|
775
|
-
|
|
519
|
+
## Service Resolution
|
|
776
520
|
|
|
777
521
|
```typescript
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const workflow = createOrderWorkflow(container)
|
|
782
|
-
workflow.hooks.orderCreated((input) => {
|
|
783
|
-
hookResult.push(input)
|
|
784
|
-
})
|
|
522
|
+
// Custom modules — use module constant
|
|
523
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
524
|
+
const service = container.resolve(GREETING_MODULE)
|
|
785
525
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
526
|
+
// Core modules — use Modules.* constants
|
|
527
|
+
container.resolve(Modules.AUTH)
|
|
528
|
+
container.resolve(Modules.USER)
|
|
789
529
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
)
|
|
794
|
-
})
|
|
530
|
+
// WRONG
|
|
531
|
+
container.resolve("greetingModuleService") // ❌
|
|
532
|
+
container.resolve("auth") // ❌
|
|
795
533
|
```
|
|
796
534
|
|
|
797
535
|
---
|
|
798
536
|
|
|
799
|
-
##
|
|
800
|
-
|
|
801
|
-
When a plugin workflow depends on another module (e.g., a payment module):
|
|
537
|
+
## Anti-Patterns — NEVER Do These
|
|
802
538
|
|
|
803
539
|
```typescript
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
540
|
+
// WRONG — using deprecated runner names
|
|
541
|
+
import { pluginIntegrationTestRunner } from "@acmekit/test-utils" // ❌
|
|
542
|
+
import { moduleIntegrationTestRunner } from "@acmekit/test-utils" // ❌
|
|
543
|
+
// RIGHT — use unified runner with mode
|
|
544
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
545
|
+
integrationTestRunner({ mode: "plugin", pluginPath: process.cwd(), ... })
|
|
546
|
+
|
|
547
|
+
// WRONG — trying to use `api` in container-only plugin tests
|
|
548
|
+
integrationTestRunner({
|
|
549
|
+
mode: "plugin",
|
|
807
550
|
pluginPath: process.cwd(),
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
},
|
|
811
|
-
testSuite: ({ container }) => {
|
|
812
|
-
it("should process order with payment", async () => {
|
|
813
|
-
const { result } = await createOrderWorkflow(container).run({
|
|
814
|
-
input: {
|
|
815
|
-
customerId: "cus_123",
|
|
816
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
817
|
-
paymentMethod: "card",
|
|
818
|
-
},
|
|
819
|
-
})
|
|
820
|
-
expect(result.payment).toEqual(
|
|
821
|
-
expect.objectContaining({ status: "captured" })
|
|
822
|
-
)
|
|
823
|
-
})
|
|
551
|
+
testSuite: ({ api }) => { // ❌ api is not available without http: true
|
|
552
|
+
await api.get("/admin/plugin")
|
|
824
553
|
},
|
|
825
554
|
})
|
|
826
|
-
```
|
|
827
555
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
Test cross-module links via `container.resolve`:
|
|
833
|
-
|
|
834
|
-
```typescript
|
|
835
|
-
import { ContainerRegistrationKeys } from "@acmekit/framework/utils"
|
|
836
|
-
|
|
837
|
-
it("should create and query a link", async () => {
|
|
838
|
-
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK)
|
|
839
|
-
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
556
|
+
// WRONG — hardcoding pluginPath
|
|
557
|
+
integrationTestRunner({ mode: "plugin", pluginPath: "/absolute/path" }) // ❌
|
|
558
|
+
// RIGHT
|
|
559
|
+
integrationTestRunner({ mode: "plugin", pluginPath: process.cwd() })
|
|
840
560
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
561
|
+
// WRONG — no auth in HTTP tests
|
|
562
|
+
it("should list", async () => {
|
|
563
|
+
await api.get("/admin/plugin") // ❌ 401
|
|
564
|
+
})
|
|
844
565
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
566
|
+
// WRONG — asserting error responses without catching (axios throws!)
|
|
567
|
+
const response = await api.post("/admin/plugin/greetings", {}, adminHeaders)
|
|
568
|
+
expect(response.status).toEqual(400) // ❌ never reached
|
|
569
|
+
// RIGHT
|
|
570
|
+
const { response } = await api.post("/admin/plugin/greetings", {}, adminHeaders).catch((e: any) => e)
|
|
571
|
+
expect(response.status).toEqual(400)
|
|
849
572
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
})
|
|
573
|
+
// WRONG — calling workflow without passing container
|
|
574
|
+
await createGreetingsWorkflow.run({ input: {} }) // ❌
|
|
575
|
+
// RIGHT
|
|
576
|
+
await createGreetingsWorkflow(container).run({ input: {} })
|
|
855
577
|
|
|
856
|
-
|
|
578
|
+
// WRONG — using .rejects.toThrow() on workflow errors
|
|
579
|
+
await expect(
|
|
580
|
+
createGreetingsWorkflow(container).run({ input: {} })
|
|
581
|
+
).rejects.toThrow() // ❌ plain object, not Error instance
|
|
582
|
+
// RIGHT
|
|
583
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
584
|
+
input: {},
|
|
585
|
+
throwOnError: false,
|
|
857
586
|
})
|
|
858
|
-
|
|
587
|
+
expect(errors[0].error.message).toContain("message")
|
|
859
588
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
589
|
+
// WRONG — calling waitSubscribersExecution AFTER triggering event
|
|
590
|
+
await eventBus.emit({ name: "greeting.created", data: { id } })
|
|
591
|
+
await TestEventUtils.waitSubscribersExecution("greeting.created", eventBus) // ❌
|
|
592
|
+
// RIGHT — capture promise BEFORE
|
|
593
|
+
const done = TestEventUtils.waitSubscribersExecution("greeting.created", eventBus)
|
|
594
|
+
await eventBus.emit({ name: "greeting.created", data: { id } })
|
|
595
|
+
await done
|
|
596
|
+
|
|
597
|
+
// WRONG — exact object assertions (timestamps/IDs change)
|
|
598
|
+
expect(result).toEqual({ id: "123", message: "Test" }) // ❌
|
|
599
|
+
// RIGHT
|
|
600
|
+
expect(result).toEqual(expect.objectContaining({
|
|
601
|
+
id: expect.any(String),
|
|
602
|
+
message: "Test",
|
|
603
|
+
}))
|
|
865
604
|
|
|
866
|
-
|
|
605
|
+
// WRONG — non-standard Jest matchers
|
|
606
|
+
expect(value).toBeOneOf([expect.any(String), null]) // ❌
|
|
607
|
+
// RIGHT
|
|
608
|
+
expect(value === null || typeof value === "string").toBe(true)
|
|
867
609
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
- Compensation on failure (verify side-effects rolled back)
|
|
873
|
-
- `when()` branches (verify skipped/executed paths)
|
|
874
|
-
- `parallelize()` results (verify both branches complete)
|
|
875
|
-
- `StepResponse.permanentFailure()` (no compensation)
|
|
876
|
-
- `StepResponse.skip()` (optional steps)
|
|
877
|
-
- Hook handlers (`workflow.hooks.hookName(handler)`)
|
|
610
|
+
// WRONG — resolve path as relative from test file
|
|
611
|
+
integrationTestRunner({ mode: "module", moduleName: "greeting", resolve: "../index" }) // ❌
|
|
612
|
+
// RIGHT
|
|
613
|
+
integrationTestRunner({ mode: "module", moduleName: "greeting", resolve: process.cwd() + "/src/modules/greeting" })
|
|
878
614
|
|
|
879
|
-
|
|
615
|
+
// WRONG — using jsonwebtoken directly
|
|
616
|
+
import jwt from "jsonwebtoken" // ❌
|
|
617
|
+
// RIGHT — use generateJwtToken from @acmekit/framework/utils
|
|
880
618
|
|
|
881
|
-
|
|
619
|
+
// WRONG — using old publishable API key names
|
|
620
|
+
{ type: "publishable" } // ❌
|
|
621
|
+
{ "x-publishable-api-key": token } // ❌
|
|
622
|
+
// RIGHT
|
|
623
|
+
{ type: ApiKeyType.CLIENT }
|
|
624
|
+
{ [CLIENT_API_KEY_HEADER]: token }
|
|
882
625
|
|
|
883
|
-
|
|
626
|
+
// WRONG — unused imports, JSDoc blocks, type casts in tests
|
|
627
|
+
```
|