From 27fb2a512a9ff0646456b35e8b6b6d84512bbfd8 Mon Sep 17 00:00:00 2001 From: philon- Date: Wed, 13 Aug 2025 16:57:31 +0200 Subject: [PATCH] Updated tests --- .../components/Dialog/RebootDialog.test.tsx | 58 ++-- .../RefreshKeysDialog.test.tsx | 11 +- .../useUnsafeRolesDialog.test.tsx | 19 +- .../PageComponents/Connect/HTTP.test.tsx | 5 +- .../Messages/TraceRoute.test.tsx | 9 +- .../src/core/hooks/useFavoriteNode.test.ts | 9 +- .../web/src/core/hooks/useIgnoreNode.test.ts | 11 +- .../stores/deviceStore/deviceStore.mock.ts | 17 -- .../web/src/core/stores/nodeDBStore/index.ts | 12 - .../stores/nodeDBStore/nodeDBStore.mock.ts | 30 +++ .../stores/nodeDBStore/nodeDBStore.test.ts | 247 ++++++++++++++++++ .../web/src/core/stores/utils/indexDB.test.ts | 158 +++++++++++ 12 files changed, 517 insertions(+), 69 deletions(-) create mode 100644 packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts create mode 100644 packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts create mode 100644 packages/web/src/core/stores/utils/indexDB.test.ts diff --git a/packages/web/src/components/Dialog/RebootDialog.test.tsx b/packages/web/src/components/Dialog/RebootDialog.test.tsx index 85cf813c..bf88be9c 100644 --- a/packages/web/src/components/Dialog/RebootDialog.test.tsx +++ b/packages/web/src/components/Dialog/RebootDialog.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, act } from "@testing-library/react"; import type { ButtonHTMLAttributes, ClassAttributes, @@ -89,7 +89,9 @@ describe("RebootDialog", () => { render( {}} />); // Schedule non-OTA reboot - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); expect(rebootMock).toHaveBeenCalledWith(5); expect(rebootOtaMock).not.toHaveBeenCalled(); @@ -97,7 +99,9 @@ describe("RebootDialog", () => { rebootOtaMock.mockClear(); // Cancel scheduled - fireEvent.click(screen.getByTestId("cancelRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("cancelRebootBtn")); + }); expect(rebootMock).toHaveBeenCalledWith(-1); expect(rebootOtaMock).not.toHaveBeenCalled(); @@ -105,8 +109,12 @@ describe("RebootDialog", () => { rebootOtaMock.mockClear(); // Schedule OTA reboot - fireEvent.click(screen.getByText(/reboot into ota mode/i)); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByText(/reboot into ota mode/i)); + }); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); expect(rebootOtaMock).toHaveBeenCalledWith(5); expect(rebootMock).not.toHaveBeenCalled(); }); @@ -115,17 +123,23 @@ describe("RebootDialog", () => { const onOpenChangeMock = vi.fn(); render(); - fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { - target: { value: "3" }, + act(() => { + fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { + target: { value: "3" }, + }); }); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); expect(rebootMock).toHaveBeenCalledWith(3); expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); - vi.advanceTimersByTime(3000); + act(() => { + vi.advanceTimersByTime(3000); + }); expect(onOpenChangeMock).toHaveBeenCalledWith(false); @@ -135,7 +149,9 @@ describe("RebootDialog", () => { const onOpenChangeMock = vi.fn(); render(); - fireEvent.click(screen.getByRole("button", { name: /reboot now/i })); + act(() => { + fireEvent.click(screen.getByRole("button", { name: /reboot now/i })); + }); expect(rebootMock).toHaveBeenCalledWith(0); expect(onOpenChangeMock).toHaveBeenCalledWith(false); @@ -148,9 +164,13 @@ describe("RebootDialog", () => { render(); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); - vi.advanceTimersByTime(5000); + act(() => { + vi.advanceTimersByTime(5000); + }); expect(rebootMock).not.toHaveBeenCalled(); expect(rebootOtaMock).not.toHaveBeenCalled(); @@ -162,13 +182,19 @@ describe("RebootDialog", () => { const onOpenChangeMock = vi.fn(); render(); - fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { - target: { value: "4" }, + act(() => { + fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { + target: { value: "4" }, + }); + }); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); }); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); expect(rebootMock).toHaveBeenCalledWith(4); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + act(() => { + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + }); expect(rebootMock).toHaveBeenCalledWith(-1); expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument(); }); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx index 6a87ccc5..d9501fc6 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx @@ -1,4 +1,4 @@ -import { DeviceContext, useDeviceStore, useMessageStore } from "@core/stores"; +import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores"; import { render } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; @@ -37,11 +37,6 @@ test("does not render dialog if no error exists for active chat", () => { useDeviceStore.getState().addDevice(deviceId); - const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); - if (!currentDeviceState) { - throw new Error("Device not found"); - } - mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); mockUseRefreshKeysDialog.mockReturnValue({ handleCloseDialog: vi.fn(), @@ -49,9 +44,9 @@ test("does not render dialog if no error exists for active chat", () => { }); const { container } = render( - + - , + , ); expect(container.firstChild).toBeNull(); diff --git a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx index a2227763..cc69ef71 100644 --- a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx +++ b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -36,7 +36,10 @@ const mockDevice = { setDialogOpen: vi.fn(), }; -vi.mock("@core/stores/deviceStore", () => ({ +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 123 }, + }, useDevice: () => ({ setDialogOpen: mockDevice.setDialogOpen, }), @@ -85,7 +88,7 @@ describe("useUnsafeRolesDialog", () => { const { result } = renderUnsafeRolesHook(); const validationPromise = result.current.validateRoleSelection( - UNSAFE_ROLES[0], + UNSAFE_ROLES[0]!, ); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith( @@ -97,7 +100,7 @@ describe("useUnsafeRolesDialog", () => { expect.any(Function), ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "confirm" }); const validationResult = await validationPromise; @@ -111,9 +114,9 @@ describe("useUnsafeRolesDialog", () => { it("should resolve with false when user dismisses the dialog", async () => { const { result } = renderUnsafeRolesHook(); const validationPromise = result.current.validateRoleSelection( - UNSAFE_ROLES[0], + UNSAFE_ROLES[0]!, ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "dismiss" }); const validationResult = await validationPromise; @@ -128,9 +131,9 @@ describe("useUnsafeRolesDialog", () => { const { result } = renderUnsafeRolesHook(); const validationPromise = result.current.validateRoleSelection( - UNSAFE_ROLES[1], + UNSAFE_ROLES[1]!, ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "confirm" }); await validationPromise; @@ -157,7 +160,7 @@ describe("useUnsafeRolesDialog", () => { true, ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "confirm" }); const validationResult = await validationPromise; diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx index c9bae27d..8c0733f1 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx @@ -9,7 +9,10 @@ vi.mock("@core/stores", () => ({ useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })), })), - useMessageStore: vi.fn() + useMessageStore: vi.fn(), + useNodeDBStore: vi.fn(() => ({ + addNodeDB: vi.fn(), + })), })); vi.mock("@core/utils/randId.ts", () => ({ diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx index 153b5b61..b3a40da7 100644 --- a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -1,6 +1,6 @@ import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; -import { mockDeviceStore } from "@core/stores/deviceStore/deviceStore.mock.ts"; -import { useDevice } from "@core/stores"; +import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts"; +import { useNodeDB } from "@core/stores"; import { Protobuf } from "@meshtastic/core"; import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -64,9 +64,10 @@ describe("TraceRoute", () => { ]); beforeEach(() => { + vi.resetAllMocks(); - vi.mocked(useDevice).mockReturnValue({ - ...mockDeviceStore, + vi.mocked(useNodeDB).mockReturnValue({ + ...mockNodeDBStore, getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { return mockNodes.get(nodeNum); }, diff --git a/packages/web/src/core/hooks/useFavoriteNode.test.ts b/packages/web/src/core/hooks/useFavoriteNode.test.ts index 6b2c0591..76063533 100644 --- a/packages/web/src/core/hooks/useFavoriteNode.test.ts +++ b/packages/web/src/core/hooks/useFavoriteNode.test.ts @@ -14,12 +14,19 @@ const mockNode = { const mockUpdateFavorite = vi.fn(); const mockGetNode = vi.fn(() => mockNode); const mockToast = vi.fn(); +const mockSendAdminMessage = vi.fn(); vi.mock("@core/stores", () => ({ - useDevice: () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useNodeDB: () => ({ updateFavorite: mockUpdateFavorite, getNode: mockGetNode, }), + useDevice: () => ({ + sendAdminMessage: mockSendAdminMessage, + }), })); vi.mock("@core/hooks/useToast.ts", () => ({ diff --git a/packages/web/src/core/hooks/useIgnoreNode.test.ts b/packages/web/src/core/hooks/useIgnoreNode.test.ts index 5a7904c8..deb5b143 100644 --- a/packages/web/src/core/hooks/useIgnoreNode.test.ts +++ b/packages/web/src/core/hooks/useIgnoreNode.test.ts @@ -14,12 +14,19 @@ const mockNode = { const mockUpdateIgnore = vi.fn(); const mockGetNode = vi.fn(() => mockNode); const mockToast = vi.fn(); +const mockSendAdminMessage = vi.fn(); vi.mock("@core/stores", () => ({ - useDevice: () => ({ - updateIgnored: mockUpdateIgnore, + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useNodeDB: () => ({ + updateIgnore: mockUpdateIgnore, getNode: mockGetNode, }), + useDevice: () => ({ + sendAdminMessage: mockSendAdminMessage, + }), })); vi.mock("@core/hooks/useToast.ts", () => ({ diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index 63114812..b25e7884 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -23,14 +23,12 @@ export const mockDeviceStore: Device = { hardware: {} as Protobuf.Mesh.MyNodeInfo, metadata: new Map(), traceroutes: new Map(), - nodeErrors: new Map(), connection: undefined, activeNode: 0, waypoints: [], pendingSettingsChanges: false, messageDraft: "", unreadCounts: new Map(), - nodesMap: new Map(), dialog: { import: false, QR: false, @@ -64,30 +62,15 @@ export const mockDeviceStore: Device = { setPendingSettingsChanges: vi.fn(), addChannel: vi.fn(), addWaypoint: vi.fn(), - addNodeInfo: vi.fn(), - addUser: vi.fn(), - addPosition: vi.fn(), addConnection: vi.fn(), addTraceRoute: vi.fn(), addMetadata: vi.fn(), - removeNode: vi.fn(), setDialogOpen: vi.fn(), getDialogOpen: vi.fn().mockReturnValue(false), - processPacket: vi.fn(), setMessageDraft: vi.fn(), - setNodeError: vi.fn(), - clearNodeError: vi.fn(), - getNodeError: vi.fn().mockReturnValue(undefined), - hasNodeError: vi.fn().mockReturnValue(false), incrementUnread: vi.fn(), resetUnread: vi.fn(), - getNodes: vi.fn().mockReturnValue([]), - getNodesLength: vi.fn().mockReturnValue(0), - getNode: vi.fn().mockReturnValue(undefined), - getMyNode: vi.fn(), sendAdminMessage: vi.fn(), - updateFavorite: vi.fn(), - updateIgnored: vi.fn(), addClientNotification: vi.fn(), removeClientNotification: vi.fn(), getClientNotification: vi.fn(), diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index 88e10303..77ddc7e7 100644 --- a/packages/web/src/core/stores/nodeDBStore/index.ts +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -60,18 +60,6 @@ type NodeDBPersisted = { nodeDBs: Map; }; -export function equalBytes(a: Uint8Array, b: Uint8Array): boolean { - if (a.byteLength !== b.byteLength) { - return false; - } - for (let i = 0; i < a.byteLength; i++) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -} - function nodeDBFactory( id: number, get: () => PrivateNodeDBState, diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts new file mode 100644 index 00000000..031ee84d --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts @@ -0,0 +1,30 @@ +import { vi } from "vitest"; +import type { NodeDB } from "./index.ts"; + +/** + * You can spread this base mock in your tests and override only the + * properties relevant to a specific test case. + */ +export const mockNodeDBStore: NodeDB = { + id: 0, + myNodeNum: 0, + nodeErrors: new Map(), + nodeMap: new Map(), + + addNode: vi.fn(), + addUser: vi.fn(), + addPosition: vi.fn(), + removeNode: vi.fn(), + processPacket: vi.fn(), + setNodeError: vi.fn(), + clearNodeError: vi.fn(), + getNodeError: vi.fn().mockReturnValue(undefined), + hasNodeError: vi.fn().mockReturnValue(false), + getNodes: vi.fn().mockReturnValue([]), + getNodesLength: vi.fn().mockReturnValue(0), + getNode: vi.fn().mockReturnValue(undefined), + getMyNode: vi.fn(), + updateFavorite: vi.fn(), + updateIgnore: vi.fn(), + setNodeNum: vi.fn(), +}; diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts new file mode 100644 index 00000000..40660dde --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const idbMem = new Map(); +vi.mock("idb-keyval", () => ({ + get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), + set: vi.fn((key: string, val: string) => { + idbMem.set(key, val); + return Promise.resolve(); + }), + del: vi.fn((k: string) => { + idbMem.delete(k); + return Promise.resolve(); + }), +})); + +// import a fresh copy of the store module (because the store is created at import time) +async function freshStore() { + vi.resetModules(); + const mod = await import("../nodeDBStore"); + return mod; +} + +function makeNode(num: number, extras: Record = {}) { + return { num, ...extras } as any; +} + +describe("NodeDB store", () => { + beforeEach(() => { + idbMem.clear(); + vi.clearAllMocks(); + }); + + it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => { + const { useNodeDBStore } = await freshStore(); + + const db1 = useNodeDBStore.getState().addNodeDB(123); + const db2 = useNodeDBStore.getState().addNodeDB(123); + expect(db1).toBe(db2); + + const got = useNodeDBStore.getState().getNodeDB(123); + expect(got).toBe(db1); + + expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1); + }); + + it("addNode, getNode(s), getNodesLength, removeNode", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + db.addNode(makeNode(10)); + db.addNode(makeNode(11)); + expect(db.getNodesLength()).toBe(2); + expect(db.getNode(10)?.num).toBe(10); + + const all = db.getNodes(); + expect(all.map(n => n.num).sort()).toEqual([10, 11]); + + db.removeNode(10); + expect(db.getNodesLength()).toBe(1); + expect(db.getNode(10)).toBeUndefined(); + }); + + it("processPacket creates or updates a node", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + db.processPacket({ from: 50, time: 1111, snr: 7 } as any); + expect(db.getNode(50)).toBeTruthy(); + expect(db.getNode(50)?.lastHeard).toBe(1111); + expect(db.getNode(50)?.snr).toBe(7); + + db.processPacket({ from: 50, time: 2222, snr: 9 } as any); + expect(db.getNode(50)?.lastHeard).toBe(2222); + expect(db.getNode(50)?.snr).toBe(9); + }); + + it("addUser and addPosition updates existing or creates new nodes", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + // addUser creates node if missing + db.addUser({ from: 77, data: { id: "u" } } as any); + expect(db.getNode(77)?.user).toEqual({ id: "u" }); + + // addPosition updates same node + db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any); + expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 }); + expect(db.getNode(77)?.num).toBe(77); + }); + + it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + db.setNodeError(10, "BadFoo" as any); + expect(db.hasNodeError(10)).toBe(true); + expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" }); + + db.clearNodeError(10); + expect(db.hasNodeError(10)).toBe(false); + expect(db.getNodeError(10)).toBeUndefined(); + }); + + it("getMyNode throws before setNodeNum; works after", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + db.addNode(makeNode(123)); + + expect(() => db.getMyNode()).toThrow(); + db.setNodeNum(123); + + const me = db.getMyNode(); + expect(me.num).toBe(123); + }); + + it("setNodeNum merges with existing DB with same myNodeNum", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + + + const oldDB = st.addNodeDB(10); + oldDB.setNodeNum(999); + oldDB.addNode(makeNode(200)); + oldDB.setNodeError(200, "ERROR" as any); + + const newDB = st.addNodeDB(11); + // newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old + newDB.setNodeNum(999); + + expect(st.getNodeDB(10)).toBeUndefined(); + expect(st.getNodeDB(11)).toBeDefined(); + expect(newDB.getNode(200)).toBeTruthy(); + expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" }); + }); + + it("setNodeNum does not merge when new DB already has nodes; old is removed", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + + const oldDB = st.addNodeDB(10); + oldDB.setNodeNum(999); + oldDB.addNode(makeNode(200)); // old has data + + const newDB = st.addNodeDB(11); + newDB.addNode(makeNode(300)); // new has data -> no merge + newDB.setNodeNum(999); + + expect(st.getNodeDB(10)).toBeUndefined(); // old removed + // new kept its own nodes; did not copy old's + expect(newDB.getNode(300)).toBeTruthy(); + expect(newDB.getNode(200)).toBeUndefined(); + }); + + it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => { + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + const db = st.addNodeDB(123); + db.setNodeNum(321); + db.addNode(makeNode(50)); + db.setNodeError(50, "ERROR" as any); + } + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + const db = st.getNodeDB(123)!; + + // methods should work after rehydrate + expect(db.getNode(50)?.num).toBe(50); + expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" }); + db.addNode(makeNode(51)); + expect(db.getNode(51)).toBeTruthy(); + } + }); + + it("getNodes applies filter and excludes myNodeNum", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + db.setNodeNum(11); + db.addNode(makeNode(10)); + db.addNode(makeNode(11)); + db.addNode(makeNode(12)); + + const all = db.getNodes(); + expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11) + + const filtered = db.getNodes((n) => n.num > 10); + expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11 + }); + + it("when exceeding cap, evicts earliest inserted, not the newly added", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + for (let i = 1; i <= 10; i++) st.addNodeDB(i); + st.addNodeDB(11); + expect(st.getNodeDB(1)).toBeUndefined(); + expect(st.getNodeDB(11)).toBeDefined(); + }); + + it("removeNodeDB persists removal across reload", async () => { + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + st.addNodeDB(99); + expect(st.getNodeDB(99)).toBeDefined(); + st.removeNodeDB(99); + expect(st.getNodeDB(99)).toBeUndefined(); + } + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + expect(st.getNodeDB(99)).toBeUndefined(); // still gone + } + }); + + it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => { + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + + const orphan = st.addNodeDB(500); // no setNodeNum + orphan.addNode(makeNode(1)); + + const good = st.addNodeDB(501); + good.setNodeNum(42); + good.addNode(makeNode(2)); + } + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped + expect(st.getNodeDB(501)).toBeDefined(); // kept + expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy(); + } + }); + + it("methods throw after their DB is removed from the map", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + const db = st.addNodeDB(800); + + st.removeNodeDB(800); + + expect(() => db.getNodesLength()).toThrow(/No nodeDB found/); + expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/); + }); +}); diff --git a/packages/web/src/core/stores/utils/indexDB.test.ts b/packages/web/src/core/stores/utils/indexDB.test.ts new file mode 100644 index 00000000..14abe329 --- /dev/null +++ b/packages/web/src/core/stores/utils/indexDB.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as idb from "idb-keyval"; +import { createStorage } from "./indexDB"; + +type PersistStorage = ReturnType>; + +describe("indexDB.ts persistence (steps 1–5)", () => { + let store: PersistStorage; + + beforeEach(() => { + vi.restoreAllMocks(); + store = createStorage(); + }); + + async function roundTrip(obj: any) { + const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); + const getSpy = vi.spyOn(idb, "get"); + await store.setItem("rt", obj); + const storedStr = (setSpy.mock.calls[0] as any[])[1] as string; + getSpy.mockResolvedValue(storedStr); + return await store.getItem("rt"); + } + + // Basic methods + it("getItem returns null when idb-keyval.get yields undefined", async () => { + const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); + const res = await store.getItem("missing-key"); + expect(getSpy).toHaveBeenCalledWith("missing-key"); + expect(res).toBeNull(); + }); + + it("setItem writes a string via idb-keyval.set", async () => { + const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); + const payload = { state: { a: 1 }, version: 0 }; + await store.setItem("k1", payload); + expect(setSpy).toHaveBeenCalledTimes(1); + const [, value] = setSpy.mock.calls[0]!; + expect(typeof value).toBe("string"); + // sanity: it should be JSON + expect(() => JSON.parse(value as string)).not.toThrow(); + }); + + it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => { + const delSpy = vi.spyOn(idb, "del").mockResolvedValue(); + await store.removeItem("k2"); + expect(delSpy).toHaveBeenCalledWith("k2"); + + const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); + const res = await store.getItem("k2"); + expect(getSpy).toHaveBeenCalledWith("k2"); + expect(res).toBeNull(); + }); + + // Map + it("round-trips an empty Map", async () => { + const out = await roundTrip({ state: { m: new Map() }, version: 0 }); + expect(out?.state.m instanceof Map).toBe(true); + expect(out?.state.m.size).toBe(0); + }); + + it("round-trips a Map and preserves numeric key semantics", async () => { + const m = new Map([[1, "a"]]); + const out = await roundTrip({ state: { m }, version: 0 }); + const m2 = out!.state.m as Map; + expect(m2 instanceof Map).toBe(true); + expect(m2.get(1)).toBe("a"); + }); + + it("round-trips nested Map inside arrays/objects", async () => { + const payload = { + state: { + list: [new Map([[2, 3]])], + obj: { inner: new Map([["k", 7]]) }, + }, + version: 0, + }; + const out = await roundTrip(payload); + + expect(out!.state.list[0] instanceof Map).toBe(true); + expect((out!.state.list[0] as Map).get(2)).toBe(3); + expect(out!.state.obj.inner instanceof Map).toBe(true); + expect((out!.state.obj.inner as Map).get("k")).toBe(7); + }); + + // Uint8Array + it("round-trips a Uint8Array (simple)", async () => { + const u8 = new Uint8Array([1, 2, 255]); + const out = await roundTrip({ state: { u8 }, version: 0 }); + const u2 = out!.state.u8 as Uint8Array; + expect(u2 instanceof Uint8Array).toBe(true); + expect(Array.from(u2)).toEqual([1, 2, 255]); + }); + + it("round-trips a Uint8Array view with non-zero byteOffset", async () => { + const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer; + const view = new Uint8Array(buf, 2, 4); // [8,7,6,5] + const out = await roundTrip({ state: { view }, version: 0 }); + const v2 = out!.state.view as Uint8Array; + expect(v2 instanceof Uint8Array).toBe(true); + expect(Array.from(v2)).toEqual([8, 7, 6, 5]); + // ensure it's a standalone buffer now, without offset + expect(v2.byteOffset).toBe(0); + }); + + // Mixed & nested structures + it("round-trips Map values containing objects with nested Uint8Array", async () => { + const inner = { key: new Uint8Array([7, 8]) }; + const m = new Map([[42, inner]]); + const out = await roundTrip({ state: { m }, version: 0 }); + const m2 = out!.state.m as Map; + expect(m2 instanceof Map).toBe(true); + + const got = m2.get(42)!; + expect(got.key instanceof Uint8Array).toBe(true); + expect(Array.from(got.key)).toEqual([7, 8]); + }); + + it("round-trips deep nesting (array → object → map → u8)", async () => { + const payload = { + state: { + arr: [ + { + obj: { + m: new Map([["k", new Uint8Array([9])]]), + }, + }, + ], + }, + version: 0, + }; + const out = await roundTrip(payload); + const m2 = out!.state.arr[0].obj.m as Map; + expect(m2 instanceof Map).toBe(true); + const u = m2.get("k")!; + expect(u instanceof Uint8Array).toBe(true); + expect(Array.from(u)).toEqual([9]); + }); + + it("does not alter plain objects/arrays", async () => { + const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 }; + const out = await roundTrip(payload); + expect(out).toEqual(payload); + }); + + it("revives envelope-looking objects", async () => { + // Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope. + const forged = JSON.stringify({ + state: { m: { __datatype: "Map", value: [[1, "x"]] } }, + version: 0, + }); + const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged); + const out = await store.getItem("forged"); + expect(getSpy).toHaveBeenCalled(); + const m2 = out!.state.m as Map; + expect(m2 instanceof Map).toBe(true); + expect(m2.get(1)).toBe("x"); + }); +}); \ No newline at end of file