模型庫下載原本前端用 fetch + Authorization: Bearer 跨 origin 直連 FAA,
觸發 CORS preflight(OPTIONS);FAA 未設 CORS、OPTIONS 回 405 → 下載失敗。
FAA 設計本就支援「token 放 query string(access_token) + redirect 導航下載」,
故不需 FAA 設 CORS。改採 ADR-017 §11 v1.3 乙案:
- backend: download_url 組成含 ?access_token={url.QueryEscape(token)} 完整 FAA URL;
ModelDownloadResponse.Token 標 deprecated 保留(向下相容);token 不進 log
- frontend: 移除 downloadModelFile/deriveDownloadFilename(fetch+blob),
改 triggerNavDownload 用 <a download href> 導航;ModelDownloadGrant 移除 token 欄
- 整條鏈無 CORS:前端→backend 同 origin、前端→FAA 導航無 preflight
docs: ADR-017 增補 §11(v1.3) + TDD.md §9.5
tests: backend 8 PASS(含 token escape 邊界)、frontend 35 PASS;Reviewer 通過(0C/0M)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
6.6 KiB
TypeScript
190 lines
6.6 KiB
TypeScript
/**
|
||
* 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<typeof vi.fn>;
|
||
|
||
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<string, unknown>).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);
|
||
});
|
||
});
|