@gencow/core 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +5 -5
  4. package/dist/index.js +2 -2
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow.js +4 -11
  16. package/dist/workflows-api.js +5 -12
  17. package/package.json +45 -42
  18. package/src/__tests__/auth.test.ts +90 -86
  19. package/src/__tests__/crons.test.ts +69 -67
  20. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  21. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  22. package/src/__tests__/crud.test.ts +694 -711
  23. package/src/__tests__/dist-exports.test.ts +120 -120
  24. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  25. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  26. package/src/__tests__/fixtures/basic/index.ts +1 -1
  27. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  28. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  29. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  30. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  31. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  32. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  33. package/src/__tests__/helpers/seed-like-fill.ts +47 -41
  34. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  35. package/src/__tests__/httpaction.test.ts +91 -91
  36. package/src/__tests__/image-optimization.test.ts +570 -574
  37. package/src/__tests__/load.test.ts +321 -308
  38. package/src/__tests__/network-sim.test.ts +238 -215
  39. package/src/__tests__/reactive.test.ts +380 -358
  40. package/src/__tests__/retry.test.ts +99 -84
  41. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  42. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  43. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  44. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  45. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  46. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  47. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  48. package/src/__tests__/scheduler-durable.test.ts +117 -117
  49. package/src/__tests__/scheduler-exec.test.ts +258 -246
  50. package/src/__tests__/scheduler.test.ts +129 -111
  51. package/src/__tests__/storage.test.ts +282 -269
  52. package/src/__tests__/tsconfig.json +6 -6
  53. package/src/__tests__/validator.test.ts +236 -232
  54. package/src/__tests__/workflow.test.ts +309 -286
  55. package/src/__tests__/ws-integration.test.ts +223 -218
  56. package/src/__tests__/ws-scale.test.ts +168 -159
  57. package/src/auth-config.ts +18 -18
  58. package/src/auth.ts +106 -106
  59. package/src/crons.ts +77 -77
  60. package/src/crud.ts +523 -479
  61. package/src/index.ts +69 -5
  62. package/src/reactive.ts +357 -331
  63. package/src/retry.ts +51 -54
  64. package/src/rls-db.ts +195 -205
  65. package/src/rls.ts +33 -36
  66. package/src/scheduler.ts +237 -211
  67. package/src/server.ts +0 -1
  68. package/src/storage.ts +632 -593
  69. package/src/v.ts +119 -114
  70. package/src/workflow-types.ts +67 -70
  71. package/src/workflow.ts +99 -116
  72. package/src/workflows-api.ts +231 -241
  73. package/dist/db.d.ts +0 -13
  74. package/dist/db.js +0 -16
  75. package/src/db.ts +0 -18
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { describe, it, expect, beforeAll } from "bun:test";
20
- import { getRegisteredQueries, getRegisteredMutations, getQueryDef } from "../reactive";
20
+ import { getRegisteredQueries, getRegisteredMutations, getQueryDef } from "../reactive.js";
21
21
 
22
22
  // ─── Mock PgTable ────────────────────────────────────────────────────────────
23
23
  // 실제 Drizzle pgTable을 사용 — getTableName() 등 Drizzle 공식 API 호환 보장
@@ -26,28 +26,28 @@ import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
26
26
 
27
27
  // ─── crud import ───────────────────────────────────────────────────────
28
28
 
29
- import { crud, parseFilterNode, applyFilterOp } from "../crud";
29
+ import { crud, parseFilterNode, applyFilterOp } from "../crud.js";
30
30
 
31
31
  // ─── 테스트 테이블 정의 ────────────────────────────────────────────────────
32
32
 
33
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(),
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
40
  });
41
41
 
42
42
  const articles = pgTable("articles", {
43
- id: serial("id").primaryKey(),
44
- title: text("title"),
45
- createdAt: timestamp("created_at").defaultNow(),
43
+ id: serial("id").primaryKey(),
44
+ title: text("title"),
45
+ createdAt: timestamp("created_at").defaultNow(),
46
46
  });
47
47
 
48
48
  const items = pgTable("items", {
49
- id: serial("id").primaryKey(),
50
- name: text("name"),
49
+ id: serial("id").primaryKey(),
50
+ name: text("name"),
51
51
  });
52
52
 
53
53
  // ─── Mock 유틸 ─────────────────────────────────────────────────────────
@@ -59,140 +59,138 @@ const items = pgTable("items", {
59
59
  * 2. select({count: _}) → count query chain
60
60
  */
61
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
- };
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
95
  }
96
96
 
