@gencow/core 0.1.24 → 0.1.25

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 (73) 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 +46 -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 +50 -44
  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/src/db.ts +0 -18
@@ -12,8 +12,8 @@
12
12
  */
13
13
 
14
14
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
15
- import { createStorage, storageRoutes } from "../storage";
16
- import type { StorageImageTierConfig } from "../storage";
15
+ import { createStorage, storageRoutes } from "../storage.js";
16
+ import type { StorageImageTierConfig } from "../storage.js";
17
17
  import * as fs from "fs/promises";
18
18
  import * as path from "path";
19
19
  import * as os from "os";
@@ -21,632 +21,628 @@ import * as os from "os";
21
21
  // ─── 테스트 헬퍼 ────────────────────────────────────────
22
22
 
23
23
  /** 간단한 Hono context mock */
24
- function mockContext(
25
- id: string,
26
- query: Record<string, string> = {},
27
- headers: Record<string, string> = {},
28
- ) {
29
- let responseData: unknown = null;
30
- let responseStatus: number = 200;
31
- let responseHeaders: Record<string, string> = {};
32
-
33
- return {
34
- c: {
35
- req: {
36
- param: (key: string) => (key === "id" ? id : ""),
37
- query: (key: string) => query[key],
38
- header: (name: string) => headers[name.toLowerCase()],
39
- },
40
- json: (data: unknown, status?: number) => {
41
- responseData = data;
42
- responseStatus = status ?? 200;
43
- return new Response(JSON.stringify(data), {
44
- status: responseStatus,
45
- headers: { "Content-Type": "application/json" },
46
- });
47
- },
48
- body: (data: unknown, status: number, hdrs: Record<string, string>) => {
49
- responseStatus = status;
50
- responseHeaders = hdrs;
51
- return new Response(data as BodyInit, { status, headers: hdrs });
52
- },
53
- },
54
- getResponseData: () => responseData,
55
- getResponseStatus: () => responseStatus,
56
- getResponseHeaders: () => responseHeaders,
57
- };
24
+ function mockContext(id: string, query: Record<string, string> = {}, headers: Record<string, string> = {}) {
25
+ let responseData: unknown = null;
26
+ let responseStatus: number = 200;
27
+ let responseHeaders: Record<string, string> = {};
28
+
29
+ return {
30
+ c: {
31
+ req: {
32
+ param: (key: string) => (key === "id" ? id : ""),
33
+ query: (key: string) => query[key],
34
+ header: (name: string) => headers[name.toLowerCase()],
35
+ },
36
+ json: (data: unknown, status?: number) => {
37
+ responseData = data;
38
+ responseStatus = status ?? 200;
39
+ return new Response(JSON.stringify(data), {
40
+ status: responseStatus,
41
+ headers: { "Content-Type": "application/json" },
42
+ });
43
+ },
44
+ body: (data: unknown, status: number, hdrs: Record<string, string>) => {
45
+ responseStatus = status;
46
+ responseHeaders = hdrs;
47
+ return new Response(data as BodyInit, { status, headers: hdrs });
48
+ },
49
+ },
50
+ getResponseData: () => responseData,
51
+ getResponseStatus: () => responseStatus,
52
+ getResponseHeaders: () => responseHeaders,
53
+ };
58
54
  }
59
55
 
60
56
  // ─── 테스트 시작 ────────────────────────────────────────
61
57
 
62
58
  describe("Image Optimization — storageRoutes", () => {
63
- let tmpDir: string;
64
-
65
- beforeEach(async () => {
66
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-img-test-"));
67
- });
68
-
69
- afterEach(async () => {
70
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
59
+ let tmpDir: string;
60
+
61
+ beforeEach(async () => {
62
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gencow-img-test-"));
63
+ });
64
+
65
+ afterEach(async () => {
66
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
67
+ });
68
+
69
+ // ─── 기본 동작 (파라미터 없음) ──────────────────────
70
+
71
+ describe("기본 동작 — 파라미터 없음", () => {
72
+ it("이미지 파일에 파라미터 없이 요청 → 원본 서빙", async () => {
73
+ const storage = createStorage(tmpDir);
74
+ // 1x1 PNG (유효한 이미지)
75
+ const pngBuffer = Buffer.from(
76
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
77
+ "base64",
78
+ );
79
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
80
+
81
+ const handler = storageRoutes(storage, undefined, tmpDir);
82
+ const { c } = mockContext(id, {}, {});
83
+ const response = await handler(c as any);
84
+
85
+ expect(response.status).toBe(200);
86
+ expect(response.headers.get("content-type")).toBe("image/png");
71
87
  });
72
88
 
73
- // ─── 기본 동작 (파라미터 없음) ──────────────────────
74
-
75
- describe("기본 동작 파라미터 없음", () => {
76
- it("이미지 파일에 파라미터 없이 요청 → 원본 서빙", async () => {
77
- const storage = createStorage(tmpDir);
78
- // 1x1 PNG (유효한 이미지)
79
- const pngBuffer = Buffer.from(
80
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
81
- "base64"
82
- );
83
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
84
-
85
- const handler = storageRoutes(storage, undefined, tmpDir);
86
- const { c } = mockContext(id, {}, {});
87
- const response = await handler(c as any);
89
+ it("비이미지 파일은 항상 원본 서빙", async () => {
90
+ const storage = createStorage(tmpDir);
91
+ const id = await storage.storeBuffer(Buffer.from("hello"), "test.txt", "text/plain");
88
92
 
89
- expect(response.status).toBe(200);
90
- expect(response.headers.get("content-type")).toBe("image/png");
91
- });
92
-
93
- it("비이미지 파일은 항상 원본 서빙", async () => {
94
- const storage = createStorage(tmpDir);
95
- const id = await storage.storeBuffer(Buffer.from("hello"), "test.txt", "text/plain");
96
-
97
- const handler = storageRoutes(storage, undefined, tmpDir);
98
- const { c } = mockContext(id, { w: "300" }, {}); // 파라미터 있어도 변환 안 됨
99
-
100
- const response = await handler(c as any);
101
- expect(response.status).toBe(200);
102
- expect(response.headers.get("content-type")).toBe("text/plain");
103
- });
93
+ const handler = storageRoutes(storage, undefined, tmpDir);
94
+ const { c } = mockContext(id, { w: "300" }, {}); // 파라미터 있어도 변환 안 됨
104
95
 
105
- it("존재하지 않는 storageId 404", async () => {
106
- const storage = createStorage(tmpDir);
107
- const handler = storageRoutes(storage, undefined, tmpDir);
108
- const { c } = mockContext("nonexistent-id");
109
-
110
- const response = await handler(c as any);
111
- expect(response.status).toBe(404);
112
- });
96
+ const response = await handler(c as any);
97
+ expect(response.status).toBe(200);
98
+ expect(response.headers.get("content-type")).toBe("text/plain");
113
99
  });
114
100
 
115
- // ─── 파라미터 검증 (parseTransformParams 간접 검증)
116
-
117
- describe("파라미터 검증", () => {
118
- it("유효한 w 파라미터 변환 시도 (sharp 미설치 시 원본 반환)", async () => {
119
- const storage = createStorage(tmpDir);
120
- const pngBuffer = Buffer.from(
121
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
122
- "base64"
123
- );
124
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
125
-
126
- const handler = storageRoutes(storage, undefined, tmpDir);
127
- const { c } = mockContext(id, { w: "300" }, {});
128
- const response = await handler(c as any);
129
- // sharp 미설치 → graceful degradation → 원본 서빙
130
- expect(response.status).toBe(200);
131
- });
132
-
133
- it("무효한 w 파라미터 (> 4096) → 원본 서빙 (파라미터 무시)", async () => {
134
- const storage = createStorage(tmpDir);
135
- const pngBuffer = Buffer.from(
136
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
137
- "base64"
138
- );
139
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
140
-
141
- const handler = storageRoutes(storage, undefined, tmpDir);
142
- const { c } = mockContext(id, { w: "9999" }, {});
143
- const response = await handler(c as any);
144
- expect(response.status).toBe(200);
145
- // 무효 파라미터 → parseTransformParams returns null → 원본 서빙
146
- expect(response.headers.get("content-type")).toBe("image/png");
147
- });
148
-
149
- it("무효한 w 파라미터 (음수) → 원본 서빙", async () => {
150
- const storage = createStorage(tmpDir);
151
- const pngBuffer = Buffer.from(
152
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
153
- "base64"
154
- );
155
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
156
-
157
- const handler = storageRoutes(storage, undefined, tmpDir);
158
- const { c } = mockContext(id, { w: "-1" }, {});
159
- const response = await handler(c as any);
160
- expect(response.status).toBe(200);
161
- expect(response.headers.get("content-type")).toBe("image/png");
162
- });
163
-
164
- it("무효한 포맷 → 원본 서빙", async () => {
165
- const storage = createStorage(tmpDir);
166
- const pngBuffer = Buffer.from(
167
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
168
- "base64"
169
- );
170
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
171
-
172
- const handler = storageRoutes(storage, undefined, tmpDir);
173
- const { c } = mockContext(id, { f: "bmp" }, {}); // bmp는 허용 포맷 아님
174
- const response = await handler(c as any);
175
- expect(response.status).toBe(200);
176
- expect(response.headers.get("content-type")).toBe("image/png");
177
- });
101
+ it("존재하지 않는 storageId 404", async () => {
102
+ const storage = createStorage(tmpDir);
103
+ const handler = storageRoutes(storage, undefined, tmpDir);
104
+ const { c } = mockContext("nonexistent-id");
178
105
 
179
- it("무효한 품질 (0) 원본 서빙", async () => {
180
- const storage = createStorage(tmpDir);
181
- const pngBuffer = Buffer.from(
182
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
183
- "base64"
184
- );
185
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
186
-
187
- const handler = storageRoutes(storage, undefined, tmpDir);
188
- const { c } = mockContext(id, { q: "0" }, {});
189
- const response = await handler(c as any);
190
- expect(response.status).toBe(200);
191
- });
106
+ const response = await handler(c as any);
107
+ expect(response.status).toBe(404);
108
+ });
109
+ });
110
+
111
+ // ─── 파라미터 검증 (parseTransformParams 간접 검증)
112
+
113
+ describe("파라미터 검증", () => {
114
+ it("유효한 w 파라미터 → 변환 시도 (sharp 미설치 시 원본 반환)", async () => {
115
+ const storage = createStorage(tmpDir);
116
+ const pngBuffer = Buffer.from(
117
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
118
+ "base64",
119
+ );
120
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
121
+
122
+ const handler = storageRoutes(storage, undefined, tmpDir);
123
+ const { c } = mockContext(id, { w: "300" }, {});
124
+ const response = await handler(c as any);
125
+ // sharp 미설치 → graceful degradation → 원본 서빙
126
+ expect(response.status).toBe(200);
127
+ });
192
128
 
193
- it("무효한 품질 (101) → 원본 서빙", async () => {
194
- const storage = createStorage(tmpDir);
195
- const pngBuffer = Buffer.from(
196
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
197
- "base64"
198
- );
199
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
200
-
201
- const handler = storageRoutes(storage, undefined, tmpDir);
202
- const { c } = mockContext(id, { q: "101" }, {});
203
- const response = await handler(c as any);
204
- expect(response.status).toBe(200);
205
- });
129
+ it("무효한 w 파라미터 (> 4096) → 원본 서빙 (파라미터 무시)", async () => {
130
+ const storage = createStorage(tmpDir);
131
+ const pngBuffer = Buffer.from(
132
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
133
+ "base64",
134
+ );
135
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
136
+
137
+ const handler = storageRoutes(storage, undefined, tmpDir);
138
+ const { c } = mockContext(id, { w: "9999" }, {});
139
+ const response = await handler(c as any);
140
+ expect(response.status).toBe(200);
141
+ // 무효 파라미터 → parseTransformParams returns null → 원본 서빙
142
+ expect(response.headers.get("content-type")).toBe("image/png");
143
+ });
206
144
 
207
- it("무효한 fit 모드 → 원본 서빙", async () => {
208
- const storage = createStorage(tmpDir);
209
- const pngBuffer = Buffer.from(
210
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
211
- "base64"
212
- );
213
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
214
-
215
- const handler = storageRoutes(storage, undefined, tmpDir);
216
- const { c } = mockContext(id, { fit: "stretch" }, {});
217
- const response = await handler(c as any);
218
- expect(response.status).toBe(200);
219
- });
145
+ it("무효한 w 파라미터 (음수) → 원본 서빙", async () => {
146
+ const storage = createStorage(tmpDir);
147
+ const pngBuffer = Buffer.from(
148
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
149
+ "base64",
150
+ );
151
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
152
+
153
+ const handler = storageRoutes(storage, undefined, tmpDir);
154
+ const { c } = mockContext(id, { w: "-1" }, {});
155
+ const response = await handler(c as any);
156
+ expect(response.status).toBe(200);
157
+ expect(response.headers.get("content-type")).toBe("image/png");
220
158
  });
221
159
 
222
- // ─── Auto WebP 분기 ──────────────────────────────────
160
+ it("무효한 포맷 원본 서빙", async () => {
161
+ const storage = createStorage(tmpDir);
162
+ const pngBuffer = Buffer.from(
163
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
164
+ "base64",
165
+ );
166
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
167
+
168
+ const handler = storageRoutes(storage, undefined, tmpDir);
169
+ const { c } = mockContext(id, { f: "bmp" }, {}); // bmp는 허용 포맷 아님
170
+ const response = await handler(c as any);
171
+ expect(response.status).toBe(200);
172
+ expect(response.headers.get("content-type")).toBe("image/png");
173
+ });
223
174
 
224
- describe("Auto WebP", () => {
225
- it("Accept: image/webp + PNG 원본 → Auto WebP 시도", async () => {
226
- const storage = createStorage(tmpDir);
227
- const pngBuffer = Buffer.from(
228
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
229
- "base64"
230
- );
231
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
175
+ it("무효한 품질 (0) → 원본 서빙", async () => {
176
+ const storage = createStorage(tmpDir);
177
+ const pngBuffer = Buffer.from(
178
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
179
+ "base64",
180
+ );
181
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
182
+
183
+ const handler = storageRoutes(storage, undefined, tmpDir);
184
+ const { c } = mockContext(id, { q: "0" }, {});
185
+ const response = await handler(c as any);
186
+ expect(response.status).toBe(200);
187
+ });
232
188
 
233
- const handler = storageRoutes(storage, undefined, tmpDir);
234
- const { c } = mockContext(id, {}, { accept: "text/html,image/webp,*/*" });
235
- const response = await handler(c as any);
236
- // sharp 미설치 시 원본 서빙 (graceful degradation)
237
- expect(response.status).toBe(200);
238
- });
189
+ it("무효한 품질 (101) → 원본 서빙", async () => {
190
+ const storage = createStorage(tmpDir);
191
+ const pngBuffer = Buffer.from(
192
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
193
+ "base64",
194
+ );
195
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
196
+
197
+ const handler = storageRoutes(storage, undefined, tmpDir);
198
+ const { c } = mockContext(id, { q: "101" }, {});
199
+ const response = await handler(c as any);
200
+ expect(response.status).toBe(200);
201
+ });
239
202
 
240
- it("Accept 헤더에 webp 없음 Auto WebP 안 함 (원본 서빙)", async () => {
241
- const storage = createStorage(tmpDir);
242
- const pngBuffer = Buffer.from(
243
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
244
- "base64"
245
- );
246
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
247
-
248
- const handler = storageRoutes(storage, undefined, tmpDir);
249
- const { c } = mockContext(id, {}, { accept: "text/html,*/*" });
250
- const response = await handler(c as any);
251
- expect(response.status).toBe(200);
252
- expect(response.headers.get("content-type")).toBe("image/png");
253
- });
203
+ it("무효한 fit 모드 → 원본 서빙", async () => {
204
+ const storage = createStorage(tmpDir);
205
+ const pngBuffer = Buffer.from(
206
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
207
+ "base64",
208
+ );
209
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
210
+
211
+ const handler = storageRoutes(storage, undefined, tmpDir);
212
+ const { c } = mockContext(id, { fit: "stretch" }, {});
213
+ const response = await handler(c as any);
214
+ expect(response.status).toBe(200);
215
+ });
216
+ });
217
+
218
+ // ─── Auto WebP 분기 ──────────────────────────────────
219
+
220
+ describe("Auto WebP", () => {
221
+ it("Accept: image/webp + PNG 원본 → Auto WebP 시도", async () => {
222
+ const storage = createStorage(tmpDir);
223
+ const pngBuffer = Buffer.from(
224
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
225
+ "base64",
226
+ );
227
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
228
+
229
+ const handler = storageRoutes(storage, undefined, tmpDir);
230
+ const { c } = mockContext(id, {}, { accept: "text/html,image/webp,*/*" });
231
+ const response = await handler(c as any);
232
+ // sharp 미설치 시 원본 서빙 (graceful degradation)
233
+ expect(response.status).toBe(200);
234
+ });
254
235
 
255
- it("WebP 원본에는 Auto WebP 적용 안 함", async () => {
256
- const storage = createStorage(tmpDir);
257
- // 가짜 webp 데이터 — RIFF 헤더
258
- const webpBuffer = Buffer.alloc(16);
259
- webpBuffer.write("RIFF", 0);
260
- webpBuffer.writeUInt32LE(8, 4);
261
- webpBuffer.write("WEBP", 8);
262
- const id = await storage.storeBuffer(webpBuffer, "test.webp", "image/webp");
263
-
264
- const handler = storageRoutes(storage, undefined, tmpDir);
265
- const { c } = mockContext(id, {}, { accept: "image/webp" });
266
- const response = await handler(c as any);
267
- expect(response.status).toBe(200);
268
- expect(response.headers.get("content-type")).toBe("image/webp");
269
- });
236
+ it("Accept 헤더에 webp 없음 → Auto WebP 안 함 (원본 서빙)", async () => {
237
+ const storage = createStorage(tmpDir);
238
+ const pngBuffer = Buffer.from(
239
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
240
+ "base64",
241
+ );
242
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
243
+
244
+ const handler = storageRoutes(storage, undefined, tmpDir);
245
+ const { c } = mockContext(id, {}, { accept: "text/html,*/*" });
246
+ const response = await handler(c as any);
247
+ expect(response.status).toBe(200);
248
+ expect(response.headers.get("content-type")).toBe("image/png");
249
+ });
270
250
 
271
- it("autoWebp=false 설정 Auto WebP 비활성", async () => {
272
- const storage = createStorage(tmpDir);
273
- const pngBuffer = Buffer.from(
274
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
275
- "base64"
276
- );
277
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
278
-
279
- const tierConfig: StorageImageTierConfig = { autoWebp: false };
280
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
281
- const { c } = mockContext(id, {}, { accept: "image/webp" });
282
- const response = await handler(c as any);
283
- expect(response.status).toBe(200);
284
- expect(response.headers.get("content-type")).toBe("image/png");
285
- });
251
+ it("WebP 원본에는 Auto WebP 적용 안 함", async () => {
252
+ const storage = createStorage(tmpDir);
253
+ // 가짜 webp 데이터 — RIFF 헤더
254
+ const webpBuffer = Buffer.alloc(16);
255
+ webpBuffer.write("RIFF", 0);
256
+ webpBuffer.writeUInt32LE(8, 4);
257
+ webpBuffer.write("WEBP", 8);
258
+ const id = await storage.storeBuffer(webpBuffer, "test.webp", "image/webp");
259
+
260
+ const handler = storageRoutes(storage, undefined, tmpDir);
261
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
262
+ const response = await handler(c as any);
263
+ expect(response.status).toBe(200);
264
+ expect(response.headers.get("content-type")).toBe("image/webp");
286
265
  });
287
266
 
288
- // ─── Tier 기반 403 응답 ─────────────────────────────
289
-
290
- describe("Tier 기반 접근 제어", () => {
291
- it("resize=false + w 파라미터 → 403 PLAN_LIMIT", async () => {
292
- const storage = createStorage(tmpDir);
293
- const pngBuffer = Buffer.from(
294
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
295
- "base64"
296
- );
297
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
298
-
299
- const tierConfig: StorageImageTierConfig = {
300
- autoWebp: true,
301
- resize: false,
302
- formats: ["webp"],
303
- qualityControl: false,
304
- };
305
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
306
- const { c } = mockContext(id, { w: "300" }, {});
307
- const response = await handler(c as any);
308
-
309
- expect(response.status).toBe(403);
310
- const body = await response.json();
311
- expect(body.code).toBe("PLAN_LIMIT");
312
- expect(body.upgrade).toBeTruthy();
313
- });
267
+ it("autoWebp=false 설정 Auto WebP 비활성", async () => {
268
+ const storage = createStorage(tmpDir);
269
+ const pngBuffer = Buffer.from(
270
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
271
+ "base64",
272
+ );
273
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
274
+
275
+ const tierConfig: StorageImageTierConfig = { autoWebp: false };
276
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
277
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
278
+ const response = await handler(c as any);
279
+ expect(response.status).toBe(200);
280
+ expect(response.headers.get("content-type")).toBe("image/png");
281
+ });
282
+ });
283
+
284
+ // ─── Tier 기반 403 응답 ─────────────────────────────
285
+
286
+ describe("Tier 기반 접근 제어", () => {
287
+ it("resize=false + w 파라미터 → 403 PLAN_LIMIT", async () => {
288
+ const storage = createStorage(tmpDir);
289
+ const pngBuffer = Buffer.from(
290
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
291
+ "base64",
292
+ );
293
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
294
+
295
+ const tierConfig: StorageImageTierConfig = {
296
+ autoWebp: true,
297
+ resize: false,
298
+ formats: ["webp"],
299
+ qualityControl: false,
300
+ };
301
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
302
+ const { c } = mockContext(id, { w: "300" }, {});
303
+ const response = await handler(c as any);
304
+
305
+ expect(response.status).toBe(403);
306
+ const body = await response.json();
307
+ expect(body.code).toBe("PLAN_LIMIT");
308
+ expect(body.upgrade).toBeTruthy();
309
+ });
314
310
 
315
- it("허용되지 않은 포맷 → 403 PLAN_LIMIT", async () => {
316
- const storage = createStorage(tmpDir);
317
- const pngBuffer = Buffer.from(
318
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
319
- "base64"
320
- );
321
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
322
-
323
- const tierConfig: StorageImageTierConfig = {
324
- resize: true,
325
- formats: ["webp"], // avif 불허
326
- qualityControl: true,
327
- };
328
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
329
- const { c } = mockContext(id, { f: "avif" }, {});
330
- const response = await handler(c as any);
331
-
332
- expect(response.status).toBe(403);
333
- const body = await response.json();
334
- expect(body.code).toBe("PLAN_LIMIT");
335
- expect(body.allowed.formats).toEqual(["webp"]);
336
- });
311
+ it("허용되지 않은 포맷 → 403 PLAN_LIMIT", async () => {
312
+ const storage = createStorage(tmpDir);
313
+ const pngBuffer = Buffer.from(
314
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
315
+ "base64",
316
+ );
317
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
318
+
319
+ const tierConfig: StorageImageTierConfig = {
320
+ resize: true,
321
+ formats: ["webp"], // avif 불허
322
+ qualityControl: true,
323
+ };
324
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
325
+ const { c } = mockContext(id, { f: "avif" }, {});
326
+ const response = await handler(c as any);
327
+
328
+ expect(response.status).toBe(403);
329
+ const body = await response.json();
330
+ expect(body.code).toBe("PLAN_LIMIT");
331
+ expect(body.allowed.formats).toEqual(["webp"]);
332
+ });
337
333
 
338
- it("qualityControl=false + q 파라미터 → 403", async () => {
339
- const storage = createStorage(tmpDir);
340
- const pngBuffer = Buffer.from(
341
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
342
- "base64"
343
- );
344
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
345
-
346
- const tierConfig: StorageImageTierConfig = {
347
- resize: true,
348
- formats: ["webp", "avif", "jpeg", "png"],
349
- qualityControl: false,
350
- };
351
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
352
- const { c } = mockContext(id, { q: "50" }, {});
353
- const response = await handler(c as any);
354
-
355
- expect(response.status).toBe(403);
356
- });
334
+ it("qualityControl=false + q 파라미터 → 403", async () => {
335
+ const storage = createStorage(tmpDir);
336
+ const pngBuffer = Buffer.from(
337
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
338
+ "base64",
339
+ );
340
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
341
+
342
+ const tierConfig: StorageImageTierConfig = {
343
+ resize: true,
344
+ formats: ["webp", "avif", "jpeg", "png"],
345
+ qualityControl: false,
346
+ };
347
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
348
+ const { c } = mockContext(id, { q: "50" }, {});
349
+ const response = await handler(c as any);
350
+
351
+ expect(response.status).toBe(403);
352
+ });
357
353
 
358
- it("모든 기능 허용 → 403 없음 (sharp 미설치 시 원본 반환)", async () => {
359
- const storage = createStorage(tmpDir);
360
- const pngBuffer = Buffer.from(
361
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
362
- "base64"
363
- );
364
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
354
+ it("모든 기능 허용 → 403 없음 (sharp 미설치 시 원본 반환)", async () => {
355
+ const storage = createStorage(tmpDir);
356
+ const pngBuffer = Buffer.from(
357
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
358
+ "base64",
359
+ );
360
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
365
361
 
366
- // tierConfig 미전달 → 모든 기능 활성화
367
- const handler = storageRoutes(storage, undefined, tmpDir);
368
- const { c } = mockContext(id, { w: "300", f: "webp", q: "80" }, {});
369
- const response = await handler(c as any);
362
+ // tierConfig 미전달 → 모든 기능 활성화
363
+ const handler = storageRoutes(storage, undefined, tmpDir);
364
+ const { c } = mockContext(id, { w: "300", f: "webp", q: "80" }, {});
365
+ const response = await handler(c as any);
370
366
 
371
- expect(response.status).toBe(200); // 403이 아님
372
- });
367
+ expect(response.status).toBe(200); // 403이 아님
368
+ });
369
+ });
370
+
371
+ // ─── 캐시 관리 ─────────────────────────────────────
372
+
373
+ describe("캐시 관리", () => {
374
+ it("delete 시 캐시 디렉토리도 정리된다", async () => {
375
+ const storage = createStorage(tmpDir);
376
+ const pngBuffer = Buffer.from(
377
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
378
+ "base64",
379
+ );
380
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
381
+
382
+ // 가짜 캐시 파일 생성
383
+ const cacheDir = path.join(tmpDir, ".cache");
384
+ await fs.mkdir(cacheDir, { recursive: true });
385
+ await fs.writeFile(path.join(cacheDir, `${id}_auto_webp.webp`), "fake cache");
386
+ await fs.writeFile(path.join(cacheDir, `${id}_w300_fwebp_q80.webp`), "fake cache 2");
387
+ // 다른 UUID의 캐시는 남아야 함
388
+ await fs.writeFile(path.join(cacheDir, "other-uuid_auto_webp.webp"), "other");
389
+
390
+ // 삭제
391
+ await storage.delete(id);
392
+
393
+ // 해당 UUID 캐시 삭제됨
394
+ const entries = await fs.readdir(cacheDir);
395
+ expect(entries).not.toContain(`${id}_auto_webp.webp`);
396
+ expect(entries).not.toContain(`${id}_w300_fwebp_q80.webp`);
397
+ // 다른 UUID 캐시는 남아 있음
398
+ expect(entries).toContain("other-uuid_auto_webp.webp");
373
399
  });
374
400
 
375
- // ─── 캐시 관리 ─────────────────────────────────────
376
-
377
- describe("캐시 관리", () => {
378
- it("delete 시 캐시 디렉토리도 정리된다", async () => {
379
- const storage = createStorage(tmpDir);
380
- const pngBuffer = Buffer.from(
381
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
382
- "base64"
383
- );
384
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
385
-
386
- // 가짜 캐시 파일 생성
387
- const cacheDir = path.join(tmpDir, ".cache");
388
- await fs.mkdir(cacheDir, { recursive: true });
389
- await fs.writeFile(path.join(cacheDir, `${id}_auto_webp.webp`), "fake cache");
390
- await fs.writeFile(path.join(cacheDir, `${id}_w300_fwebp_q80.webp`), "fake cache 2");
391
- // 다른 UUID의 캐시는 남아야 함
392
- await fs.writeFile(path.join(cacheDir, "other-uuid_auto_webp.webp"), "other");
393
-
394
- // 삭제
395
- await storage.delete(id);
396
-
397
- // 해당 UUID 캐시 삭제됨
398
- const entries = await fs.readdir(cacheDir);
399
- expect(entries).not.toContain(`${id}_auto_webp.webp`);
400
- expect(entries).not.toContain(`${id}_w300_fwebp_q80.webp`);
401
- // 다른 UUID 캐시는 남아 있음
402
- expect(entries).toContain("other-uuid_auto_webp.webp");
403
- });
404
-
405
- it("캐시 디렉토리 미존재 시 delete 에러 없음", async () => {
406
- const storage = createStorage(tmpDir);
407
- const id = await storage.storeBuffer(Buffer.from("test"), "test.png", "image/png");
401
+ it("캐시 디렉토리 미존재 delete 에러 없음", async () => {
402
+ const storage = createStorage(tmpDir);
403
+ const id = await storage.storeBuffer(Buffer.from("test"), "test.png", "image/png");
408
404
 
409
- // .cache 디렉토리가 없어도 에러 없이 정상 삭제
410
- await expect(storage.delete(id)).resolves.toBeUndefined();
411
- });
405
+ // .cache 디렉토리가 없어도 에러 없이 정상 삭제
406
+ await expect(storage.delete(id)).resolves.toBeUndefined();
412
407
  });
408
+ });
413
409
 
414
- // ─── 하위호환성 ────────────────────────────────────
410
+ // ─── 하위호환성 ────────────────────────────────────
415
411
 
416
- describe("하위호환성", () => {
417
- it("tierConfig 없이 storageRoutes 호출 → 에러 없음", () => {
418
- const storage = createStorage(tmpDir);
419
- // 기존 3인자 호출
420
- const handler = storageRoutes(storage, undefined, tmpDir);
421
- expect(typeof handler).toBe("function");
422
- });
412
+ describe("하위호환성", () => {
413
+ it("tierConfig 없이 storageRoutes 호출 → 에러 없음", () => {
414
+ const storage = createStorage(tmpDir);
415
+ // 기존 3인자 호출
416
+ const handler = storageRoutes(storage, undefined, tmpDir);
417
+ expect(typeof handler).toBe("function");
418
+ });
423
419
 
424
- it("Cache-Control: immutable 헤더 유지", async () => {
425
- const storage = createStorage(tmpDir);
426
- const id = await storage.storeBuffer(Buffer.from("test"), "test.txt", "text/plain");
420
+ it("Cache-Control: immutable 헤더 유지", async () => {
421
+ const storage = createStorage(tmpDir);
422
+ const id = await storage.storeBuffer(Buffer.from("test"), "test.txt", "text/plain");
427
423
 
428
- const handler = storageRoutes(storage, undefined, tmpDir);
429
- const { c } = mockContext(id);
430
- const response = await handler(c as any);
424
+ const handler = storageRoutes(storage, undefined, tmpDir);
425
+ const { c } = mockContext(id);
426
+ const response = await handler(c as any);
431
427
 
432
- expect(response.headers.get("cache-control")).toContain("immutable");
433
- });
428
+ expect(response.headers.get("cache-control")).toContain("immutable");
434
429
  });
430
+ });
435
431
 
436
- // ─── 앱별 이미지 오버라이드 ─────────────────────────
432
+ // ─── 앱별 이미지 오버라이드 ─────────────────────────
437
433
 
438
- describe("앱별 이미지 오버라이드", () => {
439
- afterEach(() => {
440
- delete process.env.__GENCOW_IMAGE_APP_CONFIG;
441
- });
434
+ describe("앱별 이미지 오버라이드", () => {
435
+ afterEach(() => {
436
+ delete process.env.__GENCOW_IMAGE_APP_CONFIG;
437
+ });
442
438
 
443
- it("autoQuality를 tierConfig로 전달하면 기본값(75) 대신 사용", () => {
444
- const storage = createStorage(tmpDir);
445
- const tierConfig: StorageImageTierConfig = { autoQuality: 85 };
446
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
447
- expect(typeof handler).toBe("function");
448
- });
439
+ it("autoQuality를 tierConfig로 전달하면 기본값(75) 대신 사용", () => {
440
+ const storage = createStorage(tmpDir);
441
+ const tierConfig: StorageImageTierConfig = { autoQuality: 85 };
442
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
443
+ expect(typeof handler).toBe("function");
444
+ });
449
445
 
450
- it("__GENCOW_IMAGE_APP_CONFIG 환경변수가 있으면 파싱하여 config에 병합", async () => {
451
- process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoMaxWidth: 1280, autoQuality: 60 });
452
-
453
- const storage = createStorage(tmpDir);
454
- const pngBuffer = Buffer.from(
455
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
456
- "base64"
457
- );
458
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
459
-
460
- // Tier: autoMaxWidth=1920 → 앱 설정 1280이 더 작으므로 1280 적용
461
- const tierConfig: StorageImageTierConfig = { autoMaxWidth: 1920 };
462
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
463
- const { c } = mockContext(id, {}, { accept: "image/webp" });
464
- const response = await handler(c as any);
465
- expect(response.status).toBe(200); // 정상 처리 (sharp 미설치 시 원본)
466
- });
446
+ it("__GENCOW_IMAGE_APP_CONFIG 환경변수가 있으면 파싱하여 config에 병합", async () => {
447
+ process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoMaxWidth: 1280, autoQuality: 60 });
448
+
449
+ const storage = createStorage(tmpDir);
450
+ const pngBuffer = Buffer.from(
451
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
452
+ "base64",
453
+ );
454
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
455
+
456
+ // Tier: autoMaxWidth=1920 → 앱 설정 1280이 더 작으므로 1280 적용
457
+ const tierConfig: StorageImageTierConfig = { autoMaxWidth: 1920 };
458
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
459
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
460
+ const response = await handler(c as any);
461
+ expect(response.status).toBe(200); // 정상 처리 (sharp 미설치 시 원본)
462
+ });
467
463
 
468
- it("앱 maxWidth > Tier maxWidth → Tier ceiling 적용", async () => {
469
- // 앱이 3840을 원하지만 Tier(Hobby)가 1920이면 1920 적용
470
- process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoMaxWidth: 3840 });
471
-
472
- const storage = createStorage(tmpDir);
473
- const pngBuffer = Buffer.from(
474
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
475
- "base64"
476
- );
477
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
478
-
479
- const tierConfig: StorageImageTierConfig = { autoMaxWidth: 1920 };
480
- const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
481
- const { c } = mockContext(id, {}, { accept: "image/webp" });
482
- const response = await handler(c as any);
483
- expect(response.status).toBe(200);
484
- });
464
+ it("앱 maxWidth > Tier maxWidth → Tier ceiling 적용", async () => {
465
+ // 앱이 3840을 원하지만 Tier(Hobby)가 1920이면 1920 적용
466
+ process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoMaxWidth: 3840 });
467
+
468
+ const storage = createStorage(tmpDir);
469
+ const pngBuffer = Buffer.from(
470
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
471
+ "base64",
472
+ );
473
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
474
+
475
+ const tierConfig: StorageImageTierConfig = { autoMaxWidth: 1920 };
476
+ const handler = storageRoutes(storage, undefined, tmpDir, tierConfig);
477
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
478
+ const response = await handler(c as any);
479
+ expect(response.status).toBe(200);
480
+ });
485
481
 
486
- it("앱 설정 없음 → 기본값 유지", async () => {
487
- // __GENCOW_IMAGE_APP_CONFIG가 없을 때 기본 동작
488
- const storage = createStorage(tmpDir);
489
- const pngBuffer = Buffer.from(
490
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
491
- "base64"
492
- );
493
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
494
-
495
- const handler = storageRoutes(storage, undefined, tmpDir);
496
- const { c } = mockContext(id, {}, { accept: "image/webp" });
497
- const response = await handler(c as any);
498
- expect(response.status).toBe(200);
499
- });
482
+ it("앱 설정 없음 → 기본값 유지", async () => {
483
+ // __GENCOW_IMAGE_APP_CONFIG가 없을 때 기본 동작
484
+ const storage = createStorage(tmpDir);
485
+ const pngBuffer = Buffer.from(
486
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
487
+ "base64",
488
+ );
489
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
490
+
491
+ const handler = storageRoutes(storage, undefined, tmpDir);
492
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
493
+ const response = await handler(c as any);
494
+ expect(response.status).toBe(200);
495
+ });
500
496
 
501
- it("무효한 JSON → 파싱 무시 (에러 없음)", async () => {
502
- process.env.__GENCOW_IMAGE_APP_CONFIG = "not-valid-json";
497
+ it("무효한 JSON → 파싱 무시 (에러 없음)", async () => {
498
+ process.env.__GENCOW_IMAGE_APP_CONFIG = "not-valid-json";
503
499
 
504
- const storage = createStorage(tmpDir);
505
- const pngBuffer = Buffer.from(
506
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
507
- "base64"
508
- );
509
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
500
+ const storage = createStorage(tmpDir);
501
+ const pngBuffer = Buffer.from(
502
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
503
+ "base64",
504
+ );
505
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
510
506
 
511
- const handler = storageRoutes(storage, undefined, tmpDir);
512
- const { c } = mockContext(id, {}, { accept: "image/webp" });
513
- const response = await handler(c as any);
514
- expect(response.status).toBe(200); // 에러 없이 기본값 사용
515
- });
507
+ const handler = storageRoutes(storage, undefined, tmpDir);
508
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
509
+ const response = await handler(c as any);
510
+ expect(response.status).toBe(200); // 에러 없이 기본값 사용
511
+ });
516
512
 
517
- it("autoQuality 범위 초과 (101) → 무시", async () => {
518
- process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoQuality: 101 });
513
+ it("autoQuality 범위 초과 (101) → 무시", async () => {
514
+ process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoQuality: 101 });
519
515
 
520
- const storage = createStorage(tmpDir);
521
- const pngBuffer = Buffer.from(
522
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
523
- "base64"
524
- );
525
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
516
+ const storage = createStorage(tmpDir);
517
+ const pngBuffer = Buffer.from(
518
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
519
+ "base64",
520
+ );
521
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
526
522
 
527
- const handler = storageRoutes(storage, undefined, tmpDir);
528
- const { c } = mockContext(id, {}, { accept: "image/webp" });
529
- const response = await handler(c as any);
530
- expect(response.status).toBe(200);
531
- });
523
+ const handler = storageRoutes(storage, undefined, tmpDir);
524
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
525
+ const response = await handler(c as any);
526
+ expect(response.status).toBe(200);
527
+ });
532
528
 
533
- it(".image-config.json 변경 → 동일 handler에서 새 config 반영 (파일 기반 hot-reload)", async () => {
534
- // 1. .image-config.json에 mw320 설정
535
- const fs = await import("fs");
536
- const configPath = `${tmpDir}/.image-config.json`;
537
- fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 320 }));
538
-
539
- const storage = createStorage(tmpDir);
540
- const pngBuffer = Buffer.from(
541
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
542
- "base64"
543
- );
544
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
545
-
546
- const handler = storageRoutes(storage, undefined, tmpDir, { autoMaxWidth: 1920 });
547
- const { c: c1 } = mockContext(id, {}, { accept: "image/webp" });
548
- const response1 = await handler(c1 as any);
549
- expect(response1.status).toBe(200);
550
-
551
- // 2. 파일을 mw1920으로 변경 (앱 재시작 없이)
552
- fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 1920 }));
553
-
554
- // 3. 동일 handler로 다시 요청 → 새 config 반영되어야 함
555
- const { c: c2 } = mockContext(id, {}, { accept: "image/webp" });
556
- const response2 = await handler(c2 as any);
557
- expect(response2.status).toBe(200);
558
- // 두 요청 모두 성공 — config 파일이 매 요청마다 읽히므로 재시작 불필요
559
- });
529
+ it(".image-config.json 변경 → 동일 handler에서 새 config 반영 (파일 기반 hot-reload)", async () => {
530
+ // 1. .image-config.json에 mw320 설정
531
+ const fs = await import("fs");
532
+ const configPath = `${tmpDir}/.image-config.json`;
533
+ fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 320 }));
534
+
535
+ const storage = createStorage(tmpDir);
536
+ const pngBuffer = Buffer.from(
537
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
538
+ "base64",
539
+ );
540
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
541
+
542
+ const handler = storageRoutes(storage, undefined, tmpDir, { autoMaxWidth: 1920 });
543
+ const { c: c1 } = mockContext(id, {}, { accept: "image/webp" });
544
+ const response1 = await handler(c1 as any);
545
+ expect(response1.status).toBe(200);
546
+
547
+ // 2. 파일을 mw1920으로 변경 (앱 재시작 없이)
548
+ fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 1920 }));
549
+
550
+ // 3. 동일 handler로 다시 요청 → 새 config 반영되어야 함
551
+ const { c: c2 } = mockContext(id, {}, { accept: "image/webp" });
552
+ const response2 = await handler(c2 as any);
553
+ expect(response2.status).toBe(200);
554
+ // 두 요청 모두 성공 — config 파일이 매 요청마다 읽히므로 재시작 불필요
555
+ });
560
556
 
