jim800121chen b10fbb8091 feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
影片分頁上傳從舊 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>
2026-07-30 14:23:21 +08:00

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 MBADR-019 WP-4 放寬,常數校驗)", () => {
expect(MAX_LOCAL_VIDEO_BYTES).toBe(500 * 1024 * 1024);
});
it("validateLocalVideoFile91 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");
});
});
/* -------------------------------------------------------------------------- */
/* Uploadmock 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 objectclosure 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 / uploadBatchImagesXHR mock", () => {
const origXHR = globalThis.XMLHttpRequest;
beforeEach(() => {
lastXhr = null;
});
afterEach(() => {
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
});
it("uploadImagePOST /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/seekbody 帶 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();
});
});