@gencow/core 0.1.12 → 0.1.13

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.
@@ -0,0 +1,527 @@
1
+ /**
2
+ * packages/core/src/__tests__/crud.test.ts
3
+ *
4
+ * crud() v2 Zero-Boilerplate API Factory — 유닛 테스트
5
+ *
6
+ * 검증 항목:
7
+ * 1. 팩토리가 query/mutation을 글로벌 레지스트리에 자동 등록하는지
8
+ * 2. Secure by Default: 기본 auth 필수, public 옵션 동작
9
+ * 3. 반환 객체 구조: { list, get, create, update, remove }
10
+ * 4. 테이블명 기반 key 생성 (prefix 오버라이드)
11
+ * 5. options 기본값 (defaultLimit, maxLimit, realtime)
12
+ * 6. UUID id 자동 감지 (text PK → v.string())
13
+ * 7. list 반환값: { data: T[], total: number }
14
+ * 8. allowedFilters 화이트리스트 동작
15
+ *
16
+ * Run: bun test packages/core/src/__tests__/crud.test.ts
17
+ */
18
+
19
+ import { describe, it, expect, beforeAll } from "bun:test";
20
+ import { getRegisteredQueries, getRegisteredMutations, getQueryDef } from "../reactive";
21
+
22
+ // ─── Mock PgTable ────────────────────────────────────────────────────────────
23
+ // 실제 Drizzle pgTable을 사용 — getTableName() 등 Drizzle 공식 API 호환 보장
24
+
25
+ import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
26
+
27
+ // ─── crud import ───────────────────────────────────────────────────────
28
+
29
+ import { crud } from "../crud";
30
+
31
+ // ─── 테스트 테이블 정의 ────────────────────────────────────────────────────
32
+
33
+ const tasks = pgTable("tasks", {
34
+ id: serial("id").primaryKey(),
35
+ title: text("title").notNull(),
36
+ description: text("description"),
37
+ userId: text("user_id"),
38
+ createdAt: timestamp("created_at").defaultNow(),
39
+ updatedAt: timestamp("updated_at").defaultNow(),
40
+ });
41
+
42
+ const articles = pgTable("articles", {
43
+ id: serial("id").primaryKey(),
44
+ title: text("title"),
45
+ createdAt: timestamp("created_at").defaultNow(),
46
+ });
47
+
48
+ const items = pgTable("items", {
49
+ id: serial("id").primaryKey(),
50
+ name: text("name"),
51
+ });
52
+
53
+ // ─── Mock 유틸 ─────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * list handler가 parallel로 SELECT + COUNT를 실행하므로,
57
+ * select()가 두 가지 호출 패턴을 지원해야 함:
58
+ * 1. select() → data query chain
59
+ * 2. select({count: _}) → count query chain
60
+ */
61
+ function createListMockCtx(mockData: any[], opts?: { authCalled?: { value: boolean } }) {
62
+ // data select chain
63
+ const dataChain = {
64
+ from: () => dataChain,
65
+ where: () => dataChain,
66
+ orderBy: () => dataChain,
67
+ limit: () => dataChain,
68
+ offset: () => Promise.resolve(mockData),
69
+ };
70
+ // count select chain
71
+ const countChain = {
72
+ from: () => countChain,
73
+ where: () => Promise.resolve([{ count: mockData.length }]),
74
+ };
75
+
76
+ return {
77
+ auth: {
78
+ requireAuth: () => {
79
+ if (opts?.authCalled) opts.authCalled.value = true;
80
+ return { user: { id: "user-1" } };
81
+ },
82
+ getSession: () => null,
83
+ },
84
+ db: {
85
+ select: (selectArg?: any) => {
86
+ // select({count: ...}) — count query
87
+ if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
88
+ return countChain;
89
+ }
90
+ // select() — data query
91
+ return dataChain;
92
+ },
93
+ },
94
+ };
95
+ }
96
+
97
+ /**
98
+ * mutation handler 실행용 mock context.
99
+ * insert/update/delete + realtime emit + fetchListWithTotal(select+count) 지원.
100
+ */
101
+ function createMutationMockCtx(opts: {
102
+ listData?: any[];
103
+ insertResult?: any;
104
+ authUser?: any;
105
+ }) {
106
+ const listData = opts.listData ?? [];
107
+ const insertResult = opts.insertResult;
108
+ const emitted: { key: string; data: any }[] = [];
109
+ let capturedValues: any = null;
110
+
111
+ // select chain: data + count 양쪽 지원
112
+ const selectDataChain = {
113
+ from: () => selectDataChain,
114
+ where: () => selectDataChain,
115
+ orderBy: () => Promise.resolve(listData),
116
+ };
117
+ const selectCountChain = {
118
+ from: () => selectCountChain,
119
+ where: () => Promise.resolve([{ count: listData.length }]),
120
+ };
121
+
122
+ const mockCtx = {
123
+ auth: {
124
+ requireAuth: () => opts.authUser ?? { id: "user-1", email: "test@example.com" },
125
+ },
126
+ db: {
127
+ select: (selectArg?: any) => {
128
+ if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
129
+ return selectCountChain;
130
+ }
131
+ return selectDataChain;
132
+ },
133
+ insert: () => ({
134
+ values: (v: any) => { capturedValues = v; return { returning: () => Promise.resolve([insertResult]) }; },
135
+ }),
136
+ update: () => ({
137
+ set: () => ({ where: () => ({ returning: () => Promise.resolve([insertResult]) }) }),
138
+ }),
139
+ delete: () => ({
140
+ where: () => Promise.resolve(),
141
+ }),
142
+ },
143
+ realtime: {
144
+ emit: (key: string, data: any) => emitted.push({ key, data }),
145
+ },
146
+ };
147
+
148
+ return { mockCtx, emitted, getCapturedValues: () => capturedValues };
149
+ }
150
+
151
+
152
+ // ═══════════════════════════════════════════════════════════════════════════════
153
+ // 1. 기본 동작: 팩토리 반환값 + 레지스트리 등록
154
+ // ═══════════════════════════════════════════════════════════════════════════════
155
+
156
+ describe("crud() — 기본 동작", () => {
157
+ let result: ReturnType<typeof crud>;
158
+
159
+ beforeAll(() => {
160
+ result = crud(tasks);
161
+ });
162
+
163
+ it("{ list, get, create, update, remove } 5개 프로퍼티를 반환한다", () => {
164
+ expect(result).toHaveProperty("list");
165
+ expect(result).toHaveProperty("get");
166
+ expect(result).toHaveProperty("create");
167
+ expect(result).toHaveProperty("update");
168
+ expect(result).toHaveProperty("remove");
169
+ expect(Object.keys(result)).toHaveLength(5);
170
+ });
171
+
172
+ it("query/mutation 각각 레지스트리에 등록된다", () => {
173
+ const queries = getRegisteredQueries();
174
+ const mutations = getRegisteredMutations();
175
+
176
+ // query: tasks.list, tasks.get
177
+ expect(queries).toContain("tasks.list");
178
+ expect(queries).toContain("tasks.get");
179
+
180
+ // mutation: tasks.create, tasks.update, tasks.remove
181
+ const mutationNames = mutations.map((m: any) => m.name);
182
+ expect(mutationNames).toContain("tasks.create");
183
+ expect(mutationNames).toContain("tasks.update");
184
+ expect(mutationNames).toContain("tasks.remove");
185
+ });
186
+
187
+ it("등록된 query/mutation은 핸들러를 가지고 있다", () => {
188
+ const listDef = getQueryDef("tasks.list");
189
+ expect(listDef).toBeDefined();
190
+ expect(typeof listDef!.handler).toBe("function");
191
+
192
+ const getDef = getQueryDef("tasks.get");
193
+ expect(getDef).toBeDefined();
194
+ expect(typeof getDef!.handler).toBe("function");
195
+ });
196
+ });
197
+
198
+ // ═══════════════════════════════════════════════════════════════════════════════
199
+ // 2. Secure by Default — 인증 기본 활성화
200
+ // ═══════════════════════════════════════════════════════════════════════════════
201
+
202
+ describe("crud() — Secure by Default", () => {
203
+ it("기본 호출 시 모든 query/mutation이 isPublic === false (auth 필수)", () => {
204
+ const result = crud(articles);
205
+
206
+ const listDef = getQueryDef("articles.list");
207
+ const getDef = getQueryDef("articles.get");
208
+
209
+ expect(listDef!.isPublic).toBe(false);
210
+ expect(getDef!.isPublic).toBe(false);
211
+
212
+ const mutations = getRegisteredMutations();
213
+ const createDef = mutations.find((m: any) => m.name === "articles.create");
214
+ const updateDef = mutations.find((m: any) => m.name === "articles.update");
215
+ const removeDef = mutations.find((m: any) => m.name === "articles.remove");
216
+
217
+ expect(createDef!.isPublic).toBe(false);
218
+ expect(updateDef!.isPublic).toBe(false);
219
+ expect(removeDef!.isPublic).toBe(false);
220
+ });
221
+
222
+ it("{ public: true } 옵션 시 모든 엔드포인트가 isPublic === true", () => {
223
+ const result = crud(items, { public: true });
224
+
225
+ const listDef = getQueryDef("items.list");
226
+ const getDef = getQueryDef("items.get");
227
+
228
+ expect(listDef!.isPublic).toBe(true);
229
+ expect(getDef!.isPublic).toBe(true);
230
+
231
+ const mutations = getRegisteredMutations();
232
+ const createDef = mutations.find((m: any) => m.name === "items.create");
233
+ expect(createDef!.isPublic).toBe(true);
234
+ });
235
+ });
236
+
237
+ // ═══════════════════════════════════════════════════════════════════════════════
238
+ // 3. prefix 오버라이드
239
+ // ═══════════════════════════════════════════════════════════════════════════════
240
+
241
+ describe("crud() — prefix 오버라이드", () => {
242
+ it("prefix 옵션으로 키 네임스페이스를 변경할 수 있다", () => {
243
+ const customTable = pgTable("my_items", {
244
+ id: serial("id").primaryKey(),
245
+ name: text("name"),
246
+ });
247
+
248
+ crud(customTable, { prefix: "products", public: true });
249
+
250
+ const listDef = getQueryDef("products.list");
251
+ const getDef = getQueryDef("products.get");
252
+
253
+ expect(listDef).toBeDefined();
254
+ expect(getDef).toBeDefined();
255
+
256
+ const mutations = getRegisteredMutations();
257
+ expect(mutations.find((m: any) => m.name === "products.create")).toBeDefined();
258
+ expect(mutations.find((m: any) => m.name === "products.update")).toBeDefined();
259
+ expect(mutations.find((m: any) => m.name === "products.remove")).toBeDefined();
260
+ });
261
+ });
262
+
263
+ // ═══════════════════════════════════════════════════════════════════════════════
264
+ // 4. id 컬럼 필수 체크
265
+ // ═══════════════════════════════════════════════════════════════════════════════
266
+
267
+ describe("crud() — 검증", () => {
268
+ it("id 컬럼이 없는 테이블은 에러를 던진다", () => {
269
+ // id 컬럼이 없는 실제 Drizzle 테이블
270
+ const badTable = pgTable("bad", {
271
+ name: text("name"),
272
+ });
273
+
274
+ expect(() => crud(badTable)).toThrow("[crud]");
275
+ expect(() => crud(badTable)).toThrow("must have an 'id' column");
276
+ });
277
+ });
278
+
279
+ // ═══════════════════════════════════════════════════════════════════════════════
280
+ // 5. 핸들러 실행 — list ({ data, total } 반환 검증)
281
+ // ═══════════════════════════════════════════════════════════════════════════════
282
+
283
+ describe("crud() — list handler 실행", () => {
284
+ it("list handler가 { data: T[], total: number } 형태로 반환한다", async () => {
285
+ const testTable = pgTable("handler_test", {
286
+ id: serial("id").primaryKey(),
287
+ createdAt: timestamp("created_at").defaultNow(),
288
+ });
289
+
290
+ crud(testTable, { public: true });
291
+
292
+ const listDef = getQueryDef("handler_test.list");
293
+ expect(listDef).toBeDefined();
294
+
295
+ const mockData = [{ id: 1 }, { id: 2 }];
296
+ const mockCtx = createListMockCtx(mockData);
297
+
298
+ const result = await listDef!.handler(mockCtx, {});
299
+
300
+ // { data, total } 형태 검증
301
+ expect(result).toHaveProperty("data");
302
+ expect(result).toHaveProperty("total");
303
+ expect(result.data).toEqual(mockData);
304
+ expect(result.total).toBe(2);
305
+ });
306
+ });
307
+
308
+ // ═══════════════════════════════════════════════════════════════════════════════
309
+ // 6. 핸들러 실행 — create (auth + userId 자동 주입 + realtime { data, total })
310
+ // ═══════════════════════════════════════════════════════════════════════════════
311
+
312
+ describe("crud() — create handler 실행", () => {
313
+ it("인증된 사용자의 create가 userId 자동 주입 + { data, total } realtime emit", async () => {
314
+ const testTable = pgTable("create_test", {
315
+ id: serial("id").primaryKey(),
316
+ title: text("title"),
317
+ userId: text("user_id"),
318
+ createdAt: timestamp("created_at").defaultNow(),
319
+ });
320
+
321
+ crud(testTable);
322
+
323
+ const mutations = getRegisteredMutations();
324
+ const createDef = mutations.find((m: any) => m.name === "create_test.create");
325
+ expect(createDef).toBeDefined();
326
+
327
+ const insertedData = { id: 42, title: "New Task", userId: "user-1" };
328
+ const { mockCtx, emitted, getCapturedValues } = createMutationMockCtx({
329
+ listData: [insertedData],
330
+ insertResult: insertedData,
331
+ });
332
+
333
+ const result = await createDef!.handler(mockCtx, { title: "New Task" });
334
+ expect(result).toEqual(insertedData);
335
+
336
+ // userId 자동 주입 확인
337
+ expect(getCapturedValues().userId).toBe("user-1");
338
+
339
+ // realtime emit — { data, total } 형태 확인
340
+ expect(emitted.length).toBeGreaterThanOrEqual(1);
341
+ expect(emitted[0].key).toBe("create_test.list");
342
+ expect(emitted[0].data).toHaveProperty("data");
343
+ expect(emitted[0].data).toHaveProperty("total");
344
+ expect(emitted[0].data.total).toBe(1);
345
+ });
346
+ });
347
+
348
+ // ═══════════════════════════════════════════════════════════════════════════════
349
+ // 7. 핸들러 실행 — remove (realtime { data, total })
350
+ // ═══════════════════════════════════════════════════════════════════════════════
351
+
352
+ describe("crud() — remove handler 실행", () => {
353
+ it("remove가 ctx.db.delete().where() 호출 후 { data, total } realtime emit 한다", async () => {
354
+ const testTable = pgTable("remove_test", {
355
+ id: serial("id").primaryKey(),
356
+ createdAt: timestamp("created_at").defaultNow(),
357
+ });
358
+
359
+ crud(testTable);
360
+
361
+ const mutations = getRegisteredMutations();
362
+ const removeDef = mutations.find((m: any) => m.name === "remove_test.remove");
363
+ expect(removeDef).toBeDefined();
364
+
365
+ const { mockCtx, emitted } = createMutationMockCtx({
366
+ listData: [], // 삭제 후 빈 리스트
367
+ });
368
+
369
+ const result = await removeDef!.handler(mockCtx, { id: 99 });
370
+ expect(result).toEqual({ success: true });
371
+
372
+ // realtime emit — { data, total } 형태
373
+ expect(emitted.length).toBeGreaterThanOrEqual(1);
374
+ expect(emitted[0].key).toBe("remove_test.list");
375
+ expect(emitted[0].data).toHaveProperty("data");
376
+ expect(emitted[0].data).toHaveProperty("total");
377
+ expect(emitted[0].data.total).toBe(0);
378
+ });
379
+ });
380
+
381
+ // ═══════════════════════════════════════════════════════════════════════════════
382
+ // 8. public: true — auth skip 확인
383
+ // ═══════════════════════════════════════════════════════════════════════════════
384
+
385
+ describe("crud() — public 모드 auth skip", () => {
386
+ it("public: true 시 requireAuth를 호출하지 않는다", async () => {
387
+ const testTable = pgTable("pub_test", {
388
+ id: serial("id").primaryKey(),
389
+ createdAt: timestamp("created_at").defaultNow(),
390
+ });
391
+
392
+ crud(testTable, { public: true });
393
+
394
+ const listDef = getQueryDef("pub_test.list");
395
+ expect(listDef).toBeDefined();
396
+
397
+ const authCalled = { value: false };
398
+ const mockCtx = createListMockCtx([{ id: 1 }], { authCalled });
399
+
400
+ // public 모드에서는 requireAuth를 호출하면 안됨 → 별도 mock
401
+ mockCtx.auth.requireAuth = () => { authCalled.value = true; throw new Error("should not be called"); };
402
+
403
+ const result = await listDef!.handler(mockCtx, {});
404
+ expect(authCalled.value).toBe(false);
405
+ expect(result.data).toEqual([{ id: 1 }]);
406
+ expect(result.total).toBe(1);
407
+ });
408
+ });
409
+
410
+ // ═══════════════════════════════════════════════════════════════════════════════
411
+ // 9. UUID id 자동 감지
412
+ // ═══════════════════════════════════════════════════════════════════════════════
413
+
414
+ describe("crud() — UUID id 자동 감지", () => {
415
+ it("text PK 테이블에서 get args에 string id validator를 사용한다", () => {
416
+ const uuidTable = pgTable("uuid_test", {
417
+ id: text("id").primaryKey(), // UUID string PK
418
+ name: text("name"),
419
+ });
420
+
421
+ crud(uuidTable, { public: true });
422
+
423
+ const getDef = getQueryDef("uuid_test.get");
424
+ expect(getDef).toBeDefined();
425
+ // argsSchema에 id가 존재 (string 타입)
426
+ expect(getDef!.argsSchema).toBeDefined();
427
+ expect(getDef!.argsSchema).toHaveProperty("id");
428
+ });
429
+
430
+ it("serial PK 테이블에서 get args에 number id validator를 사용한다", () => {
431
+ const serialTable = pgTable("serial_test", {
432
+ id: serial("id").primaryKey(),
433
+ name: text("name"),
434
+ });
435
+
436
+ crud(serialTable, { public: true });
437
+
438
+ const getDef = getQueryDef("serial_test.get");
439
+ expect(getDef).toBeDefined();
440
+ expect(getDef!.argsSchema).toBeDefined();
441
+ expect(getDef!.argsSchema).toHaveProperty("id");
442
+ });
443
+ });
444
+
445
+ // ═══════════════════════════════════════════════════════════════════════════════
446
+ // 10. allowedFilters — 화이트리스트 동작
447
+ // ═══════════════════════════════════════════════════════════════════════════════
448
+
449
+ describe("crud() — allowedFilters", () => {
450
+ it("allowedFilters에 명시된 필드의 필터가 적용된다", async () => {
451
+ const filterTable = pgTable("filter_test", {
452
+ id: serial("id").primaryKey(),
453
+ status: text("status"),
454
+ category: text("category"),
455
+ userId: text("user_id"),
456
+ createdAt: timestamp("created_at").defaultNow(),
457
+ });
458
+
459
+ crud(filterTable, {
460
+ public: true,
461
+ allowedFilters: ["status", "category"],
462
+ });
463
+
464
+ const listDef = getQueryDef("filter_test.list");
465
+ expect(listDef).toBeDefined();
466
+
467
+ // allowedFilters를 사용하면 handler가 에러 없이 실행됨
468
+ const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
469
+ const result = await listDef!.handler(mockCtx, {
470
+ filters: { status: "active" },
471
+ });
472
+
473
+ expect(result).toHaveProperty("data");
474
+ expect(result).toHaveProperty("total");
475
+ });
476
+
477
+ it("allowedFilters에 없는 필드의 필터는 무시된다 (보안)", async () => {
478
+ const filterTable2 = pgTable("filter_test2", {
479
+ id: serial("id").primaryKey(),
480
+ status: text("status"),
481
+ userId: text("user_id"),
482
+ createdAt: timestamp("created_at").defaultNow(),
483
+ });
484
+
485
+ crud(filterTable2, {
486
+ public: true,
487
+ allowedFilters: ["status"],
488
+ });
489
+
490
+ const listDef = getQueryDef("filter_test2.list");
491
+ expect(listDef).toBeDefined();
492
+
493
+ // userId는 allowedFilters에 없으므로 무시되어야 함
494
+ const mockCtx = createListMockCtx([{ id: 1 }]);
495
+ const result = await listDef!.handler(mockCtx, {
496
+ filters: { userId: "hacker-attempt" },
497
+ });
498
+
499
+ // 에러 없이 정상 실행 (필터가 무시됨)
500
+ expect(result).toHaveProperty("data");
501
+ expect(result).toHaveProperty("total");
502
+ });
503
+
504
+ it("filters가 비어있으면 에러 없이 동작한다", async () => {
505
+ const filterTable3 = pgTable("filter_test3", {
506
+ id: serial("id").primaryKey(),
507
+ status: text("status"),
508
+ createdAt: timestamp("created_at").defaultNow(),
509
+ });
510
+
511
+ crud(filterTable3, {
512
+ public: true,
513
+ allowedFilters: ["status"],
514
+ });
515
+
516
+ const listDef = getQueryDef("filter_test3.list");
517
+ const mockCtx = createListMockCtx([{ id: 1 }]);
518
+
519
+ // filters 미전달
520
+ const result1 = await listDef!.handler(mockCtx, {});
521
+ expect(result1).toHaveProperty("data");
522
+
523
+ // filters 빈 객체
524
+ const result2 = await listDef!.handler(mockCtx, { filters: {} });
525
+ expect(result2).toHaveProperty("data");
526
+ });
527
+ });
@@ -142,17 +142,17 @@ describe("Scheduler 실행 — registerAction + executeAction", () => {
142
142
  expect(result).toBe("Hello World");
143
143
  });
