visionA/visionA-frontend/src/stores/device-store.test.ts
jim800121chen 51dfd0f2d8 fix(frontend): normalizeDevice serialNumber 加 trim(WP-C Minor #1)
純空白字串(" ")原判 truthy → 會以空白 serial 組出必失敗的請求路由。
改為先 trim 再判定:serial !== "" ? serial : null。順帶消除字面 "0"
被 falsy 誤判為 null 的隱性行為。補純空白 → null 邊界測試。

Reviewer 通過(0C/0M/0Mi)。tsc/eslint 0、18 test 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:35 +08:00

393 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
});
// OF2api.ts 不再需要 token gettercookie 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 都吃、缺欄位與空字串/純空白 → nullWP-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 識別值 = 傳入的 serialNumberWP-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 設為該 idloading 態)", 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();
});
});