diff --git a/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx b/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx new file mode 100644 index 000000000..b4c573c5f --- /dev/null +++ b/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx @@ -0,0 +1,131 @@ +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