Browse Source

Updated tests

pull/780/head
philon- 11 months ago
parent
commit
27fb2a512a
  1. 58
      packages/web/src/components/Dialog/RebootDialog.test.tsx
  2. 11
      packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  3. 19
      packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  4. 5
      packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
  5. 9
      packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx
  6. 9
      packages/web/src/core/hooks/useFavoriteNode.test.ts
  7. 11
      packages/web/src/core/hooks/useIgnoreNode.test.ts
  8. 17
      packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
  9. 12
      packages/web/src/core/stores/nodeDBStore/index.ts
  10. 30
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
  11. 247
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts
  12. 158
      packages/web/src/core/stores/utils/indexDB.test.ts

58
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(<RebootDialog open onOpenChange={() => {}} />);
// 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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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();
});

11
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(
<DeviceContext.Provider value={currentDeviceState}>
<CurrentDeviceContext.Provider value={{ deviceId }}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>,
</CurrentDeviceContext.Provider>,
);
expect(container.firstChild).toBeNull();

19
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;

5
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", () => ({

9
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);
},

9
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", () => ({

11
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", () => ({

17
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(),

12
packages/web/src/core/stores/nodeDBStore/index.ts

@ -60,18 +60,6 @@ type NodeDBPersisted = {
nodeDBs: Map<number, NodeDBData>;
};
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,

30
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(),
};

247
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<string, string>();
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<string, any> = {}) {
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/);
});
});

158
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<T> = ReturnType<typeof createStorage<T>>;
describe("indexDB.ts persistence (steps 1–5)", () => {
let store: PersistStorage<any>;
beforeEach(() => {
vi.restoreAllMocks();
store = createStorage<any>();
});
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<number,string> and preserves numeric key semantics", async () => {
const m = new Map<number, string>([[1, "a"]]);
const out = await roundTrip({ state: { m }, version: 0 });
const m2 = out!.state.m as Map<number, string>;
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<number, number>([[2, 3]])],
obj: { inner: new Map<string, number>([["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<number, number>).get(2)).toBe(3);
expect(out!.state.obj.inner instanceof Map).toBe(true);
expect((out!.state.obj.inner as Map<string, number>).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<number, { key: Uint8Array }>([[42, inner]]);
const out = await roundTrip({ state: { m }, version: 0 });
const m2 = out!.state.m as Map<number, { key: Uint8Array }>;
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<string, Uint8Array>([["k", new Uint8Array([9])]]),
},
},
],
},
version: 0,
};
const out = await roundTrip(payload);
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>;
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<number, string>;
expect(m2 instanceof Map).toBe(true);
expect(m2.get(1)).toBe("x");
});
});
Loading…
Cancel
Save