/** * 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; send: ReturnType; abort: ReturnType; setRequestHeader: ReturnType; getResponseHeader: ReturnType; 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) => 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 = {}; // 直接賦值到 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; 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", ); }); });