diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx index ecc5a9c..8c52410 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx @@ -59,4 +59,37 @@ describe("useBrokerageAccounts", () => { }, ]); }); + + it("keeps the current connection snapshot after a background refetch error", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + let calls = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + calls += 1; + return calls === 1 + ? HttpResponse.json([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]) + : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); + }), + ); + const { result } = renderHook(() => useBrokerageAccounts(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + void result.current.refetch(); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data?.[0].id).toBe("account-1"); + }); }); diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index f25ff30..f625713 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -1,10 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; import type { FC, PropsWithChildren } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; +import { server } from "@/test/server"; import { usePortfolio } from "./usePortfolio"; describe("usePortfolio", () => { @@ -34,4 +36,45 @@ describe("usePortfolio", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toMatchObject({ positions: [] }); }); + + it("keeps the current connection snapshot after a background refetch error", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + let calls = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + calls += 1; + return calls === 1 + ? HttpResponse.json({ + lastSuccessfulAt: "2026-08-14T00:00:00Z", + isStale: false, + totalsByCurrency: [], + positions: [], + cashBalances: [], + }) + : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); + }), + ); + const client = new QueryClient(); + const wrapper: FC = (props) => { + const { children } = props; + return ( + + {children} + + ); + }; + const { result } = renderHook(() => usePortfolio("account-1"), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + void result.current.refetch(); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data).toMatchObject({ + lastSuccessfulAt: "2026-08-14T00:00:00Z", + }); + }); }); diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index d62ff13..15d39e1 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -1,9 +1,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import type { FC, PropsWithChildren } from "react"; import { describe, expect, it } from "vitest"; -import { apiClient } from "@/shared/api"; +import { apiClient, apiQueryClient } from "@/shared/api"; import { server } from "@/test/server"; import { AuthProvider, useAuth } from "./auth"; @@ -100,4 +100,47 @@ describe("AuthProvider", () => { expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); }); + + 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: { id: "user-1", email: "user@example.com" }, + }), + ), + ); + const queryClient = new QueryClient(); + 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" }); + const wrapper: FC = (props) => { + const { children } = props; + return ( + + {children} + + ); + }; + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.accessToken).toBe("test-token")); + + await act(() => result.current.logout()); + + expect(result.current.user).toBeNull(); + expect(result.current.accessToken).toBeNull(); + expect(queryClient.getQueryData(statusKey)).toBeUndefined(); + expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); + }); }); diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts new file mode 100644 index 0000000..405116f --- /dev/null +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -0,0 +1,37 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { apiQueryClient } from "@/shared/api"; +import type { paths } from "@/shared/api/generated/schema"; + +type TbankConnectionStatus = + paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; + +const brokerageAccountsPath = "/api/v1/brokerage-accounts"; + +const tbankConnectionQueryOptions = () => + apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); + +const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey; + +function commitTbankConnectionBoundary( + queryClient: QueryClient, + status: TbankConnectionStatus, +) { + queryClient.setQueryData(tbankConnectionQueryKey, status); + void queryClient.resetQueries({ + predicate: ({ queryKey }) => { + const [method, path] = queryKey; + return ( + method === "get" && + typeof path === "string" && + (path === brokerageAccountsPath || + path.startsWith(`${brokerageAccountsPath}/`)) + ); + }, + }); +} + +export { + commitTbankConnectionBoundary, + type TbankConnectionStatus, + tbankConnectionQueryOptions, +}; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index c163a5f..742a0dc 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -23,6 +23,20 @@ const brokerageAccountsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts", ).queryKey; +const portfolioQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/portfolio", + { params: { path: { accountId: "account-1" } } }, +).queryKey; +const operationsQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/operations", + { params: { path: { accountId: "account-1" } } }, +).queryKey; +const unrelatedQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/auth/me", +).queryKey; function renderSection(client: QueryClient) { return render( @@ -46,11 +60,13 @@ function seededClient() { openedOn: null, }, ]); + client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); + client.setQueryData(unrelatedQueryKey, { id: "user-1" }); return client; } describe("TbankConnectionSection", () => { - it("updates the generated status cache and invalidates accounts after connecting", async () => { + it("commits connection status and resets the complete brokerage query subtree after connecting", async () => { const client = seededClient(); renderSection(client); @@ -65,12 +81,36 @@ describe("TbankConnectionSection", () => { screen.getByRole("button", { name: "Заменить token" }), ).toBeInTheDocument(), ); - expect(client.getQueryState(brokerageAccountsQueryKey)?.isInvalidated).toBe( - true, - ); + expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); + expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); - it("updates the generated status cache and invalidates accounts after deleting", async () => { + it("creates the same financial boundary when an existing connection is replaced", async () => { + server.use( + http.get("*/api/v1/tbank-connection", () => + HttpResponse.json({ + connected: true, + connectedAt: "2026-08-13T00:00:00Z", + }), + ), + ); + const client = seededClient(); + renderSection(client); + + await screen.findByRole("button", { name: "Заменить token" }); + fireEvent.change(screen.getByLabelText("Token T-Bank"), { + target: { value: "replacement-token" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Заменить token" })); + + await waitFor(() => + expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(), + ); + expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + }); + + it("commits disconnected status and resets financial queries after deleting", async () => { server.use( http.get("*/api/v1/tbank-connection", () => HttpResponse.json({ @@ -92,8 +132,98 @@ describe("TbankConnectionSection", () => { screen.getByRole("button", { name: "Подключить" }), ).toBeInTheDocument(), ); - expect(client.getQueryState(brokerageAccountsQueryKey)?.isInvalidated).toBe( - true, + expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); + expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); + }); + + it("cancels an in-flight previous-boundary query and ignores its late response", async () => { + const client = seededClient(); + let finishPrevious: ((value: { source: string }) => void) | undefined; + let aborted = false; + const previousResponse = new Promise<{ source: string }>((resolve) => { + finishPrevious = resolve; + }); + const previousRequest = client + .fetchQuery({ + queryKey: operationsQueryKey, + queryFn: ({ signal }) => { + signal.addEventListener("abort", () => { + aborted = true; + }); + return previousResponse; + }, + }) + .catch(() => undefined); + renderSection(client); + + await screen.findByText("Не подключено"); + fireEvent.change(screen.getByLabelText("Token T-Bank"), { + target: { value: "replacement-token" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Подключить" })); + + await waitFor(() => expect(aborted).toBe(true)); + await previousRequest; + finishPrevious?.({ source: "previous-connection" }); + await Promise.resolve(); + expect(client.getQueryData(operationsQueryKey)).toBeUndefined(); + }); + + it("preserves status and financial cache when a connection update fails", async () => { + server.use( + http.put("*/api/v1/tbank-connection", () => + HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), + ), ); + const client = seededClient(); + renderSection(client); + + await screen.findByText("Не подключено"); + fireEvent.change(screen.getByLabelText("Token T-Bank"), { + target: { value: "replacement-token" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Подключить" })); + + await screen.findByText( + "T-Bank временно недоступен. Повторите попытку позже.", + ); + expect( + screen.getByRole("button", { name: "Подключить" }), + ).toBeInTheDocument(); + expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined(); + expect(client.getQueryData(portfolioQueryKey)).toEqual({ + source: "previous-connection", + }); + }); + + it("preserves connected status and financial cache when deletion fails", async () => { + server.use( + http.get("*/api/v1/tbank-connection", () => + HttpResponse.json({ + connected: true, + connectedAt: "2026-08-14T00:00:00Z", + }), + ), + http.delete("*/api/v1/tbank-connection", () => + HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), + ), + ); + const client = seededClient(); + renderSection(client); + + fireEvent.click( + await screen.findByRole("button", { name: "Удалить подключение" }), + ); + fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ })); + + await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз."); + expect( + screen.getByRole("button", { name: "Заменить token" }), + ).toBeInTheDocument(); + expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined(); + expect(client.getQueryData(portfolioQueryKey)).toEqual({ + source: "previous-connection", + }); }); }); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.tsx index 4058210..aa335cb 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.tsx @@ -11,22 +11,19 @@ import type { FC } from "react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { brokerageAccountsQueryKey } from "@/entities/brokerage-account"; import { apiQueryClient } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { + commitTbankConnectionBoundary, + type TbankConnectionStatus, +} from "../model/connection-boundary"; import { TbankConnectionError, TbankConnectionLoading, } from "./TbankConnectionSection.view"; -import { - tbankConnectionQueryKey, - useTbankConnectionViewModel, -} from "./TbankConnectionSection.vm"; +import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm"; -type Status = - paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; type TbankConnectionSectionLoadedProps = { - status: Status; + status: TbankConnectionStatus; }; function errorCode(error: unknown): string | undefined { @@ -59,8 +56,7 @@ const TbankConnectionSectionLoaded: FC = ( } = useForm<{ token: string }>(); const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { onSuccess: (data) => { - queryClient.setQueryData(tbankConnectionQueryKey, data); - queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey }); + commitTbankConnectionBoundary(queryClient, data); reset(); }, onError: (error) => setRequestError(errorCode(error) ?? "REQUEST_ERROR"), @@ -70,11 +66,10 @@ const TbankConnectionSectionLoaded: FC = ( "/api/v1/tbank-connection", { onSuccess: () => { - queryClient.setQueryData(tbankConnectionQueryKey, { + commitTbankConnectionBoundary(queryClient, { connected: false, connectedAt: null, }); - queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey }); setDeleteOpened(false); }, onError: () => setRequestError("REQUEST_ERROR"), diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts index 11d16f4..5df9190 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts @@ -1,11 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/features/auth"; -import { apiQueryClient } from "@/shared/api"; - -const tbankConnectionQueryOptions = () => - apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); - -const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey; +import { tbankConnectionQueryOptions } from "../model/connection-boundary"; function useTbankConnectionViewModel() { const { accessToken } = useAuth(); @@ -19,4 +14,4 @@ function useTbankConnectionViewModel() { return { state: "loaded" as const, status: status.data }; } -export { tbankConnectionQueryKey, useTbankConnectionViewModel }; +export { useTbankConnectionViewModel };