@acmekit/acmekit 2.13.83 → 2.13.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,874 +10,885 @@ 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
 
25
+ **jest.mock factories are hoisted.** `jest.mock()` runs BEFORE `const`/`let` declarations. Never reference file-level variables inside a `jest.mock()` factory — see "Unit Tests" section below.
26
+
27
+ **Always add `jest.clearAllMocks()` in `beforeEach`.** Mock state leaks between test blocks. Without explicit cleanup, assertions on mock call counts fail from prior-test contamination.
28
+
23
29
  ---
24
30
 
25
31
  ## Test Runner Selection
26
32
 
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 |
33
+ | What to test | Mode | HTTP? | Fixtures | DB setup |
34
+ |---|---|---|---|---|
35
+ | Full plugin (modules + workflows + subscribers + jobs) | `mode: "plugin"` | no | `container`, `acmekitApp`, `MikroOrmWrapper`, `dbConfig` | Schema sync |
36
+ | Plugin HTTP routes end-to-end | `mode: "plugin"` + `http: true` | yes | `api`, `container`, `dbConfig` | Runs migrations |
37
+ | Module service CRUD in isolation | `mode: "module"` | no | `service`, `MikroOrmWrapper`, `acmekitApp`, `dbConfig` | Schema sync |
38
+ | Pure functions (no DB) | Plain Jest `describe/it` | — | none | N/A |
32
39
 
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.
40
+ ---
34
41
 
35
42
  ## File Locations (must match `jest.config.js` buckets)
36
43
 
37
44
  ```
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
45
+ integration-tests/plugin/<feature>.spec.ts → TEST_TYPE=integration:plugin
46
+ integration-tests/http/<feature>.spec.ts → TEST_TYPE=integration:http
47
+ src/modules/<mod>/__tests__/<name>.spec.ts → TEST_TYPE=integration:modules
48
+ src/**/__tests__/<name>.unit.spec.ts → TEST_TYPE=unit
41
49
  ```
42
50
 
51
+ **`integration-tests/plugin/`** — container-only tests (module CRUD, workflows, subscribers, jobs, event spying). No HTTP server.
52
+
53
+ **`integration-tests/http/`** — full HTTP tests with Express. Boots the entire framework with the plugin installed. Requires auth setup (JWT + client API key).
54
+
55
+ ---
56
+
43
57
  ## Commands
44
58
 
45
59
  ```bash
46
60
  pnpm test:unit # Unit tests
47
61
  pnpm test:integration:modules # Module integration tests
48
- pnpm test:integration:plugin # Full plugin integration tests
62
+ pnpm test:integration:plugin # Plugin integration tests (container-only)
63
+ pnpm test:integration:http # Plugin HTTP integration tests
49
64
  ```
50
65
 
51
66
  All integration tests require `NODE_OPTIONS=--experimental-vm-modules` (set in package.json scripts).
52
67
 
53
68
  ---
54
69
 
55
- ## Plugin Integration Tests
70
+ ## Plugin Integration Tests (`integration-tests/plugin/`)
71
+
72
+ No HTTP server — test services, workflows, subscribers, and jobs directly through the container.
56
73
 
57
74
  ```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
75
+ import { integrationTestRunner, MockEventBusService } from "@acmekit/test-utils"
76
+ import { Modules } from "@acmekit/framework/utils"
77
+ import { GREETING_MODULE } from "../../src/modules/greeting"
61
78
 
62
79
  jest.setTimeout(60 * 1000)
63
80
 
64
- pluginIntegrationTestRunner({
81
+ integrationTestRunner({
82
+ mode: "plugin",
65
83
  pluginPath: process.cwd(),
66
84
  pluginOptions: {
67
85
  apiKey: "test-api-key",
68
86
  },
69
- // additionalModules: { myOtherModule: OtherModule },
70
- // injectedDependencies: { externalApi: mockExternalApi },
71
- testSuite: ({ container, acmekitApp, MikroOrmWrapper }) => {
87
+ testSuite: ({ container, acmekitApp }) => {
72
88
  describe("Plugin loading", () => {
73
- it("should load plugin modules", () => {
89
+ it("should load plugin resources", () => {
74
90
  expect(acmekitApp.modules).toBeDefined()
75
91
  })
76
92
 
77
- it("should resolve typed plugin options", () => {
78
- const options = plugin.resolveOptions(container)
79
- expect(options.apiKey).toBe("test-api-key")
93
+ it("should resolve EVENT_BUS as MockEventBusService", () => {
94
+ const eventBus = container.resolve(Modules.EVENT_BUS)
95
+ expect(eventBus).toBeInstanceOf(MockEventBusService)
80
96
  })
81
97
  })
82
98
 
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" },
99
+ describe("GreetingModule CRUD", () => {
100
+ it("should create a greeting", async () => {
101
+ const service: any = container.resolve(GREETING_MODULE)
102
+ const result = await service.createGreetings([
103
+ { message: "Hello World" },
93
104
  ])
94
105
  expect(result).toHaveLength(1)
95
106
  expect(result[0]).toEqual(
96
107
  expect.objectContaining({
97
108
  id: expect.any(String),
98
- title: "Launch Announcement",
109
+ message: "Hello World",
99
110
  })
100
111
  )
101
112
  })
102
113
 
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
114
  it("should throw on missing required field", async () => {
127
- await expect(service.createBlogPosts([{}])).rejects.toThrow()
115
+ const service: any = container.resolve(GREETING_MODULE)
116
+ await expect(service.createGreetings([{}])).rejects.toThrow()
128
117
  })
129
118
  })
130
119
  },
131
120
  })
132
121
  ```
133
122
 
134
- ### `pluginIntegrationTestRunner` Options
123
+ ### Plugin mode options
135
124
 
136
125
  | Option | Type | Default | Description |
