@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,287 +1,400 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: test-writer
|
|
3
|
-
description: Generates comprehensive integration tests for AcmeKit plugins. Auto-detects the correct
|
|
3
|
+
description: Generates comprehensive integration tests for AcmeKit plugins. Auto-detects the correct mode (plugin for container/HTTP, 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 for plugins. Generate comprehensive integration tests using the correct
|
|
9
|
+
You are an AcmeKit test engineer for plugins. 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 error handling
|
|
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, models, workflows, and subscribers
|
|
14
|
-
3. Identify
|
|
14
|
+
3. Identify the correct test tier (HTTP, plugin container, module, or unit)
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
## Test Runner
|
|
18
|
+
## Test Runner — `integrationTestRunner` (unified)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
**NEVER use `pluginIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.**
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| What to test | Mode | File location |
|
|
21
27
|
|---|---|---|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
28
|
+
| API routes (HTTP end-to-end) | `mode: "plugin"` + `http: true` | `integration-tests/http/<feature>.spec.ts` |
|
|
29
|
+
| Workflows, subscribers, jobs (container) | `mode: "plugin"` | `integration-tests/plugin/<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` |
|
|
25
32
|
|
|
26
|
-
**CRITICAL:** `
|
|
33
|
+
**CRITICAL:** `mode: "plugin"` without `http: true` has NO HTTP server. There is no `api` fixture. Do NOT write tests using `api.get()` or `api.post()` — test services directly through `container.resolve()`.
|
|
27
34
|
|
|
28
35
|
---
|
|
29
36
|
|
|
30
|
-
## Plugin Integration Test Template
|
|
37
|
+
## Plugin HTTP Integration Test Template
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
import { pluginIntegrationTestRunner } from "@acmekit/test-utils"
|
|
34
|
-
import { plugin } from "../../src/plugin"
|
|
35
|
-
|
|
36
|
-
jest.setTimeout(60 * 1000)
|
|
39
|
+
Boots the full framework with plugin installed. Requires auth setup (JWT + client API key).
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
```typescript
|
|
42
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
43
|
+
import {
|
|
44
|
+
ApiKeyType,
|
|
45
|
+
CLIENT_API_KEY_HEADER,
|
|
46
|
+
ContainerRegistrationKeys,
|
|
47
|
+
generateJwtToken,
|
|
48
|
+
Modules,
|
|
49
|
+
} from "@acmekit/framework/utils"
|
|
50
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
51
|
+
|
|
52
|
+
jest.setTimeout(120 * 1000)
|
|
53
|
+
|
|
54
|
+
integrationTestRunner({
|
|
55
|
+
mode: "plugin",
|
|
56
|
+
http: true,
|
|
39
57
|
pluginPath: process.cwd(),
|
|
40
|
-
pluginOptions: {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
pluginOptions: { apiKey: "test-api-key" },
|
|
59
|
+
testSuite: ({ api, container }) => {
|
|
60
|
+
let adminHeaders: Record<string, any>
|
|
61
|
+
let clientHeaders: Record<string, any>
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
const userModule = container.resolve(Modules.USER)
|
|
65
|
+
const authModule = container.resolve(Modules.AUTH)
|
|
66
|
+
const apiKeyModule = container.resolve(Modules.API_KEY)
|
|
67
|
+
|
|
68
|
+
const user = await userModule.createUsers({
|
|
69
|
+
email: "admin@test.js",
|
|
47
70
|
})
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
const authIdentity = await authModule.createAuthIdentities({
|
|
73
|
+
provider_identities: [
|
|
74
|
+
{ provider: "emailpass", entity_id: "admin@test.js" },
|
|
75
|
+
],
|
|
76
|
+
app_metadata: { user_id: user.id },
|
|
52
77
|
})
|
|
53
|
-
})
|
|
54
78
|
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
const config = container.resolve(
|
|
80
|
+
ContainerRegistrationKeys.CONFIG_MODULE
|
|
81
|
+
)
|
|
82
|
+
const { jwtSecret, jwtOptions } = config.projectConfig.http
|
|
83
|
+
|
|
84
|
+
const token = generateJwtToken(
|
|
85
|
+
{
|
|
86
|
+
actor_id: user.id,
|
|
87
|
+
actor_type: "user",
|
|
88
|
+
auth_identity_id: authIdentity.id,
|
|
89
|
+
app_metadata: { user_id: user.id },
|
|
90
|
+
},
|
|
91
|
+
{ secret: jwtSecret, expiresIn: "1d", jwtOptions }
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
adminHeaders = {
|
|
95
|
+
headers: { authorization: `Bearer ${token}` },
|
|
96
|
+
}
|
|
57
97
|
|
|
58
|
-
|
|
59
|
-
|
|
98
|
+
const apiKey = await apiKeyModule.createApiKeys({
|
|
99
|
+
title: "Test Client Key",
|
|
100
|
+
type: ApiKeyType.CLIENT,
|
|
101
|
+
created_by: "test",
|
|
60
102
|
})
|
|
61
103
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
clientHeaders = {
|
|
105
|
+
headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("POST /admin/plugin/greetings", () => {
|
|
110
|
+
it("should create a greeting", async () => {
|
|
111
|
+
const response = await api.post(
|
|
112
|
+
"/admin/plugin/greetings",
|
|
113
|
+
{ message: "Hello from HTTP" },
|
|
114
|
+
adminHeaders
|
|
115
|
+
)
|
|
116
|
+
expect(response.status).toEqual(200)
|
|
117
|
+
expect(response.data.greeting).toEqual(
|
|
68
118
|
expect.objectContaining({
|
|
69
119
|
id: expect.any(String),
|
|
70
|
-
|
|
120
|
+
message: "Hello from HTTP",
|
|
71
121
|
})
|
|
72
122
|
)
|
|
73
123
|
})
|
|
74
124
|
|
|
75
|
-
it("should
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const [posts, count] = await service.listAndCountBlogPosts()
|
|
81
|
-
expect(count).toBe(2)
|
|
125
|
+
it("should reject missing required fields", async () => {
|
|
126
|
+
const { response } = await api
|
|
127
|
+
.post("/admin/plugin/greetings", {}, adminHeaders)
|
|
128
|
+
.catch((e: any) => e)
|
|
129
|
+
expect(response.status).toEqual(400)
|
|
82
130
|
})
|
|
131
|
+
})
|
|
83
132
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
133
|
+
describe("DELETE /admin/plugin/greetings/:id", () => {
|
|
134
|
+
it("should soft-delete and return confirmation", async () => {
|
|
135
|
+
const created = (
|
|
136
|
+
await api.post(
|
|
137
|
+
"/admin/plugin/greetings",
|
|
138
|
+
{ message: "Delete me" },
|
|
139
|
+
adminHeaders
|
|
140
|
+
)
|
|
141
|
+
).data.greeting
|
|
142
|
+
|
|
143
|
+
const response = await api.delete(
|
|
144
|
+
`/admin/plugin/greetings/${created.id}`,
|
|
145
|
+
adminHeaders
|
|
146
|
+
)
|
|
147
|
+
expect(response.data).toEqual({
|
|
148
|
+
id: created.id,
|
|
149
|
+
object: "greeting",
|
|
150
|
+
deleted: true,
|
|
91
151
|
})
|
|
92
|
-
expect(published).toHaveLength(1)
|
|
93
|
-
expect(published[0].title).toBe("Active")
|
|
94
152
|
})
|
|
153
|
+
})
|
|
95
154
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(
|
|
155
|
+
describe("Client routes", () => {
|
|
156
|
+
it("GET /client/plugin/greetings with API key", async () => {
|
|
157
|
+
await api.post(
|
|
158
|
+
"/admin/plugin/greetings",
|
|
159
|
+
{ message: "Public" },
|
|
160
|
+
adminHeaders
|
|
161
|
+
)
|
|
162
|
+
const response = await api.get(
|
|
163
|
+
"/client/plugin/greetings",
|
|
164
|
+
clientHeaders
|
|
165
|
+
)
|
|
166
|
+
expect(response.status).toEqual(200)
|
|
167
|
+
expect(response.data.greetings).toHaveLength(1)
|
|
108
168
|
})
|
|
109
169
|
|
|
110
|
-
it("should
|
|
111
|
-
await
|
|
170
|
+
it("should reject without API key", async () => {
|
|
171
|
+
const error = await api
|
|
172
|
+
.get("/client/plugin/greetings")
|
|
173
|
+
.catch((e: any) => e)
|
|
174
|
+
expect(error.response.status).toEqual(400)
|
|
112
175
|
})
|
|
176
|
+
})
|
|
113
177
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
178
|
+
describe("Auth enforcement", () => {
|
|
179
|
+
it("admin route rejects without JWT", async () => {
|
|
180
|
+
const error = await api
|
|
181
|
+
.get("/admin/plugin")
|
|
182
|
+
.catch((e: any) => e)
|
|
183
|
+
expect(error.response.status).toEqual(401)
|
|
119
184
|
})
|
|
120
185
|
})
|
|
121
186
|
},
|
|
122
187
|
})
|
|
123
188
|
```
|
|
124
189
|
|
|
125
|
-
**
|
|
190
|
+
**Fixtures (HTTP mode):** `api`, `container`, `dbConfig`.
|
|
126
191
|
|
|
127
192
|
---
|
|
128
193
|
|
|
129
|
-
##
|
|
194
|
+
## Plugin Container Integration Test Template
|
|
130
195
|
|
|
131
|
-
|
|
196
|
+
No HTTP server — test services, workflows, subscribers, and jobs directly through the container:
|
|
132
197
|
|
|
133
198
|
```typescript
|
|
134
|
-
import {
|
|
199
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
200
|
+
import { createGreetingsWorkflow } from "../../src/workflows"
|
|
201
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
135
202
|
|
|
136
|
-
jest.setTimeout(
|
|
203
|
+
jest.setTimeout(60 * 1000)
|
|
137
204
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
205
|
+
integrationTestRunner({
|
|
206
|
+
mode: "plugin",
|
|
207
|
+
pluginPath: process.cwd(),
|
|
208
|
+
pluginOptions: { apiKey: "test-api-key" },
|
|
209
|
+
testSuite: ({ container, acmekitApp }) => {
|
|
210
|
+
describe("Plugin loading", () => {
|
|
211
|
+
it("should load plugin resources", () => {
|
|
212
|
+
expect(acmekitApp.modules).toBeDefined()
|
|
213
|
+
})
|
|
144
214
|
})
|
|
145
215
|
|
|
146
|
-
describe("
|
|
147
|
-
it("should create a
|
|
148
|
-
const
|
|
149
|
-
|
|
216
|
+
describe("GreetingModule CRUD", () => {
|
|
217
|
+
it("should create a greeting", async () => {
|
|
218
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
219
|
+
const result = await service.createGreetings([
|
|
220
|
+
{ message: "Launch Announcement" },
|
|
150
221
|
])
|
|
151
222
|
expect(result).toHaveLength(1)
|
|
152
223
|
expect(result[0]).toEqual(
|
|
153
224
|
expect.objectContaining({
|
|
154
225
|
id: expect.any(String),
|
|
155
|
-
|
|
226
|
+
message: "Launch Announcement",
|
|
156
227
|
})
|
|
157
228
|
)
|
|
158
229
|
})
|
|
230
|
+
|
|
231
|
+
it("should throw on missing required field", async () => {
|
|
232
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
233
|
+
await expect(service.createGreetings([{}])).rejects.toThrow()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe("createGreetingsWorkflow", () => {
|
|
238
|
+
it("should create via workflow", async () => {
|
|
239
|
+
const { result } = await createGreetingsWorkflow(container).run({
|
|
240
|
+
input: { greetings: [{ message: "Workflow Hello" }] },
|
|
241
|
+
})
|
|
242
|
+
expect(result).toHaveLength(1)
|
|
243
|
+
expect(result[0].message).toBe("Workflow Hello")
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("should reject invalid input", async () => {
|
|
247
|
+
const { errors } = await createGreetingsWorkflow(container).run({
|
|
248
|
+
input: { greetings: [{ message: "", status: "invalid" }] },
|
|
249
|
+
throwOnError: false,
|
|
250
|
+
})
|
|
251
|
+
expect(errors).toHaveLength(1)
|
|
252
|
+
expect(errors[0].error.message).toContain("Invalid")
|
|
253
|
+
})
|
|
159
254
|
})
|
|
160
255
|
},
|
|
161
256
|
})
|
|
162
257
|
```
|
|
163
258
|
|
|
259
|
+
**Fixtures (container-only):** `container`, `acmekitApp`, `MikroOrmWrapper`, `dbConfig`.
|
|
260
|
+
|
|
164
261
|
---
|
|
165
262
|
|
|
166
|
-
##
|
|
263
|
+
## Subscriber Test Template (direct handler invocation)
|
|
167
264
|
|
|
168
|
-
|
|
265
|
+
Plugin container mode doesn't have a real event bus — import the handler and call it manually:
|
|
169
266
|
|
|
170
267
|
```typescript
|
|
171
|
-
import {
|
|
268
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
269
|
+
import greetingCreatedHandler from "../../src/subscribers/greeting-created"
|
|
270
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
271
|
+
|
|
272
|
+
jest.setTimeout(60 * 1000)
|
|
172
273
|
|
|
173
|
-
|
|
274
|
+
integrationTestRunner({
|
|
275
|
+
mode: "plugin",
|
|
174
276
|
pluginPath: process.cwd(),
|
|
175
277
|
testSuite: ({ container }) => {
|
|
176
|
-
it("should
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
278
|
+
it("should append ' [notified]' to greeting message", async () => {
|
|
279
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
280
|
+
const [greeting] = await service.createGreetings([
|
|
281
|
+
{ message: "Hello World" },
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
// Call handler directly with event + container
|
|
285
|
+
await greetingCreatedHandler({
|
|
286
|
+
event: {
|
|
287
|
+
data: { id: greeting.id },
|
|
288
|
+
name: "greeting.created",
|
|
181
289
|
},
|
|
290
|
+
container,
|
|
182
291
|
})
|
|
183
|
-
expect(result.order).toEqual(
|
|
184
|
-
expect.objectContaining({ customerId: "cus_123" })
|
|
185
|
-
)
|
|
186
|
-
})
|
|
187
292
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
input: {},
|
|
191
|
-
throwOnError: false,
|
|
192
|
-
})
|
|
193
|
-
expect(errors).toHaveLength(1)
|
|
194
|
-
expect(errors[0].error.message).toContain("customerId")
|
|
293
|
+
const updated = await service.retrieveGreeting(greeting.id)
|
|
294
|
+
expect(updated.message).toBe("Hello World [notified]")
|
|
195
295
|
})
|
|
196
296
|
},
|
|
197
297
|
})
|
|
198
298
|
```
|
|
199
299
|
|
|
200
|
-
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Job Test Template (direct invocation)
|
|
201
303
|
|
|
202
304
|
```typescript
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
customerId: "cus_123",
|
|
209
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
210
|
-
paymentMethod: "invalid-method",
|
|
211
|
-
},
|
|
212
|
-
throwOnError: false,
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
expect(errors).toHaveLength(1)
|
|
216
|
-
|
|
217
|
-
// Verify compensation ran — order was rolled back
|
|
218
|
-
const orders = await service.listOrders({ customerId: "cus_123" })
|
|
219
|
-
expect(orders).toHaveLength(0)
|
|
220
|
-
})
|
|
221
|
-
```
|
|
305
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
306
|
+
import cleanupGreetingsJob from "../../src/jobs/cleanup-greetings"
|
|
307
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
308
|
+
|
|
309
|
+
jest.setTimeout(60 * 1000)
|
|
222
310
|
|
|
223
|
-
|
|
311
|
+
integrationTestRunner({
|
|
312
|
+
mode: "plugin",
|
|
313
|
+
pluginPath: process.cwd(),
|
|
314
|
+
testSuite: ({ container }) => {
|
|
315
|
+
it("should soft-delete greetings with lang='old'", async () => {
|
|
316
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
224
317
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
input: {
|
|
231
|
-
customerId: "cus_123",
|
|
232
|
-
items: [{ sku: "SKU-001", qty: 1 }],
|
|
233
|
-
sendNotification: false,
|
|
234
|
-
},
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
const allEvents = eventBusSpy.mock.calls.flatMap((call) => call[0])
|
|
238
|
-
expect(allEvents).toEqual(
|
|
239
|
-
expect.not.arrayContaining([
|
|
240
|
-
expect.objectContaining({ name: "order.notification.sent" }),
|
|
241
|
-
])
|
|
242
|
-
)
|
|
243
|
-
eventBusSpy.mockRestore()
|
|
244
|
-
})
|
|
245
|
-
```
|
|
318
|
+
await service.createGreetings([
|
|
319
|
+
{ message: "Old 1", lang: "old" },
|
|
320
|
+
{ message: "Old 2", lang: "old" },
|
|
321
|
+
{ message: "Current", lang: "en" },
|
|
322
|
+
])
|
|
246
323
|
|
|
247
|
-
|
|
324
|
+
await cleanupGreetingsJob(container)
|
|
248
325
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
326
|
+
const remaining = await service.listGreetings()
|
|
327
|
+
expect(remaining).toHaveLength(1)
|
|
328
|
+
expect(remaining[0].lang).toBe("en")
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it("should do nothing when no old greetings exist", async () => {
|
|
332
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
333
|
+
await service.createGreetings([{ message: "Hello", lang: "en" }])
|
|
334
|
+
|
|
335
|
+
await cleanupGreetingsJob(container)
|
|
254
336
|
|
|
255
|
-
|
|
256
|
-
|
|
337
|
+
const remaining = await service.listGreetings()
|
|
338
|
+
expect(remaining).toHaveLength(1)
|
|
339
|
+
})
|
|
340
|
+
},
|
|
257
341
|
})
|
|
258
342
|
```
|
|
259
343
|
|
|
260
|
-
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Module Integration Test Template
|
|
261
347
|
|
|
262
348
|
```typescript
|
|
263
|
-
|
|
264
|
-
const hookResult: any[] = []
|
|
349
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
265
350
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
351
|
+
jest.setTimeout(30000)
|
|
352
|
+
|
|
353
|
+
integrationTestRunner<IGreetingModuleService>({
|
|
354
|
+
mode: "module",
|
|
355
|
+
moduleName: "greeting",
|
|
356
|
+
resolve: process.cwd() + "/src/modules/greeting",
|
|
357
|
+
testSuite: ({ service }) => {
|
|
358
|
+
describe("createGreetings", () => {
|
|
359
|
+
it("should create a greeting", async () => {
|
|
360
|
+
const result = await service.createGreetings([
|
|
361
|
+
{ message: "Hello" },
|
|
362
|
+
])
|
|
363
|
+
expect(result).toHaveLength(1)
|
|
364
|
+
expect(result[0]).toEqual(
|
|
365
|
+
expect.objectContaining({
|
|
366
|
+
id: expect.any(String),
|
|
367
|
+
message: "Hello",
|
|
368
|
+
})
|
|
369
|
+
)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
270
372
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
373
|
+
describe("softDeleteGreetings / restoreGreetings", () => {
|
|
374
|
+
it("should soft delete and restore", async () => {
|
|
375
|
+
const [created] = await service.createGreetings([
|
|
376
|
+
{ message: "To Delete" },
|
|
377
|
+
])
|
|
378
|
+
await service.softDeleteGreetings([created.id])
|
|
274
379
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
380
|
+
const listed = await service.listGreetings({ id: created.id })
|
|
381
|
+
expect(listed).toHaveLength(0)
|
|
382
|
+
|
|
383
|
+
await service.restoreGreetings([created.id])
|
|
384
|
+
|
|
385
|
+
const restored = await service.listGreetings({ id: created.id })
|
|
386
|
+
expect(restored).toHaveLength(1)
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
},
|
|
279
390
|
})
|
|
280
391
|
```
|
|
281
392
|
|
|
282
393
|
---
|
|
283
394
|
|
|
284
|
-
## Asserting Domain Events
|
|
395
|
+
## Asserting Domain Events (container-only mode)
|
|
396
|
+
|
|
397
|
+
Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
|
|
285
398
|
|
|
286
399
|
```typescript
|
|
287
400
|
import { MockEventBusService } from "@acmekit/test-utils"
|
|
@@ -296,16 +409,16 @@ afterEach(() => {
|
|
|
296
409
|
eventBusSpy.mockClear()
|
|
297
410
|
})
|
|
298
411
|
|
|
299
|
-
it("should emit
|
|
300
|
-
const service = container.resolve(
|
|
301
|
-
await service.
|
|
412
|
+
it("should emit greeting.created event", async () => {
|
|
413
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
414
|
+
await service.createGreetings([{ message: "Event Test" }])
|
|
302
415
|
|
|
416
|
+
// MockEventBusService.emit receives an ARRAY of events
|
|
303
417
|
const events = eventBusSpy.mock.calls[0][0]
|
|
304
|
-
expect(events).toHaveLength(1)
|
|
305
418
|
expect(events).toEqual(
|
|
306
419
|
expect.arrayContaining([
|
|
307
420
|
expect.objectContaining({
|
|
308
|
-
name: "
|
|
421
|
+
name: "greeting.created",
|
|
309
422
|
data: expect.objectContaining({ id: expect.any(String) }),
|
|
310
423
|
}),
|
|
311
424
|
])
|
|
@@ -315,131 +428,77 @@ it("should emit blog-post.created event", async () => {
|
|
|
315
428
|
|
|
316
429
|
---
|
|
317
430
|
|
|
318
|
-
## Waiting for Subscribers
|
|
319
|
-
|
|
320
|
-
**CRITICAL: create the promise BEFORE triggering the event.**
|
|
321
|
-
|
|
322
|
-
```typescript
|
|
323
|
-
import { TestEventUtils } from "@acmekit/test-utils"
|
|
324
|
-
import { Modules } from "@acmekit/framework/utils"
|
|
325
|
-
|
|
326
|
-
it("should execute subscriber side-effect", async () => {
|
|
327
|
-
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
328
|
-
|
|
329
|
-
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
|
330
|
-
"blog-post.created",
|
|
331
|
-
eventBus
|
|
332
|
-
)
|
|
333
|
-
const service = container.resolve(BLOG_MODULE)
|
|
334
|
-
await service.createBlogPosts([{ title: "Trigger" }])
|
|
335
|
-
await subscriberExecution
|
|
336
|
-
// assert side-effect
|
|
337
|
-
})
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
---
|
|
341
|
-
|
|
342
|
-
## Link / Relation Testing
|
|
343
|
-
|
|
344
|
-
```typescript
|
|
345
|
-
import { ContainerRegistrationKeys } from "@acmekit/framework/utils"
|
|
346
|
-
|
|
347
|
-
it("should create and query a cross-module link", async () => {
|
|
348
|
-
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK)
|
|
349
|
-
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
350
|
-
|
|
351
|
-
const blogService = container.resolve(BLOG_MODULE)
|
|
352
|
-
const [post] = await blogService.createBlogPosts([{ title: "Linked Post" }])
|
|
353
|
-
const [category] = await blogService.createBlogCategories([{ name: "Tech" }])
|
|
354
|
-
|
|
355
|
-
await remoteLink.create([{
|
|
356
|
-
blogModuleService: { blog_post_id: post.id },
|
|
357
|
-
blogCategoryModuleService: { blog_category_id: category.id },
|
|
358
|
-
}])
|
|
359
|
-
|
|
360
|
-
const { data: [linkedPost] } = await query.graph({
|
|
361
|
-
entity: "blog_post",
|
|
362
|
-
fields: ["id", "title", "category.*"],
|
|
363
|
-
filters: { id: post.id },
|
|
364
|
-
})
|
|
365
|
-
expect(linkedPost.category.name).toBe("Tech")
|
|
366
|
-
})
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
---
|
|
370
|
-
|
|
371
431
|
## What to Test
|
|
372
432
|
|
|
373
433
|
**Plugin loading:**
|
|
374
434
|
- Modules resolve from container
|
|
375
|
-
- Plugin options accessible via `plugin.resolveOptions(container)`
|
|
376
435
|
- Auto-discovered resources loaded (subscribers, workflows, jobs)
|
|
377
436
|
|
|
378
437
|
**Module services:**
|
|
379
438
|
- Create (single + batch), list (with filters), listAndCount, retrieve, update, softDelete/restore
|
|
380
439
|
- Custom service methods
|
|
381
|
-
-
|
|
382
|
-
-
|
|
440
|
+
- Required field validation → `rejects.toThrow()`
|
|
441
|
+
- Not found → `.catch((e: any) => e)` + `error.message` check
|
|
383
442
|
|
|
384
443
|
**Workflows:**
|
|
385
|
-
- Happy path + correct result
|
|
386
|
-
- `throwOnError: false` + `errors`
|
|
387
|
-
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
- `
|
|
391
|
-
-
|
|
444
|
+
- Happy path + correct result via `workflow(container).run({ input })`
|
|
445
|
+
- `throwOnError: false` + `errors[0].error.message` for invalid input
|
|
446
|
+
- Step compensation on failure (verify side-effects rolled back)
|
|
447
|
+
|
|
448
|
+
**Subscribers:**
|
|
449
|
+
- Import handler directly, call with `{ event: { data, name }, container }`
|
|
450
|
+
- Verify side-effects after handler call
|
|
392
451
|
|
|
393
|
-
**
|
|
394
|
-
-
|
|
395
|
-
-
|
|
452
|
+
**Jobs:**
|
|
453
|
+
- Import function directly, call with `container`
|
|
454
|
+
- Verify mutations (records created/deleted/updated)
|
|
455
|
+
- Handle no-op case (empty result set)
|
|
396
456
|
|
|
397
|
-
**
|
|
398
|
-
-
|
|
457
|
+
**HTTP routes (with `http: true`):**
|
|
458
|
+
- Success responses with correct shape
|
|
459
|
+
- Delete responses: `{ id, object: "resource", deleted: true }`
|
|
460
|
+
- Validation errors (400 via `.catch((e: any) => e)`)
|
|
461
|
+
- Auth: 401 without JWT, 400 without client API key
|
|
399
462
|
|
|
400
|
-
**
|
|
401
|
-
-
|
|
463
|
+
**Events (container mode):**
|
|
464
|
+
- Spy on `MockEventBusService.prototype.emit` (prototype, not instance)
|
|
465
|
+
- Access events via `eventBusSpy.mock.calls[0][0]` (array of event objects)
|
|
402
466
|
|
|
403
467
|
---
|
|
404
468
|
|
|
405
469
|
## Rules
|
|
406
470
|
|
|
471
|
+
### MANDATORY
|
|
472
|
+
|
|
473
|
+
- **Unified runner only** — `integrationTestRunner` with `mode`, never deprecated names
|
|
474
|
+
- **Container-only tests have NO `api` fixture** — use `container.resolve()` for service access
|
|
475
|
+
- **HTTP tests require `http: true`** — and full auth setup in `beforeEach`
|
|
476
|
+
- **Always use `pluginPath: process.cwd()`** — never hardcode paths
|
|
477
|
+
- **JWT from config** — use `generateJwtToken` from `@acmekit/framework/utils` with `config.projectConfig.http.jwtSecret`
|
|
478
|
+
- **Client API key** — use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER`. NEVER `"publishable"` or `"x-publishable-api-key"`
|
|
479
|
+
|
|
407
480
|
### Assertions
|
|
408
481
|
|
|
409
482
|
- Use `.toEqual()` for exact matches
|
|
410
483
|
- Use `expect.objectContaining()` with `expect.any(String)` for IDs and timestamps
|
|
411
|
-
- Use `expect.arrayContaining()` for list assertions
|
|
412
|
-
- Use `expect.not.arrayContaining()` for negative list assertions
|
|
413
484
|
- **Only standard Jest matchers** — NEVER `expect.toBeOneOf()`, `expect.toSatisfy()`
|
|
414
485
|
- For nullable fields: `expect(value === null || typeof value === "string").toBe(true)`
|
|
415
486
|
|
|
416
487
|
### Error Testing
|
|
417
488
|
|
|
418
|
-
- `.catch((e) => e)` + `error.message` check —
|
|
419
|
-
- `.rejects.toThrow()` — when only checking
|
|
420
|
-
- `try/catch` + `error.type` check — when checking AcmeKitError type
|
|
489
|
+
- `.catch((e: any) => e)` + `error.message` check — for service errors with specific messages
|
|
490
|
+
- `.rejects.toThrow()` — when only checking service errors throw (NOT workflows)
|
|
421
491
|
- For workflow errors: `{ errors } = await workflow.run({ throwOnError: false })`, check `errors[0].error.message`
|
|
422
|
-
|
|
423
|
-
### Structure
|
|
424
|
-
|
|
425
|
-
- Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
|
|
426
|
-
- One assertion per test when possible
|
|
427
|
-
- Always check both success AND error cases
|
|
428
|
-
- For update tests: create → update → verify
|
|
429
|
-
- For delete tests: create → delete → verify not found
|
|
430
|
-
- Runners handle DB setup/teardown — no manual cleanup needed
|
|
492
|
+
- NEVER use `.rejects.toThrow()` on workflows — always fails (plain objects, not Error instances)
|
|
431
493
|
|
|
432
494
|
### Imports & Style
|
|
433
495
|
|
|
434
496
|
- **Only import what you use** — remove unused imports
|
|
435
|
-
- Resolve plugin services via module constant: `container.resolve(
|
|
436
|
-
- Resolve core services via `Modules.*` constants: `container.resolve(Modules.AUTH)`
|
|
437
|
-
-
|
|
438
|
-
-
|
|
497
|
+
- Resolve plugin services via module constant: `container.resolve(GREETING_MODULE)`
|
|
498
|
+
- Resolve core services via `Modules.*` constants: `container.resolve(Modules.AUTH)`
|
|
499
|
+
- Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
|
|
500
|
+
- Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
501
|
+
- Runners handle DB setup/teardown — no manual cleanup needed
|
|
439
502
|
- Spy on `MockEventBusService.prototype` — not an instance
|
|
440
|
-
- `waitSubscribersExecution` promise BEFORE triggering event
|
|
441
503
|
- `jest.restoreAllMocks()` in `afterEach` when spying
|
|
442
|
-
-
|
|
443
|
-
- Use `throwOnError: false` to inspect workflow errors without throwing
|
|
444
|
-
- For JWT tokens: use `generateJwtToken` from `@acmekit/framework/utils` — NEVER `jsonwebtoken` directly
|
|
445
|
-
- For client API keys: use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER` — NEVER `"publishable"` or `"x-publishable-api-key"`
|
|
504
|
+
- NEVER use JSDoc blocks or type casts in test files
|