@gencow/core 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crud.d.ts +12 -0
- package/dist/crud.js +16 -0
- package/dist/db.d.ts +13 -0
- package/dist/db.js +16 -0
- package/dist/document-types.d.ts +65 -0
- package/dist/document-types.js +15 -0
- package/dist/grounded-answer-types.d.ts +62 -0
- package/dist/grounded-answer-types.js +6 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +5 -1
- package/dist/rag-ingest-types.d.ts +39 -0
- package/dist/rag-ingest-types.js +1 -0
- package/dist/rag-operations-types.d.ts +81 -0
- package/dist/rag-operations-types.js +1 -0
- package/dist/rag-schema.d.ts +1557 -0
- package/dist/rag-schema.js +87 -0
- package/dist/reactive.d.ts +13 -0
- package/dist/rls-db.d.ts +9 -2
- package/dist/runtime-env-policy.d.ts +5 -0
- package/dist/runtime-env-policy.js +56 -0
- package/dist/search-types.d.ts +83 -0
- package/dist/search-types.js +1 -0
- package/dist/server.d.ts +1 -2
- package/dist/server.js +0 -1
- package/dist/storage-shared.d.ts +36 -0
- package/dist/storage-shared.js +39 -0
- package/dist/storage.d.ts +2 -26
- package/dist/storage.js +19 -15
- package/dist/workflow-types.d.ts +3 -1
- package/package.json +1 -1
- package/src/crud.ts +33 -0
- package/src/document-types.ts +95 -0
- package/src/grounded-answer-types.ts +78 -0
- package/src/index.ts +68 -2
- package/src/rag-ingest-types.ts +52 -0
- package/src/rag-operations-types.ts +90 -0
- package/src/rag-schema.ts +94 -0
- package/src/reactive.ts +13 -0
- package/src/rls-db.ts +9 -4
- package/src/runtime-env-policy.ts +66 -0
- package/src/search-types.ts +91 -0
- package/src/server.ts +1 -2
- package/src/storage-shared.ts +74 -0
- package/src/storage.ts +29 -46
- package/src/workflow-types.ts +3 -1
- package/src/__tests__/auth.test.ts +0 -118
- package/src/__tests__/crons.test.ts +0 -83
- package/src/__tests__/crud-codegen-integration.test.ts +0 -246
- package/src/__tests__/crud-owner-rls.test.ts +0 -387
- package/src/__tests__/crud.test.ts +0 -930
- package/src/__tests__/dist-exports.test.ts +0 -176
- package/src/__tests__/fixtures/basic/auth.ts +0 -32
- package/src/__tests__/fixtures/basic/drizzle.config.ts +0 -12
- package/src/__tests__/fixtures/basic/index.ts +0 -6
- package/src/__tests__/fixtures/basic/migrations/0000_last_warstar.sql +0 -75
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +0 -497
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +0 -13
- package/src/__tests__/fixtures/basic/schema.ts +0 -51
- package/src/__tests__/fixtures/basic/tasks.ts +0 -15
- package/src/__tests__/fixtures/common/auth-schema.ts +0 -67
- package/src/__tests__/helpers/basic-rls-fixture.ts +0 -135
- package/src/__tests__/helpers/pglite-migrations.ts +0 -32
- package/src/__tests__/helpers/pglite-rls-session.ts +0 -51
- package/src/__tests__/helpers/seed-like-fill.ts +0 -202
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +0 -50
- package/src/__tests__/httpaction.test.ts +0 -122
- package/src/__tests__/image-optimization.test.ts +0 -648
- package/src/__tests__/load.test.ts +0 -389
- package/src/__tests__/network-sim.test.ts +0 -319
- package/src/__tests__/reactive.test.ts +0 -479
- package/src/__tests__/retry.test.ts +0 -113
- package/src/__tests__/rls-crud-basic.test.ts +0 -317
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +0 -117
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +0 -142
- package/src/__tests__/rls-custom-query-handlers.test.ts +0 -128
- package/src/__tests__/rls-db-leased-connection.test.ts +0 -118
- package/src/__tests__/rls-session-and-policies.test.ts +0 -228
- package/src/__tests__/scheduler-durable-v2.test.ts +0 -288
- package/src/__tests__/scheduler-durable.test.ts +0 -173
- package/src/__tests__/scheduler-exec.test.ts +0 -328
- package/src/__tests__/scheduler.test.ts +0 -187
- package/src/__tests__/storage.test.ts +0 -334
- package/src/__tests__/tsconfig.json +0 -8
- package/src/__tests__/validator.test.ts +0 -323
- package/src/__tests__/workflow.test.ts +0 -606
- package/src/__tests__/ws-integration.test.ts +0 -309
- package/src/__tests__/ws-scale.test.ts +0 -241
- package/src/auth.ts +0 -155
|
@@ -1,648 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* packages/core/src/__tests__/image-optimization.test.ts
|
|
3
|
-
*
|
|
4
|
-
* Image Optimization Service 단위 + 통합 테스트
|
|
5
|
-
* - parseTransformParams (storageRoutes를 통해 간접 검증)
|
|
6
|
-
* - buildCacheKey (캐시 HIT/MISS 검증)
|
|
7
|
-
* - Tier 기반 403 응답
|
|
8
|
-
* - Auto WebP 분기
|
|
9
|
-
* - 세마포어 동시 제한
|
|
10
|
-
*
|
|
11
|
-
* Run: bun test packages/core/src/__tests__/image-optimization.test.ts
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
-
import { createStorage, storageRoutes } from "../storage.js";
|
|
16
|
-
import type { StorageImageTierConfig } from "../storage.js";
|
|
17
|
-
import * as fs from "fs/promises";
|
|
18
|
-
import * as path from "path";
|
|
19
|
-
import * as os from "os";
|
|
20
|
-
|
|
21
|
-
// ─── 테스트 헬퍼 ────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/** 간단한 Hono context mock */
|
|
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
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ─── 테스트 시작 ────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
describe("Image Optimization — storageRoutes", () => {
|
|
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");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("비이미지 파일은 항상 원본 서빙", async () => {
|
|
90
|
-
const storage = createStorage(tmpDir);
|
|
91
|
-
const id = await storage.storeBuffer(Buffer.from("hello"), "test.txt", "text/plain");
|
|
92
|
-
|
|
93
|
-
const handler = storageRoutes(storage, undefined, tmpDir);
|
|
94
|
-
const { c } = mockContext(id, { w: "300" }, {}); // 파라미터 있어도 변환 안 됨
|
|
95
|
-
|
|
96
|
-
const response = await handler(c as any);
|
|
97
|
-
expect(response.status).toBe(200);
|
|
98
|
-
expect(response.headers.get("content-type")).toBe("text/plain");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("존재하지 않는 storageId → 404", async () => {
|
|
102
|
-
const storage = createStorage(tmpDir);
|
|
103
|
-
const handler = storageRoutes(storage, undefined, tmpDir);
|
|
104
|
-
const { c } = mockContext("nonexistent-id");
|
|
105
|
-
|
|
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
|
-
});
|
|
128
|
-
|
|
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
|
-
});
|
|
144
|
-
|
|
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");
|
|
158
|
-
});
|
|
159
|
-
|
|
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
|
-
});
|
|
174
|
-
|
|
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
|
-
});
|
|
188
|
-
|
|
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
|
-
});
|
|
202
|
-
|
|
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
|
-
});
|
|
235
|
-
|
|
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
|
-
});
|
|
250
|
-
|
|
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");
|
|
265
|
-
});
|
|
266
|
-
|
|
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
|
-
});
|
|
310
|
-
|
|
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
|
-
});
|
|
333
|
-
|
|
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
|
-
});
|
|
353
|
-
|
|
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");
|
|
361
|
-
|
|
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);
|
|
366
|
-
|
|
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");
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it("캐시 디렉토리 미존재 시 delete 에러 없음", async () => {
|
|
402
|
-
const storage = createStorage(tmpDir);
|
|
403
|
-
const id = await storage.storeBuffer(Buffer.from("test"), "test.png", "image/png");
|
|
404
|
-
|
|
405
|
-
// .cache 디렉토리가 없어도 에러 없이 정상 삭제
|
|
406
|
-
await expect(storage.delete(id)).resolves.toBeUndefined();
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// ─── 하위호환성 ────────────────────────────────────
|
|
411
|
-
|
|
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
|
-
});
|
|
419
|
-
|
|
420
|
-
it("Cache-Control: immutable 헤더 유지", async () => {
|
|
421
|
-
const storage = createStorage(tmpDir);
|
|
422
|
-
const id = await storage.storeBuffer(Buffer.from("test"), "test.txt", "text/plain");
|
|
423
|
-
|
|
424
|
-
const handler = storageRoutes(storage, undefined, tmpDir);
|
|
425
|
-
const { c } = mockContext(id);
|
|
426
|
-
const response = await handler(c as any);
|
|
427
|
-
|
|
428
|
-
expect(response.headers.get("cache-control")).toContain("immutable");
|
|
429
|
-
});
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
// ─── 앱별 이미지 오버라이드 ─────────────────────────
|
|
433
|
-
|
|
434
|
-
describe("앱별 이미지 오버라이드", () => {
|
|
435
|
-
afterEach(() => {
|
|
436
|
-
delete process.env.__GENCOW_IMAGE_APP_CONFIG;
|
|
437
|
-
});
|
|
438
|
-
|
|
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
|
-
});
|
|
445
|
-
|
|
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
|
-
});
|
|
463
|
-
|
|
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
|
-
});
|
|
481
|
-
|
|
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
|
-
});
|
|
496
|
-
|
|
497
|
-
it("무효한 JSON → 파싱 무시 (에러 없음)", async () => {
|
|
498
|
-
process.env.__GENCOW_IMAGE_APP_CONFIG = "not-valid-json";
|
|
499
|
-
|
|
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");
|
|
506
|
-
|
|
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
|
-
});
|
|
512
|
-
|
|
513
|
-
it("autoQuality 범위 초과 (101) → 무시", async () => {
|
|
514
|
-
process.env.__GENCOW_IMAGE_APP_CONFIG = JSON.stringify({ autoQuality: 101 });
|
|
515
|
-
|
|
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");
|
|
522
|
-
|
|
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
|
-
});
|
|
528
|
-
|
|
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
|
-
});
|
|
556
|
-
|
|
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
|
-
});
|
|
582
|
-
|
|
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
|
-
});
|
|
605
|
-
|
|
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
|
-
});
|
|
627
|
-
|
|
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);
|
|
646
|
-
});
|
|
647
|
-
});
|
|
648
|
-
});
|