137
126
  |---|---|---|---|
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` |
127
+ | `mode` | `"plugin"` | **(required)** | Selects plugin mode |
128
+ | `pluginPath` | `string` | **(required)** | Path to plugin root always use `process.cwd()` |
129
+ | `pluginOptions` | `Record<string, unknown>` | `{}` | Simulates host app plugin config |
130
+ | `http` | `boolean` | `false` | Set `true` to boot full Express server for HTTP tests |
140
131
  | `additionalModules` | `Record<string, any>` | `{}` | Extra modules to load alongside the plugin |
141
132
  | `injectedDependencies` | `Record<string, any>` | `{}` | Mock services to register in the container |
142
- | `schema` | `string` | `"public"` | Postgres schema |
133
+ | `skipDependencyValidation` | `boolean` | `false` | Skip `definePlugin({ dependencies })` validation — use when peer plugins aren't installed |
134
+ | `pluginModuleOptions` | `Record<string, Record<string, any>>` | `{}` | Per-module options keyed by module name (e.g., provider config) |
143
135
  | `dbName` | `string` | auto-generated | Override the computed DB name |
136
+ | `schema` | `string` | `"public"` | Postgres schema |
144
137
  | `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 |
138
+ | `hooks` | `RunnerHooks` | `{}` | Lifecycle hooks |
139
+ | `testSuite` | `(options) => void` | **(required)** | Test callback |
148
140
 
149
- ### `PluginSuiteOptions` fields
141
+ ### Plugin mode fixtures (container-only)
150
142
 
151
143
  - `container` — proxy to the shared `AcmeKitContainer` (auto-refreshed each `beforeEach`)
152
144
  - `acmekitApp` — proxy to the `AcmeKitApp` instance (has `.modules`, `.sharedContainer`)
153
145
  - `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
154
- - `dbConfig` — `{ schema, clientUrl }`
146
+ - `dbConfig` — `{ schema, clientUrl, dbName }`
147
+
148
+ ### Plugin test lifecycle (container-only)
149
+
150
+ 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.
155
151
 
156
- ### Plugin test lifecycle
152
+ ---
157
153
 
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.
154
+ ## Plugin HTTP Integration Tests (`integration-tests/http/`)
159
155
 
160
- ### Testing plugin options
156
+ 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
157
 
162
158
  ```typescript
163
- import { plugin } from "../../src/plugin"
159
+ import { integrationTestRunner } from "@acmekit/test-utils"
160
+ import {
161
+ ApiKeyType,
162
+ CLIENT_API_KEY_HEADER,
163
+ ContainerRegistrationKeys,
164
+ generateJwtToken,
165
+ Modules,
166
+ } from "@acmekit/framework/utils"
167
+ import { GREETING_MODULE } from "../../src/modules/greeting"
164
168
 
