/** * Model Download API Client 單元測試(Phase 0.9 / ADR-017 v1.3 — query-string token + redirect 導航) * * 覆蓋: * 1. getModelDownload — 200 正規化(download_url + expires_at)/ 404 / 403 / 501 / 502 / parse 缺欄 * 2. triggerNavDownload — 建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除 * 3. ModelDownloadError 形狀 * * v1.3 移除(對齊 ADR §11.8): * - downloadModelFile(fetch + Bearer + blob,CORS 405 元兇)整段拔掉 → 不再測。 * - deriveDownloadFilename(redirect 跨 origin 檔名由 FAA Content-Disposition 決定、前端命名無效)→ 不再測。 * - triggerBlobDownload(blob object URL)→ 改為 triggerNavDownload(FAA 真實 URL,不需 createObjectURL)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getModelDownload, ModelDownloadError, triggerNavDownload, } from "./model-download"; 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. getModelDownload */ /* ========================================================================== */ describe("getModelDownload", () => { it("200 → 正規化 { downloadUrl, expiresAt }(snake_case;download_url 已含 ?access_token=)", async () => { fetchMock.mockResolvedValue( jsonResponse({ success: true, data: { download_url: "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc123", token: "fdt_abc123", // 後端仍回(deprecated),前端不使用 expires_at: "2026-06-07T12:02:00Z", }, }), ); const grant = await getModelDownload("m1"); expect(grant.downloadUrl).toBe( "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc123", ); expect(grant.expiresAt).toBe("2026-06-07T12:02:00Z"); // 前端不再讀 token 欄位(已移出 ModelDownloadGrant 型別) expect((grant as unknown as Record).token).toBeUndefined(); }); it("camelCase 也吃(downloadUrl / expiresAt)", async () => { fetchMock.mockResolvedValue( jsonResponse({ success: true, data: { downloadUrl: "https://faa/x.nef?access_token=fdt_x", expiresAt: "2026-06-07T00:00:00Z", }, }), ); const grant = await getModelDownload("m1"); expect(grant.downloadUrl).toBe("https://faa/x.nef?access_token=fdt_x"); expect(grant.expiresAt).toBe("2026-06-07T00:00:00Z"); }); it("空 modelId → validation_failed(不打 API)", async () => { await expect(getModelDownload("")).rejects.toMatchObject({ name: "ModelDownloadError", code: "validation_failed", }); expect(fetchMock).not.toHaveBeenCalled(); }); it.each([ [404, "model_not_found"], [403, "forbidden"], [501, "upload_not_supported"], [502, "sign_failed"], ])("backend %i → ModelDownloadError code=%s(小寫)", async (status, code) => { fetchMock.mockResolvedValue( jsonResponse({ success: false, error: { code, message: "x" } }, status), ); await expect(getModelDownload("m1")).rejects.toMatchObject({ name: "ModelDownloadError", status, code, }); }); it("200 但缺 download_url → parse_error", async () => { fetchMock.mockResolvedValue( jsonResponse({ success: true, data: { expires_at: "2026-06-07T12:02:00Z" }, }), ); await expect(getModelDownload("m1")).rejects.toMatchObject({ code: "parse_error", }); }); }); /* ========================================================================== */ /* 2. triggerNavDownload */ /* ========================================================================== */ describe("triggerNavDownload", () => { it("建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除", () => { const clickSpy = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => {}); let capturedHref = ""; let hasDownloadAttr = false; const appendSpy = vi .spyOn(document.body, "appendChild") .mockImplementation((node) => { const a = node as HTMLAnchorElement; capturedHref = a.href; hasDownloadAttr = a.hasAttribute("download"); return node; }); const removeSpy = vi .spyOn(document.body, "removeChild") .mockImplementation((node) => node); const url = "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc"; triggerNavDownload(url); expect(capturedHref).toBe(url); expect(hasDownloadAttr).toBe(true); expect(clickSpy).toHaveBeenCalledOnce(); expect(appendSpy).toHaveBeenCalledOnce(); expect(removeSpy).toHaveBeenCalledOnce(); }); it("不建立 blob object URL(不呼叫 createObjectURL)", () => { const createObjectURL = vi.fn(); globalThis.URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL; vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); triggerNavDownload("https://faa/x.nef?access_token=fdt_x"); expect(createObjectURL).not.toHaveBeenCalled(); }); it("空 downloadUrl → validation_failed(不建立 anchor)", () => { const clickSpy = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => {}); expect(() => triggerNavDownload("")).toThrowError( expect.objectContaining({ code: "validation_failed" }), ); expect(clickSpy).not.toHaveBeenCalled(); }); }); /* ========================================================================== */ /* 3. ModelDownloadError 形狀 */ /* ========================================================================== */ describe("ModelDownloadError", () => { it("保留 status / code / message / requestId", () => { const e = new ModelDownloadError(403, "forbidden", "no", "req-1"); expect(e.name).toBe("ModelDownloadError"); expect(e.status).toBe(403); expect(e.code).toBe("forbidden"); expect(e.message).toBe("no"); expect(e.requestId).toBe("req-1"); expect(e).toBeInstanceOf(Error); }); });