/** * device-store 單元測試 — 驗證 F6 新增的雲端裝置 store * * 覆蓋: * - fetchDevices 成功(list 正規化) * - fetchDevices 遇到 501 / TUNNEL_DISCONNECTED 時 fallback 為空 list * - connectDevice 成功 + 失敗 flow * - snake_case ↔ camelCase 混合 payload 都能讀進 state */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/lib/api"; import { useDeviceStore } from "./device-store"; // 把 fetch mock 起來(api.ts 會呼叫 global fetch) beforeEach(() => { // 清 store state useDeviceStore.setState({ devices: [], selectedDevice: null, isLoading: false, connectingId: null, disconnectingId: null, unpairingId: null, error: null, }); // OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶) }); afterEach(() => { vi.restoreAllMocks(); }); /** 小工具:做一個 envelope 的 Response */ function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } describe("useDeviceStore", () => { it("fetchDevices 成功時正規化 list(接受 snake_case + camelCase)", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: [ { id: "dev-1", name: "KL520", device_type: "kl520", status: "connected", remote_status: "online", last_seen_at: "2026-04-21T00:00:00Z", firmware_version: "2.3.1", }, { id: "dev-2", name: "KL720", type: "kl720", status: "disconnected", remoteStatus: "offline", lastSeenAt: "2026-04-20T23:30:00Z", }, ], }), ); await useDeviceStore.getState().fetchDevices(); expect(fetchSpy).toHaveBeenCalledOnce(); const { devices, isLoading, error } = useDeviceStore.getState(); expect(isLoading).toBe(false); expect(error).toBeNull(); expect(devices).toHaveLength(2); expect(devices[0]).toMatchObject({ id: "dev-1", type: "kl520", remoteStatus: "online", firmwareVersion: "2.3.1", }); expect(devices[1]).toMatchObject({ id: "dev-2", type: "kl720", remoteStatus: "offline", }); }); it("tunnel_online === true → remoteStatus 覆蓋為 online(即使 remote_status 是 offline)", async () => { // bug fix:後端 remote_status 是 DB 靜態 offline,但 tunnel_online 即時算出 tunnel 活著。 vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: [ { id: "dev-1", name: "KL520", type: "kl520", status: "connected", remote_status: "offline", // DB 靜態值 tunnel_online: true, // 即時 tunnel 狀態 }, ], }), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().devices[0]).toMatchObject({ id: "dev-1", remoteStatus: "online", }); }); it("tunnel_online === false → 沿用 remote_status 判定(不覆蓋)", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: [ { id: "dev-1", name: "KL520", type: "kl520", status: "disconnected", remote_status: "reconnecting", tunnel_online: false, }, ], }), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().devices[0]).toMatchObject({ id: "dev-1", remoteStatus: "reconnecting", }); }); it("tunnel_online 缺欄位 → 沿用 remote_status(守住既有行為)", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: [ { id: "dev-1", name: "KL520", type: "kl520", status: "disconnected", remote_status: "offline", // 無 tunnel_online }, ], }), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().devices[0]).toMatchObject({ id: "dev-1", remoteStatus: "offline", }); }); it("tunnel_online 與 remote_status 皆缺 → remoteStatus 預設 unknown", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: [{ id: "dev-1", name: "KL520", type: "kl520", status: "connected" }], }), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().devices[0]).toMatchObject({ id: "dev-1", remoteStatus: "unknown", }); }); it("serial_number 正規化:snake_case / camelCase 都吃、缺欄位與空字串/純空白 → null(WP-C)", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: [ // snake_case(後端實際形狀,omitempty) { id: "dev-1", name: "A", type: "kl520", status: "connected", serial_number: "KN00123456" }, // camelCase 容錯 { id: "dev-2", name: "B", type: "kl720", status: "connected", serialNumber: "KN99887766" }, // 缺欄位(舊資料 / 未回報)→ null { id: "dev-3", name: "C", type: "kl520", status: "connected" }, // 空字串(防禦性;無法路由)→ null { id: "dev-4", name: "D", type: "kl520", status: "connected", serial_number: "" }, // 純空白(防禦性;trim 後為空、無法路由)→ null { id: "dev-5", name: "E", type: "kl520", status: "connected", serial_number: " " }, ], }), ); await useDeviceStore.getState().fetchDevices(); const { devices } = useDeviceStore.getState(); expect(devices[0]?.serialNumber).toBe("KN00123456"); expect(devices[1]?.serialNumber).toBe("KN99887766"); expect(devices[2]?.serialNumber).toBeNull(); expect(devices[3]?.serialNumber).toBeNull(); expect(devices[4]?.serialNumber).toBeNull(); }); it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( { success: false, error: { code: "NOT_IMPLEMENTED", message: "stub" }, }, 501, ), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().devices).toEqual([]); expect(useDeviceStore.getState().error).toBeNull(); }); it("fetchDevices 遇到 TUNNEL_DISCONNECTED 時視為空 list", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( { success: false, error: { code: "TUNNEL_DISCONNECTED", message: "no agent" }, }, 502, ), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().devices).toEqual([]); expect(useDeviceStore.getState().error).toBeNull(); }); it("fetchDevices 遇到其他錯誤時寫入 error state", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( { success: false, error: { code: "INTERNAL_ERROR", message: "boom" }, }, 500, ), ); await useDeviceStore.getState().fetchDevices(); expect(useDeviceStore.getState().error).toBe("boom"); }); it("connectDevice 成功時 connectingId 正確清除且回傳 true", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: {} }), ); const ok = await useDeviceStore.getState().connectDevice("dev-1"); expect(ok).toBe(true); expect(useDeviceStore.getState().connectingId).toBeNull(); }); it("connect / disconnect 的 path 識別值 = 傳入的 serialNumber(WP-C serial 路由)", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue(jsonResponse({ success: true, data: {} })); await useDeviceStore.getState().connectDevice("KN00123456"); expect(String(fetchSpy.mock.calls[0]?.[0])).toContain( "/api/devices/KN00123456/connect", ); await useDeviceStore.getState().disconnectDevice("KN00123456"); expect(String(fetchSpy.mock.calls[1]?.[0])).toContain( "/api/devices/KN00123456/disconnect", ); }); it("connectDevice 失敗時回傳 false 且寫入 error", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( { success: false, error: { code: "INTERNAL_ERROR", message: "fail" }, }, 500, ), ); const ok = await useDeviceStore.getState().connectDevice("dev-x"); expect(fetchMock).toHaveBeenCalled(); expect(ok).toBe(false); expect(useDeviceStore.getState().error).toBe("fail"); expect(useDeviceStore.getState().connectingId).toBeNull(); }); it("ApiError instanceof 檢查有效(呼叫端能分流)", async () => { // 用 internal 路徑確認 ApiError 被正確丟出 — 間接測試,不直接呼叫 request const err = new ApiError(500, { code: "INTERNAL_ERROR", message: "x" }); expect(err).toBeInstanceOf(ApiError); expect(err.code).toBe("INTERNAL_ERROR"); }); }); describe("useDeviceStore.unpairDevice", () => { const summary = { id: "dev-1", name: "KL520", type: "kl520", status: "connected" as const, remoteStatus: "online" as const, }; it("成功時打對 endpoint、回 { ok: true }、從 list 移除該裝置且清空 unpairingId", async () => { useDeviceStore.setState({ devices: [summary, { ...summary, id: "dev-2" }], selectedDevice: { ...summary }, }); const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }), ); const result = await useDeviceStore.getState().unpairDevice("dev-1"); expect(result).toEqual({ ok: true }); // 打到 POST /api/devices/dev-1/unpair const calledUrl = String(fetchSpy.mock.calls[0]?.[0]); expect(calledUrl).toContain("/api/devices/dev-1/unpair"); expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" }); const state = useDeviceStore.getState(); expect(state.devices.map((d) => d.id)).toEqual(["dev-2"]); // selectedDevice 是被移除的那台 → 清為 null expect(state.selectedDevice).toBeNull(); expect(state.unpairingId).toBeNull(); }); it("成功移除非當前 selected 的裝置 → 不動 selectedDevice", async () => { useDeviceStore.setState({ devices: [summary, { ...summary, id: "dev-2" }], selectedDevice: { ...summary, id: "dev-2" }, }); vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }), ); await useDeviceStore.getState().unpairDevice("dev-1"); expect(useDeviceStore.getState().selectedDevice?.id).toBe("dev-2"); }); it("呼叫期間 unpairingId 設為該 id(loading 態)", async () => { let observedDuringCall: string | null = "not-set"; vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => { // fetch 進行中時 store 應已標記 unpairingId observedDuringCall = useDeviceStore.getState().unpairingId; return jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }); }); await useDeviceStore.getState().unpairDevice("dev-1"); expect(observedDuringCall).toBe("dev-1"); expect(useDeviceStore.getState().unpairingId).toBeNull(); }); it("403 FORBIDDEN(非 owner)→ 回 { ok:false, code:'FORBIDDEN' },不移除 list,清空 unpairingId", async () => { useDeviceStore.setState({ devices: [summary] }); vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( { success: false, error: { code: "FORBIDDEN", message: "not owner" } }, 403, ), ); const result = await useDeviceStore.getState().unpairDevice("dev-1"); expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" }); // 失敗時 list 不變 expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]); expect(useDeviceStore.getState().unpairingId).toBeNull(); expect(useDeviceStore.getState().error).toBe("not owner"); }); it("404 NOT_FOUND(裝置已刪)→ 回 { ok:false, code:'NOT_FOUND' }", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( { success: false, error: { code: "NOT_FOUND", message: "device not found" } }, 404, ), ); const result = await useDeviceStore.getState().unpairDevice("dev-1"); expect(result).toMatchObject({ ok: false, code: "NOT_FOUND" }); expect(useDeviceStore.getState().unpairingId).toBeNull(); }); });