import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { adminKeys, catalogKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys"; import { invalidateCatalogState } from "./realtimeCatalogInvalidation"; import { buildEventsUrl, RealtimeEventsProvider } from "./RealtimeEventsProvider"; const mockState = vi.hoisted(() => ({ user: { id: 1, username: "admin", email: "admin@example.com", role: "admin", permissions: [], download_allowed: true, }, pageActivity: { isVisible: true, isFocused: true, isFrozen: false, canPollDashboard: true, canApplyRealtimeUpdates: true, }, })); vi.mock("@/hooks/useAuth", () => { const useAuth = () => ({ user: mockState.user, profile: null, }); return { useAuth, useOptionalAuth: useAuth }; }); vi.mock("@/hooks/usePageActivity", () => ({ usePageActivity: () => mockState.pageActivity, })); vi.mock("react-router", () => ({ useLocation: () => ({ pathname: "/" }), })); class FakeWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSED = 3; static instances: FakeWebSocket[] = []; onopen: (() => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; onclose: (() => void) | null = null; readyState = FakeWebSocket.CONNECTING; constructor(public url: string) { FakeWebSocket.instances.push(this); } send() {} close() { this.readyState = FakeWebSocket.CLOSED; } emitClose() { this.readyState = FakeWebSocket.CLOSED; this.onclose?.(); } } describe("buildEventsUrl", () => { it("includes auth token and websocket scheme", () => { expect( buildEventsUrl("token-123", { protocol: "https:", host: "example.com", }), ).toBe("wss://example.com/api/v1/events/ws?token=token-123"); }); it("omits the query string when no token is available", () => { expect( buildEventsUrl(null, { protocol: "http:", host: "localhost:5173", }), ).toBe("ws://localhost:5173/api/v1/events/ws"); }); }); describe("invalidateCatalogState", () => { it("invalidates library lists for a scoped library change", async () => { const queryClient = new QueryClient(); const otherCatalogKey = catalogKeys.list({ source: "section", scope: "library", section_id: "all", library_id: 1, limit: 60, offset: 0, }); const changedCatalogKey = catalogKeys.list({ source: "section", scope: "library", section_id: "all", library_id: 3, limit: 60, offset: 0, }); const otherSectionKey = sectionKeys.libraryLayout(1); const changedSectionKey = sectionKeys.libraryLayout(3); const userLibrariesKey = libraryKeys.user("profile-1"); queryClient.setQueryData(adminKeys.libraries(), []); queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []); queryClient.setQueryData(userLibrariesKey, []); queryClient.setQueryData(otherCatalogKey, { items: [] }); queryClient.setQueryData(changedCatalogKey, { items: [] }); queryClient.setQueryData(otherSectionKey, { sections: [] }); queryClient.setQueryData(changedSectionKey, { sections: [] }); invalidateCatalogState(queryClient, { libraryId: 3, allowDashboardRefetch: false }); await Promise.resolve(); expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(true); expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe( true, ); expect(queryClient.getQueryState(userLibrariesKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(otherCatalogKey)?.isInvalidated).toBe(false); expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(otherSectionKey)?.isInvalidated).toBe(false); expect(queryClient.getQueryState(changedSectionKey)?.isInvalidated).toBe(true); }); it("can skip library lists for item-scoped catalog changes", async () => { const queryClient = new QueryClient(); const changedCatalogKey = catalogKeys.list({ source: "section", scope: "library", section_id: "all", library_id: 3, limit: 60, offset: 0, }); queryClient.setQueryData(adminKeys.libraries(), []); queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []); queryClient.setQueryData(libraryKeys.all, []); queryClient.setQueryData(changedCatalogKey, { items: [] }); invalidateCatalogState(queryClient, { itemId: "item-1", libraryId: 3, allowDashboardRefetch: false, includeLibraryLists: false, }); await Promise.resolve(); expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(false); expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe( false, ); expect(queryClient.getQueryState(libraryKeys.all)?.isInvalidated).toBe(false); expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true); }); }); describe("RealtimeEventsProvider", () => { beforeEach(() => { FakeWebSocket.instances = []; vi.useFakeTimers(); vi.stubGlobal("WebSocket", FakeWebSocket); mockState.pageActivity = { isVisible: true, isFocused: true, isFrozen: false, canPollDashboard: true, canApplyRealtimeUpdates: true, }; }); afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); it("ignores stale close events from intentionally closed sockets", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const view = render(
, ); expect(FakeWebSocket.instances).toHaveLength(1); const firstSocket = FakeWebSocket.instances[0]; act(() => { mockState.pageActivity = { ...mockState.pageActivity, canApplyRealtimeUpdates: false, }; view.rerender(
, ); }); act(() => { mockState.pageActivity = { ...mockState.pageActivity, canApplyRealtimeUpdates: true, }; view.rerender(
, ); }); expect(FakeWebSocket.instances).toHaveLength(2); act(() => { firstSocket?.emitClose(); vi.advanceTimersByTime(1_000); }); expect(FakeWebSocket.instances).toHaveLength(2); }); });