sonamu 0.10.4 → 0.10.6
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/bin/cli.js +206 -187
- package/dist/cone/cone-generator.js +3 -9
- package/dist/database/knex.d.ts.map +1 -1
- package/dist/database/knex.js +2 -2
- package/dist/database/puri.d.ts +17 -0
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +102 -3
- package/dist/migration/code-generation.js +6 -6
- package/dist/migration/migrator.d.ts.map +1 -1
- package/dist/migration/migrator.js +51 -6
- package/dist/ui-web/assets/{index-CDd6xT-F.js → index-D0MHYbxl.js} +2 -2
- package/dist/ui-web/assets/index-Dx_JX4aQ.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/package.json +2 -2
- package/src/bin/cli.ts +283 -282
- package/src/cone/cone-generator.ts +2 -18
- package/src/database/__tests__/puri.test.ts +183 -0
- package/src/database/knex.ts +4 -1
- package/src/database/puri.ts +229 -2
- package/src/database/puri.types.test-d.ts +56 -0
- package/src/migration/code-generation.ts +5 -5
- package/src/migration/migrator.ts +81 -7
- package/src/skills/AGENTS.md +76 -50
- package/src/skills/sonamu/SKILL.md +46 -227
- package/src/skills/{sonamu/ai-agents.md → sonamu-ai-agents/SKILL.md} +3 -3
- package/src/skills/{sonamu/api.md → sonamu-api/SKILL.md} +3 -2
- package/src/skills/{sonamu/auth.md → sonamu-auth/SKILL.md} +9 -4
- package/src/skills/{sonamu/auth-plugins.md → sonamu-auth/references/plugins.md} +2 -7
- package/src/skills/sonamu-auth/references/user-id-migration-followups.md +192 -0
- package/src/skills/{sonamu/auth-migration.md → sonamu-auth/references/user-id-migration.md} +1 -195
- package/src/skills/sonamu-config/SKILL.md +203 -0
- package/src/skills/{sonamu → sonamu-config/references}/database.md +3 -8
- package/src/skills/sonamu-config/references/environments.md +178 -0
- package/src/skills/sonamu-config/references/server-options.md +400 -0
- package/src/skills/sonamu-entity/SKILL.md +180 -0
- package/src/skills/{sonamu/entity-validation-checklist.md → sonamu-entity/references/creation-workflow.md} +117 -8
- package/src/skills/sonamu-entity/references/design-guides.md +243 -0
- package/src/skills/sonamu-entity/references/field-types.md +170 -0
- package/src/skills/sonamu-entity/references/relations-detail.md +245 -0
- package/src/skills/{sonamu/entity-relations.md → sonamu-entity/references/relations.md} +1 -258
- package/src/skills/{sonamu → sonamu-entity/references}/subset.md +1 -11
- package/src/skills/sonamu-fixture/SKILL.md +180 -0
- package/src/skills/{sonamu/fixture-cli.md → sonamu-fixture/references/cli-usage.md} +5 -176
- package/src/skills/{sonamu → sonamu-fixture/references}/cone.md +4 -14
- package/src/skills/sonamu-frontend/SKILL.md +142 -0
- package/src/skills/sonamu-frontend/references/components.md +323 -0
- package/src/skills/sonamu-frontend/references/examples.md +64 -0
- package/src/skills/sonamu-frontend/references/hooks.md +273 -0
- package/src/skills/sonamu-frontend/references/runtime.md +165 -0
- package/src/skills/{sonamu → sonamu-frontend/references}/scaffolding.md +3 -8
- package/src/skills/{sonamu/i18n.md → sonamu-i18n/SKILL.md} +2 -2
- package/src/skills/{sonamu/migration.md → sonamu-migration/SKILL.md} +1 -1
- package/src/skills/{sonamu/naite.md → sonamu-naite/SKILL.md} +5 -5
- package/src/skills/sonamu-query/SKILL.md +48 -0
- package/src/skills/sonamu-query/references/model-patterns.md +390 -0
- package/src/skills/{sonamu → sonamu-query/references}/model.md +1 -401
- package/src/skills/{sonamu → sonamu-query/references}/puri.md +31 -243
- package/src/skills/sonamu-query/references/search.md +238 -0
- package/src/skills/{sonamu → sonamu-query/references}/upsert.md +1 -6
- package/src/skills/{sonamu/tasks.md → sonamu-tasks/SKILL.md} +1 -1
- package/src/skills/sonamu-testing/SKILL.md +251 -0
- package/src/skills/{sonamu/testing-devrunner.md → sonamu-testing/references/devrunner.md} +4 -9
- package/src/skills/sonamu-testing/references/helpers.md +185 -0
- package/src/skills/sonamu-testing/references/patterns.md +263 -0
- package/src/skills/sonamu-testing/references/pitfalls.md +588 -0
- package/src/skills/sonamu-testing/references/quick-start.md +285 -0
- package/src/skills/sonamu-testing/references/type-safety.md +172 -0
- package/src/skills/sonamu-testing/references/writing-plan.md +375 -0
- package/src/skills/{sonamu/vector.md → sonamu-vector/SKILL.md} +1 -1
- package/dist/ui-web/assets/index-Dx4ap5i4.css +0 -1
- package/src/skills/commands/sonamu-skills.md +0 -20
- package/src/skills/project/README.md +0 -19
- package/src/skills/project/architecture.md +0 -373
- package/src/skills/sonamu/cdd.md +0 -129
- package/src/skills/sonamu/config.md +0 -772
- package/src/skills/sonamu/create-sonamu.md +0 -208
- package/src/skills/sonamu/entity-basic.md +0 -678
- package/src/skills/sonamu/framework-change.md +0 -96
- package/src/skills/sonamu/frontend.md +0 -915
- package/src/skills/sonamu/project-init.md +0 -477
- package/src/skills/sonamu/skill-contribution.md +0 -247
- package/src/skills/sonamu/testing.md +0 -2163
|
@@ -1,2163 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: sonamu-testing
|
|
3
|
-
description: Writing Sonamu tests. bootstrap, test/testAs functions, Fixture creation, Naite.get() assertions, expectQuery/expectUB helpers, Mock patterns. Use when writing or structuring test code for Models and APIs.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Sonamu Test System
|
|
7
|
-
|
|
8
|
-
Sonamu provides a Vitest-based test environment. Each test is isolated in a transaction and automatically rolled back.
|
|
9
|
-
|
|
10
|
-
**Example project**: `sonamu/examples/miomock` - reference for real test code
|
|
11
|
-
|
|
12
|
-
**WARNING: Projects with 10 or more entities must use a batch strategy** (see "Large-Scale Project Strategy" below)
|
|
13
|
-
|
|
14
|
-
**Reference documents**:
|
|
15
|
-
|
|
16
|
-
- **Fixture CLI commands**: `fixture-cli.md` - fixture gen/fetch/explore usage, 3-Tier DB structure
|
|
17
|
-
- **Fixture creation tips**: "Fixture Data Creation Tips" section at the bottom of this document, or the "Practical Tips" section in `fixture-cli.md`
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Quick Start - Getting Started with Tests Quickly
|
|
22
|
-
|
|
23
|
-
**Prerequisites**: scaffolding completed, nullable field handling in types.ts completed
|
|
24
|
-
|
|
25
|
-
### Step 1: Extend test-helpers.ts
|
|
26
|
-
|
|
27
|
-
```typescript
|
|
28
|
-
// packages/api/src/application/__tests__/test-helpers.ts
|
|
29
|
-
|
|
30
|
-
import { User, UserSaveParams } from "../user/user.types";
|
|
31
|
-
import { Post, PostSaveParams } from "../post/post.types";
|
|
32
|
-
import { Comment, CommentSaveParams } from "../comment/comment.types";
|
|
33
|
-
import UserModel from "../user/user.model";
|
|
34
|
-
import PostModel from "../post/post.model";
|
|
35
|
-
import CommentModel from "../comment/comment.model";
|
|
36
|
-
|
|
37
|
-
// User helper
|
|
38
|
-
export async function createTestUser(params?: Partial<UserSaveParams>): Promise<number> {
|
|
39
|
-
const user: UserSaveParams = {
|
|
40
|
-
email: `test-${Date.now()}@example.com`,
|
|
41
|
-
name: "Test User",
|
|
42
|
-
...params,
|
|
43
|
-
};
|
|
44
|
-
const [id] = await UserModel.save([user]);
|
|
45
|
-
return id;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// User with dependencies (dependency chain)
|
|
49
|
-
export async function createTestUserWithDeps() {
|
|
50
|
-
const userId = await createTestUser();
|
|
51
|
-
return { userId };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Post helper
|
|
55
|
-
export async function createTestPost(
|
|
56
|
-
authorId: number,
|
|
57
|
-
params?: Partial<PostSaveParams>,
|
|
58
|
-
): Promise<number> {
|
|
59
|
-
const post: PostSaveParams = {
|
|
60
|
-
author_id: authorId,
|
|
61
|
-
title: "Test Post",
|
|
62
|
-
content: "Test content",
|
|
63
|
-
...params,
|
|
64
|
-
};
|
|
65
|
-
const [id] = await PostModel.save([post]);
|
|
66
|
-
return id;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Post with dependencies
|
|
70
|
-
export async function createTestPostWithDeps() {
|
|
71
|
-
const { userId } = await createTestUserWithDeps();
|
|
72
|
-
const postId = await createTestPost(userId);
|
|
73
|
-
return { userId, postId };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Comment helper
|
|
77
|
-
export async function createTestComment(
|
|
78
|
-
postId: number,
|
|
79
|
-
authorId: number,
|
|
80
|
-
params?: Partial<CommentSaveParams>,
|
|
81
|
-
): Promise<number> {
|
|
82
|
-
const comment: CommentSaveParams = {
|
|
83
|
-
post_id: postId,
|
|
84
|
-
author_id: authorId,
|
|
85
|
-
content: "Test comment",
|
|
86
|
-
...params,
|
|
87
|
-
};
|
|
88
|
-
const [id] = await CommentModel.save([comment]);
|
|
89
|
-
return id;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Comment with dependencies
|
|
93
|
-
export async function createTestCommentWithDeps() {
|
|
94
|
-
const { userId, postId } = await createTestPostWithDeps();
|
|
95
|
-
const commentId = await createTestComment(postId, userId);
|
|
96
|
-
return { userId, postId, commentId };
|
|
97
|
-
}
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
**CRITICAL patterns**:
|
|
101
|
-
|
|
102
|
-
- `createTestX()`: basic creation helper (overridable via params)
|
|
103
|
-
- `createTestXWithDeps()`: helper that automatically handles dependencies (creates all required data together)
|
|
104
|
-
- FK fields use the `_id` suffix (`author_id`, `post_id`)
|
|
105
|
-
- Returns: primarily returns ID; WithDeps returns an object with multiple IDs
|
|
106
|
-
|
|
107
|
-
**CRITICAL: All required fields must be included!**
|
|
108
|
-
|
|
109
|
-
Sonamu's `ubUpsert` uses PostgreSQL's `ON CONFLICT ... DO UPDATE` query.
|
|
110
|
-
Even for updates, **all required fields (fields with NOT NULL constraints)** must be included.
|
|
111
|
-
|
|
112
|
-
When required fields are missing:
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
// BAD - missing required field content
|
|
116
|
-
const post: PostSaveParams = {
|
|
117
|
-
author_id: authorId,
|
|
118
|
-
title: "Test",
|
|
119
|
-
// content missing! → ubUpsert ON CONFLICT UPDATE attempts to set NULL → DB error
|
|
120
|
-
};
|
|
121
|
-
// Error: null value in column "content" violates not-null constraint
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### Distinguishing Required vs Optional Fields
|
|
125
|
-
|
|
126
|
-
**1. Check entity.json**
|
|
127
|
-
|
|
128
|
-
```json
|
|
129
|
-
// post.entity.json
|
|
130
|
-
{
|
|
131
|
-
"props": [
|
|
132
|
-
{ "name": "id", "type": "integer" }, // auto-generated - exclude
|
|
133
|
-
{ "name": "title", "type": "string", "length": 255 }, // required! (no nullable)
|
|
134
|
-
{ "name": "content", "type": "string" }, // required! (no nullable)
|
|
135
|
-
{ "name": "category", "type": "string", "nullable": true }, // optional (nullable)
|
|
136
|
-
{ "name": "author_id", "type": "integer" }, // required! (FK, no nullable)
|
|
137
|
-
{ "name": "view_count", "type": "integer", "dbDefault": "0" }, // required but has DB default
|
|
138
|
-
{ "name": "created_at", "type": "date", "dbDefault": "CURRENT_TIMESTAMP" } // automatic
|
|
139
|
-
]
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
**Required fields**: Fields **without** `nullable: true`
|
|
144
|
-
|
|
145
|
-
- `title`, `content`, `author_id`
|
|
146
|
-
- **Must** provide default values in test-helpers.ts
|
|
147
|
-
|
|
148
|
-
**Optional fields**: Fields **with** `nullable: true`
|
|
149
|
-
|
|
150
|
-
- `category`
|
|
151
|
-
- Can be omitted in test-helpers.ts
|
|
152
|
-
|
|
153
|
-
**Excluded fields**:
|
|
154
|
-
|
|
155
|
-
- `id`: auto-increment (auto-generated on save)
|
|
156
|
-
- `created_at`: automatically set by dbDefault
|
|
157
|
-
- `view_count`: automatically set by dbDefault="0"
|
|
158
|
-
|
|
159
|
-
**2. Write test-helpers.ts**
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
export async function createTestPost(
|
|
163
|
-
authorId: number,
|
|
164
|
-
params?: Partial<PostSaveParams>,
|
|
165
|
-
): Promise<number> {
|
|
166
|
-
const post: PostSaveParams = {
|
|
167
|
-
// Required fields must be included (fields without nullable)
|
|
168
|
-
author_id: authorId,
|
|
169
|
-
title: "Test Post", // required!
|
|
170
|
-
content: "Test content", // required!
|
|
171
|
-
|
|
172
|
-
// Optional fields can be omitted (fields with nullable: true)
|
|
173
|
-
// category: null, // can be omitted
|
|
174
|
-
|
|
175
|
-
// Fields with dbDefault can also be omitted
|
|
176
|
-
// view_count: 0, // can be omitted since dbDefault="0"
|
|
177
|
-
|
|
178
|
-
...params, // allow override
|
|
179
|
-
};
|
|
180
|
-
const saved = await PostModel.save(post);
|
|
181
|
-
return saved.id;
|
|
182
|
-
}
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
**Rule summary**:
|
|
186
|
-
|
|
187
|
-
1. Fields without `nullable: true` in entity.json = required fields
|
|
188
|
-
2. Required fields **must** have default values in test-helpers.ts
|
|
189
|
-
3. `id`, `created_at`, fields with `dbDefault` can be excluded
|
|
190
|
-
4. Required fields are also needed for ubUpsert's ON CONFLICT UPDATE
|
|
191
|
-
|
|
192
|
-
### Step 2: Write the test file
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
// packages/api/src/application/post/__tests__/post.test.ts
|
|
196
|
-
|
|
197
|
-
import { bootstrap } from "sonamu";
|
|
198
|
-
import { describe, test, expect, vi } from "vitest";
|
|
199
|
-
import PostModel from "../post.model";
|
|
200
|
-
import { createTestPostWithDeps } from "../../__tests__/test-helpers";
|
|
201
|
-
|
|
202
|
-
bootstrap(vi); // CRITICAL: required!
|
|
203
|
-
|
|
204
|
-
describe("PostModel", () => {
|
|
205
|
-
describe("A. Create", () => {
|
|
206
|
-
test("create post", async () => {
|
|
207
|
-
const { userId, postId } = await createTestPostWithDeps();
|
|
208
|
-
|
|
209
|
-
const post = await PostModel.findById(postId, ["A"]);
|
|
210
|
-
expect(post.id).toBe(postId);
|
|
211
|
-
expect(post.author_id).toBe(userId);
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("B. Read", () => {
|
|
216
|
-
test("findById - Subset A", async () => {
|
|
217
|
-
const { postId } = await createTestPostWithDeps();
|
|
218
|
-
|
|
219
|
-
const post = await PostModel.findById(postId, ["A"]);
|
|
220
|
-
expect(post.id).toBe(postId);
|
|
221
|
-
expect(post).toHaveProperty("title");
|
|
222
|
-
expect(post).toHaveProperty("content");
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("findMany - list query", async () => {
|
|
226
|
-
await createTestPostWithDeps();
|
|
227
|
-
await createTestPostWithDeps();
|
|
228
|
-
|
|
229
|
-
const { rows } = await PostModel.findMany({ num: 10 });
|
|
230
|
-
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
describe("C. Update", () => {
|
|
235
|
-
test("update post", async () => {
|
|
236
|
-
const { postId } = await createTestPostWithDeps();
|
|
237
|
-
|
|
238
|
-
await PostModel.save([
|
|
239
|
-
{
|
|
240
|
-
id: postId,
|
|
241
|
-
title: "Updated Title",
|
|
242
|
-
},
|
|
243
|
-
]);
|
|
244
|
-
|
|
245
|
-
const updated = await PostModel.findById("A", postId);
|
|
246
|
-
expect(updated.title).toBe("Updated Title");
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
describe("D. Delete", () => {
|
|
251
|
-
test("delete post", async () => {
|
|
252
|
-
const { postId } = await createTestPostWithDeps();
|
|
253
|
-
|
|
254
|
-
await PostModel.del(postId);
|
|
255
|
-
|
|
256
|
-
const post = await PostModel.findById(postId, ["A"]);
|
|
257
|
-
expect(post).toBeNull();
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
describe("E. Business Logic", () => {
|
|
262
|
-
test("full process from post creation to adding a comment", async () => {
|
|
263
|
-
// 1. create post
|
|
264
|
-
const { userId, postId } = await createTestPostWithDeps({
|
|
265
|
-
title: "New Post",
|
|
266
|
-
content: "Content",
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// 2. another user writes a comment
|
|
270
|
-
const commenterId = await createTestUser();
|
|
271
|
-
const commentId = await createTestComment(postId, commenterId, {
|
|
272
|
-
content: "Great post!",
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// 3. fetch post (with comments)
|
|
276
|
-
const post = await PostModel.findById(postId, ["A"]);
|
|
277
|
-
expect(post.comments).toHaveLength(1);
|
|
278
|
-
expect(post.comments[0].id).toBe(commentId);
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
**Pattern summary**:
|
|
285
|
-
|
|
286
|
-
- `bootstrap(vi)` call is required
|
|
287
|
-
- `describe` + `test` pattern (order: A. Create, B. Read, C. Update, D. Delete, E. Business Logic)
|
|
288
|
-
- Use `createTestXWithDeps()` helper to automatically resolve dependencies
|
|
289
|
-
- The Business Logic section is the most important! (implements real business scenarios)
|
|
290
|
-
|
|
291
|
-
### Step 3: Run tests
|
|
292
|
-
|
|
293
|
-
```bash
|
|
294
|
-
# Start dev server if it's down
|
|
295
|
-
pnpm sonamu dev
|
|
296
|
-
|
|
297
|
-
# Tests during development (default)
|
|
298
|
-
pnpm sonamu test
|
|
299
|
-
pnpm sonamu test user.model
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
**Done!** See the sections below for detailed information.
|
|
303
|
-
|
|
304
|
-
---
|
|
305
|
-
|
|
306
|
-
## Pre-Test Writing Checklist
|
|
307
|
-
|
|
308
|
-
- [ ] **Confirm entity design is complete** - `pnpm db:migration` and `pnpm scaffolding` completed without errors
|
|
309
|
-
- [ ] **Plan test writing** - group entities by business process (→ see "Test Writing Plan" below)
|
|
310
|
-
- [ ] **Handle nullable fields in types.ts (FIRST!)** - immediately after entity creation, apply partial + extend handling for nullable fields (→ see "Tasks to Do Immediately After Entity Creation" below)
|
|
311
|
-
- [ ] **Prepare Seed Data** - base data required due to FK constraints (→ see "minimum seed data" in database.md)
|
|
312
|
-
- [ ] **Test helper functions** - prepare helpers for handling complex entity dependencies
|
|
313
|
-
- [ ] **For 10 or more entities** - plan batch strategy (see "Large-Scale Project Strategy" below)
|
|
314
|
-
|
|
315
|
-
## Core Test Writing Principles
|
|
316
|
-
|
|
317
|
-
### 1. Verify Actual Structure First
|
|
318
|
-
|
|
319
|
-
**CRITICAL: Always verify the actual entity structure before planning tests.**
|
|
320
|
-
|
|
321
|
-
Before writing tests, you must verify the following:
|
|
322
|
-
|
|
323
|
-
```typescript
|
|
324
|
-
// STEP 1: Check entity.json
|
|
325
|
-
// - actual field names and types
|
|
326
|
-
// - nullable status
|
|
327
|
-
// - enum value list
|
|
328
|
-
// - relation structure
|
|
329
|
-
|
|
330
|
-
// STEP 2: Check types.ts
|
|
331
|
-
// - partial settings in SaveParams
|
|
332
|
-
// - nullish handling for nullable fields
|
|
333
|
-
// - _ids arrays for ManyToMany relations
|
|
334
|
-
|
|
335
|
-
// STEP 3: Check sonamu.generated.ts
|
|
336
|
-
// - Enum type definitions
|
|
337
|
-
// - Subset type structure
|
|
338
|
-
// - BaseSchema structure
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
**Wrong approach:**
|
|
342
|
-
|
|
343
|
-
```typescript
|
|
344
|
-
// BAD - writing tests based on guesses
|
|
345
|
-
test("create user", async () => {
|
|
346
|
-
const [userId] = await UserModel.save([
|
|
347
|
-
{
|
|
348
|
-
name: "Test",
|
|
349
|
-
status: "active", // may actually be "normal"
|
|
350
|
-
role: "user", // may actually be "normal"
|
|
351
|
-
},
|
|
352
|
-
]);
|
|
353
|
-
});
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
**Correct approach:**
|
|
357
|
-
|
|
358
|
-
```typescript
|
|
359
|
-
// GOOD - write after checking entity.json
|
|
360
|
-
// 1. Check user.entity.json:
|
|
361
|
-
// - role: enum ["admin", "normal", "guest"]
|
|
362
|
-
// - status: enum ["active", "inactive"] with dbDefault: "active"
|
|
363
|
-
// - name: string (required)
|
|
364
|
-
// - email: string (nullable)
|
|
365
|
-
|
|
366
|
-
// 2. Check user.types.ts:
|
|
367
|
-
// - status, email are partial in SaveParams
|
|
368
|
-
|
|
369
|
-
// 3. Write test
|
|
370
|
-
test("create user", async () => {
|
|
371
|
-
const [userId] = await UserModel.save([
|
|
372
|
-
{
|
|
373
|
-
name: "Test",
|
|
374
|
-
role: "normal", // exact enum value from entity.json
|
|
375
|
-
// status can be omitted since it has dbDefault
|
|
376
|
-
// email can be omitted since it's nullable
|
|
377
|
-
},
|
|
378
|
-
]);
|
|
379
|
-
});
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
### 2. Understanding Subset Structure
|
|
383
|
-
|
|
384
|
-
**Access nested relations using dot notation.**
|
|
385
|
-
|
|
386
|
-
```typescript
|
|
387
|
-
// Check Subset definition in entity.json
|
|
388
|
-
{
|
|
389
|
-
"subsets": {
|
|
390
|
-
"A": [
|
|
391
|
-
"id",
|
|
392
|
-
"title",
|
|
393
|
-
"evaluation_form.id", // BelongsToOne relation
|
|
394
|
-
"evaluation_form.title",
|
|
395
|
-
"evaluation_form.category.id", // nested relation
|
|
396
|
-
"evaluation_form.category.name"
|
|
397
|
-
]
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Access in tests
|
|
402
|
-
test("fetch evaluation item", async () => {
|
|
403
|
-
const { itemId } = await createTestEvaluationItemWithDeps();
|
|
404
|
-
|
|
405
|
-
const item = await EvaluationItemModel.findById("A", itemId);
|
|
406
|
-
|
|
407
|
-
// CORRECT - nested access via dot notation
|
|
408
|
-
expect(item.evaluation_form.id).toBe(formId);
|
|
409
|
-
expect(item.evaluation_form.category.name).toBe("Competency Evaluation");
|
|
410
|
-
|
|
411
|
-
// WRONG - attempting direct FK access
|
|
412
|
-
// expect(item.evaluation_form_id).toBe(formId); // type error!
|
|
413
|
-
});
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
**Important rules:**
|
|
417
|
-
|
|
418
|
-
- FK of BelongsToOne relation is defined as `relation.id` form in Subset
|
|
419
|
-
- Access in tests as `entity.relation.field` form
|
|
420
|
-
- Direct `entity.relation_id` access is not possible (not included in Subset)
|
|
421
|
-
|
|
422
|
-
### 3. Handling DECIMAL Types
|
|
423
|
-
|
|
424
|
-
**DECIMAL types are returned from PostgreSQL with a `.00` suffix.**
|
|
425
|
-
|
|
426
|
-
```typescript
|
|
427
|
-
// entity.json
|
|
428
|
-
{
|
|
429
|
-
"props": [
|
|
430
|
-
{ "name": "salary", "type": "number", "precision": 10, "scale": 2 }
|
|
431
|
-
]
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Generated in migration
|
|
435
|
-
table.decimal("salary", 10, 2); // DECIMAL(10,2)
|
|
436
|
-
|
|
437
|
-
// Writing tests
|
|
438
|
-
test("fetch salary info", async () => {
|
|
439
|
-
const [userId] = await UserModel.save([{
|
|
440
|
-
name: "Test",
|
|
441
|
-
salary: 75000, // input: number
|
|
442
|
-
}]);
|
|
443
|
-
|
|
444
|
-
const user = await UserModel.findById("A", userId);
|
|
445
|
-
|
|
446
|
-
// WRONG - exact comparison may fail
|
|
447
|
-
// expect(user.salary).toBe(75000); // DB may return "75000.00"
|
|
448
|
-
|
|
449
|
-
// CORRECT - pattern matching with toMatch()
|
|
450
|
-
expect(String(user.salary)).toMatch(/^75000(\.00)?$/);
|
|
451
|
-
|
|
452
|
-
// Or convert to number and compare
|
|
453
|
-
expect(Number(user.salary)).toBe(75000);
|
|
454
|
-
|
|
455
|
-
// Or range check
|
|
456
|
-
expect(user.salary).toBeGreaterThanOrEqual(74999.99);
|
|
457
|
-
expect(user.salary).toBeLessThanOrEqual(75000.01);
|
|
458
|
-
});
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
**DECIMAL type comparison patterns:**
|
|
462
|
-
|
|
463
|
-
```typescript
|
|
464
|
-
// Pattern 1: string pattern matching
|
|
465
|
-
expect(String(value)).toMatch(/^1234\.56$/);
|
|
466
|
-
expect(String(value)).toMatch(/^1234(\.56)?$/); // .56 optional
|
|
467
|
-
|
|
468
|
-
// Pattern 2: convert to number and compare
|
|
469
|
-
expect(Number(value)).toBe(1234.56);
|
|
470
|
-
|
|
471
|
-
// Pattern 3: range check (considering floating point errors)
|
|
472
|
-
expect(value).toBeCloseTo(1234.56, 2); // up to 2 decimal places
|
|
473
|
-
|
|
474
|
-
// Pattern 4: toMatchObject (when comparing objects)
|
|
475
|
-
expect(result).toMatchObject({
|
|
476
|
-
salary: expect.any(Number), // type check only
|
|
477
|
-
});
|
|
478
|
-
```
|
|
479
|
-
|
|
480
|
-
## Enum Value Usage Rules
|
|
481
|
-
|
|
482
|
-
**CRITICAL: Only use enum values defined in entity.json.**
|
|
483
|
-
|
|
484
|
-
### Rules
|
|
485
|
-
|
|
486
|
-
1. Check the exact value list for enum fields in entity.json
|
|
487
|
-
2. If possible, use TypeScript enum types from `sonamu.generated.ts` (type-safe)
|
|
488
|
-
3. Set valid enum values as defaults in test-helpers.ts
|
|
489
|
-
4. Do not use arbitrary strings
|
|
490
|
-
|
|
491
|
-
```typescript
|
|
492
|
-
// WRONG: written based on guesses
|
|
493
|
-
role: "user"; // entity.json defines it as "normal"
|
|
494
|
-
status: "in_progress"; // entity.json defines it as "pending"
|
|
495
|
-
|
|
496
|
-
// CORRECT: written after checking entity.json
|
|
497
|
-
role: "normal"; // exact value from entity.json
|
|
498
|
-
status: "pending"; // exact value from entity.json
|
|
499
|
-
|
|
500
|
-
// BEST: use TypeScript enum
|
|
501
|
-
import { UserRoleEnum } from "../sonamu.generated";
|
|
502
|
-
role: UserRoleEnum.normal;
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
**Core principle: entity.json is the Single Source of Truth.**
|
|
506
|
-
|
|
507
|
-
## Test Writing Plan
|
|
508
|
-
|
|
509
|
-
### Planning Based on Entity Design Prompt
|
|
510
|
-
|
|
511
|
-
After entity design is complete (confirming migration + scaffolding succeed), group tests according to **the business processes and data flows specified at the time of entity design**.
|
|
512
|
-
|
|
513
|
-
**CRITICAL:** Group tests by **business flow units**, not by simple alphabetical order or individual entities.
|
|
514
|
-
|
|
515
|
-
### Step 1: Re-examine the Entity Design Prompt
|
|
516
|
-
|
|
517
|
-
Extract the following from the prompt written at the time of the design request:
|
|
518
|
-
|
|
519
|
-
- Business process flow
|
|
520
|
-
- Relationships between entities (relations)
|
|
521
|
-
- Data creation order
|
|
522
|
-
- Key usage scenarios
|
|
523
|
-
|
|
524
|
-
### Step 2: Group by Business Process
|
|
525
|
-
|
|
526
|
-
Group entities by **business flow units**, not simple priority.
|
|
527
|
-
|
|
528
|
-
**Customer consultation system example:**
|
|
529
|
-
|
|
530
|
-
```
|
|
531
|
-
Group 1: Core Infrastructure
|
|
532
|
-
Organization (related agency)
|
|
533
|
-
└─ User
|
|
534
|
-
└─ LoginHistory
|
|
535
|
-
|
|
536
|
-
Business flow: register agency → create user → login
|
|
537
|
-
Test order: Organization → User → LoginHistory
|
|
538
|
-
|
|
539
|
-
Group 2: Damage Type Management
|
|
540
|
-
DamageType (self-referencing)
|
|
541
|
-
└─ CounterMeasure
|
|
542
|
-
|
|
543
|
-
Business flow: build damage type hierarchy → write countermeasures for each type
|
|
544
|
-
Test order: DamageType → CounterMeasure
|
|
545
|
-
|
|
546
|
-
Group 3: Consultation Process (core business)
|
|
547
|
-
User (applicant) + User (counselor) + DamageType
|
|
548
|
-
└─ Consultation
|
|
549
|
-
├─ ConsultationChannelLog
|
|
550
|
-
└─ ConsultationHistory
|
|
551
|
-
|
|
552
|
-
Business flow:
|
|
553
|
-
1. Applicant submits consultation request
|
|
554
|
-
2. Assign counselor
|
|
555
|
-
3. Classify damage type
|
|
556
|
-
4. Communication by channel (online/phone/SMS/KakaoTalk)
|
|
557
|
-
5. Record status change history
|
|
558
|
-
|
|
559
|
-
Test order: Consultation → ConsultationChannelLog → ConsultationHistory
|
|
560
|
-
|
|
561
|
-
Group 4: Content Management (independent)
|
|
562
|
-
FAQ
|
|
563
|
-
Banner
|
|
564
|
-
Material
|
|
565
|
-
Notice
|
|
566
|
-
|
|
567
|
-
Business flow: independent CRUD for each
|
|
568
|
-
Test order: any order (can be written in parallel)
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
### Step 3: Work Order per Group
|
|
572
|
-
|
|
573
|
-
**For each group:**
|
|
574
|
-
|
|
575
|
-
1. **Modify types.ts** - handle nullable fields for all entities in the group at once
|
|
576
|
-
2. **Extend test-helpers.ts** - write helper functions for entities in the group together
|
|
577
|
-
3. **Write test files** - write in dependency order within the group
|
|
578
|
-
4. **Business Logic tests** - implement real business scenarios (the key!)
|
|
579
|
-
5. **Verify tests pass** - proceed to next group
|
|
580
|
-
|
|
581
|
-
**test-helpers.ts example (considering dependency chains):**
|
|
582
|
-
|
|
583
|
-
```typescript
|
|
584
|
-
// Write helpers considering dependency chains
|
|
585
|
-
export async function createTestUserWithDeps() {
|
|
586
|
-
const organizationId = await createTestOrganization();
|
|
587
|
-
const userId = await createTestUser(organizationId);
|
|
588
|
-
return { organizationId, userId };
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
export async function createTestConsultationWithDeps() {
|
|
592
|
-
const { userId: applicantId } = await createTestUserWithDeps({
|
|
593
|
-
role: "applicant",
|
|
594
|
-
});
|
|
595
|
-
const { userId: counselorId } = await createTestUserWithDeps({
|
|
596
|
-
role: "counselor",
|
|
597
|
-
});
|
|
598
|
-
const damageTypeId = await createTestDamageType(null);
|
|
599
|
-
const consultationId = await createTestConsultation(applicantId, counselorId, damageTypeId);
|
|
600
|
-
return { applicantId, counselorId, damageTypeId, consultationId };
|
|
601
|
-
}
|
|
602
|
-
```
|
|
603
|
-
|
|
604
|
-
### Step 4: Business Logic Tests (the key!)
|
|
605
|
-
|
|
606
|
-
**IMPORTANT:** The E. Business Logic section is the most important.
|
|
607
|
-
|
|
608
|
-
In this section:
|
|
609
|
-
|
|
610
|
-
- Implement **real business scenarios** specified in the entity design prompt
|
|
611
|
-
- Test **interactions** between entities
|
|
612
|
-
- Validate **data flows**
|
|
613
|
-
|
|
614
|
-
This is what differentiates it from simple CRUD tests, and it's **the core that validates design intent**.
|
|
615
|
-
|
|
616
|
-
**Business Logic test example (consultation process):**
|
|
617
|
-
|
|
618
|
-
```typescript
|
|
619
|
-
describe("E. Business Logic", () => {
|
|
620
|
-
test("full process from consultation submission to completion", async () => {
|
|
621
|
-
// 1. submit consultation + create dependencies
|
|
622
|
-
const { consultationId, counselorId } = await createTestConsultationWithDeps();
|
|
623
|
-
// 2. record channel logs (online submission, phone consultation)
|
|
624
|
-
await createTestConsultationChannelLog(consultationId, {
|
|
625
|
-
channel: "online",
|
|
626
|
-
});
|
|
627
|
-
await createTestConsultationChannelLog(consultationId, {
|
|
628
|
-
channel: "phone",
|
|
629
|
-
});
|
|
630
|
-
// 3. record status history
|
|
631
|
-
await createTestConsultationHistory(consultationId, counselorId, {
|
|
632
|
-
status: "consulting",
|
|
633
|
-
});
|
|
634
|
-
// 4. complete consultation
|
|
635
|
-
await ConsultationModel.save([{ id: consultationId, status: "completed" }]);
|
|
636
|
-
// 5. verify: status, 2 channel logs, history
|
|
637
|
-
const c = await ConsultationModel.findById("A", consultationId);
|
|
638
|
-
expect(c.status).toBe("completed");
|
|
639
|
-
});
|
|
640
|
-
});
|
|
641
|
-
```
|
|
642
|
-
|
|
643
|
-
### Notes
|
|
644
|
-
|
|
645
|
-
**DO:**
|
|
646
|
-
|
|
647
|
-
- Always reference the entity design prompt
|
|
648
|
-
- Group by business process flow
|
|
649
|
-
- Test order that considers dependency order
|
|
650
|
-
- Business Logic tests based on real usage scenarios
|
|
651
|
-
- Clearly implement dependency chains in test-helpers
|
|
652
|
-
|
|
653
|
-
**DON'T:**
|
|
654
|
-
|
|
655
|
-
- Write tests in simple alphabetical order
|
|
656
|
-
- Only test entities individually (missing integration perspective)
|
|
657
|
-
- Set priorities unrelated to business flow
|
|
658
|
-
- Write tests that ignore the intent of the entity design
|
|
659
|
-
|
|
660
|
-
### Checklist per Group
|
|
661
|
-
|
|
662
|
-
When test writing for a process group is complete:
|
|
663
|
-
|
|
664
|
-
- [ ] Nullable field handling in types.ts completed for all entities in the group
|
|
665
|
-
- [ ] test-helpers written reflecting dependency chains within the group
|
|
666
|
-
- [ ] Module test file written for each entity in the group
|
|
667
|
-
- [ ] **Key business scenarios included in Business Logic tests**
|
|
668
|
-
- [ ] All tests pass confirmed (`pnpm sonamu test`)
|
|
669
|
-
- [ ] Proceed to next group
|
|
670
|
-
|
|
671
|
-
## Tasks to Do Immediately After Entity Creation
|
|
672
|
-
|
|
673
|
-
### Handling nullable Fields in types.ts (Required)
|
|
674
|
-
|
|
675
|
-
After creating an entity and generating types.ts with `sonamu generate`, immediately handle nullable fields **before writing tests**.
|
|
676
|
-
|
|
677
|
-
#### Work Order
|
|
678
|
-
|
|
679
|
-
1. Run `sonamu generate`
|
|
680
|
-
2. Check the generated `*.types.ts` file
|
|
681
|
-
3. Apply partial + extend + nullish handling for nullable fields
|
|
682
|
-
4. Start writing tests
|
|
683
|
-
|
|
684
|
-
#### Fields to Process
|
|
685
|
-
|
|
686
|
-
- All fields with `nullable: true`
|
|
687
|
-
- Fields with `dbDefault` (`.optional().default(value)`)
|
|
688
|
-
- FK relation fields that are nullable
|
|
689
|
-
|
|
690
|
-
#### Practical Example
|
|
691
|
-
|
|
692
|
-
**STEP 1: File generated after running sonamu generate**
|
|
693
|
-
|
|
694
|
-
```typescript
|
|
695
|
-
// faq.types.ts (auto-generated)
|
|
696
|
-
import type { z } from "zod"; // WRONG: type import
|
|
697
|
-
import { FAQBaseListParams, FAQBaseSchema } from "../sonamu.generated";
|
|
698
|
-
|
|
699
|
-
export const FAQListParams = FAQBaseListParams;
|
|
700
|
-
export type FAQListParams = z.infer<typeof FAQListParams>;
|
|
701
|
-
|
|
702
|
-
export const FAQSaveParams = FAQBaseSchema.partial({
|
|
703
|
-
id: true,
|
|
704
|
-
created_at: true,
|
|
705
|
-
updated_at: true,
|
|
706
|
-
});
|
|
707
|
-
export type FAQSaveParams = z.infer<typeof FAQSaveParams>;
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
**STEP 2: Immediate fix (nullable fields + Zod import handling)**
|
|
711
|
-
|
|
712
|
-
```typescript
|
|
713
|
-
// faq.types.ts (fix complete)
|
|
714
|
-
import { z } from "zod"; // CORRECT: change to regular import
|
|
715
|
-
import { FAQBaseListParams, FAQBaseSchema } from "../sonamu.generated";
|
|
716
|
-
|
|
717
|
-
export const FAQListParams = FAQBaseListParams;
|
|
718
|
-
export type FAQListParams = z.infer<typeof FAQListParams>;
|
|
719
|
-
|
|
720
|
-
export const FAQSaveParams = FAQBaseSchema.partial({
|
|
721
|
-
id: true,
|
|
722
|
-
created_at: true,
|
|
723
|
-
updated_at: true,
|
|
724
|
-
// add nullable fields
|
|
725
|
-
category: true,
|
|
726
|
-
order_num: true,
|
|
727
|
-
}).extend({
|
|
728
|
-
// redefine nullable fields as nullish
|
|
729
|
-
category: z.string().nullish(), // string | null | undefined
|
|
730
|
-
order_num: z.number().nullish(), // number | null | undefined
|
|
731
|
-
updated_at: z.date().nullish(), // date | null | undefined
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
export type FAQSaveParams = z.infer<typeof FAQSaveParams>;
|
|
735
|
-
```
|
|
736
|
-
|
|
737
|
-
#### Why Is This Necessary?
|
|
738
|
-
|
|
739
|
-
**Problem:** Zod's `nullable()` gives `T | null` but it's still required.
|
|
740
|
-
|
|
741
|
-
```typescript
|
|
742
|
-
// entity.json
|
|
743
|
-
{ "name": "category", "type": "string", "nullable": true }
|
|
744
|
-
|
|
745
|
-
// Generated BaseSchema
|
|
746
|
-
z.object({
|
|
747
|
-
category: z.string().nullable(), // string | null (required!)
|
|
748
|
-
})
|
|
749
|
-
|
|
750
|
-
// applying partial only
|
|
751
|
-
.partial({ category: true }) // category?: string | null
|
|
752
|
-
|
|
753
|
-
// WRONG: undefined cannot be assigned to string | null
|
|
754
|
-
const [id] = await FAQModel.save([{
|
|
755
|
-
question: "Question",
|
|
756
|
-
answer: "Answer",
|
|
757
|
-
// omitting category causes type error!
|
|
758
|
-
}]);
|
|
759
|
-
```
|
|
760
|
-
|
|
761
|
-
**Solution:** Combination of `partial()` + `extend()` + `nullish()`
|
|
762
|
-
|
|
763
|
-
```typescript
|
|
764
|
-
// CORRECT: proper handling
|
|
765
|
-
FAQBaseSchema.partial({ category: true }).extend({
|
|
766
|
-
category: z.string().nullish(),
|
|
767
|
-
}); // string | null | undefined
|
|
768
|
-
|
|
769
|
-
// Can freely omit in tests
|
|
770
|
-
const [id] = await FAQModel.save([
|
|
771
|
-
{
|
|
772
|
-
question: "Question",
|
|
773
|
-
answer: "Answer",
|
|
774
|
-
// category can be omitted!
|
|
775
|
-
},
|
|
776
|
-
]);
|
|
777
|
-
```
|
|
778
|
-
|
|
779
|
-
#### Application Criteria
|
|
780
|
-
|
|
781
|
-
| Field type | Handling |
|
|
782
|
-
| -------------------------------- | ------------------------------- |
|
|
783
|
-
| `id`, `created_at`, `updated_at` | Always partial (auto-generated) |
|
|
784
|
-
| Fields with `dbDefault` | `.optional().default(value)` |
|
|
785
|
-
| Fields with `nullable: true` | partial + extend + `.nullish()` |
|
|
786
|
-
| Required fields | Excluded from partial |
|
|
787
|
-
|
|
788
|
-
#### Checklist
|
|
789
|
-
|
|
790
|
-
- [ ] Change `import type { z }` to `import { z }`
|
|
791
|
-
- [ ] Add nullable fields to partial
|
|
792
|
-
- [ ] Redefine as nullish via extend
|
|
793
|
-
- [ ] Use `.optional().default()` for dbDefault fields
|
|
794
|
-
- [ ] Confirm required fields are excluded from partial
|
|
795
|
-
|
|
796
|
-
**Detailed type safety guide:** See "TypeScript Type Safety" and "Type Safety Notes" sections below
|
|
797
|
-
|
|
798
|
-
## TypeScript Type Safety
|
|
799
|
-
|
|
800
|
-
### Optional Chaining Required When Indexing Arrays
|
|
801
|
-
|
|
802
|
-
When accessing a property after indexing into an array, you must use optional chaining (`?.`).
|
|
803
|
-
|
|
804
|
-
**Reason:**
|
|
805
|
-
|
|
806
|
-
- Array indexing (`array[0]`, `array[1]`, etc.) can always return `undefined`
|
|
807
|
-
- TypeScript infers the type of `array[0]` as `T | undefined`
|
|
808
|
-
- Accessing a property without optional chaining causes a compile error
|
|
809
|
-
|
|
810
|
-
**Wrong:**
|
|
811
|
-
|
|
812
|
-
```typescript
|
|
813
|
-
// Type error: Object is possibly 'undefined'
|
|
814
|
-
expect(list.rows[0].title).toBe("test");
|
|
815
|
-
expect(searchResults.rows[0].name).toContain("keyword");
|
|
816
|
-
```
|
|
817
|
-
|
|
818
|
-
**Correct:**
|
|
819
|
-
|
|
820
|
-
```typescript
|
|
821
|
-
// Use optional chaining
|
|
822
|
-
expect(list.rows[0]?.title).toBe("test");
|
|
823
|
-
expect(searchResults.rows[0]?.name).toContain("keyword");
|
|
824
|
-
|
|
825
|
-
// Or verify existence first, then access
|
|
826
|
-
expect(list.rows.length).toBeGreaterThanOrEqual(1);
|
|
827
|
-
expect(list.rows[0].title).toBe("test"); // now safe
|
|
828
|
-
```
|
|
829
|
-
|
|
830
|
-
### Recommended Patterns
|
|
831
|
-
|
|
832
|
-
When accessing array elements in test code:
|
|
833
|
-
|
|
834
|
-
**Pattern 1: Use optional chaining**
|
|
835
|
-
|
|
836
|
-
```typescript
|
|
837
|
-
const result = await Model.findMany("A", { num: 10, page: 1 });
|
|
838
|
-
expect(result.rows[0]?.field).toBe(expectedValue);
|
|
839
|
-
```
|
|
840
|
-
|
|
841
|
-
**Pattern 2: Verify length, then access**
|
|
842
|
-
|
|
843
|
-
```typescript
|
|
844
|
-
const result = await Model.findMany("A", { num: 10, page: 1 });
|
|
845
|
-
expect(result.rows.length).toBeGreaterThanOrEqual(1);
|
|
846
|
-
expect(result.rows[0].field).toBe(expectedValue); // type-safe
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
**Pattern 3: Optional chaining required when using find()**
|
|
850
|
-
|
|
851
|
-
```typescript
|
|
852
|
-
const list = await Model.findMany("A", { num: 10, page: 1 });
|
|
853
|
-
const item = list.rows.find((r) => r.id === targetId);
|
|
854
|
-
expect(item?.field).toBe(expectedValue); // find() can return undefined
|
|
855
|
-
```
|
|
856
|
-
|
|
857
|
-
### General Rules
|
|
858
|
-
|
|
859
|
-
- Property access after array indexing: `array[0]?.property`
|
|
860
|
-
- Results of `find()`, `filter()[0]`, etc.: always use `?.`
|
|
861
|
-
- Nested object access: `obj.nested?.deep?.property`
|
|
862
|
-
- Non-null assertion (`!`) only when certain
|
|
863
|
-
|
|
864
|
-
## Model Basic Methods (Test Targets)
|
|
865
|
-
|
|
866
|
-
Sonamu Model provides the following methods by default. Tests are written targeting these methods:
|
|
867
|
-
|
|
868
|
-
| Method | Purpose | Returns |
|
|
869
|
-
| -------------------------- | ---------------------- | -------------------------------- |
|
|
870
|
-
| `findById(subset, id)` | Fetch single record | `Promise<Subset>` |
|
|
871
|
-
| `findMany(subset, params)` | Fetch list | `Promise<ListResult<Subset>>` |
|
|
872
|
-
| `save(rows)` | Create/update (upsert) | `Promise<number[]>` (ids) |
|
|
873
|
-
| `del(ids)` | Delete | `Promise<number>` (delete count) |
|
|
874
|
-
|
|
875
|
-
**Note:** It's `del`, not `delete`. This avoids JavaScript reserved words.
|
|
876
|
-
|
|
877
|
-
## Large-Scale Project Strategy (10 or more entities)
|
|
878
|
-
|
|
879
|
-
**CRITICAL: Do not work on all entities at once if a project has 10 or more entities.**
|
|
880
|
-
|
|
881
|
-
### Problems
|
|
882
|
-
|
|
883
|
-
- Working on 55 entities at once causes context confusion
|
|
884
|
-
- Serious risk of errors such as modifying the wrong file or deleting required content
|
|
885
|
-
- Cannot track relationships, lose direction while writing tests
|
|
886
|
-
|
|
887
|
-
### Solution: Batch Work Units
|
|
888
|
-
|
|
889
|
-
**Rule: Group related entities together and work in batches of 5–10**
|
|
890
|
-
|
|
891
|
-
```
|
|
892
|
-
Batch 1: User, Institution, Role related (5 entities)
|
|
893
|
-
→ Tests complete → Commit
|
|
894
|
-
|
|
895
|
-
Batch 2: Survey, Question, Response related (7 entities)
|
|
896
|
-
→ Tests complete → Commit
|
|
897
|
-
|
|
898
|
-
Batch 3: Report, Statistics related (6 entities)
|
|
899
|
-
→ Tests complete → Commit
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
### Batch Grouping Criteria
|
|
903
|
-
|
|
904
|
-
**Grouping by domain (recommended):**
|
|
905
|
-
|
|
906
|
-
```
|
|
907
|
-
Auth/Permissions: User, Role, Permission, Session
|
|
908
|
-
Surveys: Survey, Question, Choice, Response
|
|
909
|
-
Reports: Report, Chart, Export
|
|
910
|
-
Administration: Institution, Department, Settings
|
|
911
|
-
```
|
|
912
|
-
|
|
913
|
-
**Grouping by dependencies:**
|
|
914
|
-
|
|
915
|
-
```
|
|
916
|
-
1st: Independent entities (User, Institution, etc.)
|
|
917
|
-
2nd: Entities depending on 1st (Survey → Institution)
|
|
918
|
-
3rd: Entities depending on 2nd (Question → Survey)
|
|
919
|
-
```
|
|
920
|
-
|
|
921
|
-
### Batch Work Process
|
|
922
|
-
|
|
923
|
-
**For each batch:**
|
|
924
|
-
|
|
925
|
-
1. List entities in the batch explicitly
|
|
926
|
-
2. Write test helpers (createTest...)
|
|
927
|
-
3. Complete tests for all entities
|
|
928
|
-
4. Confirm all tests pass
|
|
929
|
-
5. **Git commit, then proceed to next batch**
|
|
930
|
-
|
|
931
|
-
**Between-batch checklist:**
|
|
932
|
-
|
|
933
|
-
- [ ] All tests in current batch pass
|
|
934
|
-
- [ ] Previous batch tests still pass (prevent regression)
|
|
935
|
-
- [ ] Commit complete (establish rollback point)
|
|
936
|
-
|
|
937
|
-
### Declare Before Starting Work
|
|
938
|
-
|
|
939
|
-
**IMPORTANT: Declare explicitly before starting each batch**
|
|
940
|
-
|
|
941
|
-
```
|
|
942
|
-
"Starting batch 1: User, Institution, Role entities (5)
|
|
943
|
-
- User: write user.model.test.ts
|
|
944
|
-
- Institution: write institution.model.test.ts
|
|
945
|
-
- Role: write role.model.test.ts
|
|
946
|
-
Only work on files to be modified, do not touch other files
|
|
947
|
-
Shall we proceed?"
|
|
948
|
-
```
|
|
949
|
-
|
|
950
|
-
### Warning Signs
|
|
951
|
-
|
|
952
|
-
**Stop work immediately** if any of the following occur:
|
|
953
|
-
|
|
954
|
-
- Attempting to modify entities outside the batch scope
|
|
955
|
-
- Asking the same question repeatedly
|
|
956
|
-
- Confusing entity relationships
|
|
957
|
-
- Trying to re-modify files already completed
|
|
958
|
-
|
|
959
|
-
## Running Tests
|
|
960
|
-
|
|
961
|
-
**Principle: Use `pnpm sonamu test` during development.** Assume the dev server is always running. If the dev server is down, start it first with `pnpm sonamu dev`, then run tests. Use `pnpm test` only in CI environments.
|
|
962
|
-
|
|
963
|
-
```bash
|
|
964
|
-
# Check dev server (start if it's down)
|
|
965
|
-
pnpm sonamu dev
|
|
966
|
-
|
|
967
|
-
# Tests during development (default)
|
|
968
|
-
pnpm sonamu test
|
|
969
|
-
pnpm sonamu test user.model
|
|
970
|
-
pnpm sonamu test user.model -p "findMany"
|
|
971
|
-
|
|
972
|
-
# CI environments only
|
|
973
|
-
pnpm test
|
|
974
|
-
```
|
|
975
|
-
|
|
976
|
-
### DevRunner — `sonamu test` (Default Test Execution Method)
|
|
977
|
-
|
|
978
|
-
`sonamu test` runs tests through a Vitest Node API instance that resides inside the `sonamu dev` process. Instead of starting Vitest fresh each time, it reuses an already-initialized instance, making execution 3.2x faster, and it integrates with HMR so tests always run against the latest code immediately after source changes.
|
|
979
|
-
|
|
980
|
-
#### Prerequisites
|
|
981
|
-
|
|
982
|
-
**1. Enable devRunner in sonamu.config.ts:**
|
|
983
|
-
|
|
984
|
-
```typescript
|
|
985
|
-
export default defineConfig({
|
|
986
|
-
test: {
|
|
987
|
-
devRunner: {
|
|
988
|
-
enabled: true,
|
|
989
|
-
// routePrefix: "/__test__", // optional, default value
|
|
990
|
-
// vitestConfigPath: undefined, // optional, default: vitest.config.ts (relative to api-root)
|
|
991
|
-
},
|
|
992
|
-
},
|
|
993
|
-
});
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
Configuration type (`SonamuDevRunnerConfig`):
|
|
997
|
-
|
|
998
|
-
- `enabled: boolean` — Whether to enable DevRunner (default: false)
|
|
999
|
-
- `routePrefix?: string` — Test endpoint path prefix (default: `/__test__`)
|
|
1000
|
-
- `vitestConfigPath?: string` — vitest.config.ts path (relative to api-root)
|
|
1001
|
-
|
|
1002
|
-
**2. Start the dev server:**
|
|
1003
|
-
|
|
1004
|
-
```bash
|
|
1005
|
-
sonamu dev # or pnpm dev
|
|
1006
|
-
```
|
|
1007
|
-
|
|
1008
|
-
When the dev server starts, `DevVitestManager` is automatically initialized under the `isLocal() && devRunner.enabled` condition, and test endpoints are registered with Fastify.
|
|
1009
|
-
|
|
1010
|
-
#### CLI Usage
|
|
1011
|
-
|
|
1012
|
-
```bash
|
|
1013
|
-
# Run all tests
|
|
1014
|
-
sonamu test
|
|
1015
|
-
|
|
1016
|
-
# Specify file (matched by partial filename — uses globTestSpecifications)
|
|
1017
|
-
sonamu test user.model
|
|
1018
|
-
|
|
1019
|
-
# Multiple files
|
|
1020
|
-
sonamu test user.model order.model
|
|
1021
|
-
|
|
1022
|
-
# Run specific test cases only (test name pattern)
|
|
1023
|
-
sonamu test user.model --pattern "findMany"
|
|
1024
|
-
sonamu test user.model -p "findMany"
|
|
1025
|
-
|
|
1026
|
-
# Print Naite traces
|
|
1027
|
-
sonamu test user.model --traces
|
|
1028
|
-
sonamu test user.model -t
|
|
1029
|
-
|
|
1030
|
-
# Combine file + pattern + trace
|
|
1031
|
-
sonamu test user.model -p "findMany" -t
|
|
1032
|
-
```
|
|
1033
|
-
|
|
1034
|
-
Argument processing rules:
|
|
1035
|
-
|
|
1036
|
-
- `--pattern` / `-p`: test name string filter (`setGlobalTestNamePattern` → `resetGlobalTestNamePattern` after execution)
|
|
1037
|
-
- `--traces` / `-t`: boolean flag, enables Naite trace output
|
|
1038
|
-
- Arguments not starting with `-`: treated as file list
|
|
1039
|
-
- Multiple files allowed
|
|
1040
|
-
- `ok: false` in server response is reflected as exit code 1
|
|
1041
|
-
|
|
1042
|
-
**→ Naite traces, HMR integration, HTTP API, internal architecture, performance comparison, troubleshooting details: `testing-devrunner.md`**
|
|
1043
|
-
|
|
1044
|
-
## sonamu.config.ts Test Configuration and Config Files
|
|
1045
|
-
|
|
1046
|
-
**→ Configuration type definitions, DevRunner/parallel settings, activation conditions, parallel DB flow, vitest.config.ts/global.ts details: `testing-devrunner.md`**
|
|
1047
|
-
|
|
1048
|
-
Key settings summary only:
|
|
1049
|
-
|
|
1050
|
-
```typescript
|
|
1051
|
-
// sonamu.config.ts
|
|
1052
|
-
export default defineConfig({
|
|
1053
|
-
test: {
|
|
1054
|
-
devRunner: { enabled: true }, // required to use pnpm sonamu test
|
|
1055
|
-
// parallel: true, // optional: separate DB per worker
|
|
1056
|
-
// maxWorkers: 4, // optional: number of parallel workers
|
|
1057
|
-
},
|
|
1058
|
-
});
|
|
1059
|
-
```
|
|
1060
|
-
|
|
1061
|
-
## Test Basic Patterns
|
|
1062
|
-
|
|
1063
|
-
### bootstrap
|
|
1064
|
-
|
|
1065
|
-
`bootstrap(vi)` call required in all test files:
|
|
1066
|
-
|
|
1067
|
-
```typescript
|
|
1068
|
-
import { bootstrap, test } from "sonamu/test";
|
|
1069
|
-
import { describe, expect, vi } from "vitest";
|
|
1070
|
-
|
|
1071
|
-
bootstrap(vi);
|
|
1072
|
-
|
|
1073
|
-
describe("MyTest", () => {
|
|
1074
|
-
test("test case", async () => {
|
|
1075
|
-
// ...
|
|
1076
|
-
});
|
|
1077
|
-
});
|
|
1078
|
-
```
|
|
1079
|
-
|
|
1080
|
-
**bootstrap options:**
|
|
1081
|
-
|
|
1082
|
-
```typescript
|
|
1083
|
-
// Default: forTesting: true (fast, skips Syncer/Task)
|
|
1084
|
-
bootstrap(vi);
|
|
1085
|
-
|
|
1086
|
-
// forTesting: false - full initialization (loads Syncer, Task, EntityManager, etc.)
|
|
1087
|
-
// Used in tests for migrator, syncer, template, etc.
|
|
1088
|
-
bootstrap(vi, { forTesting: false });
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
### test vs testAs
|
|
1092
|
-
|
|
1093
|
-
```typescript
|
|
1094
|
-
// Unauthenticated test - Context.user is null
|
|
1095
|
-
test("unauthenticated test", async () => {
|
|
1096
|
-
const me = await UserModel.me();
|
|
1097
|
-
expect(me).toBeNull();
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
// Authenticated test - Context.user is set
|
|
1101
|
-
import type { UserSubsetSS } from "../sonamu.generated";
|
|
1102
|
-
|
|
1103
|
-
const adminUser: UserSubsetSS = {
|
|
1104
|
-
id: 1,
|
|
1105
|
-
created_at: new Date(),
|
|
1106
|
-
email: "admin@test.com",
|
|
1107
|
-
username: "admin",
|
|
1108
|
-
role: "admin",
|
|
1109
|
-
};
|
|
1110
|
-
|
|
1111
|
-
testAs(adminUser, "admin permission test", async () => {
|
|
1112
|
-
const me = await UserModel.me();
|
|
1113
|
-
expect(me?.role).toBe("admin");
|
|
1114
|
-
});
|
|
1115
|
-
```
|
|
1116
|
-
|
|
1117
|
-
### test.each
|
|
1118
|
-
|
|
1119
|
-
```typescript
|
|
1120
|
-
test.each([
|
|
1121
|
-
{ input: "user@example.com", expected: true },
|
|
1122
|
-
{ input: "invalid-email", expected: false },
|
|
1123
|
-
])("email validation: $input → $expected", async ({ input, expected }) => {
|
|
1124
|
-
expect(validateEmail(input)).toBe(expected);
|
|
1125
|
-
});
|
|
1126
|
-
```
|
|
1127
|
-
|
|
1128
|
-
## Fixture
|
|
1129
|
-
|
|
1130
|
-
### createFixtureLoader
|
|
1131
|
-
|
|
1132
|
-
```typescript
|
|
1133
|
-
// api/src/testing/fixture.ts
|
|
1134
|
-
import { createFixtureLoader } from "sonamu/test";
|
|
1135
|
-
import { CompanyModel } from "../application/company/company.model";
|
|
1136
|
-
import { UserModel } from "../application/user/user.model";
|
|
1137
|
-
|
|
1138
|
-
export const loadFixtures = createFixtureLoader({
|
|
1139
|
-
company01: async () => CompanyModel.findById("A", 1),
|
|
1140
|
-
user01: async () => UserModel.findById("A", 1),
|
|
1141
|
-
});
|
|
1142
|
-
```
|
|
1143
|
-
|
|
1144
|
-
### Using in tests
|
|
1145
|
-
|
|
1146
|
-
```typescript
|
|
1147
|
-
import { loadFixtures } from "../../testing/fixture";
|
|
1148
|
-
|
|
1149
|
-
test("update company info", async () => {
|
|
1150
|
-
const f0 = await loadFixtures(["company01"]);
|
|
1151
|
-
|
|
1152
|
-
await CompanyModel.save([
|
|
1153
|
-
{
|
|
1154
|
-
...f0.company01,
|
|
1155
|
-
name: "Updated Company",
|
|
1156
|
-
},
|
|
1157
|
-
]);
|
|
1158
|
-
|
|
1159
|
-
const f1 = await loadFixtures(["company01"]);
|
|
1160
|
-
expect(f1.company01.name).toBe("Updated Company");
|
|
1161
|
-
});
|
|
1162
|
-
```
|
|
1163
|
-
|
|
1164
|
-
## Naite (Test Tracing System)
|
|
1165
|
-
|
|
1166
|
-
**→ Detailed guide (key list, chaining filters, wildcard, del, internal structure): `naite.md`**
|
|
1167
|
-
|
|
1168
|
-
Naite is a tracing system that records values with `Naite.t("key", value)` in source code and validates them with `Naite.get("key")` in tests.
|
|
1169
|
-
|
|
1170
|
-
### Commonly Used Patterns in Tests
|
|
1171
|
-
|
|
1172
|
-
```typescript
|
|
1173
|
-
import { Naite } from "sonamu";
|
|
1174
|
-
|
|
1175
|
-
// Query validation
|
|
1176
|
-
expect(Naite.get("esq-query").first()).not.contain("limit");
|
|
1177
|
-
|
|
1178
|
-
// UpsertBuilder behavior validation
|
|
1179
|
-
const trace = Naite.get("puri:ub-upserted").first();
|
|
1180
|
-
expect(trace).toMatchObject({ tableName: "users", rowCount: 3 });
|
|
1181
|
-
|
|
1182
|
-
// Fetch methods: .first(), .last(), .at(n), .result() (full array)
|
|
1183
|
-
// Filters: .fromFile("user.model.ts"), .fromFunction("findById"), .where("data.tableName", "=", "users")
|
|
1184
|
-
```
|
|
1185
|
-
|
|
1186
|
-
## Test Helper: expectQuery
|
|
1187
|
-
|
|
1188
|
-
Helper for validating specific parts of SQL queries (see miomock for reference):
|
|
1189
|
-
|
|
1190
|
-
```typescript
|
|
1191
|
-
// api/src/testing/expect-query.ts
|
|
1192
|
-
import { type AST, Parser } from "node-sql-parser";
|
|
1193
|
-
import { expect } from "vitest";
|
|
1194
|
-
|
|
1195
|
-
export type QueryPart =
|
|
1196
|
-
| "type"
|
|
1197
|
-
| "table"
|
|
1198
|
-
| "columns"
|
|
1199
|
-
| "set"
|
|
1200
|
-
| "where"
|
|
1201
|
-
| "join"
|
|
1202
|
-
| "orderBy"
|
|
1203
|
-
| "pagination"
|
|
1204
|
-
| "groupBy"
|
|
1205
|
-
| "having";
|
|
1206
|
-
|
|
1207
|
-
export function expectQuery(query: string, part?: QueryPart) {
|
|
1208
|
-
if (!part) return expect(query);
|
|
1209
|
-
const ast = parseQuery(query);
|
|
1210
|
-
const extractedSql = extractors[part](ast);
|
|
1211
|
-
return expect(extractedSql);
|
|
1212
|
-
}
|
|
1213
|
-
```
|
|
1214
|
-
|
|
1215
|
-
### Usage Examples
|
|
1216
|
-
|
|
1217
|
-
```typescript
|
|
1218
|
-
import { expectQuery } from "../testing/expect-query";
|
|
1219
|
-
|
|
1220
|
-
test("validate select query", async () => {
|
|
1221
|
-
const db = UserModel.getPuri("r");
|
|
1222
|
-
await db.table("users").select({ id: "users.id" });
|
|
1223
|
-
const query = Naite.get("puri:executed-query").first();
|
|
1224
|
-
|
|
1225
|
-
expectQuery(query, "type").toBe("select");
|
|
1226
|
-
expectQuery(query, "table").toBe("users");
|
|
1227
|
-
expectQuery(query, "columns").toMatchInlineSnapshot(`""users"."id" AS \`id\`"`);
|
|
1228
|
-
});
|
|
1229
|
-
|
|
1230
|
-
test("validate where condition", async () => {
|
|
1231
|
-
const db = UserModel.getPuri("r");
|
|
1232
|
-
await db.table("users").where("users.id", 1);
|
|
1233
|
-
const query = Naite.get("puri:executed-query").first();
|
|
1234
|
-
|
|
1235
|
-
expectQuery(query, "where").toMatchInlineSnapshot(`""users"."id" = 1"`);
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
test("validate join", async () => {
|
|
1239
|
-
const db = UserModel.getPuri("r");
|
|
1240
|
-
await db.table("employees").leftJoin("departments", "employees.department_id", "departments.id");
|
|
1241
|
-
const query = Naite.get("puri:executed-query").first();
|
|
1242
|
-
|
|
1243
|
-
expectQuery(query, "join").toMatchInlineSnapshot(
|
|
1244
|
-
`"LEFT JOIN departments ON "employees"."department_id" = "departments"."id""`,
|
|
1245
|
-
);
|
|
1246
|
-
});
|
|
1247
|
-
```
|
|
1248
|
-
|
|
1249
|
-
## Test Helper: expectUB
|
|
1250
|
-
|
|
1251
|
-
UpsertBuilder state validation helper (see miomock for reference):
|
|
1252
|
-
|
|
1253
|
-
```typescript
|
|
1254
|
-
// api/src/testing/expect-ub.ts
|
|
1255
|
-
import type { UpsertBuilder } from "sonamu";
|
|
1256
|
-
import { expect } from "vitest";
|
|
1257
|
-
|
|
1258
|
-
export type UBPart =
|
|
1259
|
-
| "tables"
|
|
1260
|
-
| "hasTable"
|
|
1261
|
-
| "rowCount"
|
|
1262
|
-
| "rows"
|
|
1263
|
-
| "row"
|
|
1264
|
-
| "refs"
|
|
1265
|
-
| "uniquesMap"
|
|
1266
|
-
| "uniqueIndexes";
|
|
1267
|
-
|
|
1268
|
-
export function expectUB<P extends UBPart>(
|
|
1269
|
-
ub: UpsertBuilder,
|
|
1270
|
-
part: P,
|
|
1271
|
-
tableName?: string,
|
|
1272
|
-
index?: number,
|
|
1273
|
-
) {
|
|
1274
|
-
// ... implementation
|
|
1275
|
-
}
|
|
1276
|
-
```
|
|
1277
|
-
|
|
1278
|
-
### Usage Examples
|
|
1279
|
-
|
|
1280
|
-
```typescript
|
|
1281
|
-
import { expectUB } from "../testing/expect-ub";
|
|
1282
|
-
|
|
1283
|
-
test("validate UpsertBuilder state", async () => {
|
|
1284
|
-
const ub = new UpsertBuilder();
|
|
1285
|
-
|
|
1286
|
-
// initial state
|
|
1287
|
-
expectUB(ub, "hasTable", "users").toBe(false);
|
|
1288
|
-
expectUB(ub, "tables").toEqual([]);
|
|
1289
|
-
|
|
1290
|
-
// after register
|
|
1291
|
-
ub.register("users", {
|
|
1292
|
-
email: "test@test.com",
|
|
1293
|
-
username: "test",
|
|
1294
|
-
password: "pw",
|
|
1295
|
-
role: "normal",
|
|
1296
|
-
});
|
|
1297
|
-
|
|
1298
|
-
expectUB(ub, "hasTable", "users").toBe(true);
|
|
1299
|
-
expectUB(ub, "rowCount", "users").toBe(1);
|
|
1300
|
-
expectUB(ub, "row", "users", 0).toMatchObject({
|
|
1301
|
-
email: "test@test.com",
|
|
1302
|
-
username: "test",
|
|
1303
|
-
});
|
|
1304
|
-
|
|
1305
|
-
// confirm reset after upsert
|
|
1306
|
-
await ub.upsert(wdb, "users");
|
|
1307
|
-
expectUB(ub, "rowCount", "users").toBe(0);
|
|
1308
|
-
});
|
|
1309
|
-
```
|
|
1310
|
-
|
|
1311
|
-
## Mock Patterns
|
|
1312
|
-
|
|
1313
|
-
### setup-mocks.ts
|
|
1314
|
-
|
|
1315
|
-
```typescript
|
|
1316
|
-
// api/src/testing/setup-mocks.ts
|
|
1317
|
-
import { Naite } from "sonamu";
|
|
1318
|
-
import { vi } from "vitest";
|
|
1319
|
-
|
|
1320
|
-
vi.mock("fs/promises", async (importOriginal) => {
|
|
1321
|
-
const actual = (await importOriginal()) as typeof import("fs/promises");
|
|
1322
|
-
return {
|
|
1323
|
-
...actual,
|
|
1324
|
-
access: vi.fn((path, mode) => {
|
|
1325
|
-
// virtual file system check
|
|
1326
|
-
const vfs = Naite.get("mock:fs/promises:virtualFileSystem").result();
|
|
1327
|
-
if (vfs.some((v) => v === path)) {
|
|
1328
|
-
return Promise.resolve();
|
|
1329
|
-
}
|
|
1330
|
-
return actual.access(path, mode);
|
|
1331
|
-
}),
|
|
1332
|
-
writeFile: vi.fn((path, data) => {
|
|
1333
|
-
Naite.t("fs/promises:writeFile", { path, data });
|
|
1334
|
-
}),
|
|
1335
|
-
rm: vi.fn(async (path, options) => {
|
|
1336
|
-
Naite.t("fs/promises:rm", { path, options });
|
|
1337
|
-
return Promise.resolve();
|
|
1338
|
-
}),
|
|
1339
|
-
};
|
|
1340
|
-
});
|
|
1341
|
-
```
|
|
1342
|
-
|
|
1343
|
-
### test-helpers.ts
|
|
1344
|
-
|
|
1345
|
-
```typescript
|
|
1346
|
-
// api/src/testing/test-helpers.ts
|
|
1347
|
-
import { Entity, EntityManager, type EntityJson } from "sonamu";
|
|
1348
|
-
import { vi } from "vitest";
|
|
1349
|
-
|
|
1350
|
-
// Mocking EntityManager.get
|
|
1351
|
-
export function mockEntityManagerGet(
|
|
1352
|
-
targetEntityId: string,
|
|
1353
|
-
overrideCallback: (original: EntityJson) => EntityJson,
|
|
1354
|
-
) {
|
|
1355
|
-
const originalEntityJson = EntityManager.get(targetEntityId).toJson();
|
|
1356
|
-
const originalGet = EntityManager.get;
|
|
1357
|
-
return vi.spyOn(EntityManager, "get").mockImplementation((entityId) => {
|
|
1358
|
-
if (entityId === targetEntityId) {
|
|
1359
|
-
return new Entity(overrideCallback(originalEntityJson));
|
|
1360
|
-
}
|
|
1361
|
-
return originalGet.call(EntityManager, entityId);
|
|
1362
|
-
});
|
|
1363
|
-
}
|
|
1364
|
-
```
|
|
1365
|
-
|
|
1366
|
-
## CRUD Test Patterns
|
|
1367
|
-
|
|
1368
|
-
### Create & Read
|
|
1369
|
-
|
|
1370
|
-
```typescript
|
|
1371
|
-
test("Create - create new user", async () => {
|
|
1372
|
-
const [userId] = await UserModel.save([
|
|
1373
|
-
{
|
|
1374
|
-
email: "newuser@test.com",
|
|
1375
|
-
username: "newuser",
|
|
1376
|
-
password: "hashedpassword",
|
|
1377
|
-
role: "normal",
|
|
1378
|
-
},
|
|
1379
|
-
]);
|
|
1380
|
-
|
|
1381
|
-
expect(userId).toBeGreaterThan(0);
|
|
1382
|
-
|
|
1383
|
-
const user = await UserModel.findById("A", userId);
|
|
1384
|
-
expect(user.email).toBe("newuser@test.com");
|
|
1385
|
-
});
|
|
1386
|
-
```
|
|
1387
|
-
|
|
1388
|
-
### Update
|
|
1389
|
-
|
|
1390
|
-
```typescript
|
|
1391
|
-
test("Update - update user", async () => {
|
|
1392
|
-
const f0 = await loadFixtures(["user01"]);
|
|
1393
|
-
|
|
1394
|
-
await UserModel.save([
|
|
1395
|
-
{
|
|
1396
|
-
...f0.user01,
|
|
1397
|
-
username: "updated_username",
|
|
1398
|
-
},
|
|
1399
|
-
]);
|
|
1400
|
-
|
|
1401
|
-
const f1 = await loadFixtures(["user01"]);
|
|
1402
|
-
expect(f1.user01.username).toBe("updated_username");
|
|
1403
|
-
});
|
|
1404
|
-
```
|
|
1405
|
-
|
|
1406
|
-
### Error Tests
|
|
1407
|
-
|
|
1408
|
-
```typescript
|
|
1409
|
-
test("error when fetching non-existent user", async () => {
|
|
1410
|
-
await expect(UserModel.findById("A", 99999)).rejects.toThrow("not found");
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
test("unresolved reference error", async () => {
|
|
1414
|
-
const ub = new UpsertBuilder();
|
|
1415
|
-
const companyRef = ub.register("companies", { name: "Test" });
|
|
1416
|
-
ub.register("departments", { company_id: companyRef, name: "Dept" });
|
|
1417
|
-
|
|
1418
|
-
// attempt upsert in wrong order
|
|
1419
|
-
await expect(ub.upsert(wdb, "departments")).rejects.toThrow(/unresolved reference/);
|
|
1420
|
-
});
|
|
1421
|
-
```
|
|
1422
|
-
|
|
1423
|
-
## Test Structuring Patterns
|
|
1424
|
-
|
|
1425
|
-
```typescript
|
|
1426
|
-
describe("UpsertBuilder", () => {
|
|
1427
|
-
describe("A. Basic registration (register)", () => {
|
|
1428
|
-
test("register() returns UBRef", async () => {
|
|
1429
|
-
/* ... */
|
|
1430
|
-
});
|
|
1431
|
-
test("multiple register() calls accumulate rows", async () => {
|
|
1432
|
-
/* ... */
|
|
1433
|
-
});
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
describe("B. Table management", () => {
|
|
1437
|
-
test("basic behavior of getTable()/hasTable()", async () => {
|
|
1438
|
-
/* ... */
|
|
1439
|
-
});
|
|
1440
|
-
});
|
|
1441
|
-
|
|
1442
|
-
describe("C. Upsert execution", () => {
|
|
1443
|
-
test("upsert() - insert new row", async () => {
|
|
1444
|
-
/* ... */
|
|
1445
|
-
});
|
|
1446
|
-
test("upsert() - update existing row", async () => {
|
|
1447
|
-
/* ... */
|
|
1448
|
-
});
|
|
1449
|
-
test("insertOnly() - insert only", async () => {
|
|
1450
|
-
/* ... */
|
|
1451
|
-
});
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
describe("D. Error handling", () => {
|
|
1455
|
-
test("upsert on non-existent table → empty array", async () => {
|
|
1456
|
-
/* ... */
|
|
1457
|
-
});
|
|
1458
|
-
test("unresolved reference → error", async () => {
|
|
1459
|
-
/* ... */
|
|
1460
|
-
});
|
|
1461
|
-
});
|
|
1462
|
-
});
|
|
1463
|
-
```
|
|
1464
|
-
|
|
1465
|
-
## File Structure
|
|
1466
|
-
|
|
1467
|
-
```
|
|
1468
|
-
api/src/testing/
|
|
1469
|
-
├── fixture.ts # createFixtureLoader definition
|
|
1470
|
-
├── global.ts # globalSetup (dotenv, setup export)
|
|
1471
|
-
├── setup-mocks.ts # global Mock configuration
|
|
1472
|
-
├── test-helpers.ts # test utility functions
|
|
1473
|
-
├── expect-query.ts # SQL query validation helper
|
|
1474
|
-
└── expect-ub.ts # UpsertBuilder validation helper
|
|
1475
|
-
```
|
|
1476
|
-
|
|
1477
|
-
## Rules
|
|
1478
|
-
|
|
1479
|
-
- `bootstrap(vi)` call required in all test files
|
|
1480
|
-
- Each test is automatically rolled back (test isolation)
|
|
1481
|
-
- Use `test` for unauthenticated tests, `testAs` for authenticated tests
|
|
1482
|
-
- Define fixtures with `createFixtureLoader` and load with `loadFixtures`
|
|
1483
|
-
- Use Naite to track and validate query/UpsertBuilder behavior
|
|
1484
|
-
- Recommend snapshot tests using `toMatchInlineSnapshot()`
|
|
1485
|
-
- Configure Mocks globally in `setup-mocks.ts` or use `vi.spyOn` within tests
|
|
1486
|
-
|
|
1487
|
-
## Type Safety Notes
|
|
1488
|
-
|
|
1489
|
-
### Zod Import Method
|
|
1490
|
-
|
|
1491
|
-
**CRITICAL: Always use regular imports when importing Zod in test files.**
|
|
1492
|
-
|
|
1493
|
-
```typescript
|
|
1494
|
-
// CORRECT - in test files
|
|
1495
|
-
import { z } from "zod";
|
|
1496
|
-
import { describe, expect, vi } from "vitest";
|
|
1497
|
-
|
|
1498
|
-
// WRONG - runtime error when using type import
|
|
1499
|
-
import type { z } from "zod"; // error when test runs!
|
|
1500
|
-
```
|
|
1501
|
-
|
|
1502
|
-
**Reason:** Because `z.infer<>` and Zod schemas are used directly in tests, the Zod object is needed at runtime.
|
|
1503
|
-
|
|
1504
|
-
**Where this applies:**
|
|
1505
|
-
|
|
1506
|
-
- `*.model.test.ts` - all test files
|
|
1507
|
-
- `test-helpers.ts` - helper files that use Zod schemas
|
|
1508
|
-
|
|
1509
|
-
### Checking partial Settings in SaveParams
|
|
1510
|
-
|
|
1511
|
-
When testing `Model.save()`, you must check the `SaveParams` partial settings in `*.types.ts`:
|
|
1512
|
-
|
|
1513
|
-
```typescript
|
|
1514
|
-
// user.types.ts
|
|
1515
|
-
import { z } from "zod"; // regular import in types files too
|
|
1516
|
-
import { UserBaseSchema } from "../sonamu.generated";
|
|
1517
|
-
|
|
1518
|
-
export const UserSaveParams = UserBaseSchema.partial({
|
|
1519
|
-
id: true, // auto-generated
|
|
1520
|
-
created_at: true, // auto-generated
|
|
1521
|
-
updated_at: true, // auto-generated
|
|
1522
|
-
});
|
|
1523
|
-
export type UserSaveParams = z.infer<typeof UserSaveParams>;
|
|
1524
|
-
```
|
|
1525
|
-
|
|
1526
|
-
### Nullable Field Handling Pattern
|
|
1527
|
-
|
|
1528
|
-
**→ See "Tasks to Do Immediately After Entity Creation" section above** (partial + extend + nullish pattern)
|
|
1529
|
-
|
|
1530
|
-
### Use Nullish Coalescing
|
|
1531
|
-
|
|
1532
|
-
Nullish coalescing is required when a variable can be of type `T | undefined`:
|
|
1533
|
-
|
|
1534
|
-
```typescript
|
|
1535
|
-
// WRONG: userId may be number | undefined
|
|
1536
|
-
const user = await UserModel.findById("A", userId);
|
|
1537
|
-
|
|
1538
|
-
// CORRECT: guard against undefined with nullish coalescing
|
|
1539
|
-
const user = await UserModel.findById("A", userId ?? 0);
|
|
1540
|
-
```
|
|
1541
|
-
|
|
1542
|
-
Especially be careful when using IDs created in a previous step:
|
|
1543
|
-
|
|
1544
|
-
```typescript
|
|
1545
|
-
const [userId] = await UserModel.save([{ ... }]);
|
|
1546
|
-
|
|
1547
|
-
// WRONG: userId is number | undefined
|
|
1548
|
-
const user = await UserModel.findById("A", userId);
|
|
1549
|
-
|
|
1550
|
-
// CORRECT:
|
|
1551
|
-
const user = await UserModel.findById("A", userId ?? 0);
|
|
1552
|
-
```
|
|
1553
|
-
|
|
1554
|
-
### SaveParams Import Location
|
|
1555
|
-
|
|
1556
|
-
SaveParams types are exported from each entity's types.ts, not from sonamu.generated.
|
|
1557
|
-
|
|
1558
|
-
**Wrong:**
|
|
1559
|
-
|
|
1560
|
-
```typescript
|
|
1561
|
-
// test-helpers.ts
|
|
1562
|
-
import type { UserSaveParams, TaskSaveParams } from "../application/sonamu.generated"; // WRONG
|
|
1563
|
-
```
|
|
1564
|
-
|
|
1565
|
-
**Correct:**
|
|
1566
|
-
|
|
1567
|
-
```typescript
|
|
1568
|
-
// test-helpers.ts
|
|
1569
|
-
import type { UserSaveParams } from "../application/user/user.types";
|
|
1570
|
-
import type { TaskSaveParams } from "../application/task/task.types";
|
|
1571
|
-
```
|
|
1572
|
-
|
|
1573
|
-
**Reason:**
|
|
1574
|
-
|
|
1575
|
-
- sonamu.generated only exports BaseSchema and BaseListParams
|
|
1576
|
-
- SaveParams is defined with BaseSchema.partial() in each entity's types.ts
|
|
1577
|
-
|
|
1578
|
-
## Practical Notes (Common Pitfalls)
|
|
1579
|
-
|
|
1580
|
-
### 1. Fixture Data Preparation Required
|
|
1581
|
-
|
|
1582
|
-
**Problem:** Tests fail without base data due to foreign key constraints
|
|
1583
|
-
|
|
1584
|
-
**Solution:**
|
|
1585
|
-
|
|
1586
|
-
```sql
|
|
1587
|
-
-- database/scripts/seed-initial-data.sql
|
|
1588
|
-
INSERT INTO institutions (id, name, code) VALUES (1, 'HQ', 'HQ');
|
|
1589
|
-
INSERT INTO departments (id, name, institution_id) VALUES (1, 'Research', 1);
|
|
1590
|
-
INSERT INTO roles (id, code, name) VALUES (1, 'ADMIN', 'Administrator');
|
|
1591
|
-
```
|
|
1592
|
-
|
|
1593
|
-
```bash
|
|
1594
|
-
# 1. apply seed data to test DB
|
|
1595
|
-
PGPASSWORD=1234 psql -h 0.0.0.0 -U postgres -d project_test -f database/scripts/seed-initial-data.sql
|
|
1596
|
-
|
|
1597
|
-
# 2. create dump
|
|
1598
|
-
pnpm dump
|
|
1599
|
-
|
|
1600
|
-
# 3. apply to fixture DB
|
|
1601
|
-
pnpm seed
|
|
1602
|
-
|
|
1603
|
-
# 4. sonamu fixture sync (optional)
|
|
1604
|
-
pnpm sonamu fixture sync
|
|
1605
|
-
```
|
|
1606
|
-
|
|
1607
|
-
### 2. SaveParams Type Design (Partial)
|
|
1608
|
-
|
|
1609
|
-
**Problem 1:** Type error occurs when changing only some fields on update
|
|
1610
|
-
|
|
1611
|
-
**Problem 2:** Type error occurs when receiving overrides as Partial in test helpers
|
|
1612
|
-
|
|
1613
|
-
```typescript
|
|
1614
|
-
// WRONG - nullable fields not set to partial
|
|
1615
|
-
export const QuestionSaveParams = QuestionBaseSchema.partial({
|
|
1616
|
-
id: true,
|
|
1617
|
-
created_at: true,
|
|
1618
|
-
});
|
|
1619
|
-
|
|
1620
|
-
// test-helpers.ts
|
|
1621
|
-
export async function createTestQuestion(
|
|
1622
|
-
collectionId: number,
|
|
1623
|
-
override?: Partial<QuestionSaveParams>,
|
|
1624
|
-
) {
|
|
1625
|
-
const [id] = await QuestionModel.save([
|
|
1626
|
-
{
|
|
1627
|
-
content: "test question",
|
|
1628
|
-
parent_id: null,
|
|
1629
|
-
answer_group_id: null,
|
|
1630
|
-
...override, // type error: undefined cannot be assigned to null
|
|
1631
|
-
},
|
|
1632
|
-
]);
|
|
1633
|
-
return id;
|
|
1634
|
-
}
|
|
1635
|
-
```
|
|
1636
|
-
|
|
1637
|
-
**Solution:** Set nullable/dbDefault fields to partial
|
|
1638
|
-
|
|
1639
|
-
```typescript
|
|
1640
|
-
// api/src/application/user/user.types.ts
|
|
1641
|
-
export const UserSaveParams = UserBaseSchema.partial({
|
|
1642
|
-
id: true, // needed for update
|
|
1643
|
-
created_at: true, // dbDefault
|
|
1644
|
-
password: true, // nullable
|
|
1645
|
-
email: true, // nullable
|
|
1646
|
-
phone: true, // nullable
|
|
1647
|
-
user_type: true, // dbDefault
|
|
1648
|
-
position_code: true, // nullable
|
|
1649
|
-
position_name: true, // nullable
|
|
1650
|
-
hire_date: true, // nullable
|
|
1651
|
-
status: true, // dbDefault
|
|
1652
|
-
department_id: true, // nullable relation
|
|
1653
|
-
});
|
|
1654
|
-
```
|
|
1655
|
-
|
|
1656
|
-
**Application criteria:**
|
|
1657
|
-
|
|
1658
|
-
- id, created_at, updated_at: always partial (auto-generated)
|
|
1659
|
-
- Fields with dbDefault: set to partial
|
|
1660
|
-
- FK fields with nullable: true: set to partial
|
|
1661
|
-
- Regular fields with nullable: true (e.g. description): set to partial
|
|
1662
|
-
|
|
1663
|
-
**Key:** Required fields (employee_no, login_id, name, institution_id) are excluded from partial to maintain type safety
|
|
1664
|
-
|
|
1665
|
-
### 3. Excluding Relation Fields on Update
|
|
1666
|
-
|
|
1667
|
-
**Problem:** Subset includes relation objects, but SaveParams only has FK, causing errors
|
|
1668
|
-
|
|
1669
|
-
```typescript
|
|
1670
|
-
// WRONG
|
|
1671
|
-
const user = await UserModel.findById("A", userId);
|
|
1672
|
-
await UserModel.save([{ ...user, status: "inactive" }]);
|
|
1673
|
-
// → "column 'department' does not exist" error
|
|
1674
|
-
```
|
|
1675
|
-
|
|
1676
|
-
**Solution:** Exclude relation fields + explicitly add FK
|
|
1677
|
-
|
|
1678
|
-
```typescript
|
|
1679
|
-
// CORRECT
|
|
1680
|
-
const user = await UserModel.findById("A", userId);
|
|
1681
|
-
const { institution, department, ...userData } = user;
|
|
1682
|
-
await UserModel.save([
|
|
1683
|
-
{
|
|
1684
|
-
...userData,
|
|
1685
|
-
institution_id: user.institution.id, // explicitly add FK
|
|
1686
|
-
department_id: user.department?.id ?? null,
|
|
1687
|
-
status: "inactive",
|
|
1688
|
-
},
|
|
1689
|
-
]);
|
|
1690
|
-
```
|
|
1691
|
-
|
|
1692
|
-
**Reason:** `UserSubsetA` includes `institution`, `department` objects, but does not include `institution_id`, `department_id` FKs
|
|
1693
|
-
|
|
1694
|
-
### 4. ubUpsert is an Upsert Operation
|
|
1695
|
-
|
|
1696
|
-
**Problem:** Unique constraint violation tests fail
|
|
1697
|
-
|
|
1698
|
-
```typescript
|
|
1699
|
-
// failing test
|
|
1700
|
-
test("employee number must be unique", async () => {
|
|
1701
|
-
await UserModel.save([{ employee_no: "001", ... }]);
|
|
1702
|
-
|
|
1703
|
-
// attempt to create with duplicate employee number
|
|
1704
|
-
await expect(
|
|
1705
|
-
UserModel.save([{ employee_no: "001", ... }])
|
|
1706
|
-
).rejects.toThrow(); // does not throw error, performs UPDATE instead
|
|
1707
|
-
});
|
|
1708
|
-
```
|
|
1709
|
-
|
|
1710
|
-
**Cause:** Sonamu's `save()` uses `ubUpsert` → on conflict, performs UPDATE instead of throwing error
|
|
1711
|
-
|
|
1712
|
-
**Solution:** Skip such tests
|
|
1713
|
-
|
|
1714
|
-
```typescript
|
|
1715
|
-
test.skip("employee number must be unique (skipped because ubUpsert performs upsert)", async () => {
|
|
1716
|
-
// ...
|
|
1717
|
-
});
|
|
1718
|
-
```
|
|
1719
|
-
|
|
1720
|
-
### 5. testAs Usage
|
|
1721
|
-
|
|
1722
|
-
**Problem:** Calling testAs inside test causes an error
|
|
1723
|
-
|
|
1724
|
-
```typescript
|
|
1725
|
-
// WRONG
|
|
1726
|
-
test("permission test", async () => {
|
|
1727
|
-
await testAs(adminUser, "description", async () => { ... });
|
|
1728
|
-
// → "Calling the test function inside another test function is not allowed" error
|
|
1729
|
-
});
|
|
1730
|
-
|
|
1731
|
-
// CORRECT - use as a replacement for test
|
|
1732
|
-
testAs(adminUser, "permission test", async () => {
|
|
1733
|
-
const result = await UserModel.del([userId]);
|
|
1734
|
-
expect(result).toBe(1);
|
|
1735
|
-
});
|
|
1736
|
-
```
|
|
1737
|
-
|
|
1738
|
-
### 6. Validating Model Queries with Naite
|
|
1739
|
-
|
|
1740
|
-
**Add Naite recording to Model:**
|
|
1741
|
-
|
|
1742
|
-
```typescript
|
|
1743
|
-
// user.model.ts
|
|
1744
|
-
import { Naite } from "sonamu";
|
|
1745
|
-
|
|
1746
|
-
async findMany(...) {
|
|
1747
|
-
// ... build qb ...
|
|
1748
|
-
|
|
1749
|
-
// record query for testing
|
|
1750
|
-
Naite.t("esq-query", qb.toQuery());
|
|
1751
|
-
|
|
1752
|
-
return this.executeSubsetQuery({ ... });
|
|
1753
|
-
}
|
|
1754
|
-
```
|
|
1755
|
-
|
|
1756
|
-
**Validate in test:**
|
|
1757
|
-
|
|
1758
|
-
```typescript
|
|
1759
|
-
test("should not have limit when num: 0", async () => {
|
|
1760
|
-
await UserModel.findMany("A", { num: 0, page: 1 });
|
|
1761
|
-
|
|
1762
|
-
expect(Naite.get("esq-query").first()).not.contain("limit");
|
|
1763
|
-
expect(Naite.get("esq-query").first()).not.contain("offset");
|
|
1764
|
-
});
|
|
1765
|
-
```
|
|
1766
|
-
|
|
1767
|
-
### 7. Consider Multilingual Error Messages
|
|
1768
|
-
|
|
1769
|
-
```typescript
|
|
1770
|
-
// WRONG: only validates English message
|
|
1771
|
-
await expect(UserModel.findById("A", 99999)).rejects.toThrow("not found");
|
|
1772
|
-
|
|
1773
|
-
// CORRECT: partial match on actual error message
|
|
1774
|
-
await expect(UserModel.findById("A", 99999)).rejects.toThrow("does not exist");
|
|
1775
|
-
```
|
|
1776
|
-
|
|
1777
|
-
### 8. pnpm Workspace and Vitest Instance Conflicts
|
|
1778
|
-
|
|
1779
|
-
**Problem:** "Vitest failed to access its internal state" error
|
|
1780
|
-
|
|
1781
|
-
**Cause:** When sonamu is connected via `link:`, sonamu and the project's vitest are installed at separate paths with different peer dependency combinations
|
|
1782
|
-
|
|
1783
|
-
**Temporary fix (for testing):**
|
|
1784
|
-
|
|
1785
|
-
```json
|
|
1786
|
-
// packages/api/package.json
|
|
1787
|
-
{
|
|
1788
|
-
"dependencies": {
|
|
1789
|
-
"sonamu": "0.8.0" // specify version instead of link
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
```
|
|
1793
|
-
|
|
1794
|
-
**Fundamental fix:** Contact sonamu developers (framework internal issue)
|
|
1795
|
-
|
|
1796
|
-
### 9. assert() for Truthy Checks
|
|
1797
|
-
|
|
1798
|
-
```typescript
|
|
1799
|
-
import assert from "assert";
|
|
1800
|
-
|
|
1801
|
-
test("create user", async () => {
|
|
1802
|
-
const [userId] = await UserModel.save([{ ... }]);
|
|
1803
|
-
|
|
1804
|
-
// truthy check
|
|
1805
|
-
assert(userId);
|
|
1806
|
-
|
|
1807
|
-
// userId is now safely inferred as number
|
|
1808
|
-
const user = await UserModel.findById("A", userId);
|
|
1809
|
-
});
|
|
1810
|
-
```
|
|
1811
|
-
|
|
1812
|
-
### 10. Create Test Data Directly
|
|
1813
|
-
|
|
1814
|
-
**miomock convention:** Minimize fixtures, create data directly within tests
|
|
1815
|
-
|
|
1816
|
-
```typescript
|
|
1817
|
-
// recommended pattern
|
|
1818
|
-
test("create user", async () => {
|
|
1819
|
-
const [userId] = await UserModel.save([
|
|
1820
|
-
{
|
|
1821
|
-
employee_no: "2026001",
|
|
1822
|
-
login_id: "testuser",
|
|
1823
|
-
name: "Test User",
|
|
1824
|
-
institution_id: 1,
|
|
1825
|
-
// ... required fields
|
|
1826
|
-
},
|
|
1827
|
-
]);
|
|
1828
|
-
|
|
1829
|
-
const user = await UserModel.findById("A", userId);
|
|
1830
|
-
expect(user.name).toBe("Test User");
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
// Fixtures only for shared data
|
|
1834
|
-
const f = await loadFixtures(["institution01"]); // only for shared data like institutions
|
|
1835
|
-
```
|
|
1836
|
-
|
|
1837
|
-
## Complex Entity Test Strategy
|
|
1838
|
-
|
|
1839
|
-
When dependencies between entities are complex (Institution → Department → User → Task → TaskParticipant), use test helper functions.
|
|
1840
|
-
|
|
1841
|
-
### Defining Test Helper Functions
|
|
1842
|
-
|
|
1843
|
-
```typescript
|
|
1844
|
-
// api/src/testing/test-helpers.ts
|
|
1845
|
-
import assert from "assert";
|
|
1846
|
-
import { InstitutionModel } from "../application/institution/institution.model";
|
|
1847
|
-
import { DepartmentModel } from "../application/department/department.model";
|
|
1848
|
-
import { UserModel } from "../application/user/user.model";
|
|
1849
|
-
import { TaskModel } from "../application/task/task.model";
|
|
1850
|
-
|
|
1851
|
-
// each helper requires only the minimum required fields and provides defaults for the rest
|
|
1852
|
-
let counter = 0;
|
|
1853
|
-
function uniqueId(prefix: string) {
|
|
1854
|
-
return `${prefix}_${Date.now()}_${++counter}`;
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
export async function createTestInstitution(override?: Partial<InstitutionSaveParams>) {
|
|
1858
|
-
const [id] = await InstitutionModel.save([
|
|
1859
|
-
{
|
|
1860
|
-
name: "Test Institution",
|
|
1861
|
-
code: uniqueId("INST"),
|
|
1862
|
-
...override,
|
|
1863
|
-
},
|
|
1864
|
-
]);
|
|
1865
|
-
assert(id);
|
|
1866
|
-
return id;
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
export async function createTestDepartment(
|
|
1870
|
-
institutionId: number,
|
|
1871
|
-
override?: Partial<DepartmentSaveParams>,
|
|
1872
|
-
) {
|
|
1873
|
-
const [id] = await DepartmentModel.save([
|
|
1874
|
-
{
|
|
1875
|
-
name: "Test Department",
|
|
1876
|
-
code: uniqueId("DEPT"),
|
|
1877
|
-
dept_type: "division",
|
|
1878
|
-
institution_id: institutionId,
|
|
1879
|
-
is_active: true,
|
|
1880
|
-
sort_order: 0,
|
|
1881
|
-
...override,
|
|
1882
|
-
},
|
|
1883
|
-
]);
|
|
1884
|
-
assert(id);
|
|
1885
|
-
return id;
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
export async function createTestUser(institutionId: number, override?: Partial<UserSaveParams>) {
|
|
1889
|
-
const [id] = await UserModel.save([
|
|
1890
|
-
{
|
|
1891
|
-
employee_no: uniqueId("EMP"),
|
|
1892
|
-
login_id: uniqueId("login"),
|
|
1893
|
-
name: "Test User",
|
|
1894
|
-
institution_id: institutionId,
|
|
1895
|
-
...override,
|
|
1896
|
-
},
|
|
1897
|
-
]);
|
|
1898
|
-
assert(id);
|
|
1899
|
-
return id;
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
export async function createTestTask(
|
|
1903
|
-
principalInvestigatorId: number,
|
|
1904
|
-
override?: Partial<TaskSaveParams>,
|
|
1905
|
-
) {
|
|
1906
|
-
const [id] = await TaskModel.save([
|
|
1907
|
-
{
|
|
1908
|
-
task_no: uniqueId("TASK"),
|
|
1909
|
-
title: "Test Task",
|
|
1910
|
-
year: new Date().getFullYear(),
|
|
1911
|
-
begin_date: new Date(),
|
|
1912
|
-
end_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
1913
|
-
principal_investigator_id: principalInvestigatorId,
|
|
1914
|
-
...override,
|
|
1915
|
-
},
|
|
1916
|
-
]);
|
|
1917
|
-
assert(id);
|
|
1918
|
-
return id;
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
// create the entire dependency chain at once
|
|
1922
|
-
export async function createTestTaskWithDeps(taskOverride?: Partial<TaskSaveParams>) {
|
|
1923
|
-
const institutionId = await createTestInstitution();
|
|
1924
|
-
const userId = await createTestUser(institutionId);
|
|
1925
|
-
const taskId = await createTestTask(userId, taskOverride);
|
|
1926
|
-
return { institutionId, userId, taskId };
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
export async function createTestUserWithDeps(userOverride?: Partial<UserSaveParams>) {
|
|
1930
|
-
const institutionId = await createTestInstitution();
|
|
1931
|
-
const userId = await createTestUser(institutionId, userOverride);
|
|
1932
|
-
return { institutionId, userId };
|
|
1933
|
-
}
|
|
1934
|
-
```
|
|
1935
|
-
|
|
1936
|
-
### Using in Tests
|
|
1937
|
-
|
|
1938
|
-
```typescript
|
|
1939
|
-
import { createTestTaskWithDeps, createTestUser } from "../../testing/test-helpers";
|
|
1940
|
-
|
|
1941
|
-
describe("TaskModel", () => {
|
|
1942
|
-
// GOOD: concise with helper functions
|
|
1943
|
-
test("Create - create with minimum required fields", async () => {
|
|
1944
|
-
const { taskId } = await createTestTaskWithDeps();
|
|
1945
|
-
|
|
1946
|
-
const task = await TaskModel.findById("D", taskId);
|
|
1947
|
-
expect(task.id).toBe(taskId);
|
|
1948
|
-
});
|
|
1949
|
-
|
|
1950
|
-
// GOOD: customize specific fields
|
|
1951
|
-
test("Create - create with specific status", async () => {
|
|
1952
|
-
const { taskId } = await createTestTaskWithDeps({
|
|
1953
|
-
status: "approved",
|
|
1954
|
-
title: "Approved Task",
|
|
1955
|
-
});
|
|
1956
|
-
|
|
1957
|
-
const task = await TaskModel.findById("D", taskId);
|
|
1958
|
-
expect(task.status).toBe("approved");
|
|
1959
|
-
});
|
|
1960
|
-
|
|
1961
|
-
// BAD: creating dependencies directly in every test (repetitive)
|
|
1962
|
-
test("Create - direct creation (not recommended)", async () => {
|
|
1963
|
-
const [institutionId] = await InstitutionModel.save([{ name: "...", code: "..." }]);
|
|
1964
|
-
assert(institutionId);
|
|
1965
|
-
const [userId] = await UserModel.save([{ ... }]);
|
|
1966
|
-
assert(userId);
|
|
1967
|
-
const [taskId] = await TaskModel.save([{ ... }]);
|
|
1968
|
-
assert(taskId);
|
|
1969
|
-
// ...
|
|
1970
|
-
});
|
|
1971
|
-
});
|
|
1972
|
-
```
|
|
1973
|
-
|
|
1974
|
-
### Subset → SaveParams Conversion Helper
|
|
1975
|
-
|
|
1976
|
-
When modifying findById results and saving again, relations must be converted to FKs:
|
|
1977
|
-
|
|
1978
|
-
```typescript
|
|
1979
|
-
// api/src/testing/test-helpers.ts
|
|
1980
|
-
|
|
1981
|
-
// Task Subset A → SaveParams conversion
|
|
1982
|
-
export function taskToSaveParams(task: TaskSubsetA): TaskSaveParams {
|
|
1983
|
-
const { program, project, principal_investigator, department, prev_task, ...rest } = task;
|
|
1984
|
-
|
|
1985
|
-
return {
|
|
1986
|
-
...rest,
|
|
1987
|
-
program_id: program?.id ?? null,
|
|
1988
|
-
project_id: project?.id ?? null,
|
|
1989
|
-
principal_investigator_id: principal_investigator.id,
|
|
1990
|
-
department_id: department?.id ?? null,
|
|
1991
|
-
prev_task_id: prev_task?.id ?? null,
|
|
1992
|
-
};
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
// generic helper (note: write directly if relation field names differ)
|
|
1996
|
-
export function relationToFk<T extends Record<string, any>>(
|
|
1997
|
-
data: T,
|
|
1998
|
-
relationFields: string[],
|
|
1999
|
-
): Record<string, any> {
|
|
2000
|
-
const result: Record<string, any> = {};
|
|
2001
|
-
|
|
2002
|
-
for (const [key, value] of Object.entries(data)) {
|
|
2003
|
-
if (relationFields.includes(key)) {
|
|
2004
|
-
// relation → FK
|
|
2005
|
-
result[`${key}_id`] = value?.id ?? null;
|
|
2006
|
-
} else {
|
|
2007
|
-
result[key] = value;
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
return result;
|
|
2012
|
-
}
|
|
2013
|
-
```
|
|
2014
|
-
|
|
2015
|
-
### Simplifying Update Tests
|
|
2016
|
-
|
|
2017
|
-
```typescript
|
|
2018
|
-
import { createTestTaskWithDeps, taskToSaveParams } from "../../testing/test-helpers";
|
|
2019
|
-
|
|
2020
|
-
test("Update - update task info", async () => {
|
|
2021
|
-
const { taskId } = await createTestTaskWithDeps();
|
|
2022
|
-
|
|
2023
|
-
const task = await TaskModel.findById("A", taskId);
|
|
2024
|
-
await TaskModel.save([
|
|
2025
|
-
{
|
|
2026
|
-
...taskToSaveParams(task),
|
|
2027
|
-
title: "Updated Title",
|
|
2028
|
-
},
|
|
2029
|
-
]);
|
|
2030
|
-
|
|
2031
|
-
const updated = await TaskModel.findById("A", taskId);
|
|
2032
|
-
expect(updated.title).toBe("Updated Title");
|
|
2033
|
-
});
|
|
2034
|
-
```
|
|
2035
|
-
|
|
2036
|
-
### Notes
|
|
2037
|
-
|
|
2038
|
-
**Do not use beforeAll/beforeEach:**
|
|
2039
|
-
|
|
2040
|
-
In sonamu's test environment, creating data with beforeAll/beforeEach may end up referencing sonamu internal code. Instead, call helper functions within each test.
|
|
2041
|
-
|
|
2042
|
-
```typescript
|
|
2043
|
-
// WRONG: using beforeAll
|
|
2044
|
-
describe("TaskModel", () => {
|
|
2045
|
-
let taskId: number;
|
|
2046
|
-
beforeAll(async () => {
|
|
2047
|
-
const result = await createTestTaskWithDeps();
|
|
2048
|
-
taskId = result.taskId;
|
|
2049
|
-
});
|
|
2050
|
-
|
|
2051
|
-
test("...", async () => {
|
|
2052
|
-
// using taskId - may cause problems
|
|
2053
|
-
});
|
|
2054
|
-
});
|
|
2055
|
-
|
|
2056
|
-
// CORRECT: create in each test
|
|
2057
|
-
describe("TaskModel", () => {
|
|
2058
|
-
test("...", async () => {
|
|
2059
|
-
const { taskId } = await createTestTaskWithDeps();
|
|
2060
|
-
// use taskId
|
|
2061
|
-
});
|
|
2062
|
-
});
|
|
2063
|
-
```
|
|
2064
|
-
|
|
2065
|
-
---
|
|
2066
|
-
|
|
2067
|
-
## Common Mistakes and Solutions
|
|
2068
|
-
|
|
2069
|
-
### ubUpsert Does Not Throw Unique Constraint Errors
|
|
2070
|
-
|
|
2071
|
-
**→ See "Practical Notes #4. ubUpsert is an Upsert Operation" above**
|
|
2072
|
-
|
|
2073
|
-
### Transaction Isolation and Test Isolation
|
|
2074
|
-
|
|
2075
|
-
Each test runs in an independent transaction so data is isolated. Even within the same test, data you created may not be immediately visible in queries.
|
|
2076
|
-
|
|
2077
|
-
```typescript
|
|
2078
|
-
// BAD: expecting exact count may fail
|
|
2079
|
-
test("search by role name", async () => {
|
|
2080
|
-
await createTestRole({ name: "AdminA" });
|
|
2081
|
-
await createTestRole({ name: "AdminB" });
|
|
2082
|
-
|
|
2083
|
-
const { rows } = await RoleModel.findMany("A", {
|
|
2084
|
-
keyword: "Admin",
|
|
2085
|
-
});
|
|
2086
|
-
|
|
2087
|
-
// may not see 2 due to transaction isolation
|
|
2088
|
-
expect(rows.length).toBe(2);
|
|
2089
|
-
});
|
|
2090
|
-
|
|
2091
|
-
// GOOD: use unique identifier and flexible assertion
|
|
2092
|
-
test("search by role name", async () => {
|
|
2093
|
-
// unique identifier to prevent conflicts
|
|
2094
|
-
const testName = `SearchTest_${Date.now()}`;
|
|
2095
|
-
await createTestRole({ name: `${testName}A` });
|
|
2096
|
-
await createTestRole({ name: `${testName}B` });
|
|
2097
|
-
|
|
2098
|
-
const { rows } = await RoleModel.findMany("A", {
|
|
2099
|
-
keyword: testName,
|
|
2100
|
-
});
|
|
2101
|
-
|
|
2102
|
-
// verify at least 1
|
|
2103
|
-
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
2104
|
-
// content validation
|
|
2105
|
-
expect(rows.some((r) => r.name.includes(testName))).toBe(true);
|
|
2106
|
-
});
|
|
2107
|
-
```
|
|
2108
|
-
|
|
2109
|
-
**Patterns:**
|
|
2110
|
-
|
|
2111
|
-
- Use unique identifiers: `Date.now()`, `uuid()`, etc. to prevent conflicts
|
|
2112
|
-
- Flexible assertions: use `toBeGreaterThanOrEqual(1)` instead of `toBe(2)`
|
|
2113
|
-
- Content validation: verify actual data matches rather than count
|
|
2114
|
-
|
|
2115
|
-
### Conditional Validation for Sorting Tests
|
|
2116
|
-
|
|
2117
|
-
Since not all data may be returned in sorting tests, use conditional validation:
|
|
2118
|
-
|
|
2119
|
-
```typescript
|
|
2120
|
-
// BAD: assumes two items are always returned
|
|
2121
|
-
test("sort - newest ID first", async () => {
|
|
2122
|
-
const id1 = await createTestRole({ name: "Role1" });
|
|
2123
|
-
const id2 = await createTestRole({ name: "Role2" });
|
|
2124
|
-
|
|
2125
|
-
const { rows } = await RoleModel.findMany("A", {
|
|
2126
|
-
orderBy: "id-desc",
|
|
2127
|
-
});
|
|
2128
|
-
|
|
2129
|
-
const id2Index = rows.findIndex((r) => r.id === id2);
|
|
2130
|
-
const id1Index = rows.findIndex((r) => r.id === id1);
|
|
2131
|
-
|
|
2132
|
-
// fails if either is missing
|
|
2133
|
-
expect(id2Index).toBeLessThan(id1Index);
|
|
2134
|
-
});
|
|
2135
|
-
|
|
2136
|
-
// GOOD: conditional validation
|
|
2137
|
-
test("sort - newest ID first", async () => {
|
|
2138
|
-
const id1 = await createTestRole({ name: "Role1" });
|
|
2139
|
-
const id2 = await createTestRole({ name: "Role2" });
|
|
2140
|
-
|
|
2141
|
-
const { rows } = await RoleModel.findMany("A", {
|
|
2142
|
-
orderBy: "id-desc",
|
|
2143
|
-
});
|
|
2144
|
-
|
|
2145
|
-
const testRoles = rows.filter((r) => [id1, id2].includes(r.id));
|
|
2146
|
-
expect(testRoles.length).toBeGreaterThanOrEqual(1);
|
|
2147
|
-
|
|
2148
|
-
// only validate order when both roles are returned
|
|
2149
|
-
if (testRoles.length === 2) {
|
|
2150
|
-
const id2Index = rows.findIndex((r) => r.id === id2);
|
|
2151
|
-
const id1Index = rows.findIndex((r) => r.id === id1);
|
|
2152
|
-
expect(id2Index).toBeLessThan(id1Index);
|
|
2153
|
-
}
|
|
2154
|
-
});
|
|
2155
|
-
```
|
|
2156
|
-
|
|
2157
|
-
**Key:** Accept the uncertainty caused by transaction isolation, and only assert when validation is possible.
|
|
2158
|
-
|
|
2159
|
-
---
|
|
2160
|
-
|
|
2161
|
-
## Fixture Data Creation Tips
|
|
2162
|
-
|
|
2163
|
-
**→ Detailed guide (unique constraint handling, gen vs fetch selection, DB sequence reset, FixtureGenerator customization): `fixture-cli.md` "Practical Tips" section**
|