@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.
@@ -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,166 @@ 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`.
134
-
135
- #### Unit Test
174
+ #### Subscriber Test
136
175
 
137
176
  ```typescript
138
- import { formatOrderNumber } from "../format-order-number"
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)
139
182
 
140
- describe("formatOrderNumber", () => {
141
- it("should pad to 6 digits", () => {
142
- expect(formatOrderNumber(42)).toBe("ORD-000042")
143
- })
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
+ },
144
207
  })
145
208
  ```
146
209
 
147
- #### Testing Workflows
210
+ #### Job Test
148
211
 
149
212
  ```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")
156
- })
213
+ import { integrationTestRunner } from "@acmekit/test-utils"
214
+ import archiveOldPostsJob from "../../src/jobs/archive-old-posts"
215
+ import { POST_MODULE } from "../../src/modules/post"
157
216
 
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,
163
- })
164
- expect(errors).toHaveLength(1)
165
- expect(errors[0].error.message).toContain("title") // errors[0].error.message — NOT errors[0].message
166
- })
217
+ jest.setTimeout(60 * 1000)
167
218
 
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,
173
- })
174
- expect(errors[0].error.message).toContain("Card declined")
175
- })
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
+ ])
176
230
 
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,
181
- })
182
- // SkipStep doesn't produce errors — workflow continues
183
- expect(result).toBeDefined()
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
+ },
184
238
  })
185
239
  ```
186
240
 
187
- #### Testing Domain Events
241
+ #### Module Integration Test
188
242
 
189
243
  ```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
- )
244
+ import { integrationTestRunner } from "@acmekit/test-utils"
245
+
246
+ jest.setTimeout(30000)
247
+
248
+ integrationTestRunner<IPostModuleService>({
249
+ mode: "module",
250
+ moduleName: "post",
251
+ resolve: process.cwd() + "/src/modules/post",
252
+ testSuite: ({ service }) => {
253
+ it("should create a post", async () => {
254
+ const result = await service.createPosts([{ title: "Test" }])
255
+ expect(result[0]).toEqual(
256
+ expect.objectContaining({ title: "Test" })
257
+ )
258
+ })
259
+ },
203
260
  })
204
261
  ```
205
262
 
@@ -208,17 +265,18 @@ it("should emit post.created event", async () => {
208
265
  ```bash
209
266
  pnpm test:unit # Unit tests
210
267
  pnpm test:integration:modules # Module integration tests
268
+ pnpm test:integration:app # App integration tests (workflows/subscribers/jobs)
211
269
  pnpm test:integration:http # HTTP integration tests
212
270
  ```
213
271
 
214
272
  ## Key Patterns
215
273
 
274
+ - Use `integrationTestRunner` with `mode` — never the old deprecated runner names
216
275
  - 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
276
+ - Inline auth setup in `beforeEach` — no `createAdminUser` helper
277
+ - Pass body directly: `api.post(url, body, headers)` NOT `{ body: {...} }`
278
+ - Use `.catch((e: any) => e)` for error assertions — axios throws on 4xx/5xx
222
279
  - Use `expect.objectContaining()` with `expect.any(String)` for IDs/timestamps
223
280
  - Call `waitSubscribersExecution` BEFORE triggering the event
281
+ - Import jobs/subscribers directly and call with container
224
282
  - Use realistic test data, not "test" or "foo"