165
- pluginIntegrationTestRunner({
169
+ jest.setTimeout(120 * 1000)
170
+
171
+ integrationTestRunner({
172
+ mode: "plugin",
173
+ http: true,
166
174
  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)
175
+ pluginOptions: { apiKey: "test-api-key" },
176
+ testSuite: ({ api, container }) => {
177
+ let adminHeaders: Record<string, any>
178
+ let clientHeaders: Record<string, any>
179
+
180
+ beforeEach(async () => {
181
+ const userModule = container.resolve(Modules.USER)
182
+ const authModule = container.resolve(Modules.AUTH)
183
+ const apiKeyModule = container.resolve(Modules.API_KEY)
184
+
185
+ const user = await userModule.createUsers({
186
+ email: "admin@test.js",
187
+ })
188
+
189
+ const authIdentity = await authModule.createAuthIdentities({
190
+ provider_identities: [
191
+ { provider: "emailpass", entity_id: "admin@test.js" },
192
+ ],
193
+ app_metadata: { user_id: user.id },
194
+ })
195
+
196
+ const config = container.resolve(
197
+ ContainerRegistrationKeys.CONFIG_MODULE
198
+ )
199
+ const { jwtSecret, jwtOptions } = config.projectConfig.http
200
+
201
+ const token = generateJwtToken(
202
+ {
203
+ actor_id: user.id,
204
+ actor_type: "user",
205
+ auth_identity_id: authIdentity.id,
206
+ app_metadata: { user_id: user.id },
207
+ },
208
+ { secret: jwtSecret, expiresIn: "1d", jwtOptions }
209
+ )
210
+
211
+ adminHeaders = {
212
+ headers: { authorization: `Bearer ${token}` },
213
+ }
214
+
215
+ const apiKey = await apiKeyModule.createApiKeys({
216
+ title: "Test Client Key",
217
+ type: ApiKeyType.CLIENT,
218
+ created_by: "test",
219
+ })
220
+
221
+ clientHeaders = {
222
+ headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
223
+ }
224
+ })
225
+
226
+ describe("Admin plugin routes", () => {
227
+ it("POST /admin/plugin/greetings creates a greeting", async () => {
228
+ const response = await api.post(
229
+ "/admin/plugin/greetings",
230
+ { message: "Hello from HTTP" },
231
+ adminHeaders
232
+ )
233
+ expect(response.status).toEqual(200)
234
+ expect(response.data.greeting).toEqual(
235
+ expect.objectContaining({
236
+ id: expect.any(String),
237
+ message: "Hello from HTTP",
238
+ })
239
+ )
240
+ })
241
+
242
+ it("DELETE /admin/plugin/greetings/:id soft-deletes", async () => {
243
+ const created = await api.post(
244
+ "/admin/plugin/greetings",
245
+ { message: "Delete me" },
246
+ adminHeaders
247
+ )
248
+ const response = await api.delete(
249
+ `/admin/plugin/greetings/${created.data.greeting.id}`,
250
+ adminHeaders
251
+ )
252
+ expect(response.data).toEqual({
253
+ id: created.data.greeting.id,
254
+ object: "greeting",
255
+ deleted: true,
256
+ })
257
+ })
258
+ })
259
+
260
+ describe("Client plugin routes", () => {
261
+ it("GET /client/plugin/greetings with API key", async () => {
262
+ await api.post(
263
+ "/admin/plugin/greetings",
264
+ { message: "Public" },
265
+ adminHeaders
266
+ )
267
+ const response = await api.get(
268
+ "/client/plugin/greetings",
269
+ clientHeaders
270
+ )
271
+ expect(response.status).toEqual(200)
272
+ expect(response.data.greetings).toHaveLength(1)
273
+ })
274
+ })
275
+
276
+ describe("Auth enforcement", () => {
277
+ it("admin route rejects without JWT", async () => {
278
+ const error = await api
279
+ .get("/admin/plugin")
280
+ .catch((e: any) => e)
281
+ expect(error.response.status).toEqual(401)
282
+ })
173
283
  })
174
284
  },
175
285
  })
176
286
  ```
177
287
 
288
+ ### Plugin HTTP fixtures
289
+
290
+ - `api` — axios instance pointed at `http://localhost:<port>`
291
+ - `container` — proxy to the live container (includes all framework modules)
292
+ - `dbConfig` — `{ schema, clientUrl, dbName }`
293
+
178
294
  ---
179
295
 
180
296
  ## Module Integration Tests
181
297
 
182
298
  ```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
299
+ import { integrationTestRunner } from "@acmekit/test-utils"
185
300
 
186
301
  jest.setTimeout(30000)
187
302
 
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" },
303
+ integrationTestRunner<IGreetingModuleService>({
304
+ mode: "module",
305
+ moduleName: "greeting",
306
+ resolve: process.cwd() + "/src/modules/greeting",
193
307
  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" },
308
+ describe("createGreetings", () => {
309
+ it("should create a greeting", async () => {
310
+ const result = await service.createGreetings([
311
+ { message: "Hello" },
202
312
  ])
203
313
  expect(result).toHaveLength(1)
204
314
  expect(result[0]).toEqual(
205
315
  expect.objectContaining({
206
316
  id: expect.any(String),
207
- title: "Quarterly Report",
317
+ message: "Hello",
208
318
  })
209
319
  )
210
320
  })
211
-
212
- it("should throw on missing required field", async () => {
213
- await expect(service.createPosts([{}])).rejects.toThrow()
214
- })
215
321
  })
216
322
  },
217
323
  })
218
324
  ```
219
325
 
220
- ### `SuiteOptions<TService>` fields
326
+ ### Module mode fixtures
221
327
 
222
328
  - `service` — proxy to the module service (auto-refreshed each `beforeEach`)
223
- - `MikroOrmWrapper` — raw DB access: `.getManager()`, `.forkManager()`, `.getOrm()`
329
+ - `MikroOrmWrapper` — raw DB access
224
330
  - `acmekitApp` — proxy to the `AcmeKitApp` instance
225
331
  - `dbConfig` — `{ schema, clientUrl }`
226
332
 
227
- ### CRUD test patterns
228
-
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
333
+ ### Module test lifecycle
285
334
 
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
- ```
335
+ Each `it` block gets: schema drop + recreate → fresh module boot → test runs → schema clear → module shutdown. No manual cleanup needed.
297
336
 
298
337
  ---
299
338
 
300
- ## Testing Workflows Through Container
339
+ ## Testing Workflows
301
340
 
302
- Workflows are tested by running them via `container` NOT via HTTP:
341
+ Workflows are called via `workflowFn(container).run({ input })`:
303
342
 
304
343
  ```typescript
305
- import { createOrderWorkflow } from "../../src/workflows"
344
+ import { createGreetingsWorkflow } from "../../src/workflows"
306
345
 
307
- pluginIntegrationTestRunner({
346
+ integrationTestRunner({
347
+ mode: "plugin",
308
348
  pluginPath: process.cwd(),
309
349
  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
- },
350
+ it("should create a greeting via workflow", async () => {
351
+ const { result } = await createGreetingsWorkflow(container).run({
352
+ input: { greetings: [{ message: "Workflow Hello" }] },
316
353
  })
317
- expect(result.order).toEqual(
318
- expect.objectContaining({ customerId: "cus_123" })
319
- )
354
+ expect(result).toHaveLength(1)
355
+ expect(result[0].message).toBe("Workflow Hello")
320
356
  })
321
357
 
322
- it("should reject invalid workflow input", async () => {
323
- const { errors } = await createOrderWorkflow(container).run({
324
- input: {},
358
+ it("should reject invalid input", async () => {
359
+ const { errors } = await createGreetingsWorkflow(container).run({
360
+ input: { greetings: [{ message: "", status: "invalid" }] },
325
361
  throwOnError: false,
326
362
  })
327
363
  expect(errors).toHaveLength(1)
364
+ expect(errors[0].error.message).toContain("Invalid")
328
365
  })
329
366
  },
330
367
  })
331
368
  ```
332
369
 
333
- ---
334
-
335
- ## Asserting Domain Events
336
-
337
- Both runners inject `MockEventBusService` under `Modules.EVENT_BUS`. Spy on the **prototype**, not an instance.
370
+ ### Step compensation
338
371
 
339
372
  ```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" }])
373
+ it("should compensate on failure", async () => {
374
+ const service: any = container.resolve(GREETING_MODULE)
355
375
 
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
- )
376
+ const { errors } = await createGreetingsWorkflow(container).run({
377
+ input: { greetings: [{ message: "Will Fail", trigger_error: true }] },
378
+ throwOnError: false,
379
+ })
380
+ expect(errors).toHaveLength(1)
367
381
 
368
- // Second argument is always { internal: true }
369
- expect(eventBusSpy.mock.calls[0][1]).toEqual({ internal: true })
382
+ // Verify compensation ran greeting rolled back
383
+ const greetings = await service.listGreetings()
384
+ expect(greetings).toHaveLength(0)
370
385
  })
371
386
  ```
372
387
 
373
388
  ---
374
389
 
375
- ## Waiting for Subscribers
390
+ ## Testing Subscribers
391
+
392
+ ### Direct handler invocation (plugin mode — container-only)
376
393
 
377
- Use `waitSubscribersExecution` when testing subscriber side-effects. **CRITICAL: create the promise BEFORE triggering the event.**
394
+ Import the handler and call it manually with `{ event, container }`:
378
395
 
379
396
  ```typescript
380
- import { TestEventUtils } from "@acmekit/test-utils"
381
- import { Modules } from "@acmekit/framework/utils"
397
+ import { integrationTestRunner } from "@acmekit/test-utils"
398
+ import greetingCreatedHandler from "../../src/subscribers/greeting-created"
399
+ import { GREETING_MODULE } from "../../src/modules/greeting"
382
400
 
383
- it("should execute subscriber side-effect", async () => {
384
- const eventBus = container.resolve(Modules.EVENT_BUS)
401
+ jest.setTimeout(60 * 1000)
385
402
 
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
403
+ integrationTestRunner({
404
+ mode: "plugin",
405
+ pluginPath: process.cwd(),
406
+ testSuite: ({ container }) => {
407
+ it("should append ' [notified]' to greeting message", async () => {
408
+ const service: any = container.resolve(GREETING_MODULE)
409
+ const [greeting] = await service.createGreetings([
410
+ { message: "Hello World" },
411
+ ])
412
+
413
+ await greetingCreatedHandler({
414
+ event: {
415
+ data: { id: greeting.id },
416
+ name: "greeting.created",
417
+ },
418
+ container,
419
+ })
420
+
421
+ const updated = await service.retrieveGreeting(greeting.id)
422
+ expect(updated.message).toBe("Hello World [notified]")
423
+ })
424
+ },
395
425
  })
396
426
  ```
397
427
 
398
- ---
428
+ ### Event bus driven (plugin HTTP mode or app mode)
399
429
 
400
- ## Resolving Services in Tests
430
+ Use `TestEventUtils.waitSubscribersExecution` with the real event bus:
401
431
 
402
432
  ```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)
433
+ import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
407
434
 
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
435
+ // CRITICAL: create the promise BEFORE emitting the event
436
+ const subscriberDone = TestEventUtils.waitSubscribersExecution(
437
+ "greeting.created",
438
+ eventBus
439
+ )
440
+ await eventBus.emit({ name: "greeting.created", data: { id } })
441
+ await subscriberDone
413
442
  ```
414
443
 
415
444
  ---
416
445
 
417
- ## JWT Token Generation
446
+ ## Testing Jobs
418
447
 
419
- **ALWAYS use `generateJwtToken` from `@acmekit/framework/utils`.** NEVER use `jsonwebtoken` directly or hardcode the JWT secret.
420
-
421
- This applies when testing your plugin mounted in a host application with `acmekitIntegrationTestRunner`.
448
+ Import the job function directly and call with container:
422
449
 
423
450
  ```typescript
424
- import {
425
- ContainerRegistrationKeys,
426
- generateJwtToken,
427
- Modules,
428
- } from "@acmekit/framework/utils"
451
+ import { integrationTestRunner } from "@acmekit/test-utils"
452
+ import cleanupGreetingsJob from "../../src/jobs/cleanup-greetings"
453
+ import { GREETING_MODULE } from "../../src/modules/greeting"
429
454
 
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
455
+ jest.setTimeout(60 * 1000)
433
456
 
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 },
440
- },
441
- { secret: jwtSecret, expiresIn: "1d", jwtOptions }
442
- )
443
- ```
457
+ integrationTestRunner({
458
+ mode: "plugin",
459
+ pluginPath: process.cwd(),
460
+ testSuite: ({ container }) => {
461
+ it("should soft-delete greetings with lang='old'", async () => {
462
+ const service: any = container.resolve(GREETING_MODULE)
444
463
 
445
- ---
464
+ await service.createGreetings([
465
+ { message: "Old 1", lang: "old" },
466
+ { message: "Old 2", lang: "old" },
467
+ { message: "Current", lang: "en" },
468
+ ])
446
469
 
447
- ## Client API Key Generation
470
+ await cleanupGreetingsJob(container)
448
471
 
449
- **ALWAYS use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER`.** NEVER use `type: "publishable"` or `"x-publishable-api-key"`.
472
+ const remaining = await service.listGreetings()
473
+ expect(remaining).toHaveLength(1)
474
+ expect(remaining[0].lang).toBe("en")
475
+ })
450
476
 