561
- it("config 파일 삭제 → 기본값으로 복원 (파일 기반 hot-reload)", async () => {
562
- // 1. 커스텀 config 파일 생성
563
- const fs = await import("fs");
564
- const configPath = `${tmpDir}/.image-config.json`;
565
- fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 320, autoQuality: 50 }));
566
-
567
- const storage = createStorage(tmpDir);
568
- const pngBuffer = Buffer.from(
569
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
570
- "base64"
571
- );
572
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
573
-
574
- const handler = storageRoutes(storage, undefined, tmpDir);
575
- const { c: c1 } = mockContext(id, {}, { accept: "image/webp" });
576
- await handler(c1 as any);
577
-
578
- // 2. config 파일 삭제 (리셋)
579
- fs.unlinkSync(configPath);
580
-
581
- // 3. 동일 handler → 기본값 사용
582
- const { c: c2 } = mockContext(id, {}, { accept: "image/webp" });
583
- const response2 = await handler(c2 as any);
584
- expect(response2.status).toBe(200);
585
- });
557
+ it("config 파일 삭제 → 기본값으로 복원 (파일 기반 hot-reload)", async () => {
558
+ // 1. 커스텀 config 파일 생성
559
+ const fs = await import("fs");
560
+ const configPath = `${tmpDir}/.image-config.json`;
561
+ fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 320, autoQuality: 50 }));
562
+
563
+ const storage = createStorage(tmpDir);
564
+ const pngBuffer = Buffer.from(
565
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
566
+ "base64",
567
+ );
568
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
569
+
570
+ const handler = storageRoutes(storage, undefined, tmpDir);
571
+ const { c: c1 } = mockContext(id, {}, { accept: "image/webp" });
572
+ await handler(c1 as any);
573
+
574
+ // 2. config 파일 삭제 (리셋)
575
+ fs.unlinkSync(configPath);
576
+
577
+ // 3. 동일 handler → 기본값 사용
578
+ const { c: c2 } = mockContext(id, {}, { accept: "image/webp" });
579
+ const response2 = await handler(c2 as any);
580
+ expect(response2.status).toBe(200);
581
+ });
586
582
 
