import { act, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { apiClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { type Portfolio, portfolioQueryOptions, usePortfolio, } from "./usePortfolio"; const snapshot: Portfolio = { cashBalances: [], isStale: false, lastSuccessfulAt: "2026-08-14T00:00:00Z", positions: [], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }], }; function auth() { configureAuthClient({ accessToken: () => "synthetic", clear: vi.fn(), refresh: () => Promise.resolve(true), }); } describe("persistent resource query lifecycle", () => { it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => { auth(); const modes: (null | string)[] = []; let observations = 0; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", ({ request }) => { const mode = new URL(request.url).searchParams.get("sync"); modes.push(mode); if (mode) observations += 1; return HttpResponse.json( resource(snapshot, { stateVersion: observations > 1 ? "new" : "old", }), ); }, ), ); const { queryClient, wrapper } = createQueryWrapper(); const { result } = renderHook(() => usePortfolio("a"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); await act(() => result.current.observe("new")); await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); expect(modes).toEqual([null, "observe", "observe"]); expect(queryClient.getQueryCache().getAll()).toHaveLength(1); expect( queryClient.getQueryData(portfolioQueryOptions("a").queryKey), ).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } }); }); it("does not convert missing or failed source data to empty financial success", async () => { auth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json( resource(null, { sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed", }, }), ), ), ); const { result } = renderHook(() => usePortfolio("a"), { wrapper: createQueryWrapper().wrapper, }); await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); expect(result.current.data).toBeUndefined(); expect(result.current.isSuccess).toBe(false); expect(result.current.status).toBe("pending"); expect(result.current.isPending).toBe(true); expect(result.current.isLoading).toBe(false); expect(result.current.transport).toEqual( { error: null, fetchStatus: "idle", status: "success", }, ); expect(result.current.meta?.availability).toBe("missing"); }); it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { auth(); let isReady = false; const refreshed = resource(snapshot, { stateVersion: "ready" }); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(isReady ? refreshed : resource(null)), ), ); const { result } = renderHook(() => usePortfolio("a"), { wrapper: createQueryWrapper().wrapper, }); await waitFor(() => expect(result.current.transport.status).toBe("success"), ); await act(async () => { const missing = await result.current.refetch(); expect(missing.status).toBe("success"); expect(missing.data).toEqual(resource(null)); expect(missing.data?.data).toBeNull(); isReady = true; const loaded = await result.current.refetch(); expect(loaded.data).toEqual(refreshed); expect(loaded.data?.data?.totalsByCurrency).toEqual( snapshot.totalsByCurrency, ); }); await waitFor(() => expect(result.current.data).toEqual(snapshot)); expect(result.current.meta).toEqual(refreshed.meta); expect(result.current.status).toBe("success"); expect(result.current.isSuccess).toBe(true); expect(result.current.isPending).toBe(false); }); it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { auth(); const { promise: blocked, resolve: release } = Promise.withResolvers(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", async ({ request }) => { if (!new URL(request.url).searchParams.has("sync")) await blocked; return HttpResponse.json(resource(snapshot)); }, ), ); const old = apiClient.GET( "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "a" } } }, ); // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" }); await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "a" }, query: { sync: "observe" } }, }); release(); await rejected; }); it("clears private snapshots on confirmed access loss and fences another pending success", async () => { auth(); let isDenied = false; const { promise: blocked, resolve: release } = Promise.withResolvers(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", async ({ params }) => { if (params.accountId === "b") await blocked; return isDenied && params.accountId === "a" ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) : HttpResponse.json(resource(snapshot)); }, ), ); const { wrapper } = createQueryWrapper(); const { result } = renderHook( () => ({ a: usePortfolio("a"), b: usePortfolio("b") }), { wrapper }, ); await waitFor(() => expect(result.current.a.data).toEqual(snapshot)); isDenied = true; await act(() => result.current.a.refetch()); await waitFor(() => expect(result.current.a.data).toBeUndefined()); await act(async () => { release(); await blocked; }); expect(result.current.b.data).toBeUndefined(); expect(result.current.a.isError).toBe(true); }); });