@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.
- package/dist/templates/app/.claude/agents/test-writer.md +348 -368
- package/dist/templates/app/.claude/commands/test.md +7 -1
- package/dist/templates/app/.claude/rules/testing.md +711 -844
- package/dist/templates/app/.claude/skills/write-test/SKILL.md +216 -112
- package/dist/templates/plugin/.claude/agents/test-writer.md +405 -265
- package/dist/templates/plugin/.claude/commands/test.md +7 -1
- package/dist/templates/plugin/.claude/rules/testing.md +608 -597
- package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +275 -147
- package/package.json +39 -39
|
@@ -1,182 +1,113 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: test-writer
|
|
3
|
-
description: Generates comprehensive integration tests for AcmeKit modules, workflows, and API routes. Auto-detects the correct
|
|
3
|
+
description: Generates comprehensive integration tests for AcmeKit modules, workflows, subscribers, jobs, and API routes. Auto-detects the correct mode (app for HTTP/container, module for isolated services). Use proactively after implementing a feature to add test coverage.
|
|
4
4
|
tools: Read, Write, Edit, Glob, Grep
|
|
5
5
|
model: sonnet
|
|
6
6
|
maxTurns: 20
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
You are an AcmeKit test engineer. Generate comprehensive integration tests using the correct
|
|
9
|
+
You are an AcmeKit test engineer. Generate comprehensive integration tests using the unified `integrationTestRunner` with the correct mode and patterns.
|
|
10
10
|
|
|
11
11
|
**BEFORE writing any test:**
|
|
12
|
-
1. Read `.claude/rules/testing.md` — it contains anti-patterns and
|
|
12
|
+
1. Read `.claude/rules/testing.md` — it contains anti-patterns, fixtures, lifecycle, and error handling rules you MUST follow
|
|
13
13
|
2. Read the source code you're testing — understand service methods, route paths, validators, and response shapes
|
|
14
|
-
3. Identify
|
|
14
|
+
3. Identify the correct test tier (HTTP, app, module, or unit)
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
## Test Runner
|
|
18
|
+
## Test Runner — `integrationTestRunner` (unified)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|---|---|---|
|
|
22
|
-
| API routes (HTTP end-to-end) | `acmekitIntegrationTestRunner` | `integration-tests/http/<feature>.spec.ts` |
|
|
23
|
-
| Module service CRUD (no HTTP) | `moduleIntegrationTestRunner` | `src/modules/<mod>/__tests__/<name>.spec.ts` |
|
|
24
|
-
| Pure functions (no DB) | Plain Jest `describe/it` | `src/**/__tests__/<name>.unit.spec.ts` |
|
|
25
|
-
|
|
26
|
-
**Decision rule:** If the code under test is an API route → `acmekitIntegrationTestRunner`. If it's a module service method → `moduleIntegrationTestRunner`.
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## Module Integration Test Template
|
|
20
|
+
**NEVER use `acmekitIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.**
|
|
31
21
|
|
|
32
22
|
```typescript
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
jest.setTimeout(30000)
|
|
23
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
24
|
+
```
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
testSuite: ({ service }) => {
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
jest.restoreAllMocks()
|
|
47
|
-
})
|
|
26
|
+
| What to test | Mode | File location |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| API routes (HTTP end-to-end) | `mode: "app"` | `integration-tests/http/<feature>.spec.ts` |
|
|
29
|
+
| Workflows, subscribers, jobs (container) | `mode: "app"` | `integration-tests/app/<feature>.spec.ts` |
|
|
30
|
+
| Module service CRUD (isolated) | `mode: "module"` | `src/modules/<mod>/__tests__/<name>.spec.ts` |
|
|
31
|
+
| Pure functions (no DB) | Plain Jest | `src/**/__tests__/<name>.unit.spec.ts` |
|
|
48
32
|
|
|
49
|
-
|
|
50
|
-
it("should create an entity", async () => {
|
|
51
|
-
const result = await service.createMyEntities([
|
|
52
|
-
{ title: "Quarterly Report" },
|
|
53
|
-
])
|
|
54
|
-
expect(result).toHaveLength(1)
|
|
55
|
-
expect(result[0]).toEqual(
|
|
56
|
-
expect.objectContaining({
|
|
57
|
-
id: expect.any(String),
|
|
58
|
-
title: "Quarterly Report",
|
|
59
|
-
})
|
|
60
|
-
)
|
|
61
|
-
})
|
|
33
|
+
---
|
|
62
34
|
|
|
63
|
-
|
|
64
|
-
await expect(service.createMyEntities([{}])).rejects.toThrow()
|
|
65
|
-
})
|
|
66
|
-
})
|
|
35
|
+
## HTTP Integration Test Template
|
|
67
36
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
expect(count).toEqual(2)
|
|
86
|
-
})
|
|
87
|
-
})
|
|
37
|
+
```typescript
|
|
38
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
39
|
+
import {
|
|
40
|
+
ApiKeyType,
|
|
41
|
+
CLIENT_API_KEY_HEADER,
|
|
42
|
+
ContainerRegistrationKeys,
|
|
43
|
+
generateJwtToken,
|
|
44
|
+
Modules,
|
|
45
|
+
} from "@acmekit/framework/utils"
|
|
46
|
+
|
|
47
|
+
jest.setTimeout(60 * 1000)
|
|
48
|
+
|
|
49
|
+
integrationTestRunner({
|
|
50
|
+
mode: "app",
|
|
51
|
+
testSuite: ({ api, getContainer }) => {
|
|
52
|
+
let adminHeaders: Record<string, any>
|
|
53
|
+
let clientHeaders: Record<string, any>
|
|
88
54
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const retrieved = await service.retrieveMyEntity(created.id)
|
|
95
|
-
expect(retrieved.title).toBe("Retrieve Me")
|
|
96
|
-
})
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
const container = getContainer()
|
|
57
|
+
const userModule = container.resolve(Modules.USER)
|
|
58
|
+
const authModule = container.resolve(Modules.AUTH)
|
|
59
|
+
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
97
60
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.catch((e) => e)
|
|
102
|
-
expect(error.message).toContain("not found")
|
|
61
|
+
// Create admin user
|
|
62
|
+
const user = await userModule.createUsers({
|
|
63
|
+
email: "admin@test.js",
|
|
103
64
|
})
|
|
104
|
-
})
|
|
105
65
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
{
|
|
110
|
-
]
|
|
111
|
-
|
|
112
|
-
title: "New Title",
|
|
113
|
-
})
|
|
114
|
-
expect(updated.title).toBe("New Title")
|
|
66
|
+
// Create auth identity
|
|
67
|
+
const authIdentity = await authModule.createAuthIdentities({
|
|
68
|
+
provider_identities: [
|
|
69
|
+
{ provider: "emailpass", entity_id: "admin@test.js" },
|
|
70
|
+
],
|
|
71
|
+
app_metadata: { user_id: user.id },
|
|
115
72
|
})
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
describe("softDeleteMyEntities / restoreMyEntities", () => {
|
|
119
|
-
it("should soft delete and restore", async () => {
|
|
120
|
-
const [created] = await service.createMyEntities([
|
|
121
|
-
{ title: "To Delete" },
|
|
122
|
-
])
|
|
123
|
-
await service.softDeleteMyEntities([created.id])
|
|
124
73
|
|
|
125
|
-
|
|
126
|
-
|
|
74
|
+
// Generate JWT from project config — NEVER hardcode the secret
|
|
75
|
+
const config = container.resolve(
|
|
76
|
+
ContainerRegistrationKeys.CONFIG_MODULE
|
|
77
|
+
)
|
|
78
|
+
const { jwtSecret, jwtOptions } = config.projectConfig.http
|
|
79
|
+
|
|
80
|
+
const token = generateJwtToken(
|
|
81
|
+
{
|
|
82
|
+
actor_id: user.id,
|
|
83
|
+
actor_type: "user",
|
|
84
|
+
auth_identity_id: authIdentity.id,
|
|
85
|
+
app_metadata: { user_id: user.id },
|
|
86
|
+
},
|
|
87
|
+
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
88
|
+
)
|
|
127
89
|
|
|
128
|
-
|
|
90
|
+
adminHeaders = {
|
|
91
|
+
headers: { authorization: `Bearer ${token}` },
|
|
92
|
+
}
|
|
129
93
|
|
|
130
|
-
|
|
131
|
-
|
|
94
|
+
// Create client API key
|
|
95
|
+
const apiKey = await apiKeyModule.createApiKeys({
|
|
96
|
+
title: "Test Client Key",
|
|
97
|
+
type: ApiKeyType.CLIENT,
|
|
98
|
+
created_by: "test",
|
|
132
99
|
})
|
|
133
|
-
})
|
|
134
|
-
},
|
|
135
|
-
})
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
**Lifecycle:** Each `it` gets schema drop + recreate → fresh module boot. No manual cleanup needed.
|
|
139
|
-
|
|
140
|
-
---
|
|
141
|
-
|
|
142
|
-
## HTTP Admin Route Test Template
|
|
143
|
-
|
|
144
|
-
```typescript
|
|
145
|
-
import { acmekitIntegrationTestRunner } from "@acmekit/test-utils"
|
|
146
|
-
import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user"
|
|
147
100
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
testSuite: ({ api, getContainer, dbConnection }) => {
|
|
152
|
-
beforeEach(async () => {
|
|
153
|
-
await createAdminUser(dbConnection, adminHeaders, getContainer())
|
|
101
|
+
clientHeaders = {
|
|
102
|
+
headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
|
|
103
|
+
}
|
|
154
104
|
})
|
|
155
105
|
|
|
156
106
|
describe("GET /admin/posts", () => {
|
|
157
107
|
it("should list posts", async () => {
|
|
158
108
|
const response = await api.get("/admin/posts", adminHeaders)
|
|
159
109
|
expect(response.status).toEqual(200)
|
|
160
|
-
expect(response.data).
|
|
161
|
-
count: 0,
|
|
162
|
-
limit: 20,
|
|
163
|
-
offset: 0,
|
|
164
|
-
posts: [],
|
|
165
|
-
})
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it("should support field selection", async () => {
|
|
169
|
-
await api.post(
|
|
170
|
-
"/admin/posts",
|
|
171
|
-
{ title: "Test" },
|
|
172
|
-
adminHeaders
|
|
173
|
-
)
|
|
174
|
-
const response = await api.get(
|
|
175
|
-
"/admin/posts?fields=id,title",
|
|
176
|
-
adminHeaders
|
|
177
|
-
)
|
|
178
|
-
expect(response.status).toEqual(200)
|
|
179
|
-
expect(response.data.posts).toHaveLength(1)
|
|
110
|
+
expect(response.data.posts).toBeDefined()
|
|
180
111
|
})
|
|
181
112
|
})
|
|
182
113
|
|
|
@@ -184,7 +115,7 @@ acmekitIntegrationTestRunner({
|
|
|
184
115
|
it("should create a post", async () => {
|
|
185
116
|
const response = await api.post(
|
|
186
117
|
"/admin/posts",
|
|
187
|
-
{ title: "Launch Announcement"
|
|
118
|
+
{ title: "Launch Announcement" },
|
|
188
119
|
adminHeaders
|
|
189
120
|
)
|
|
190
121
|
expect(response.status).toEqual(200)
|
|
@@ -199,18 +130,7 @@ acmekitIntegrationTestRunner({
|
|
|
199
130
|
it("should reject missing required fields with 400", async () => {
|
|
200
131
|
const { response } = await api
|
|
201
132
|
.post("/admin/posts", {}, adminHeaders)
|
|
202
|
-
.catch((e) => e)
|
|
203
|
-
expect(response.status).toEqual(400)
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
it("should reject unknown fields with 400", async () => {
|
|
207
|
-
const { response } = await api
|
|
208
|
-
.post(
|
|
209
|
-
"/admin/posts",
|
|
210
|
-
{ title: "Test", unknown_field: "bad" },
|
|
211
|
-
adminHeaders
|
|
212
|
-
)
|
|
213
|
-
.catch((e) => e)
|
|
133
|
+
.catch((e: any) => e)
|
|
214
134
|
expect(response.status).toEqual(400)
|
|
215
135
|
})
|
|
216
136
|
})
|
|
@@ -236,13 +156,17 @@ acmekitIntegrationTestRunner({
|
|
|
236
156
|
deleted: true,
|
|
237
157
|
})
|
|
238
158
|
})
|
|
159
|
+
})
|
|
239
160
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
161
|
+
describe("Client routes", () => {
|
|
162
|
+
it("should return 200 with client API key", async () => {
|
|
163
|
+
const response = await api.get("/client/posts", clientHeaders)
|
|
164
|
+
expect(response.status).toEqual(200)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("should return 400 without API key", async () => {
|
|
168
|
+
const error = await api.get("/client/posts").catch((e: any) => e)
|
|
169
|
+
expect(error.response.status).toEqual(400)
|
|
246
170
|
})
|
|
247
171
|
})
|
|
248
172
|
},
|
|
@@ -251,51 +175,86 @@ acmekitIntegrationTestRunner({
|
|
|
251
175
|
|
|
252
176
|
---
|
|
253
177
|
|
|
254
|
-
##
|
|
178
|
+
## App Integration Test Template (workflows, subscribers, jobs)
|
|
255
179
|
|
|
256
|
-
|
|
180
|
+
For tests that only need `getContainer()` — no auth setup, no HTTP assertions:
|
|
257
181
|
|
|
258
182
|
```typescript
|
|
259
|
-
import {
|
|
260
|
-
import {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
183
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
184
|
+
import { createPostsWorkflow } from "../../src/workflows/workflows"
|
|
185
|
+
import { POST_MODULE } from "../../src/modules/post"
|
|
186
|
+
|
|
187
|
+
jest.setTimeout(60 * 1000)
|
|
188
|
+
|
|
189
|
+
integrationTestRunner({
|
|
190
|
+
mode: "app",
|
|
191
|
+
testSuite: ({ getContainer }) => {
|
|
192
|
+
describe("createPostsWorkflow", () => {
|
|
193
|
+
it("should create posts with defaults", async () => {
|
|
194
|
+
const { result } = await createPostsWorkflow(getContainer()).run({
|
|
195
|
+
input: { posts: [{ title: "My First Post" }] },
|
|
196
|
+
})
|
|
266
197
|
|
|
267
|
-
|
|
198
|
+
expect(result).toHaveLength(1)
|
|
199
|
+
expect(result[0]).toEqual(
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
id: expect.any(String),
|
|
202
|
+
title: "My First Post",
|
|
203
|
+
})
|
|
204
|
+
)
|
|
205
|
+
})
|
|
268
206
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
207
|
+
it("should reject invalid input via validation step", async () => {
|
|
208
|
+
const { errors } = await createPostsWorkflow(getContainer()).run({
|
|
209
|
+
input: { posts: [{ title: "", status: "bad" }] },
|
|
210
|
+
throwOnError: false,
|
|
211
|
+
})
|
|
272
212
|
|
|
273
|
-
|
|
213
|
+
expect(errors).toHaveLength(1)
|
|
214
|
+
expect(errors[0].error.message).toContain("Invalid")
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Subscriber Test Template
|
|
224
|
+
|
|
225
|
+
Uses `TestEventUtils.waitSubscribersExecution` with the real event bus. **CRITICAL: create the promise BEFORE emitting the event.**
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { integrationTestRunner, TestEventUtils } from "@acmekit/test-utils"
|
|
229
|
+
import { Modules } from "@acmekit/framework/utils"
|
|
230
|
+
import { POST_MODULE } from "../../src/modules/post"
|
|
231
|
+
|
|
232
|
+
jest.setTimeout(60 * 1000)
|
|
233
|
+
|
|
234
|
+
integrationTestRunner({
|
|
235
|
+
mode: "app",
|
|
236
|
+
testSuite: ({ getContainer }) => {
|
|
237
|
+
it("should execute subscriber side-effect", async () => {
|
|
274
238
|
const container = getContainer()
|
|
275
|
-
|
|
239
|
+
const service: any = container.resolve(POST_MODULE)
|
|
240
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
276
241
|
|
|
277
|
-
const
|
|
278
|
-
|
|
242
|
+
const [post] = await service.createPosts([
|
|
243
|
+
{ title: "Test", content: "Original" },
|
|
244
|
+
])
|
|
279
245
|
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
adminHeaders
|
|
246
|
+
// CRITICAL: create promise BEFORE emitting
|
|
247
|
+
const subscriberDone = TestEventUtils.waitSubscribersExecution(
|
|
248
|
+
"post.published",
|
|
249
|
+
eventBus
|
|
285
250
|
)
|
|
286
|
-
})
|
|
287
251
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
expect(response.data.products).toHaveLength(1)
|
|
292
|
-
})
|
|
252
|
+
// Emit event — single object { name, data } format (real event bus)
|
|
253
|
+
await eventBus.emit({ name: "post.published", data: { id: post.id } })
|
|
254
|
+
await subscriberDone
|
|
293
255
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.get("/client/products")
|
|
297
|
-
.catch((e) => e)
|
|
298
|
-
expect(response.status).toEqual(400)
|
|
256
|
+
const updated = await service.retrievePost(post.id)
|
|
257
|
+
expect(updated.content).toBe("Original [notified]")
|
|
299
258
|
})
|
|
300
259
|
},
|
|
301
260
|
})
|
|
@@ -303,98 +262,119 @@ acmekitIntegrationTestRunner({
|
|
|
303
262
|
|
|
304
263
|
---
|
|
305
264
|
|
|
306
|
-
##
|
|
265
|
+
## Job Test Template
|
|
266
|
+
|
|
267
|
+
Import the job function directly and call with container:
|
|
307
268
|
|
|
308
269
|
```typescript
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
.catch((e) => e)
|
|
313
|
-
expect(response.status).toEqual(400)
|
|
314
|
-
|
|
315
|
-
// 404 — not found (also check type and message)
|
|
316
|
-
const { response } = await api
|
|
317
|
-
.get("/admin/posts/invalid-id", adminHeaders)
|
|
318
|
-
.catch((e) => e)
|
|
319
|
-
expect(response.status).toEqual(404)
|
|
320
|
-
expect(response.data.type).toEqual("not_found")
|
|
321
|
-
expect(response.data.message).toContain("not found")
|
|
322
|
-
|
|
323
|
-
// 401 — unauthorized
|
|
324
|
-
const { response } = await api
|
|
325
|
-
.get("/admin/posts")
|
|
326
|
-
.catch((e) => e)
|
|
327
|
-
expect(response.status).toEqual(401)
|
|
328
|
-
```
|
|
270
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
271
|
+
import archiveOldPostsJob from "../../src/jobs/archive-old-posts"
|
|
272
|
+
import { POST_MODULE } from "../../src/modules/post"
|
|
329
273
|
|
|
330
|
-
|
|
274
|
+
jest.setTimeout(60 * 1000)
|
|
331
275
|
|
|
332
|
-
|
|
276
|
+
integrationTestRunner({
|
|
277
|
+
mode: "app",
|
|
278
|
+
testSuite: ({ getContainer }) => {
|
|
279
|
+
it("should soft-delete archived posts", async () => {
|
|
280
|
+
const container = getContainer()
|
|
281
|
+
const service: any = container.resolve(POST_MODULE)
|
|
333
282
|
|
|
334
|
-
|
|
335
|
-
|
|
283
|
+
await service.createPosts([
|
|
284
|
+
{ title: "Archived Post", status: "archived" },
|
|
285
|
+
{ title: "Active Post", status: "published" },
|
|
286
|
+
])
|
|
336
287
|
|
|
337
|
-
|
|
288
|
+
await archiveOldPostsJob(container)
|
|
338
289
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
290
|
+
const remaining = await service.listPosts()
|
|
291
|
+
expect(remaining).toHaveLength(1)
|
|
292
|
+
expect(remaining[0].title).toBe("Active Post")
|
|
293
|
+
})
|
|
342
294
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
295
|
+
it("should handle empty set gracefully", async () => {
|
|
296
|
+
const container = getContainer()
|
|
297
|
+
const service: any = container.resolve(POST_MODULE)
|
|
346
298
|
|
|
347
|
-
|
|
348
|
-
|
|
299
|
+
await service.createPosts([
|
|
300
|
+
{ title: "Active", status: "published" },
|
|
301
|
+
])
|
|
349
302
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
data: expect.objectContaining({ id: expect.any(String) }),
|
|
357
|
-
}),
|
|
358
|
-
])
|
|
359
|
-
)
|
|
303
|
+
await archiveOldPostsJob(container)
|
|
304
|
+
|
|
305
|
+
const remaining = await service.listPosts()
|
|
306
|
+
expect(remaining).toHaveLength(1)
|
|
307
|
+
})
|
|
308
|
+
},
|
|
360
309
|
})
|
|
361
310
|
```
|
|
362
311
|
|
|
363
312
|
---
|
|
364
313
|
|
|
365
|
-
##
|
|
366
|
-
|
|
367
|
-
Test workflows directly without HTTP when testing workflow logic in isolation:
|
|
314
|
+
## Module Integration Test Template
|
|
368
315
|
|
|
369
316
|
```typescript
|
|
370
|
-
import {
|
|
371
|
-
import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user"
|
|
317
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
372
318
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
319
|
+
jest.setTimeout(30000)
|
|
320
|
+
|
|
321
|
+
integrationTestRunner<IPostModuleService>({
|
|
322
|
+
mode: "module",
|
|
323
|
+
moduleName: "post",
|
|
324
|
+
resolve: process.cwd() + "/src/modules/post",
|
|
325
|
+
testSuite: ({ service }) => {
|
|
326
|
+
describe("createPosts", () => {
|
|
327
|
+
it("should create a post", async () => {
|
|
328
|
+
const result = await service.createPosts([
|
|
329
|
+
{ title: "Quarterly Report" },
|
|
330
|
+
])
|
|
331
|
+
expect(result).toHaveLength(1)
|
|
332
|
+
expect(result[0]).toEqual(
|
|
333
|
+
expect.objectContaining({
|
|
334
|
+
id: expect.any(String),
|
|
335
|
+
title: "Quarterly Report",
|
|
336
|
+
})
|
|
337
|
+
)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it("should throw on missing required field", async () => {
|
|
341
|
+
await expect(service.createPosts([{}])).rejects.toThrow()
|
|
342
|
+
})
|
|
377
343
|
})
|
|
378
344
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
345
|
+
describe("listPosts", () => {
|
|
346
|
+
it("should filter by field", async () => {
|
|
347
|
+
await service.createPosts([
|
|
348
|
+
{ title: "Active", status: "published" },
|
|
349
|
+
{ title: "Draft", status: "draft" },
|
|
350
|
+
])
|
|
351
|
+
const published = await service.listPosts({ status: "published" })
|
|
352
|
+
expect(published).toHaveLength(1)
|
|
353
|
+
expect(published[0].title).toBe("Active")
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it("should list and count", async () => {
|
|
357
|
+
await service.createPosts([{ title: "A" }, { title: "B" }])
|
|
358
|
+
const [items, count] = await service.listAndCountPosts()
|
|
359
|
+
expect(count).toEqual(2)
|
|
382
360
|
})
|
|
383
|
-
expect(result.post).toEqual(
|
|
384
|
-
expect.objectContaining({
|
|
385
|
-
id: expect.any(String),
|
|
386
|
-
title: "Launch Announcement",
|
|
387
|
-
})
|
|
388
|
-
)
|
|
389
361
|
})
|
|
390
362
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
363
|
+
describe("softDeletePosts / restorePosts", () => {
|
|
364
|
+
it("should soft delete and restore", async () => {
|
|
365
|
+
const [created] = await service.createPosts([
|
|
366
|
+
{ title: "To Delete" },
|
|
367
|
+
])
|
|
368
|
+
await service.softDeletePosts([created.id])
|
|
369
|
+
|
|
370
|
+
const listed = await service.listPosts({ id: created.id })
|
|
371
|
+
expect(listed).toHaveLength(0)
|
|
372
|
+
|
|
373
|
+
await service.restorePosts([created.id])
|
|
374
|
+
|
|
375
|
+
const restored = await service.listPosts({ id: created.id })
|
|
376
|
+
expect(restored).toHaveLength(1)
|
|
395
377
|
})
|
|
396
|
-
expect(errors).toHaveLength(1)
|
|
397
|
-
expect(errors[0].error.message).toContain("title")
|
|
398
378
|
})
|
|
399
379
|
},
|
|
400
380
|
})
|
|
@@ -402,95 +382,101 @@ acmekitIntegrationTestRunner({
|
|
|
402
382
|
|
|
403
383
|
---
|
|
404
384
|
|
|
405
|
-
##
|
|
385
|
+
## Unit Test Template (No Framework Bootstrap)
|
|
406
386
|
|
|
407
|
-
|
|
387
|
+
For providers, utilities, and standalone classes. Uses plain Jest with `jest.mock`.
|
|
388
|
+
|
|
389
|
+
**CRITICAL — jest.mock hoisting:** `jest.mock()` factories are hoisted above `const`/`let` by SWC. Never reference file-level variables inside a factory. Create mocks INSIDE the factory and access via `require()`.
|
|
408
390
|
|
|
409
391
|
```typescript
|
|
410
|
-
|
|
411
|
-
|
|
392
|
+
// Provider unit test pattern
|
|
393
|
+
jest.mock("external-sdk", () => {
|
|
394
|
+
const mocks = {
|
|
395
|
+
doThing: jest.fn(),
|
|
396
|
+
}
|
|
397
|
+
const MockClient = jest.fn().mockImplementation(() => ({
|
|
398
|
+
doThing: mocks.doThing,
|
|
399
|
+
}))
|
|
400
|
+
return { Client: MockClient, __mocks: mocks }
|
|
401
|
+
})
|
|
412
402
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
403
|
+
const { __mocks: sdkMocks } = require("external-sdk")
|
|
404
|
+
|
|
405
|
+
import MyProvider from "../my-provider"
|
|
406
|
+
|
|
407
|
+
describe("MyProvider", () => {
|
|
408
|
+
let provider: MyProvider
|
|
409
|
+
const mockContainer = {} as any
|
|
410
|
+
const defaultOptions = { apiKey: "test-key" }
|
|
411
|
+
|
|
412
|
+
beforeEach(() => {
|
|
413
|
+
jest.clearAllMocks()
|
|
414
|
+
provider = new MyProvider(mockContainer, defaultOptions)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
describe("static identifier", () => {
|
|
418
|
+
it("should have correct identifier", () => {
|
|
419
|
+
expect(MyProvider.identifier).toBe("my-provider")
|
|
417
420
|
})
|
|
421
|
+
})
|
|
418
422
|
|
|
419
|
-
|
|
420
|
-
|
|
423
|
+
describe("validateOptions", () => {
|
|
424
|
+
it("should accept valid options", () => {
|
|
425
|
+
expect(() =>
|
|
426
|
+
MyProvider.validateOptions({ apiKey: "key" })
|
|
427
|
+
).not.toThrow()
|
|
428
|
+
})
|
|
421
429
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"post.created",
|
|
425
|
-
eventBus
|
|
426
|
-
)
|
|
427
|
-
await api.post("/admin/posts", { title: "Trigger" }, adminHeaders)
|
|
428
|
-
await subscriberExecution
|
|
429
|
-
|
|
430
|
-
// Assert subscriber side-effect (e.g., audit log created)
|
|
431
|
-
const response = await api.get("/admin/audit-logs", adminHeaders)
|
|
432
|
-
expect(response.data.audit_logs).toEqual(
|
|
433
|
-
expect.arrayContaining([
|
|
434
|
-
expect.objectContaining({ event: "post.created" }),
|
|
435
|
-
])
|
|
436
|
-
)
|
|
430
|
+
it("should reject missing required option", () => {
|
|
431
|
+
expect(() => MyProvider.validateOptions({})).toThrow()
|
|
437
432
|
})
|
|
438
|
-
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
describe("doSomething", () => {
|
|
436
|
+
it("should delegate to SDK", async () => {
|
|
437
|
+
sdkMocks.doThing.mockResolvedValue({ success: true })
|
|
438
|
+
const result = await provider.doSomething({ input: "test" })
|
|
439
|
+
expect(result.success).toBe(true)
|
|
440
|
+
})
|
|
441
|
+
})
|
|
439
442
|
})
|
|
440
443
|
```
|
|
441
444
|
|
|
442
|
-
|
|
445
|
+
**Timer mocking:** If code under test uses `setTimeout` or `sleep()`, use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or mock the sleep method.
|
|
443
446
|
|
|
444
|
-
|
|
447
|
+
**SWC regex:** Complex regex literals may fail. Use `new RegExp("...")` instead.
|
|
445
448
|
|
|
446
|
-
|
|
447
|
-
it("should handle batch create/update/delete", async () => {
|
|
448
|
-
const existing = (
|
|
449
|
-
await api.post("/admin/posts", { title: "Existing" }, adminHeaders)
|
|
450
|
-
).data.post
|
|
451
|
-
|
|
452
|
-
const response = await api.post(
|
|
453
|
-
"/admin/posts/batch",
|
|
454
|
-
{
|
|
455
|
-
create: [{ title: "New Post" }],
|
|
456
|
-
update: [{ id: existing.id, title: "Updated" }],
|
|
457
|
-
delete: [existing.id],
|
|
458
|
-
},
|
|
459
|
-
adminHeaders
|
|
460
|
-
)
|
|
461
|
-
expect(response.status).toEqual(200)
|
|
462
|
-
expect(response.data.created).toHaveLength(1)
|
|
463
|
-
expect(response.data.updated).toHaveLength(1)
|
|
464
|
-
expect(response.data.deleted).toHaveLength(1)
|
|
465
|
-
})
|
|
466
|
-
```
|
|
449
|
+
**Error paths:** Read the implementation to check whether errors are thrown or returned. Don't assume from the return type.
|
|
467
450
|
|
|
468
451
|
---
|
|
469
452
|
|
|
470
|
-
##
|
|
453
|
+
## Asserting Domain Events (module mode)
|
|
471
454
|
|
|
472
455
|
```typescript
|
|
473
|
-
import {
|
|
456
|
+
import { MockEventBusService } from "@acmekit/test-utils"
|
|
474
457
|
|
|
475
|
-
|
|
476
|
-
const container = getContainer()
|
|
477
|
-
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK)
|
|
478
|
-
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
458
|
+
let eventBusSpy: jest.SpyInstance
|
|
479
459
|
|
|
480
|
-
|
|
481
|
-
|
|
460
|
+
beforeEach(() => {
|
|
461
|
+
eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
afterEach(() => {
|
|
465
|
+
eventBusSpy.mockClear()
|
|
466
|
+
})
|
|
482
467
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
[Modules.CATEGORY]: { category_id: category.id },
|
|
486
|
-
}])
|
|
468
|
+
it("should emit post.created event", async () => {
|
|
469
|
+
await service.createPosts([{ title: "Event Test" }])
|
|
487
470
|
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
471
|
+
const events = eventBusSpy.mock.calls[0][0]
|
|
472
|
+
expect(events).toEqual(
|
|
473
|
+
expect.arrayContaining([
|
|
474
|
+
expect.objectContaining({
|
|
475
|
+
name: "post.created",
|
|
476
|
+
data: expect.objectContaining({ id: expect.any(String) }),
|
|
477
|
+
}),
|
|
478
|
+
])
|
|
479
|
+
)
|
|
494
480
|
})
|
|
495
481
|
```
|
|
496
482
|
|
|
@@ -501,80 +487,74 @@ it("should create and query a cross-module link", async () => {
|
|
|
501
487
|
**For module services:**
|
|
502
488
|
- Create (single + batch), list (with filters), listAndCount, retrieve, update, softDelete/restore
|
|
503
489
|
- Custom service methods
|
|
504
|
-
-
|
|
490
|
+
- Required field validation → `rejects.toThrow()`
|
|
491
|
+
- Not found → `.catch((e: any) => e)` + `error.message` check
|
|
505
492
|
- Edge cases (empty arrays, duplicate unique constraints)
|
|
506
|
-
- Related entities (create parent → child, retrieve with `{ relations: [...] }`)
|
|
507
493
|
|
|
508
494
|
**For HTTP routes:**
|
|
509
495
|
- Success responses with correct shape (`expect.objectContaining`)
|
|
510
|
-
- List responses with pagination shape
|
|
511
|
-
- Delete responses with `{ id, object, deleted: true }`
|
|
512
|
-
- Validation errors (400 via `.catch((e) => e)`)
|
|
513
|
-
- Not found (404
|
|
514
|
-
- Auth required (401 for `/admin/*` without headers, 400 for `/client/*` without
|
|
515
|
-
-
|
|
516
|
-
- Query parameters (`?fields=`, `?limit=`, `?offset=`, `?order=`, `?q=`)
|
|
517
|
-
- Sorting verification (`?order=title`, `?order=-created_at`)
|
|
518
|
-
- Batch operations (`{ create, update, delete }` → `{ created, updated, deleted }`)
|
|
496
|
+
- List responses with pagination shape
|
|
497
|
+
- Delete responses with `{ id, object: "resource", deleted: true }`
|
|
498
|
+
- Validation errors (400 via `.catch((e: any) => e)`)
|
|
499
|
+
- Not found (404)
|
|
500
|
+
- Auth required (401 for `/admin/*` without headers, 400 for `/client/*` without API key)
|
|
501
|
+
- Query parameters (`?fields=`, `?limit=`, `?offset=`)
|
|
519
502
|
|
|
520
503
|
**For workflows:**
|
|
521
|
-
- Happy path + correct result via `workflow(
|
|
522
|
-
- `throwOnError: false` + `errors`
|
|
523
|
-
-
|
|
524
|
-
- Long-running workflows: `subscribe` + `setStepSuccess` + completion promise
|
|
525
|
-
- Workflow results available via HTTP after `waitWorkflowExecutions()`
|
|
504
|
+
- Happy path + correct result via `workflow(getContainer()).run({ input })`
|
|
505
|
+
- `throwOnError: false` + `errors[0].error.message` for invalid input
|
|
506
|
+
- Step compensation on failure (verify side-effects rolled back)
|
|
526
507
|
|
|
527
508
|
**For subscribers:**
|
|
528
509
|
- `TestEventUtils.waitSubscribersExecution` promise BEFORE triggering action
|
|
529
510
|
- Verify subscriber side-effects after awaiting the promise
|
|
530
511
|
|
|
531
|
-
**For
|
|
532
|
-
- `
|
|
512
|
+
**For jobs:**
|
|
513
|
+
- Direct function import + call with `container` or `getContainer()`
|
|
514
|
+
- Verify mutations (records created/deleted/updated)
|
|
515
|
+
- Handle no-op case (empty result set)
|
|
533
516
|
|
|
534
517
|
---
|
|
535
518
|
|
|
536
519
|
## Rules
|
|
537
520
|
|
|
538
|
-
### MANDATORY
|
|
521
|
+
### MANDATORY
|
|
539
522
|
|
|
540
|
-
- **
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
-
|
|
544
|
-
-
|
|
523
|
+
- **Unified runner only** — `integrationTestRunner` with `mode`, never deprecated names
|
|
524
|
+
- **Inline auth setup** — no `createAdminUser` helper exists. Resolve `Modules.USER`, `Modules.AUTH`, `Modules.API_KEY` in `beforeEach`
|
|
525
|
+
- **JWT from config** — use `generateJwtToken` from `@acmekit/framework/utils` with `config.projectConfig.http.jwtSecret`
|
|
526
|
+
- **Client API key** — use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER` constants. NEVER `"publishable"` or `"x-publishable-api-key"`
|
|
527
|
+
- **Every `/admin/*` request needs `adminHeaders`** — without it, 401
|
|
528
|
+
- **Every `/client/*` request needs `clientHeaders`** — without it, 400
|
|
545
529
|
|
|
546
530
|
### Assertions
|
|
547
531
|
|
|
548
532
|
- Use `.toEqual()` for status codes and exact matches
|
|
549
533
|
- Use `expect.objectContaining()` with `expect.any(String)` for IDs and timestamps
|
|
550
534
|
- Use `expect.arrayContaining()` for list assertions
|
|
551
|
-
- Use `expect.not.arrayContaining()` for negative list assertions
|
|
552
535
|
- Delete responses are exact: `{ id, object: "resource", deleted: true }`
|
|
553
536
|
- **Only standard Jest matchers** — NEVER `expect.toBeOneOf()`, `expect.toSatisfy()`
|
|
554
537
|
- For nullable fields: `expect(value === null || typeof value === "string").toBe(true)`
|
|
555
|
-
- For sorting: map to array of values, compare against sorted copy
|
|
556
538
|
|
|
557
539
|
### Error Testing
|
|
558
540
|
|
|
559
|
-
- Use `.catch((e) => e)` then destructure `{ response }` for HTTP
|
|
541
|
+
- Use `.catch((e: any) => e)` then destructure `{ response }` for HTTP errors
|
|
560
542
|
- Always check exact status code (`.toEqual(400)`) — never ranges
|
|
561
|
-
- For 404: also check `response.data.type` ("not_found") and `response.data.message`
|
|
562
543
|
- For workflow errors: `{ errors } = await workflow.run({ throwOnError: false })`, check `errors[0].error.message`
|
|
563
|
-
|
|
564
|
-
### Workflow & Event Patterns
|
|
565
|
-
|
|
566
|
-
- `waitWorkflowExecutions()` is called automatically in `afterEach` — only call it explicitly between two API calls when the second depends on workflow completion
|
|
567
|
-
- `waitSubscribersExecution` promise MUST be created BEFORE the triggering action
|
|
568
|
-
- Spy on `MockEventBusService.prototype.emit` (prototype, not instance)
|
|
569
|
-
- Access events via `eventBusSpy.mock.calls[0][0]` (array of event objects)
|
|
570
|
-
- Direct workflow execution: `workflow(getContainer()).run({ input })` — always pass container
|
|
571
|
-
- Use `throwOnError: false` to inspect workflow errors without throwing
|
|
544
|
+
- NEVER use `.rejects.toThrow()` on workflows — always fails (plain objects, not Error instances)
|
|
572
545
|
|
|
573
546
|
### Imports & Style
|
|
574
547
|
|
|
575
548
|
- **Only import what you use** — remove unused imports
|
|
576
|
-
- Resolve services via module constant: `getContainer().resolve(POST_MODULE)`
|
|
549
|
+
- Resolve services via module constant: `getContainer().resolve(POST_MODULE)`
|
|
577
550
|
- Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
|
|
578
551
|
- Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
579
552
|
- Runners handle DB setup/teardown — no manual cleanup needed
|
|
580
553
|
- Use `jest.restoreAllMocks()` in `afterEach` when spying
|
|
554
|
+
- Direct workflow execution: `workflow(getContainer()).run({ input })`
|
|
555
|
+
- `waitSubscribersExecution` promise BEFORE triggering event
|
|
556
|
+
- NEVER use JSDoc blocks or type casts in test files
|
|
557
|
+
- **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
|
|
558
|
+
- **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error
|
|
559
|
+
- **Mock timers or sleep** when code under test has delays — prevents timeouts
|
|
560
|
+
- **Use `new RegExp()` over complex regex literals** — SWC parser has edge cases
|