451
- ```typescript
452
- import {
453
- ApiKeyType,
454
- CLIENT_API_KEY_HEADER,
455
- Modules,
456
- } from "@acmekit/framework/utils"
477
+ it("should do nothing when no old greetings exist", async () => {
478
+ const service: any = container.resolve(GREETING_MODULE)
479
+ await service.createGreetings([{ message: "Hello", lang: "en" }])
457
480
 
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
- })
481
+ await cleanupGreetingsJob(container)
464
482
 
465
- const clientHeaders = {
466
- headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
467
- }
483
+ const remaining = await service.listGreetings()
484
+ expect(remaining).toHaveLength(1)
485
+ })
486
+ },
487
+ })
468
488
  ```
469
489
 
470
490
  ---
471
491
 
472
- ## Service Resolution
492
+ ## Asserting Domain Events
473
493
 
474
- **ALWAYS use `Modules.*` constants** to resolve core services. NEVER use string literals like `"auth"`, `"user"`, `"customer"`.
494
+ Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
475
495
 
476
496
  ```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)
497
+ import { MockEventBusService } from "@acmekit/test-utils"
481
498
 
482
- // WRONG string literals are fragile and may not match container keys
483
- const authModule = container.resolve("auth") // ❌
484
- const userModule = container.resolve("user") // ❌
485
- ```
499
+ it("should capture events", async () => {
500
+ const spy = jest.spyOn(MockEventBusService.prototype, "emit")
501
+ const eventBus = container.resolve(Modules.EVENT_BUS)
486
502
 
487
- ---
503
+ await eventBus.emit([
504
+ { name: "greeting.created", data: { id: "g1", message: "Hello" } },
505
+ ])
506
+
507
+ expect(spy).toHaveBeenCalledTimes(1)
508
+ expect(spy.mock.calls[0][0]).toEqual(
509
+ expect.arrayContaining([
510
+ expect.objectContaining({
511
+ name: "greeting.created",
512
+ data: expect.objectContaining({ id: "g1" }),
513
+ }),
514
+ ])
515
+ )
488
516
 
489
- ## Environment
517
+ spy.mockRestore()
518
+ })
519
+ ```
490
520
 
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>`
521
+ **Note:** MockEventBusService `emit` takes an **array** of events. Real event bus `emit` takes a single `{ name, data }` object.
495
522
 
