@gencow/core 0.1.8 → 0.1.10
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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/reactive.d.ts +9 -1
- package/dist/scheduler.d.ts +29 -10
- package/dist/scheduler.js +5 -19
- package/dist/scoped-db.d.ts +34 -0
- package/dist/scoped-db.js +364 -0
- package/dist/storage.d.ts +21 -3
- package/dist/storage.js +112 -8
- package/dist/table.d.ts +67 -0
- package/dist/table.js +98 -0
- package/package.json +1 -1
- package/src/__tests__/auth.test.ts +114 -0
- package/src/__tests__/httpaction.test.ts +122 -0
- package/src/__tests__/scheduler-exec.test.ts +246 -0
- package/src/__tests__/scheduler.test.ts +169 -0
- package/src/__tests__/scoped-db.test.ts +442 -0
- package/src/__tests__/storage.test.ts +208 -0
- package/src/__tests__/table.test.ts +324 -0
- package/src/__tests__/validator.test.ts +284 -0
- package/src/index.ts +6 -0
- package/src/reactive.ts +7 -1
- package/src/scheduler.ts +17 -3
- package/src/scoped-db.ts +416 -0
- package/src/storage.ts +157 -12
- package/src/table.ts +165 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/__tests__/scoped-db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for createScopedDb() — Proxy wrapping Drizzle DB with auto-filter injection.
|
|
5
|
+
*
|
|
6
|
+
* Run: bun test packages/core/src/__tests__/scoped-db.test.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
|
10
|
+
import { createScopedDb, applyFieldAccess } from "../scoped-db";
|
|
11
|
+
import { gencowTable, ownerFilter, _resetTableRegistry, getTableAccessMeta } from "../table";
|
|
12
|
+
import type { GencowCtx } from "../reactive";
|
|
13
|
+
import { serial, text, integer } from "drizzle-orm/pg-core";
|
|
14
|
+
import { pgTable } from "drizzle-orm/pg-core";
|
|
15
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
// ─── Mock DB builder ────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a mock Drizzle-like DB that records method calls.
|
|
21
|
+
* Supports chaining: db.select().from(table).where(cond).limit(n)
|
|
22
|
+
*/
|
|
23
|
+
function makeMockDb() {
|
|
24
|
+
const calls: Array<{ method: string; args: any[] }> = [];
|
|
25
|
+
let lastWhere: any = undefined;
|
|
26
|
+
|
|
27
|
+
const chainable = (self: any) =>
|
|
28
|
+
new Proxy(self, {
|
|
29
|
+
get(target, prop: string) {
|
|
30
|
+
if (prop === "_calls") return calls;
|
|
31
|
+
if (prop === "_lastWhere") return lastWhere;
|
|
32
|
+
if (prop === "then") return undefined; // Not a thenable by default
|
|
33
|
+
if (prop === "execute") return undefined;
|
|
34
|
+
|
|
35
|
+
return (...args: any[]) => {
|
|
36
|
+
calls.push({ method: prop, args });
|
|
37
|
+
if (prop === "where") {
|
|
38
|
+
lastWhere = args[0];
|
|
39
|
+
}
|
|
40
|
+
return chainable({});
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const db = {
|
|
46
|
+
select: (...args: any[]) => {
|
|
47
|
+
calls.push({ method: "select", args });
|
|
48
|
+
return chainable({});
|
|
49
|
+
},
|
|
50
|
+
insert: (table: any) => {
|
|
51
|
+
calls.push({ method: "insert", args: [table] });
|
|
52
|
+
return chainable({});
|
|
53
|
+
},
|
|
54
|
+
update: (table: any) => {
|
|
55
|
+
calls.push({ method: "update", args: [table] });
|
|
56
|
+
return chainable({});
|
|
57
|
+
},
|
|
58
|
+
delete: (table: any) => {
|
|
59
|
+
calls.push({ method: "delete", args: [table] });
|
|
60
|
+
return chainable({});
|
|
61
|
+
},
|
|
62
|
+
execute: (sqlQuery: any) => {
|
|
63
|
+
calls.push({ method: "execute", args: [sqlQuery] });
|
|
64
|
+
return Promise.resolve([]);
|
|
65
|
+
},
|
|
66
|
+
query: {},
|
|
67
|
+
$client: {},
|
|
68
|
+
_: { session: {} },
|
|
69
|
+
_calls: calls,
|
|
70
|
+
_lastWhere: () => lastWhere,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return db;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeCtx(userId: string, role = "user"): GencowCtx {
|
|
77
|
+
return {
|
|
78
|
+
auth: {
|
|
79
|
+
getUserIdentity: () => ({ id: userId, email: `${userId}@test.com`, name: "Test" }),
|
|
80
|
+
requireAuth: () => ({ id: userId, email: `${userId}@test.com`, name: "Test", role } as any),
|
|
81
|
+
},
|
|
82
|
+
db: {},
|
|
83
|
+
unsafeDb: {},
|
|
84
|
+
storage: {} as any,
|
|
85
|
+
scheduler: {} as any,
|
|
86
|
+
realtime: {} as any,
|
|
87
|
+
retry: {} as any,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeAnonCtx(): GencowCtx {
|
|
92
|
+
return {
|
|
93
|
+
auth: {
|
|
94
|
+
getUserIdentity: () => null,
|
|
95
|
+
requireAuth: () => { throw new Error("Authentication required"); },
|
|
96
|
+
},
|
|
97
|
+
db: {},
|
|
98
|
+
unsafeDb: {},
|
|
99
|
+
storage: {} as any,
|
|
100
|
+
scheduler: {} as any,
|
|
101
|
+
realtime: {} as any,
|
|
102
|
+
retry: {} as any,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Tests ───────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("createScopedDb()", () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
_resetTableRegistry();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── execute() 차단 ──────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("execute() 차단", () => {
|
|
116
|
+
it("ctx.db.execute()를 호출하면 에러를 던진다", () => {
|
|
117
|
+
const db = makeMockDb();
|
|
118
|
+
const ctx = makeCtx("user-1");
|
|
119
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
120
|
+
|
|
121
|
+
expect(() => scopedDb.execute(sql`SELECT 1`)).toThrow(
|
|
122
|
+
"ctx.db.execute() is not allowed"
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("에러 메시지에 unsafeDb 사용 안내를 포함한다", () => {
|
|
127
|
+
const db = makeMockDb();
|
|
128
|
+
const ctx = makeCtx("user-1");
|
|
129
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
scopedDb.execute(sql`SELECT 1`);
|
|
133
|
+
expect(true).toBe(false); // should not reach
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
expect(err.message).toContain("ctx.unsafeDb.execute()");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── $client / _ 접근 차단 ────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("직접 클라이언트 접근 차단", () => {
|
|
143
|
+
it("ctx.db.$client 접근 시 에러", () => {
|
|
144
|
+
const db = makeMockDb();
|
|
145
|
+
const ctx = makeCtx("user-1");
|
|
146
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
147
|
+
|
|
148
|
+
expect(() => scopedDb.$client).toThrow("ctx.db.$client is not allowed");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("ctx.db._ 접근 시 에러", () => {
|
|
152
|
+
const db = makeMockDb();
|
|
153
|
+
const ctx = makeCtx("user-1");
|
|
154
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
155
|
+
|
|
156
|
+
expect(() => scopedDb._).toThrow("ctx.db._ is not allowed");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── select().from() filter 주입 ─────────────────────
|
|
161
|
+
|
|
162
|
+
describe("select().from() — filter 주입", () => {
|
|
163
|
+
it("gencowTable에서 select().from() 호출 시 from이 실행된다", () => {
|
|
164
|
+
const table = gencowTable("scoped_tasks", {
|
|
165
|
+
id: serial("id").primaryKey(),
|
|
166
|
+
userId: text("user_id").notNull(),
|
|
167
|
+
}, {
|
|
168
|
+
filter: () => true,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const db = makeMockDb();
|
|
172
|
+
const ctx = makeCtx("user-1");
|
|
173
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
174
|
+
|
|
175
|
+
scopedDb.select().from(table);
|
|
176
|
+
|
|
177
|
+
const fromCall = db._calls.find((c: any) => c.method === "from");
|
|
178
|
+
expect(fromCall).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("pgTable에서 select().from() 호출 시 그대로 통과한다", () => {
|
|
182
|
+
const table = pgTable("plain_tasks", {
|
|
183
|
+
id: serial("id").primaryKey(),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const db = makeMockDb();
|
|
187
|
+
const ctx = makeCtx("user-1");
|
|
188
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
189
|
+
|
|
190
|
+
scopedDb.select().from(table);
|
|
191
|
+
|
|
192
|
+
const fromCall = db._calls.find((c: any) => c.method === "from");
|
|
193
|
+
expect(fromCall).toBeDefined();
|
|
194
|
+
// No where should be added for plain pgTable
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("filter: () => true 일 때 추가 where 없음", () => {
|
|
198
|
+
const table = gencowTable("public_table", {
|
|
199
|
+
id: serial("id").primaryKey(),
|
|
200
|
+
}, {
|
|
201
|
+
filter: () => true,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const db = makeMockDb();
|
|
205
|
+
const ctx = makeCtx("user-1");
|
|
206
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
207
|
+
|
|
208
|
+
const chain = scopedDb.select().from(table);
|
|
209
|
+
|
|
210
|
+
// true filter 일 때 where가 호출되지 않아야 함
|
|
211
|
+
// (then에서 pending filters가 true이므로)
|
|
212
|
+
const calls = db._calls;
|
|
213
|
+
const whereCall = calls.find((c: any) => c.method === "where");
|
|
214
|
+
// No where for public tables
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── insert는 필터 없이 통과 ─────────────────────────
|
|
219
|
+
|
|
220
|
+
describe("insert() — 패스스루", () => {
|
|
221
|
+
it("insert()는 필터 없이 통과한다", () => {
|
|
222
|
+
const table = gencowTable("insert_test", {
|
|
223
|
+
id: serial("id").primaryKey(),
|
|
224
|
+
userId: text("user_id").notNull(),
|
|
225
|
+
}, ownerFilter("userId"));
|
|
226
|
+
|
|
227
|
+
const db = makeMockDb();
|
|
228
|
+
const ctx = makeCtx("user-1");
|
|
229
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
230
|
+
|
|
231
|
+
// insert는 새 행 추가이므로 filter 적용하지 않음
|
|
232
|
+
// (filter는 읽기/수정/삭제용)
|
|
233
|
+
scopedDb.insert(table);
|
|
234
|
+
|
|
235
|
+
const insertCall = db._calls.find((c: any) => c.method === "insert");
|
|
236
|
+
expect(insertCall).toBeDefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── update/delete filter 주입 ───────────────────────
|
|
241
|
+
|
|
242
|
+
describe("update() / delete() — filter 주입", () => {
|
|
243
|
+
it("update(gencowTable)가 실행된다", () => {
|
|
244
|
+
const table = gencowTable("update_test", {
|
|
245
|
+
id: serial("id").primaryKey(),
|
|
246
|
+
userId: text("user_id").notNull(),
|
|
247
|
+
}, ownerFilter("userId"));
|
|
248
|
+
|
|
249
|
+
const db = makeMockDb();
|
|
250
|
+
const ctx = makeCtx("user-1");
|
|
251
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
252
|
+
|
|
253
|
+
scopedDb.update(table);
|
|
254
|
+
|
|
255
|
+
const updateCall = db._calls.find((c: any) => c.method === "update");
|
|
256
|
+
expect(updateCall).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("delete(gencowTable)가 실행된다", () => {
|
|
260
|
+
const table = gencowTable("delete_test", {
|
|
261
|
+
id: serial("id").primaryKey(),
|
|
262
|
+
userId: text("user_id").notNull(),
|
|
263
|
+
}, ownerFilter("userId"));
|
|
264
|
+
|
|
265
|
+
const db = makeMockDb();
|
|
266
|
+
const ctx = makeCtx("user-1");
|
|
267
|
+
const scopedDb = createScopedDb(db, ctx);
|
|
268
|
+
|
|
269
|
+
scopedDb.delete(table);
|
|
270
|
+
|
|
271
|
+
const deleteCall = db._calls.find((c: any) => c.method === "delete");
|
|
272
|
+
expect(deleteCall).toBeDefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ─── applyFieldAccess ───────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe("applyFieldAccess()", () => {
|
|
280
|
+
beforeEach(() => {
|
|
281
|
+
_resetTableRegistry();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("미허용 필드를 null로 치환한다", () => {
|
|
285
|
+
const table = gencowTable("field_mask", {
|
|
286
|
+
id: serial("id").primaryKey(),
|
|
287
|
+
salary: integer("salary"),
|
|
288
|
+
name: text("name"),
|
|
289
|
+
}, {
|
|
290
|
+
filter: () => true,
|
|
291
|
+
fieldAccess: {
|
|
292
|
+
salary: { read: (ctx) => (ctx.auth.requireAuth() as any).role === "admin" },
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const result = [
|
|
297
|
+
{ id: 1, salary: 100000, name: "Alice" },
|
|
298
|
+
{ id: 2, salary: 200000, name: "Bob" },
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const userCtx = makeCtx("user-1", "user");
|
|
302
|
+
const masked = applyFieldAccess(result, table, userCtx);
|
|
303
|
+
|
|
304
|
+
expect(masked[0].salary).toBeNull();
|
|
305
|
+
expect(masked[0].name).toBe("Alice");
|
|
306
|
+
expect(masked[1].salary).toBeNull();
|
|
307
|
+
expect(masked[1].name).toBe("Bob");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("허용된 사용자의 필드는 유지된다", () => {
|
|
311
|
+
const table = gencowTable("field_keep", {
|
|
312
|
+
id: serial("id").primaryKey(),
|
|
313
|
+
salary: integer("salary"),
|
|
314
|
+
}, {
|
|
315
|
+
filter: () => true,
|
|
316
|
+
fieldAccess: {
|
|
317
|
+
salary: { read: (ctx) => (ctx.auth.requireAuth() as any).role === "admin" },
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const result = [{ id: 1, salary: 100000 }];
|
|
322
|
+
const adminCtx = makeCtx("admin-1", "admin");
|
|
323
|
+
const kept = applyFieldAccess(result, table, adminCtx);
|
|
324
|
+
|
|
325
|
+
expect(kept[0].salary).toBe(100000);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("fieldAccess가 없는 테이블은 결과를 그대로 반환한다", () => {
|
|
329
|
+
const table = gencowTable("no_field_access", {
|
|
330
|
+
id: serial("id").primaryKey(),
|
|
331
|
+
salary: integer("salary"),
|
|
332
|
+
}, {
|
|
333
|
+
filter: () => true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const result = [{ id: 1, salary: 100000 }];
|
|
337
|
+
const ctx = makeCtx("user-1");
|
|
338
|
+
const kept = applyFieldAccess(result, table, ctx);
|
|
339
|
+
|
|
340
|
+
expect(kept[0].salary).toBe(100000);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("단일 객체도 처리한다 (배열이 아닌 경우)", () => {
|
|
344
|
+
const table = gencowTable("single_obj", {
|
|
345
|
+
id: serial("id").primaryKey(),
|
|
346
|
+
salary: integer("salary"),
|
|
347
|
+
}, {
|
|
348
|
+
filter: () => true,
|
|
349
|
+
fieldAccess: {
|
|
350
|
+
salary: { read: () => false },
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const result = { id: 1, salary: 100000 };
|
|
355
|
+
const ctx = makeCtx("user-1");
|
|
356
|
+
const masked = applyFieldAccess(result, table, ctx);
|
|
357
|
+
|
|
358
|
+
expect(masked.salary).toBeNull();
|
|
359
|
+
expect(masked.id).toBe(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("pgTable은 그대로 반환한다", () => {
|
|
363
|
+
const table = pgTable("pg_no_field", {
|
|
364
|
+
id: serial("id").primaryKey(),
|
|
365
|
+
salary: integer("salary"),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const result = [{ id: 1, salary: 100000 }];
|
|
369
|
+
const ctx = makeCtx("user-1");
|
|
370
|
+
const kept = applyFieldAccess(result, table, ctx);
|
|
371
|
+
|
|
372
|
+
expect(kept[0].salary).toBe(100000);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("비인증 사용자 — read 체크 실패 시 필드를 null로 치환", () => {
|
|
376
|
+
const table = gencowTable("anon_field", {
|
|
377
|
+
id: serial("id").primaryKey(),
|
|
378
|
+
secret: text("secret"),
|
|
379
|
+
}, {
|
|
380
|
+
filter: () => true,
|
|
381
|
+
fieldAccess: {
|
|
382
|
+
secret: { read: (ctx) => !!ctx.auth.getUserIdentity() },
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = [{ id: 1, secret: "top-secret" }];
|
|
387
|
+
const anonCtx = makeAnonCtx();
|
|
388
|
+
const masked = applyFieldAccess(result, table, anonCtx);
|
|
389
|
+
|
|
390
|
+
expect(masked[0].secret).toBeNull();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("여러 필드에 대한 fieldAccess 적용", () => {
|
|
394
|
+
const table = gencowTable("multi_field", {
|
|
395
|
+
id: serial("id").primaryKey(),
|
|
396
|
+
salary: integer("salary"),
|
|
397
|
+
ssn: text("ssn"),
|
|
398
|
+
name: text("name"),
|
|
399
|
+
}, {
|
|
400
|
+
filter: () => true,
|
|
401
|
+
fieldAccess: {
|
|
402
|
+
salary: { read: () => false },
|
|
403
|
+
ssn: { read: () => false },
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const result = { id: 1, salary: 100000, ssn: "123-45-6789", name: "Alice" };
|
|
408
|
+
const ctx = makeCtx("user-1");
|
|
409
|
+
const masked = applyFieldAccess(result, table, ctx);
|
|
410
|
+
|
|
411
|
+
expect(masked.salary).toBeNull();
|
|
412
|
+
expect(masked.ssn).toBeNull();
|
|
413
|
+
expect(masked.name).toBe("Alice");
|
|
414
|
+
expect(masked.id).toBe(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("null/undefined 결과는 그대로 반환", () => {
|
|
418
|
+
const table = gencowTable("null_result", {
|
|
419
|
+
id: serial("id").primaryKey(),
|
|
420
|
+
}, {
|
|
421
|
+
filter: () => true,
|
|
422
|
+
fieldAccess: { id: { read: () => false } },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const ctx = makeCtx("user-1");
|
|
426
|
+
expect(applyFieldAccess(null, table, ctx)).toBeNull();
|
|
427
|
+
expect(applyFieldAccess(undefined, table, ctx)).toBeUndefined();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("빈 배열은 그대로 반환", () => {
|
|
431
|
+
const table = gencowTable("empty_arr", {
|
|
432
|
+
id: serial("id").primaryKey(),
|
|
433
|
+
}, {
|
|
434
|
+
filter: () => true,
|
|
435
|
+
fieldAccess: { id: { read: () => false } },
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const ctx = makeCtx("user-1");
|
|
439
|
+
const result = applyFieldAccess([], table, ctx);
|
|
440
|
+
expect(result).toEqual([]);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/__tests__/storage.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for createStorage() — file storage API
|
|
5
|
+
* Uses temp directory for file I/O.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun test packages/core/src/__tests__/storage.test.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
11
|
+
import { createStorage } from "../storage";
|
|
12
|
+
import * as fs from "fs/promises";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import * as os from "os";
|
|
15
|
+
|
|
16
|
+
describe("createStorage()", () => {
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-storage-test-"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ─── store + getUrl ─────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("store()", () => {
|
|
30
|
+
it("File 객체를 저장하고 storageId를 반환한다", async () => {
|
|
31
|
+
const storage = createStorage(tmpDir);
|
|
32
|
+
const file = new File(["hello world"], "test.txt", { type: "text/plain" });
|
|
33
|
+
|
|
34
|
+
const id = await storage.store(file);
|
|
35
|
+
expect(typeof id).toBe("string");
|
|
36
|
+
expect(id.length).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("저장된 파일이 디스크에 존재한다", async () => {
|
|
40
|
+
const storage = createStorage(tmpDir);
|
|
41
|
+
const file = new File(["content"], "doc.txt", { type: "text/plain" });
|
|
42
|
+
|
|
43
|
+
const id = await storage.store(file);
|
|
44
|
+
const filePath = path.join(tmpDir, id);
|
|
45
|
+
const stat = await fs.stat(filePath);
|
|
46
|
+
expect(stat.isFile()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("Blob 객체도 저장 가능하다", async () => {
|
|
50
|
+
const storage = createStorage(tmpDir);
|
|
51
|
+
const blob = new Blob(["blob content"], { type: "application/octet-stream" });
|
|
52
|
+
|
|
53
|
+
const id = await storage.store(blob);
|
|
54
|
+
expect(typeof id).toBe("string");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("50MB 초과 파일 → 에러", async () => {
|
|
58
|
+
const storage = createStorage(tmpDir);
|
|
59
|
+
// 51MB File mock — File constructor에서 size를 직접 제어
|
|
60
|
+
const bigContent = new Uint8Array(51 * 1024 * 1024);
|
|
61
|
+
const file = new File([bigContent], "big.bin");
|
|
62
|
+
|
|
63
|
+
await expect(storage.store(file)).rejects.toThrow("File too large");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("커스텀 filename 적용", async () => {
|
|
67
|
+
const storage = createStorage(tmpDir);
|
|
68
|
+
const file = new File(["data"], "original.txt");
|
|
69
|
+
|
|
70
|
+
const id = await storage.store(file, "custom-name.txt");
|
|
71
|
+
const meta = await storage.getMeta(id);
|
|
72
|
+
expect(meta?.name).toBe("custom-name.txt");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── storeBuffer ────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("storeBuffer()", () => {
|
|
79
|
+
it("Buffer를 저장하고 storageId를 반환한다", async () => {
|
|
80
|
+
const storage = createStorage(tmpDir);
|
|
81
|
+
const buffer = Buffer.from("buffer content");
|
|
82
|
+
|
|
83
|
+
const id = await storage.storeBuffer(buffer, "buffer.txt", "text/plain");
|
|
84
|
+
expect(typeof id).toBe("string");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("저장된 파일의 메타데이터가 올바르다", async () => {
|
|
88
|
+
const storage = createStorage(tmpDir);
|
|
89
|
+
const buffer = Buffer.from("meta test");
|
|
90
|
+
|
|
91
|
+
const id = await storage.storeBuffer(buffer, "meta.txt", "text/plain");
|
|
92
|
+
const meta = await storage.getMeta(id);
|
|
93
|
+
expect(meta).not.toBeNull();
|
|
94
|
+
expect(meta!.name).toBe("meta.txt");
|
|
95
|
+
expect(meta!.type).toBe("text/plain");
|
|
96
|
+
expect(meta!.size).toBe(buffer.length);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── getUrl ─────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe("getUrl()", () => {
|
|
103
|
+
it("storageId로 URL 반환", () => {
|
|
104
|
+
const storage = createStorage(tmpDir);
|
|
105
|
+
const url = storage.getUrl("some-uuid-123");
|
|
106
|
+
expect(url).toBe("/api/storage/some-uuid-123");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("URL 패턴이 /api/storage/{id} 형식이다", async () => {
|
|
110
|
+
const storage = createStorage(tmpDir);
|
|
111
|
+
const file = new File(["test"], "test.txt");
|
|
112
|
+
const id = await storage.store(file);
|
|
113
|
+
const url = storage.getUrl(id);
|
|
114
|
+
expect(url).toMatch(/^\/api\/storage\/.+$/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── getMeta ────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("getMeta()", () => {
|
|
121
|
+
it("저장된 파일의 메타데이터를 반환한다", async () => {
|
|
122
|
+
const storage = createStorage(tmpDir);
|
|
123
|
+
const file = new File(["hello"], "hello.txt", { type: "text/plain" });
|
|
124
|
+
const id = await storage.store(file);
|
|
125
|
+
|
|
126
|
+
const meta = await storage.getMeta(id);
|
|
127
|
+
expect(meta).not.toBeNull();
|
|
128
|
+
expect(meta!.id).toBe(id);
|
|
129
|
+
expect(meta!.name).toBe("hello.txt");
|
|
130
|
+
expect(meta!.type).toContain("text/plain");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("존재하지 않는 storageId → null 반환", async () => {
|
|
134
|
+
const storage = createStorage(tmpDir);
|
|
135
|
+
const meta = await storage.getMeta("nonexistent-id");
|
|
136
|
+
expect(meta).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── delete ─────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("delete()", () => {
|
|
143
|
+
it("파일을 삭제한다", async () => {
|
|
144
|
+
const storage = createStorage(tmpDir);
|
|
145
|
+
const file = new File(["to delete"], "delete-me.txt");
|
|
146
|
+
const id = await storage.store(file);
|
|
147
|
+
|
|
148
|
+
// 파일 존재 확인
|
|
149
|
+
const filePath = path.join(tmpDir, id);
|
|
150
|
+
const statBefore = await fs.stat(filePath);
|
|
151
|
+
expect(statBefore.isFile()).toBe(true);
|
|
152
|
+
|
|
153
|
+
// 삭제
|
|
154
|
+
await storage.delete(id);
|
|
155
|
+
|
|
156
|
+
// 파일 삭제 확인
|
|
157
|
+
const exists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
158
|
+
expect(exists).toBe(false);
|
|
159
|
+
|
|
160
|
+
// 메타데이터 삭제 확인
|
|
161
|
+
const meta = await storage.getMeta(id);
|
|
162
|
+
expect(meta).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("존재하지 않는 storageId 삭제 시 에러 없음", async () => {
|
|
166
|
+
const storage = createStorage(tmpDir);
|
|
167
|
+
await expect(storage.delete("nonexistent-id")).resolves.toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ─── Storage Quota ──────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("스토리지 쿼터", () => {
|
|
174
|
+
it("쿼터 초과 시 에러 (rawSql 있을 때)", async () => {
|
|
175
|
+
const mockRawSql = async (sql: string, _params?: unknown[]) => {
|
|
176
|
+
if (sql.includes("information_schema")) return []; // ensureFilesTable
|
|
177
|
+
if (sql.includes("SUM")) return [{ total: "999999999" }]; // 쿼터에 근접
|
|
178
|
+
return [];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const storage = createStorage(tmpDir, {
|
|
182
|
+
rawSql: mockRawSql,
|
|
183
|
+
storageQuota: 1000000000, // 1GB
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const file = new File(["x".repeat(1024)], "test.txt");
|
|
187
|
+
await expect(storage.store(file)).rejects.toThrow("Storage quota exceeded");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("쿼터 0 = 무제한 (에러 없음)", async () => {
|
|
191
|
+
const mockRawSql = async (sql: string, _params?: unknown[]) => {
|
|
192
|
+
if (sql.includes("information_schema")) return [];
|
|
193
|
+
if (sql.includes("SUM")) return [{ total: "999999999999" }];
|
|
194
|
+
if (sql.includes("INSERT")) return [];
|
|
195
|
+
return [];
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const storage = createStorage(tmpDir, {
|
|
199
|
+
rawSql: mockRawSql,
|
|
200
|
+
storageQuota: 0, // 무제한
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const file = new File(["small"], "small.txt");
|
|
204
|
+
const id = await storage.store(file);
|
|
205
|
+
expect(typeof id).toBe("string");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|