97
97
  /**
98
98
  * mutation handler 실행용 mock context.
99
99
  * insert/update/delete + realtime emit + fetchListWithTotal(select+count) 지원.
100
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 }),
101
+ function createMutationMockCtx(opts: { listData?: any[]; insertResult?: any; authUser?: any }) {
102
+ const listData = opts.listData ?? [];
103
+ const insertResult = opts.insertResult;
104
+ const emitted: { key: string; data: any }[] = [];
105
+ let capturedValues: any = null;
106
+
107
+ // select chain: data + count 양쪽 지원
108
+ const selectDataChain = {
109
+ from: () => selectDataChain,
110
+ where: () => selectDataChain,
111
+ orderBy: () => Promise.resolve(listData),
112
+ };
113
+ const selectCountChain = {
114
+ from: () => selectCountChain,
115
+ where: () => Promise.resolve([{ count: listData.length }]),
116
+ };
117
+
118
+ const mockCtx = {
119
+ auth: {
120
+ requireAuth: () => opts.authUser ?? { id: "user-1", email: "test@example.com" },
121
+ },
122
+ db: {
123
+ select: (selectArg?: any) => {
124
+ if (selectArg && typeof selectArg === "object" && "count" in selectArg) {
125
+ return selectCountChain;
126
+ }
127
+ return selectDataChain;
128
+ },
129
+ insert: () => ({
130
+ values: (v: any) => {
131
+ capturedValues = v;
132
+ return { returning: () => Promise.resolve([insertResult]) };
145
133
  },
146
- };
147
-
148
- return { mockCtx, emitted, getCapturedValues: () => capturedValues };
134
+ }),
135
+ update: () => ({
136
+ set: () => ({ where: () => ({ returning: () => Promise.resolve([insertResult]) }) }),
137
+ }),
138
+ delete: () => ({
139
+ where: () => Promise.resolve(),
140
+ }),
141
+ },
142
+ realtime: {
143
+ emit: (key: string, data: any) => emitted.push({ key, data }),
144
+ },
145
+ };
146
+
147
+ return { mockCtx, emitted, getCapturedValues: () => capturedValues };
149
148
  }
150
149
 
151
-
152
150
  // ═══════════════════════════════════════════════════════════════════════════════
153
151
  // 1. 기본 동작: 팩토리 반환값 + 레지스트리 등록
154
152
  // ═══════════════════════════════════════════════════════════════════════════════
155
153
 
156
154
  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
- });
155
+ let result: ReturnType<typeof crud>;
156
+
157
+ beforeAll(() => {
158
+ result = crud(tasks);
159
+ });
160
+
161
+ it("{ list, get, create, update, remove } 5개 프로퍼티를 반환한다", () => {
162
+ expect(result).toHaveProperty("list");
163
+ expect(result).toHaveProperty("get");
164
+ expect(result).toHaveProperty("create");
165
+ expect(result).toHaveProperty("update");
166
+ expect(result).toHaveProperty("remove");
167
+ expect(Object.keys(result)).toHaveLength(5);
168
+ });
169
+
170
+ it("query/mutation 각각 레지스트리에 등록된다", () => {
171
+ const queries = getRegisteredQueries();
172
+ const mutations = getRegisteredMutations();
173
+
174
+ // query: tasks.list, tasks.get
175
+ expect(queries).toContain("tasks.list");
176
+ expect(queries).toContain("tasks.get");
177
+
178
+ // mutation: tasks.create, tasks.update, tasks.remove
179
+ const mutationNames = mutations.map((m: any) => m.name);
180
+ expect(mutationNames).toContain("tasks.create");
181
+ expect(mutationNames).toContain("tasks.update");
182
+ expect(mutationNames).toContain("tasks.remove");
183
+ });
184
+
185
+ it("등록된 query/mutation은 핸들러를 가지고 있다", () => {
186
+ const listDef = getQueryDef("tasks.list");
187
+ expect(listDef).toBeDefined();
188
+ expect(typeof listDef!.handler).toBe("function");
189
+
190
+ const getDef = getQueryDef("tasks.get");
191
+ expect(getDef).toBeDefined();
192
+ expect(typeof getDef!.handler).toBe("function");
193
+ });
196
194
  });
197
195
 
198
196
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -200,38 +198,38 @@ describe("crud() — 기본 동작", () => {
200
198
  // ═══════════════════════════════════════════════════════════════════════════════
201
199
 
202
200
  describe("crud() — Secure by Default", () => {
203
- it("기본 호출 시 모든 query/mutation이 isPublic === false (auth 필수)", () => {
204
- const result = crud(articles);
201
+ it("기본 호출 시 모든 query/mutation이 isPublic === false (auth 필수)", () => {
202
+ const result = crud(articles);
205
203
 
206
- const listDef = getQueryDef("articles.list");
207
- const getDef = getQueryDef("articles.get");
204
+ const listDef = getQueryDef("articles.list");
205
+ const getDef = getQueryDef("articles.get");
208
206
 
209
- expect(listDef!.isPublic).toBe(false);
210
- expect(getDef!.isPublic).toBe(false);
207
+ expect(listDef!.isPublic).toBe(false);
208
+ expect(getDef!.isPublic).toBe(false);
211
209
 
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");
210
+ const mutations = getRegisteredMutations();
211
+ const createDef = mutations.find((m: any) => m.name === "articles.create");
212
+ const updateDef = mutations.find((m: any) => m.name === "articles.update");
213
+ const removeDef = mutations.find((m: any) => m.name === "articles.remove");
216
214
 
217
- expect(createDef!.isPublic).toBe(false);
218
- expect(updateDef!.isPublic).toBe(false);
219
- expect(removeDef!.isPublic).toBe(false);
220
- });
215
+ expect(createDef!.isPublic).toBe(false);
216
+ expect(updateDef!.isPublic).toBe(false);
217
+ expect(removeDef!.isPublic).toBe(false);
218
+ });
221
219
 
222
- it("{ public: true } 옵션 시 모든 엔드포인트가 isPublic === true", () => {
223
- const result = crud(items, { public: true });
220
+ it("{ public: true } 옵션 시 모든 엔드포인트가 isPublic === true", () => {
221
+ const result = crud(items, { public: true });
224
222
 
225
- const listDef = getQueryDef("items.list");
226
- const getDef = getQueryDef("items.get");
223
+ const listDef = getQueryDef("items.list");
224
+ const getDef = getQueryDef("items.get");
227
225
 
228
- expect(listDef!.isPublic).toBe(true);
229
- expect(getDef!.isPublic).toBe(true);
226
+ expect(listDef!.isPublic).toBe(true);
227
+ expect(getDef!.isPublic).toBe(true);
230
228
 
231
- const mutations = getRegisteredMutations();
232
- const createDef = mutations.find((m: any) => m.name === "items.create");
233
- expect(createDef!.isPublic).toBe(true);
234
- });
229
+ const mutations = getRegisteredMutations();
230
+ const createDef = mutations.find((m: any) => m.name === "items.create");
231
+ expect(createDef!.isPublic).toBe(true);
232
+ });
235
233
  });
236
234
 
237
235
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -239,25 +237,25 @@ describe("crud() — Secure by Default", () => {
239
237
  // ═══════════════════════════════════════════════════════════════════════════════
240
238
 
241
239
  describe("crud() — prefix 오버라이드", () => {
242
- it("prefix 옵션으로 키 네임스페이스를 변경할 수 있다", () => {
243
- const customTable = pgTable("my_items", {
244
- id: serial("id").primaryKey(),
245
- name: text("name"),
246
- });
240
+ it("prefix 옵션으로 키 네임스페이스를 변경할 수 있다", () => {
241
+ const customTable = pgTable("my_items", {
242
+ id: serial("id").primaryKey(),
243
+ name: text("name"),
244
+ });
247
245
 
248
- crud(customTable, { prefix: "products", public: true });
246
+ crud(customTable, { prefix: "products", public: true });
249
247
 
250
- const listDef = getQueryDef("products.list");
251
- const getDef = getQueryDef("products.get");
248
+ const listDef = getQueryDef("products.list");
249
+ const getDef = getQueryDef("products.get");
252
250
 
253
- expect(listDef).toBeDefined();
254
- expect(getDef).toBeDefined();
251
+ expect(listDef).toBeDefined();
252
+ expect(getDef).toBeDefined();
255
253
 
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
- });
254
+ const mutations = getRegisteredMutations();
255
+ expect(mutations.find((m: any) => m.name === "products.create")).toBeDefined();
256
+ expect(mutations.find((m: any) => m.name === "products.update")).toBeDefined();
257
+ expect(mutations.find((m: any) => m.name === "products.remove")).toBeDefined();
258
+ });
261
259
  });
262
260
 
263
261
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -265,15 +263,15 @@ describe("crud() — prefix 오버라이드", () => {
265
263
  // ═══════════════════════════════════════════════════════════════════════════════
266
264
 
267
265
  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");
266
+ it("id 컬럼이 없는 테이블은 에러를 던진다", () => {
267
+ // id 컬럼이 없는 실제 Drizzle 테이블
268
+ const badTable = pgTable("bad", {
269
+ name: text("name"),
276
270
  });
271
+
272
+ expect(() => crud(badTable)).toThrow("[crud]");
273
+ expect(() => crud(badTable)).toThrow("must have an 'id' column");
274
+ });
277
275
  });
278
276
 
279
277
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -281,28 +279,28 @@ describe("crud() — 검증", () => {
281
279
  // ═══════════════════════════════════════════════════════════════════════════════
282
280
 
283
281
  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
- });
282
+ it("list handler가 { data: T[], total: number } 형태로 반환한다", async () => {
283
+ const testTable = pgTable("handler_test", {
284
+ id: serial("id").primaryKey(),
285
+ createdAt: timestamp("created_at").defaultNow(),
286
+ });
289
287
 
290
- crud(testTable, { public: true });
288
+ crud(testTable, { public: true });
291
289
 
292
- const listDef = getQueryDef("handler_test.list");
293
- expect(listDef).toBeDefined();
290
+ const listDef = getQueryDef("handler_test.list");
291
+ expect(listDef).toBeDefined();
294
292
 
295
- const mockData = [{ id: 1 }, { id: 2 }];
296
- const mockCtx = createListMockCtx(mockData);
293
+ const mockData = [{ id: 1 }, { id: 2 }];
294
+ const mockCtx = createListMockCtx(mockData);
297
295
 
298
- const result = await listDef!.handler(mockCtx, {});
296
+ const result = await listDef!.handler(mockCtx, {});
299
297
 
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
- });
298
+ // { data, total } 형태 검증
299
+ expect(result).toHaveProperty("data");
300
+ expect(result).toHaveProperty("total");
301
+ expect(result.data).toEqual(mockData);
302
+ expect(result.total).toBe(2);
303
+ });
306
304
  });
307
305
 
308
306
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -310,39 +308,39 @@ describe("crud() — list handler 실행", () => {
310
308
  // ═══════════════════════════════════════════════════════════════════════════════
311
309
 
312
310
  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);
311
+ it("인증된 사용자의 create가 userId 자동 주입 + { data, total } realtime emit", async () => {
312
+ const testTable = pgTable("create_test", {
313
+ id: serial("id").primaryKey(),
314
+ title: text("title"),
315
+ userId: text("user_id"),
316
+ createdAt: timestamp("created_at").defaultNow(),
317
+ });
318
+
319
+ crud(testTable);
320
+
321
+ const mutations = getRegisteredMutations();
322
+ const createDef = mutations.find((m: any) => m.name === "create_test.create");
323
+ expect(createDef).toBeDefined();
324
+
325
+ const insertedData = { id: 42, title: "New Task", userId: "user-1" };
326
+ const { mockCtx, emitted, getCapturedValues } = createMutationMockCtx({
327
+ listData: [insertedData],
328
+ insertResult: insertedData,
345
329
  });
330
+
331
+ const result = await createDef!.handler(mockCtx, { title: "New Task" });
332
+ expect(result).toEqual(insertedData);
333
+
334
+ // userId 자동 주입 확인
335
+ expect(getCapturedValues().userId).toBe("user-1");
336
+
337
+ // realtime emit — { data, total } 형태 확인
338
+ expect(emitted.length).toBeGreaterThanOrEqual(1);
339
+ expect(emitted[0].key).toBe("create_test.list");
340
+ expect(emitted[0].data).toHaveProperty("data");
341
+ expect(emitted[0].data).toHaveProperty("total");
342
+ expect(emitted[0].data.total).toBe(1);
343
+ });
346
344
  });
347
345
 
348
346
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -350,32 +348,32 @@ describe("crud() — create handler 실행", () => {
350
348
  // ═══════════════════════════════════════════════════════════════════════════════
351
349
 
352
350
  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
- });
351
+ it("remove가 ctx.db.delete().where() 호출 후 { data, total } realtime emit 한다", async () => {
352
+ const testTable = pgTable("remove_test", {
353
+ id: serial("id").primaryKey(),
354
+ createdAt: timestamp("created_at").defaultNow(),
355
+ });
358
356
 
359
- crud(testTable);
357
+ crud(testTable);
360
358
 
361
- const mutations = getRegisteredMutations();
362
- const removeDef = mutations.find((m: any) => m.name === "remove_test.remove");
363
- expect(removeDef).toBeDefined();
359
+ const mutations = getRegisteredMutations();
360
+ const removeDef = mutations.find((m: any) => m.name === "remove_test.remove");
361
+ expect(removeDef).toBeDefined();
364
362
 
365
- const { mockCtx, emitted } = createMutationMockCtx({
366
- listData: [], // 삭제 후 빈 리스트
367
- });
363
+ const { mockCtx, emitted } = createMutationMockCtx({
364
+ listData: [], // 삭제 후 빈 리스트
365
+ });
368
366
 
369
- const result = await removeDef!.handler(mockCtx, { id: 99 });
370
- expect(result).toEqual({ success: true });
367
+ const result = await removeDef!.handler(mockCtx, { id: 99 });
368
+ expect(result).toEqual({ success: true });
371
369
 
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
- });
370
+ // realtime emit — { data, total } 형태
371
+ expect(emitted.length).toBeGreaterThanOrEqual(1);
372
+ expect(emitted[0].key).toBe("remove_test.list");
373
+ expect(emitted[0].data).toHaveProperty("data");
374
+ expect(emitted[0].data).toHaveProperty("total");
375
+ expect(emitted[0].data.total).toBe(0);
376
+ });
379
377
  });
380
378
 
381
379
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -383,28 +381,31 @@ describe("crud() — remove handler 실행", () => {
383
381
  // ═══════════════════════════════════════════════════════════════════════════════
384
382
 
385
383
  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
- });
384
+ it("public: true 시 requireAuth를 호출하지 않는다", async () => {
385
+ const testTable = pgTable("pub_test", {
386
+ id: serial("id").primaryKey(),
387
+ createdAt: timestamp("created_at").defaultNow(),
388
+ });
391
389
 
392
- crud(testTable, { public: true });
390
+ crud(testTable, { public: true });
393
391
 
394
- const listDef = getQueryDef("pub_test.list");
395
- expect(listDef).toBeDefined();
392
+ const listDef = getQueryDef("pub_test.list");
393
+ expect(listDef).toBeDefined();
396
394
 
397
- const authCalled = { value: false };
398
- const mockCtx = createListMockCtx([{ id: 1 }], { authCalled });
395
+ const authCalled = { value: false };
396
+ const mockCtx = createListMockCtx([{ id: 1 }], { authCalled });
399
397
 
400
- // public 모드에서는 requireAuth를 호출하면 안됨 → 별도 mock
401
- mockCtx.auth.requireAuth = () => { authCalled.value = true; throw new Error("should not be called"); };
398
+ // public 모드에서는 requireAuth를 호출하면 안됨 → 별도 mock
399
+ mockCtx.auth.requireAuth = () => {
400
+ authCalled.value = true;
401
+ throw new Error("should not be called");
402
+ };
402
403
 
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
- });
404
+ const result = await listDef!.handler(mockCtx, {});
405
+ expect(authCalled.value).toBe(false);
406
+ expect(result.data).toEqual([{ id: 1 }]);
407
+ expect(result.total).toBe(1);
408
+ });
408
409
  });
409
410
 
410
411
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -412,34 +413,34 @@ describe("crud() — public 모드 auth skip", () => {
412
413
  // ═══════════════════════════════════════════════════════════════════════════════
413
414
 
414
415
  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");
416
+ it("text PK 테이블에서 get args에 string id validator를 사용한다", () => {
417
+ const uuidTable = pgTable("uuid_test", {
418
+ id: text("id").primaryKey(), // UUID string PK
419
+ name: text("name"),
428
420
  });
429
421
 
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
- });
422
+ crud(uuidTable, { public: true });
435
423
 
436
- crud(serialTable, { public: true });
424
+ const getDef = getQueryDef("uuid_test.get");
425
+ expect(getDef).toBeDefined();
426
+ // argsSchema에 id가 존재 (string 타입)
427
+ expect(getDef!.argsSchema).toBeDefined();
428
+ expect(getDef!.argsSchema).toHaveProperty("id");
429
+ });
437
430
 
438
- const getDef = getQueryDef("serial_test.get");
439
- expect(getDef).toBeDefined();
440
- expect(getDef!.argsSchema).toBeDefined();
441
- expect(getDef!.argsSchema).toHaveProperty("id");
431
+ it("serial PK 테이블에서 get args에 number id validator를 사용한다", () => {
432
+ const serialTable = pgTable("serial_test", {
433
+ id: serial("id").primaryKey(),
434
+ name: text("name"),
442
435
  });
436
+
437
+ crud(serialTable, { public: true });
438
+
439
+ const getDef = getQueryDef("serial_test.get");
440
+ expect(getDef).toBeDefined();
441
+ expect(getDef!.argsSchema).toBeDefined();
442
+ expect(getDef!.argsSchema).toHaveProperty("id");
443
+ });
443
444
  });
444
445
 
445
446
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -447,83 +448,83 @@ describe("crud() — UUID id 자동 감지", () => {
447
448
  // ═══════════════════════════════════════════════════════════════════════════════
448
449
 
449
450
  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");
451
+ it("allowedFilters에 명시된 필드의 필터가 적용된다", async () => {
452
+ const filterTable = pgTable("filter_test", {
453
+ id: serial("id").primaryKey(),
454
+ status: text("status"),
455
+ category: text("category"),
456
+ userId: text("user_id"),
457
+ createdAt: timestamp("created_at").defaultNow(),
526
458
  });
459
+
460
+ crud(filterTable, {
461
+ public: true,
462
+ allowedFilters: ["status", "category"],
463
+ });
464
+
465
+ const listDef = getQueryDef("filter_test.list");
466
+ expect(listDef).toBeDefined();
467
+
468
+ // allowedFilters를 사용하면 handler가 에러 없이 실행됨
469
+ const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
470
+ const result = await listDef!.handler(mockCtx, {
471
+ filters: { status: "active" },
472
+ });
473
+
474
+ expect(result).toHaveProperty("data");
475
+ expect(result).toHaveProperty("total");
476
+ });
477
+
478
+ it("allowedFilters에 없는 필드의 필터는 무시된다 (보안)", async () => {
479
+ const filterTable2 = pgTable("filter_test2", {
480
+ id: serial("id").primaryKey(),
481
+ status: text("status"),
482
+ userId: text("user_id"),
483
+ createdAt: timestamp("created_at").defaultNow(),
484
+ });
485
+
486
+ crud(filterTable2, {
487
+ public: true,
488
+ allowedFilters: ["status"],
489
+ });
490
+
491
+ const listDef = getQueryDef("filter_test2.list");
492
+ expect(listDef).toBeDefined();
493
+
494
+ // userId는 allowedFilters에 없으므로 무시되어야 함
495
+ const mockCtx = createListMockCtx([{ id: 1 }]);
496
+ const result = await listDef!.handler(mockCtx, {
497
+ filters: { userId: "hacker-attempt" },
498
+ });
499
+
500
+ // 에러 없이 정상 실행 (필터가 무시됨)
501
+ expect(result).toHaveProperty("data");
502
+ expect(result).toHaveProperty("total");
503
+ });
504
+
505
+ it("filters가 비어있으면 에러 없이 동작한다", async () => {
506
+ const filterTable3 = pgTable("filter_test3", {
507
+ id: serial("id").primaryKey(),
508
+ status: text("status"),
509
+ createdAt: timestamp("created_at").defaultNow(),
510
+ });
511
+
512
+ crud(filterTable3, {
513
+ public: true,
514
+ allowedFilters: ["status"],
515
+ });
516
+
517
+ const listDef = getQueryDef("filter_test3.list");
518
+ const mockCtx = createListMockCtx([{ id: 1 }]);
519
+
520
+ // filters 미전달
521
+ const result1 = await listDef!.handler(mockCtx, {});
522
+ expect(result1).toHaveProperty("data");
523
+
524
+ // filters 빈 객체
525
+ const result2 = await listDef!.handler(mockCtx, { filters: {} });
526
+ expect(result2).toHaveProperty("data");
527
+ });
527
528
  });
528
529
 
529
530
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -531,190 +532,175 @@ describe("crud() — allowedFilters", () => {
531
532
  // ═══════════════════════════════════════════════════════════════════════════════
532
533
 
533
534
  describe("v3 Filter Engine — parseFilterNode", () => {
534
- // TC1: 하위 호환성 (Implicit eq)
535
- it("TC1: 단순 key-value는 암묵적 eq로 파싱된다 (하위호환)", () => {
536
- const table = pgTable("tc1_test", {
537
- id: serial("id").primaryKey(),
538
- status: text("status"),
539
- });
540
-
541
- const result = parseFilterNode({ status: "active" }, table);
542
- expect(result).toBeDefined();
543
- });
544
-
545
- it("TC1: 여러 key-value 동시 사용 시 모두 AND 결합된다", () => {
546
- const table = pgTable("tc1b_test", {
547
- id: serial("id").primaryKey(),
548
- status: text("status"),
549
- category: text("category"),
550
- });
551
-
552
- const result = parseFilterNode({ status: "active", category: "tech" }, table);
553
- expect(result).toBeDefined();
554
- });
555
-
556
- // TC2: 다중 논리망 (Nested OR & AND)
557
- it("TC2: AND + 중첩 OR 필터가 SQL 조건으로 빌드된다", () => {
558
- const table = pgTable("tc2_test", {
559
- id: serial("id").primaryKey(),
560
- category: text("category"),
561
- qty: serial("qty"),
562
- flag: text("flag"),
563
- });
564
-
565
- const result = parseFilterNode({
566
- AND: [
567
- { category: "A" },
568
- { OR: [
569
- { qty: { op: "gte", value: 10 } },
570
- { flag: true },
571
- ] },
572
- ],
573
- }, table);
574
-
575
- expect(result).toBeDefined();
576
- });
577
-
578
- it("TC2: 단순 OR 배열도 파싱된다", () => {
579
- const table = pgTable("tc2b_test", {
580
- id: serial("id").primaryKey(),
581
- title: text("title"),
582
- description: text("description"),
583
- });
584
-
585
- const result = parseFilterNode({
586
- OR: [
587
- { title: { op: "ilike", value: "%AI%" } },
588
- { description: { op: "ilike", value: "%데이터%" } },
589
- ],
590
- }, table);
591
-
592
- expect(result).toBeDefined();
593
- });
594
-
595
- // TC3: 보안 방어 — allowedFilters 위반
596
- it("TC3: allowedFilters에 없는 필드는 완전 묵살 (직접 접근)", () => {
597
- const table = pgTable("tc3_test", {
598
- id: serial("id").primaryKey(),
599
- status: text("status"),
600
- title: text("title"),
601
- passwordHash: text("password_hash"),
602
- });
603
-
604
- // password_hash 직접 접근 → 묵살
605
- const r1 = parseFilterNode(
606
- { passwordHash: { op: "eq", value: "xxx" } },
607
- table,
608
- ["status", "title"],
609
- );
610
- expect(r1).toBeUndefined(); // 모든 조건 제거됨
535
+ // TC1: 하위 호환성 (Implicit eq)
536
+ it("TC1: 단순 key-value는 암묵적 eq로 파싱된다 (하위호환)", () => {
537
+ const table = pgTable("tc1_test", {
538
+ id: serial("id").primaryKey(),
539
+ status: text("status"),
540
+ });
541
+
542
+ const result = parseFilterNode({ status: "active" }, table);
543
+ expect(result).toBeDefined();
544
+ });
545
+
546
+ it("TC1: 여러 key-value 동시 사용 시 모두 AND 결합된다", () => {
547
+ const table = pgTable("tc1b_test", {
548
+ id: serial("id").primaryKey(),
549
+ status: text("status"),
550
+ category: text("category"),
551
+ });
552
+
553
+ const result = parseFilterNode({ status: "active", category: "tech" }, table);
554
+ expect(result).toBeDefined();
555
+ });
556
+
557
+ // TC2: 다중 논리망 (Nested OR & AND)
558
+ it("TC2: AND + 중첩 OR 필터가 SQL 조건으로 빌드된다", () => {
559
+ const table = pgTable("tc2_test", {
560
+ id: serial("id").primaryKey(),
561
+ category: text("category"),
562
+ qty: serial("qty"),
563
+ flag: text("flag"),
564
+ });
565
+
566
+ const result = parseFilterNode(
567
+ {
568
+ AND: [{ category: "A" }, { OR: [{ qty: { op: "gte", value: 10 } }, { flag: true }] }],
569
+ },
570
+ table,
571
+ );
572
+
573
+ expect(result).toBeDefined();
574
+ });
575
+
576
+ it("TC2: 단순 OR 배열도 파싱된다", () => {
577
+ const table = pgTable("tc2b_test", {
578
+ id: serial("id").primaryKey(),
579
+ title: text("title"),
580
+ description: text("description"),
611
581
  });
612
582
 
613
- it("TC3: OR 틈새 우회 시도 시 미허용 필드만 묵살, 허용 필드는 유지", () => {
614
- const table = pgTable("tc3b_test", {
615
- id: serial("id").primaryKey(),
616
- status: text("status"),
617
- isAdmin: text("is_admin"),
618
- });
583
+ const result = parseFilterNode(
584
+ {
585
+ OR: [{ title: { op: "ilike", value: "%AI%" } }, { description: { op: "ilike", value: "%데이터%" } }],
586
+ },
587
+ table,
588
+ );
589
+
590
+ expect(result).toBeDefined();
591
+ });
619
592
 
620
- const r = parseFilterNode(
621
- { OR: [{ status: "public" }, { isAdmin: true }] },
622
- table,
623
- ["status"],
624
- );
625
- // status는 허용 → OR 조건 살아있음
626
- expect(r).toBeDefined();
593
+ // TC3: 보안 방어 — allowedFilters 위반
594
+ it("TC3: allowedFilters에 없는 필드는 완전 묵살 (직접 접근)", () => {
595
+ const table = pgTable("tc3_test", {
596
+ id: serial("id").primaryKey(),
597
+ status: text("status"),
598
+ title: text("title"),
599
+ passwordHash: text("password_hash"),
627
600
  });
628
601
 
629
- it("TC3: 모든 필드가 미허용이면 undefined 반환", () => {
630
- const table = pgTable("tc3c_test", {
631
- id: serial("id").primaryKey(),
632
- secret: text("secret"),
633
- });
602
+ // password_hash 직접 접근 묵살
603
+ const r1 = parseFilterNode({ passwordHash: { op: "eq", value: "xxx" } }, table, ["status", "title"]);
604
+ expect(r1).toBeUndefined(); // 모든 조건 제거됨
605
+ });
634
606
 
635
- const r = parseFilterNode(
636
- { OR: [{ secret: "hi" }] },
637
- table,
638
- ["status"],
639
- );
640
- expect(r).toBeUndefined();
607
+ it("TC3: OR 틈새 우회 시도 시 미허용 필드만 묵살, 허용 필드는 유지", () => {
608
+ const table = pgTable("tc3b_test", {
609
+ id: serial("id").primaryKey(),
610
+ status: text("status"),
611
+ isAdmin: text("is_admin"),
641
612
  });
642
613
 
643
- // TC4: 악의적 페이로드 구조 방어
644
- it("TC4: OR에 비배열 전달 무시, 크래시 없음", () => {
645
- const table = pgTable("tc4_test", {
646
- id: serial("id").primaryKey(),
647
- age: serial("age"),
648
- });
614
+ const r = parseFilterNode({ OR: [{ status: "public" }, { isAdmin: true }] }, table, ["status"]);
615
+ // status는 허용 OR 조건 살아있음
616
+ expect(r).toBeDefined();
617
+ });
649
618
 
650
- expect(parseFilterNode({ OR: "not-an-array" as any }, table)).toBeUndefined();
619
+ it("TC3: 모든 필드가 미허용이면 undefined 반환", () => {
620
+ const table = pgTable("tc3c_test", {
621
+ id: serial("id").primaryKey(),
622
+ secret: text("secret"),
651
623
  });
652
624
 
653
- it("TC4: 미지원 op 키워드(DROP TABLE 등) 묵살, 크래시 없음", () => {
654
- const table = pgTable("tc4b_test", {
655
- id: serial("id").primaryKey(),
656
- age: serial("age"),
657
- });
625
+ const r = parseFilterNode({ OR: [{ secret: "hi" }] }, table, ["status"]);
626
+ expect(r).toBeUndefined();
627
+ });
658
628
 
659
- expect(parseFilterNode({ age: { op: "DROP TABLE", value: 1 } }, table)).toBeUndefined();
629
+ // TC4: 악의적 페이로드 구조 방어
630
+ it("TC4: OR에 비배열 전달 시 무시, 크래시 없음", () => {
631
+ const table = pgTable("tc4_test", {
632
+ id: serial("id").primaryKey(),
633
+ age: serial("age"),
660
634
  });
661
635
 
662
- it("TC4: AND에 null/string/number 비객체 원소 묵살", () => {
663
- const table = pgTable("tc4c_test", {
664
- id: serial("id").primaryKey(),
665
- });
636
+ expect(parseFilterNode({ OR: "not-an-array" as any }, table)).toBeUndefined();
637
+ });
666
638
 
667
- expect(parseFilterNode({ AND: [null, "string", 42] as any }, table)).toBeUndefined();
668
- expect(parseFilterNode({ AND: null as any }, table)).toBeUndefined();
639
+ it("TC4: 미지원 op 키워드(DROP TABLE 등) 묵살, 크래시 없음", () => {
640
+ const table = pgTable("tc4b_test", {
641
+ id: serial("id").primaryKey(),
642
+ age: serial("age"),
669
643
  });
670
644
 
671
- it("TC4: 존재하지 않는 컬럼명 필터는 묵살", () => {
672
- const table = pgTable("tc4d_test", {
673
- id: serial("id").primaryKey(),
674
- name: text("name"),
675
- });
645
+ expect(parseFilterNode({ age: { op: "DROP TABLE", value: 1 } }, table)).toBeUndefined();
646
+ });
676
647
 
677
- const r = parseFilterNode({ nonExistentCol: "value" }, table);
678
- expect(r).toBeUndefined();
648
+ it("TC4: AND에 null/string/number 비객체 원소 묵살", () => {
649
+ const table = pgTable("tc4c_test", {
650
+ id: serial("id").primaryKey(),
679
651
  });
680
652
 
681
- // TC5: IN / NIN 연산자 배열값 처리
682
- it("TC5: in 연산자 정상 배열은 SQL 생성", () => {
683
- const table = pgTable("tc5_test", {
684
- id: serial("id").primaryKey(),
685
- status: text("status"),
686
- });
653
+ expect(parseFilterNode({ AND: [null, "string", 42] as any }, table)).toBeUndefined();
654
+ expect(parseFilterNode({ AND: null as any }, table)).toBeUndefined();
655
+ });
687
656
 
688
- const r = parseFilterNode({ id: { op: "in", value: [1, 2, 3] } }, table);
689
- expect(r).toBeDefined();
657
+ it("TC4: 존재하지 않는 컬럼명 필터는 묵살", () => {
658
+ const table = pgTable("tc4d_test", {
659
+ id: serial("id").primaryKey(),
660
+ name: text("name"),
690
661
  });
691
662
 
692
- it("TC5: in 연산자 비배열은 묵살", () => {
693
- const table = pgTable("tc5b_test", {
694
- id: serial("id").primaryKey(),
695
- });
663
+ const r = parseFilterNode({ nonExistentCol: "value" }, table);
664
+ expect(r).toBeUndefined();
665
+ });
696
666
 
697
- const r = parseFilterNode({ id: { op: "in", value: "1" } }, table);
698
- expect(r).toBeUndefined();
667
+ // TC5: IN / NIN 연산자 배열값 처리
668
+ it("TC5: in 연산자 — 정상 배열은 SQL 생성", () => {
669
+ const table = pgTable("tc5_test", {
670
+ id: serial("id").primaryKey(),
671
+ status: text("status"),
699
672
  });
700
673
 
701
- it("TC5: in 연산자 배열은 묵살", () => {
702
- const table = pgTable("tc5c_test", {
703
- id: serial("id").primaryKey(),
704
- });
674
+ const r = parseFilterNode({ id: { op: "in", value: [1, 2, 3] } }, table);
675
+ expect(r).toBeDefined();
676
+ });
705
677
 
706
- const r = parseFilterNode({ id: { op: "in", value: [] } }, table);
707
- expect(r).toBeUndefined();
678
+ it("TC5: in 연산자 비배열은 묵살", () => {
679
+ const table = pgTable("tc5b_test", {
680
+ id: serial("id").primaryKey(),
708
681
  });
709
682
 
710
- it("TC5: nin 연산자 정상 배열은 SQL 생성", () => {
711
- const table = pgTable("tc5d_test", {
712
- id: serial("id").primaryKey(),
713
- });
683
+ const r = parseFilterNode({ id: { op: "in", value: "1" } }, table);
684
+ expect(r).toBeUndefined();
685
+ });
714
686
 
715
- const r = parseFilterNode({ id: { op: "nin", value: [10, 20] } }, table);
716
- expect(r).toBeDefined();
687
+ it("TC5: in 연산자 배열은 묵살", () => {
688
+ const table = pgTable("tc5c_test", {
689
+ id: serial("id").primaryKey(),
717
690
  });
691
+
692
+ const r = parseFilterNode({ id: { op: "in", value: [] } }, table);
693
+ expect(r).toBeUndefined();
694
+ });
695
+
696
+ it("TC5: nin 연산자 — 정상 배열은 SQL 생성", () => {
697
+ const table = pgTable("tc5d_test", {
698
+ id: serial("id").primaryKey(),
699
+ });
700
+
701
+ const r = parseFilterNode({ id: { op: "nin", value: [10, 20] } }, table);
702
+ expect(r).toBeDefined();
703
+ });
718
704
  });
719
705
 
720
706
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -722,145 +708,145 @@ describe("v3 Filter Engine — parseFilterNode", () => {
722
708
  // ═══════════════════════════════════════════════════════════════════════════════
723
709
 
724
710
  describe("v3 Filter Engine — applyFilterOp", () => {
725
- const table = pgTable("op_test", {
726
- id: serial("id").primaryKey(),
727
- name: text("name"),
728
- age: serial("age"),
729
- });
711
+ const table = pgTable("op_test", {
712
+ id: serial("id").primaryKey(),
713
+ name: text("name"),
714
+ age: serial("age"),
715
+ });
716
+
717
+ it("eq 연산자가 SQL 조건을 반환한다", () => {
718
+ expect(applyFilterOp(table.name, "eq", "test")).toBeDefined();
719
+ });
720
+
721
+ it("ne 연산자가 SQL 조건을 반환한다", () => {
722
+ expect(applyFilterOp(table.name, "ne", "test")).toBeDefined();
723
+ });
724
+
725
+ it("gt/gte/lt/lte 비교 연산자가 SQL 조건을 반환한다", () => {
726
+ expect(applyFilterOp(table.age, "gt", 10)).toBeDefined();
727
+ expect(applyFilterOp(table.age, "gte", 18)).toBeDefined();
728
+ expect(applyFilterOp(table.age, "lt", 100)).toBeDefined();
729
+ expect(applyFilterOp(table.age, "lte", 65)).toBeDefined();
730
+ });
731
+
732
+ it("like/ilike 패턴 연산자가 SQL 조건을 반환한다", () => {
733
+ expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
734
+ expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
735
+ });
736
+
737
+ it("in 연산자 — 정상 배열은 SQL 반환", () => {
738
+ expect(applyFilterOp(table.id, "in", [1, 2, 3])).toBeDefined();
739
+ });
740
+
741
+ it("nin 연산자 — 빈 배열은 undefined 반환", () => {
742
+ expect(applyFilterOp(table.id, "nin", [])).toBeUndefined();
743
+ });
744
+
745
+ it("미지원 연산자는 undefined 반환", () => {
746
+ expect(applyFilterOp(table.id, "INVALID" as any, 1)).toBeUndefined();
747
+ });
748
+ });
730
749
 
731
- it("eq 연산자가 SQL 조건을 반환한다", () => {
732
- expect(applyFilterOp(table.name, "eq", "test")).toBeDefined();
733
- });
750
+ // ═══════════════════════════════════════════════════════════════════════════════
751
+ // 13. v3 crud() handler 통합 테스트 — Advanced Filters
752
+ // ═══════════════════════════════════════════════════════════════════════════════
734
753
 
735
- it("ne 연산자가 SQL 조건을 반환한다", () => {
736
- expect(applyFilterOp(table.name, "ne", "test")).toBeDefined();
754
+ describe("v3 crud() advanced filters through handler", () => {
755
+ it("allowedFilters 미설정 시 필터가 무시된다 (Secure by Default)", async () => {
756
+ const openTable = pgTable("v3_open_filter", {
757
+ id: serial("id").primaryKey(),
758
+ status: text("status"),
759
+ createdAt: timestamp("created_at").defaultNow(),
737
760
  });
738
761
 
739
- it("gt/gte/lt/lte 비교 연산자가 SQL 조건을 반환한다", () => {
740
- expect(applyFilterOp(table.age, "gt", 10)).toBeDefined();
741
- expect(applyFilterOp(table.age, "gte", 18)).toBeDefined();
742
- expect(applyFilterOp(table.age, "lt", 100)).toBeDefined();
743
- expect(applyFilterOp(table.age, "lte", 65)).toBeDefined();
762
+ // allowedFilters 미설정 필터 전체 무시 (v2 호환, Secure by Default)
763
+ crud(openTable, { public: true });
764
+
765
+ const listDef = getQueryDef("v3_open_filter.list");
766
+ expect(listDef).toBeDefined();
767
+
768
+ const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
769
+ const result = await listDef!.handler(mockCtx, {
770
+ filters: { status: { op: "eq", value: "active" } },
744
771
  });
745
772
 
746
- it("like/ilike 패턴 연산자가 SQL 조건을 반환한다", () => {
747
- expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
748
- expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
773
+ // 필터가 무시되어도 에러 없이 정상 동작
774
+ expect(result).toHaveProperty("data");
775
+ expect(result).toHaveProperty("total");
776
+ });
777
+
778
+ it("allowedFilters 설정 시 v3 고급 필터가 동작한다", async () => {
779
+ const advTable = pgTable("v3_adv_filter", {
780
+ id: serial("id").primaryKey(),
781
+ status: text("status"),
782
+ createdAt: timestamp("created_at").defaultNow(),
749
783
  });
750
784
 
751
- it("in 연산자 정상 배열은 SQL 반환", () => {
752
- expect(applyFilterOp(table.id, "in", [1, 2, 3])).toBeDefined();
785
+ crud(advTable, { public: true, allowedFilters: ["status"] });
786
+
787
+ const listDef = getQueryDef("v3_adv_filter.list");
788
+ const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
789
+ const result = await listDef!.handler(mockCtx, {
790
+ filters: { status: { op: "eq", value: "active" } },
753
791
  });
754
792
 
755
- it("nin 연산자 — 빈 배열은 undefined 반환", () => {
756
- expect(applyFilterOp(table.id, "nin", [])).toBeUndefined();
793
+ expect(result).toHaveProperty("data");
794
+ expect(result).toHaveProperty("total");
795
+ });
796
+
797
+ it("복합 OR/AND 필터가 handler에서 에러 없이 실행된다", async () => {
798
+ const complexTable = pgTable("v3_complex", {
799
+ id: serial("id").primaryKey(),
800
+ title: text("title"),
801
+ status: text("status"),
802
+ createdAt: timestamp("created_at").defaultNow(),
757
803
  });
758
804
 
759
- it("미지원 연산자는 undefined 반환", () => {
760
- expect(applyFilterOp(table.id, "INVALID" as any, 1)).toBeUndefined();
805
+ crud(complexTable, { public: true, allowedFilters: ["title", "status"] });
806
+
807
+ const listDef = getQueryDef("v3_complex.list");
808
+ const mockCtx = createListMockCtx([]);
809
+
810
+ const result = await listDef!.handler(mockCtx, {
811
+ filters: {
812
+ OR: [
813
+ { title: { op: "ilike", value: "%AI%" } },
814
+ { status: { op: "in", value: ["active", "archived"] } },
815
+ ],
816
+ },
761
817
  });
762
- });
763
818
 
764
- // ═══════════════════════════════════════════════════════════════════════════════
765
- // 13. v3 crud() handler 통합 테스트 — Advanced Filters
766
- // ═══════════════════════════════════════════════════════════════════════════════
819
+ expect(result).toHaveProperty("data");
820
+ expect(result.total).toBe(0);
821
+ });
767
822
 
768
- describe("v3 crud() advanced filters through handler", () => {
769
- it("allowedFilters 미설정 필터가 무시된다 (Secure by Default)", async () => {
770
- const openTable = pgTable("v3_open_filter", {
771
- id: serial("id").primaryKey(),
772
- status: text("status"),
773
- createdAt: timestamp("created_at").defaultNow(),
774
- });
775
-
776
- // allowedFilters 미설정 → 필터 전체 무시 (v2 호환, Secure by Default)
777
- crud(openTable, { public: true });
778
-
779
- const listDef = getQueryDef("v3_open_filter.list");
780
- expect(listDef).toBeDefined();
781
-
782
- const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
783
- const result = await listDef!.handler(mockCtx, {
784
- filters: { status: { op: "eq", value: "active" } },
785
- });
786
-
787
- // 필터가 무시되어도 에러 없이 정상 동작
788
- expect(result).toHaveProperty("data");
789
- expect(result).toHaveProperty("total");
790
- });
791
-
792
- it("allowedFilters 설정 시 v3 고급 필터가 동작한다", async () => {
793
- const advTable = pgTable("v3_adv_filter", {
794
- id: serial("id").primaryKey(),
795
- status: text("status"),
796
- createdAt: timestamp("created_at").defaultNow(),
797
- });
798
-
799
- crud(advTable, { public: true, allowedFilters: ["status"] });
800
-
801
- const listDef = getQueryDef("v3_adv_filter.list");
802
- const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
803
- const result = await listDef!.handler(mockCtx, {
804
- filters: { status: { op: "eq", value: "active" } },
805
- });
806
-
807
- expect(result).toHaveProperty("data");
808
- expect(result).toHaveProperty("total");
809
- });
810
-
811
- it("복합 OR/AND 필터가 handler에서 에러 없이 실행된다", async () => {
812
- const complexTable = pgTable("v3_complex", {
813
- id: serial("id").primaryKey(),
814
- title: text("title"),
815
- status: text("status"),
816
- createdAt: timestamp("created_at").defaultNow(),
817
- });
818
-
819
- crud(complexTable, { public: true, allowedFilters: ["title", "status"] });
820
-
821
- const listDef = getQueryDef("v3_complex.list");
822
- const mockCtx = createListMockCtx([]);
823
-
824
- const result = await listDef!.handler(mockCtx, {
825
- filters: {
826
- OR: [
827
- { title: { op: "ilike", value: "%AI%" } },
828
- { status: { op: "in", value: ["active", "archived"] } },
829
- ],
830
- },
831
- });
832
-
833
- expect(result).toHaveProperty("data");
834
- expect(result.total).toBe(0);
835
- });
836
-
837
- it("악성 페이로드가 handler를 크래시시키지 않는다", async () => {
838
- const safeTable = pgTable("v3_safe", {
839
- id: serial("id").primaryKey(),
840
- name: text("name"),
841
- createdAt: timestamp("created_at").defaultNow(),
842
- });
843
-
844
- crud(safeTable, { public: true });
845
-
846
- const listDef = getQueryDef("v3_safe.list");
847
- const mockCtx = createListMockCtx([{ id: 1 }]);
848
-
849
- // 다양한 악성 페이로드 — 모두 에러 없이 실행
850
- const payloads = [
851
- { filters: { OR: "not-array" } },
852
- { filters: { AND: null } },
853
- { filters: { name: { op: "EVIL_OP", value: 1 } } },
854
- { filters: { OR: [null, undefined, 42, "bad"] } },
855
- { filters: {} },
856
- ];
857
-
858
- for (const payload of payloads) {
859
- const result = await listDef!.handler(mockCtx, payload);
860
- expect(result).toHaveProperty("data");
861
- expect(result).toHaveProperty("total");
862
- }
823
+ it("악성 페이로드가 handler를 크래시시키지 않는다", async () => {
824
+ const safeTable = pgTable("v3_safe", {
825
+ id: serial("id").primaryKey(),
826
+ name: text("name"),
827
+ createdAt: timestamp("created_at").defaultNow(),
863
828
  });
829
+
830
+ crud(safeTable, { public: true });
831
+
832
+ const listDef = getQueryDef("v3_safe.list");
833
+ const mockCtx = createListMockCtx([{ id: 1 }]);
834
+
835
+ // 다양한 악성 페이로드 — 모두 에러 없이 실행
836
+ const payloads = [
837
+ { filters: { OR: "not-array" } },
838
+ { filters: { AND: null } },
839
+ { filters: { name: { op: "EVIL_OP", value: 1 } } },
840
+ { filters: { OR: [null, undefined, 42, "bad"] } },
841
+ { filters: {} },
842
+ ];
843
+
844
+ for (const payload of payloads) {
845
+ const result = await listDef!.handler(mockCtx, payload);
846
+ expect(result).toHaveProperty("data");
847
+ expect(result).toHaveProperty("total");
848
+ }
849
+ });
864
850
  });
865
851
 
866
852
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -868,80 +854,77 @@ describe("v3 crud() — advanced filters through handler", () => {
868
854
  // ═══════════════════════════════════════════════════════════════════════════════
869
855
 
870
856
  describe("v3 보안 강화 — 재귀 깊이 제한", () => {
871
- it("MAX_FILTER_DEPTH(5) 초과 시 조건 묵살", () => {
872
- const table = pgTable("depth_test", {
873
- id: serial("id").primaryKey(),
874
- name: text("name"),
875
- });
876
-
877
- // 깊이 6단계 중첩 — MAX_FILTER_DEPTH=5 초과
878
- const deepFilter = {
879
- OR: [{ AND: [{ OR: [{ AND: [{ OR: [{ AND: [{ name: "deep" }] }] }] }] }] }],
880
- };
881
-
882
- const result = parseFilterNode(deepFilter, table);
883
- // 깊이 초과된 부분이 묵살되어 결과가 undefined이거나 부분 조건만 남음
884
- // 중요한 것은 스택 오버플로 없이 정상 반환
885
- expect(() => parseFilterNode(deepFilter, table)).not.toThrow();
886
- });
887
-
888
- it("MAX_FILTER_DEPTH 이내의 중첩은 정상 동작", () => {
889
- const table = pgTable("depth_ok_test", {
890
- id: serial("id").primaryKey(),
891
- name: text("name"),
892
- status: text("status"),
893
- });
894
-
895
- // 깊이 3단계 — 정상 범위
896
- const normalFilter = {
897
- OR: [
898
- { AND: [{ OR: [{ name: "test" }] }] },
899
- { status: "active" },
900
- ],
901
- };
902
-
903
- const result = parseFilterNode(normalFilter, table);
904
- expect(result).toBeDefined();
905
- });
906
-
907
- it("극단적 깊이(100단계)에서도 크래시 없음", () => {
908
- const table = pgTable("depth_extreme_test", {
909
- id: serial("id").primaryKey(),
910
- name: text("name"),
911
- });
912
-
913
- // 100단계 중첩 생성
914
- let filter: any = { name: "leaf" };
915
- for (let i = 0; i < 100; i++) {
916
- filter = i % 2 === 0 ? { OR: [filter] } : { AND: [filter] };
917
- }
918
-
919
- expect(() => parseFilterNode(filter, table)).not.toThrow();
920
- // 깊이 초과로 조건 묵살 → undefined
921
- const result = parseFilterNode(filter, table);
922
- expect(result).toBeUndefined();
857
+ it("MAX_FILTER_DEPTH(5) 초과 시 조건 묵살", () => {
858
+ const table = pgTable("depth_test", {
859
+ id: serial("id").primaryKey(),
860
+ name: text("name"),
923
861
  });
924
- });
925
862
 
926
- describe("v3 보안 강화like/ilike 타입 검증", () => {
927
- const table = pgTable("like_type_test", {
928
- id: serial("id").primaryKey(),
929
- name: text("name"),
930
- });
863
+ // 깊이 6단계 중첩 MAX_FILTER_DEPTH=5 초과
864
+ const deepFilter = {
865
+ OR: [{ AND: [{ OR: [{ AND: [{ OR: [{ AND: [{ name: "deep" }] }] }] }] }] }],
866
+ };
931
867
 
932
- it("like에 문자열이 아닌 값 전달 시 undefined 반환", () => {
933
- expect(applyFilterOp(table.name, "like", 12345)).toBeUndefined();
934
- expect(applyFilterOp(table.name, "like", null)).toBeUndefined();
935
- expect(applyFilterOp(table.name, "like", { evil: true })).toBeUndefined();
936
- });
868
+ const result = parseFilterNode(deepFilter, table);
869
+ // 깊이 초과된 부분이 묵살되어 결과가 undefined이거나 부분 조건만 남음
870
+ // 중요한 것은 스택 오버플로 없이 정상 반환
871
+ expect(() => parseFilterNode(deepFilter, table)).not.toThrow();
872
+ });
937
873
 
938
- it("ilike에 문자열이 아닌 전달 시 undefined 반환", () => {
939
- expect(applyFilterOp(table.name, "ilike", 99)).toBeUndefined();
940
- expect(applyFilterOp(table.name, "ilike", ["array"])).toBeUndefined();
874
+ it("MAX_FILTER_DEPTH 이내의 중첩은 정상 동작", () => {
875
+ const table = pgTable("depth_ok_test", {
876
+ id: serial("id").primaryKey(),
877
+ name: text("name"),
878
+ status: text("status"),
941
879
  });
942
880
 
943
- it("like/ilike에 정상 문자열 전달 SQL 반환", () => {
944
- expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
945
- expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
881
+ // 깊이 3단계 정상 범위
882
+ const normalFilter = {
883
+ OR: [{ AND: [{ OR: [{ name: "test" }] }] }, { status: "active" }],
884
+ };
885
+
886
+ const result = parseFilterNode(normalFilter, table);
887
+ expect(result).toBeDefined();
888
+ });
889
+
890
+ it("극단적 깊이(100단계)에서도 크래시 없음", () => {
891
+ const table = pgTable("depth_extreme_test", {
892
+ id: serial("id").primaryKey(),
893
+ name: text("name"),
946
894
  });
895
+
896
+ // 100단계 중첩 생성
897
+ let filter: any = { name: "leaf" };
898
+ for (let i = 0; i < 100; i++) {
899
+ filter = i % 2 === 0 ? { OR: [filter] } : { AND: [filter] };
900
+ }
901
+
902
+ expect(() => parseFilterNode(filter, table)).not.toThrow();
903
+ // 깊이 초과로 조건 묵살 → undefined
904
+ const result = parseFilterNode(filter, table);
905
+ expect(result).toBeUndefined();
906
+ });
907
+ });
908
+
909
+ describe("v3 보안 강화 — like/ilike 타입 검증", () => {
910
+ const table = pgTable("like_type_test", {
911
+ id: serial("id").primaryKey(),
912
+ name: text("name"),
913
+ });
914
+
915
+ it("like에 문자열이 아닌 값 전달 시 undefined 반환", () => {
916
+ expect(applyFilterOp(table.name, "like", 12345)).toBeUndefined();
917
+ expect(applyFilterOp(table.name, "like", null)).toBeUndefined();
918
+ expect(applyFilterOp(table.name, "like", { evil: true })).toBeUndefined();
919
+ });
920
+
921
+ it("ilike에 문자열이 아닌 값 전달 시 undefined 반환", () => {
922
+ expect(applyFilterOp(table.name, "ilike", 99)).toBeUndefined();
923
+ expect(applyFilterOp(table.name, "ilike", ["array"])).toBeUndefined();
924
+ });
925
+
926
+ it("like/ilike에 정상 문자열 전달 시 SQL 반환", () => {
927
+ expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
928
+ expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
929
+ });
947
930
  });