587
- it("config 파일 파싱 — autoWebp=false → WebP 변환 미적용", async () => {
588
- const fs = await import("fs");
589
- const configPath = `${tmpDir}/.image-config.json`;
590
- // autoWebp=false로 설정
591
- fs.writeFileSync(configPath, JSON.stringify({ autoWebp: false }));
592
-
593
- const storage = createStorage(tmpDir);
594
- const pngBuffer = Buffer.from(
595
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
596
- "base64"
597
- );
598
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
599
-
600
- const handler = storageRoutes(storage, undefined, tmpDir, { autoWebp: true });
601
- const { c } = mockContext(id, {}, { accept: "image/webp" });
602
- const response = await handler(c as any);
603
- expect(response.status).toBe(200);
604
- // autoWebp=false → Content-Type이 원본(image/png)으로 유지되어야 함
605
- // sharp 미설치 시 원본 서빙이므로 항상 image/png
606
- const ct = response.headers.get("content-type");
607
- expect(ct).toBe("image/png");
608
- });
583
+ it("config 파일 파싱 — autoWebp=false → WebP 변환 미적용", async () => {
584
+ const fs = await import("fs");
585
+ const configPath = `${tmpDir}/.image-config.json`;
586
+ // autoWebp=false로 설정
587
+ fs.writeFileSync(configPath, JSON.stringify({ autoWebp: false }));
588
+
589
+ const storage = createStorage(tmpDir);
590
+ const pngBuffer = Buffer.from(
591
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
592
+ "base64",
593
+ );
594
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
595
+
596
+ const handler = storageRoutes(storage, undefined, tmpDir, { autoWebp: true });
597
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
598
+ const response = await handler(c as any);
599
+ expect(response.status).toBe(200);
600
+ // autoWebp=false → Content-Type이 원본(image/png)으로 유지되어야 함
601
+ // sharp 미설치 시 원본 서빙이므로 항상 image/png
602
+ const ct = response.headers.get("content-type");
603
+ expect(ct).toBe("image/png");
604
+ });
609
605
 
