// @vitest-environment happy-dom // Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.test.ts // so React doesn't log act() warnings about the createRoot render/unmount // and the popstate-driven state updates. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { useAckDrawerUrlState } from "./useAckDrawerUrlState"; /** * Minimal renderHook shim — same pattern as the other hook tests. */ function renderHook(setup: () => TResult): { result: { current: TResult | undefined }; unmount: () => void; } { const result: { current: TResult | undefined } = { current: undefined }; const container = document.createElement("div"); document.body.appendChild(container); function Probe() { result.current = setup(); return null; } const root: Root = createRoot(container); act(() => { root.render(React.createElement(Probe)); }); return { result, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** * Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a * writable `window.location.search`, but `window.happyDOM.setURL` updates * the URL the window reports without triggering a navigation — exactly * what we want for mounting the hook at `/acks?ack=42` etc. */ function setLocation(url: string): void { (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); } describe("useAckDrawerUrlState", () => { type PushState = (state: unknown, unused: string, url?: string | URL | null) => void; let pushStateMock: ReturnType>; let replaceStateMock: ReturnType>; beforeEach(() => { pushStateMock = vi.fn(); replaceStateMock = vi.fn(); vi.stubGlobal("history", { pushState: pushStateMock, replaceState: replaceStateMock, state: null, }); }); afterEach(() => { vi.unstubAllGlobals(); setLocation("http://localhost/"); }); it("reads the ?ack= param from window.location.search on mount", () => { setLocation("http://localhost/acks?ack=42"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); expect(result.current?.ackId).toBe("42"); expect(typeof result.current?.open).toBe("function"); expect(typeof result.current?.close).toBe("function"); expect(typeof result.current?.setAckId).toBe("function"); unmount(); }); it("returns null ackId when no ?ack= param is set", () => { setLocation("http://localhost/acks"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); expect(result.current?.ackId).toBeNull(); unmount(); }); it("returns null ackId when ?ack= is present but empty", () => { setLocation("http://localhost/acks?ack="); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); expect(result.current?.ackId).toBeNull(); unmount(); }); it("open(id) pushes a new history entry containing ?ack=ID", () => { setLocation("http://localhost/acks"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); act(() => { result.current?.open("42"); }); expect(pushStateMock).toHaveBeenCalledTimes(1); expect(replaceStateMock).not.toHaveBeenCalled(); const urlArg = pushStateMock.mock.calls[0][2] as string; expect(urlArg).toContain("?ack=42"); unmount(); }); it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => { setLocation("http://localhost/acks?ack=42"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); act(() => { result.current?.setAckId("43"); }); expect(replaceStateMock).toHaveBeenCalledTimes(1); expect(pushStateMock).not.toHaveBeenCalled(); const urlArg = replaceStateMock.mock.calls[0][2] as string; expect(urlArg).toContain("?ack=43"); unmount(); }); it("open() and close() preserve other query params (only ?ack= is touched)", () => { setLocation("http://localhost/acks?sort=date&ack=42"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); act(() => { result.current?.open("43"); }); const openUrl = pushStateMock.mock.calls[0][2] as string; expect(openUrl).toContain("sort=date"); expect(openUrl).toMatch(/[?&]ack=43/); act(() => { result.current?.close(); }); const closeUrl = pushStateMock.mock.calls[1][2] as string; expect(closeUrl).toContain("sort=date"); expect(closeUrl).not.toContain("ack="); unmount(); }); it("close() pushes a new history entry with the ?ack= param stripped", () => { setLocation("http://localhost/acks?ack=42"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); act(() => { result.current?.close(); }); expect(pushStateMock).toHaveBeenCalledTimes(1); expect(replaceStateMock).not.toHaveBeenCalled(); const urlArg = pushStateMock.mock.calls[0][2] as string; expect(urlArg).not.toContain("?ack="); expect(result.current?.ackId).toBeNull(); unmount(); }); it("updates ackId in response to popstate (browser back/forward)", async () => { setLocation("http://localhost/acks?ack=42"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); expect(result.current?.ackId).toBe("42"); setLocation("http://localhost/acks?ack=43"); await act(async () => { window.dispatchEvent(new PopStateEvent("popstate")); await Promise.resolve(); }); expect(result.current?.ackId).toBe("43"); unmount(); }); it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => { // The acks drawer is independent of the claim/remit/provider // drawers — opening an ack must leave the others intact so a user // can deep-link to multiple states simultaneously (though the UI // currently only shows one drawer at a time, the URL params don't // know that). setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062"); const { result, unmount } = renderHook(() => useAckDrawerUrlState()); act(() => { result.current?.open("42"); }); const openUrl = pushStateMock.mock.calls[0][2] as string; expect(openUrl).toContain("claim=CLM-1"); expect(openUrl).toContain("remit=REM-1"); expect(openUrl).toContain("provider=1881068062"); expect(openUrl).toMatch(/[?&]ack=42/); unmount(); }); });