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