610
- it("config 파일 파싱 — Tier ceiling 초과 값은 clamp", async () => {
611
- const fs = await import("fs");
612
- const configPath = `${tmpDir}/.image-config.json`;
613
- // autoMaxWidth=5000 (Tier ceiling 1920 초과)
614
- fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 5000 }));
615
-
616
- const storage = createStorage(tmpDir);
617
- const pngBuffer = Buffer.from(
618
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
619
- "base64"
620
- );
621
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
622
-
623
- // Tier ceiling = 1920
624
- const handler = storageRoutes(storage, undefined, tmpDir, { autoMaxWidth: 1920 });
625
- const { c } = mockContext(id, {}, { accept: "image/webp" });
626
- const response = await handler(c as any);
627
- expect(response.status).toBe(200);
628
- // config의 5000이 Tier ceiling 1920으로 clamp되어야 함
629
- // sharp 미설치 → 원본 서빙이지만, 파싱 자체가 에러 없이 동작하는지 확인
630
- });
606
+ it("config 파일 파싱 — Tier ceiling 초과 값은 clamp", async () => {
607
+ const fs = await import("fs");
608
+ const configPath = `${tmpDir}/.image-config.json`;
609
+ // autoMaxWidth=5000 (Tier ceiling 1920 초과)
610
+ fs.writeFileSync(configPath, JSON.stringify({ autoMaxWidth: 5000 }));
611
+
612
+ const storage = createStorage(tmpDir);
613
+ const pngBuffer = Buffer.from(
614
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
615
+ "base64",
616
+ );
617
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
618
+
619
+ // Tier ceiling = 1920
620
+ const handler = storageRoutes(storage, undefined, tmpDir, { autoMaxWidth: 1920 });
621
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
622
+ const response = await handler(c as any);
623
+ expect(response.status).toBe(200);
624
+ // config의 5000이 Tier ceiling 1920으로 clamp되어야 함
625
+ // sharp 미설치 → 원본 서빙이지만, 파싱 자체가 에러 없이 동작하는지 확인
626
+ });
631
627
 
