/** * 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_VIDEO_BYTES, VIDEO_FALLBACK_FPS, buildBatchImageUrl, frameToSeekSeconds, seekVideo, uploadBatchImages, uploadImage, validateBatchFiles, validateImageFile, 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("影片:上限為 500 MB(常數校驗)", () => { expect(MAX_VIDEO_BYTES).toBe(500 * 1024 * 1024); }); it("影片:499 MB 通過,剛好 500 MB 通過,501 MB 擋 SIZE(邊界)", () => { const mb = 1024 * 1024; expect(validateVideoFile(makeSizedFile("v.mp4", 499 * mb))).toBeNull(); // 剛好等於上限:validateVideoFile 用 > 判斷,等於上限應通過 expect(validateVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull(); expect(validateVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe("SIZE"); }); 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(); }); }); 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(); }); });