/** * media.ts 單元測試(塊 3) * * 驗證: * - validateImageFile / validateVideoFile / validateBatchFiles 各種通過 / 失敗路徑 * - buildBatchImageUrl 組路徑(含 / 不含 cacheBust) * - uploadImage / uploadBatchImages / seekVideo 打對的路徑與 FormData 欄位 * (mock XMLHttpRequest + api.post) */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_BATCH_IMAGES, MAX_BATCH_TOTAL_BYTES, MAX_LOCAL_VIDEO_BYTES, MAX_VIDEO_BYTES, VIDEO_FALLBACK_FPS, buildBatchImageUrl, frameToSeekSeconds, seekVideo, uploadBatchImages, uploadImage, validateBatchFiles, validateImageFile, validateLocalVideoFile, validateVideoFile, } from "./media"; function makeFile(name: string, sizeBytes = 1024): File { const blob = new Blob([new Uint8Array(sizeBytes)], { type: "application/octet-stream" }); return new File([blob], name); } /** * 建立「宣稱」指定 size 的 File,但不實際配置該大小的記憶體。 * 用於大檔邊界測試(500 MB 若真配置 Uint8Array 會 OOM)——只覆寫 File.size getter。 */ function makeSizedFile(name: string, sizeBytes: number): File { const file = new File([new Blob([new Uint8Array(8)])], name); Object.defineProperty(file, "size", { value: sizeBytes, configurable: true }); return file; } describe("media validation", () => { it("圖片:JPG/PNG 通過,其他型別擋 TYPE", () => { expect(validateImageFile(makeFile("a.jpg"))).toBeNull(); expect(validateImageFile(makeFile("a.jpeg"))).toBeNull(); expect(validateImageFile(makeFile("a.PNG"))).toBeNull(); expect(validateImageFile(makeFile("a.gif"))?.code).toBe("TYPE"); expect(validateImageFile(makeFile("a.mp4"))?.code).toBe("TYPE"); }); it("圖片:超過 20 MB 擋 SIZE", () => { const big = makeFile("a.jpg", 21 * 1024 * 1024); expect(validateImageFile(big)?.code).toBe("SIZE"); }); it("影片:MP4/AVI/MOV/MPEG 通過,其他擋 TYPE", () => { expect(validateVideoFile(makeFile("v.mp4"))).toBeNull(); expect(validateVideoFile(makeFile("v.mov"))).toBeNull(); expect(validateVideoFile(makeFile("v.mpeg"))).toBeNull(); expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE"); }); it("影片:上限為 90 MB(常數校驗)", () => { expect(MAX_VIDEO_BYTES).toBe(90 * 1024 * 1024); }); it("影片:89 MB 通過,剛好 90 MB 通過,91 MB 擋 SIZE(邊界)", () => { const mb = 1024 * 1024; expect(validateVideoFile(makeSizedFile("v.mp4", 89 * mb))).toBeNull(); // 剛好等於上限:validateVideoFile 用 > 判斷,等於上限應通過 expect(validateVideoFile(makeSizedFile("v.mp4", 90 * mb))).toBeNull(); expect(validateVideoFile(makeSizedFile("v.mp4", 91 * mb))?.code).toBe("SIZE"); }); it("影片 localhost 路徑:上限為 500 MB(ADR-019 WP-4 放寬,常數校驗)", () => { expect(MAX_LOCAL_VIDEO_BYTES).toBe(500 * 1024 * 1024); }); it("validateLocalVideoFile:91 MB(過 tunnel 上限)仍通過,剛好 500 MB 通過,501 MB 擋 SIZE", () => { const mb = 1024 * 1024; // 91 MB 若走舊 tunnel 上限(90MB)會被擋;localhost 路徑放寬到 500MB → 通過 expect(validateLocalVideoFile(makeSizedFile("v.mp4", 91 * mb))).toBeNull(); expect(validateLocalVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull(); expect(validateLocalVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe( "SIZE", ); }); it("validateLocalVideoFile:型別檢查與 validateVideoFile 相同(非影片擋 TYPE)", () => { expect(validateLocalVideoFile(makeFile("v.mp4"))).toBeNull(); expect(validateLocalVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE"); }); it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => { expect(validateBatchFiles([])?.code).toBe("EMPTY"); const tooMany = Array.from({ length: MAX_BATCH_IMAGES + 1 }, (_, i) => makeFile(`img${i}.jpg`), ); expect(validateBatchFiles(tooMany)?.code).toBe("COUNT"); expect( validateBatchFiles([makeFile("ok.jpg"), makeFile("bad.txt")])?.code, ).toBe("TYPE"); expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull(); }); it("批次合計上限:常數為 80 MB(校驗)", () => { expect(MAX_BATCH_TOTAL_BYTES).toBe(80 * 1024 * 1024); }); it("批次合計上限:50 張各 19MB(合計 950MB)→ TOTAL_SIZE(原地雷)", () => { const mb = 1024 * 1024; const files = Array.from({ length: 50 }, (_, i) => makeSizedFile(`img${i}.jpg`, 19 * mb), ); // 逐張都 ≤20MB 會通過單檔檢查,但合計 950MB 應被合計上限擋下 expect(validateBatchFiles(files)?.code).toBe("TOTAL_SIZE"); }); it("批次合計上限:剛好 80MB 通過、超過 1 byte 擋 TOTAL_SIZE(邊界)", () => { const mb = 1024 * 1024; // 8 張各 10MB = 80MB,剛好等於上限(用 > 判斷 → 通過) const exactly = Array.from({ length: 8 }, (_, i) => makeSizedFile(`e${i}.jpg`, 10 * mb), ); expect(validateBatchFiles(exactly)).toBeNull(); // 在 80MB 基礎上多 1 byte → 超過上限 const over = [ ...Array.from({ length: 8 }, (_, i) => makeSizedFile(`o${i}.jpg`, 10 * mb)), makeSizedFile("extra.jpg", 1), ]; expect(validateBatchFiles(over)?.code).toBe("TOTAL_SIZE"); }); it("批次合計上限:單檔超限(SIZE)優先於合計檢查", () => { // 一張 21MB(單檔超 20MB 上限)→ 應回 SIZE 而非 TOTAL_SIZE const files = [makeSizedFile("big.jpg", 21 * 1024 * 1024)]; expect(validateBatchFiles(files)?.code).toBe("SIZE"); }); }); describe("frameToSeekSeconds", () => { it("有 durationSeconds + totalFrames → 用 duration 換算(不依賴 fps)", () => { // 100 frames / 10 秒 → frame 50 = 5 秒 expect(frameToSeekSeconds(50, 100, 10)).toBe(5); // frame 0 = 0 秒 expect(frameToSeekSeconds(0, 100, 10)).toBe(0); // 換算與 fps 常數無關:即使 fps 常數是 15,這裡仍用 duration expect(frameToSeekSeconds(30, 60, 12)).toBe(6); }); it("缺 durationSeconds → 退用具名 fps 常數(frame / VIDEO_FALLBACK_FPS)", () => { expect(frameToSeekSeconds(30, 100, undefined)).toBe(30 / VIDEO_FALLBACK_FPS); // duration <= 0 也視為缺 expect(frameToSeekSeconds(30, 100, 0)).toBe(30 / VIDEO_FALLBACK_FPS); }); it("缺 totalFrames → 退用 fps 常數", () => { expect(frameToSeekSeconds(45, undefined, 10)).toBe(45 / VIDEO_FALLBACK_FPS); }); it("負 frame → clamp 為 0", () => { expect(frameToSeekSeconds(-5, 100, 10)).toBe(0); expect(frameToSeekSeconds(-5, undefined, undefined)).toBe(0); }); }); describe("buildBatchImageUrl", () => { // jsdom 有 window 且未設 NEXT_PUBLIC_API_BASE → getApiBaseUrl 回 ""(同 origin 相對路徑) it("組出 /api/media/batch-images/:index(無 cacheBust)", () => { expect(buildBatchImageUrl(3)).toBe("/api/media/batch-images/3"); }); it("帶 cacheBust 加上 _t query", () => { expect(buildBatchImageUrl(0, "abc")).toBe("/api/media/batch-images/0?_t=abc"); }); }); /* -------------------------------------------------------------------------- */ /* Upload(mock XMLHttpRequest) */ /* -------------------------------------------------------------------------- */ interface FakeXHR { method?: string; url?: string; withCredentials?: boolean; timeout?: number; sent?: FormData; status: number; responseText: string; upload: { onprogress: ((ev: ProgressEvent) => void) | null }; onload: (() => void) | null; onerror: (() => void) | null; ontimeout: (() => void) | null; open(method: string, url: string): void; send(body: FormData): void; abort(): void; } let lastXhr: FakeXHR | null = null; function installXhrMock(responder: (xhr: FakeXHR) => void) { // 以工廠回傳 plain object(closure over `xhr`),避免 class + `this` 別名(no-this-alias)。 function XHRMock(this: unknown) { const xhr: FakeXHR = { withCredentials: false, timeout: 0, status: 200, responseText: "", upload: { onprogress: null }, onload: null, onerror: null, ontimeout: null, open(method: string, url: string) { xhr.method = method; xhr.url = url; }, send(body: FormData) { xhr.sent = body; queueMicrotask(() => responder(xhr)); }, abort() {}, }; lastXhr = xhr; return xhr; } (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest; } describe("uploadImage / uploadBatchImages(XHR mock)", () => { const origXHR = globalThis.XMLHttpRequest; beforeEach(() => { lastXhr = null; }); afterEach(() => { (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR; }); it("uploadImage:POST /api/media/upload/image、帶 deviceId+file、回傳 data", async () => { installXhrMock((xhr) => { xhr.status = 200; xhr.responseText = JSON.stringify({ success: true, data: { streamUrl: "/api/camera/stream", sourceType: "image", filename: "a.jpg" }, }); xhr.onload?.(); }); const res = await uploadImage("dev-1", makeFile("a.jpg")); expect(res.sourceType).toBe("image"); expect(res.streamUrl).toBe("/api/camera/stream"); expect(lastXhr?.method).toBe("POST"); expect(lastXhr?.url).toContain("/api/media/upload/image"); expect(lastXhr?.withCredentials).toBe(true); expect(lastXhr?.sent?.get("deviceId")).toBe("dev-1"); expect(lastXhr?.sent?.get("file")).toBeInstanceOf(File); }); it("uploadBatchImages:多檔以 files 欄位附加", async () => { installXhrMock((xhr) => { xhr.status = 200; xhr.responseText = JSON.stringify({ success: true, data: { streamUrl: "/api/camera/stream", sourceType: "batch_image", totalImages: 2 }, }); xhr.onload?.(); }); const res = await uploadBatchImages("dev-1", [makeFile("a.jpg"), makeFile("b.png")]); expect(res.totalImages).toBe(2); expect(lastXhr?.url).toContain("/api/media/upload/batch-images"); expect(lastXhr?.sent?.getAll("files")).toHaveLength(2); }); it("非 2xx 且有 error envelope → 拋 ApiError(保留 code)", async () => { installXhrMock((xhr) => { xhr.status = 400; xhr.responseText = JSON.stringify({ success: false, error: { code: "BAD_REQUEST", message: "only JPG/PNG files are supported" }, }); xhr.onload?.(); }); await expect(uploadImage("dev-1", makeFile("a.jpg"))).rejects.toMatchObject({ code: "BAD_REQUEST", status: 400, }); }); it("回報上傳進度", async () => { installXhrMock((xhr) => { xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent); xhr.status = 200; xhr.responseText = JSON.stringify({ success: true, data: { streamUrl: "/s", sourceType: "image" }, }); xhr.onload?.(); }); const onProgress = vi.fn(); await uploadImage("dev-1", makeFile("a.jpg"), { onProgress }); expect(onProgress).toHaveBeenCalledWith(50); }); }); describe("seekVideo", () => { it("POST /api/media/seek,body 帶 timeSeconds", async () => { const apiModule = await import("@/lib/api"); const spy = vi .spyOn(apiModule.api, "post") .mockResolvedValue({ seekTo: 3, frameOffset: 45 } as never); const res = await seekVideo(3); expect(spy).toHaveBeenCalledWith("/api/media/seek", { timeSeconds: 3 }); expect(res).toEqual({ seekTo: 3, frameOffset: 45 }); spy.mockRestore(); }); });