純空白字串(" ")原判 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>
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|