@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.
- package/dist/templates/app/.claude/agents/test-writer.md +287 -379
- package/dist/templates/app/.claude/commands/test.md +7 -1
- package/dist/templates/app/.claude/rules/testing.md +493 -846
- package/dist/templates/app/.claude/skills/write-test/SKILL.md +175 -117
- package/dist/templates/plugin/.claude/agents/test-writer.md +332 -273
- package/dist/templates/plugin/.claude/commands/test.md +7 -1
- package/dist/templates/plugin/.claude/rules/testing.md +397 -653
- package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +233 -153
- 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)
|
|
37
|
-
|
|
38
|
-
moduleIntegrationTestRunner<IMyModuleService>({
|
|
39
|
-
moduleName: Modules.MY_MODULE,
|
|
40
|
-
resolve: "./src/modules/my-module",
|
|
41
|
-
injectedDependencies: {
|
|
42
|
-
[Modules.EVENT_BUS]: new MockEventBusService(),
|
|
43
|
-
},
|
|
44
|
-
testSuite: ({ service }) => {
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
jest.restoreAllMocks()
|
|
47
|
-
})
|
|
23
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
24
|
+
```
|
|
48
25
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
expect(result[0]).toEqual(
|
|
56
|
-
expect.objectContaining({
|
|
57
|
-
id: expect.any(String),
|
|
58
|
-
title: "Quarterly Report",
|
|
59
|
-
})
|
|
60
|
-
)
|
|
61
|
-
})
|
|
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` |
|
|
62
32
|
|
|
63
|
-
|
|
64
|
-
await expect(service.createMyEntities([{}])).rejects.toThrow()
|
|
65
|
-
})
|
|
66
|
-
})
|
|
33
|
+
---
|
|
67
34
|
|
|
68
|
-
|
|
69
|
-
it("should list with filters", async () => {
|
|
70
|
-
await service.createMyEntities([
|
|
71
|
-
{ title: "Post A", status: "active" },
|
|
72
|
-
{ title: "Post B", status: "draft" },
|
|
73
|
-
])
|
|
74
|
-
const filtered = await service.listMyEntities({ status: "active" })
|
|
75
|
-
expect(filtered).toHaveLength(1)
|
|
76
|
-
expect(filtered[0].title).toBe("Post A")
|
|
77
|
-
})
|
|
35
|
+
## HTTP Integration Test Template
|
|
78
36
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
73
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,137 +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
|
-
testSuite: ({ getContainer, dbConnection }) => {
|
|
375
|
-
beforeEach(async () => {
|
|
376
|
-
await createAdminUser(dbConnection, adminHeaders, getContainer())
|
|
377
|
-
})
|
|
319
|
+
jest.setTimeout(30000)
|
|
378
320
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
)
|
|
382
338
|
})
|
|
383
|
-
expect(result.post).toEqual(
|
|
384
|
-
expect.objectContaining({
|
|
385
|
-
id: expect.any(String),
|
|
386
|
-
title: "Launch Announcement",
|
|
387
|
-
})
|
|
388
|
-
)
|
|
389
|
-
})
|
|
390
339
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
input: {},
|
|
394
|
-
throwOnError: false,
|
|
340
|
+
it("should throw on missing required field", async () => {
|
|
341
|
+
await expect(service.createPosts([{}])).rejects.toThrow()
|
|
395
342
|
})
|
|
396
|
-
expect(errors).toHaveLength(1)
|
|
397
|
-
expect(errors[0].error.message).toContain("title")
|
|
398
343
|
})
|
|
399
|
-
},
|
|
400
|
-
})
|
|
401
|
-
```
|
|
402
344
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
})
|
|
406
355
|
|
|
407
|
-
|
|
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)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
408
362
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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])
|
|
412
369
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
beforeEach(async () => {
|
|
416
|
-
await createAdminUser(dbConnection, adminHeaders, getContainer())
|
|
417
|
-
})
|
|
370
|
+
const listed = await service.listPosts({ id: created.id })
|
|
371
|
+
expect(listed).toHaveLength(0)
|
|
418
372
|
|
|
419
|
-
|
|
420
|
-
const eventBus = getContainer().resolve(Modules.EVENT_BUS)
|
|
373
|
+
await service.restorePosts([created.id])
|
|
421
374
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
)
|
|
375
|
+
const restored = await service.listPosts({ id: created.id })
|
|
376
|
+
expect(restored).toHaveLength(1)
|
|
377
|
+
})
|
|
437
378
|
})
|
|
438
379
|
},
|
|
439
380
|
})
|
|
@@ -441,56 +382,33 @@ acmekitIntegrationTestRunner({
|
|
|
441
382
|
|
|
442
383
|
---
|
|
443
384
|
|
|
444
|
-
##
|
|
385
|
+
## Asserting Domain Events (module mode)
|
|
445
386
|
|
|
446
387
|
```typescript
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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)
|
|
388
|
+
import { MockEventBusService } from "@acmekit/test-utils"
|
|
389
|
+
|
|
390
|
+
let eventBusSpy: jest.SpyInstance
|
|
391
|
+
|
|
392
|
+
beforeEach(() => {
|
|
393
|
+
eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
465
394
|
})
|
|
466
|
-
```
|
|
467
395
|
|
|
468
|
-
|
|
396
|
+
afterEach(() => {
|
|
397
|
+
eventBusSpy.mockClear()
|
|
398
|
+
})
|
|
469
399
|
|
|
470
|
-
|
|
400
|
+
it("should emit post.created event", async () => {
|
|
401
|
+
await service.createPosts([{ title: "Event Test" }])
|
|
471
402
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const category = (await api.post("/admin/categories", { name: "Tech" }, adminHeaders)).data.category
|
|
482
|
-
|
|
483
|
-
await remoteLink.create([{
|
|
484
|
-
[Modules.POST]: { post_id: post.id },
|
|
485
|
-
[Modules.CATEGORY]: { category_id: category.id },
|
|
486
|
-
}])
|
|
487
|
-
|
|
488
|
-
const { data: [linkedPost] } = await query.graph({
|
|
489
|
-
entity: "post",
|
|
490
|
-
fields: ["id", "title", "category.*"],
|
|
491
|
-
filters: { id: post.id },
|
|
492
|
-
})
|
|
493
|
-
expect(linkedPost.category.name).toBe("Tech")
|
|
403
|
+
const events = eventBusSpy.mock.calls[0][0]
|
|
404
|
+
expect(events).toEqual(
|
|
405
|
+
expect.arrayContaining([
|
|
406
|
+
expect.objectContaining({
|
|
407
|
+
name: "post.created",
|
|
408
|
+
data: expect.objectContaining({ id: expect.any(String) }),
|
|
409
|
+
}),
|
|
410
|
+
])
|
|
411
|
+
)
|
|
494
412
|
})
|
|
495
413
|
```
|
|
496
414
|
|
|
@@ -501,80 +419,70 @@ it("should create and query a cross-module link", async () => {
|
|
|
501
419
|
**For module services:**
|
|
502
420
|
- Create (single + batch), list (with filters), listAndCount, retrieve, update, softDelete/restore
|
|
503
421
|
- Custom service methods
|
|
504
|
-
-
|
|
422
|
+
- Required field validation → `rejects.toThrow()`
|
|
423
|
+
- Not found → `.catch((e: any) => e)` + `error.message` check
|
|
505
424
|
- Edge cases (empty arrays, duplicate unique constraints)
|
|
506
|
-
- Related entities (create parent → child, retrieve with `{ relations: [...] }`)
|
|
507
425
|
|
|
508
426
|
**For HTTP routes:**
|
|
509
427
|
- 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 }`)
|
|
428
|
+
- List responses with pagination shape
|
|
429
|
+
- Delete responses with `{ id, object: "resource", deleted: true }`
|
|
430
|
+
- Validation errors (400 via `.catch((e: any) => e)`)
|
|
431
|
+
- Not found (404)
|
|
432
|
+
- Auth required (401 for `/admin/*` without headers, 400 for `/client/*` without API key)
|
|
433
|
+
- Query parameters (`?fields=`, `?limit=`, `?offset=`)
|
|
519
434
|
|
|
520
435
|
**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()`
|
|
436
|
+
- Happy path + correct result via `workflow(getContainer()).run({ input })`
|
|
437
|
+
- `throwOnError: false` + `errors[0].error.message` for invalid input
|
|
438
|
+
- Step compensation on failure (verify side-effects rolled back)
|
|
526
439
|
|
|
527
440
|
**For subscribers:**
|
|
528
441
|
- `TestEventUtils.waitSubscribersExecution` promise BEFORE triggering action
|
|
529
442
|
- Verify subscriber side-effects after awaiting the promise
|
|
530
443
|
|
|
531
|
-
**For
|
|
532
|
-
- `
|
|
444
|
+
**For jobs:**
|
|
445
|
+
- Direct function import + call with `container` or `getContainer()`
|
|
446
|
+
- Verify mutations (records created/deleted/updated)
|
|
447
|
+
- Handle no-op case (empty result set)
|
|
533
448
|
|
|
534
449
|
---
|
|
535
450
|
|
|
536
451
|
## Rules
|
|
537
452
|
|
|
538
|
-
### MANDATORY
|
|
453
|
+
### MANDATORY
|
|
539
454
|
|
|
540
|
-
- **
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
-
|
|
544
|
-
-
|
|
455
|
+
- **Unified runner only** — `integrationTestRunner` with `mode`, never deprecated names
|
|
456
|
+
- **Inline auth setup** — no `createAdminUser` helper exists. Resolve `Modules.USER`, `Modules.AUTH`, `Modules.API_KEY` in `beforeEach`
|
|
457
|
+
- **JWT from config** — use `generateJwtToken` from `@acmekit/framework/utils` with `config.projectConfig.http.jwtSecret`
|
|
458
|
+
- **Client API key** — use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER` constants. NEVER `"publishable"` or `"x-publishable-api-key"`
|
|
459
|
+
- **Every `/admin/*` request needs `adminHeaders`** — without it, 401
|
|
460
|
+
- **Every `/client/*` request needs `clientHeaders`** — without it, 400
|
|
545
461
|
|
|
546
462
|
### Assertions
|
|
547
463
|
|
|
548
464
|
- Use `.toEqual()` for status codes and exact matches
|
|
549
465
|
- Use `expect.objectContaining()` with `expect.any(String)` for IDs and timestamps
|
|
550
466
|
- Use `expect.arrayContaining()` for list assertions
|
|
551
|
-
- Use `expect.not.arrayContaining()` for negative list assertions
|
|
552
467
|
- Delete responses are exact: `{ id, object: "resource", deleted: true }`
|
|
553
468
|
- **Only standard Jest matchers** — NEVER `expect.toBeOneOf()`, `expect.toSatisfy()`
|
|
554
469
|
- For nullable fields: `expect(value === null || typeof value === "string").toBe(true)`
|
|
555
|
-
- For sorting: map to array of values, compare against sorted copy
|
|
556
470
|
|
|
557
471
|
### Error Testing
|
|
558
472
|
|
|
559
|
-
- Use `.catch((e) => e)` then destructure `{ response }` for HTTP
|
|
473
|
+
- Use `.catch((e: any) => e)` then destructure `{ response }` for HTTP errors
|
|
560
474
|
- Always check exact status code (`.toEqual(400)`) — never ranges
|
|
561
|
-
- For 404: also check `response.data.type` ("not_found") and `response.data.message`
|
|
562
475
|
- 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
|
|
476
|
+
- NEVER use `.rejects.toThrow()` on workflows — always fails (plain objects, not Error instances)
|
|
572
477
|
|
|
573
478
|
### Imports & Style
|
|
574
479
|
|
|
575
480
|
- **Only import what you use** — remove unused imports
|
|
576
|
-
- Resolve services via module constant: `getContainer().resolve(POST_MODULE)`
|
|
481
|
+
- Resolve services via module constant: `getContainer().resolve(POST_MODULE)`
|
|
577
482
|
- Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
|
|
578
483
|
- Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
579
484
|
- Runners handle DB setup/teardown — no manual cleanup needed
|
|
580
485
|
- Use `jest.restoreAllMocks()` in `afterEach` when spying
|
|
486
|
+
- Direct workflow execution: `workflow(getContainer()).run({ input })`
|
|
487
|
+
- `waitSubscribersExecution` promise BEFORE triggering event
|
|
488
|
+
- NEVER use JSDoc blocks or type casts in test files
|