@acmekit/acmekit 2.13.83 → 2.13.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,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.** `pluginIntegrationTestRunner` and `moduleIntegrationTestRunner` sync DB schema from entities — no migration files needed. `acmekitIntegrationTestRunner` (host app) runs real migrations modules MUST have migration files or you get `TableNotFoundException`.
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 | Runner | Fixtures provided | DB setup |
28
- |---|---|---|---|
29
- | Full plugin (modules + workflows + subscribers + jobs) | `pluginIntegrationTestRunner` | `container`, `acmekitApp`, `MikroOrmWrapper`, `dbConfig` | Schema sync (no migrations) |
30
- | Module service CRUD in isolation | `moduleIntegrationTestRunner` | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync (no migrations) |
31
- | Pure functions (no DB) | Plain Jest `describe/it` | none | N/A |
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
- **No HTTP server:** `pluginIntegrationTestRunner` does NOT start an Express server. There is no `api` fixture. To test HTTP routes, mount the plugin in a host application and use `acmekitIntegrationTestRunner` there.
36
+ ---
34
37
 
35
38
  ## File Locations (must match `jest.config.js` buckets)
36
39
 
37
40
  ```
38
- integration-tests/plugin/<feature>.spec.ts → TEST_TYPE=integration:plugin
39
- src/modules/<mod>/__tests__/<name>.spec.ts → TEST_TYPE=integration:modules
40
- src/**/__tests__/<name>.unit.spec.ts → TEST_TYPE=unit
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 # Full plugin integration tests
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 { pluginIntegrationTestRunner } from "@acmekit/test-utils"
59
- import { plugin } from "../../src/plugin"
60
- import { BLOG_MODULE } from "../../src/modules/blog" // BLOG_MODULE = "blog" — must match Module() key
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
- pluginIntegrationTestRunner({
77
+ integrationTestRunner({
78
+ mode: "plugin",
65
79
  pluginPath: process.cwd(),
66
80
  pluginOptions: {
67
81
  apiKey: "test-api-key",
68
82
  },
69
- // additionalModules: { myOtherModule: OtherModule },
70
- // injectedDependencies: { externalApi: mockExternalApi },
71
- testSuite: ({ container, acmekitApp, MikroOrmWrapper }) => {
83
+ testSuite: ({ container, acmekitApp }) => {
72
84
  describe("Plugin loading", () => {
73
- it("should load plugin modules", () => {
85
+ it("should load plugin resources", () => {
74
86
  expect(acmekitApp.modules).toBeDefined()
75
87
  })
76
88
 
77
- it("should resolve typed plugin options", () => {
78
- const options = plugin.resolveOptions(container)
79
- expect(options.apiKey).toBe("test-api-key")
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("BlogModule CRUD", () => {
84
- let service: any
85
-
86
- beforeEach(() => {
87
- service = container.resolve(BLOG_MODULE)
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
- title: "Launch Announcement",
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
- await expect(service.createBlogPosts([{}])).rejects.toThrow()
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
- ### `pluginIntegrationTestRunner` Options
119
+ ### Plugin mode options
135
120
 
136
121
  | Option | Type | Default | Description |
137
122
  |---|---|---|---|
138
- | `pluginPath` | `string` | **(required)** | Path to plugin root (where `package.json` lives), usually `process.cwd()` |
139
- | `pluginOptions` | `Record<string, unknown>` | `{}` | Simulates host app plugin config from `acmekit-config.ts` |
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
- | `cwd` | `string` | `process.cwd()` | Working directory |
146
- | `hooks` | `{ beforePluginInit?, afterPluginInit? }` | `{}` | Lifecycle hooks |
147
- | `testSuite` | `(options: PluginSuiteOptions) => void` | **(required)** | Test callback |
132
+ | `hooks` | `RunnerHooks` | `{}` | Lifecycle hooks |
133
+ | `testSuite` | `(options) => void` | **(required)** | Test callback |
148
134
 
149
- ### `PluginSuiteOptions` fields
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
- ### Plugin test lifecycle
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
- Each `it` block gets: schema drop + recreate → fresh plugin boot (all modules, subscribers, workflows, jobs loaded via SubscriberLoader, WorkflowLoader, JobLoader) → test runs → schema clear → module shutdown. No manual cleanup needed.
146
+ ---
159
147
 
160
- ### Testing plugin options
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 { plugin } from "../../src/plugin"
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
- pluginIntegrationTestRunner({
165
+ integrationTestRunner({
166
+ mode: "plugin",
167
+ http: true,
166
168
  pluginPath: process.cwd(),
167
- pluginOptions: { apiKey: "test-key", debug: true },
168
- testSuite: ({ container }) => {
169
- it("should resolve typed plugin options", () => {
170
- const options = plugin.resolveOptions(container)
171
- expect(options.apiKey).toBe("test-key")
172
- expect(options.debug).toBe(true)
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 { moduleIntegrationTestRunner } from "@acmekit/test-utils"
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
- moduleIntegrationTestRunner<IMyModuleService>({
189
- moduleName: MY_MODULE, // must match the string passed to Module(), e.g., "my-module"
190
- resolve: "./src/modules/my-module", // path from CWD to module root (for model auto-discovery)
191
- // moduleModels: [Post, Comment], // OR pass models explicitly
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
- afterEach(() => {
195
- jest.restoreAllMocks()
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
- title: "Quarterly Report",
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
- ### `SuiteOptions<TService>` fields
320
+ ### Module mode fixtures
221
321
 
222
322
  - `service` — proxy to the module service (auto-refreshed each `beforeEach`)
223
- - `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
323
+ - `MikroOrmWrapper` — raw DB access
224
324
  - `acmekitApp` — proxy to the `AcmeKitApp` instance