496
523
  ---
497
524
 
498
- ## Anti-Patterns — NEVER Do These
525
+ ## Service Resolution
499
526
 
500
527
  ```typescript
501
- // WRONGusing 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()
528
+ // Custom modules use module constant
529
+ import { GREETING_MODULE } from "../../src/modules/greeting"
530
+ const service = container.resolve(GREETING_MODULE)
520
531
 
521
- // WRONGusing 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)
532
+ // Core modules use Modules.* constants
533
+ container.resolve(Modules.AUTH)
534
+ container.resolve(Modules.USER)
526
535
 
527
- // WRONG — typeof checks on result fields
528
- expect(typeof result.id).toBe("string") // ❌
529
- // RIGHT
530
- expect(result.id).toEqual(expect.any(String))
536
+ // WRONG
537
+ container.resolve("greetingModuleService") // ❌
538
+ container.resolve("auth") //
539
+ ```
531
540
 
532
- // WRONG — JSDoc comment block at file top (test files never have these)
533
- /** BlogModule — tests CRUD, validation, events */ // ❌
541
+ ---
534
542
 
535
- // WRONG type casts in tests
536
- const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
543
+ ## Unit Tests (No Framework Bootstrap)
537
544
 
538
- // WRONGvague range assertions
539
- expect(someStatus).toBeGreaterThanOrEqual(400) // ❌
540
- // RIGHT
541
- expect(someStatus).toEqual("voided")
545
+ For providers, utility functions, and standalone classes that don't need the database or AcmeKit container. Uses plain Jest no `integrationTestRunner`.
542
546
 
543
- // WRONGasserting 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
- }))
547
+ **File naming:** `src/**/__tests__/<name>.unit.spec.ts` matches `TEST_TYPE=unit` in `jest.config.js`.
550
548
 
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
549
+ ### jest.mock Hoisting (Temporal Dead Zone)
563
550
 
564
- // WRONG hardcoding pluginPath
565
- pluginIntegrationTestRunner({ pluginPath: "/absolute/path/to/plugin" }) // ❌
566
- // RIGHT
567
- pluginIntegrationTestRunner({ pluginPath: process.cwd() })
551
+ `jest.mock()` factories are **hoisted above all `const`/`let` declarations** by SWC/Babel. Referencing a file-level `const` inside a `jest.mock()` factory causes `ReferenceError: Cannot access before initialization`.
568
552
 
569
- // WRONG — passing module object to resolve (type error: expects string)
570
- import MyModule from "../index"
571
- moduleIntegrationTestRunner({ resolve: MyModule, ... }) // ❌
553
+ ```typescript
554
+ // WRONG TDZ error: mockSign is not yet initialized when factory runs
555
+ const mockSign = jest.fn()
556
+ jest.mock("tronweb", () => ({
557
+ TronWeb: jest.fn().mockImplementation(() => ({ trx: { sign: mockSign } })),
558
+ }))
572
559
 
573
- // WRONGrelative 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", ... })
560
+ // RIGHTcreate mocks INSIDE the factory, expose via module return
561
+ jest.mock("tronweb", () => {
562
+ const mocks = {
563
+ sign: jest.fn(),
564
+ isAddress: jest.fn().mockReturnValue(true),
565
+ }
566
+ const MockTronWeb = jest.fn().mockImplementation(() => ({
567
+ trx: { sign: mocks.sign },
568
+ }))
569
+ MockTronWeb.isAddress = mocks.isAddress
570
+ return { TronWeb: MockTronWeb, __mocks: mocks }
571
+ })
577
572
 
578
- // WRONG unused imports
579
- import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if never used
580
- // RIGHT — only import what you use
573
+ // Access mocks after jest.mock via require()
574
+ const { __mocks: tronMocks } = require("tronweb")
575
+ ```
581
576
 
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
577
+ **Rule:** All mock state must live INSIDE the `jest.mock()` factory or be accessed via `require()` after the mock is set up. Never reference file-level `const`/`let` from inside a `jest.mock()` factory.
585
578
 
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)
579
+ ### Provider Unit Test Pattern
617
580
 
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"
581
+ Providers have a specific structure: constructor receives `(container, options)`, static `identifier`, optional static `validateOptions`. Test each part:
624
582
 
