221 lines
13 KiB
TypeScript
221 lines
13 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||
import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download";
|
||
|
||
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
|
||
|
||
test("original downloads use the archived JPEG rather than the prepared PNG and transfer only HEAD metadata", async () => {
|
||
const calls: Array<{ url: string; method: string }> = [];
|
||
const loader = serverLoader({
|
||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
|
||
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
|
||
} }) },
|
||
fetch: async (url: URL, options: { method: string; cache: string }) => {
|
||
expect(options.cache).toBe("default");
|
||
calls.push({ url: url.toString(), method: options.method });
|
||
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
|
||
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
|
||
},
|
||
});
|
||
const result = await loader("lib/storage/original-image-download.ts").resolveOriginalCdnDownload("https://upstream.example/temporary.png", "产品.png", new AbortController().signal);
|
||
expect(calls).toEqual([{ url: "https://cdn.example/original.jpg", method: "HEAD" }]);
|
||
expect(result).toMatchObject({ delivery: "cdn", filename: "产品.jpg", bytes: 12345 });
|
||
expect(new URL(result.downloadUrl).pathname).toBe("/original.jpg");
|
||
});
|
||
|
||
test("signed original URLs retain the byte-preserving proxy without changing their signature", async () => {
|
||
const module = serverLoader({
|
||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||
fetch() { throw new Error("Do not add filename parameters to a signed URL"); },
|
||
})("lib/storage/original-image-download.ts");
|
||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.jpg?token=signature%2Fvalue&e=123", "image.jpg", new AbortController().signal);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
test("existing CDN objects bypass the archive scan and retain the MIME-derived format", async () => {
|
||
const module = serverLoader({
|
||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||
"./image-archive-db": { findImageArchiveDownloadTask() { throw new Error("An existing CDN object needs no archive scan"); } },
|
||
fetch: async () => ({ ok: true, headers: new Headers({ "content-type": "image/webp" }) }),
|
||
})("lib/storage/original-image-download.ts");
|
||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.png", "source.png", new AbortController().signal);
|
||
expect(result.filename).toBe("source.webp");
|
||
expect(new URL(result.downloadUrl).pathname).toBe("/source.png");
|
||
});
|
||
|
||
test("temporary third-party URLs are never checked with HEAD when no archive is ready", async () => {
|
||
const module = serverLoader({
|
||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||
fetch() { throw new Error("The upstream file service does not support HEAD"); },
|
||
})("lib/storage/original-image-download.ts");
|
||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
||
});
|
||
|
||
test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
|
||
let closed = false;
|
||
const module = serverLoader({
|
||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||
"./image-archive-db": {},
|
||
"./service": {
|
||
toDbS3UploadConfig: () => ({ bucket: "images" }),
|
||
createS3Client: () => ({
|
||
send: async (command: { input: { Bucket: string; Key: string } }) => {
|
||
expect(command.input).toEqual({ Bucket: "images", Key: "uploads/source.png" });
|
||
return { ContentType: "image/jpeg", ContentLength: 12345 };
|
||
},
|
||
destroy: () => { closed = true; },
|
||
}),
|
||
},
|
||
fetch: async () => { throw new DOMException("Timed out", "TimeoutError"); },
|
||
})("lib/storage/original-image-download.ts");
|
||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/uploads/source.png", "图片.png", new AbortController().signal);
|
||
expect(result).toMatchObject({ delivery: "cdn", filename: "图片.jpg", bytes: 12345 });
|
||
expect(closed).toBe(true);
|
||
});
|
||
|
||
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
|
||
const route = serverLoader({
|
||
"next/server": { NextResponse: Response },
|
||
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async () => { throw new Error("HTTP 404"); } },
|
||
})("app/api/image-proxy/route.ts");
|
||
const request = { nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fcdn.example%2Fmissing.jpg&original=1&download=resolve"), signal: new AbortController().signal, headers: new Headers() };
|
||
const response = await route.GET(request);
|
||
const data = await response.json();
|
||
expect(response.status).toBe(404);
|
||
expect(data.failure).toMatchObject({ code: "SOURCE_NOT_FOUND", retryable: false });
|
||
expect(data.failure.recommendation).toBeTruthy();
|
||
expect(data.error).toContain(data.requestId);
|
||
});
|
||
|
||
test("the original proxy returns the first bytes while the upstream is still sending the file", async () => {
|
||
const head = Uint8Array.from([255, 216, 255, 224]);
|
||
const tail = Uint8Array.from([4, 8, 255, 217]);
|
||
let upstream!: ReadableStreamDefaultController<Uint8Array>;
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) { upstream = controller; controller.enqueue(head); },
|
||
});
|
||
const route = serverLoader({
|
||
"next/server": { NextResponse: Response },
|
||
fetch: async () => new Response(body, { headers: { "content-type": "image/jpeg", "content-length": "8" } }),
|
||
})("app/api/image-proxy/route.ts");
|
||
const response = await route.GET({
|
||
nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fimages.example%2Foriginal.jpg&original=1"),
|
||
signal: new AbortController().signal, headers: new Headers(),
|
||
});
|
||
expect(response.headers.get("content-length")).toBe("8");
|
||
const reader = response.body.getReader();
|
||
expect((await reader.read()).value).toEqual(head);
|
||
upstream.enqueue(tail);
|
||
upstream.close();
|
||
expect((await reader.read()).value).toEqual(tail);
|
||
expect((await reader.read()).done).toBe(true);
|
||
});
|
||
|
||
function browserMocks() {
|
||
const frames: any[] = []; const links: any[] = [];
|
||
return { frames, links,
|
||
window: { location: { origin: "https://app.example" }, setTimeout() {} },
|
||
document: { createElement: (tag: string) => ({ tag, click() { links.push(this); }, remove() {} }),
|
||
body: { appendChild: (el: any) => { if (el.tag === "iframe") frames.push(el); }, removeChild() {} } },
|
||
};
|
||
}
|
||
|
||
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||
const dom = browserMocks(); const calls: string[] = [];
|
||
const progress: CanvasImageDownloadProgress[] = [];
|
||
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||
calls.push(url);
|
||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
||
} })("lib/client/canvas-image-download.ts");
|
||
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
|
||
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||
expect(calls).toHaveLength(1);
|
||
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
||
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||
expect(dom.links).toHaveLength(0);
|
||
expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]);
|
||
});
|
||
|
||
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
|
||
const dom = browserMocks(); const bytes = Buffer.from([255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 255, 217]); let calls = 0;
|
||
const client = serverLoader({ ...dom, fetch: async () => ++calls === 1 ? Response.json({ delivery: "proxy" }) : new Response(bytes, { headers: { "content-type": "image/jpeg" } }) })("lib/client/canvas-image-download.ts");
|
||
expect(await client.downloadOriginalImage({ url: "https://external.example/image.png", name: "original.png" })).toEqual({ filename: "original.jpg", bytes: bytes.length });
|
||
expect(calls).toBe(2);
|
||
const actual = await fetch(dom.links[0].href).then(r=>r.arrayBuffer());
|
||
expect(Buffer.from(actual).equals(bytes)).toBe(true);
|
||
URL.revokeObjectURL(dom.links[0].href);
|
||
});
|
||
|
||
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
|
||
const dom = browserMocks();
|
||
const calls: string[] = [];
|
||
const progress: CanvasImageDownloadProgress[] = [];
|
||
const url = "https://cdn.example/image.png";
|
||
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
|
||
calls.push(path);
|
||
if (calls.length === 1) {
|
||
return Response.json({ delivery: "cdn", downloadUrl: url, filename: "商品.webp", bytes: 500 });
|
||
}
|
||
expect(init?.method).toBe("POST");
|
||
expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" });
|
||
if (calls.length === 2) {
|
||
return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在准备原图" }, { status: 202 });
|
||
}
|
||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 });
|
||
} })("lib/client/canvas-image-download.ts");
|
||
expect(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }))
|
||
.toEqual({ filename: "商品.png", bytes: 900 });
|
||
expect(calls.slice(1)).toEqual(["/api/canvas/download-image", "/api/canvas/download-image"]);
|
||
expect(progress.map(event => event.phase)).toEqual(["preparing", "converting", "converting", "downloading"]);
|
||
for (const event of progress.filter(event => event.phase === "converting")) {
|
||
expect(event.message).toContain("正在转换为兼容 PNG");
|
||
expect(event.percent).toBeUndefined();
|
||
}
|
||
expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]);
|
||
expect(dom.links).toHaveLength(0);
|
||
});
|
||
|
||
test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => {
|
||
const dom = browserMocks();
|
||
const calls: string[] = [];
|
||
const progress: CanvasImageDownloadProgress[] = [];
|
||
let cancelled = false;
|
||
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
||
const client = serverLoader({ ...dom, fetch: async (path: string) => {
|
||
calls.push(path);
|
||
if (calls.length === 1) return Response.json({ delivery: "proxy" });
|
||
if (calls.length === 2) {
|
||
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
|
||
{ headers: { "content-type": "image/avif" } });
|
||
}
|
||
return new Response(bytes, { headers: { "content-type": "image/png", "content-length": String(bytes.length) } });
|
||
} })("lib/client/canvas-image-download.ts");
|
||
expect(await client.downloadOriginalImage({
|
||
url: "https://external.example/image", name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event),
|
||
})).toEqual({ filename: "商品.png", bytes: bytes.length });
|
||
expect(cancelled).toBe(true);
|
||
expect(calls[2]).toBe("/api/canvas/download-image");
|
||
expect(progress).toContainEqual({
|
||
phase: "downloading", message: "正在下载 PNG...", loadedBytes: bytes.length, totalBytes: bytes.length, percent: 99,
|
||
});
|
||
expect(dom.links).toHaveLength(1);
|
||
URL.revokeObjectURL(dom.links[0].href);
|
||
});
|
||
|
||
test("original downloads reject processed references before network access", async () => {
|
||
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
|
||
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");
|
||
});
|
||
|
||
test("chat downloads display the server cause and advice without losing its feedback ID", async () => {
|
||
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||
const client = serverLoader({ ...browserMocks(), fetch: async () => Response.json({ failure, requestId: "original-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||
let error: unknown;
|
||
try { await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); } catch(e) { error=e; }
|
||
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain("下载编号:original-feedback");
|
||
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain(failure.recommendation);
|
||
});
|