632
- it("config 파일 파싱 — 잘못된 JSON → 기본값 fallback (에러 없음)", async () => {
633
- const fs = await import("fs");
634
- const configPath = `${tmpDir}/.image-config.json`;
635
- // 잘못된 JSON
636
- fs.writeFileSync(configPath, "{ invalid json !!!");
637
-
638
- const storage = createStorage(tmpDir);
639
- const pngBuffer = Buffer.from(
640
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
641
- "base64"
642
- );
643
- const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
644
-
645
- const handler = storageRoutes(storage, undefined, tmpDir, { autoMaxWidth: 1920 });
646
- const { c } = mockContext(id, {}, { accept: "image/webp" });
647
- // 잘못된 JSON이 에러 없이 기본값으로 fallback
648
- const response = await handler(c as any);
649
- expect(response.status).toBe(200);
650
- });
628
+ it("config 파일 파싱 — 잘못된 JSON → 기본값 fallback (에러 없음)", async () => {
629
+ const fs = await import("fs");
630
+ const configPath = `${tmpDir}/.image-config.json`;
631
+ // 잘못된 JSON
632
+ fs.writeFileSync(configPath, "{ invalid json !!!");
633
+
634
+ const storage = createStorage(tmpDir);
635
+ const pngBuffer = Buffer.from(
636
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
637
+ "base64",
638
+ );
639
+ const id = await storage.storeBuffer(pngBuffer, "test.png", "image/png");
640
+
641
+ const handler = storageRoutes(storage, undefined, tmpDir, { autoMaxWidth: 1920 });
642
+ const { c } = mockContext(id, {}, { accept: "image/webp" });
643
+ // 잘못된 JSON이 에러 없이 기본값으로 fallback
644
+ const response = await handler(c as any);
645
+ expect(response.status).toBe(200);
651
646
  });
647
+ });
652
648
  });