625
- // RIGHT — Option 1: use throwOnError: false + errors array (recommended)
626
- const { errors } = await createOrderWorkflow(container).run({
627
- input: {},
628
- throwOnError: false,
583
+ ```typescript
584
+ jest.mock("external-sdk", () => {
585
+ const mocks = {
586
+ doThing: jest.fn(),
587
+ }
588
+ const MockClient = jest.fn().mockImplementation(() => ({
589
+ doThing: mocks.doThing,
590
+ }))
591
+ return { Client: MockClient, __mocks: mocks }
629
592
  })
630
- expect(errors).toHaveLength(1)
631
- expect(errors[0].error.message).toContain("customerId")
632
593
 
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
- )
594
+ const { __mocks: sdkMocks } = require("external-sdk")
641
595
 
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
- ```
596
+ import MyProvider from "../my-provider"
646
597
 
647
- ---
598
+ describe("MyProvider", () => {
599
+ let provider: MyProvider
648
600
 
649
- ## Workflow Composition Testing
601
+ const mockContainer = {} as any
602
+ const defaultOptions = { apiKey: "test-key" }
650
603
 
651
- ### Happy path + error inspection
604
+ beforeEach(() => {
605
+ jest.clearAllMocks()
606
+ provider = new MyProvider(mockContainer, defaultOptions)
607
+ })
652
608
 
653
- ```typescript
654
- import { createOrderWorkflow } from "../../src/workflows"
609
+ describe("static identifier", () => {
610
+ it("should have correct identifier", () => {
611
+ expect(MyProvider.identifier).toBe("my-provider")
612
+ })
613
+ })
655
614
 
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
- )
615
+ describe("validateOptions", () => {
616
+ it("should accept valid options", () => {
617
+ expect(() =>
618
+ MyProvider.validateOptions({ apiKey: "key" })
619
+ ).not.toThrow()
669
620
  })
670
621
 
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")
622
+ it("should reject missing required option", () => {
623
+ expect(() => MyProvider.validateOptions({})).toThrow()
678
624
  })
679
- },
625
+ })
626
+
627
+ describe("doSomething", () => {
628
+ it("should delegate to SDK", async () => {
629
+ sdkMocks.doThing.mockResolvedValue({ success: true })
630
+ const result = await provider.doSomething({ input: "test" })
631
+ expect(result.success).toBe(true)
632
+ expect(sdkMocks.doThing).toHaveBeenCalledWith(
633
+ expect.objectContaining({ input: "test" })
634
+ )
635
+ })
636
+ })
680
637
  })
681
638
  ```
682
639
 
683
- ### Step compensation
640
+ ### Mock Cleanup Between Tests
684
641
 
685
- Test that compensation reverses side-effects when a later step fails:
642
+ Mock state leaks between `describe` and `it` blocks. **Always add cleanup:**
686
643
 
687
644
  ```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,
645
+ // Recommended: file-level cleanup
646
+ beforeEach(() => {
647
+ jest.clearAllMocks()
648
+ })
649
+
650
+ // Alternative: per-describe when different describes need different setups
651
+ describe("feature A", () => {
652
+ beforeEach(() => {
653
+ jest.clearAllMocks()
654
+ mockFn.mockResolvedValue("A result")
699
655
  })
656
+ })
657
+ ```
700
658
 
701
- expect(errors).toHaveLength(1)
659
+ Without `jest.clearAllMocks()`, a mock called in one test still shows those calls in the next test:
702
660
 
703
- // Verify compensation ran — order should not exist
704
- const orders = await service.listOrders({ customerId: "cus_123" })
705
- expect(orders).toHaveLength(0)
706
- })
661
+ ```typescript
662
+ expect(mockSign).not.toHaveBeenCalled() // FAILS called by prior test
707
663
  ```
708
664
 
709
- ### `when()` branches
665
+ ### Testing Code with Timers
666
+
667
+ When code under test calls `setTimeout`, `setInterval`, or a `sleep()` function, tests time out or run slowly.
710
668
 
711
669
  ```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
- })
670
+ // Option 1: Fake timers (for setTimeout/setInterval)
671
+ beforeEach(() => {
672
+ jest.useFakeTimers()
673
+ })
674
+ afterEach(() => {
675
+ jest.useRealTimers()
676
+ })
677
+ it("should retry after delay", async () => {
678
+ const promise = provider.retryOperation()
679
+ await jest.advanceTimersByTimeAsync(3000)
680
+ const result = await promise
681
+ expect(result).toBeDefined()
682
+ })
722
683
 
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" }),
728
- ])
729
- )
730
- eventBusSpy.mockRestore()
684
+ // Option 2: Mock the sleep method (for custom sleep/delay functions)
685
+ it("should complete without waiting", async () => {
686
+ jest.spyOn(provider as any, "sleep_").mockResolvedValue(undefined)
687
+ const result = await provider.longRunningOperation()
688
+ expect(result).toBeDefined()
731
689
  })
732
690
  ```
733
691
 
734
- ### `parallelize()` results
692
+ ### SWC Regex Limitation
693
+
694
+ SWC's regex parser fails on certain complex regex literals. If you get a `Syntax Error` from SWC on a line with a regex:
735
695
 
736
696
  ```typescript
737
- it("should run parallel steps", async () => {
738
- const { result } = await enrichOrderWorkflow(container).run({
739
- input: { orderId: order.id },
740
- })
697
+ // WRONG SWC may fail to parse this
698
+ const pattern = /^(\*|[0-9]+)(\/[0-9]+)?$|^\*\/[0-9]+$/
741
699
 
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)
747
- })
700
+ // RIGHT use RegExp constructor
701
+ const pattern = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$|^\\*\\/[0-9]+$")
748
702
  ```
749
703
 
750
- ### `StepResponse.permanentFailure()` and `StepResponse.skip()`
704
+ ### Verifying Error Paths
751
705
 
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
- })
706
+ When testing error cases, **read the implementation** to determine whether the method throws or returns an error object. Don't assume from the return type alone.
758
707
 