225
325
  - `dbConfig` — `{ schema, clientUrl }`
226
326
 
227
- ### CRUD test patterns
327
+ ### Module test lifecycle
228
328
 
229
- ```typescript
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 Through Container
333
+ ## Testing Workflows
301
334
 
302
- Workflows are tested by running them via `container` NOT via HTTP:
335
+ Workflows are called via `workflowFn(container).run({ input })`:
303
336
 
304
337
  ```typescript
305
- import { createOrderWorkflow } from "../../src/workflows"
338
+ import { createGreetingsWorkflow } from "../../src/workflows"
306
339
 
307
- pluginIntegrationTestRunner({
340
+ integrationTestRunner({
341
+ mode: "plugin",
308
342
  pluginPath: process.cwd(),
309
343
  testSuite: ({ container }) => {
310
- it("should execute the create order workflow", async () => {
311
- const { result } = await createOrderWorkflow(container).run({
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.order).toEqual(
318
- expect.objectContaining({ customerId: "cus_123" })
319
- )
348
+ expect(result).toHaveLength(1)
349
+ expect(result[0].message).toBe("Workflow Hello")
320
350
  })
321
351
 
322
- it("should reject invalid workflow input", async () => {
323
- const { errors } = await createOrderWorkflow(container).run({
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
- import { MockEventBusService } from "@acmekit/test-utils"
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
- // Direct access to mock.calls for precise assertions
357
- const events = eventBusSpy.mock.calls[0][0] // first call, first arg = events array
358
- expect(events).toHaveLength(1)
359
- expect(events).toEqual(
360
- expect.arrayContaining([
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
- // Second argument is always { internal: true }
369
- expect(eventBusSpy.mock.calls[0][1]).toEqual({ internal: true })
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
- ## Waiting for Subscribers
384
+ ## Testing Subscribers
376
385
 
377
- Use `waitSubscribersExecution` when testing subscriber side-effects. **CRITICAL: create the promise BEFORE triggering the event.**
386
+ ### Direct handler invocation (plugin mode container-only)
378
387
 
379
- ```typescript
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
- // In pluginIntegrationTestRunner use module constant (matches Module() key)
404
- import { BLOG_MODULE } from "../../src/modules/blog" // BLOG_MODULE = "blog"
405
- const service = container.resolve(BLOG_MODULE)
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
- This applies when testing your plugin mounted in a host application with `acmekitIntegrationTestRunner`.
422
-
423
- ```typescript
424
- import {
425
- ContainerRegistrationKeys,
426
- generateJwtToken,
427
- Modules,
428
- } from "@acmekit/framework/utils"
395
+ jest.setTimeout(60 * 1000)
429
396
 
430
- // Resolve the JWT secret from the project config — NEVER hardcode "supersecret"
431
- const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
432
- const { jwtSecret, jwtOptions } = config.projectConfig.http
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 token = generateJwtToken(
435
- {
436
- actor_id: user.id,
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
- ## Service Resolution
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
- // RIGHT
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
- // WRONG string literals are fragile and may not match container keys
483
- const authModule = container.resolve("auth") // ❌
484
- const userModule = container.resolve("user") // ❌
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
- ## Environment
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
- ## Anti-Patterns NEVER Do These
442
+ Import the job function directly and call with container:
499
443
 
500
444
  ```typescript
501
- // WRONG using acmekitIntegrationTestRunner in a plugin
502
- import { acmekitIntegrationTestRunner } from "@acmekit/test-utils" // ❌
503
- // RIGHT use pluginIntegrationTestRunner for plugin tests
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
- // WRONG — using .rejects.toThrow() on workflow errors
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
- // NOTE: .rejects.toThrow() DOES work for service-level errors (e.g.,
643
- // service.retrievePost("bad-id")) because services throw real Error
644
- // instances. The serialization only happens in the workflow engine.
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
- ## Workflow Composition Testing
464
+ await cleanupGreetingsJob(container)
650
465
 
651
- ### Happy path + error inspection
466
+ const remaining = await service.listGreetings()
467
+ expect(remaining).toHaveLength(1)
468
+ expect(remaining[0].lang).toBe("en")
469
+ })
652
470
 
653
- ```typescript
654
- import { createOrderWorkflow } from "../../src/workflows"
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
- pluginIntegrationTestRunner({
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
- it("should reject invalid input via inputSchema", async () => {
672
- const { errors } = await createOrderWorkflow(container).run({
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
- ### Step compensation
684
-
685
- Test that compensation reverses side-effects when a later step fails:
484
+ ---
686
485
 
687
- ```typescript
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
- expect(errors).toHaveLength(1)
488
+ Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
702
489
 
703
- // Verify compensation ran — order should not exist
704
- const orders = await service.listOrders({ customerId: "cus_123" })
705
- expect(orders).toHaveLength(0)
706
- })
707
- ```
490
+ ```typescript
491
+ import { MockEventBusService } from "@acmekit/test-utils"
708
492
 
709
- ### `when()` branches
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
- ```typescript
712
- it("should skip notification when flag is false", async () => {
713
- const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
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
- // No notification event emitted
724
- const allEvents = eventBusSpy.mock.calls.flatMap((call) => call[0])
725
- expect(allEvents).toEqual(
726
- expect.not.arrayContaining([
727
- expect.objectContaining({ name: "order.notification.sent" }),
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
- ```typescript
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
- ### `StepResponse.permanentFailure()` and `StepResponse.skip()`
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
- it("should skip optional step", async () => {
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
- ### Workflow hooks
519
+ ## Service Resolution
776
520
 
777
521
  ```typescript
778
- it("should execute hook handler", async () => {
779
- const hookResult: any[] = []
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
- await workflow.run({
787
- input: { customerId: "cus_123", items: [{ sku: "SKU-001", qty: 1 }] },
788
- })
526
+ // Core modules — use Modules.* constants
527
+ container.resolve(Modules.AUTH)
528
+ container.resolve(Modules.USER)
789
529
 
790
- expect(hookResult).toHaveLength(1)
791
- expect(hookResult[0]).toEqual(
792
- expect.objectContaining({ orderId: expect.any(String) })
793
- )
794
- })
530
+ // WRONG
531
+ container.resolve("greetingModuleService") // ❌
532
+ container.resolve("auth") //
795
533
  ```
796
534
 
797
535
  ---
798
536
 
799
- ## Cross-Module Testing with additionalModules
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
- import { PaymentModule } from "@acmekit/payment"
805
-
806
- pluginIntegrationTestRunner({
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
- additionalModules: {
809
- paymentModuleService: PaymentModule,
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
- ## Link / Relation Testing in Plugin Context
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
- const blogService = container.resolve(BLOG_MODULE) // "blog" from Module() key
842
- const [post] = await blogService.createBlogPosts([{ title: "Linked Post" }])
843
- const [category] = await blogService.createBlogCategories([{ name: "Tech" }])
561
+ // WRONG no auth in HTTP tests
562
+ it("should list", async () => {
563
+ await api.get("/admin/plugin") // ❌ 401
564
+ })
844
565
 
845
- await remoteLink.create([{
846
- blogModuleService: { blog_post_id: post.id },
847
- blogCategoryModuleService: { blog_category_id: category.id },
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
- const { data: [linkedPost] } = await query.graph({
851
- entity: "blog_post",
852
- fields: ["id", "title", "category.*"],
853
- filters: { id: post.id },
854
- })
573
+ // WRONG calling workflow without passing container
574
+ await createGreetingsWorkflow.run({ input: {} }) // ❌
575
+ // RIGHT
576
+ await createGreetingsWorkflow(container).run({ input: {} })
855
577
 
856
- expect(linkedPost.category.name).toBe("Tech")
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
- ## What to Test
863
-
864
- **Plugin loading:** Modules resolve from container, plugin options accessible via `plugin.resolveOptions(container)`, auto-discovered resources (subscribers, workflows, jobs) are loaded.
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
- **Module services:** CRUD operations (create, retrieve, list, update, softDelete, restore), custom methods, validation (required fields → `rejects.toThrow()`, not found → check error.message), related entities with `{ relations: [...] }`.
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
- **Workflows:**
869
- - Happy path + correct result
870
- - `throwOnError: false` + `errors` array inspection
871
- - `inputSchema` rejection with specific error messages
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
- **Subscribers/Events:** Spy on `MockEventBusService.prototype.emit`, access events via `mock.calls[0][0]`, use `TestEventUtils.waitSubscribersExecution` for async subscriber side-effects.
615
+ // WRONG using jsonwebtoken directly
616
+ import jwt from "jsonwebtoken" // ❌
617
+ // RIGHT — use generateJwtToken from @acmekit/framework/utils
880
618
 
881
- **Links:** `remoteLink.create()` + `query.graph()` with related fields.
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
- **HTTP routes:** Cannot be tested with `pluginIntegrationTestRunner`. Mount the plugin in a host app and use `acmekitIntegrationTestRunner` there.
626
+ // WRONG unused imports, JSDoc blocks, type casts in tests
627
+ ```