feat(visionA-frontend): Phase 0.9 模型庫下載 — 前端對接 FAA delegated download
對齊 ADR-017 v1.2 決策 2:model-card 下載按鈕 → 打 visionA endpoint 拿 url+token → fetch 跨 origin 直連 FAA + Authorization Bearer → blob 觸發下載(不經 visionA、不經 AWS)。 - src/lib/api/model-download.ts: getModelDownload(打 GET /api/models/:id/download)+ downloadModelFile(fetch + Bearer header + blob,credentials:"omit" 不帶 visionA cookie)+ triggerBlobDownload(延遲 revoke + finally 釋放)+ deriveDownloadFilename + ModelDownloadError - model-store: downloadingId 互斥(同時只一個下載、finally 必清)+ downloadModel action + isModelDownloadable(source==converted && status==ready) - model-card: 下載按鈕(converted+ready 才顯示、上傳類隱藏;preventDefault+stopPropagation 防觸發外層 Link 導航;loading;toast 錯誤) - i18n zh/en: models.action.download.* / models.download.*(各 error code 對應訊息) 關鍵差異:模型庫下載跨 origin + 需 Bearer header,不能用 <a href> navigation(無法帶 header), 必須 fetch+blob。與既有 conversion download(同 origin navigation)分流。 測試: 43 unit + 互動 test(mock fetch / 按鈕互動 / 顯示條件 / error 分流);tsc/lint/build 全綠。 Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過(ADR 決策 2 規格 7/7 符合)。 待 stage 實測: FAA 端須設 CORS(允許 visionA origin + preflight Allow-Headers: Authorization), 撞到會落 network_error。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c63886a194
commit
53e8ab4ae1
163
visionA-frontend/src/components/models/model-card.test.tsx
Normal file
163
visionA-frontend/src/components/models/model-card.test.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ModelCard 互動測試(Phase 0.9 模型下載)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 下載按鈕顯示條件:converted + ready 顯示;uploaded / 非 ready 不顯示
|
||||
* - 點下載按鈕:preventDefault + stopPropagation(不觸發外層 <Link> 導航)
|
||||
* - 下載中:按鈕 disabled + 顯示「下載中」
|
||||
* - 成功 → toast.success;失敗 → toast.error(用 backend code 對應 i18n)
|
||||
* - 其他卡片下載中時,本卡按鈕 disabled
|
||||
*
|
||||
* Mock:
|
||||
* - sonner toast → 攔截 success / error
|
||||
* - model-store downloadModel action → vi.spyOn 控制回傳
|
||||
* - next/navigation(jsdom 無 app router context)
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useModelStore, type ModelSummary } from "@/stores/model-store";
|
||||
|
||||
import { ModelCard } from "./model-card";
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
return { toast: Object.assign(vi.fn(), { success, error }) };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
const convertedReady: ModelSummary = {
|
||||
id: "m1",
|
||||
name: "YOLOv5s",
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function renderCard(model: ModelSummary) {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelCard model={model} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useModelStore.setState({ downloadingId: null });
|
||||
(toast.success as Mock).mockReset();
|
||||
(toast.error as Mock).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useModelStore.setState({ downloadingId: null });
|
||||
});
|
||||
|
||||
describe("ModelCard 下載按鈕顯示條件", () => {
|
||||
it("converted + ready → 顯示下載按鈕", () => {
|
||||
renderCard(convertedReady);
|
||||
expect(screen.getByTestId("model-card-download")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uploaded(即使 ready)→ 不顯示下載按鈕", () => {
|
||||
renderCard({ ...convertedReady, source: "uploaded" });
|
||||
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("converted 但 scanning → 不顯示下載按鈕", () => {
|
||||
renderCard({ ...convertedReady, status: "scanning" });
|
||||
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preset → 不顯示下載按鈕", () => {
|
||||
renderCard({ ...convertedReady, source: "preset" });
|
||||
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelCard 下載互動", () => {
|
||||
it("點下載按鈕:preventDefault + stopPropagation(不觸發 Link 導航)", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useModelStore.getState(), "downloadModel")
|
||||
.mockResolvedValue({ ok: true });
|
||||
|
||||
renderCard(convertedReady);
|
||||
const btn = screen.getByTestId("model-card-download");
|
||||
|
||||
// 自建 event 驗 preventDefault / stopPropagation 都被呼叫
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
const preventDefault = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagation = vi.spyOn(event, "stopPropagation");
|
||||
fireEvent(btn, event);
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(stopPropagation).toHaveBeenCalled();
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(convertedReady));
|
||||
});
|
||||
|
||||
it("下載成功 → toast.success", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ ok: true });
|
||||
renderCard(convertedReady);
|
||||
fireEvent.click(screen.getByTestId("model-card-download"));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("下載失敗(forbidden)→ toast.error,描述用對應 i18n", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "forbidden",
|
||||
message: "no",
|
||||
});
|
||||
renderCard(convertedReady);
|
||||
fireEvent.click(screen.getByTestId("model-card-download"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
// 第二參數 description 對應 models.download.error.forbidden(繁中)
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("你沒有權限下載此模型");
|
||||
});
|
||||
|
||||
it("未知 code → toast.error 描述退化成 unknown 文案", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "some_unmapped_code",
|
||||
message: "x",
|
||||
});
|
||||
renderCard(convertedReady);
|
||||
fireEvent.click(screen.getByTestId("model-card-download"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("下載失敗,請稍後再試");
|
||||
});
|
||||
|
||||
it("其他卡片下載中(downloadingId != null)→ 本卡按鈕 disabled", () => {
|
||||
useModelStore.setState({ downloadingId: "other-model" });
|
||||
renderCard(convertedReady);
|
||||
expect(screen.getByTestId("model-card-download")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("本卡下載中 → 顯示「下載中…」且 disabled", () => {
|
||||
useModelStore.setState({ downloadingId: "m1" });
|
||||
renderCard(convertedReady);
|
||||
const btn = screen.getByTestId("model-card-download");
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveTextContent("下載中");
|
||||
});
|
||||
});
|
||||
@ -10,15 +10,27 @@
|
||||
* 不再用 local-tool 的 accuracy / fps / supportedHardware(那些是內建 preset 的附加 metadata)
|
||||
* - 狀態 Badge 對齊 flow-model-upload §5.4(uploading / scanning / ready / rejected)
|
||||
* - 比較模式(Checkbox)保留,但預設關閉(雛形不做 comparison)
|
||||
* - Phase 0.9:可下載的 model(converted + ready)顯示「下載」按鈕,走 FAA delegated download。
|
||||
* 整張卡片包在 <Link> 裡 → 下載按鈕需 preventDefault + stopPropagation 避免觸發導航。
|
||||
*/
|
||||
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModelStatus, ModelSummary } from "@/stores/model-store";
|
||||
import {
|
||||
isModelDownloadable,
|
||||
useModelStore,
|
||||
type ModelStatus,
|
||||
type ModelSummary,
|
||||
} from "@/stores/model-store";
|
||||
|
||||
interface ModelCardProps {
|
||||
model: ModelSummary;
|
||||
@ -43,6 +55,39 @@ export function ModelCard({ model }: ModelCardProps) {
|
||||
const t = useT();
|
||||
const statusMeta = STATUS_VARIANT[model.status];
|
||||
|
||||
const downloadModel = useModelStore((s) => s.downloadModel);
|
||||
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
||||
const isDownloading = useModelStore((s) => s.downloadingId === model.id);
|
||||
// 任一卡片下載中時,其他卡片的下載按鈕 disable(store 同時只允許一個下載)。
|
||||
const isAnyDownloading = useModelStore((s) => s.downloadingId !== null);
|
||||
const [localBusy, setLocalBusy] = useState(false);
|
||||
|
||||
const downloadable = isModelDownloadable(model);
|
||||
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
// 整張卡片是 <Link> → 阻止冒泡到外層導航。
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (localBusy || isAnyDownloading) return;
|
||||
|
||||
setLocalBusy(true);
|
||||
const result = await downloadModel(model);
|
||||
setLocalBusy(false);
|
||||
|
||||
if (result.ok) {
|
||||
toast.success(t("models.download.toast.start"), {
|
||||
description: t("models.download.toast.hint"),
|
||||
});
|
||||
} else {
|
||||
// 用 backend code 對應 i18n;找不到對應 key 時 t() 回 key 本身,仍退化到 unknown。
|
||||
const key = `models.download.error.${result.code}`;
|
||||
const desc = t(key);
|
||||
toast.error(t("models.download.error.title"), {
|
||||
description: desc === key ? t("models.download.error.unknown") : desc,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/models/${model.id}`} data-testid="model-card">
|
||||
<Card
|
||||
@ -93,6 +138,31 @@ export function ModelCard({ model }: ModelCardProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{downloadable && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={isAnyDownloading || localBusy}
|
||||
aria-label={t("models.action.download.aria")}
|
||||
data-testid="model-card-download"
|
||||
>
|
||||
{isDownloading || localBusy ? (
|
||||
<>
|
||||
<Spinner size="sm" label={t("models.action.downloading")} />
|
||||
{t("models.action.downloading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon aria-hidden />
|
||||
{t("models.action.download")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
292
visionA-frontend/src/lib/api/model-download.test.ts
Normal file
292
visionA-frontend/src/lib/api/model-download.test.ts
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Model Download API Client 單元測試(Phase 0.9)
|
||||
*
|
||||
* 覆蓋:
|
||||
* 1. getModelDownload — 200 正規化 / 404 / 403 / 501 / 502 / parse 缺欄
|
||||
* 2. downloadModelFile — happy(fetch + Bearer → blob → anchor click)/ FAA 非 2xx / CORS network_error / abort
|
||||
* 3. triggerBlobDownload — anchor 屬性 + revokeObjectURL
|
||||
* 4. deriveDownloadFilename — 從 URL path 取檔名 / fallback
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
deriveDownloadFilename,
|
||||
downloadModelFile,
|
||||
getModelDownload,
|
||||
ModelDownloadError,
|
||||
triggerBlobDownload,
|
||||
} 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, token, expiresAt }(snake_case)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
download_url: "https://faa.example.com:5081/files/models/u1/j1.nef",
|
||||
token: "fdt_abc123",
|
||||
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",
|
||||
);
|
||||
expect(grant.token).toBe("fdt_abc123");
|
||||
expect(grant.expiresAt).toBe("2026-06-07T12:02:00Z");
|
||||
});
|
||||
|
||||
it("camelCase 也吃(downloadUrl / expiresAt)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: "https://faa/x.nef",
|
||||
token: "fdt_x",
|
||||
expiresAt: "2026-06-07T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const grant = await getModelDownload("m1");
|
||||
expect(grant.downloadUrl).toBe("https://faa/x.nef");
|
||||
expect(grant.token).toBe("fdt_x");
|
||||
});
|
||||
|
||||
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 但缺 token → parse_error", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: { download_url: "https://faa/x.nef" },
|
||||
}),
|
||||
);
|
||||
await expect(getModelDownload("m1")).rejects.toMatchObject({
|
||||
code: "parse_error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 2. downloadModelFile */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("downloadModelFile", () => {
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
let clickSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
revokeObjectURL = vi.fn();
|
||||
globalThis.URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
|
||||
globalThis.URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL;
|
||||
// anchor.click 在 jsdom 預設不觸發 navigation,但我們仍想斷言它被呼叫
|
||||
clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("happy path:帶 Authorization Bearer → blob → anchor click + 延遲 revoke", async () => {
|
||||
// 直接給帶 .blob() 的物件,避免 fake timers 下 jsdom Response/Blob stream 內部報錯
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
blob: () => Promise.resolve(new Blob(["nef-bytes"])),
|
||||
} as unknown as Response);
|
||||
|
||||
await downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef");
|
||||
|
||||
// 驗 fetch 帶對的 header + credentials omit
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://faa/x.nef",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
credentials: "omit",
|
||||
headers: { Authorization: "Bearer fdt_tok" },
|
||||
}),
|
||||
);
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
// revoke 延遲觸發
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
it("FAA 非 2xx → download_failed(帶 status)", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("nope", { status: 404 }));
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "download_failed", status: 404 });
|
||||
});
|
||||
|
||||
it("fetch throw(CORS / 連不上)→ network_error", async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "network_error" });
|
||||
});
|
||||
|
||||
it("AbortError → aborted", async () => {
|
||||
const abortErr = new Error("The operation was aborted");
|
||||
abortErr.name = "AbortError";
|
||||
fetchMock.mockRejectedValue(abortErr);
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "aborted" });
|
||||
});
|
||||
|
||||
it("空 token → validation_failed(不打 fetch)", async () => {
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "validation_failed" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 3. triggerBlobDownload */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("triggerBlobDownload", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.URL.createObjectURL = vi.fn(
|
||||
() => "blob:trigger-url",
|
||||
) as unknown as typeof URL.createObjectURL;
|
||||
globalThis.URL.revokeObjectURL = vi.fn() as unknown as typeof URL.revokeObjectURL;
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("建立 anchor、設 download 屬性、click 後從 DOM 移除", () => {
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
let capturedDownload = "";
|
||||
let capturedHref = "";
|
||||
const appendSpy = vi
|
||||
.spyOn(document.body, "appendChild")
|
||||
.mockImplementation((node) => {
|
||||
const a = node as HTMLAnchorElement;
|
||||
capturedDownload = a.download;
|
||||
capturedHref = a.href;
|
||||
return node;
|
||||
});
|
||||
const removeSpy = vi
|
||||
.spyOn(document.body, "removeChild")
|
||||
.mockImplementation((node) => node);
|
||||
|
||||
triggerBlobDownload(new Blob(["x"]), "my-model.nef");
|
||||
|
||||
expect(capturedDownload).toBe("my-model.nef");
|
||||
expect(capturedHref).toContain("blob:trigger-url");
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
expect(appendSpy).toHaveBeenCalledOnce();
|
||||
expect(removeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 4. deriveDownloadFilename */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("deriveDownloadFilename", () => {
|
||||
it("從 URL path 取最後一段含副檔名", () => {
|
||||
expect(
|
||||
deriveDownloadFilename(
|
||||
"https://faa.example.com:5081/files/models/u1/j1.nef",
|
||||
"YOLOv5s",
|
||||
),
|
||||
).toBe("j1.nef");
|
||||
});
|
||||
|
||||
it("URL 帶 query string 也只取 pathname 最後段", () => {
|
||||
expect(
|
||||
deriveDownloadFilename("https://faa/files/u/abc.nef?token=x", "name"),
|
||||
).toBe("abc.nef");
|
||||
});
|
||||
|
||||
it("path 無副檔名 → fallback 用 modelName.nef", () => {
|
||||
expect(deriveDownloadFilename("https://faa/files/u/blob", "My Model")).toBe(
|
||||
"My_Model.nef",
|
||||
);
|
||||
});
|
||||
|
||||
it("URL 無法解析 → fallback;非法字元被取代", () => {
|
||||
expect(deriveDownloadFilename("not a url", "a/b c")).toBe("a_b_c.nef");
|
||||
});
|
||||
|
||||
it("modelName 已含 .nef 不重複加", () => {
|
||||
expect(deriveDownloadFilename("bad", "model.nef")).toBe("model.nef");
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 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);
|
||||
});
|
||||
});
|
||||
233
visionA-frontend/src/lib/api/model-download.ts
Normal file
233
visionA-frontend/src/lib/api/model-download.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Model Download API Client(Phase 0.9 — 模型庫「FAA delegated download」對接)
|
||||
*
|
||||
* 對齊:
|
||||
* - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §10.4 + 決策 2
|
||||
* - backend endpoint `GET /api/models/:id/download`(commit c63886a,stage e2e 驗過)
|
||||
*
|
||||
* ⚠️ 為什麼這支「下載」跟既有轉檔下載(conversion.ts `getConversionDownloadURL`)完全不同:
|
||||
* - 轉檔下載:同 origin、走 visionA backend、用 `<a href>` browser navigation(302 redirect)。
|
||||
* - 模型庫下載:**跨 origin 直連 FAA(如 stage-9527:5081)+ 必須帶 `Authorization: Bearer {token}`**。
|
||||
* → browser navigation(`<a href download>` / `window.location.href`)**無法帶 Authorization header**,
|
||||
* 所以一定要用 `fetch(url, { headers: { Authorization } }) → blob → 動態 <a> 觸發下載`。
|
||||
*
|
||||
* 兩步驟流程(ADR-017 決策 2 v1.2 實測流程):
|
||||
* 1. `getModelDownload(modelId)`:打 visionA `GET /api/models/:id/download`,
|
||||
* 回 `{ downloadUrl, token, expiresAt }`(visionA 已向 MC 簽好 opaque `fdt_` token)。
|
||||
* 2. `downloadModelFile(downloadUrl, token, filename)`:帶 `Authorization: Bearer {token}`
|
||||
* 直接 GET `downloadUrl`(FAA),取 blob,建立 object URL + 動態 anchor click 觸發瀏覽器下載。
|
||||
*
|
||||
* 錯誤分層:
|
||||
* - client 不翻譯成中文 — i18n 留在 store / UI(用 `error.code` 對應 `models.download.error.<code>`)。
|
||||
* - backend 回的 code(model_not_found / forbidden / upload_not_supported / sign_failed …)原樣透出。
|
||||
*/
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** `GET /api/models/:id/download` 正規化後的回傳。 */
|
||||
export interface ModelDownloadGrant {
|
||||
/** FAA 直連下載 URL(跨 origin,如 https://stage-9527...:5081/files/models/{userID}/{jobID}.nef) */
|
||||
downloadUrl: string;
|
||||
/** MC 簽的 opaque delegated download token(`fdt_...`);只用於下一步的 Authorization header */
|
||||
token: string;
|
||||
/** ISO 8601 — token 過期時間 */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Error class */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.<code>`)。
|
||||
*
|
||||
* code 統一全小寫(對齊 conversion.ts `ConversionAPIError` 的命名規範,避免 UI 端做大小寫處理)。
|
||||
* 常見 code:
|
||||
* - `model_not_found`(404)/ `forbidden`(403)/ `upload_not_supported`(501,第一階段不支援上傳類)
|
||||
* - `sign_failed`(502,MC 簽 token 失敗)
|
||||
* - `download_failed`(FAA GET 非 2xx;可能含 CORS 被擋 → network_error)
|
||||
* - `network_error` / `timeout` / `aborted` / `parse_error`(client 端網路層)
|
||||
*/
|
||||
export class ModelDownloadError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly requestId?: string;
|
||||
|
||||
constructor(status: number, code: string, message: string, requestId?: string) {
|
||||
super(message);
|
||||
this.name = "ModelDownloadError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
if (typeof Error.captureStackTrace === "function") {
|
||||
Error.captureStackTrace(this, ModelDownloadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把底層 `ApiError` / 一般 Error 包成 `ModelDownloadError`(code 統一小寫)。 */
|
||||
function wrapError(err: unknown): ModelDownloadError {
|
||||
if (err instanceof ModelDownloadError) return err;
|
||||
if (err instanceof ApiError) {
|
||||
return new ModelDownloadError(err.status, err.code.toLowerCase(), err.message);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
const maybeCode = (err as unknown as { code?: unknown }).code;
|
||||
const code =
|
||||
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
|
||||
return new ModelDownloadError(0, code, err.message);
|
||||
}
|
||||
return new ModelDownloadError(0, "unknown", String(err));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 1. GET /api/models/:id/download — 取 FAA 下載授權 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 向 visionA backend 取得模型的 FAA delegated download 授權。
|
||||
*
|
||||
* 走既有 `api.get` wrapper(自動帶 cookie session、解 envelope、ApiError mapping)。
|
||||
* 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。
|
||||
*
|
||||
* @throws {ModelDownloadError} 404 model_not_found / 403 forbidden /
|
||||
* 501 upload_not_supported / 502 sign_failed / 其他網路層錯誤
|
||||
*/
|
||||
export async function getModelDownload(modelId: string): Promise<ModelDownloadGrant> {
|
||||
if (!modelId) {
|
||||
throw new ModelDownloadError(0, "validation_failed", "modelId is required");
|
||||
}
|
||||
try {
|
||||
const raw = await api.get<Record<string, unknown>>(
|
||||
`/api/models/${encodeURIComponent(modelId)}/download`,
|
||||
);
|
||||
const r = raw ?? {};
|
||||
const downloadUrl = String(r.download_url ?? r.downloadUrl ?? "");
|
||||
const token = String(r.token ?? "");
|
||||
const expiresAt = String(r.expires_at ?? r.expiresAt ?? "");
|
||||
if (!downloadUrl || !token) {
|
||||
throw new ModelDownloadError(
|
||||
500,
|
||||
"parse_error",
|
||||
"download: missing download_url or token in response",
|
||||
);
|
||||
}
|
||||
return { downloadUrl, token, expiresAt };
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2. fetch FAA + Bearer header + blob → 觸發瀏覽器下載 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 帶 `Authorization: Bearer {token}` 跨 origin 直連 FAA 下載檔案,並觸發瀏覽器存檔。
|
||||
*
|
||||
* 為什麼不能用 `<a href download>`:browser navigation 無法帶自訂 header(Authorization),
|
||||
* 而 FAA `TryReadAccessToken` 只認 `Authorization: Bearer`(不認 query / 自訂 header)。
|
||||
* → 必須 fetch → response.blob() → URL.createObjectURL → 動態 anchor click → revokeObjectURL。
|
||||
*
|
||||
* CORS 註記:FAA 端需允許 visionA 前端 origin(ADR-017 決策 2 Q3)。若 FAA 未設 CORS,
|
||||
* 跨 origin fetch 會 throw `TypeError: Failed to fetch` → 落到 `network_error`(UI 顯示下載失敗)。
|
||||
* 這是 FAA 端設定問題,**前端 code 不為 CORS 改**。
|
||||
*
|
||||
* @param downloadUrl FAA 絕對 URL(來自 getModelDownload)
|
||||
* @param token opaque `fdt_` token(只用於 Authorization header,不寫進 URL / log)
|
||||
* @param filename 存檔檔名(如 `{jobID}.nef`)
|
||||
* @throws {ModelDownloadError} download_failed(FAA 非 2xx)/ network_error / aborted
|
||||
*/
|
||||
export async function downloadModelFile(
|
||||
downloadUrl: string,
|
||||
token: string,
|
||||
filename: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (!downloadUrl || !token) {
|
||||
throw new ModelDownloadError(0, "validation_failed", "downloadUrl and token are required");
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
// 跨 origin 直連 FAA:不帶 visionA cookie(FAA 用 delegated token 認證,cookie 無意義)
|
||||
credentials: "omit",
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
|
||||
throw new ModelDownloadError(0, "aborted", "Download aborted");
|
||||
}
|
||||
// TypeError: Failed to fetch — 通常是 CORS 被擋 / 連不上 FAA
|
||||
throw new ModelDownloadError(
|
||||
0,
|
||||
"network_error",
|
||||
`Failed to reach FAA: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ModelDownloadError(
|
||||
res.status,
|
||||
"download_failed",
|
||||
`FAA download failed: HTTP ${res.status}`,
|
||||
res.headers.get("X-Request-Id") ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
triggerBlobDownload(blob, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 object URL + 動態 anchor click 觸發瀏覽器存檔,完成後 revoke 避免記憶體洩漏。
|
||||
*
|
||||
* 抽成獨立函式:便於測試(驗 anchor 屬性 / revoke 被呼叫)、也讓上面 fetch 流程更乾淨。
|
||||
*/
|
||||
export function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
// 不掛進 DOM 也能 click(現代瀏覽器支援),但部分 Firefox 版本需 append;保險起見 append + remove
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
} finally {
|
||||
// 立即 revoke 可能在某些瀏覽器中斷下載,延遲一拍再 revoke
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helper */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 從 FAA download URL 推導存檔檔名(`.../models/{userID}/{jobID}.nef` → `{jobID}.nef`)。
|
||||
*
|
||||
* 回退:URL 解析失敗 / 無副檔名時用 `{modelName}.nef`(modelName 已去除非法字元)。
|
||||
*/
|
||||
export function deriveDownloadFilename(downloadUrl: string, modelName: string): string {
|
||||
try {
|
||||
// downloadUrl 可能含 query string;取 pathname 最後一段
|
||||
const path = new URL(downloadUrl).pathname;
|
||||
const last = path.split("/").filter(Boolean).pop() ?? "";
|
||||
if (last && /\.[a-z0-9]+$/i.test(last)) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
// URL 解析失敗 → 走 fallback
|
||||
}
|
||||
const safe = (modelName || "model").replace(/[^\w.\-]+/g, "_");
|
||||
return safe.endsWith(".nef") ? safe : `${safe}.nef`;
|
||||
}
|
||||
@ -221,6 +221,29 @@ export const en: Dictionary = {
|
||||
"models.upload.error.urlFailed": "Server is busy, please try again.",
|
||||
"models.upload.error.networkLost": "Network lost, upload paused.",
|
||||
"models.upload.toast.uploaded": "Model \"{name}\" uploaded.",
|
||||
// ── Model download (Phase 0.9 FAA delegated download) ──
|
||||
"models.action.download": "Download",
|
||||
"models.action.downloading": "Downloading…",
|
||||
"models.action.download.aria": "Download the model file to your device",
|
||||
"models.action.download.unsupportedTooltip":
|
||||
"Only converted models are downloadable in this phase",
|
||||
"models.download.toast.start": "Download started",
|
||||
"models.download.toast.hint":
|
||||
"If you don't see a download prompt, check your browser settings",
|
||||
"models.download.error.title": "Download failed",
|
||||
"models.download.error.model_not_found": "Model not found.",
|
||||
"models.download.error.forbidden": "You don't have permission to download this model.",
|
||||
"models.download.error.upload_not_supported":
|
||||
"Uploaded models are not downloadable in this phase.",
|
||||
"models.download.error.sign_failed":
|
||||
"Failed to obtain a download grant, please try again later.",
|
||||
"models.download.error.download_failed":
|
||||
"Failed to download from the file server, please try again later.",
|
||||
"models.download.error.network_error":
|
||||
"Network error, please check your connection and retry.",
|
||||
"models.download.error.timeout": "Download timed out, please try again later.",
|
||||
"models.download.error.busy": "A download is already in progress, please wait.",
|
||||
"models.download.error.unknown": "Download failed, please try again later.",
|
||||
|
||||
// ── Workspace ──
|
||||
"workspace.title": "Workspace",
|
||||
|
||||
@ -219,6 +219,24 @@ export const zhHant: Dictionary = {
|
||||
"models.upload.error.urlFailed": "伺服器忙碌,請稍後再試",
|
||||
"models.upload.error.networkLost": "網路中斷,上傳已暫停",
|
||||
"models.upload.toast.uploaded": "模型「{name}」已上傳",
|
||||
// ── 模型下載(Phase 0.9 FAA delegated download)──
|
||||
"models.action.download": "下載",
|
||||
"models.action.downloading": "下載中…",
|
||||
"models.action.download.aria": "下載模型檔到本機",
|
||||
"models.action.download.unsupportedTooltip": "第一階段僅支援轉檔產生的模型下載",
|
||||
"models.download.toast.start": "下載已開始",
|
||||
"models.download.toast.hint": "若沒看到下載提示,請檢查瀏覽器設定",
|
||||
"models.download.error.title": "下載失敗",
|
||||
// 各 error code(對齊 backend:model_not_found / forbidden / upload_not_supported / sign_failed)
|
||||
"models.download.error.model_not_found": "找不到此模型",
|
||||
"models.download.error.forbidden": "你沒有權限下載此模型",
|
||||
"models.download.error.upload_not_supported": "第一階段尚不支援上傳類模型下載",
|
||||
"models.download.error.sign_failed": "取得下載授權失敗,請稍後再試",
|
||||
"models.download.error.download_failed": "從檔案伺服器下載失敗,請稍後再試",
|
||||
"models.download.error.network_error": "網路連線失敗,請檢查網路後重試",
|
||||
"models.download.error.timeout": "下載逾時,請稍後再試",
|
||||
"models.download.error.busy": "已有下載進行中,請稍候",
|
||||
"models.download.error.unknown": "下載失敗,請稍後再試",
|
||||
|
||||
// ── Workspace ──
|
||||
"workspace.title": "推論工作區",
|
||||
|
||||
@ -1,11 +1,45 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useModelStore } from "./model-store";
|
||||
import { ModelDownloadError } from "@/lib/api/model-download";
|
||||
|
||||
import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store";
|
||||
|
||||
// mock 下載 API client(store 的 downloadModel action 依賴它)
|
||||
vi.mock("@/lib/api/model-download", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/model-download")>(
|
||||
"@/lib/api/model-download",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getModelDownload: vi.fn(),
|
||||
downloadModelFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { downloadModelFile, getModelDownload } from "@/lib/api/model-download";
|
||||
|
||||
const mockGetModelDownload = vi.mocked(getModelDownload);
|
||||
const mockDownloadModelFile = vi.mocked(downloadModelFile);
|
||||
|
||||
function reset() {
|
||||
useModelStore.setState({ models: [], selectedModel: null, isLoading: false });
|
||||
useModelStore.setState({
|
||||
models: [],
|
||||
selectedModel: null,
|
||||
isLoading: false,
|
||||
downloadingId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const convertedReady: ModelSummary = {
|
||||
id: "m1",
|
||||
name: "YOLOv5s",
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("model-store(F5 骨架)", () => {
|
||||
beforeEach(reset);
|
||||
|
||||
@ -36,3 +70,103 @@ describe("model-store(F5 骨架)", () => {
|
||||
await expect(useModelStore.getState().fetchModels()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelDownloadable(Phase 0.9)", () => {
|
||||
it("converted + ready → 可下載", () => {
|
||||
expect(isModelDownloadable({ source: "converted", status: "ready" })).toBe(true);
|
||||
});
|
||||
|
||||
it("uploaded(即使 ready)→ 不可下載", () => {
|
||||
expect(isModelDownloadable({ source: "uploaded", status: "ready" })).toBe(false);
|
||||
});
|
||||
|
||||
it("converted 但非 ready(scanning)→ 不可下載", () => {
|
||||
expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
|
||||
});
|
||||
|
||||
it("preset → 不可下載", () => {
|
||||
expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadModel action(Phase 0.9)", () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("happy path:取授權 → 下載 → 回 { ok: true },過程中 downloadingId = 該 model,結束清空", async () => {
|
||||
mockGetModelDownload.mockResolvedValue({
|
||||
downloadUrl: "https://faa/files/u/m1.nef",
|
||||
token: "fdt_tok",
|
||||
expiresAt: "2026-06-07T12:00:00Z",
|
||||
});
|
||||
mockDownloadModelFile.mockResolvedValue(undefined);
|
||||
|
||||
const promise = useModelStore.getState().downloadModel(convertedReady);
|
||||
// action 同步階段已設 downloadingId
|
||||
expect(useModelStore.getState().downloadingId).toBe("m1");
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockGetModelDownload).toHaveBeenCalledWith("m1");
|
||||
// 檔名從 URL path 推導(m1.nef)
|
||||
expect(mockDownloadModelFile).toHaveBeenCalledWith(
|
||||
"https://faa/files/u/m1.nef",
|
||||
"fdt_tok",
|
||||
"m1.nef",
|
||||
);
|
||||
// 結束後清空 loading
|
||||
expect(useModelStore.getState().downloadingId).toBeNull();
|
||||
});
|
||||
|
||||
it("不可下載的 model(uploaded)→ 不打 API,回 upload_not_supported", async () => {
|
||||
const result = await useModelStore.getState().downloadModel({
|
||||
...convertedReady,
|
||||
source: "uploaded",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: "upload_not_supported",
|
||||
message: expect.any(String),
|
||||
});
|
||||
expect(mockGetModelDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("已有下載進行中 → 回 busy,不重複打 API", async () => {
|
||||
useModelStore.setState({ downloadingId: "other" });
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "busy" });
|
||||
expect(mockGetModelDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getModelDownload 拋 ModelDownloadError → 回對應 code,並清空 downloadingId", async () => {
|
||||
mockGetModelDownload.mockRejectedValue(
|
||||
new ModelDownloadError(403, "forbidden", "no"),
|
||||
);
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "forbidden" });
|
||||
expect(useModelStore.getState().downloadingId).toBeNull();
|
||||
});
|
||||
|
||||
it("downloadModelFile 拋 network_error(CORS)→ 回 network_error", async () => {
|
||||
mockGetModelDownload.mockResolvedValue({
|
||||
downloadUrl: "https://faa/files/u/m1.nef",
|
||||
token: "fdt_tok",
|
||||
expiresAt: "2026-06-07T12:00:00Z",
|
||||
});
|
||||
mockDownloadModelFile.mockRejectedValue(
|
||||
new ModelDownloadError(0, "network_error", "Failed to fetch"),
|
||||
);
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "network_error" });
|
||||
expect(useModelStore.getState().downloadingId).toBeNull();
|
||||
});
|
||||
|
||||
it("非 ModelDownloadError 的例外 → 退化成 unknown", async () => {
|
||||
mockGetModelDownload.mockRejectedValue(new Error("weird"));
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,6 +19,12 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
deriveDownloadFilename,
|
||||
downloadModelFile,
|
||||
getModelDownload,
|
||||
ModelDownloadError,
|
||||
} from "@/lib/api/model-download";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types — 對齊 api-spec.md §4 */
|
||||
@ -116,12 +122,31 @@ function normalizeInitResult(raw: unknown): UploadInitResult {
|
||||
/* Store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** download action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast / inline 錯誤)。 */
|
||||
export type DownloadResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
/**
|
||||
* 判斷一個 model 是否可下載(Phase 0.9 第一階段:只支援轉檔 promote 的 model)。
|
||||
*
|
||||
* 為什麼只有 `converted`:第一階段 FAA delegated download 只覆蓋「轉檔→promote」類 model
|
||||
* (ADR-017 §10.4 B1 object_key 斷層)。上傳類 model 在 visionA 自己的 storage、沒有 FAA object_key,
|
||||
* backend 會回 501 upload_not_supported。UI 依此條件隱藏下載按鈕、避免使用者點了才吃 501。
|
||||
*/
|
||||
export function isModelDownloadable(model: Pick<ModelSummary, "source" | "status">): boolean {
|
||||
return model.source === "converted" && model.status === "ready";
|
||||
}
|
||||
|
||||
interface ModelState {
|
||||
models: ModelSummary[];
|
||||
selectedModel: Model | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** 正在下載中的 model id(同時間只允許一個下載;null = 無)。供 UI 顯示 per-card loading。 */
|
||||
downloadingId: string | null;
|
||||
|
||||
/** 呼叫 `GET /api/models` */
|
||||
fetchModels: () => Promise<void>;
|
||||
/** 呼叫 `GET /api/models/:id` */
|
||||
@ -138,6 +163,11 @@ interface ModelState {
|
||||
finalizeUpload: (modelId: string, etag: string | null) => Promise<void>;
|
||||
/** 呼叫 `DELETE /api/models/:id` */
|
||||
deleteModel: (id: string) => Promise<boolean>;
|
||||
/**
|
||||
* 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 帶 Bearer token 直連 FAA 取 blob)。
|
||||
* 回 `DownloadResult`,由 UI 決定顯示 toast / inline 錯誤(用 `code` 對應 i18n)。
|
||||
*/
|
||||
downloadModel: (model: ModelSummary) => Promise<DownloadResult>;
|
||||
|
||||
/** 測試 / 雛形用 */
|
||||
_setModels: (models: ModelSummary[]) => void;
|
||||
@ -149,6 +179,7 @@ export const useModelStore = create<ModelState>()((set, get) => ({
|
||||
selectedModel: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
downloadingId: null,
|
||||
|
||||
fetchModels: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
@ -218,6 +249,39 @@ export const useModelStore = create<ModelState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
downloadModel: async (model) => {
|
||||
// 防呆:不可下載的 model(非 converted / 非 ready)直接回對應錯誤、不打 API。
|
||||
if (!isModelDownloadable(model)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "upload_not_supported",
|
||||
message: "model is not downloadable",
|
||||
};
|
||||
}
|
||||
// 同時間只允許一個下載中(避免重複點擊 / 多檔同時拉爆頻寬)。
|
||||
if (get().downloadingId) {
|
||||
return { ok: false, code: "busy", message: "another download is in progress" };
|
||||
}
|
||||
|
||||
set({ downloadingId: model.id });
|
||||
try {
|
||||
const grant = await getModelDownload(model.id);
|
||||
const filename = deriveDownloadFilename(grant.downloadUrl, model.name);
|
||||
await downloadModelFile(grant.downloadUrl, grant.token, filename);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// ModelDownloadError 帶 code(i18n 用);其他 Error 退化成 unknown。
|
||||
if (err instanceof ModelDownloadError) {
|
||||
return { ok: false, code: err.code, message: err.message };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, code: "unknown", message };
|
||||
} finally {
|
||||
// 無論成功 / 失敗都要清掉 loading 狀態(避免按鈕卡在 disabled)。
|
||||
set({ downloadingId: null });
|
||||
}
|
||||
},
|
||||
|
||||
_setModels: (models) => set({ models }),
|
||||
_setSelected: (selectedModel) => set({ selectedModel }),
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user