import type { FC, PropsWithChildren } from "react"; import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import * as sharedApi from "@/shared/api"; import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AuthProvider } from "./auth"; import { useAuth } from "./auth-context"; const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { params: { query: { includeArchived: "false" } }, }).queryKey; function createAuthWrapper( queryClient: ReturnType, ): FC { return props => ( {props.children} ); } async function expireSession() { server.use(http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), { once: true })); try { await apiClient.GET("/api/v1/brokerage-accounts"); } catch (error) { // A refresh that changes identity cancels the original user's request. if (!(error instanceof DOMException && error.name === "AbortError")) throw error; } } async function requestAuthorization() { let authorization: null | string = null; server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => { authorization = request.headers.get("Authorization"); return HttpResponse.json(resource([])); }, { once: true })); await apiClient.GET("/api/v1/brokerage-accounts"); return authorization; } describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { let authorization: null | string = null; server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "restored-token", user: { email: "user@example.com", id: "user-1" }, }), ), http.get("*/api/v1/brokerage-accounts", ({ request }) => { authorization = request.headers.get("Authorization"); return HttpResponse.json(resource([])); }), ); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); const { rerender, result } = renderHook( () => ({ ...useAuth(), queryClient: useQueryClient() }), { wrapper }, ); await waitFor(() => expect(result.current.ready).toBe(true)); await apiClient.GET("/api/v1/brokerage-accounts"); expect(authorization).toBe("Bearer restored-token"); queryClient.setQueryData(["rerender-proof"], { value: "preserved" }); rerender(); expect(result.current.queryClient).toBe(queryClient); expect(result.current.queryClient.getQueryData(["rerender-proof"])).toEqual( { value: "preserved" }, ); }); it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => { server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), )); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); expect(result.current.user).toBeUndefined(); expect(result.current.unavailable).toBe(false); expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); it.each([ ["a transport error", () => HttpResponse.error()], ["a 502 from the proxy", () => new HttpResponse(null, { status: 502 })], ["a 503 problem", () => HttpResponse.json({ code: "INTERNAL_SERVER_ERROR" }, { status: 503 })], ])("settles as unavailable and keeps cached data when cold-start refresh gets %s", async (_name, failure) => { server.use(http.post("*/api/v1/auth/refresh", failure)); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "kept" }); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); expect(result.current.user).toBeUndefined(); expect(result.current.unavailable).toBe(true); expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "kept" }); }); it("restores the session when retry succeeds after an outage", async () => { let isOutage = true; server.use(http.post("*/api/v1/auth/refresh", () => isOutage ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ accessToken: "restored-token", user: { email: "user@example.com", id: "user-1" }, }), )); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.unavailable).toBe(true)); isOutage = false; act(() => result.current.retry()); await waitFor(() => expect(result.current.user?.id).toBe("user-1")); expect(result.current.unavailable).toBe(false); expect(await requestAuthorization()).toBe("Bearer restored-token"); }); it("keeps the original protected 401 and the session when serialized refresh has a transport error", async () => { let refreshCalls = 0; server.use( http.post("*/api/v1/auth/refresh", () => { refreshCalls += 1; return refreshCalls === 1 ? HttpResponse.json({ accessToken: "restored-token", user: { email: "user@example.com", id: "user-1" }, }) : HttpResponse.error(); }), http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), ), ); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true), ); const response = await apiClient.GET("/api/v1/brokerage-accounts"); expect(response.response.status).toBe(401); // An unreachable server does not prove the session ended; the next 401 refreshes again. await waitFor(() => expect(result.current.unavailable).toBe(true)); expect(result.current.user?.id).toBe("user-1"); expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" }); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]); }); it("clears connection status and financial cache independently on logout", async () => { server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "test-token", user: { email: "user@example.com", id: "user-1" }, }), ), ); const queryClient = createTestQueryClient(); const statusKey = apiQueryClient.queryOptions( "get", "/api/v1/tbank-connection", ).queryKey; const portfolioKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "account-1" } } }, ).queryKey; queryClient.setQueryData(statusKey, { connected: true, connectedAt: "2026-08-14T00:00:00Z", }); queryClient.setQueryData(portfolioKey, { source: "current-connection" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); expect(queryClient.getQueryData(bankCardsKey)).toEqual([ { id: "bank-card-1" }, ]); await act(() => result.current.logout()); expect(result.current.user).toBeUndefined(); expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); it.each([ [false, false], [true, false], [false, true], [true, true], ])( "serializes late refresh (failure=%s, login=%s)", async (failure, loginAfterLogout) => { const { promise: pending, resolve: release } = Promise.withResolvers(); let calls = 0; const order: string[] = []; server.use( http.post("*/api/v1/auth/refresh", async () => { if (++calls > 1) { order.push("refresh-start"); await pending; order.push("refresh-cookie"); } return failure && calls > 1 ? HttpResponse.error() : HttpResponse.json({ accessToken: "a", user: { email: "a@example.com", id: "a" }, }); }), http.post("*/api/v1/auth/logout", () => { order.push("logout-cookie"); return new HttpResponse(null, { status: 204 }); }), http.post("*/api/v1/auth/login", () => { order.push("login-cookie"); return HttpResponse.json({ accessToken: "b", user: { email: "b@example.com", id: "b" }, }); }), http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), ), ); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); const oldRequest = apiClient .GET("/api/v1/brokerage-accounts") // eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query. .catch(() => {}); await waitFor(() => expect(order).toContain("refresh-start")); let logout!: Promise; act(() => { logout = result.current.logout(); }); await waitFor(() => expect(result.current.user).toBeUndefined()); let login!: Promise; act(() => { login = loginAfterLogout ? result.current.login("b@example.com", "password") : Promise.resolve(); }); await act(async () => { release(); await Promise.all([oldRequest, logout, login]); }); expect( result.current.user?.id ?? null, ).toBe( loginAfterLogout ? "b" : null, ); expect(order).toEqual([ "refresh-start", "refresh-cookie", "logout-cookie", ...(loginAfterLogout ? ["login-cookie"] : []), ]); }, ); it("preserves same-user cache and removes inactive and pending data on refresh identity change", async () => { let identity = "a"; server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: identity, user: { email: `${identity}@example.com`, id: identity }, }), ), ); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); await act(() => expireSession()); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); let release!: (value: string) => void; const pending = queryClient .query({ queryFn: () => { const deferred = Promise.withResolvers(); release = deferred.resolve; return deferred.promise; }, queryKey: ["pending-finance"], }) // eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query. .catch(() => {}); identity = "b"; await act(() => expireSession()); release("a-data"); await pending; expect(result.current.user?.id).toBe("b"); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(["pending-finance"])).toBeUndefined(); }); it("replaces an observed financial query when refresh changes identity", async () => { let identity = "a"; server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: identity, user: { email: `${identity}@example.com`, id: identity }, }), ), http.get("*/api/v1/bank-cards", () => HttpResponse.json([{ id: `${identity}-card` }]), ), ); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook( () => ({ auth: useAuth(), cards: useAuthenticatedQuery( apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { params: { query: { includeArchived: "false" } }, }), ), }), { wrapper }, ); await waitFor(() => expect(result.current.cards.data?.[0]?.id).toBe("a-card"), ); identity = "b"; await act(() => expireSession()); await waitFor(() => expect(result.current.cards.data?.[0]?.id).toBe("b-card"), ); }); it("registers auth handlers once until unmount", async () => { const configure = vi.spyOn(sharedApi, "configureAuthClient"); onTestFinished(() => configure.mockRestore()); const { rerender, result, unmount } = renderHook(useAuth, { wrapper: createAuthWrapper(createTestQueryClient()), }); await waitFor(() => expect(result.current.ready).toBe(true)); const registrations = configure.mock.calls.length; rerender(); expect(configure).toHaveBeenCalledTimes(registrations); unmount(); expect(configure).toHaveBeenCalledTimes(registrations + 1); expect(configure).toHaveBeenLastCalledWith(); }); });