import { afterEach, describe, expect, it, rs } from "@rstest/core"; import { cleanup, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner"; import { AuthProvider } from "@/core/auth/AuthProvider"; // AuthProvider pulls useRouter/usePathname from next/navigation. Hand it a // no-op router so logout()'s `router.push("/")` stays inert under happy-dom // instead of trying to drive a real Next.js router. rs.mock("next/navigation", () => ({ useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }), usePathname: () => "/workspace", })); // Force the SPA (non-static) branch of AuthProvider.logout — that is the // branch that actually performs the POST that issue #3001 is about. The // static branch would short-circuit into a `router.push("/")` and never // touch the network, masking the very regression we need to lock down. rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false, })); // Keep the banner's i18n lookup hermetic — real translations are not // relevant here, we just need a stable accessible name for the button. rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ locale: "en-US", t: { workspace: { logout: "Log out", gatewayUnavailable: "Gateway unavailable.", gatewayUnavailableRetrying: "Retrying…", }, }, changeLocale: rs.fn(), }), })); afterEach(() => { rs.restoreAllMocks(); cleanup(); }); /** * Install a fetch double that keeps the gateway "unavailable" for the * banner's `/auth/me` probe (so the banner stays mounted and the recovery * button stays actionable) while recording every call so the logout * request can be inspected. Returns the captured calls for assertions. */ function installFetch(): Array<{ url: string; method: string }> { const calls: Array<{ url: string; method: string }> = []; rs.spyOn(globalThis, "fetch").mockImplementation( (input: RequestInfo | URL, init?: RequestInit) => { // `input` may be a Request object whose default stringification would // yield "[object Request]" — normalise to the URL string either way. const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const method = (init?.method ?? "GET").toUpperCase(); calls.push({ url, method }); if (url.includes("/auth/logout")) { return Promise.resolve( new Response(JSON.stringify({ message: "Successfully logged out" }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); } // `/auth/me` probe — return a transient failure so `classifyProbe` // yields "transient" and the banner keeps showing the logout button. return Promise.resolve( new Response("service unavailable", { status: 503 }), ); }, ); return calls; } function renderBanner() { return render( , ); } describe("GatewayOfflineBanner logout recovery (#3001)", () => { it("renders a