Phase 0.8 對應後端轉檔功能的 frontend UI。完整 state machine(idle / uploading /
queued / running / succeeded / failed / expired)+ XHR upload progress + polling +
half-auto result handling(加到模型庫 / 下載)。
新增 src/app/conversion/(11 元件 + 12 test files,~5,000 行 prod+test,310/310 tests 全綠):
- page.tsx:state router(mount → bootstrap → 依 store.uiState 切換 view)+ toast on
store.error(aborted / active_job_exists 不 toast,避免雙重提示)
- IdleForm.tsx + FileDropzone.tsx + ChipSelector.tsx:上傳表單(拖放 + .onnx/.tflite
format 驗證 + 500MB 大小限制 + KL520/630/720/730 chip 選擇 + ref images ≤100×10MB)
- UploadingView.tsx:XHR upload progress 0-100% + EWMA ETA(alpha=0.3 平滑,<5s 顯示
「即將完成」避抖動)+ beforeunload warning + AlertDialog 取消
- ProcessingView.tsx:StageIndicator 三段式(解析模型 → 量化編譯 → 編譯 NEF)+
Progress bar 三模式(queued / determinate / indeterminate)+ tab title `(轉檔中)` 防疊加 +
409 active_job_exists banner(switchedFromActiveJob flag)
- SuccessView.tsx + PromoteDialog.tsx:兩按鈕(加到模型庫 / 下載)視覺平衡不暗示預設答案 +
7 天 expires_at 倒數(mount 時 setTimeout 鎖過期那刻 + 60s tick)+ PromoteDialog 單欄位
name 驗證(≤100 字元 / 無 /\\)+ spinner-during-close 阻擋 + 409 already_imported 特殊
訊息 + toast.success router.push 跳模型清單
- FailedView.tsx:5 個 known error code 翻譯(UNSUPPORTED_FORMAT / INVALID_CHECKSUM /
QUANTIZATION_FAILED / MODEL_TOO_LARGE / QUOTA_EXCEEDED)+ unknown fallback +
3 個建議解決方法 + Job ID 前 8 字元(供回報)+ 「重新開始」
- ExpiredView.tsx:橘色 hero(過期不 alarming)+ source/chip 摘要 + 「重新轉檔」→
store.reset()
新增 src/stores/conversion-store.ts(Zustand store + 29 tests):
- 7 個 UI state machine(不允許跳階段)
- recursive setTimeout polling(running 5s / queued 10s / 5xx 指數退避 cap 30s)+
visibilitychange 暫停/恢復
- 不持久化 jobId(純靠 backend getActiveConversion() lazy rebuild ownership)
- AbortController 防 stale request + 取消上傳
- switchedFromActiveJob flag(409 自動切到既存 job + UI 顯示 banner)
- formDraft(chip / taskName 提到 store,failed→idle 後保留設定,file picker 重選;
File 物件不能序列化只留 local)
新增 src/lib/api/conversion.ts + types/conversion.ts(5 client 函式 + 22 tests):
- initConversion:XHR multipart + onUploadProgress + AbortSignal
- getActiveConversion / getConversion / promoteConversionToModels:標準 fetch
- getConversionDownloadURL:純函式,回 `/api/conversion/{job_id}/download` 給 anchor
download 觸發(server-side 302 → FAA,token 不過 frontend JS)
- ConversionAPIError(status, code, message, requestId?),code 統一全小寫對齊
conversion.error.<code> i18n key 命名
新增 src/lib/utils/eta.ts(EWMA 演算法純函式 + 19 tests):抽出 smoothSpeed /
estimateRemainingSeconds / instantSpeedBytesPerSec / computeEtaUpdate(test 友善)。
新增 src/app/conversion/e2e-conversion-flow.test.tsx(5 e2e flow tests):
- happy path .onnx + KL720 + 0 ref images(idle → upload → polling → succeeded → 加到模型庫)
- variant 5 ref images
- upload fail → retry(form 設定保留)
- polling 5xx 指數退避 → 恢復繼續
- expired job → ExpiredView → 重新轉檔
修改 sidebar.tsx:左側 nav 新增「轉檔」tab(Wand2 icon,模型庫之後)。
修改 i18n 字典:新增 ~150 個 conversion.* keys(中英雙語對齊)。
PRD §9 14 條功能驗收條件全達成(4 條整合驗收等 stage 部署)。
驗證:pnpm lint / test (310/310) / build 全綠。
對齊文件:
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md
- .autoflow/04-architecture/api/api-conversion.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
553 lines
17 KiB
TypeScript
553 lines
17 KiB
TypeScript
/**
|
||
* Conversion API Client 單元測試(Phase 0.8 F-T2)
|
||
*
|
||
* 覆蓋 5 個 endpoint:
|
||
* 1. initConversion — XHR multipart upload + progress / abort / 409
|
||
* 2. getActiveConversion — has_active true / false / 異常
|
||
* 3. getConversion — 200 / 403 / 404
|
||
* 4. promoteConversionToModels — 201 / 409 / parse 缺欄
|
||
* 5. getConversionDownloadURL — 純函式輸入輸出
|
||
*/
|
||
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import {
|
||
ConversionAPIError,
|
||
getActiveConversion,
|
||
getConversion,
|
||
getConversionDownloadURL,
|
||
initConversion,
|
||
promoteConversionToModels,
|
||
} from "./conversion";
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* XHR mock helper — 取代 jsdom 的 XMLHttpRequest */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
interface MockXHRInstance {
|
||
open: ReturnType<typeof vi.fn>;
|
||
send: ReturnType<typeof vi.fn>;
|
||
abort: ReturnType<typeof vi.fn>;
|
||
setRequestHeader: ReturnType<typeof vi.fn>;
|
||
getResponseHeader: ReturnType<typeof vi.fn>;
|
||
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
|
||
withCredentials: boolean;
|
||
status: number;
|
||
responseText: string;
|
||
onload: (() => void) | null;
|
||
onerror: (() => void) | null;
|
||
ontimeout: (() => void) | null;
|
||
// 觸發測試輔助
|
||
_fireLoad: (status: number, body: unknown, headers?: Record<string, string>) => void;
|
||
_fireError: () => void;
|
||
_fireProgress: (loaded: number, total: number) => void;
|
||
_formDataSent: FormData | null;
|
||
}
|
||
|
||
function installMockXHR(): { instances: MockXHRInstance[] } {
|
||
const instances: MockXHRInstance[] = [];
|
||
|
||
// 必須是真 constructor function(不能用 vi.fn().mockImplementation — 那回的是 mock,不可 `new`)
|
||
function MockXHR(this: MockXHRInstance) {
|
||
const headers: Record<string, string> = {};
|
||
// 直接賦值到 this,避免 this 別名被 lint 警示
|
||
this.open = vi.fn();
|
||
this.abort = vi.fn();
|
||
this.setRequestHeader = vi.fn();
|
||
this.getResponseHeader = vi.fn((k: string) => headers[k.toLowerCase()] ?? null);
|
||
this.upload = { onprogress: null };
|
||
this.withCredentials = false;
|
||
this.status = 0;
|
||
this.responseText = "";
|
||
this.onload = null;
|
||
this.onerror = null;
|
||
this.ontimeout = null;
|
||
this._formDataSent = null;
|
||
// send / fire* 需要持有 instance 參考;用 instance push 後再裝上
|
||
instances.push(this);
|
||
const idx = instances.length - 1;
|
||
this.send = vi.fn((body: unknown) => {
|
||
instances[idx]._formDataSent = body instanceof FormData ? body : null;
|
||
});
|
||
this._fireLoad = (status, body, hdrs) => {
|
||
const ref = instances[idx];
|
||
ref.status = status;
|
||
ref.responseText =
|
||
typeof body === "string" ? body : body == null ? "" : JSON.stringify(body);
|
||
if (hdrs) {
|
||
for (const [k, v] of Object.entries(hdrs)) headers[k.toLowerCase()] = v;
|
||
}
|
||
ref.onload?.();
|
||
};
|
||
this._fireError = () => {
|
||
instances[idx].onerror?.();
|
||
};
|
||
this._fireProgress = (loaded, total) => {
|
||
instances[idx].upload.onprogress?.({
|
||
lengthComputable: true,
|
||
loaded,
|
||
total,
|
||
} as ProgressEvent);
|
||
};
|
||
}
|
||
|
||
// 覆蓋 global XMLHttpRequest
|
||
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = MockXHR;
|
||
return { instances };
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* fetch mock helper — 給 4 個非 init 的 endpoint */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
function jsonResponse(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
}
|
||
|
||
let fetchMock: ReturnType<typeof vi.fn>;
|
||
|
||
beforeEach(() => {
|
||
fetchMock = vi.fn();
|
||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/* ========================================================================== */
|
||
/* 1. initConversion */
|
||
/* ========================================================================== */
|
||
|
||
describe("initConversion", () => {
|
||
it("happy path:multipart 含 model + ref_images + platform,回 normalized Job", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["onnx-bytes"], "yolov5s.onnx", {
|
||
type: "application/octet-stream",
|
||
});
|
||
const ref = new File(["img"], "ref.jpg", { type: "image/jpeg" });
|
||
|
||
const promise = initConversion({
|
||
file,
|
||
refImages: [ref],
|
||
targetChip: "KL720",
|
||
taskName: "yolov5s_test",
|
||
});
|
||
|
||
// 確認 XHR 已建立並送 form
|
||
expect(instances.length).toBe(1);
|
||
const xhr = instances[0];
|
||
expect(xhr.open).toHaveBeenCalledWith(
|
||
"POST",
|
||
expect.stringMatching(/\/api\/conversion\/init$/),
|
||
true,
|
||
);
|
||
expect(xhr.withCredentials).toBe(true);
|
||
|
||
const form = xhr._formDataSent;
|
||
expect(form).toBeInstanceOf(FormData);
|
||
// model 檔
|
||
expect((form!.get("model") as File).name).toBe("yolov5s.onnx");
|
||
// ref_images[](多筆同 key)
|
||
const refs = form!.getAll("ref_images[]");
|
||
expect(refs.length).toBe(1);
|
||
expect((refs[0] as File).name).toBe("ref.jpg");
|
||
// text fields — backend 用 `model_id` 欄裝 taskName、`platform` 用 wire 數字格式
|
||
expect(form!.get("model_id")).toBe("yolov5s_test");
|
||
expect(form!.get("platform")).toBe("720");
|
||
expect(form!.get("version")).toBe("v1.0.0");
|
||
|
||
// 模擬後端 200 回 envelope
|
||
xhr._fireLoad(200, {
|
||
success: true,
|
||
data: {
|
||
job_id: "550e8400-e29b-41d4-a716-446655440000",
|
||
status: "running",
|
||
stage: "onnx",
|
||
progress: 0,
|
||
created_at: "2026-04-30T12:00:00Z",
|
||
expires_at: "2026-05-07T12:00:00Z",
|
||
source_filename: "yolov5s.onnx",
|
||
target_chip: "720",
|
||
},
|
||
});
|
||
|
||
const job = await promise;
|
||
expect(job.job_id).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||
expect(job.status).toBe("running");
|
||
expect(job.target_chip).toBe("KL720"); // wire 520→KL520 normalize
|
||
expect(job.stage).toBe("onnx");
|
||
});
|
||
|
||
it("沒給 taskName 時用檔名當 model_id", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["x"], "model.onnx");
|
||
const promise = initConversion({ file, targetChip: "KL520" });
|
||
const xhr = instances[0];
|
||
xhr._fireLoad(200, {
|
||
success: true,
|
||
data: {
|
||
job_id: "j1",
|
||
status: "running",
|
||
stage: "onnx",
|
||
created_at: "2026-04-30T12:00:00Z",
|
||
expires_at: "2026-05-07T12:00:00Z",
|
||
source_filename: "model.onnx",
|
||
target_chip: "520",
|
||
},
|
||
});
|
||
await promise;
|
||
expect(xhr._formDataSent!.get("model_id")).toBe("model.onnx");
|
||
expect(xhr._formDataSent!.get("platform")).toBe("520");
|
||
});
|
||
|
||
it("onUploadProgress 被呼叫", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["x".repeat(100)], "m.onnx");
|
||
const onUploadProgress = vi.fn();
|
||
|
||
const promise = initConversion({
|
||
file,
|
||
targetChip: "KL520",
|
||
onUploadProgress,
|
||
});
|
||
|
||
const xhr = instances[0];
|
||
xhr._fireProgress(50, 100);
|
||
xhr._fireProgress(100, 100);
|
||
xhr._fireLoad(200, {
|
||
success: true,
|
||
data: {
|
||
job_id: "j2",
|
||
status: "running",
|
||
stage: "onnx",
|
||
created_at: "2026-04-30T12:00:00Z",
|
||
expires_at: "2026-05-07T12:00:00Z",
|
||
source_filename: "m.onnx",
|
||
target_chip: "520",
|
||
},
|
||
});
|
||
|
||
await promise;
|
||
expect(onUploadProgress).toHaveBeenCalledWith(50, 100);
|
||
expect(onUploadProgress).toHaveBeenCalledWith(100, 100);
|
||
expect(onUploadProgress).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("409 active_job_exists → throw ConversionAPIError(409, 'active_job_exists', requestId)", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["x"], "m.onnx");
|
||
const promise = initConversion({ file, targetChip: "KL520" });
|
||
|
||
instances[0]._fireLoad(
|
||
409,
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "active_job_exists",
|
||
message: "你已有進行中任務",
|
||
},
|
||
},
|
||
{ "X-Request-Id": "req-abc" },
|
||
);
|
||
|
||
await expect(promise).rejects.toBeInstanceOf(ConversionAPIError);
|
||
try {
|
||
await promise;
|
||
} catch (e) {
|
||
const err = e as ConversionAPIError;
|
||
expect(err.status).toBe(409);
|
||
expect(err.code).toBe("active_job_exists");
|
||
expect(err.message).toBe("你已有進行中任務");
|
||
expect(err.requestId).toBe("req-abc");
|
||
}
|
||
});
|
||
|
||
it("network error → throw ConversionAPIError(0, 'network_error')", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["x"], "m.onnx");
|
||
const promise = initConversion({ file, targetChip: "KL520" });
|
||
instances[0]._fireError();
|
||
await expect(promise).rejects.toMatchObject({
|
||
name: "ConversionAPIError",
|
||
status: 0,
|
||
code: "network_error",
|
||
});
|
||
});
|
||
|
||
it("AbortSignal.abort → 呼叫 xhr.abort 並 reject ConversionAPIError(0, 'aborted')", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["x"], "m.onnx");
|
||
const ctrl = new AbortController();
|
||
|
||
const promise = initConversion({
|
||
file,
|
||
targetChip: "KL520",
|
||
signal: ctrl.signal,
|
||
});
|
||
|
||
ctrl.abort();
|
||
await expect(promise).rejects.toMatchObject({
|
||
name: "ConversionAPIError",
|
||
code: "aborted",
|
||
});
|
||
expect(instances[0].abort).toHaveBeenCalled();
|
||
});
|
||
|
||
it("已 abort 的 signal → 立刻 reject 不送 XHR", async () => {
|
||
const { instances } = installMockXHR();
|
||
const file = new File(["x"], "m.onnx");
|
||
const ctrl = new AbortController();
|
||
ctrl.abort();
|
||
|
||
const promise = initConversion({
|
||
file,
|
||
targetChip: "KL520",
|
||
signal: ctrl.signal,
|
||
});
|
||
|
||
await expect(promise).rejects.toMatchObject({ code: "aborted" });
|
||
expect(instances[0].send).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
/* ========================================================================== */
|
||
/* 2. getActiveConversion */
|
||
/* ========================================================================== */
|
||
|
||
describe("getActiveConversion", () => {
|
||
it("has_active=true → 回 normalized Job", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse({
|
||
success: true,
|
||
data: {
|
||
has_active: true,
|
||
job: {
|
||
job_id: "j-active",
|
||
status: "running",
|
||
stage: "bie",
|
||
progress: 45,
|
||
created_at: "2026-04-30T12:00:00Z",
|
||
expires_at: "2026-05-07T12:00:00Z",
|
||
source_filename: "yolov5s.onnx",
|
||
target_chip: "720",
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
const job = await getActiveConversion();
|
||
expect(job).not.toBeNull();
|
||
expect(job!.job_id).toBe("j-active");
|
||
expect(job!.target_chip).toBe("KL720");
|
||
expect(job!.stage).toBe("bie");
|
||
expect(job!.progress).toBe(45);
|
||
});
|
||
|
||
it("has_active=false → 回 null", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse({ success: true, data: { has_active: false, job: null } }),
|
||
);
|
||
const job = await getActiveConversion();
|
||
expect(job).toBeNull();
|
||
});
|
||
|
||
it("backend 401 → throw ConversionAPIError(401, 'unauthorized')", async () => {
|
||
// F-T2 review Major #1:code 統一全小寫,對齊 conversion.md §6 i18n key 命名
|
||
fetchMock.mockResolvedValue(new Response("", { status: 401 }));
|
||
await expect(getActiveConversion()).rejects.toMatchObject({
|
||
name: "ConversionAPIError",
|
||
status: 401,
|
||
code: "unauthorized",
|
||
});
|
||
});
|
||
});
|
||
|
||
/* ========================================================================== */
|
||
/* 3. getConversion */
|
||
/* ========================================================================== */
|
||
|
||
describe("getConversion", () => {
|
||
it("成功回 normalized Job(completed → succeeded)", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse({
|
||
success: true,
|
||
data: {
|
||
job_id: "j-1",
|
||
status: "completed",
|
||
progress: 100,
|
||
created_at: "2026-04-30T12:00:00Z",
|
||
expires_at: "2026-05-07T12:00:00Z",
|
||
source_filename: "m.onnx",
|
||
target_chip: "520",
|
||
error_code: null,
|
||
error_message: null,
|
||
},
|
||
}),
|
||
);
|
||
|
||
const job = await getConversion("j-1");
|
||
expect(job.status).toBe("succeeded"); // completed → succeeded
|
||
expect(job.target_chip).toBe("KL520");
|
||
expect(job.error).toBeUndefined();
|
||
});
|
||
|
||
it("failed 時 error_code/message 包成 error 物件", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse({
|
||
success: true,
|
||
data: {
|
||
job_id: "j-2",
|
||
status: "failed",
|
||
progress: 30,
|
||
created_at: "2026-04-30T12:00:00Z",
|
||
expires_at: "2026-05-07T12:00:00Z",
|
||
source_filename: "m.onnx",
|
||
target_chip: "720",
|
||
error_code: "QUANTIZATION_FAILED",
|
||
error_message: "Custom op X not supported",
|
||
},
|
||
}),
|
||
);
|
||
|
||
const job = await getConversion("j-2");
|
||
expect(job.status).toBe("failed");
|
||
expect(job.error).toEqual({
|
||
code: "QUANTIZATION_FAILED",
|
||
message: "Custom op X not supported",
|
||
});
|
||
});
|
||
|
||
it("404 not_found → throw ConversionAPIError(404, 'not_found')", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse(
|
||
{ success: false, error: { code: "not_found", message: "任務不存在" } },
|
||
404,
|
||
),
|
||
);
|
||
await expect(getConversion("missing")).rejects.toMatchObject({
|
||
name: "ConversionAPIError",
|
||
status: 404,
|
||
code: "not_found",
|
||
});
|
||
});
|
||
|
||
it("403 forbidden → throw ConversionAPIError(403, 'forbidden')", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse(
|
||
{ success: false, error: { code: "forbidden", message: "你無權存取此任務" } },
|
||
403,
|
||
),
|
||
);
|
||
await expect(getConversion("other-user-job")).rejects.toMatchObject({
|
||
status: 403,
|
||
code: "forbidden",
|
||
});
|
||
});
|
||
|
||
it("空 jobId → 立刻 throw ConversionAPIError 不打 fetch", async () => {
|
||
await expect(getConversion("")).rejects.toMatchObject({
|
||
code: "validation_failed",
|
||
});
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
/* ========================================================================== */
|
||
/* 4. promoteConversionToModels */
|
||
/* ========================================================================== */
|
||
|
||
describe("promoteConversionToModels", () => {
|
||
it("成功回 { model_id }", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse(
|
||
{
|
||
success: true,
|
||
data: {
|
||
model_id: "m-abc-123",
|
||
source: "converted",
|
||
source_job_id: "j-1",
|
||
name: "yolov5s_kl720",
|
||
target_chip: "kl720",
|
||
file_size: 12345678,
|
||
status: "ready",
|
||
created_at: "2026-04-30T12:30:00Z",
|
||
},
|
||
},
|
||
201,
|
||
),
|
||
);
|
||
|
||
const res = await promoteConversionToModels("j-1", { name: "yolov5s_kl720" });
|
||
expect(res).toEqual({ model_id: "m-abc-123" });
|
||
|
||
// 驗證 request body
|
||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||
expect(init.method).toBe("POST");
|
||
expect(init.body).toBe(JSON.stringify({ name: "yolov5s_kl720" }));
|
||
});
|
||
|
||
it("body 省略時送空物件", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse(
|
||
{ success: true, data: { model_id: "m-1" } },
|
||
200,
|
||
),
|
||
);
|
||
await promoteConversionToModels("j-2");
|
||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||
expect(init.body).toBe("{}");
|
||
});
|
||
|
||
it("409 job_not_completed → throw ConversionAPIError(409, 'job_not_completed')", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse(
|
||
{ success: false, error: { code: "job_not_completed", message: "尚未完成" } },
|
||
409,
|
||
),
|
||
);
|
||
await expect(promoteConversionToModels("j-3")).rejects.toMatchObject({
|
||
status: 409,
|
||
code: "job_not_completed",
|
||
});
|
||
});
|
||
|
||
it("response 缺 model_id → throw ConversionAPIError(500, 'parse_error')", async () => {
|
||
fetchMock.mockResolvedValue(
|
||
jsonResponse({ success: true, data: { source: "converted" } }, 201),
|
||
);
|
||
await expect(promoteConversionToModels("j-4")).rejects.toMatchObject({
|
||
code: "parse_error",
|
||
status: 500,
|
||
});
|
||
});
|
||
|
||
it("空 jobId → 立刻 throw 不打 fetch", async () => {
|
||
await expect(promoteConversionToModels("")).rejects.toMatchObject({
|
||
code: "validation_failed",
|
||
});
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
/* ========================================================================== */
|
||
/* 5. getConversionDownloadURL */
|
||
/* ========================================================================== */
|
||
|
||
describe("getConversionDownloadURL", () => {
|
||
it("組相對 URL(不含 base,給 anchor / location.href 用)", () => {
|
||
expect(getConversionDownloadURL("550e8400-e29b-41d4-a716-446655440000")).toBe(
|
||
"/api/conversion/550e8400-e29b-41d4-a716-446655440000/download",
|
||
);
|
||
});
|
||
|
||
it("encodeURIComponent 防止 jobId 含特殊字元注入", () => {
|
||
expect(getConversionDownloadURL("a/b?c=d")).toBe(
|
||
"/api/conversion/a%2Fb%3Fc%3Dd/download",
|
||
);
|
||
});
|
||
});
|