sonamu 0.10.4 → 0.10.6

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