// @vitest-environment jsdom // // BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no // pinned account in sessionStorage) built the app for "no account" first — so with Settings → // Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and // landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the // account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right. // // Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe // records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted. import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import BootGate from "./BootGate"; import { NavigationProvider } from "./NavigationProvider"; import { useNavigation } from "./navigationContext"; import { api, type Me } from "../lib/api"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; import i18n from "../i18n"; const renders: { page: string; lang: string }[] = []; let mounts = 0; const countMount = () => { mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one }; function Probe() { const { page } = useNavigation(); const { i18n: inst } = useTranslation(); renders.push({ page, lang: inst.language }); useEffect(countMount, []); return
{page}
; } function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); return qc; } const account = (over: Partial = {}) => ({ id: 5, preferences: {}, ...over }) as Me; let savePrefs: MockInstance; const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") }); beforeEach(async () => { localStorage.clear(); sessionStorage.clear(); // a NEW tab: nothing pinned yet window.history.replaceState(null, "", "/"); renders.length = 0; mounts = 0; await i18n.changeLanguage("en"); vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true }); savePrefs = vi.spyOn(api, "savePrefs").mockResolvedValue({ ok: true } as never); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("BootGate", () => { it("a new tab with Startup off opens on the page the account left — never on Home first", async () => { localStorage.setItem("siftlode.startOnHome.5", "0"); localStorage.setItem("siftlode.page.5", "downloads"); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); expect((await screen.findByTestId("app")).textContent).toBe("downloads"); expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads")); expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads"); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned }); it("renders the app in the account's language from its first render", async () => { vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } })); mount(); await screen.findByTestId("app"); expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu")); }); it("an account with no language stored stores the one it is shown in, once", async () => { await i18n.changeLanguage("hu"); // the browser's language, never saved vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); await waitFor(() => expect(savePrefs).toHaveBeenCalledWith({ language: "hu" })); expect(savePrefs).toHaveBeenCalledTimes(1); // The server now sorts in Hungarian, and so does what reads the stored language. await waitFor(() => expect(qc.getQueryData(qk.me())?.preferences.language).toBe("hu")); }); it("an account with its language stored stores nothing", async () => { vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } })); mount(); await screen.findByTestId("app"); expect(savePrefs).not.toHaveBeenCalled(); }); it("mounts nothing of the app while the identity is still unknown", async () => { vi.spyOn(api, "me").mockReturnValue(new Promise(() => {})); mount(); await act(async () => {}); // the setup probe resolves; `me` never does expect(screen.queryByTestId("app")).toBeNull(); expect(renders).toHaveLength(0); }); it("signed out: mounts the app (App shows the landing) without pinning an account", async () => { vi.spyOn(api, "me").mockResolvedValue(null); mount(); await screen.findByTestId("app"); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull(); }); it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => { vi.mocked(api.setupStatus).mockResolvedValue({ configured: false }); const me = vi.spyOn(api, "me").mockResolvedValue(account()); mount(); await screen.findByTestId("app"); expect(me).not.toHaveBeenCalled(); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull(); }); it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => { vi.mocked(api.setupStatus) .mockRejectedValueOnce(new Error("502")) .mockResolvedValue({ configured: true }); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); fireEvent.click(await retryButton()); await screen.findByTestId("app"); expect(renders.length).toBeGreaterThan(0); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account }); it("an unanswered first account probe is an error with a retry", async () => { vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account()); mount(); fireEvent.click(await retryButton()); await screen.findByTestId("app"); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); }); it("a background refetch that fails keeps the app mounted, as it was", async () => { const me = vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); me.mockRejectedValue(new Error("502")); await act(() => qc.refetchQueries({ queryKey: qk.me() })); // react-query hands the error to its observers on a timer: let that render land before asserting. await act(() => new Promise((r) => setTimeout(r, 20))); expect(qc.getQueryState(qk.me())?.status).toBe("error"); expect(screen.getByTestId("app")).toBeTruthy(); expect(mounts).toBe(1); }); it("a pin naming another account than the server answered as is corrected before the app reads it", async () => { // Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5. sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9"); localStorage.setItem("siftlode.startOnHome.5", "0"); localStorage.setItem("siftlode.page.5", "downloads"); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); expect((await screen.findByTestId("app")).textContent).toBe("downloads"); expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads")); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); }); it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => { const me = vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); const reload = vi.fn(); vi.stubGlobal("location", { ...window.location, reload }); me.mockResolvedValue(null); // the session ended await act(() => qc.refetchQueries({ queryKey: qk.me() })); await waitFor(() => expect(reload).toHaveBeenCalled()); }); it("an identity change reloads without moving the pin first", async () => { // The tree being torn down still runs its unmount saves before the page goes; with the pin already // moved they would land under the NEW account's local keys (a player volume still pending, say). const me = vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); const reload = vi.fn(); vi.stubGlobal("location", { ...window.location, reload }); me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6 await act(() => qc.refetchQueries({ queryKey: qk.me() })); await waitFor(() => expect(reload).toHaveBeenCalled()); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); }); // The adoption effect is idempotent for the identity it already adopted, and that is load-bearing // rather than tidy: a re-run reloads the page, so an effect that cannot tell "already adopted this // account" from "adopted a different one" turns an ordinary boot into a reload loop. The spy is // installed BEFORE mounting on purpose — the other reload tests stub it after the app is up, which // is exactly late enough to miss the re-run that setting `readyFor` triggers. it("a plain boot never reloads, however many times the adoption effect re-runs", async () => { const reload = vi.fn(); vi.stubGlobal("location", { ...window.location, reload }); vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); // Anything that re-renders the gate must leave it alone: a pref save, a background refetch. act(() => { qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })); }); await act(() => new Promise((r) => setTimeout(r, 20))); expect(reload).not.toHaveBeenCalled(); expect(mounts).toBe(1); }); it("a change to the account's data, not its identity, leaves the app as it is", async () => { vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); const reload = vi.fn(); vi.stubGlobal("location", { ...window.location, reload }); // What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account. act(() => { qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })); }); await act(() => new Promise((r) => setTimeout(r, 20))); expect(reload).not.toHaveBeenCalled(); expect(mounts).toBe(1); expect(i18n.language).toBe("en"); // the language is adopted once, never over the user's own choice }); });