From 1250581ee3336e901bd01f757328ae4c4dd091c8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 23 Aug 2026 15:20:08 +0300 Subject: [PATCH] fix: stabilize frontend query cache contracts --- .../api/useBrokerageOperations.ts | 7 +- .../api/useBrokerageAccounts.ts | 9 +- .../brokerage-event/api/useBrokerageEvents.ts | 7 +- src/entities/portfolio/api/usePortfolio.ts | 8 +- .../ui/TbankConnectionSection.test.tsx | 99 +++++++++++++++++++ .../ui/TbankConnectionSection.tsx | 11 ++- .../ui/TbankConnectionSection.vm.ts | 8 +- .../account-events/ui/AccountEventsPage.tsx | 1 + .../ui/AccountOperationsPage.tsx | 25 +++-- src/pages/account/ui/AccountPage.vm.ts | 9 +- tsconfig.app.json | 1 + tsconfig.node.json | 1 + 12 files changed, 151 insertions(+), 35 deletions(-) create mode 100644 src/features/tbank-connection/ui/TbankConnectionSection.test.tsx diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 22c4b4c..04f3ead 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -42,14 +42,13 @@ export function useBrokerageOperations( ) { const params = brokerageOperationsQueryParams(query); return useQuery({ - queryKey: ["brokerage-operations", accountId ?? "", params] as const, - enabled: Boolean(accountId && accessToken), - staleTime: 30_000, - retry: false, ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/operations", { params: { path: { accountId: accountId ?? "" }, query: params } }, ), + enabled: Boolean(accountId && accessToken), + staleTime: 30_000, + retry: false, }); } diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 4c3fdc9..118c4a1 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -2,17 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import { apiQueryClient } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; -export const brokerageAccountsQueryKey = ["brokerage-accounts"] as const; +const brokerageAccountsQueryOptions = () => + apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); + +export const brokerageAccountsQueryKey = + brokerageAccountsQueryOptions().queryKey; export type BrokerageAccount = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number]; export function useBrokerageAccounts(accessToken: string | null) { return useQuery({ - queryKey: brokerageAccountsQueryKey, + ...brokerageAccountsQueryOptions(), enabled: Boolean(accessToken), staleTime: 60_000, retry: false, - ...apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), }); } diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts index 2a4f8eb..4d214b3 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.ts +++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts @@ -12,14 +12,13 @@ export function useBrokerageEvents( accessToken: string | null, ) { return useQuery({ - queryKey: ["brokerage-events", accountId ?? ""] as const, - enabled: Boolean(accountId && accessToken), - staleTime: 30_000, - retry: false, ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/events", { params: { path: { accountId: accountId ?? "" } } }, ), + enabled: Boolean(accountId && accessToken), + staleTime: 30_000, + retry: false, }); } diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts index de7ba9b..d883346 100644 --- a/src/entities/portfolio/api/usePortfolio.ts +++ b/src/entities/portfolio/api/usePortfolio.ts @@ -2,9 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { apiQueryClient } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; -const portfolioQueryKey = (accountId: string) => - ["portfolio", accountId] as const; - const portfolioQueryPolicy = { staleTime: 30_000, retry: false, @@ -18,13 +15,12 @@ export function usePortfolio( accessToken: string | null, ) { return useQuery({ - queryKey: portfolioQueryKey(accountId ?? ""), - enabled: Boolean(accountId && accessToken), - ...portfolioQueryPolicy, ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: accountId ?? "" } } }, ), + enabled: Boolean(accountId && accessToken), + ...portfolioQueryPolicy, }); } diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx new file mode 100644 index 0000000..c163a5f --- /dev/null +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -0,0 +1,99 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { apiQueryClient } from "@/shared/api"; +import { server } from "@/test/server"; +import { TbankConnectionSection } from "./TbankConnectionSection"; + +vi.mock("@/features/auth", () => ({ + useAuth: () => ({ accessToken: "test-token" }), +})); + +afterEach(cleanup); + +const brokerageAccountsQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts", +).queryKey; + +function renderSection(client: QueryClient) { + return render( + + + + + , + ); +} + +function seededClient() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(brokerageAccountsQueryKey, [ + { + id: "account-1", + name: "Основной счет", + type: "BROKERAGE", + openedOn: null, + }, + ]); + return client; +} + +describe("TbankConnectionSection", () => { + it("updates the generated status cache and invalidates accounts after connecting", async () => { + 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 waitFor(() => + expect( + screen.getByRole("button", { name: "Заменить token" }), + ).toBeInTheDocument(), + ); + expect(client.getQueryState(brokerageAccountsQueryKey)?.isInvalidated).toBe( + true, + ); + }); + + it("updates the generated status cache and invalidates accounts after deleting", async () => { + server.use( + http.get("*/api/v1/tbank-connection", () => + HttpResponse.json({ + connected: true, + connectedAt: "2026-08-14T00:00:00Z", + }), + ), + ); + const client = seededClient(); + renderSection(client); + + fireEvent.click( + await screen.findByRole("button", { name: "Удалить подключение" }), + ); + fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ })); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "Подключить" }), + ).toBeInTheDocument(), + ); + expect(client.getQueryState(brokerageAccountsQueryKey)?.isInvalidated).toBe( + true, + ); + }); +}); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.tsx index 5574fec..a393316 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.tsx @@ -17,7 +17,10 @@ import { TbankConnectionError, TbankConnectionLoading, } from "./TbankConnectionSection.view"; -import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm"; +import { + tbankConnectionQueryKey, + useTbankConnectionViewModel, +} from "./TbankConnectionSection.vm"; type Status = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; @@ -49,18 +52,18 @@ function TbankConnectionSectionLoaded({ status }: { status: Status }) { } = useForm<{ token: string }>(); const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { onSuccess: (data) => { - queryClient.setQueryData(["tbank-connection"], data); + queryClient.setQueryData(tbankConnectionQueryKey, data); queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey }); reset(); }, - onError: (error) => setRequestError(errorCode(error)), + onError: (error) => setRequestError(errorCode(error) ?? "REQUEST_ERROR"), }); const remove = apiQueryClient.useMutation( "delete", "/api/v1/tbank-connection", { onSuccess: () => { - queryClient.setQueryData(["tbank-connection"], { + queryClient.setQueryData(tbankConnectionQueryKey, { connected: false, connectedAt: null, }); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts index cb81a59..9658e6a 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts @@ -2,12 +2,16 @@ 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"); + +export const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey; + export function useTbankConnectionViewModel() { const { accessToken } = useAuth(); const status = useQuery({ - queryKey: ["tbank-connection"], + ...tbankConnectionQueryOptions(), enabled: Boolean(accessToken), - ...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"), }); if (status.isPending) return { state: "loading" as const }; if (status.isError) diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx index 3e9d861..7c8e21b 100644 --- a/src/pages/account-events/ui/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.tsx @@ -16,6 +16,7 @@ export function AccountEventsPage() { if (events.isLoading) return ; if (events.isError) return events.refetch()} />; + if (!events.data) return ; if (!events.data.events.length) return ; return ; } diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx index e20986a..dd8603a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx @@ -68,6 +68,16 @@ export function AccountOperationsPage() { } /> ); + if (!operations.data) + return ( + + ); + const { data } = operations; + const nextCursor = data.nextCursor; return ( {t("ledger.title")} @@ -109,8 +119,8 @@ export function AccountOperationsPage() { ))} - {operations.data.items.length ? ( - operations.data.items.map((operation) => ( + {data.items.length ? ( + data.items.map((operation) => ( )) ) : ( @@ -119,7 +129,7 @@ export function AccountOperationsPage() { text={t("ledger.emptyText")} /> )} - {operations.data.hasNext || cursors.length ? ( + {data.hasNext || cursors.length ? ( diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 77c42b7..7930afe 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -1,17 +1,18 @@ import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "react-router"; -import type { BrokerageAccount } from "@/entities/brokerage-account"; +import { + type BrokerageAccount, + brokerageAccountsQueryKey, +} from "@/entities/brokerage-account"; import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio"; import { useAuth } from "@/features/auth"; -const accountsQueryKey = ["brokerage-accounts"] as const; - export function useAccountPageViewModel() { const { accountId } = useParams(); const { accessToken } = useAuth(); const portfolio = usePortfolio(accountId, accessToken); const account = useQueryClient() - .getQueryData(accountsQueryKey) + .getQueryData(brokerageAccountsQueryKey) ?.find((item) => item.id === accountId); if (portfolio.isError) return { diff --git a/tsconfig.app.json b/tsconfig.app.json index 6b832f5..3a51c23 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -9,6 +9,7 @@ "types": ["vite/client"], "allowArbitraryExtensions": true, "skipLibCheck": true, + "strict": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/tsconfig.node.json b/tsconfig.node.json index 2285809..bc61d8d 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,6 +5,7 @@ "lib": ["ES2023"], "types": ["node"], "skipLibCheck": true, + "strict": true, /* Bundler mode */ "module": "nodenext",