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 { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio"; import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => { describe("usePortfolio", () => {
@@ -34,4 +36,45 @@ describe("usePortfolio", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({ positions: [] }); 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 { 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 { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { apiClient } from "@/shared/api"; import { apiClient, apiQueryClient } from "@/shared/api";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AuthProvider, useAuth } from "./auth"; import { AuthProvider, useAuth } from "./auth";
@@ -100,4 +100,47 @@ describe("AuthProvider", () => {
expect(result.current.accessToken).toBeNull(); expect(result.current.accessToken).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); 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", "get",
"/api/v1/brokerage-accounts", "/api/v1/brokerage-accounts",
).queryKey; ).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) { function renderSection(client: QueryClient) {
return render( return render(
@@ -46,11 +60,13 @@ function seededClient() {
openedOn: null, openedOn: null,
}, },
]); ]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(unrelatedQueryKey, { id: "user-1" });
return client; return client;
} }
describe("TbankConnectionSection", () => { 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(); const client = seededClient();
renderSection(client); renderSection(client);
@@ -65,12 +81,36 @@ describe("TbankConnectionSection", () => {
screen.getByRole("button", { name: "Заменить token" }), screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument(), ).toBeInTheDocument(),
); );
expect(client.getQueryState(brokerageAccountsQueryKey)?.isInvalidated).toBe( expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
true, 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( server.use(
http.get("*/api/v1/tbank-connection", () => http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({ HttpResponse.json({
@@ -92,8 +132,98 @@ describe("TbankConnectionSection", () => {
screen.getByRole("button", { name: "Подключить" }), screen.getByRole("button", { name: "Подключить" }),
).toBeInTheDocument(), ).toBeInTheDocument(),
); );
expect(client.getQueryState(brokerageAccountsQueryKey)?.isInvalidated).toBe( expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
true, 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 { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { apiQueryClient } from "@/shared/api"; import { apiQueryClient } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema"; import {
commitTbankConnectionBoundary,
type TbankConnectionStatus,
} from "../model/connection-boundary";
import { import {
TbankConnectionError, TbankConnectionError,
TbankConnectionLoading, TbankConnectionLoading,
} from "./TbankConnectionSection.view"; } from "./TbankConnectionSection.view";
import { import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
tbankConnectionQueryKey,
useTbankConnectionViewModel,
} from "./TbankConnectionSection.vm";
type Status =
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
type TbankConnectionSectionLoadedProps = { type TbankConnectionSectionLoadedProps = {
status: Status; status: TbankConnectionStatus;
}; };
function errorCode(error: unknown): string | undefined { function errorCode(error: unknown): string | undefined {
@@ -59,8 +56,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
} = useForm<{ token: string }>(); } = useForm<{ token: string }>();
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(tbankConnectionQueryKey, data); commitTbankConnectionBoundary(queryClient, data);
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
reset(); reset();
}, },
onError: (error) => setRequestError(errorCode(error) ?? "REQUEST_ERROR"), onError: (error) => setRequestError(errorCode(error) ?? "REQUEST_ERROR"),
@@ -70,11 +66,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
"/api/v1/tbank-connection", "/api/v1/tbank-connection",
{ {
onSuccess: () => { onSuccess: () => {
queryClient.setQueryData<Status>(tbankConnectionQueryKey, { commitTbankConnectionBoundary(queryClient, {
connected: false, connected: false,
connectedAt: null, connectedAt: null,
}); });
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
setDeleteOpened(false); setDeleteOpened(false);
}, },
onError: () => setRequestError("REQUEST_ERROR"), onError: () => setRequestError("REQUEST_ERROR"),
@@ -1,11 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { apiQueryClient } from "@/shared/api"; import { tbankConnectionQueryOptions } from "../model/connection-boundary";
const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey;
function useTbankConnectionViewModel() { function useTbankConnectionViewModel() {
const { accessToken } = useAuth(); const { accessToken } = useAuth();
@@ -19,4 +14,4 @@ function useTbankConnectionViewModel() {
return { state: "loaded" as const, status: status.data }; return { state: "loaded" as const, status: status.data };
} }
export { tbankConnectionQueryKey, useTbankConnectionViewModel }; export { useTbankConnectionViewModel };