759
- expect(errors).toHaveLength(1)
760
- expect(errors[0].error.message).toContain("Fraudulent payment detected")
761
- // permanentFailure skips compensation — verify no rollback happened
762
- })
708
+ ```typescript
709
+ // If the method catches errors and returns { success: false }:
710
+ const result = await provider.process(badInput)
711
+ expect(result.success).toBe(false)
763
712
 
764
- it("should skip optional step", async () => {
765
- const { result } = await processOrderWorkflow(container).run({
766
- input: { orderId: order.id, skipLoyaltyPoints: true },
767
- })
713
+ // If the method throws (no internal try/catch on that path):
714
+ await expect(provider.process(badInput)).rejects.toThrow("invalid")
768
715
 
769
- // Workflow completes successfully, loyalty step was skipped
770
- expect(result.order.status).toBe("processed")
771
- expect(result.loyaltyPoints).toBeUndefined()
772
- })
716
+ // If validation runs BEFORE a try/catch block:
717
+ // validateInput() throws → not caught by the try/catch in process()
718
+ await expect(provider.process(badInput)).rejects.toThrow("validation")
773
719
  ```
774
720
 
775
- ### Workflow hooks
721
+ **Tip:** Look for `try/catch` blocks in the implementation. Code that runs BEFORE or OUTSIDE a `try/catch` throws directly. Code INSIDE a `try/catch` may return an error result instead.
776
722
 
777
- ```typescript
778
- it("should execute hook handler", async () => {
779
- const hookResult: any[] = []
723
+ ---
780
724
 
781
- const workflow = createOrderWorkflow(container)
782
- workflow.hooks.orderCreated((input) => {
783
- hookResult.push(input)
784
- })
725
+ ## Testing Plugins with Dependencies
785
726
 
786
- await workflow.run({
787
- input: { customerId: "cus_123", items: [{ sku: "SKU-001", qty: 1 }] },
788
- })
727
+ When your plugin depends on other plugins (declared in `definePlugin({ dependencies })`), the test runner validates that all dependencies are installed. For workspace plugins that depend on other workspace plugins, use `skipDependencyValidation`:
789
728
 
790
- expect(hookResult).toHaveLength(1)
791
- expect(hookResult[0]).toEqual(
792
- expect.objectContaining({ orderId: expect.any(String) })
793
- )
729
+ ```typescript
730
+ integrationTestRunner({
731
+ mode: "plugin",
732
+ pluginPath: process.cwd(),
733
+ skipDependencyValidation: true,
734
+ injectedDependencies: {
735
+ // Mock services your plugin expects from peer plugins
736
+ peerModuleService: { list: jest.fn().mockResolvedValue([]) },
737
+ },
738
+ testSuite: ({ container }) => { ... },
794
739
  })
795
740
  ```
796
741
 
797
- ---
798
-
799
- ## Cross-Module Testing with additionalModules
742
+ ## Configuring Providers in Plugin Tests
800
743
 
801
- When a plugin workflow depends on another module (e.g., a payment module):
744
+ Use `pluginModuleOptions` to pass per-module options (e.g., provider configuration) keyed by module name:
802
745
 
803
746
  ```typescript
804
- import { PaymentModule } from "@acmekit/payment"
805
-
806
- pluginIntegrationTestRunner({
747
+ integrationTestRunner({
748
+ mode: "plugin",
807
749
  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",
750
+ pluginModuleOptions: {
751
+ tron: {
752
+ providers: [
753
+ {
754
+ resolve: "./src/providers/energy/own-pool",
755
+ id: "own-pool",
756
+ options: { apiKey: "test-key" },
818
757
  },
819
- })
820
- expect(result.payment).toEqual(
821
- expect.objectContaining({ status: "captured" })
822
- )
823
- })
758
+ ],
759
+ },
824
760
  },
761
+ testSuite: ({ container }) => { ... },
825
762
  })
826
763
  ```
827
764
 
828
- ---
765
+ `pluginModuleOptions` is merged into the discovered module config AFTER `pluginOptions`, so module-specific options override plugin-level ones.
829
766
 
830
- ## Link / Relation Testing in Plugin Context
767
+ ---
831
768
 
832
- Test cross-module links via `container.resolve`:
769
+ ## Anti-Patterns NEVER Do These
833
770
 
834
771
  ```typescript
835
- import { ContainerRegistrationKeys } from "@acmekit/framework/utils"
772
+ // WRONG using deprecated runner names
773
+ import { pluginIntegrationTestRunner } from "@acmekit/test-utils" // ❌
774
+ import { moduleIntegrationTestRunner } from "@acmekit/test-utils" // ❌
775
+ // RIGHT — use unified runner with mode
776
+ import { integrationTestRunner } from "@acmekit/test-utils"
777
+ integrationTestRunner({ mode: "plugin", pluginPath: process.cwd(), ... })
778
+
779
+ // WRONG — trying to use `api` in container-only plugin tests
780
+ integrationTestRunner({
781
+ mode: "plugin",
782
+ pluginPath: process.cwd(),
783
+ testSuite: ({ api }) => { // ❌ api is not available without http: true
784
+ await api.get("/admin/plugin")
785
+ },
786
+ })
836
787
 
