@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.
@@ -1,61 +1,95 @@
1
1
  ---
2
2
  name: write-test
3
- description: "Generates integration or unit tests using the correct AcmeKit test runner. Auto-detects whether to use acmekitIntegrationTestRunner (HTTP routes) or moduleIntegrationTestRunner (module services). Use when adding tests for modules, workflows, API routes, or service methods."
3
+ description: "Generates integration or unit tests using the unified integrationTestRunner. Auto-detects the correct mode (app for HTTP/workflows, module for isolated services). Use when adding tests for modules, workflows, subscribers, jobs, or API routes."
4
4
  argument-hint: "[module-or-feature]"
5
5
  allowed-tools: Bash, Read, Write, Edit, Grep, Glob
6
6
  ---
7
7
 
8
8
  # Write Test
9
9
 
10
- Generate tests for AcmeKit applications using the correct test runner and patterns.
10
+ Generate tests for AcmeKit applications using `integrationTestRunner` with the correct mode and patterns.
11
11
 
12
12
  ## Current Test Files
13
13
 
14
14
  - Existing tests: !`find src -name "*.spec.ts" -o -name "*.test.ts" 2>/dev/null | head -20 || echo "(none)"`
15
15
  - HTTP integration tests: !`ls integration-tests/http/*.spec.ts 2>/dev/null || echo "(none)"`
16
+ - App integration tests: !`ls integration-tests/app/*.spec.ts 2>/dev/null || echo "(none)"`
16
17
 
17
18
  ## Critical Gotchas — Every Test Must Get These Right
18
19
 
19
- 1. **Service resolution key = module constant.** For core modules: `Modules.AUTH`, `Modules.USER`. For custom modules: `container.resolve(BLOG_MODULE)` where `BLOG_MODULE = "blog"` (the string passed to `Module()`). NEVER guess `"blogModuleService"` or `"postModuleService"` — it won't resolve (AwilixResolutionError).
20
- 2. **`moduleName` in `moduleIntegrationTestRunner` = module constant.** Same rule: `moduleName: MY_MODULE`. NEVER use `"myModuleService"`.
21
- 3. **`resolve` in `moduleIntegrationTestRunner` = string path from CWD.** Use `resolve: "./src/modules/my-module"`. NEVER pass the imported module object (`resolve: PostModule` hangs — models not found).
22
- 4. **Workflow errors are plain objects, not Error instances.** NEVER use `.rejects.toThrow()` on workflows — always fails. Use `throwOnError: false` + `errors` array. Error path: `errors[0].error.message` (NOT `errors[0].message`).
23
- 5. **`.rejects.toThrow()` DOES work for service errors** (e.g., `service.retrievePost("bad-id")`). Only workflow errors are serialized.
24
- 6. **`acmekitIntegrationTestRunner` runs real migrations** custom modules MUST have migration files or you get `TableNotFoundException`. `moduleIntegrationTestRunner` syncs schema from entities (no migrations needed).
25
- 7. **Axios throws on 4xx/5xx.** Use `.catch((e) => e)` for error-case assertions. Access via `error.response.status` / `error.response.data`.
20
+ 1. **Unified runner only.** `import { integrationTestRunner } from "@acmekit/test-utils"`. NEVER use `acmekitIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.
21
+ 2. **Service resolution key = module constant.** `getContainer().resolve(BLOG_MODULE)` where `BLOG_MODULE = "blog"` (the string passed to `Module()`). NEVER guess `"blogModuleService"`.
22
+ 3. **`resolve` in module mode = absolute path.** Use `resolve: process.cwd() + "/src/modules/post"`. NEVER pass the imported module object.
23
+ 4. **Workflow errors are plain objects.** NEVER use `.rejects.toThrow()` on workflows. Use `throwOnError: false` + `errors` array. Error path: `errors[0].error.message`.
24
+ 5. **`.rejects.toThrow()` DOES work for service errors** (real `Error` instances). Only workflow errors are serialized.
25
+ 6. **Inline auth setup.** There is no `createAdminUser` helper. Resolve `Modules.USER`, `Modules.AUTH`, `Modules.API_KEY` directly in `beforeEach`.
26
+ 7. **Axios throws on 4xx/5xx.** Use `.catch((e: any) => e)` for error assertions.
26
27
 
27
28
  ## Instructions
28
29
 
29
30
  ### Step 1: Determine Test Type
30
31
 
31
- | What to test | Runner | File location |
32
+ | What to test | Mode | File location |
32
33
  |---|---|---|
33
- | API routes (HTTP requests) | `acmekitIntegrationTestRunner` | `integration-tests/http/<feature>.spec.ts` |
34
- | Module service methods | `moduleIntegrationTestRunner` | `src/modules/<mod>/__tests__/<name>.spec.ts` |
35
- | Workflows (via HTTP) | `acmekitIntegrationTestRunner` | `integration-tests/http/<feature>.spec.ts` |
36
- | Workflows (direct) | call `workflow(getContainer()).run()` inside either runner | depends on test type |
37
- | Pure functions | Plain Jest `describe/it` | `src/**/__tests__/<name>.unit.spec.ts` |
34
+ | API routes (HTTP) | `mode: "app"` | `integration-tests/http/<feature>.spec.ts` |
35
+ | Workflows, subscribers, jobs | `mode: "app"` | `integration-tests/app/<feature>.spec.ts` |
36
+ | Module service methods | `mode: "module"` | `src/modules/<mod>/__tests__/<name>.spec.ts` |
37
+ | Pure functions | Plain Jest | `src/**/__tests__/<name>.unit.spec.ts` |
38
38
 
39
39
  ### Step 2: Generate Test File
40
40
 
41
41
  #### HTTP Integration Test
42
42
 
43
43
  ```typescript
