@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,287 +1,477 @@
|
|
|
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
|
|
31
|
-
|
|
32
|
-
```typescript
|
|
33
|
-
import { pluginIntegrationTestRunner } from "@acmekit/test-utils"
|
|
34
|
-
import { plugin } from "../../src/plugin"
|
|
37
|
+
## Plugin HTTP Integration Test Template
|
|
35
38
|
|
|
36
|
-
|
|
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
|
+
|
|
261
|
+
**When plugin depends on other plugins:** Add `skipDependencyValidation: true` and mock peer services via `injectedDependencies`.
|
|
262
|
+
|
|
263
|
+
**When plugin has providers needing options:** Use `pluginModuleOptions` keyed by module name:
|
|
264
|
+
```typescript
|
|
265
|
+
pluginModuleOptions: {
|
|
266
|
+
myModule: { providers: [{ resolve: "./src/providers/my-provider", id: "my-id", options: { apiKey: "key" } }] },
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
164
270
|
---
|
|
165
271
|
|
|
166
|
-
##
|
|
272
|
+
## Subscriber Test Template (direct handler invocation)
|
|
167
273
|
|
|
168
|
-
|
|
274
|
+
Plugin container mode doesn't have a real event bus — import the handler and call it manually:
|
|
169
275
|
|
|
170
276
|
```typescript
|
|
171
|
-
import {
|
|
277
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
278
|
+
import greetingCreatedHandler from "../../src/subscribers/greeting-created"
|
|
279
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
280
|
+
|
|
281
|
+
jest.setTimeout(60 * 1000)
|
|
172
282
|
|
|
173
|
-
|
|
283
|
+
integrationTestRunner({
|
|
284
|
+
mode: "plugin",
|
|
174
285
|
pluginPath: process.cwd(),
|
|
175
286
|
testSuite: ({ container }) => {
|
|
176
|
-
it("should
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
287
|
+
it("should append ' [notified]' to greeting message", async () => {
|
|
288
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
289
|
+
const [greeting] = await service.createGreetings([
|
|
290
|
+
{ message: "Hello World" },
|
|
291
|
+
])
|
|
292
|
+
|
|
293
|
+
// Call handler directly with event + container
|
|
294
|
+
await greetingCreatedHandler({
|
|
295
|
+
event: {
|
|
296
|
+
data: { id: greeting.id },
|
|
297
|
+
name: "greeting.created",
|
|
181
298
|
},
|
|
299
|
+
container,
|
|
182
300
|
})
|
|
183
|
-
expect(result.order).toEqual(
|
|
184
|
-
expect.objectContaining({ customerId: "cus_123" })
|
|
185
|
-
)
|
|
186
|
-
})
|
|
187
301
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
input: {},
|
|
191
|
-
throwOnError: false,
|
|
192
|
-
})
|
|
193
|
-
expect(errors).toHaveLength(1)
|
|
194
|
-
expect(errors[0].error.message).toContain("customerId")
|
|
302
|
+
const updated = await service.retrieveGreeting(greeting.id)
|
|
303
|
+
expect(updated.message).toBe("Hello World [notified]")
|
|
195
304
|
})
|
|
196
305
|
},
|
|
197
306
|
})
|
|
198
307
|
```
|
|
199
308
|
|
|
200
|
-
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Job Test Template (direct invocation)
|
|
201
312
|
|
|
202
313
|
```typescript
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const { errors } = await createOrderWorkflow(container).run({
|
|
207
|
-
input: {
|
|
208
|
-
customerId: "cus_123",
|
|
209
|
-
items: [{ sku: "SKU-001", qty: 2 }],
|
|
210
|
-
paymentMethod: "invalid-method",
|
|
211
|
-
},
|
|
212
|
-
throwOnError: false,
|
|
213
|
-
})
|
|
314
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
315
|
+
import cleanupGreetingsJob from "../../src/jobs/cleanup-greetings"
|
|
316
|
+
import { GREETING_MODULE } from "../../src/modules/greeting"
|
|
214
317
|
|
|
215
|
-
|
|
318
|
+
jest.setTimeout(60 * 1000)
|
|
216
319
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
})
|
|
221
|
-
|
|
320
|
+
integrationTestRunner({
|
|
321
|
+
mode: "plugin",
|
|
322
|
+
pluginPath: process.cwd(),
|
|
323
|
+
testSuite: ({ container }) => {
|
|
324
|
+
it("should soft-delete greetings with lang='old'", async () => {
|
|
325
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
222
326
|
|
|
223
|
-
|
|
327
|
+
await service.createGreetings([
|
|
328
|
+
{ message: "Old 1", lang: "old" },
|
|
329
|
+
{ message: "Old 2", lang: "old" },
|
|
330
|
+
{ message: "Current", lang: "en" },
|
|
331
|
+
])
|
|
224
332
|
|
|
225
|
-
|
|
226
|
-
it("should skip notification when flag is false", async () => {
|
|
227
|
-
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
228
|
-
|
|
229
|
-
await createOrderWorkflow(container).run({
|
|
230
|
-
input: {
|
|
231
|
-
customerId: "cus_123",
|
|
232
|
-
items: [{ sku: "SKU-001", qty: 1 }],
|
|
233
|
-
sendNotification: false,
|
|
234
|
-
},
|
|
235
|
-
})
|
|
333
|
+
await cleanupGreetingsJob(container)
|
|
236
334
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
335
|
+
const remaining = await service.listGreetings()
|
|
336
|
+
expect(remaining).toHaveLength(1)
|
|
337
|
+
expect(remaining[0].lang).toBe("en")
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it("should do nothing when no old greetings exist", async () => {
|
|
341
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
342
|
+
await service.createGreetings([{ message: "Hello", lang: "en" }])
|
|
343
|
+
|
|
344
|
+
await cleanupGreetingsJob(container)
|
|
345
|
+
|
|
346
|
+
const remaining = await service.listGreetings()
|
|
347
|
+
expect(remaining).toHaveLength(1)
|
|
348
|
+
})
|
|
349
|
+
},
|
|
244
350
|
})
|
|
245
351
|
```
|
|
246
352
|
|
|
247
|
-
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Module Integration Test Template
|
|
248
356
|
|
|
249
357
|
```typescript
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
358
|
+
import { integrationTestRunner } from "@acmekit/test-utils"
|
|
359
|
+
|
|
360
|
+
jest.setTimeout(30000)
|
|
361
|
+
|
|
362
|
+
integrationTestRunner<IGreetingModuleService>({
|
|
363
|
+
mode: "module",
|
|
364
|
+
moduleName: "greeting",
|
|
365
|
+
resolve: process.cwd() + "/src/modules/greeting",
|
|
366
|
+
testSuite: ({ service }) => {
|
|
367
|
+
describe("createGreetings", () => {
|
|
368
|
+
it("should create a greeting", async () => {
|
|
369
|
+
const result = await service.createGreetings([
|
|
370
|
+
{ message: "Hello" },
|
|
371
|
+
])
|
|
372
|
+
expect(result).toHaveLength(1)
|
|
373
|
+
expect(result[0]).toEqual(
|
|
374
|
+
expect.objectContaining({
|
|
375
|
+
id: expect.any(String),
|
|
376
|
+
message: "Hello",
|
|
377
|
+
})
|
|
378
|
+
)
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe("softDeleteGreetings / restoreGreetings", () => {
|
|
383
|
+
it("should soft delete and restore", async () => {
|
|
384
|
+
const [created] = await service.createGreetings([
|
|
385
|
+
{ message: "To Delete" },
|
|
386
|
+
])
|
|
387
|
+
await service.softDeleteGreetings([created.id])
|
|
388
|
+
|
|
389
|
+
const listed = await service.listGreetings({ id: created.id })
|
|
390
|
+
expect(listed).toHaveLength(0)
|
|
391
|
+
|
|
392
|
+
await service.restoreGreetings([created.id])
|
|
254
393
|
|
|
255
|
-
|
|
256
|
-
|
|
394
|
+
const restored = await service.listGreetings({ id: created.id })
|
|
395
|
+
expect(restored).toHaveLength(1)
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
},
|
|
257
399
|
})
|
|
258
400
|
```
|
|
259
401
|
|
|
260
|
-
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Unit Test Template (No Framework Bootstrap)
|
|
405
|
+
|
|
406
|
+
For providers, utilities, and standalone classes. Uses plain Jest with `jest.mock`.
|
|
407
|
+
|
|
408
|
+
**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()`.
|
|
261
409
|
|
|
262
410
|
```typescript
|
|
263
|
-
|
|
264
|
-
|
|
411
|
+
// Provider unit test pattern
|
|
412
|
+
jest.mock("external-sdk", () => {
|
|
413
|
+
const mocks = {
|
|
414
|
+
doThing: jest.fn(),
|
|
415
|
+
}
|
|
416
|
+
const MockClient = jest.fn().mockImplementation(() => ({
|
|
417
|
+
doThing: mocks.doThing,
|
|
418
|
+
}))
|
|
419
|
+
return { Client: MockClient, __mocks: mocks }
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const { __mocks: sdkMocks } = require("external-sdk")
|
|
423
|
+
|
|
424
|
+
import MyProvider from "../my-provider"
|
|
265
425
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
426
|
+
describe("MyProvider", () => {
|
|
427
|
+
let provider: MyProvider
|
|
428
|
+
const mockContainer = {} as any
|
|
429
|
+
const defaultOptions = { apiKey: "test-key" }
|
|
430
|
+
|
|
431
|
+
beforeEach(() => {
|
|
432
|
+
jest.clearAllMocks()
|
|
433
|
+
provider = new MyProvider(mockContainer, defaultOptions)
|
|
269
434
|
})
|
|
270
435
|
|
|
271
|
-
|
|
272
|
-
|
|
436
|
+
describe("static identifier", () => {
|
|
437
|
+
it("should have correct identifier", () => {
|
|
438
|
+
expect(MyProvider.identifier).toBe("my-provider")
|
|
439
|
+
})
|
|
273
440
|
})
|
|
274
441
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
442
|
+
describe("validateOptions", () => {
|
|
443
|
+
it("should accept valid options", () => {
|
|
444
|
+
expect(() =>
|
|
445
|
+
MyProvider.validateOptions({ apiKey: "key" })
|
|
446
|
+
).not.toThrow()
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it("should reject missing required option", () => {
|
|
450
|
+
expect(() => MyProvider.validateOptions({})).toThrow()
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
describe("doSomething", () => {
|
|
455
|
+
it("should delegate to SDK", async () => {
|
|
456
|
+
sdkMocks.doThing.mockResolvedValue({ success: true })
|
|
457
|
+
const result = await provider.doSomething({ input: "test" })
|
|
458
|
+
expect(result.success).toBe(true)
|
|
459
|
+
})
|
|
460
|
+
})
|
|
279
461
|
})
|
|
280
462
|
```
|
|
281
463
|
|
|
464
|
+
**Timer mocking:** If code under test uses `setTimeout` or `sleep()`, use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or mock the sleep method.
|
|
465
|
+
|
|
466
|
+
**SWC regex:** Complex regex literals may fail. Use `new RegExp("...")` instead.
|
|
467
|
+
|
|
468
|
+
**Error paths:** Read the implementation to check whether errors are thrown or returned. Don't assume from the return type.
|
|
469
|
+
|
|
282
470
|
---
|
|
283
471
|
|
|
284
|
-
## Asserting Domain Events
|
|
472
|
+
## Asserting Domain Events (container-only mode)
|
|
473
|
+
|
|
474
|
+
Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
|
|
285
475
|
|
|
286
476
|
```typescript
|
|
287
477
|
import { MockEventBusService } from "@acmekit/test-utils"
|
|
@@ -296,16 +486,16 @@ afterEach(() => {
|
|
|
296
486
|
eventBusSpy.mockClear()
|
|
297
487
|
})
|
|
298
488
|
|
|
299
|
-
it("should emit
|
|
300
|
-
const service = container.resolve(
|
|
301
|
-
await service.
|
|
489
|
+
it("should emit greeting.created event", async () => {
|
|
490
|
+
const service: any = container.resolve(GREETING_MODULE)
|
|
491
|
+
await service.createGreetings([{ message: "Event Test" }])
|
|
302
492
|
|
|
493
|
+
// MockEventBusService.emit receives an ARRAY of events
|
|
303
494
|
const events = eventBusSpy.mock.calls[0][0]
|
|
304
|
-
expect(events).toHaveLength(1)
|
|
305
495
|
expect(events).toEqual(
|
|
306
496
|
expect.arrayContaining([
|
|
307
497
|
expect.objectContaining({
|
|
308
|
-
name: "
|
|
498
|
+
name: "greeting.created",
|
|
309
499
|
data: expect.objectContaining({ id: expect.any(String) }),
|
|
310
500
|
}),
|
|
311
501
|
])
|
|
@@ -315,131 +505,81 @@ it("should emit blog-post.created event", async () => {
|
|
|
315
505
|
|
|
316
506
|
---
|
|
317
507
|
|
|
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
508
|
## What to Test
|
|
372
509
|
|
|
373
510
|
**Plugin loading:**
|
|
374
511
|
- Modules resolve from container
|
|
375
|
-
- Plugin options accessible via `plugin.resolveOptions(container)`
|
|
376
512
|
- Auto-discovered resources loaded (subscribers, workflows, jobs)
|
|
377
513
|
|
|
378
514
|
**Module services:**
|
|
379
515
|
- Create (single + batch), list (with filters), listAndCount, retrieve, update, softDelete/restore
|
|
380
516
|
- Custom service methods
|
|
381
|
-
-
|
|
382
|
-
-
|
|
517
|
+
- Required field validation → `rejects.toThrow()`
|
|
518
|
+
- Not found → `.catch((e: any) => e)` + `error.message` check
|
|
383
519
|
|
|
384
520
|
**Workflows:**
|
|
385
|
-
- Happy path + correct result
|
|
386
|
-
- `throwOnError: false` + `errors`
|
|
387
|
-
-
|
|
388
|
-
- Compensation on failure (verify side-effects rolled back)
|
|
389
|
-
- `when()` branches (verify skipped/executed paths)
|
|
390
|
-
- `parallelize()` results (verify both branches complete)
|
|
391
|
-
- Hook handlers (`workflow.hooks.hookName(handler)`)
|
|
521
|
+
- Happy path + correct result via `workflow(container).run({ input })`
|
|
522
|
+
- `throwOnError: false` + `errors[0].error.message` for invalid input
|
|
523
|
+
- Step compensation on failure (verify side-effects rolled back)
|
|
392
524
|
|
|
393
|
-
**
|
|
394
|
-
-
|
|
395
|
-
-
|
|
525
|
+
**Subscribers:**
|
|
526
|
+
- Import handler directly, call with `{ event: { data, name }, container }`
|
|
527
|
+
- Verify side-effects after handler call
|
|
396
528
|
|
|
397
|
-
**
|
|
398
|
-
-
|
|
529
|
+
**Jobs:**
|
|
530
|
+
- Import function directly, call with `container`
|
|
531
|
+
- Verify mutations (records created/deleted/updated)
|
|
532
|
+
- Handle no-op case (empty result set)
|
|
399
533
|
|
|
400
|
-
**HTTP routes:**
|
|
401
|
-
-
|
|
534
|
+
**HTTP routes (with `http: true`):**
|
|
535
|
+
- Success responses with correct shape
|
|
536
|
+
- Delete responses: `{ id, object: "resource", deleted: true }`
|
|
537
|
+
- Validation errors (400 via `.catch((e: any) => e)`)
|
|
538
|
+
- Auth: 401 without JWT, 400 without client API key
|
|
539
|
+
|
|
540
|
+
**Events (container mode):**
|
|
541
|
+
- Spy on `MockEventBusService.prototype.emit` (prototype, not instance)
|
|
542
|
+
- Access events via `eventBusSpy.mock.calls[0][0]` (array of event objects)
|
|
402
543
|
|
|
403
544
|
---
|
|
404
545
|
|
|
405
546
|
## Rules
|
|
406
547
|
|
|
548
|
+
### MANDATORY
|
|
549
|
+
|
|
550
|
+
- **Unified runner only** — `integrationTestRunner` with `mode`, never deprecated names
|
|
551
|
+
- **Container-only tests have NO `api` fixture** — use `container.resolve()` for service access
|
|
552
|
+
- **HTTP tests require `http: true`** — and full auth setup in `beforeEach`
|
|
553
|
+
- **Always use `pluginPath: process.cwd()`** — never hardcode paths
|
|
554
|
+
- **JWT from config** — use `generateJwtToken` from `@acmekit/framework/utils` with `config.projectConfig.http.jwtSecret`
|
|
555
|
+
- **Client API key** — use `ApiKeyType.CLIENT` and `CLIENT_API_KEY_HEADER`. NEVER `"publishable"` or `"x-publishable-api-key"`
|
|
556
|
+
|
|
407
557
|
### Assertions
|
|
408
558
|
|
|
409
559
|
- Use `.toEqual()` for exact matches
|
|
410
560
|
- 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
561
|
- **Only standard Jest matchers** — NEVER `expect.toBeOneOf()`, `expect.toSatisfy()`
|
|
414
562
|
- For nullable fields: `expect(value === null || typeof value === "string").toBe(true)`
|
|
415
563
|
|
|
416
564
|
### Error Testing
|
|
417
565
|
|
|
418
|
-
- `.catch((e) => e)` + `error.message` check —
|
|
419
|
-
- `.rejects.toThrow()` — when only checking
|
|
420
|
-
- `try/catch` + `error.type` check — when checking AcmeKitError type
|
|
566
|
+
- `.catch((e: any) => e)` + `error.message` check — for service errors with specific messages
|
|
567
|
+
- `.rejects.toThrow()` — when only checking service errors throw (NOT workflows)
|
|
421
568
|
- 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
|
|
569
|
+
- NEVER use `.rejects.toThrow()` on workflows — always fails (plain objects, not Error instances)
|
|
431
570
|
|
|
432
571
|
### Imports & Style
|
|
433
572
|
|
|
434
573
|
- **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
|
-
-
|
|
574
|
+
- Resolve plugin services via module constant: `container.resolve(GREETING_MODULE)`
|
|
575
|
+
- Resolve core services via `Modules.*` constants: `container.resolve(Modules.AUTH)`
|
|
576
|
+
- Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
|
|
577
|
+
- Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
578
|
+
- Runners handle DB setup/teardown — no manual cleanup needed
|
|
439
579
|
- Spy on `MockEventBusService.prototype` — not an instance
|
|
440
|
-
- `waitSubscribersExecution` promise BEFORE triggering event
|
|
441
580
|
- `jest.restoreAllMocks()` in `afterEach` when spying
|
|
442
|
-
-
|
|
443
|
-
-
|
|
444
|
-
-
|
|
445
|
-
-
|
|
581
|
+
- NEVER use JSDoc blocks or type casts in test files
|
|
582
|
+
- **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
|
|
583
|
+
- **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error
|
|
584
|
+
- **Mock timers or sleep** when code under test has delays — prevents timeouts
|
|
585
|
+
- **Use `new RegExp()` over complex regex literals** — SWC parser has edge cases
|