影片分頁上傳從舊 tunnel 路徑(/api/media/upload/video + 90MB)切到同機
localhost 直連(取 token → resolveLocalAgent → /api/local/media/upload/video)。
端到端啟用 ADR-019,取代 90MB 過渡限制。
- 新 lib/local-media.ts 編排層:getLocalUploadTicket + uploadVideoViaLocalAgent
- MAX_LOCAL_VIDEO_BYTES=500MB + validateLocalVideoFile(只綁 localhost 路徑;
舊 MAX_VIDEO_BYTES=90MB + tunnel uploadVideo 完全不碰,向下相容)
- R-3 tunnel 離線三層防護:UI disable 不渲染 uploader + ticket 502 + 錯誤映射
- 5 種錯誤 i18n(NOT_FOUND/MISMATCH/離線/401/413)+ AbortError 靜默
reviewer 通過(0C/0M)。tsc/eslint/build 0 error、WP-4 相關 70 test pass。
⚠️ 實機驗證(真序號 hash 同形 fail-closed / PNA / 500MB 大檔實傳 / 混合路徑
結果面)待 stage 部署後驗證。Minor M-1/M-2 留 WP-6 一併處理。
Refs: ADR-019 WP-4。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|