@gencow/core 0.1.11 → 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.
- package/dist/crud.d.ts +67 -25
- package/dist/crud.js +221 -101
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/rls-db.d.ts +1 -16
- package/dist/rls-db.js +14 -9
- package/dist/scheduler.js +9 -1
- package/package.json +39 -38
- package/src/__tests__/crud.test.ts +527 -0
- package/src/__tests__/scheduler-exec.test.ts +15 -18
- package/src/crud.ts +269 -121
- package/src/index.ts +4 -1
- package/src/rls-db.ts +16 -11
- package/src/scheduler.ts +8 -1
- package/dist/scoped-db.d.ts +0 -34
- package/dist/scoped-db.js +0 -364
- package/dist/table.d.ts +0 -67
- package/dist/table.js +0 -98
|
@@ -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 →
|
|
145
|
+
it("미등록 action executeAction → throw 안 함, console.error 출력", async () => {
|
|
146
146
|
const scheduler = createScheduler();
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
expect(
|
|
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 — 에러를
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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");
|