Files
kandev/apps/web/hooks/use-queue-pinned.test.ts
T

175 lines
6.3 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useQueuePinned, QUEUE_PIN_SYNC_EVENT, queuePinStorageKey } from "./use-queue-pinned";
import { makeLocalStorageMock } from "@/hooks/local-storage-mock.test-helpers";
const SESSION_A = "sess-a";
const SESSION_B = "sess-b";
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
configurable: true,
});
describe("useQueuePinned", () => {
beforeEach(() => {
localStorageMock.clear();
});
afterEach(() => {
localStorageMock.clear();
});
it("defaults to false when no pin entry exists for the session", () => {
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(false);
});
it('reads the literal string "true" only', () => {
window.localStorage.setItem(queuePinStorageKey(SESSION_A), "true");
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(true);
window.localStorage.setItem(queuePinStorageKey(SESSION_A), "1");
const { result: off } = renderHook(() => useQueuePinned(SESSION_A));
expect(off.current.value).toBe(false);
});
it("keeps pins isolated per session", () => {
window.localStorage.setItem(queuePinStorageKey(SESSION_A), "true");
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(true);
const { result: other } = renderHook(() => useQueuePinned(SESSION_B));
expect(other.current.value).toBe(false);
});
it("setValue persists to the session-scoped key and updates state", async () => {
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(false);
act(() => result.current.setValue(true));
await waitFor(() => expect(result.current.value).toBe(true));
expect(window.localStorage.getItem(queuePinStorageKey(SESSION_A))).toBe("true");
act(() => result.current.setValue(false));
await waitFor(() => expect(result.current.value).toBe(false));
expect(window.localStorage.getItem(queuePinStorageKey(SESSION_A))).toBe("false");
});
it("propagates pin changes dispatched via the sync event", async () => {
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(false);
act(() => {
window.localStorage.setItem(queuePinStorageKey(SESSION_A), "true");
window.dispatchEvent(new Event(QUEUE_PIN_SYNC_EVENT));
});
await waitFor(() => expect(result.current.value).toBe(true));
});
it("ignores sync events for other storage keys", () => {
window.localStorage.setItem("kandev:unrelated:key:v1", "true");
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(false);
act(() => {
window.dispatchEvent(new Event("kandev:unrelated:changed"));
});
expect(result.current.value).toBe(false);
});
it("degrades to false for a null session without touching storage", () => {
const getItem = vi.spyOn(localStorageMock, "getItem");
const { result } = renderHook(() => useQueuePinned(null));
expect(result.current.value).toBe(false);
expect(getItem).not.toHaveBeenCalled();
});
it("setValue is a no-op for a null session", () => {
const setItem = vi.spyOn(localStorageMock, "setItem");
setItem.mockClear();
const { result } = renderHook(() => useQueuePinned(null));
act(() => result.current.setValue(true));
expect(setItem).not.toHaveBeenCalled();
});
it("degrades to false when localStorage.getItem throws", () => {
const original = localStorageMock.getItem;
localStorageMock.getItem = () => {
throw new Error("quota exceeded");
};
try {
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(false);
} finally {
localStorageMock.getItem = original;
}
});
it("throws instead of reporting a successful save when localStorage.setItem fails", () => {
const original = localStorageMock.setItem;
localStorageMock.setItem = () => {
throw new Error("quota exceeded");
};
try {
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(() => result.current.setValue(true)).toThrow();
expect(result.current.value).toBe(false);
} finally {
localStorageMock.setItem = original;
}
});
});
describe("useQueuePinned toggle", () => {
beforeEach(() => {
localStorageMock.clear();
});
it("flips the persisted value", async () => {
const { result } = renderHook(() => useQueuePinned(SESSION_A));
expect(result.current.value).toBe(false);
act(() => result.current.toggle());
await waitFor(() => expect(result.current.value).toBe(true));
expect(window.localStorage.getItem(queuePinStorageKey(SESSION_A))).toBe("true");
act(() => result.current.toggle());
await waitFor(() => expect(result.current.value).toBe(false));
});
it("is a no-op for a null session", () => {
const setItem = vi.spyOn(localStorageMock, "setItem");
setItem.mockClear();
const { result } = renderHook(() => useQueuePinned(null));
act(() => result.current.toggle());
expect(setItem).not.toHaveBeenCalled();
});
it("keeps the current-view pin active when persistence fails", async () => {
const original = localStorageMock.setItem;
const { result } = renderHook(() => useQueuePinned(SESSION_A));
localStorageMock.setItem = () => {
throw new Error("quota exceeded");
};
try {
expect(() => act(() => result.current.toggle())).not.toThrow();
// The in-memory fallback flips the current view even though nothing
// was persisted.
expect(result.current.value).toBe(true);
expect(window.localStorage.getItem(queuePinStorageKey(SESSION_A))).toBeNull();
} finally {
localStorageMock.setItem = original;
}
// A later successful write clears the volatile fallback and persists.
act(() => result.current.toggle());
await waitFor(() => expect(result.current.value).toBe(false));
expect(window.localStorage.getItem(queuePinStorageKey(SESSION_A))).toBe("false");
});
});