144
144
 
145
- it("미등록 action executeAction → 에러를 throw한다", async () => {
145
+ it("미등록 action executeAction → throw 안 함, console.error 출력", async () => {
146
146
  const scheduler = createScheduler();
147
- // 미등록 action은 에러를 throw해야
148
- let threw = false;
149
- try {
150
- await scheduler.executeAction("nonexistent");
151
- } catch (e) {
152
- threw = true;
153
- expect((e as Error).message).toContain("not registered");
154
- }
155
- expect(threw).toBe(true);
147
+ // 공개 API는 에러를 삼기고 console.error만 출력
148
+ const errors: string[] = [];
149
+ const origError = console.error;
150
+ console.error = (...args: any[]) => { errors.push(args.map(String).join(" ")); };
151
+
152
+ await scheduler.executeAction("nonexistent"); // throw하지 않음
153
+
154
+ console.error = origError;
155
+ expect(errors.some(e => e.includes("nonexistent"))).toBe(true);
156
156
  });
157
157
 
158
158
  it("action 에러 시 다른 action에 영향 없음", async () => {
@@ -166,14 +166,11 @@ describe("Scheduler 실행 — registerAction + executeAction", () => {
166
166
  secondRan = true;
167
167
  });
168
168
 
169
- // 실패하는 action — 에러를 throw함
170
- let threw = false;
171
- try {
172
- await scheduler.executeAction("failing");
173
- } catch {
174
- threw = true;
175
- }
176
- expect(threw).toBe(true);
169
+ // 실패하는 action — 공개 API는 throw하지 않음 (에러를 삼김)
170
+ const origError = console.error;
171
+ console.error = () => {}; // suppress expected error log
172
+ await scheduler.executeAction("failing"); // throw하지 않음
173
+ console.error = origError;
177
174
 
178
175
  // 정상 action은 여전히 동작
179
176
  await scheduler.executeAction("healthy");