fix: review round 2 — a signed-out tab acts as nobody, honest share-page failures
Deep-review round 2 over main..improvement/watch-background-audio. Account resolution (server) - A request naming an account that is no longer signed in to this browser (X-Siftlode-Account or ?account=) now acts as nobody instead of the session's default account. A tab still pinned to an account signed out in another tab went on acting as the default: its player checkpoints and preference saves landed in that account, the messages socket and Plex playback ran as it, and its Sign out signed THAT account out. A hidden tab never polls /api/me, so a tab playing in the background kept doing it. The /api/me bootstrap probe alone keeps the fallback, which is how such a tab learns who it now is: the app's 401 handler reloads, and BootGate re-pins the tab. BootGate - On an identity change the tab reloads without moving its pin first. The tree being torn down still runs its unmount saves before the page goes, and with the pin already moved they wrote into the new account's local keys (a player volume still pending). The reloaded page corrects the pin. Share page (/watch) - Only a 404 says the link is gone. A 5xx, a dropped connection, and the unlock's own rate limit all rendered "no longer available ... expired or revoked" to a viewer with a valid link, with no way to try again. A load failure now offers Retry; too many password tries says so and keeps the form; an unlock that gets no answer says so instead of doing nothing. Strings in en/hu/de. Downloads - A cookie-authed asset is recognised under the current signature version and the one before it, so a job queued before a deploy that bumps the version still downloads with its cookie instead of failing on the login wall with an error that says no cookie was used. The app chunk is preloaded through lazyWithPreload, so a failed fetch no longer leaves an unhandled rejection beside the ErrorBoundary. The panel layout seed is one helper (seedLayout). Tests: the stale-tab refusal, its harmless Sign out and the probe fallback; the pre-bump authed asset; BootGate keeping the pin on reload and staying mounted through a same-account change; the reveal clock's later-commit hand-off; the prefs, panel layout and demo-scope seeds from the me cache; the share page's failure states. Each mutation-checked RED for its own reason.
This commit is contained in:
@@ -121,3 +121,79 @@ describe("WatchPage media element (S3 · 5a)", () => {
|
||||
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Only a 404 means a link is gone. Every other failure used to render the permanent "no longer
|
||||
// available" state too: a server restart, a dropped connection, and the unlock's own rate limit told a
|
||||
// viewer with a valid link that it had expired or been revoked, with no way to try again.
|
||||
describe("WatchPage honest failures", () => {
|
||||
const res = (status: number, body: unknown = {}) => ({
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
const GONE = "This link is no longer available.";
|
||||
|
||||
it("a server error is a failure with a retry, not a dead link", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-5xx");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(503))
|
||||
.mockResolvedValue(
|
||||
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
|
||||
),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
const retry = await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("a dropped connection is a failure with a retry too", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-offline");
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
});
|
||||
|
||||
it("a 404 says the link is gone", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-404");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(res(404)));
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText(GONE);
|
||||
expect(page.queryByRole("button", { name: "Try again" })).toBeNull();
|
||||
});
|
||||
|
||||
async function tryPassword(second: () => Promise<unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(200, { needs_password: true }))
|
||||
.mockImplementationOnce(second),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
fireEvent.change(await page.findByPlaceholderText("Enter password"), {
|
||||
target: { value: "guess" },
|
||||
});
|
||||
fireEvent.click(page.getByRole("button", { name: "Watch" }));
|
||||
return page;
|
||||
}
|
||||
|
||||
it("too many password tries says so, and keeps the form", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-429");
|
||||
const page = await tryPassword(async () => res(429));
|
||||
await page.findByText("Too many attempts. Try again in a few minutes.");
|
||||
expect(page.getByPlaceholderText("Enter password")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an unlock that gets no answer says so instead of doing nothing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
|
||||
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
|
||||
await page.findByText("Couldn't check the password. Try again.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user