837
- it("should create and query a link", async () => {
838
- const remoteLink = container.resolve(ContainerRegistrationKeys.LINK)
839
- const query = container.resolve(ContainerRegistrationKeys.QUERY)
788
+ // WRONG hardcoding pluginPath
789
+ integrationTestRunner({ mode: "plugin", pluginPath: "/absolute/path" }) // ❌
790
+ // RIGHT
791
+ integrationTestRunner({ mode: "plugin", pluginPath: process.cwd() })
840
792
 
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" }])
793
+ // WRONG no auth in HTTP tests
794
+ it("should list", async () => {
795
+ await api.get("/admin/plugin") // ❌ 401
796
+ })
844
797
 
845
- await remoteLink.create([{
846
- blogModuleService: { blog_post_id: post.id },
847
- blogCategoryModuleService: { blog_category_id: category.id },
848
- }])
798
+ // WRONG — asserting error responses without catching (axios throws!)
799
+ const response = await api.post("/admin/plugin/greetings", {}, adminHeaders)
800
+ expect(response.status).toEqual(400) // never reached
801
+ // RIGHT
802
+ const { response } = await api.post("/admin/plugin/greetings", {}, adminHeaders).catch((e: any) => e)
803
+ expect(response.status).toEqual(400)
849
804
 
850
- const { data: [linkedPost] } = await query.graph({
851
- entity: "blog_post",
852
- fields: ["id", "title", "category.*"],
853
- filters: { id: post.id },
854
- })
805
+ // WRONG calling workflow without passing container
806
+ await createGreetingsWorkflow.run({ input: {} }) // ❌
807
+ // RIGHT
808
+ await createGreetingsWorkflow(container).run({ input: {} })
855
809
 
856
- expect(linkedPost.category.name).toBe("Tech")
810
+ // WRONG — using .rejects.toThrow() on workflow errors
811
+ await expect(
812
+ createGreetingsWorkflow(container).run({ input: {} })
813
+ ).rejects.toThrow() // ❌ plain object, not Error instance
814
+ // RIGHT
815
+ const { errors } = await createGreetingsWorkflow(container).run({
816
+ input: {},
817
+ throwOnError: false,
857
818
  })
858
- ```
859
-
860
- ---
819
+ expect(errors[0].error.message).toContain("message")
861
820
 
862
- ## What to Test
821
+ // WRONG calling waitSubscribersExecution AFTER triggering event
822
+ await eventBus.emit({ name: "greeting.created", data: { id } })
823
+ await TestEventUtils.waitSubscribersExecution("greeting.created", eventBus) // ❌
824
+ // RIGHT — capture promise BEFORE
825
+ const done = TestEventUtils.waitSubscribersExecution("greeting.created", eventBus)
826
+ await eventBus.emit({ name: "greeting.created", data: { id } })
827
+ await done
828
+
829
+ // WRONG — exact object assertions (timestamps/IDs change)
830
+ expect(result).toEqual({ id: "123", message: "Test" }) // ❌
831
+ // RIGHT
832
+ expect(result).toEqual(expect.objectContaining({
833
+ id: expect.any(String),
834
+ message: "Test",
835
+ }))
863
836
 
864
- **Plugin loading:** Modules resolve from container, plugin options accessible via `plugin.resolveOptions(container)`, auto-discovered resources (subscribers, workflows, jobs) are loaded.
837
+ // WRONG non-standard Jest matchers
838
+ expect(value).toBeOneOf([expect.any(String), null]) // ❌
839
+ // RIGHT
840
+ expect(value === null || typeof value === "string").toBe(true)
865
841
 
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: [...] }`.
842
+ // WRONG resolve path as relative from test file
843
+ integrationTestRunner({ mode: "module", moduleName: "greeting", resolve: "../index" }) // ❌
844
+ // RIGHT
845
+ integrationTestRunner({ mode: "module", moduleName: "greeting", resolve: process.cwd() + "/src/modules/greeting" })
867
846
 
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)`)
847
+ // WRONG — using jsonwebtoken directly
848
+ import jwt from "jsonwebtoken" //
849
+ // RIGHT use generateJwtToken from @acmekit/framework/utils
878
850
 
879
- **Subscribers/Events:** Spy on `MockEventBusService.prototype.emit`, access events via `mock.calls[0][0]`, use `TestEventUtils.waitSubscribersExecution` for async subscriber side-effects.
851
+ // WRONG using old publishable API key names
852
+ { type: "publishable" } // ❌
853
+ { "x-publishable-api-key": token } // ❌
854
+ // RIGHT
855
+ { type: ApiKeyType.CLIENT }
856
+ { [CLIENT_API_KEY_HEADER]: token }
857
+
858
+ // WRONG — unused imports, JSDoc blocks, type casts in tests
859
+
860
+ // WRONG — referencing file-level const/let inside jest.mock factory (TDZ)
861
+ const mockFn = jest.fn()
862
+ jest.mock("lib", () => ({ thing: mockFn })) // ❌ ReferenceError
863
+ // RIGHT — create mocks inside factory, access via require()
864
+ jest.mock("lib", () => {
865
+ const mocks = { thing: jest.fn() }
866
+ return { ...mocks, __mocks: mocks }
867
+ })
868
+ const { __mocks } = require("lib")
869
+
870
+ // WRONG — no mock cleanup between tests
871
+ describe("A", () => { it("calls mock", () => { mockFn() }) })
872
+ describe("B", () => { it("mock is clean", () => {
873
+ expect(mockFn).not.toHaveBeenCalled() // ❌ fails — leaked from A
874
+ }) })
875
+ // RIGHT — add beforeEach(() => jest.clearAllMocks())
876
+
877
+ // WRONG — real timers in unit tests cause timeouts
878
+ it("should process", async () => {
879
+ await relay.process(data) // ❌ hangs — code calls sleep(3000) internally
880
+ })
881
+ // RIGHT — mock timers or sleep method
882
+ jest.useFakeTimers()
883
+ // or: jest.spyOn(relay as any, "sleep_").mockResolvedValue(undefined)
880
884
 
881
- **Links:** `remoteLink.create()` + `query.graph()` with related fields.
885
+ // WRONG complex regex literal that SWC can't parse
886
+ const re = /^(\*|[0-9]+)(\/[0-9]+)?$/ // ❌ SWC Syntax Error
887
+ // RIGHT
888
+ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
882
889
 
883
- **HTTP routes:** Cannot be tested with `pluginIntegrationTestRunner`. Mount the plugin in a host app and use `acmekitIntegrationTestRunner` there.
890
+ // WRONG assuming method returns error without reading implementation
891
+ const result = await provider.process(bad)
892
+ expect(result.success).toBe(false) // ❌ actually throws
893
+ // RIGHT — read implementation first to check if it throws or returns
894
+ ```