180 lines
10 KiB
TypeScript
180 lines
10 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { readFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
import * as crypto from "node:crypto";
|
|
import ts from "typescript";
|
|
import sharp from "sharp";
|
|
import JSZip from "jszip";
|
|
const root = path.resolve(__dirname, "../..");
|
|
function serverLoader(mocks: Record<string, unknown>, env: Record<string, string> = {}) {
|
|
const modules = new Map<string, { exports: any }>();
|
|
const runtimeGlobal = {};
|
|
const load = (relative: string): any => {
|
|
const filename = path.resolve(root, relative);
|
|
if (modules.has(filename)) return modules.get(filename)!.exports;
|
|
const module = { exports: {} };
|
|
modules.set(filename, module);
|
|
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
esModuleInterop: true,
|
|
},
|
|
}).outputText;
|
|
const nativeRequire = createRequire(filename);
|
|
const requireMock = (name: string) => {
|
|
if (name === "server-only") return {};
|
|
if (Object.prototype.hasOwnProperty.call(mocks, name)) return mocks[name];
|
|
if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`);
|
|
if (name.startsWith("."))
|
|
return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`)));
|
|
return nativeRequire(name);
|
|
};
|
|
const context = vm.createContext({
|
|
module,
|
|
exports: module.exports,
|
|
require: requireMock,
|
|
globalThis: runtimeGlobal,
|
|
process: { env },
|
|
Buffer,
|
|
URL,
|
|
Response,
|
|
crypto,
|
|
setTimeout,
|
|
clearTimeout,
|
|
setImmediate,
|
|
AbortSignal,
|
|
AbortController,
|
|
ReadableStream,
|
|
Headers,
|
|
Blob,
|
|
document: mocks.document,
|
|
window: mocks.window,
|
|
console: { log() {}, warn() {}, error() {}, info() {} },
|
|
fetch:
|
|
mocks.fetch ||
|
|
(() => {
|
|
throw new Error("Unexpected network call");
|
|
}),
|
|
});
|
|
new vm.Script(source, { filename }).runInContext(context);
|
|
return module.exports;
|
|
};
|
|
return load;
|
|
}
|
|
|
|
test("simultaneous archive and download fetch the source once; stored aliases survive module restart", async () => {
|
|
let requests = 0;
|
|
const source = `https://provider.example/${crypto.randomUUID()}.png`;
|
|
const bytes = await sharp({ create: { width: 16, height: 24, channels: 4, background: "#12345680" } }).png().toBuffer();
|
|
const loader = serverLoader({ fetch: async () => { requests++; await new Promise(r => setTimeout(r, 40)); return new Response(bytes, { headers: { "content-type": "image/png" } }); } });
|
|
const cache = loader("lib/storage/image-byte-cache.ts");
|
|
const results = await Promise.all(Array.from({ length: 10 }, () => cache.fetchImageBytes(source)));
|
|
expect(requests).toBe(1);
|
|
expect(results.every((r: any) => r.buffer.equals(bytes))).toBe(true);
|
|
const alias = `https://cdn.example/${crypto.randomUUID()}.png`;
|
|
await cache.cacheImageBytes(`source::${alias}`, results[0]);
|
|
const restarted = serverLoader({})("lib/storage/image-byte-cache.ts");
|
|
expect((await restarted.fetchImageBytes(alias)).buffer.equals(bytes)).toBe(true);
|
|
});
|
|
test("source body timeout aborts a slow body even after successful headers", async () => {
|
|
const cache = serverLoader({ fetch: async (_url: string, options: any) => ({
|
|
ok: true, headers: new Headers({ "content-type": "image/png" }),
|
|
arrayBuffer: () => new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("body timeout")), { once: true })),
|
|
}) })("lib/storage/image-byte-cache.ts");
|
|
await expect(cache.fetchImageBytes(`https://provider.example/${crypto.randomUUID()}`, { timeoutMs: 40 })).rejects.toThrow("body timeout");
|
|
});
|
|
test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizing", async () => {
|
|
const module = serverLoader({})("lib/canvas/photoshop-safe-download.ts");
|
|
const bytes = await sharp({ create: { width: 32, height: 48, channels: 4, background: "#12345680" } }).png().toBuffer();
|
|
expect((await module.convertImageToPhotoshopPng(bytes)).equals(bytes)).toBe(true);
|
|
const jpeg = await sharp(bytes).jpeg().toBuffer();
|
|
const converted = await module.convertImageToPhotoshopPng(jpeg);
|
|
const metadata = await sharp(converted).metadata();
|
|
expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" });
|
|
});
|
|
test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => {
|
|
let url = "https://cdn.example/uploads/test.png";
|
|
let status = "ready";
|
|
const module = serverLoader({
|
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => ({ type: "qiniu-kodo", publicUrl: "https://cdn.example", isEnabled: true }) },
|
|
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ metadata: { downloadAsset: { status, url, contentType: "image/png", bytes: 321 } } }) },
|
|
})("lib/storage/canvas-cdn-download.ts");
|
|
const ready = await module.resolveCanvasCdnDownload(["https://provider.example/out.jpg"], "花瓶.png");
|
|
expect(ready.direct.bytes).toBe(321);
|
|
expect(new URL(ready.direct.downloadUrl).searchParams.get("attname")).toBe("花瓶.png");
|
|
status = "pending";
|
|
expect((await module.resolveCanvasCdnDownload([url], "a.png")).direct).toBeNull();
|
|
status = "ready"; url = "https://evil.example/uploads/test.png";
|
|
expect((await module.resolveCanvasCdnDownload([url], "a.png")).direct).toBeNull();
|
|
});
|
|
test("PNG preparation failure leaves the original archived and schedules a retry", async () => {
|
|
let saved: any;
|
|
const module = serverLoader({
|
|
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
|
|
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
|
|
"./service": {},
|
|
})("lib/storage/image-download-preparation.ts");
|
|
await module.prepareImageArchiveDownload("task");
|
|
expect(saved.status).toBe("failed");
|
|
expect(saved.attempts).toBe(2);
|
|
expect(Date.parse(saved.retryAt)).toBeGreaterThan(Date.now());
|
|
});
|
|
test("ZIP streams retain original bytes and Unicode names without recompression", async () => {
|
|
const module = serverLoader({})("lib/canvas/download-zip-stream.ts");
|
|
const bytes = await sharp({ create: { width: 8, height: 8, channels: 3, background: "red" } }).png().toBuffer();
|
|
const zip = new JSZip(); zip.file("作品/花瓶.png", bytes);
|
|
const data = Buffer.from(await new Response(module.streamDownloadZip(zip)).arrayBuffer());
|
|
const unpacked = await JSZip.loadAsync(data);
|
|
expect((await unpacked.file("作品/花瓶.png")!.async("nodebuffer")).equals(bytes)).toBe(true);
|
|
// Local-file compression method 0 means STORE.
|
|
expect(data.readUInt16LE(8)).toBe(0);
|
|
});
|
|
|
|
test("ready downloads hand the CDN URL to the browser without buffering the file", async () => {
|
|
let frame: any; let payload: any; let appended = false;
|
|
const module = serverLoader({
|
|
"@/lib/image-delivery": { buildImageProxyUrl: (url: string) => url },
|
|
fetch: async (_url: string, init: any) => {
|
|
payload = JSON.parse(init.body);
|
|
return new Response(JSON.stringify({ delivery: "cdn", downloadUrl: "https://cdn.example/a.png?attname=a.png", filename: "a.png", bytes: 123 }), { headers: { "Content-Type": "application/json" } });
|
|
},
|
|
document: { createElement: (tag: string) => { expect(tag).toBe("iframe"); return frame = { remove() {} }; }, body: { appendChild: () => { appended = true; } } },
|
|
window: { setTimeout() {} },
|
|
})("lib/client/canvas-image-download.ts");
|
|
const result = await module.downloadCanvasImageAsPng({ url: "https://provider.example/a.png" });
|
|
expect(payload.preferDirect).toBe(true);
|
|
expect(appended).toBe(true); expect(frame.hidden).toBe(true);
|
|
expect(frame.src).toContain("https://cdn.example/"); expect(result.bytes).toBe(123);
|
|
});
|
|
test("single-image route preserves authentication and returns a small CDN response only when requested", async () => {
|
|
let signedIn = false; let lookups = 0;
|
|
const module = serverLoader({
|
|
"@/app/(auth)/auth": { auth: async () => signedIn ? { user: { id: "owner" } } : null },
|
|
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => { lookups++; return { direct: { delivery: "cdn", downloadUrl: "https://cdn.example/a.png?attname=a.png", filename: "a.png", bytes: 100 }, task: null }; } },
|
|
"@/lib/storage/image-download-preparation": {},
|
|
"next/server": { after() {}, NextResponse: class extends Response { static json(body: unknown, init?: ResponseInit) { return new Response(JSON.stringify(body), { ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) } }); } } },
|
|
})("app/api/canvas/download-image/route.ts");
|
|
const request = { url: "https://app.example/api/canvas/download-image", headers: new Headers(), json: async () => ({ url: "https://provider.example/a.png", preferDirect: true }) };
|
|
expect((await module.POST(request)).status).toBe(401); expect(lookups).toBe(0);
|
|
signedIn = true;
|
|
const response = await module.POST(request);
|
|
expect(response.status).toBe(200); expect(response.headers.get("Cache-Control")).toBe("no-store");
|
|
expect((await response.json()).delivery).toBe("cdn"); expect(lookups).toBe(1);
|
|
});
|
|
test("batch downloads use the durable prepared PNG even when the source cache is cold", async () => {
|
|
const original = `https://provider.example/${crypto.randomUUID()}.jpg`;
|
|
const prepared = `https://cdn.example/${crypto.randomUUID()}.png`;
|
|
const bytes = await sharp({ create: { width: 12, height: 20, channels: 4, background: "#abcdef80" } }).png().toBuffer();
|
|
const fetched: string[] = [];
|
|
const module = serverLoader({
|
|
"@/lib/storage/image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: original, metadata: { downloadAsset: { status: "ready", url: prepared, contentType: "image/png" } } }) },
|
|
fetch: async (url: string) => { fetched.push(url); expect(url).toBe(prepared); return new Response(bytes, { headers: { "Content-Type": "image/png" } }); },
|
|
}, { POSTGRES_URL: "mock-only" })("lib/canvas/photoshop-safe-download.ts");
|
|
const result = await module.fetchCanvasDownloadAsset({ file: { url: original, name: "作品" }, index: 0, requestOrigin: "https://app.example", cookie: "", retryCount: 1 });
|
|
expect(result.ok).toBe(true); expect(result.bytes.equals(bytes)).toBe(true);
|
|
expect(result.filename).toBe("001-作品.png"); expect(fetched).toEqual([prepared]);
|
|
});
|