44
- import { acmekitIntegrationTestRunner } from "@acmekit/test-utils"
44
+ import { integrationTestRunner } from "@acmekit/test-utils"
45
+ import {
46
+ ApiKeyType,
47
+ CLIENT_API_KEY_HEADER,
48
+ ContainerRegistrationKeys,
49
+ generateJwtToken,
50
+ Modules,
51
+ } from "@acmekit/framework/utils"
45
52
 
46
53
  jest.setTimeout(60 * 1000)
47
54
 
48
- const adminHeaders = {
49
- headers: { "x-acmekit-access-token": "test_token" },
50
- }
55
+ integrationTestRunner({
56
+ mode: "app",
57
+ testSuite: ({ api, getContainer }) => {
58
+ let adminHeaders: Record<string, any>
59
+
60
+ beforeEach(async () => {
61
+ const container = getContainer()
62
+ const userModule = container.resolve(Modules.USER)
63
+ const authModule = container.resolve(Modules.AUTH)
64
+
65
+ const user = await userModule.createUsers({ email: "admin@test.js" })
66
+ const authIdentity = await authModule.createAuthIdentities({
67
+ provider_identities: [
68
+ { provider: "emailpass", entity_id: "admin@test.js" },
69
+ ],
70
+ app_metadata: { user_id: user.id },
71
+ })
72
+
73
+ const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
74
+ const { jwtSecret, jwtOptions } = config.projectConfig.http
75
+
76
+ const token = generateJwtToken(
77
+ {
78
+ actor_id: user.id,
79
+ actor_type: "user",
80
+ auth_identity_id: authIdentity.id,
81
+ app_metadata: { user_id: user.id },
82
+ },
83
+ { secret: jwtSecret, expiresIn: "1d", jwtOptions }
84
+ )
85
+
86
+ adminHeaders = { headers: { authorization: `Bearer ${token}` } }
87
+ })
51
88
 
52
- acmekitIntegrationTestRunner({
53
- testSuite: ({ api, getContainer, utils }) => {
54
89
  describe("GET /admin/posts", () => {
55
90
  it("should list posts", async () => {
56
91
  const response = await api.get("/admin/posts", adminHeaders)
57
- expect(response.status).toBe(200)
58
- expect(response.data.posts).toBeDefined()
92
+ expect(response.status).toEqual(200)
59
93
  })
60
94
  })
61
95
 
@@ -63,143 +97,207 @@ acmekitIntegrationTestRunner({
63
97
  it("should create a post", async () => {
64
98
  const response = await api.post(
65
99
  "/admin/posts",
66
- { title: "Launch Announcement", body: "We are live." },
100
+ { title: "Launch Announcement" },
67
101
  adminHeaders
68
102
  )
69
- expect(response.status).toBe(201)
70
- expect(response.data.post.title).toBe("Launch Announcement")
103
+ expect(response.status).toEqual(200)
104
+ expect(response.data.post).toEqual(
105
+ expect.objectContaining({
106
+ id: expect.any(String),
107
+ title: "Launch Announcement",
108
+ })
109
+ )
71
110
  })
72
111
 
73
- it("should reject missing required fields with 400", async () => {
112
+ it("should reject missing required fields", async () => {
74
113
  const { response } = await api
75
114
  .post("/admin/posts", {}, adminHeaders)
76
- .catch((e) => e)
115
+ .catch((e: any) => e)
77
116
  expect(response.status).toEqual(400)
78
117
  })
79
118
  })
80
119
 
81
120
  describe("DELETE /admin/posts/:id", () => {
82
121
  it("should delete and confirm", async () => {
83
- const { data } = await api.post(
84
- "/admin/posts",
85
- { title: "To Remove" },
86
- adminHeaders
87
- )
122
+ const created = (
123
+ await api.post("/admin/posts", { title: "To Remove" }, adminHeaders)
124
+ ).data.post
125
+
88
126
  const response = await api.delete(
89
- `/admin/posts/${data.post.id}`,
127
+ `/admin/posts/${created.id}`,
90
128
  adminHeaders
91
129
  )
92
- expect(response.status).toBe(200)
93
- expect(response.data).toEqual(
94
- expect.objectContaining({ id: data.post.id, deleted: true })
95
- )
130
+ expect(response.data).toEqual({
131
+ id: created.id,
132
+ object: "post",
133
+ deleted: true,
134
+ })
96
135
  })
97
136
  })
98
137
  },
99
138
  })
100
139
  ```
101
140
 
102
- **Fixtures:** `api` (axios), `getContainer()`, `dbConnection`, `dbUtils`, `getAcmeKitApp()`, `utils.waitWorkflowExecutions()`.
103
-
104
- #### Module Integration Test
141
+ #### App Integration Test (workflows, subscribers, jobs)
105
142
 
106
143
  ```typescript
107
- import { moduleIntegrationTestRunner } from "@acmekit/test-utils"
108
- import { POST_MODULE } from "../../src/modules/post" // POST_MODULE = "post" — must match Module() key
144
+ import { integrationTestRunner } from "@acmekit/test-utils"
145
+ import { createPostsWorkflow } from "../../src/workflows/workflows"
146
+ import { POST_MODULE } from "../../src/modules/post"
109
147
 
110
- moduleIntegrationTestRunner<IPostModuleService>({
111
- moduleName: POST_MODULE, // must match Module() key
112
- resolve: "./src/modules/post", // string path from CWD — NEVER pass imported module object
113
- testSuite: ({ service }) => {
114
- describe("createPosts", () => {
115
- it("should create a post", async () => {
116
- const result = await service.createPosts([
117
- { title: "Quarterly Report" },
118
- ])
119
- expect(result).toHaveLength(1)
120
- expect(result[0]).toEqual(
121
- expect.objectContaining({ title: "Quarterly Report" })
122
- )
148
+ jest.setTimeout(60 * 1000)
149
+
150
+ integrationTestRunner({
151
+ mode: "app",
152
+ testSuite: ({ getContainer }) => {
153
+ it("should create via workflow", async () => {
154
+ const { result } = await createPostsWorkflow(getContainer()).run({
155
+ input: { posts: [{ title: "My Post" }] },
123
156
  })
157
+ expect(result[0]).toEqual(
158
+ expect.objectContaining({ title: "My Post" })
159
+ )
160
+ })
124
161
 
125
- it("should throw on invalid data", async () => {
126
- await expect(service.createPosts([{}])).rejects.toThrow()
162
+ it("should reject invalid input", async () => {
163
+ const { errors } = await createPostsWorkflow(getContainer()).run({
164
+ input: { posts: [{ title: "", status: "bad" }] },
165
+ throwOnError: false,
127
166
  })
167
+ expect(errors).toHaveLength(1)
168
+ expect(errors[0].error.message).toContain("Invalid")
128
169
  })
129
170
  },
130
171
  })
131
172
  ```
132
173
 
133
- **Fixtures:** `service` (proxy), `MikroOrmWrapper`, `acmekitApp`, `dbConfig`.
174
+ #### Subscriber Test
175
+
176
+ ```typescript
177
+ import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
178
+ import { Modules } from "@acmekit/framework/utils"
179
+ import { POST_MODULE } from "../../src/modules/post"
180
+
181
+ jest.setTimeout(60 * 1000)
182
+
183
+ integrationTestRunner({
184
+ mode: "app",
185
+ testSuite: ({ getContainer }) => {
186
+ it("should execute subscriber side-effect", async () => {
187
+ const container = getContainer()
188
+ const service: any = container.resolve(POST_MODULE)
189
+ const eventBus = container.resolve(Modules.EVENT_BUS)
190
+
191
+ const [post] = await service.createPosts([
192
+ { title: "Test", content: "Original" },
193
+ ])
194
+
195
+ // CRITICAL: create promise BEFORE emitting
196
+ const subscriberDone = TestEventUtils.waitSubscribersExecution(
197
+ "post.published",
198
+ eventBus
199
+ )
200
+ await eventBus.emit({ name: "post.published", data: { id: post.id } })
201
+ await subscriberDone
202
+
203
+ const updated = await service.retrievePost(post.id)
204
+ expect(updated.content).toBe("Original [notified]")
205
+ })
206
+ },
207
+ })
208
+ ```
134
209
 
135
- #### Unit Test
210
+ #### Job Test
136
211
 
137
212
  ```typescript
138
- import { formatOrderNumber } from "../format-order-number"
213
+ import { integrationTestRunner } from "@acmekit/test-utils"
214
+ import archiveOldPostsJob from "../../src/jobs/archive-old-posts"
215
+ import { POST_MODULE } from "../../src/modules/post"
139
216
 
140
- describe("formatOrderNumber", () => {
141
- it("should pad to 6 digits", () => {
142
- expect(formatOrderNumber(42)).toBe("ORD-000042")
143
- })
217
+ jest.setTimeout(60 * 1000)
218
+
219
+ integrationTestRunner({
220
+ mode: "app",
221
+ testSuite: ({ getContainer }) => {
222
+ it("should archive old posts", async () => {
223
+ const container = getContainer()
224
+ const service: any = container.resolve(POST_MODULE)
225
+
226
+ await service.createPosts([
227
+ { title: "Old", status: "archived" },
228
+ { title: "Active", status: "published" },
229
+ ])
230
+
231
+ await archiveOldPostsJob(container)
232
+
233
+ const remaining = await service.listPosts()
234
+ expect(remaining).toHaveLength(1)
235
+ expect(remaining[0].title).toBe("Active")
236
+ })
237
+ },
144
238
  })
145
239
  ```
146
240
 
147
- #### Testing Workflows
241
+ #### Unit Test (No Framework Bootstrap)
242
+
243
+ For providers, utilities, and standalone classes. **CRITICAL:** `jest.mock()` factories are hoisted above `const`/`let`. Create mocks INSIDE the factory, access via `require()`.
148
244
 
149
245
  ```typescript
150
- // Via HTTP (when route triggers the workflow)
151
- it("should process order via workflow", async () => {
152
- await api.post("/admin/orders", { items: [...] }, adminHeaders)
153
- await utils.waitWorkflowExecutions() // wait for async workflows
154
- const response = await api.get("/admin/orders", adminHeaders)
155
- expect(response.data.orders[0].status).toBe("processed")
246
+ jest.mock("external-sdk", () => {
247
+ const mocks = { doThing: jest.fn() }
248
+ const MockClient = jest.fn().mockImplementation(() => ({
249
+ doThing: mocks.doThing,
250
+ }))
251
+ return { Client: MockClient, __mocks: mocks }
156
252
  })
157
253
 
158
- // Direct execution NEVER use .rejects.toThrow() on workflows
159
- it("should reject invalid workflow input", async () => {
160
- const { errors } = await createPostWorkflow(getContainer()).run({
161
- input: {},
162
- throwOnError: false,
254
+ const { __mocks: sdkMocks } = require("external-sdk")
255
+
256
+ import MyProvider from "../my-provider"
257
+
258
+ describe("MyProvider", () => {
259
+ const mockContainer = {} as any
260
+
261
+ beforeEach(() => {
262
+ jest.clearAllMocks()
163
263
  })
164
- expect(errors).toHaveLength(1)
165
- expect(errors[0].error.message).toContain("title") // errors[0].error.message — NOT errors[0].message
166
- })
167
264
 
168
- // Typed errors — PermanentFailure stops retries, SkipStep skips gracefully
169
- it("should permanently fail on invalid card", async () => {
170
- const { errors } = await chargeCardWorkflow(getContainer()).run({
171
- input: { cardToken: "invalid", amount: 100 },
172
- throwOnError: false,
265
+ it("should have correct identifier", () => {
266
+ expect(MyProvider.identifier).toBe("my-provider")
173
267
  })
174
- expect(errors[0].error.message).toContain("Card declined")
175
- })
176
268
 
177
- it("should skip optional step when disabled", async () => {
178
- const { result } = await processOrderWorkflow(getContainer()).run({
179
- input: { items: [{ id: "item-1" }] },
180
- throwOnError: false,
269
+ it("should delegate to SDK", async () => {
270
+ sdkMocks.doThing.mockResolvedValue({ success: true })
271
+ const provider = new MyProvider(mockContainer, { apiKey: "key" })
272
+ const result = await provider.doSomething({ input: "test" })
273
+ expect(result.success).toBe(true)
181
274
  })
182
- // SkipStep doesn't produce errors — workflow continues
183
- expect(result).toBeDefined()
184
275
  })
185
276
  ```
186
277
 
187
- #### Testing Domain Events
278
+ **Timer mocking:** Use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or `jest.spyOn(instance, "sleep_").mockResolvedValue(undefined)`.
279
+
280
+ **SWC regex:** Use `new RegExp("...")` instead of complex regex literals.
281
+
282
+ #### Module Integration Test
188
283
 
189
284
  ```typescript
190
- import { MockEventBusService } from "@acmekit/test-utils"
191
-
192
- let eventBusSpy: jest.SpyInstance
193
- beforeEach(() => { eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit") })
194
- afterEach(() => { eventBusSpy.mockRestore() })
195
-
196
- it("should emit post.created event", async () => {
197
- await service.createPosts([{ title: "Event Test" }])
198
- expect(eventBusSpy).toHaveBeenCalledWith(
199
- expect.arrayContaining([
200
- expect.objectContaining({ eventName: "post.created" }),
201
- ])
202
- )
285
+ import { integrationTestRunner } from "@acmekit/test-utils"
286
+
287
+ jest.setTimeout(30000)
288
+
289
+ integrationTestRunner<IPostModuleService>({
290
+ mode: "module",
291
+ moduleName: "post",
292
+ resolve: process.cwd() + "/src/modules/post",
293
+ testSuite: ({ service }) => {
294
+ it("should create a post", async () => {
295
+ const result = await service.createPosts([{ title: "Test" }])
296
+ expect(result[0]).toEqual(
297
+ expect.objectContaining({ title: "Test" })
298
+ )
299
+ })
300
+ },
203
301
  })
204
302
  ```
205
303
 
@@ -208,17 +306,23 @@ it("should emit post.created event", async () => {
208
306
  ```bash
209
307
  pnpm test:unit # Unit tests
210
308
  pnpm test:integration:modules # Module integration tests
309
+ pnpm test:integration:app # App integration tests (workflows/subscribers/jobs)
211
310
  pnpm test:integration:http # HTTP integration tests
212
311
  ```
213
312
 
214
313
  ## Key Patterns
215
314
 
315
+ - Use `integrationTestRunner` with `mode` — never the old deprecated runner names
216
316
  - Match the `jest.config.js` test buckets — don't invent new locations
217
- - All integration tests require `NODE_OPTIONS=--experimental-vm-modules`
218
- - Runners handle DB setup/teardown automaticallyno manual cleanup needed
219
- - Always pass `adminHeaders` on `/admin/*` routes
220
- - Pass body directly to `api.post(url, body, headers)` — NOT `{ body: {...} }`
221
- - Use `.catch((e) => e)` for error-case assertions — axios throws on 4xx/5xx
317
+ - Inline auth setup in `beforeEach` — no `createAdminUser` helper
318
+ - Pass body directly: `api.post(url, body, headers)` NOT `{ body: {...} }`
319
+ - Use `.catch((e: any) => e)` for error assertions — axios throws on 4xx/5xx
222
320
  - Use `expect.objectContaining()` with `expect.any(String)` for IDs/timestamps
223
321
  - Call `waitSubscribersExecution` BEFORE triggering the event
322
+ - Import jobs/subscribers directly and call with container
224
323
  - Use realistic test data, not "test" or "foo"
324
+ - **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
325
+ - **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error (SWC/Babel hoisting)
326
+ - **Mock timers or sleep** when code under test has delays — prevents test timeouts
327
+ - **Use `new RegExp()` over complex regex literals** — SWC parser fails on some patterns
328
+ - **Read implementation to verify error paths** — check if errors are thrown vs returned