feat: align investment client with hourly sync
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m43s

This commit is contained in:
2026-10-09 09:04:45 +03:00
parent 54deae57b3
commit 432cca299f
15 changed files with 1244 additions and 2508 deletions
@@ -497,7 +497,6 @@ describe("AccountOperationsPage", () => {
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
@@ -518,11 +517,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
await screen.findByRole("button", { name: "Сбросить" });
@@ -534,7 +528,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
to: "2024-03-01T23:59:59.999Z",
},
]);
expect(syncRequests).toEqual([]);
});
it("does not label incomplete empty operations as a confirmed empty period", async () => {
@@ -770,28 +770,6 @@ describe("AccountPage states", () => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1)
@@ -130,17 +130,6 @@ describe("AccountsPage", () => {
});
it("keeps the cached account list quietly after a refresh error", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
@@ -169,7 +158,6 @@ describe("AccountsPage", () => {
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
expect(requests).toEqual([]);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -15,6 +15,7 @@ const AccountsPage: FC = () => {
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
sync={viewModel.sync}
/>
);
};
@@ -4,15 +4,17 @@ import {
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { ApiErrorCode, apiErrorCode, isApiErrorCode, type paths } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; state: "loaded" }
= | { accounts: BrokerageAccountCard[]; state: "loaded"; sync: AccountsSync }
| { code?: string; state: "error" }
| { state: "empty" }
| { state: "loading" };
type AccountsSync = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["sync"];
function isAccountsAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
@@ -50,6 +52,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
state: "loaded" as const,
sync: accounts.sync,
};
}
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { Button, Stack, Text, Title } from "@mantine/core";
import { Badge, Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -11,10 +11,35 @@ import {
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
sync:
| null
| undefined
| {
finishedAt: null | string;
sections: {
accountId?: string;
errorCode: null | string;
section: string;
}[];
startedAt: string;
state: string;
};
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { t } = useTranslation();
const sync = props.sync;
const syncLabel = sync
? new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(sync.finishedAt ?? sync.startedAt))
: null;
const syncColor = sync?.state === "failed" || sync?.state === "partial"
? "red"
: "gray";
return (
<Stack gap="lg">
@@ -24,6 +49,21 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
{sync && (
<Text c="dimmed" size="xs">
Последний обход:
{" "}
{syncLabel}
{" "}
<Badge
color={syncColor}
size="xs"
>
{sync.state}
</Badge>
</Text>
)}
</Stack>
<Button