@acmekit/acmekit 2.13.86 → 2.13.88
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/code-reviewer.md +18 -0
- package/dist/templates/app/.claude/agents/test-writer.md +28 -18
- package/dist/templates/app/.claude/rules/admin-components.md +18 -4
- package/dist/templates/app/.claude/rules/admin-data.md +73 -0
- package/dist/templates/app/.claude/rules/admin-patterns.md +20 -0
- package/dist/templates/app/.claude/rules/api-routes.md +25 -1
- package/dist/templates/app/.claude/rules/modules.md +169 -0
- package/dist/templates/app/.claude/rules/testing.md +96 -16
- package/dist/templates/app/.claude/rules/workflows.md +2 -0
- package/dist/templates/app/.claude/skills/admin-customization/SKILL.md +10 -0
- package/dist/templates/app/.claude/skills/write-test/SKILL.md +7 -0
- package/dist/templates/app/CLAUDE.md +2 -1
- package/dist/templates/plugin/.claude/agents/code-reviewer.md +18 -0
- package/dist/templates/plugin/.claude/agents/test-writer.md +30 -23
- package/dist/templates/plugin/.claude/rules/admin-components.md +18 -4
- package/dist/templates/plugin/.claude/rules/admin-data.md +73 -0
- package/dist/templates/plugin/.claude/rules/admin-patterns.md +20 -0
- package/dist/templates/plugin/.claude/rules/api-routes.md +25 -1
- package/dist/templates/plugin/.claude/rules/modules.md +169 -0
- package/dist/templates/plugin/.claude/rules/testing.md +187 -20
- package/dist/templates/plugin/.claude/rules/workflows.md +2 -0
- package/dist/templates/plugin/.claude/skills/admin-customization/SKILL.md +10 -0
- package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +8 -0
- package/dist/templates/plugin/CLAUDE.md +2 -1
- package/package.json +39 -39
|
@@ -8,7 +8,30 @@ paths:
|
|
|
8
8
|
|
|
9
9
|
# Testing Rules
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Pre-Flight Checklist (MANDATORY before writing any test)
|
|
12
|
+
|
|
13
|
+
Complete these steps IN ORDER before writing a single line of test code:
|
|
14
|
+
|
|
15
|
+
1. **Verify migrations are current.** Run `npx acmekit db:generate <module>` and `npx acmekit db:migrate` if you changed any model. `mode: "app"` runs real migrations — custom modules MUST have migration files or you get `TableNotFoundException`.
|
|
16
|
+
|
|
17
|
+
2. **Verify jest config buckets.** Read `jest.config.js` and confirm the test type you need exists:
|
|
18
|
+
```
|
|
19
|
+
integration:http → integration-tests/http/
|
|
20
|
+
integration:app → integration-tests/app/
|
|
21
|
+
integration:modules → src/modules/*/__tests__/
|
|
22
|
+
unit → src/**/__tests__/*.unit.spec.ts
|
|
23
|
+
```
|
|
24
|
+
If the bucket is missing, add the jest config entry BEFORE writing the test. A test file in a directory with no matching jest config is silently ignored.
|
|
25
|
+
|
|
26
|
+
3. **Read the source code under test.** Read service methods, route handlers, workflow steps, subscriber handlers — understand actual method signatures, return types, and error paths.
|
|
27
|
+
|
|
28
|
+
4. **Read mock interfaces.** When your test depends on auto-injected mocks (`MockEventBusService`, `MockLockingService`, `MockSecretsService`), read the mock source in `@acmekit/test-utils` to verify method names and signatures. Do NOT assume mock methods match the real service 1:1 — mocks have convenience methods (e.g., `setSecret()`, `getEmittedEvents()`) and may omit advanced features.
|
|
29
|
+
|
|
30
|
+
5. **Identify the correct test tier.** Match your test to the right mode and file location (see Test Runner Selection below).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Critical Rules
|
|
12
35
|
|
|
13
36
|
**Single unified runner.** Use `integrationTestRunner` from `@acmekit/test-utils` with a `mode` parameter. The old names (`acmekitIntegrationTestRunner`, `moduleIntegrationTestRunner`) are deprecated aliases — NEVER use them.
|
|
14
37
|
|
|
@@ -520,29 +543,18 @@ expect(response.data).toEqual({
|
|
|
520
543
|
|
|
521
544
|
## Asserting Domain Events
|
|
522
545
|
|
|
523
|
-
|
|
546
|
+
`MockEventBusService` accumulates all emitted events. Use `.getEmittedEvents()` — no spy needed.
|
|
524
547
|
|
|
525
548
|
```typescript
|
|
526
|
-
import { MockEventBusService } from "@acmekit/test-utils"
|
|
527
|
-
|
|
528
|
-
let eventBusSpy: jest.SpyInstance
|
|
529
|
-
|
|
530
|
-
beforeEach(() => {
|
|
531
|
-
eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
afterEach(() => {
|
|
535
|
-
eventBusSpy.mockClear()
|
|
536
|
-
})
|
|
537
|
-
|
|
538
549
|
it("should emit post.created event", async () => {
|
|
539
550
|
await service.createPosts([{ title: "Event Test" }])
|
|
540
551
|
|
|
541
|
-
const
|
|
552
|
+
const eventBus = getContainer().resolve(Modules.EVENT_BUS)
|
|
553
|
+
const events = eventBus.getEmittedEvents()
|
|
542
554
|
expect(events).toEqual(
|
|
543
555
|
expect.arrayContaining([
|
|
544
556
|
expect.objectContaining({
|
|
545
|
-
name: "post.created",
|
|
557
|
+
name: "post.created", // NOTE: property is "name", NOT "eventName"
|
|
546
558
|
data: expect.objectContaining({ id: expect.any(String) }),
|
|
547
559
|
}),
|
|
548
560
|
])
|
|
@@ -550,6 +562,8 @@ it("should emit post.created event", async () => {
|
|
|
550
562
|
})
|
|
551
563
|
```
|
|
552
564
|
|
|
565
|
+
> `emitEventStep` maps `eventName` to `name` internally — always assert on `event.name`.
|
|
566
|
+
|
|
553
567
|
---
|
|
554
568
|
|
|
555
569
|
## Waiting for Subscribers
|
|
@@ -904,6 +918,61 @@ await expect(provider.process(badInput)).rejects.toThrow("validation")
|
|
|
904
918
|
|
|
905
919
|
---
|
|
906
920
|
|
|
921
|
+
## Auto-Injected Infrastructure Mocks
|
|
922
|
+
|
|
923
|
+
The test runner automatically injects mock implementations for infrastructure modules in plugin and module modes. You do NOT need to manually inject these:
|
|
924
|
+
|
|
925
|
+
| Module | Mock class | Behavior |
|
|
926
|
+
|---|---|---|
|
|
927
|
+
| `Modules.EVENT_BUS` | `MockEventBusService` | Accumulates events — call `.getEmittedEvents()` to inspect |
|
|
928
|
+
| `Modules.LOCKING` | `MockLockingService` | In-memory locking — `execute()` runs job immediately, acquire/release tracked |
|
|
929
|
+
| `Modules.SECRETS` | `MockSecretsService` | In-memory store — pre-populate with `.setSecret(id, value)` |
|
|
930
|
+
|
|
931
|
+
Override any mock via `injectedDependencies` if needed — user overrides take precedence.
|
|
932
|
+
|
|
933
|
+
### Asserting emitted events (no spy needed)
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
const eventBus = getContainer().resolve(Modules.EVENT_BUS)
|
|
937
|
+
|
|
938
|
+
await createPostWorkflow(getContainer()).run({ input: { title: "Test" } })
|
|
939
|
+
|
|
940
|
+
// Inspect emitted events directly — no jest.spyOn needed
|
|
941
|
+
const events = eventBus.getEmittedEvents()
|
|
942
|
+
const postEvent = events.find((e: any) => e.name === "post.created")
|
|
943
|
+
expect(postEvent).toBeDefined()
|
|
944
|
+
expect(postEvent.data).toEqual(expect.objectContaining({ id: expect.any(String) }))
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
> Event objects have `{ name, data, metadata?, options? }`. The property is `name`, NOT `eventName` — `emitEventStep` maps `eventName` to `name` internally.
|
|
948
|
+
|
|
949
|
+
---
|
|
950
|
+
|
|
951
|
+
## Client Routes Require API Key
|
|
952
|
+
|
|
953
|
+
Routes under `/client/*` use `AcmeKitTypedRequest` (non-authenticated), but they are NOT fully public. AcmeKit middleware enforces a client API key header on all `/client/*` routes. "Public" means "no user authentication" — a client API key is still required.
|
|
954
|
+
|
|
955
|
+
---
|
|
956
|
+
|
|
957
|
+
## Known Environment Issues
|
|
958
|
+
|
|
959
|
+
### Database Teardown Permission Errors
|
|
960
|
+
|
|
961
|
+
**Symptom:** `permission denied for schema public` or `cannot drop schema` during test teardown (afterAll).
|
|
962
|
+
|
|
963
|
+
**Cause:** PostgreSQL user lacks `CREATE`/`DROP` privileges on the `public` schema, or concurrent test processes hold locks.
|
|
964
|
+
|
|
965
|
+
**Fix:**
|
|
966
|
+
```bash
|
|
967
|
+
# Grant full privileges to the test user (run once)
|
|
968
|
+
psql -U postgres -c "GRANT ALL ON SCHEMA public TO <your_user>;"
|
|
969
|
+
psql -U postgres -c "ALTER USER <your_user> CREATEDB;"
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
This is an environment setup issue, not a test code issue. If teardown fails but all tests passed, the test results are still valid.
|
|
973
|
+
|
|
974
|
+
---
|
|
975
|
+
|
|
907
976
|
## Anti-Patterns — NEVER Do These
|
|
908
977
|
|
|
909
978
|
```typescript
|
|
@@ -1068,4 +1137,15 @@ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
|
|
|
1068
1137
|
const result = await provider.process(bad)
|
|
1069
1138
|
expect(result.success).toBe(false) // ❌ actually throws
|
|
1070
1139
|
// RIGHT — read implementation first to check if it throws or returns
|
|
1140
|
+
|
|
1141
|
+
// WRONG — using jest.spyOn to inspect emitted events
|
|
1142
|
+
jest.spyOn(MockEventBusService.prototype, "emit") // ❌ fragile, complex extraction
|
|
1143
|
+
// RIGHT — use built-in event accumulation
|
|
1144
|
+
const eventBus = getContainer().resolve(Modules.EVENT_BUS)
|
|
1145
|
+
const events = eventBus.getEmittedEvents()
|
|
1146
|
+
|
|
1147
|
+
// WRONG — checking event.eventName (emitEventStep maps eventName → name internally)
|
|
1148
|
+
expect(event.eventName).toBe("post.created") // ❌ property doesn't exist
|
|
1149
|
+
// RIGHT
|
|
1150
|
+
expect(event.name).toBe("post.created")
|
|
1071
1151
|
```
|
|
@@ -502,6 +502,8 @@ dismissRemoteLinkStep([{
|
|
|
502
502
|
|
|
503
503
|
Always resolve services fresh from `container`. Never capture from closure in compensation.
|
|
504
504
|
|
|
505
|
+
**Workflow steps have the full application container** — they can resolve ANY module. Module providers and services are scoped to their parent module and cannot resolve other modules. When a provider needs cross-module dependencies, the step resolves them and passes them through the service to the provider as method parameters (see `modules.md` → Provider Container Scoping).
|
|
506
|
+
|
|
505
507
|
```typescript
|
|
506
508
|
// Typed resolution (preferred)
|
|
507
509
|
const service = container.resolve<IBlogModuleService>(BLOG_MODULE)
|
|
@@ -376,6 +376,16 @@ Each widget file exports exactly one default component and one `config`.
|
|
|
376
376
|
|
|
377
377
|
Widgets receive no props. Fetch all data internally using hooks.
|
|
378
378
|
|
|
379
|
+
### Admin Dependencies
|
|
380
|
+
|
|
381
|
+
Form-related packages are provided by acmekit — do NOT install them separately:
|
|
382
|
+
|
|
383
|
+
- `react-hook-form` — import `useForm` from `"react-hook-form"` (available transitively)
|
|
384
|
+
- `@hookform/resolvers` — import `zodResolver` from `"@hookform/resolvers/zod"` (available transitively)
|
|
385
|
+
- `zod` — import as `import * as zod from "@acmekit/deps/zod"` (NOT `from "zod"`)
|
|
386
|
+
|
|
387
|
+
**For plugins:** Add `react-hook-form` and `@hookform/resolvers` to `peerDependencies` (+ `devDependencies` mirror), never just `devDependencies`.
|
|
388
|
+
|
|
379
389
|
### Prefer Shared SDK Over Raw Fetch
|
|
380
390
|
|
|
381
391
|
Use `src/admin/lib/sdk.ts` for backend calls so auth, path prefixes, and route typings stay consistent:
|
|
@@ -15,6 +15,13 @@ Generate tests for AcmeKit applications using `integrationTestRunner` with the c
|
|
|
15
15
|
- HTTP integration tests: !`ls integration-tests/http/*.spec.ts 2>/dev/null || echo "(none)"`
|
|
16
16
|
- App integration tests: !`ls integration-tests/app/*.spec.ts 2>/dev/null || echo "(none)"`
|
|
17
17
|
|
|
18
|
+
## Pre-Flight Checklist (MANDATORY — do these BEFORE writing any test)
|
|
19
|
+
|
|
20
|
+
1. **Verify migrations are current.** If model files changed, run `npx acmekit db:generate <module>` + `npx acmekit db:migrate`. Missing migrations cause `TableNotFoundException`.
|
|
21
|
+
2. **Verify jest config buckets.** Read `jest.config.js` — confirm the bucket you need exists (`integration:http`, `integration:app`, `integration:modules`, `unit`). If missing, add the entry and create the directory first.
|
|
22
|
+
3. **Read source code under test.** Understand method signatures, return types, error handling.
|
|
23
|
+
4. **Read mock interfaces.** If using auto-injected mocks, read their source to verify method names. Don't guess from real service interfaces.
|
|
24
|
+
|
|
18
25
|
## Critical Gotchas — Every Test Must Get These Right
|
|
19
26
|
|
|
20
27
|
1. **Unified runner only.** `import { integrationTestRunner } from "@acmekit/test-utils"`. NEVER use `acmekitIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.
|
|
@@ -8,7 +8,8 @@ You are a senior AcmeKit framework developer. Build features using code generato
|
|
|
8
8
|
|
|
9
9
|
- Edit files in `src/types/generated/` — overwritten by `npx acmekit generate types`
|
|
10
10
|
- Import from `.acmekit/types/` or `src/types/generated/` directly — use `InferTypeOf` or barrel exports
|
|
11
|
-
- Import `zod`, `awilix`, or `pg` directly — use `@acmekit/framework/zod`, `@acmekit/framework/awilix`, `@acmekit/framework/pg`
|
|
11
|
+
- Import `zod`, `awilix`, or `pg` directly — backend: use `@acmekit/framework/zod`, `@acmekit/framework/awilix`, `@acmekit/framework/pg`; admin UI: use `@acmekit/deps/zod`
|
|
12
|
+
- Add `react-hook-form` or `@hookform/resolvers` as dependencies — they are provided by acmekit
|
|
12
13
|
- Use string literals for container keys — use `ContainerRegistrationKeys.*` and `Modules.*`
|
|
13
14
|
- Call services directly for mutations in routes — use workflows
|
|
14
15
|
- Put business logic in workflow steps — steps are thin wrappers; service methods own orchestration
|
|
@@ -76,8 +76,11 @@ For each match: if the method name has no `_` suffix it's a public method — wr
|
|
|
76
76
|
```
|
|
77
77
|
grep -rn "Module(" src/modules/ | grep -v "ModuleProvider\|AcmeKitService"
|
|
78
78
|
grep -rn "static identifier" src/modules/
|
|
79
|
+
grep -rn "this\.container_\[" src/modules/
|
|
80
|
+
grep -rn "container_\." src/modules/ | grep -v "logger\|options\|container_:"
|
|
79
81
|
```
|
|
80
82
|
Check: any file with `ModuleProvider` that uses `Module()` instead → wrong export format.
|
|
83
|
+
Check: providers resolving other modules from `this.container_` → module-scoped container can't see other modules. Cross-module deps must be passed as method params from the workflow step through the service.
|
|
81
84
|
|
|
82
85
|
**If hasWorkflows:**
|
|
83
86
|
```
|
|
@@ -93,7 +96,9 @@ grep -rn "res\.json.*result\b" src/api/
|
|
|
93
96
|
grep -rn "validators\.ts" src/api/
|
|
94
97
|
grep -rn "import.*from.*\/service\"" src/api/
|
|
95
98
|
grep -rn "validatedBody as any" src/api/
|
|
99
|
+
grep -rn "req\.validatedQuery[^?]" src/api/
|
|
96
100
|
```
|
|
101
|
+
For `req.validatedQuery`: verify every destructuring uses `?? {}` fallback (e.g., `req.validatedQuery ?? {}`). Without it, requests with no query string crash.
|
|
97
102
|
|
|
98
103
|
**If hasSubscribers:**
|
|
99
104
|
```
|
|
@@ -109,7 +114,12 @@ grep -rn "await.*service\." src/jobs/
|
|
|
109
114
|
```
|
|
110
115
|
grep -rn "^export default function " src/admin/
|
|
111
116
|
grep -rn "zone:.*\`\|zone:.*\+" src/admin/
|
|
117
|
+
grep -rn "from [\"']zod[\"']" src/admin/
|
|
118
|
+
grep -rn "<SectionRow.*>" src/admin/ | grep "children\|>" | grep -v "/>"
|
|
119
|
+
grep -rn "react-hook-form\|@hookform" package.json
|
|
120
|
+
grep -rn "useDataTable" src/admin/
|
|
112
121
|
```
|
|
122
|
+
For each `useDataTable` match with `filtering` config: check that the companion `useQuery` includes filter/search state in `queryKey` AND passes filter params in `queryFn`. A static `queryKey` like `["items"]` with filtering enabled means filters do nothing (server-side filtering, `manualFiltering: true`).
|
|
113
123
|
|
|
114
124
|
### Step 5 — Read matched files
|
|
115
125
|
For every file with a hit, read it fully to confirm the issue in context.
|
|
@@ -155,6 +165,7 @@ Only work through sections for detected features. Explicitly skip and note secti
|
|
|
155
165
|
- [ ] `PREFIX` and `IDENTIFIER_KEY` constants defined once in `constants.ts`, never hardcoded inline
|
|
156
166
|
- [ ] `createProvidersLoader` used for modules accepting providers — no hand-rolled provider loops
|
|
157
167
|
- [ ] Service resolves providers via `this.providers.resolve(id)` — NOT `(this as any).__container__`
|
|
168
|
+
- [ ] Providers do NOT resolve other modules from `this.container_` (constructor or property access) — provider containers are module-scoped. Cross-module deps must be received as method parameters, resolved by the workflow step and passed through the parent module's service
|
|
158
169
|
|
|
159
170
|
### Workflows — only if hasWorkflows
|
|
160
171
|
- [ ] Steps in `src/workflows/steps/<name>.ts` — one per file, not in workflow files
|
|
@@ -180,6 +191,7 @@ Only work through sections for detected features. Explicitly skip and note secti
|
|
|
180
191
|
- [ ] `defineRouteConfig` with Zod body (`.strict()`) and/or query schemas
|
|
181
192
|
- [ ] No `validators.ts` for new routes
|
|
182
193
|
- [ ] `req.validatedBody`, `req.validatedQuery`, `req.queryConfig` — not `req.remoteQueryConfig`
|
|
194
|
+
- [ ] `req.validatedQuery` destructured with `?? {}` fallback — undefined when request has no query string
|
|
183
195
|
- [ ] Every method that uses `req.queryConfig` has BOTH `query` and `queryConfig` in its config — `queryConfig` without `query` is silently ignored
|
|
184
196
|
- [ ] `additional_data` destructured without `as any` — `validatedBody` is already typed
|
|
185
197
|
- [ ] Mutations: workflow → `query.graph()` refetch → return refetched data
|
|
@@ -217,6 +229,12 @@ Only work through sections for detected features. Explicitly skip and note secti
|
|
|
217
229
|
- [ ] `usePrompt()` before destructive actions
|
|
218
230
|
- [ ] API calls via shared SDK from `src/admin/lib/sdk.ts`
|
|
219
231
|
- [ ] `useRouteModal().handleSuccess()` for navigation after create/edit
|
|
232
|
+
- [ ] Zod imported from `@acmekit/deps/zod` — NOT bare `"zod"`
|
|
233
|
+
- [ ] `SectionRow` uses only `title`, `value`, `actions` props — no `children` (use `value` for custom JSX)
|
|
234
|
+
- [ ] `onRowClick` callback uses `row.original.id` — NOT `row.id` (runtime is TanStack `Row<TData>` despite type)
|
|
235
|
+
- [ ] DataTable with `filtering` config: `useQuery` queryKey includes filter/search/pagination state — NOT static like `["items"]`
|
|
236
|
+
- [ ] DataTable with `filtering` config: `queryFn` passes filter state as query params to API — NOT fetching unfiltered data
|
|
237
|
+
- [ ] `react-hook-form` and `@hookform/resolvers` in `peerDependencies` (not only `devDependencies`) — they are provided by acmekit
|
|
220
238
|
|
|
221
239
|
### Middlewares — only if hasMiddlewares
|
|
222
240
|
- [ ] Every middleware calls `next()`
|
|
@@ -8,10 +8,24 @@ maxTurns: 20
|
|
|
8
8
|
|
|
9
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
|
-
**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
**MANDATORY PRE-FLIGHT CHECKLIST — complete ALL steps before writing any test code:**
|
|
12
|
+
|
|
13
|
+
1. **Read `.claude/rules/testing.md`** — contains anti-patterns, fixtures, lifecycle, and error handling rules you MUST follow.
|
|
14
|
+
|
|
15
|
+
2. **Build the plugin.** Run `pnpm build` (or verify `pnpm dev` is running). Stale `.acmekit/server/` output causes cryptic errors (`__joinerConfig is not a function`, missing services). The test runner loads compiled JS from `.acmekit/server/`, not TypeScript source.
|
|
16
|
+
|
|
17
|
+
3. **Verify jest config buckets.** Read `jest.config.js` and confirm the test bucket you need exists:
|
|
18
|
+
- `integration:plugin` → `integration-tests/plugin/`
|
|
19
|
+
- `integration:http` → `integration-tests/http/`
|
|
20
|
+
- `integration:modules` → `src/modules/*/__tests__/`
|
|
21
|
+
- `unit` → `src/**/__tests__/*.unit.spec.ts`
|
|
22
|
+
If the bucket is missing, add it to `jest.config.js` and create the directory BEFORE writing the test.
|
|
23
|
+
|
|
24
|
+
4. **Read the source code under test** — understand service methods, models, workflows, subscribers, route paths, validators, response shapes, and error handling.
|
|
25
|
+
|
|
26
|
+
5. **Read mock interfaces.** If your test uses auto-injected mocks (`MockEventBusService`, `MockLockingService`, `MockSecretsService`), read their actual source to verify exact method names and signatures. Do NOT guess — mocks have convenience methods (e.g., `setSecret()`, `getEmittedEvents()`) not on real services, and may omit some real service methods.
|
|
27
|
+
|
|
28
|
+
6. **Identify the correct test tier** (HTTP, plugin container, module, or unit).
|
|
15
29
|
|
|
16
30
|
---
|
|
17
31
|
|
|
@@ -471,31 +485,22 @@ describe("MyProvider", () => {
|
|
|
471
485
|
|
|
472
486
|
## Asserting Domain Events (container-only mode)
|
|
473
487
|
|
|
474
|
-
|
|
488
|
+
`MockEventBusService` accumulates emitted events — use `.getEmittedEvents()` directly, no spy needed.
|
|
475
489
|
|
|
476
490
|
```typescript
|
|
477
|
-
import {
|
|
478
|
-
|
|
479
|
-
let eventBusSpy: jest.SpyInstance
|
|
480
|
-
|
|
481
|
-
beforeEach(() => {
|
|
482
|
-
eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
|
|
483
|
-
})
|
|
484
|
-
|
|
485
|
-
afterEach(() => {
|
|
486
|
-
eventBusSpy.mockClear()
|
|
487
|
-
})
|
|
491
|
+
import { Modules } from "@acmekit/framework/utils"
|
|
488
492
|
|
|
489
493
|
it("should emit greeting.created event", async () => {
|
|
490
494
|
const service: any = container.resolve(GREETING_MODULE)
|
|
491
495
|
await service.createGreetings([{ message: "Event Test" }])
|
|
492
496
|
|
|
493
|
-
//
|
|
494
|
-
const
|
|
497
|
+
// Access events directly from the mock event bus — no spy needed
|
|
498
|
+
const eventBus = container.resolve(Modules.EVENT_BUS)
|
|
499
|
+
const events = eventBus.getEmittedEvents()
|
|
495
500
|
expect(events).toEqual(
|
|
496
501
|
expect.arrayContaining([
|
|
497
502
|
expect.objectContaining({
|
|
498
|
-
name: "greeting.created",
|
|
503
|
+
name: "greeting.created", // NOTE: property is "name", NOT "eventName"
|
|
499
504
|
data: expect.objectContaining({ id: expect.any(String) }),
|
|
500
505
|
}),
|
|
501
506
|
])
|
|
@@ -503,6 +508,8 @@ it("should emit greeting.created event", async () => {
|
|
|
503
508
|
})
|
|
504
509
|
```
|
|
505
510
|
|
|
511
|
+
> `emitEventStep` maps `eventName` to `name` internally — always assert on `event.name`.
|
|
512
|
+
|
|
506
513
|
---
|
|
507
514
|
|
|
508
515
|
## What to Test
|
|
@@ -538,8 +545,8 @@ it("should emit greeting.created event", async () => {
|
|
|
538
545
|
- Auth: 401 without JWT, 400 without client API key
|
|
539
546
|
|
|
540
547
|
**Events (container mode):**
|
|
541
|
-
-
|
|
542
|
-
-
|
|
548
|
+
- Use `container.resolve(Modules.EVENT_BUS).getEmittedEvents()` — no spy needed
|
|
549
|
+
- Event objects have `{ name, data }` — property is `name`, NOT `eventName`
|
|
543
550
|
|
|
544
551
|
---
|
|
545
552
|
|
|
@@ -576,8 +583,8 @@ it("should emit greeting.created event", async () => {
|
|
|
576
583
|
- Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
|
|
577
584
|
- Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
|
|
578
585
|
- Runners handle DB setup/teardown — no manual cleanup needed
|
|
579
|
-
-
|
|
580
|
-
- `
|
|
586
|
+
- Use `eventBus.getEmittedEvents()` for event assertions — no spy ceremony
|
|
587
|
+
- Event property is `name`, NOT `eventName` (`emitEventStep` maps internally)
|
|
581
588
|
- NEVER use JSDoc blocks or type casts in test files
|
|
582
589
|
- **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
|
|
583
590
|
- **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error
|
|
@@ -36,10 +36,18 @@ useDataTable({
|
|
|
36
36
|
filtering: { state: DataTableFilteringState, onFilteringChange: (v) => void },
|
|
37
37
|
pagination: { state: DataTablePaginationState, onPaginationChange: (v) => void },
|
|
38
38
|
rowSelection: { state: DataTableRowSelectionState, onRowSelectionChange: (v) => void, enableRowSelection?: boolean | ((row) => boolean) },
|
|
39
|
-
onRowClick: (event, row) => void,
|
|
39
|
+
onRowClick: (event, row) => void, // see note below
|
|
40
40
|
})
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
**`onRowClick` row parameter:** The TypeScript signature declares `row: TData`, but at runtime `row` is a TanStack `Row<TData>` object. Access your data via `row.original`:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
onRowClick: (_event, row) => navigate(row.original.id),
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This is a known type mismatch in `useDataTable`. TypeScript may report an error on `row.original` — cast if needed: `(row as any).original.id`.
|
|
50
|
+
|
|
43
51
|
State types: `DataTablePaginationState`, `DataTableSortingState`, `DataTableFilteringState`, `DataTableRowSelectionState` — all from `@acmekit/ui`.
|
|
44
52
|
|
|
45
53
|
**`DataTable.Table` emptyState shape:**
|
|
@@ -274,6 +282,8 @@ const useFilters = () => {
|
|
|
274
282
|
|
|
275
283
|
**Best practice**: For API-loaded filter options, fetch with `{ limit: 1000 }` and conditionally add the filter only when data is available.
|
|
276
284
|
|
|
285
|
+
**CRITICAL**: `useDataTable` uses server-side filtering (`manualFiltering: true`). Defining filters alone does NOT filter data — you MUST wire filter state to the API query. See the "DataTable Server-Side Filtering" section in the admin-data rules for the complete pattern (building `queryParams` from filter/search state, including them in `queryKey`, and passing as URL query params in `queryFn`).
|
|
286
|
+
|
|
277
287
|
---
|
|
278
288
|
|
|
279
289
|
## Forms — Form.Field Compound Component
|
|
@@ -284,7 +294,11 @@ const useFilters = () => {
|
|
|
284
294
|
import { Form, Input, Textarea, Select, Switch } from "@acmekit/ui"
|
|
285
295
|
import { useForm } from "react-hook-form"
|
|
286
296
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
287
|
-
import * as zod from "zod"
|
|
297
|
+
import * as zod from "@acmekit/deps/zod"
|
|
298
|
+
|
|
299
|
+
// NOTE: react-hook-form and @hookform/resolvers are provided by acmekit.
|
|
300
|
+
// In plugins: list them in peerDependencies (NOT devDependencies).
|
|
301
|
+
// In apps: they are available transitively — no extra dependency needed.
|
|
288
302
|
|
|
289
303
|
const Schema = zod.object({
|
|
290
304
|
title: zod.string().min(1, "Title is required"),
|
|
@@ -412,7 +426,7 @@ import { useForm } from "react-hook-form"
|
|
|
412
426
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
413
427
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
414
428
|
import { useParams } from "react-router-dom"
|
|
415
|
-
import * as zod from "zod"
|
|
429
|
+
import * as zod from "@acmekit/deps/zod"
|
|
416
430
|
import { sdk } from "../../../../lib/sdk"
|
|
417
431
|
|
|
418
432
|
const EditSchema = zod.object({
|
|
@@ -544,7 +558,7 @@ import { Button, RouteFocusModal, Form, Heading, Input, Text, Textarea, toast, K
|
|
|
544
558
|
import { useForm } from "react-hook-form"
|
|
545
559
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
546
560
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
547
|
-
import * as zod from "zod"
|
|
561
|
+
import * as zod from "@acmekit/deps/zod"
|
|
548
562
|
import { sdk } from "../../../lib/sdk"
|
|
549
563
|
|
|
550
564
|
const CreateSchema = zod.object({
|
|
@@ -75,6 +75,69 @@ const { data, isLoading } = useQuery({
|
|
|
75
75
|
})
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
### DataTable Server-Side Filtering
|
|
79
|
+
|
|
80
|
+
**`useDataTable` uses `manualFiltering: true`** — it does NOT filter data client-side. Filter/search/pagination state must be sent to the API as query parameters. If you don't wire state to the query, changing filters has no effect.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const [search, setSearch] = useState("")
|
|
84
|
+
const [filtering, setFiltering] = useState<DataTableFilteringState>({})
|
|
85
|
+
const [pagination, setPagination] = useState<DataTablePaginationState>({
|
|
86
|
+
pageIndex: 0,
|
|
87
|
+
pageSize: 20,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Build query params from filter/search/pagination state
|
|
91
|
+
const queryParams = useMemo(() => {
|
|
92
|
+
const params: Record<string, string> = {}
|
|
93
|
+
|
|
94
|
+
Object.entries(filtering).forEach(([key, value]) => {
|
|
95
|
+
if (value == null) return
|
|
96
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
97
|
+
params[key] = value.join(",")
|
|
98
|
+
} else if (!Array.isArray(value)) {
|
|
99
|
+
params[key] = String(value)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (search) {
|
|
104
|
+
params.q = search
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return params
|
|
108
|
+
}, [filtering, search])
|
|
109
|
+
|
|
110
|
+
// Include queryParams in queryKey so React Query refetches on filter changes
|
|
111
|
+
const { data, isLoading } = useQuery({
|
|
112
|
+
queryKey: ["wallets", queryParams],
|
|
113
|
+
queryFn: () => {
|
|
114
|
+
const qs = new URLSearchParams(queryParams).toString()
|
|
115
|
+
return sdk.admin.fetch(`/wallets${qs ? `?${qs}` : ""}`, {
|
|
116
|
+
method: "GET",
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const table = useDataTable({
|
|
122
|
+
data: data?.wallets ?? [],
|
|
123
|
+
columns,
|
|
124
|
+
filters,
|
|
125
|
+
getRowId: (row) => row.id,
|
|
126
|
+
rowCount: data?.count ?? data?.wallets?.length ?? 0,
|
|
127
|
+
isLoading,
|
|
128
|
+
search: { state: search, onSearchChange: setSearch },
|
|
129
|
+
filtering: { state: filtering, onFilteringChange: setFiltering },
|
|
130
|
+
pagination: { state: pagination, onPaginationChange: setPagination },
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Key rules:**
|
|
135
|
+
- `queryKey` MUST include filter/search/pagination state — otherwise React Query won't refetch when filters change
|
|
136
|
+
- `queryFn` MUST pass state as query parameters to the API
|
|
137
|
+
- Multiselect filters produce `string[]` — serialize as comma-separated for query params
|
|
138
|
+
- Radio filters produce `string` — pass directly
|
|
139
|
+
- The API route must handle these params (see `api-routes.md` for array filter handling)
|
|
140
|
+
|
|
78
141
|
---
|
|
79
142
|
|
|
80
143
|
## Action Menus — ActionMenu Component
|
|
@@ -247,6 +310,10 @@ const confirmed = await prompt({
|
|
|
247
310
|
**Entity types:**
|
|
248
311
|
- Hand-writing `type Payment = { id: string; status: string; created_at: string }` instead of inferring from SDK. Hand-written types drift from response schemas: date fields should be `string | Date` (from `z.union`), nullable fields need `| null`, enum fields are `string` (not the user enum). Always use `type Payment = Awaited<ReturnType<typeof sdk.admin.fetch<"/admin/payments", "GET">>>["items"][0]`
|
|
249
312
|
|
|
313
|
+
**Imports & dependencies:**
|
|
314
|
+
- Importing `zod` from `"zod"` in admin code — use `import * as zod from "@acmekit/deps/zod"` to share the same zod instance as acmekit
|
|
315
|
+
- Adding `react-hook-form` or `@hookform/resolvers` to `devDependencies` — they are provided by acmekit. In plugins: list them in `peerDependencies` (+ `devDependencies` mirror). In apps: they are available transitively, no extra dependency needed
|
|
316
|
+
|
|
250
317
|
**Form context:**
|
|
251
318
|
- Using `Form.Field`, `Form.Label`, `SwitchBox`, or any `Form.*` sub-component without a form provider — they call `useFormContext()` and crash with "Cannot destructure property 'getFieldState' of 'useFormContext(...)' as it is null". `RouteDrawer.Form` / `RouteFocusModal.Form` provide this automatically; standalone pages must wrap with `<Form {...form}>` from `@acmekit/ui`
|
|
252
319
|
- Importing `FormProvider` from `react-hook-form` directly — causes dual-instance context mismatch. Always use `Form` from `@acmekit/ui` (it IS the FormProvider re-export)
|
|
@@ -265,6 +332,8 @@ const confirmed = await prompt({
|
|
|
265
332
|
- Not using `size="small"` on ALL buttons (header actions, form buttons, table actions)
|
|
266
333
|
|
|
267
334
|
**DataTable:**
|
|
335
|
+
- **Static `queryKey` with filters** — using `queryKey: ["posts"]` when the table has filters/search/pagination. The `queryKey` MUST include filter/search/pagination state so React Query refetches when they change: `queryKey: ["posts", queryParams]`
|
|
336
|
+
- **Not passing filter state to the API** — `useDataTable` uses `manualFiltering: true` (server-side). If the `queryFn` doesn't send filter values as query parameters, changing filters has zero effect on the displayed data
|
|
268
337
|
- Passing `columns`, `filters`, `heading`, `emptyState` as props to `<DataTable>` — it only accepts `instance`. Use sub-components: `DataTable.Toolbar`, `DataTable.Table`, etc.
|
|
269
338
|
- Using boolean flags like `enablePagination: true` in `useDataTable` — pass state objects: `pagination: { state, onPaginationChange }`
|
|
270
339
|
- Using `table.getState().rowSelection` — use `table.getRowSelection()` instead
|
|
@@ -272,6 +341,7 @@ const confirmed = await prompt({
|
|
|
272
341
|
- Passing `commands` as a prop to `<DataTable>` — pass to `useDataTable` hook and render `<DataTable.CommandBar>`
|
|
273
342
|
- Using `Container className="divide-y p-0"` for DataTable pages — use `Container className="flex flex-col overflow-hidden p-0"` (DataTable needs flex layout)
|
|
274
343
|
- Rendering a `DataTable.*` sub-component without its matching state in `useDataTable` — causes runtime crash. `.Search` requires `search`, `.SortingMenu` requires `sorting`, `.FilterMenu` requires `filters`, `.CommandBar` requires `commands` + `rowSelection`. Never add a sub-component without wiring its state.
|
|
344
|
+
- Using `row.id` in `onRowClick` — `row` is a TanStack `Row<TData>` at runtime (despite the `TData` type signature). Access data via `row.original.id`. Cast if needed: `(row as any).original.id`.
|
|
275
345
|
|
|
276
346
|
**Error handling:**
|
|
277
347
|
- Using `toast.error` for query errors — throw them (`if (isError) throw error`) for error boundary
|
|
@@ -299,6 +369,9 @@ const confirmed = await prompt({
|
|
|
299
369
|
- Not breaking large detail pages into section components — if a detail page has 3+ sections, each should be its own component in `components/`
|
|
300
370
|
- Putting delete logic inline instead of extracting a reusable `useDelete*Action` hook when the same delete is used in multiple places
|
|
301
371
|
|
|
372
|
+
**SectionRow:**
|
|
373
|
+
- Passing `children` to `SectionRow` — it does NOT accept children. Use the `value` prop for custom JSX content (badges, copy buttons, formatted text). `SectionRow` only accepts `title`, `value`, and `actions`.
|
|
374
|
+
|
|
302
375
|
**Wrong component for context:**
|
|
303
376
|
- Using `StatusBadge` in DataTable columns — use `StatusCell` instead (dot indicator, truncation-safe)
|
|
304
377
|
- Using raw `Switch` + manual label/description instead of `SwitchBox` for toggle settings
|
|
@@ -535,6 +535,26 @@ import { SectionRow } from "@acmekit/ui"
|
|
|
535
535
|
|
|
536
536
|
**Props**: `title: string`, `value?: ReactNode | string | null`, `actions?: ReactNode`.
|
|
537
537
|
|
|
538
|
+
**SectionRow does NOT accept `children`.** Pass custom JSX through the `value` prop instead:
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
// WRONG — children is not a valid prop (TypeScript error)
|
|
542
|
+
<SectionRow title="Address">
|
|
543
|
+
<div className="font-mono">{wallet.address}</div>
|
|
544
|
+
</SectionRow>
|
|
545
|
+
|
|
546
|
+
// RIGHT — use value prop for custom JSX content
|
|
547
|
+
<SectionRow
|
|
548
|
+
title="Address"
|
|
549
|
+
value={
|
|
550
|
+
<div className="group flex items-center gap-x-2">
|
|
551
|
+
<Text size="small" className="font-mono">{wallet.address}</Text>
|
|
552
|
+
<Copy content={wallet.address} className="hidden group-hover:block" />
|
|
553
|
+
</div>
|
|
554
|
+
}
|
|
555
|
+
/>
|
|
556
|
+
```
|
|
557
|
+
|
|
538
558
|
---
|
|
539
559
|
|
|
540
560
|
## Component Organization — Scaling Beyond page.tsx
|
|
@@ -145,6 +145,24 @@ query: z.object({
|
|
|
145
145
|
})
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
**Query schemas — multiselect filter params:**
|
|
149
|
+
- Admin DataTable multiselect filters send array values as comma-separated strings (e.g., `?status=active,draft`)
|
|
150
|
+
- Use `z.preprocess` to split comma-separated strings into arrays:
|
|
151
|
+
```typescript
|
|
152
|
+
status: z.preprocess(
|
|
153
|
+
(v) => (typeof v === "string" ? v.split(",") : v),
|
|
154
|
+
z.array(z.string()).optional()
|
|
155
|
+
),
|
|
156
|
+
```
|
|
157
|
+
- For enum arrays, chain with `z.enum()` inside the array:
|
|
158
|
+
```typescript
|
|
159
|
+
network: z.preprocess(
|
|
160
|
+
(v) => (typeof v === "string" ? v.split(",") : v),
|
|
161
|
+
z.array(z.enum(["mainnet", "testnet"])).optional()
|
|
162
|
+
),
|
|
163
|
+
```
|
|
164
|
+
- In the handler, pass non-empty arrays to the service filter: `if (status?.length) listFilters.status = status`
|
|
165
|
+
|
|
148
166
|
**Body schemas:**
|
|
149
167
|
- `.strict()` on create/update bodies to reject unknown fields
|
|
150
168
|
- `.nullish()` on fields that can be explicitly cleared to null (not just `.optional()`)
|
|
@@ -346,7 +364,7 @@ res.status(200).json({ id, object: "blog_post", deleted: true })
|
|
|
346
364
|
| Property | Available on | Description |
|
|
347
365
|
|---|---|---|
|
|
348
366
|
| `req.validatedBody` | POST routes with `body` config | Validated request body |
|
|
349
|
-
| `req.validatedQuery` | Routes with `query` config | Validated query params (all params) |
|
|
367
|
+
| `req.validatedQuery` | Routes with `query` config | Validated query params (all params). **Can be `undefined` when request has no query string** — always destructure with `?? {}` fallback |
|
|
350
368
|
| `req.filterableFields` | Routes with `query` config | Query params minus pagination/fields/order |
|
|
351
369
|
| `req.queryConfig.fields` | Routes with `queryConfig` | Array of field strings to select |
|
|
352
370
|
| `req.queryConfig.pagination` | Routes with `queryConfig` | `{ skip, take, order }` |
|
|
@@ -748,4 +766,10 @@ const { additional_data, ...body } = req.validatedBody as any // ❌ defeats al
|
|
|
748
766
|
|
|
749
767
|
// RIGHT — validatedBody is already typed from the body schema; no cast needed
|
|
750
768
|
const { additional_data, ...body } = req.validatedBody // ✅ type inferred from defineRouteConfig
|
|
769
|
+
|
|
770
|
+
// WRONG — destructuring req.validatedQuery without fallback (undefined when no query params sent)
|
|
771
|
+
const { enabled, ...filters } = req.validatedQuery // ❌ crashes if request has no query string
|
|
772
|
+
|
|
773
|
+
// RIGHT — always provide fallback when destructuring validatedQuery
|
|
774
|
+
const { enabled, ...filters } = req.validatedQuery ?? {} // ✅ safe when no query params
|
|
751
775
|
```
|