The suite ran only as an admin, so nothing end to end held what a member sees. A second seeded account, a member with its own session, now walks every rail page (no admin entry, no admin call, no error state) and follows links to the admin pages. It found one: a member's link to an admin page showed the feed with no rail row current, and the address bar kept naming the admin page — only the content fell back. The navigation state now derives the page on screen through each module's gate (reachablePage), so the rail, the content, the tab title and the address bar agree, on a link, a Back, and a role taken away mid-session.
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
// @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 <div data-testid="app">{page}</div>;
|
|
}
|
|
|
|
function mount() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<BootGate>
|
|
<NavigationProvider>
|
|
<Probe />
|
|
</NavigationProvider>
|
|
</BootGate>
|
|
</QueryClientProvider>,
|
|
);
|
|
return qc;
|
|
}
|
|
// On a Plex-enabled instance: the remembered page these cases restore is the Plex module, which only
|
|
// an instance with Plex lets an account open.
|
|
const account = (over: Partial<Me> = {}) =>
|
|
({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me;
|
|
let savePrefs: MockInstance<typeof api.savePrefs>;
|
|
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", "plex");
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
mount();
|
|
|
|
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
|
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
|
|
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<Me>(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<Me | null>(() => {}));
|
|
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", "plex");
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
mount();
|
|
|
|
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
|
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
|
|
});
|
|
});
|