fix(tbank): reset connection-derived query cache

This commit is contained in:
2026-09-01 05:54:50 +03:00
parent 7ab043dce6
commit 175727bc93
7 changed files with 305 additions and 29 deletions
@@ -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");
});
});
@@ -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<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
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",
});
});
});
+45 -2
View File
@@ -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<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
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();
});
});
@@ -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,
};
@@ -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",
});
});
});
@@ -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<TbankConnectionSectionLoadedProps> = (
} = 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<TbankConnectionSectionLoadedProps> = (
"/api/v1/tbank-connection",
{
onSuccess: () => {
queryClient.setQueryData<Status>(tbankConnectionQueryKey, {
commitTbankConnectionBoundary(queryClient, {
connected: false,
connectedAt: null,
});
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
setDeleteOpened(false);
},
onError: () => setRequestError("REQUEST_ERROR"),
@@ -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 };