chore(release): 0.4.0 #37
@@ -16,6 +16,7 @@ import type { IncomeAnalytics } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AppRoutes } from "./AppRoutes";
|
||||
|
||||
@@ -132,50 +133,56 @@ beforeEach(() => {
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json(
|
||||
accounts.map((account) => ({
|
||||
...account,
|
||||
type: "BROKERAGE",
|
||||
openedOn: null,
|
||||
portfolioSummary: null,
|
||||
})),
|
||||
resource(
|
||||
accounts.map((account) => ({
|
||||
...account,
|
||||
type: "BROKERAGE",
|
||||
openedOn: null,
|
||||
portfolioSummary: null,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
HttpResponse.json(resource(portfolio)),
|
||||
),
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ params }) =>
|
||||
HttpResponse.json({
|
||||
accountId: params.accountId,
|
||||
items,
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
observedAt: receipt.occurredAt,
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: params.accountId,
|
||||
items,
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
observedAt: receipt.occurredAt,
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
||||
HttpResponse.json(analytics),
|
||||
HttpResponse.json(resource(analytics)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "all",
|
||||
calculatedAt: receipt.occurredAt,
|
||||
incomeTotalsByCurrency: [],
|
||||
events: accounts.map((account) => ({
|
||||
account,
|
||||
type: "coupon",
|
||||
date: "2026-09-15",
|
||||
dateKind: "event",
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
displayName: "Облигация",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
projectedAmount: { currency: "RUB", value: "2" },
|
||||
})),
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "all",
|
||||
calculatedAt: receipt.occurredAt,
|
||||
incomeTotalsByCurrency: [],
|
||||
events: accounts.map((account) => ({
|
||||
account,
|
||||
type: "coupon",
|
||||
date: "2026-09-15",
|
||||
dateKind: "event",
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
displayName: "Облигация",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
projectedAmount: { currency: "RUB", value: "2" },
|
||||
})),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -224,20 +231,22 @@ describe("All accounts routes", () => {
|
||||
({ request, params }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
return HttpResponse.json({
|
||||
accountId: params.accountId,
|
||||
items: [
|
||||
{
|
||||
...receipt,
|
||||
description: url.searchParams.has("cursor")
|
||||
? "Вторая страница"
|
||||
: "Первая страница",
|
||||
},
|
||||
],
|
||||
hasNext: !url.searchParams.has("cursor"),
|
||||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||
observedAt: receipt.occurredAt,
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: params.accountId,
|
||||
items: [
|
||||
{
|
||||
...receipt,
|
||||
description: url.searchParams.has("cursor")
|
||||
? "Вторая страница"
|
||||
: "Первая страница",
|
||||
},
|
||||
],
|
||||
hasNext: !url.searchParams.has("cursor"),
|
||||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||
observedAt: receipt.occurredAt,
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -274,13 +283,15 @@ describe("All accounts routes", () => {
|
||||
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||
{ status: 400 },
|
||||
)
|
||||
: HttpResponse.json({
|
||||
accountId: "all",
|
||||
items,
|
||||
hasNext: true,
|
||||
nextCursor: "expired",
|
||||
observedAt: receipt.occurredAt,
|
||||
}),
|
||||
: HttpResponse.json(
|
||||
resource({
|
||||
accountId: "all",
|
||||
items,
|
||||
hasNext: true,
|
||||
nextCursor: "expired",
|
||||
observedAt: receipt.occurredAt,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MemoryRouter, useNavigate } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { ProtectedDocumentTitle } from "./DocumentTitle";
|
||||
|
||||
@@ -62,7 +63,7 @@ describe("document titles", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", async () => {
|
||||
await accountsReady;
|
||||
return HttpResponse.json([{ id: "a", name: "ИИС" }]);
|
||||
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
|
||||
}),
|
||||
);
|
||||
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
|
||||
@@ -85,7 +86,7 @@ describe("document titles", () => {
|
||||
async (kind, id, fallback, name) => {
|
||||
server.use(
|
||||
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
|
||||
HttpResponse.json({ name }),
|
||||
HttpResponse.json(resource({ name })),
|
||||
),
|
||||
);
|
||||
renderTitle(`/${kind}/${id}`);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
type BondPeriod = NonNullable<
|
||||
NonNullable<
|
||||
@@ -13,7 +9,7 @@ type BondPeriod = NonNullable<
|
||||
const BOND_STALE_TIME_MS = 30_000;
|
||||
|
||||
function useBondSearch(query: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
|
||||
params: { query: { query } },
|
||||
}),
|
||||
@@ -36,11 +32,11 @@ function useBond(identifier: string) {
|
||||
identifier,
|
||||
);
|
||||
|
||||
const byUid = useAuthenticatedQuery({
|
||||
const byUid = useResourceQuery({
|
||||
...bondQueryOptions(identifier),
|
||||
enabled: Boolean(identifier) && isUid,
|
||||
});
|
||||
const byTicker = useAuthenticatedQuery({
|
||||
const byTicker = useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
||||
params: { path: { ticker: identifier } },
|
||||
}),
|
||||
@@ -50,7 +46,7 @@ function useBond(identifier: string) {
|
||||
return isUid ? byUid : byTicker;
|
||||
}
|
||||
function useBondMetrics(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
@@ -59,7 +55,7 @@ function useBondMetrics(uid: string) {
|
||||
});
|
||||
}
|
||||
function useBondQuote(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
@@ -68,7 +64,7 @@ function useBondQuote(uid: string) {
|
||||
});
|
||||
}
|
||||
function useBondCandles(uid: string, period: BondPeriod) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
|
||||
params: { path: { uid }, query: { period } },
|
||||
}),
|
||||
@@ -77,7 +73,8 @@ function useBondCandles(uid: string, period: BondPeriod) {
|
||||
});
|
||||
}
|
||||
function useBondPosition(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
@@ -86,7 +83,8 @@ function useBondPosition(uid: string) {
|
||||
});
|
||||
}
|
||||
function useBondCoupons(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
type OperationsEndpoint =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
||||
|
||||
type BrokerageOperation =
|
||||
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
|
||||
type BrokerageOperationsQuery = NonNullable<
|
||||
OperationsEndpoint["parameters"]["query"]
|
||||
type BrokerageOperation = NonNullable<
|
||||
OperationsEndpoint["responses"][200]["content"]["application/json"]["data"]
|
||||
>["items"][number];
|
||||
type BrokerageOperationsQuery = Omit<
|
||||
NonNullable<OperationsEndpoint["parameters"]["query"]>,
|
||||
"sync"
|
||||
>;
|
||||
type OperationCategory = BrokerageOperation["category"];
|
||||
|
||||
@@ -56,7 +54,8 @@ function useBrokerageOperations(
|
||||
accountId: string | undefined,
|
||||
query: BrokerageOperationsQuery = {},
|
||||
) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...brokerageOperationsQueryOptions(accountId, query),
|
||||
enabled: Boolean(accountId),
|
||||
staleTime: 30_000,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
||||
|
||||
@@ -15,14 +16,16 @@ describe("useBrokerageAccounts", () => {
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
},
|
||||
]),
|
||||
HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -52,14 +55,16 @@ describe("useBrokerageAccounts", () => {
|
||||
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(
|
||||
resource([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
},
|
||||
]),
|
||||
)
|
||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
const brokerageAccountsQueryOptions = () =>
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
||||
|
||||
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
|
||||
|
||||
type BrokerageAccount =
|
||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
|
||||
type BrokerageAccount = NonNullable<
|
||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>[number];
|
||||
|
||||
function useBrokerageAccounts() {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...brokerageAccountsQueryOptions(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
|
||||
import type {
|
||||
BrokerageAccount,
|
||||
useBrokerageAccounts,
|
||||
} from "../api/useBrokerageAccounts";
|
||||
|
||||
type Money = NonNullable<
|
||||
BrokerageAccount["portfolioSummary"]
|
||||
>["totalsByCurrency"][number];
|
||||
|
||||
type BrokerageAccountCard = {
|
||||
source?: NonNullable<
|
||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
||||
>["dependencies"] extends (infer T)[] | undefined
|
||||
? T
|
||||
: never;
|
||||
account: BrokerageAccount;
|
||||
positionCounts: { stocks: number; bonds: number } | null;
|
||||
summary:
|
||||
@@ -19,11 +27,19 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
||||
|
||||
function brokerageAccountCards(
|
||||
accounts: BrokerageAccount[],
|
||||
dependencies: NonNullable<
|
||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
||||
>["dependencies"] = [],
|
||||
): BrokerageAccountCard[] {
|
||||
return accounts.map((account) => {
|
||||
const source = dependencies.find(
|
||||
(item) =>
|
||||
item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`,
|
||||
);
|
||||
const summary = account.portfolioSummary;
|
||||
if (!summary)
|
||||
return {
|
||||
source,
|
||||
account,
|
||||
positionCounts: null,
|
||||
summary: { state: "unavailable" },
|
||||
@@ -33,8 +49,9 @@ function brokerageAccountCards(
|
||||
bonds: summary.bondPositionCount,
|
||||
};
|
||||
if (!summary.totalsByCurrency.length)
|
||||
return { account, positionCounts, summary: { state: "empty" } };
|
||||
return { source, account, positionCounts, summary: { state: "empty" } };
|
||||
return {
|
||||
source,
|
||||
account,
|
||||
positionCounts,
|
||||
summary: {
|
||||
|
||||
+41
@@ -185,3 +185,44 @@ export const LargeValues: Story = {
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export const IndependentFreshness: Story = {
|
||||
name: "Независимая свежесть и ошибка сводок",
|
||||
args: {
|
||||
accounts: brokerageAccountCards(
|
||||
[
|
||||
syntheticAccount,
|
||||
{
|
||||
...syntheticAccount,
|
||||
id: "waiting",
|
||||
name: "Новый счёт",
|
||||
portfolioSummary: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
resourceKey: "portfolio/storybook-account",
|
||||
dataRevision: "1",
|
||||
stateVersion: "failed",
|
||||
availability: "complete",
|
||||
freshness: "stale",
|
||||
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
||||
sync: {
|
||||
id: "failed",
|
||||
state: "failed",
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceKey: "portfolio/waiting",
|
||||
dataRevision: "0",
|
||||
stateVersion: "waiting",
|
||||
availability: "missing",
|
||||
freshness: "unknown",
|
||||
lastSuccessfulAt: null,
|
||||
sync: { id: "waiting", state: "running", errorCode: null },
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resource } from "@/test/resource";
|
||||
import { brokerageAccountCards } from "../../model/display";
|
||||
import { BrokerageAccountList } from "./BrokerageAccountList";
|
||||
|
||||
@@ -84,3 +85,42 @@ describe("BrokerageAccountList", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows portfolio summary freshness independently for each account", () => {
|
||||
const accounts = brokerageAccountCards(
|
||||
["fresh", "stale"].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: "BROKERAGE" as const,
|
||||
openedOn: null,
|
||||
portfolioSummary: {
|
||||
stockPositionCount: 1,
|
||||
bondPositionCount: 0,
|
||||
totalsByCurrency: [{ currency: "RUB", value: "10" }],
|
||||
},
|
||||
})),
|
||||
[
|
||||
resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" })
|
||||
.meta,
|
||||
resource(null, { resourceKey: "portfolio/stale", freshness: "stale" })
|
||||
.meta,
|
||||
],
|
||||
);
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<BrokerageAccountList accounts={accounts} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("link", { name: /stale/ })).getByText(
|
||||
"Данные требуют обновления",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole("link", { name: /fresh/ })).queryByText(
|
||||
"Данные требуют обновления",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import type { BrokerageAccountCard } from "../../model/display";
|
||||
import { accountTypeKey } from "../../model/display";
|
||||
import classes from "./BrokerageAccountList.module.css";
|
||||
@@ -52,8 +52,8 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SimpleGrid className={classes.list} cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
{accounts.map(({ account, summary, positionCounts }) => (
|
||||
<SimpleGrid className={classes.list} cols={1} spacing="lg">
|
||||
{accounts.map(({ account, summary, positionCounts, source }) => (
|
||||
<Card
|
||||
component={Link}
|
||||
to={`/accounts/${account.id}`}
|
||||
@@ -92,6 +92,25 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
{t("accounts.currentValue")}
|
||||
</Text>
|
||||
<BrokerageAccountSummary summary={summary} />
|
||||
{source?.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(source.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{source?.sync.state === "running" ||
|
||||
source?.sync.state === "queued" ? (
|
||||
<Text size="xs" role="status">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
) : null}
|
||||
{source?.sync.state === "failed" ? (
|
||||
<Text size="xs">{t("accounts.refreshError")}</Text>
|
||||
) : null}
|
||||
{source?.freshness === "stale" ? (
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{positionCounts ? (
|
||||
<dl className={classes.positions}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { useBrokerageEvents } from "./useBrokerageEvents";
|
||||
|
||||
@@ -31,23 +32,25 @@ describe("useBrokerageEvents", () => {
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [
|
||||
{
|
||||
type: "dividend",
|
||||
date: "2026-09-01",
|
||||
dateKind: "registry",
|
||||
figi: "figi-1",
|
||||
instrumentUid: null,
|
||||
ticker: "SBER",
|
||||
displayName: "Сбер",
|
||||
logoName: "sber3.png",
|
||||
projectedAmount: { currency: "RUB", value: "12.34" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [
|
||||
{
|
||||
type: "dividend",
|
||||
date: "2026-09-01",
|
||||
dateKind: "registry",
|
||||
figi: "figi-1",
|
||||
instrumentUid: null,
|
||||
ticker: "SBER",
|
||||
displayName: "Сбер",
|
||||
logoName: "sber3.png",
|
||||
projectedAmount: { currency: "RUB", value: "12.34" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
type EventsEndpoint =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
||||
type BrokerageEvent =
|
||||
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
|
||||
type BrokerageEvent = NonNullable<
|
||||
EventsEndpoint["responses"][200]["content"]["application/json"]["data"]
|
||||
>["events"][number];
|
||||
|
||||
function useBrokerageEvents(
|
||||
accountId: string | undefined,
|
||||
query: EventsEndpoint["parameters"]["query"] = {},
|
||||
query: Omit<NonNullable<EventsEndpoint["parameters"]["query"]>, "sync"> = {},
|
||||
) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/events",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||
import { BrokerageEventCard } from "../BrokerageEventCard";
|
||||
|
||||
@@ -12,7 +13,14 @@ type BrokerageEventsPreviewProps = {
|
||||
|
||||
type BrokerageEventsPreviewState =
|
||||
| { state: "loading" | "error" | "empty"; to: string }
|
||||
| { state: "loaded"; to: string; items: BrokerageEvent[] };
|
||||
| {
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
items: BrokerageEvent[];
|
||||
};
|
||||
|
||||
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
const { events } = props;
|
||||
@@ -20,6 +28,21 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{events.state === "loaded" ? (
|
||||
<>
|
||||
{events.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(events.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{events.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
|
||||
{events.refreshError ? (
|
||||
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{events.state === "loading" ? (
|
||||
<LoadingSkeleton label={t("events.recentLoading")}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
@@ -33,7 +56,10 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
{events.state === "empty" ? (
|
||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{events.state === "loaded"
|
||||
{events.state === "partial" ? (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
) : null}
|
||||
{events.state === "loaded" || events.state === "partial"
|
||||
? events.items.map((event) => (
|
||||
<BrokerageEventCard
|
||||
key={JSON.stringify([
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
type IncomeSummary =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type IncomeAnalytics =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type IncomeSummary = NonNullable<
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
type IncomeAnalytics = NonNullable<
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
|
||||
function useIncomeSummary(accountId: string | undefined) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/income-summary",
|
||||
@@ -25,7 +24,8 @@ function useIncomeAnalytics(
|
||||
accountId: string | undefined,
|
||||
period: { from: string; to: string },
|
||||
) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/income-analytics",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import {
|
||||
type Portfolio,
|
||||
portfolioQueryOptions,
|
||||
usePortfolio,
|
||||
} from "./usePortfolio";
|
||||
|
||||
const snapshot: Portfolio = {
|
||||
positions: [],
|
||||
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
|
||||
cashBalances: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
isStale: false,
|
||||
};
|
||||
function auth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("persistent resource query lifecycle", () => {
|
||||
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
|
||||
auth();
|
||||
const modes: (string | null)[] = [];
|
||||
let observations = 0;
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get("sync");
|
||||
modes.push(mode);
|
||||
if (mode) observations += 1;
|
||||
return HttpResponse.json(
|
||||
resource(snapshot, {
|
||||
stateVersion: observations > 1 ? "new" : "old",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
const { wrapper, queryClient } = createQueryWrapper();
|
||||
const { result } = renderHook(() => usePortfolio("a"), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
await act(() => result.current.observe("new"));
|
||||
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
|
||||
expect(modes).toEqual([null, "observe", "observe"]);
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(
|
||||
queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
|
||||
).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot });
|
||||
});
|
||||
it("does not convert missing or failed source data to empty financial success", async () => {
|
||||
auth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(
|
||||
resource(null, {
|
||||
sync: {
|
||||
id: "work",
|
||||
state: "failed",
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => usePortfolio("a"), {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(result.current.isSuccess).toBe(false);
|
||||
expect(result.current.meta?.availability).toBe("missing");
|
||||
});
|
||||
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
||||
auth();
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
async ({ request }) => {
|
||||
if (!new URL(request.url).searchParams.has("sync")) await blocked;
|
||||
return HttpResponse.json(resource(snapshot));
|
||||
},
|
||||
),
|
||||
);
|
||||
const old = apiClient.GET(
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: "a" } } },
|
||||
);
|
||||
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||
});
|
||||
release();
|
||||
await rejected;
|
||||
});
|
||||
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
||||
auth();
|
||||
let denied = false;
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
async ({ params }) => {
|
||||
if (params.accountId === "b") await blocked;
|
||||
if (params.accountId === "a" && denied)
|
||||
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
|
||||
return HttpResponse.json(resource(snapshot));
|
||||
},
|
||||
),
|
||||
);
|
||||
const { wrapper } = createQueryWrapper();
|
||||
const { result } = renderHook(
|
||||
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
|
||||
denied = true;
|
||||
await act(() => result.current.a.refetch());
|
||||
await waitFor(() => expect(result.current.a.data).toBeUndefined());
|
||||
await act(async () => {
|
||||
release();
|
||||
await blocked;
|
||||
});
|
||||
expect(result.current.b.data).toBeUndefined();
|
||||
expect(result.current.a.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { usePortfolio } from "./usePortfolio";
|
||||
|
||||
@@ -33,14 +33,16 @@ describe("usePortfolio", () => {
|
||||
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: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
})
|
||||
? HttpResponse.json(
|
||||
resource({
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
positions: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
}),
|
||||
)
|
||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
const portfolioQueryPolicy = {
|
||||
staleTime: 30_000,
|
||||
} as const;
|
||||
|
||||
type Portfolio =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type Portfolio = NonNullable<
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
|
||||
function portfolioQueryOptions(accountId: string | undefined) {
|
||||
return apiQueryClient.queryOptions(
|
||||
@@ -20,7 +17,8 @@ function portfolioQueryOptions(accountId: string | undefined) {
|
||||
}
|
||||
|
||||
function usePortfolio(accountId: string | undefined) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...portfolioQueryOptions(accountId),
|
||||
enabled: Boolean(accountId),
|
||||
...portfolioQueryPolicy,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
unwrapResourceResult,
|
||||
useAuthenticatedQueries,
|
||||
useAuthenticatedQuery,
|
||||
useResourceQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
type StockPeriod = NonNullable<
|
||||
@@ -14,7 +15,7 @@ type StockPeriod = NonNullable<
|
||||
const STOCK_STALE_TIME_MS = 30_000;
|
||||
|
||||
function useStockSearch(query: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
|
||||
params: { query: { query } },
|
||||
}),
|
||||
@@ -35,7 +36,10 @@ function useStocks(uids: string[]) {
|
||||
const uniqueUids = [...new Set(uids)];
|
||||
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
|
||||
return new Map(
|
||||
queries.map((query, index) => [uniqueUids[index], query.data]),
|
||||
queries.map((query, index) => [
|
||||
uniqueUids[index],
|
||||
unwrapResourceResult(query).data,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,11 +49,11 @@ function useStock(identifier: string) {
|
||||
identifier,
|
||||
);
|
||||
|
||||
const byUid = useAuthenticatedQuery({
|
||||
const byUid = useResourceQuery({
|
||||
...stockQueryOptions(identifier),
|
||||
enabled: Boolean(identifier) && isUid,
|
||||
});
|
||||
const byTicker = useAuthenticatedQuery({
|
||||
const byTicker = useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
|
||||
params: { path: { ticker: identifier } },
|
||||
}),
|
||||
@@ -59,7 +63,7 @@ function useStock(identifier: string) {
|
||||
return isUid ? byUid : byTicker;
|
||||
}
|
||||
function useStockQuote(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
@@ -68,7 +72,7 @@ function useStockQuote(uid: string) {
|
||||
});
|
||||
}
|
||||
function useStockCandles(uid: string, period: StockPeriod) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
|
||||
params: { path: { uid }, query: { period } },
|
||||
}),
|
||||
@@ -77,7 +81,8 @@ function useStockCandles(uid: string, period: StockPeriod) {
|
||||
});
|
||||
}
|
||||
function useStockPosition(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
@@ -86,7 +91,7 @@ function useStockPosition(uid: string) {
|
||||
});
|
||||
}
|
||||
function useStockCompany(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
@@ -95,7 +100,8 @@ function useStockCompany(uid: string) {
|
||||
});
|
||||
}
|
||||
function useStockDividends(uid: string) {
|
||||
return useAuthenticatedQuery({
|
||||
return useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
|
||||
params: { path: { uid } },
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FC, PropsWithChildren } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
|
||||
@@ -24,7 +25,7 @@ describe("AuthProvider", () => {
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json([]);
|
||||
return HttpResponse.json(resource([]));
|
||||
}),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
@@ -143,8 +143,8 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||
refresh,
|
||||
clear: clearSession,
|
||||
});
|
||||
return () => configureAuthClient(null);
|
||||
});
|
||||
useEffect(() => () => configureAuthClient(null), []);
|
||||
|
||||
return (
|
||||
<AuthContext
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resource } from "@/test/resource";
|
||||
import { section } from "./section";
|
||||
|
||||
describe("instrument source and transport presentation", () => {
|
||||
it.each(["missing", "partial", "complete"] as const)(
|
||||
"keeps %s separate from successful transport",
|
||||
(availability) => {
|
||||
const data = availability === "missing" ? undefined : { rows: [] };
|
||||
const state = section({
|
||||
data,
|
||||
meta: resource(data, { availability }).meta,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
expect(state.loading).toBe(availability === "missing");
|
||||
expect(state.partial).toBe(availability === "partial");
|
||||
expect(state.hasData).toBe(availability !== "missing");
|
||||
},
|
||||
);
|
||||
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
|
||||
const retry = vi.fn();
|
||||
const refetch = vi.fn();
|
||||
const meta = resource([], {
|
||||
sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||||
}).meta;
|
||||
const state = section(
|
||||
{ data: [], meta, isError: false, isFetching: false, refetch },
|
||||
retry,
|
||||
);
|
||||
expect(state).toMatchObject({
|
||||
hasData: true,
|
||||
loading: false,
|
||||
error: true,
|
||||
refreshing: false,
|
||||
lastSuccessfulAt: meta.lastSuccessfulAt,
|
||||
});
|
||||
state.retry();
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
it("shows source failure without a false loading or empty result", () => {
|
||||
const state = section({
|
||||
data: undefined,
|
||||
meta: resource(null, {
|
||||
sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||||
}).meta,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
hasData: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,44 @@
|
||||
import type { paths } from "@/shared/api";
|
||||
|
||||
type Meta =
|
||||
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
type SectionState = {
|
||||
stale?: boolean;
|
||||
sourceError?: boolean;
|
||||
partial?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
hasData: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
function section(query: {
|
||||
data: unknown;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => unknown;
|
||||
}): SectionState {
|
||||
function section(
|
||||
query: {
|
||||
data: unknown;
|
||||
meta?: Meta;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => unknown;
|
||||
},
|
||||
retry?: () => unknown,
|
||||
): SectionState {
|
||||
const error = query.isError || query.meta?.sync.state === "failed";
|
||||
return {
|
||||
stale: query.meta?.freshness === "stale",
|
||||
sourceError: query.meta?.sync.state === "failed",
|
||||
partial: query.meta?.availability === "partial",
|
||||
lastSuccessfulAt: query.meta?.lastSuccessfulAt,
|
||||
hasData: Boolean(query.data),
|
||||
loading: !query.data && !query.isError,
|
||||
error: query.isError,
|
||||
refreshing: Boolean(query.data) && query.isFetching,
|
||||
loading: !query.data && !error,
|
||||
error,
|
||||
refreshing:
|
||||
Boolean(query.data) &&
|
||||
(query.isFetching ||
|
||||
query.meta?.sync.state === "running" ||
|
||||
query.meta?.sync.state === "queued"),
|
||||
retry: () => {
|
||||
void query.refetch();
|
||||
void (retry ?? query.refetch)();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import type { paths } from "@/shared/api";
|
||||
import type { SectionState } from "./section";
|
||||
|
||||
type Position =
|
||||
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type Candles =
|
||||
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type Position = NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
type Candles = NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
type ChartRow = Candles["candles"][number] & { date: string; price: number };
|
||||
type InstrumentPositionVm = {
|
||||
position?: Position;
|
||||
|
||||
@@ -177,7 +177,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<Text c="dimmed">{t("stock.noCandles")}</Text>
|
||||
<Text c="dimmed">
|
||||
{t(
|
||||
vm.candlesState.partial
|
||||
? "investment.partial"
|
||||
: "stock.noCandles",
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</InstrumentSection>
|
||||
|
||||
@@ -60,7 +60,11 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" py="lg">
|
||||
{t("stock.noOperations")}
|
||||
{t(
|
||||
vm.operationsState.partial
|
||||
? "investment.partial"
|
||||
: "stock.noOperations",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" mt="md">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import type { FC, PropsWithChildren, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { SectionState } from "../model/section";
|
||||
import classes from "./InstrumentDetails.module.css";
|
||||
|
||||
@@ -38,20 +39,40 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
</Title>
|
||||
{props.controls}
|
||||
</Group>
|
||||
{props.state.stale ? (
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
) : null}
|
||||
{props.state.partial ? (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
) : null}
|
||||
{props.state.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.state.refreshing ? (
|
||||
<Text role="status" size="xs" c="dimmed">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.state.error ? (
|
||||
<Alert color="orange" mb="sm" role="alert">
|
||||
<Alert color="orange" mb="sm" role="status" aria-live="polite">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{props.state.hasData
|
||||
? t("accounts.refreshError")
|
||||
: t("stock.sectionError")}
|
||||
{props.state.sourceError
|
||||
? t("investment.syncFailed")
|
||||
: props.state.hasData
|
||||
? t("accounts.refreshError")
|
||||
: t("stock.sectionError")}
|
||||
</Text>
|
||||
<Button size="xs" variant="default" onClick={props.state.retry}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={props.state.retry}
|
||||
disabled={props.state.refreshing}
|
||||
>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { useScreenSync } from "./model/screen-sync";
|
||||
export {
|
||||
type InvestmentScreen,
|
||||
useTbankSync,
|
||||
useTbankSyncStatus,
|
||||
} from "./model/sync";
|
||||
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { apiQueryClient, type paths } from "@/shared/api";
|
||||
import {
|
||||
apiQueryClient,
|
||||
invalidateApiRequests,
|
||||
type paths,
|
||||
} from "@/shared/api";
|
||||
|
||||
type TbankConnectionStatus =
|
||||
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||
@@ -15,6 +19,13 @@ function commitTbankConnectionBoundary(
|
||||
queryClient: QueryClient,
|
||||
status: TbankConnectionStatus,
|
||||
) {
|
||||
invalidateApiRequests(
|
||||
(path) =>
|
||||
path.startsWith("/api/v1/brokerage-accounts") ||
|
||||
path.startsWith("/api/v1/stocks") ||
|
||||
path.startsWith("/api/v1/bonds") ||
|
||||
path.startsWith("/api/v1/tbank-sync"),
|
||||
);
|
||||
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
||||
void queryClient.resetQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
@@ -22,7 +33,8 @@ function commitTbankConnectionBoundary(
|
||||
return (
|
||||
method === "get" &&
|
||||
typeof path === "string" &&
|
||||
(path === "/api/v1/bonds" ||
|
||||
(path.startsWith("/api/v1/tbank-sync") ||
|
||||
path === "/api/v1/bonds" ||
|
||||
path.startsWith("/api/v1/bonds/") ||
|
||||
path === "/api/v1/stocks" ||
|
||||
path.startsWith("/api/v1/stocks/") ||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { useScreenSync } from "../index";
|
||||
|
||||
function auth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
}
|
||||
describe("page-owned screen synchronization", () => {
|
||||
it("posts only its scope once and observes completion without importing again", async () => {
|
||||
auth();
|
||||
const modes: (string | null)[] = [];
|
||||
const bodies: unknown[] = [];
|
||||
let completed = false;
|
||||
let polls = 0;
|
||||
const meta = () =>
|
||||
resource([], { stateVersion: completed ? "2" : "1" }).meta;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||
return HttpResponse.json(resource([], meta()));
|
||||
}),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
completed = true;
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "screen",
|
||||
scope: { screen: "accounts" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
},
|
||||
{ status: 202 },
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () => {
|
||||
polls++;
|
||||
return HttpResponse.json({
|
||||
id: "screen",
|
||||
scope: { screen: "accounts" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [meta()],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { result, unmount } = renderHook(
|
||||
() => {
|
||||
const accounts = useBrokerageAccounts();
|
||||
return {
|
||||
accounts,
|
||||
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||
};
|
||||
},
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
||||
await act(async () => {
|
||||
await Promise.all([
|
||||
result.current.sync.refresh(),
|
||||
result.current.sync.refresh(),
|
||||
]);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
|
||||
);
|
||||
expect(bodies).toEqual([{ screen: "accounts" }]);
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(() => vi.advanceTimersByTimeAsync(120000));
|
||||
expect(polls).toBe(1);
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
unmount();
|
||||
await vi.advanceTimersByTimeAsync(120000);
|
||||
expect(polls).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it.each(["leave", "logout"])(
|
||||
"publishes completed empty coverage while history runs, then stops on %s",
|
||||
async (boundary) => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
auth();
|
||||
let completed = false;
|
||||
let polls = 0;
|
||||
const modes: (string | null)[] = [];
|
||||
const snapshot = () =>
|
||||
resource(completed ? [] : null, {
|
||||
stateVersion: completed ? "covered" : "initial",
|
||||
sync: { id: "history", state: "running", errorCode: null },
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () => {
|
||||
polls++;
|
||||
return HttpResponse.json({
|
||||
id: "history",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "running",
|
||||
errorCode: null,
|
||||
progress: {
|
||||
total: 2,
|
||||
completed: completed ? 1 : 0,
|
||||
discovering: true,
|
||||
},
|
||||
resources: [snapshot().meta],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { result, unmount } = renderHook(
|
||||
() => {
|
||||
const accounts = useBrokerageAccounts();
|
||||
return {
|
||||
accounts,
|
||||
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||
};
|
||||
},
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.accounts.meta?.sync.id).toBe("history"),
|
||||
);
|
||||
await waitFor(() => expect(polls).toBeGreaterThan(0));
|
||||
expect(result.current.sync.refreshing).toBe(true);
|
||||
expect(result.current.accounts.data).toBeUndefined();
|
||||
completed = true;
|
||||
vi.setSystemTime(Date.now() + 61000);
|
||||
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
|
||||
timeout: 3000,
|
||||
});
|
||||
expect(result.current.sync.refreshing).toBe(true);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
});
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
if (boundary === "leave") unmount();
|
||||
else await act(() => configureAuthClient(null));
|
||||
const stopped = polls;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
});
|
||||
expect(polls).toBe(stopped);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("clears private snapshots when status confirms access loss", async () => {
|
||||
auth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json(
|
||||
resource([], {
|
||||
sync: { id: "revoked", state: "running", errorCode: null },
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const accounts = useBrokerageAccounts();
|
||||
return {
|
||||
accounts,
|
||||
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||
};
|
||||
},
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
|
||||
expect(result.current.accounts.data).toBeUndefined();
|
||||
});
|
||||
it("observes a projection when only its dependency reports new coverage", async () => {
|
||||
auth();
|
||||
let observed = false;
|
||||
const modes: (string | null)[] = [];
|
||||
const dependency = resource([], {
|
||||
resourceKey: "operations",
|
||||
stateVersion: observed ? "covered" : "initial",
|
||||
sync: {
|
||||
id: "dependency",
|
||||
state: observed ? "idle" : "running",
|
||||
errorCode: null,
|
||||
},
|
||||
}).meta;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get("sync");
|
||||
modes.push(mode);
|
||||
if (mode) observed = true;
|
||||
return HttpResponse.json(
|
||||
resource(observed ? [] : null, {
|
||||
resourceKey: "projection",
|
||||
dependencies: [
|
||||
{ ...dependency, stateVersion: observed ? "covered" : "initial" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "dependency",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [{ ...dependency, stateVersion: "covered" }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const accounts = useBrokerageAccounts();
|
||||
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||
return accounts;
|
||||
},
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.data).toEqual([]));
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
});
|
||||
|
||||
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
|
||||
auth();
|
||||
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
|
||||
http.post("*/api/v1/tbank-sync", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const accounts = useBrokerageAccounts();
|
||||
return {
|
||||
accounts,
|
||||
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||
};
|
||||
},
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
||||
await act(() => result.current.sync.refresh());
|
||||
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
|
||||
expect(result.current.accounts.meta).toEqual(saved.meta);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
apiQueryClient,
|
||||
invalidateResourceAccess,
|
||||
type paths,
|
||||
useAuthenticatedQueries,
|
||||
} from "@/shared/api";
|
||||
import { type InvestmentScreen, useTbankSync } from "./sync";
|
||||
|
||||
type ResourceMeta =
|
||||
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
type ScreenResource = {
|
||||
meta?: ResourceMeta;
|
||||
observe: (version?: string) => Promise<unknown>;
|
||||
};
|
||||
const STATUS_INTERVAL_MS = 1000;
|
||||
|
||||
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
const mutation = useTbankSync();
|
||||
const client = useQueryClient();
|
||||
const denied = useRef(new Set<unknown>());
|
||||
const scopeKey = JSON.stringify(scope);
|
||||
const [requested, setRequested] = useState<{
|
||||
scope: string;
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const requestInFlight = useRef<string | null>(null);
|
||||
const currentScope = useRef(scopeKey);
|
||||
currentScope.current = scopeKey;
|
||||
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||
const observed = useRef(new Map<string, string>());
|
||||
const ids = [
|
||||
...new Set([
|
||||
...(requested?.scope === scopeKey ? [requested.id] : []),
|
||||
...resources
|
||||
.flatMap(({ meta }) =>
|
||||
meta ? [meta, ...(meta.dependencies ?? [])] : [],
|
||||
)
|
||||
.flatMap((meta) =>
|
||||
meta?.sync.id &&
|
||||
(meta.sync.state === "running" || meta.sync.state === "queued")
|
||||
? [meta.sync.id]
|
||||
: [],
|
||||
),
|
||||
]),
|
||||
];
|
||||
const statuses = useAuthenticatedQueries(
|
||||
ids.map((id) => ({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
|
||||
params: { path: { id } },
|
||||
}),
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: (query: {
|
||||
state: { error: unknown; data?: { state: string } };
|
||||
}) =>
|
||||
query.state.error ||
|
||||
(query.state.data && query.state.data.state !== "running")
|
||||
? false
|
||||
: STATUS_INTERVAL_MS,
|
||||
})),
|
||||
);
|
||||
useEffect(() => {
|
||||
for (const status of statuses) {
|
||||
if (status.error && !denied.current.has(status.error)) {
|
||||
denied.current.add(status.error);
|
||||
if (apiErrorCode(status.error) !== "NOT_FOUND")
|
||||
invalidateResourceAccess(client, "brokerage", status.error);
|
||||
}
|
||||
}
|
||||
const latest = new Map<string, ResourceMeta>();
|
||||
for (const status of [...statuses].sort(
|
||||
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
||||
))
|
||||
for (const meta of status.data?.resources ?? []) {
|
||||
latest.set(meta.resourceKey, meta);
|
||||
for (const dependency of meta.dependencies ?? [])
|
||||
latest.set(dependency.resourceKey, dependency);
|
||||
}
|
||||
for (const resource of resources) {
|
||||
if (!resource.meta) continue;
|
||||
const changed = [
|
||||
resource.meta,
|
||||
...(resource.meta.dependencies ?? []),
|
||||
].flatMap((current) => {
|
||||
const notified = latest.get(current.resourceKey);
|
||||
return notified && notified.stateVersion !== current.stateVersion
|
||||
? [notified]
|
||||
: [];
|
||||
});
|
||||
if (!changed.length) continue;
|
||||
const notification = JSON.stringify(
|
||||
changed.map((meta) => [meta.resourceKey, meta.stateVersion]),
|
||||
);
|
||||
if (observed.current.get(resource.meta.resourceKey) === notification)
|
||||
continue;
|
||||
observed.current.set(resource.meta.resourceKey, notification);
|
||||
const own = changed.find(
|
||||
(meta) => meta.resourceKey === resource.meta?.resourceKey,
|
||||
);
|
||||
void resource.observe(own?.stateVersion).catch(() => undefined);
|
||||
}
|
||||
}, [client, statuses, resources]);
|
||||
const refreshing =
|
||||
(currentMutation && mutation.isPending) ||
|
||||
statuses.some(
|
||||
(status) => !status.isError && status.data?.state === "running",
|
||||
) ||
|
||||
resources.some(({ meta }) => {
|
||||
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
|
||||
return (
|
||||
!status?.isError &&
|
||||
(!status?.data || status.data.state === "running") &&
|
||||
(meta?.sync.state === "running" || meta?.sync.state === "queued")
|
||||
);
|
||||
});
|
||||
const refresh = async () => {
|
||||
if (requestInFlight.current === scopeKey || refreshing) return;
|
||||
requestInFlight.current = scopeKey;
|
||||
try {
|
||||
const accepted = await mutation.mutateAsync({ body: scope });
|
||||
if (
|
||||
currentScope.current === scopeKey &&
|
||||
requestInFlight.current === scopeKey
|
||||
)
|
||||
setRequested({ scope: scopeKey, id: accepted.id });
|
||||
} catch {
|
||||
/* The mutation retains the transport error for presentation. */
|
||||
} finally {
|
||||
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
refresh,
|
||||
refreshing,
|
||||
transportError:
|
||||
(currentMutation && mutation.isError) ||
|
||||
statuses.some((status) => status.isError),
|
||||
sourceError:
|
||||
statuses.some((status) => status.data?.state === "failed") ||
|
||||
resources.some(({ meta }) => meta?.sync.state === "failed"),
|
||||
};
|
||||
}
|
||||
|
||||
export { useScreenSync };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import {
|
||||
type InvestmentScreen,
|
||||
useTbankSync,
|
||||
useTbankSyncStatus,
|
||||
} from "../index";
|
||||
import { commitTbankConnectionBoundary } from "./connection-boundary";
|
||||
|
||||
describe("sync connection and session boundaries", () => {
|
||||
it.each(["connection", "session"])(
|
||||
"discards pending status and mutation after %s changes",
|
||||
async (boundary) => {
|
||||
let generation = 0;
|
||||
configureAuthClient({
|
||||
generation: () => generation,
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const accepted = {
|
||||
id: "work",
|
||||
scope: { screen: "accounts" },
|
||||
now: "2026-09-25T12:00:00Z",
|
||||
};
|
||||
let started = 0;
|
||||
let changed = false;
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async () => {
|
||||
started += 1;
|
||||
await blocked;
|
||||
return HttpResponse.json(accepted, { status: 202 });
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", async () => {
|
||||
const now = changed ? "2026-09-25T12:01:00Z" : accepted.now;
|
||||
started += 1;
|
||||
await blocked;
|
||||
return HttpResponse.json({
|
||||
...accepted,
|
||||
now,
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { wrapper, queryClient } = createQueryWrapper();
|
||||
const published: string[] = [];
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
|
||||
const data = event.query.state.data as { now?: string } | undefined;
|
||||
if (data?.now) published.push(data.now);
|
||||
}
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
mutation: useTbankSync(),
|
||||
status: useTbankSyncStatus("work"),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
const screen: InvestmentScreen = { screen: "accounts" };
|
||||
let mutation!: Promise<unknown>;
|
||||
act(() => {
|
||||
mutation = result.current.mutation.mutateAsync({ body: screen });
|
||||
});
|
||||
const discarded = expect(mutation).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await waitFor(() => expect(started).toBe(2));
|
||||
await act(async () => {
|
||||
changed = true;
|
||||
if (boundary === "connection")
|
||||
commitTbankConnectionBoundary(queryClient, {
|
||||
connected: false,
|
||||
connectedAt: null,
|
||||
});
|
||||
else {
|
||||
generation += 1;
|
||||
queryClient.clear();
|
||||
}
|
||||
release();
|
||||
await blocked;
|
||||
});
|
||||
await discarded;
|
||||
expect(result.current.mutation.data).toBeUndefined();
|
||||
const key = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/tbank-sync/{id}",
|
||||
{ params: { path: { id: "work" } } },
|
||||
).queryKey;
|
||||
// A new request may start after a successful connection boundary; an old result cannot win it.
|
||||
if (boundary === "session")
|
||||
expect(queryClient.getQueryData(key)).toBeUndefined();
|
||||
else
|
||||
await waitFor(() =>
|
||||
expect(queryClient.getQueryData(key)).toMatchObject({
|
||||
now: "2026-09-25T12:01:00Z",
|
||||
}),
|
||||
);
|
||||
expect(published).not.toContain(accepted.now);
|
||||
unsubscribe();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
type InvestmentScreen =
|
||||
paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
|
||||
function useTbankSync() {
|
||||
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
function useTbankSyncStatus(id: string | null) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
|
||||
params: { path: { id: id ?? "" } },
|
||||
}),
|
||||
enabled: Boolean(id),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export { type InvestmentScreen, useTbankSync, useTbankSyncStatus };
|
||||
@@ -1,6 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
@@ -12,9 +18,11 @@ import {
|
||||
} from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { createQueryWrapper, createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => {
|
||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||
({ request }) => {
|
||||
searches.push(new URL(request.url).search);
|
||||
return HttpResponse.json({
|
||||
accountId,
|
||||
calculatedAt: "2099-01-01T00:00:00Z",
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId,
|
||||
calculatedAt: "2099-01-01T00:00:00Z",
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -110,3 +120,45 @@ describe("Upcoming event date filters", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const events = [
|
||||
{
|
||||
date: "2026-10-01",
|
||||
displayName: "Synthetic coupon",
|
||||
type: "coupon",
|
||||
projectedAmount: { currency: "RUB", value: "123.45" },
|
||||
},
|
||||
];
|
||||
const snapshot = resource(
|
||||
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
|
||||
{ sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
|
||||
);
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(snapshot),
|
||||
),
|
||||
);
|
||||
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryWrapper>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
events,
|
||||
sourceError: true,
|
||||
refreshError: true,
|
||||
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
|
||||
const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
|
||||
vi.mock("@/features/tbank-connection", () => ({
|
||||
useScreenSync: () => ({
|
||||
refresh,
|
||||
refreshing: false,
|
||||
sourceError: false,
|
||||
transportError: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
||||
useBrokerageEvents: vi.fn(),
|
||||
@@ -24,12 +33,17 @@ function renderPage() {
|
||||
|
||||
describe("AccountEventsPage", () => {
|
||||
beforeEach(() => {
|
||||
refresh.mockClear();
|
||||
query.mockReturnValue({
|
||||
data: {
|
||||
accountId: "account",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [],
|
||||
},
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -55,6 +69,10 @@ describe("AccountEventsPage", () => {
|
||||
it("keeps the loading fallback when no result or account is available", () => {
|
||||
query.mockReturnValue({
|
||||
data: undefined,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -99,6 +117,10 @@ describe("AccountEventsPage", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -145,12 +167,16 @@ describe("AccountEventsPage", () => {
|
||||
</MantineProvider>,
|
||||
);
|
||||
screen.getByRole("button", { name: "Повторить" }).click();
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows a structural busy state before events are available", () => {
|
||||
query.mockReturnValue({
|
||||
data: undefined,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { state: "idle", id: null, errorCode: null },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
refetch: vi.fn(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
|
||||
type AccountEventsPageViewModel =
|
||||
| { state: "loading" }
|
||||
@@ -11,6 +12,9 @@ type AccountEventsPageViewModel =
|
||||
| {
|
||||
state: "loaded";
|
||||
events: BrokerageEvent[];
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
@@ -41,22 +45,34 @@ function useAccountEventsPageViewModel(
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "events",
|
||||
accountId,
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
},
|
||||
[events],
|
||||
);
|
||||
if (events.isLoading) return { state: "loading" };
|
||||
if (events.isError && !events.data)
|
||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||
return {
|
||||
state: "error",
|
||||
retry: () => {
|
||||
void events.refetch();
|
||||
void sync.refresh();
|
||||
},
|
||||
};
|
||||
if (!events.data) return { state: "loading" };
|
||||
return {
|
||||
state: "loaded",
|
||||
events: events.data.events,
|
||||
refreshing: events.isFetching,
|
||||
refreshError: events.isError,
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||
partial: events.meta?.availability !== "complete",
|
||||
refreshing: events.isFetching || sync.refreshing,
|
||||
refreshError: events.isError || sync.sourceError || sync.transportError,
|
||||
retry: () => {
|
||||
void events.refetch();
|
||||
void sync.refresh();
|
||||
},
|
||||
draftFrom,
|
||||
draftTo,
|
||||
|
||||
+22
-4
@@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
||||
|
||||
@@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : null}
|
||||
{props.refreshing ? (
|
||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||
) : null}
|
||||
{props.refreshError ? (
|
||||
<Alert color="yellow">
|
||||
{t("accounts.refreshError")}
|
||||
<Alert color="yellow" role="status" aria-live="polite">
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
<Button variant="subtle" onClick={props.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
@@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
) : !props.partial ? (
|
||||
<AccountEventsPageEmpty />
|
||||
)}
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resource } from "@/test/resource";
|
||||
import "dayjs/locale/ru";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { DatesProvider } from "@mantine/dates";
|
||||
@@ -154,7 +155,7 @@ describe("AccountIncomePage", () => {
|
||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||
({ request }) => {
|
||||
requested.push(new URL(request.url).search);
|
||||
return HttpResponse.json(analytics);
|
||||
return HttpResponse.json(resource(analytics));
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -236,7 +237,7 @@ describe("AccountIncomePage", () => {
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(response),
|
||||
HttpResponse.json(resource(response)),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -284,7 +285,7 @@ describe("AccountIncomePage", () => {
|
||||
])("shows a local error for %s", async (_name, response) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(response),
|
||||
HttpResponse.json(resource(response)),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -299,13 +300,15 @@ describe("AccountIncomePage", () => {
|
||||
it("accepts an empty analytics response", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json({
|
||||
...analytics,
|
||||
totalsByCurrency: [],
|
||||
buckets: [],
|
||||
instruments: [],
|
||||
entries: [],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...analytics,
|
||||
totalsByCurrency: [],
|
||||
buckets: [],
|
||||
instruments: [],
|
||||
entries: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -347,7 +350,7 @@ describe("AccountIncomePage", () => {
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(response),
|
||||
HttpResponse.json(resource(response)),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -369,13 +372,15 @@ describe("AccountIncomePage", () => {
|
||||
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json({
|
||||
...analytics,
|
||||
yearComparison: {
|
||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...analytics,
|
||||
yearComparison: {
|
||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||
);
|
||||
@@ -392,13 +397,16 @@ describe("AccountIncomePage", () => {
|
||||
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
||||
expect(eventsRequest).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
||||
const selected = screen.getByRole("region", {
|
||||
const year = screen.getByRole("region", { name: "Годом ранее" });
|
||||
const selected = within(year).getByRole("region", {
|
||||
name: "01.08.2026 – 27.08.2026",
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Предыдущий период" }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
|
||||
expect(
|
||||
@@ -409,7 +417,7 @@ describe("AccountIncomePage", () => {
|
||||
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
|
||||
expect(within(selected).getByText(amount)).toBeInTheDocument();
|
||||
}
|
||||
const difference = screen.getByRole("region", { name: "Разница" });
|
||||
const difference = within(year).getByRole("region", { name: "Разница" });
|
||||
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
|
||||
expect(within(difference).getByText(amount)).toBeInTheDocument();
|
||||
}
|
||||
@@ -425,7 +433,18 @@ describe("AccountIncomePage", () => {
|
||||
it("shows the unavailable year state without falling back to the previous period", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(analytics),
|
||||
HttpResponse.json(
|
||||
resource(analytics, {
|
||||
dependencies: [
|
||||
resource(analytics).meta,
|
||||
resource(analytics).meta,
|
||||
{
|
||||
...resource(null).meta,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -433,9 +452,11 @@ describe("AccountIncomePage", () => {
|
||||
await screen.findByText("Сравнение с годом ранее недоступно."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Разница" }),
|
||||
screen.queryByRole("region", { name: "Годом ранее" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Предыдущий период" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows a recoverable realized error", async () => {
|
||||
@@ -537,13 +558,15 @@ function respondWithRequestedPeriod(requests: string[]) {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url.search);
|
||||
return HttpResponse.json({
|
||||
...analytics,
|
||||
period: {
|
||||
from: url.searchParams.get("from"),
|
||||
to: url.searchParams.get("to"),
|
||||
},
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
...analytics,
|
||||
period: {
|
||||
from: url.searchParams.get("from"),
|
||||
to: url.searchParams.get("to"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -669,7 +692,32 @@ describe("income period and snapshot boundary", () => {
|
||||
await screen.findByText(/Показан последний загруженный результат/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||
respondWithRequestedPeriod([]);
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
id: "income",
|
||||
scope: { screen: "income", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
},
|
||||
{ status: 202 },
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "income",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [resource(null, { stateVersion: "2" }).meta],
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -694,10 +742,12 @@ describe("income period and snapshot boundary", () => {
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
async () => {
|
||||
await pending;
|
||||
return HttpResponse.json({
|
||||
...analytics,
|
||||
period: { from: "2026-08-02", to: "2026-08-27" },
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
...analytics,
|
||||
period: { from: "2026-08-02", to: "2026-08-27" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -778,3 +828,64 @@ describe("income period and snapshot boundary", () => {
|
||||
expect(screen.getAllByText("Купон")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource({ ...analytics, previous: null, yearComparison: null }),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Год назад" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByText("Период сравнения ещё не загружен полностью."),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
screen.queryByText("Сравнение с годом ранее недоступно."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Разница" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Период")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(analytics, {
|
||||
dependencies: [
|
||||
resource(analytics).meta,
|
||||
resource(analytics).meta,
|
||||
{
|
||||
...resource(null).meta,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed",
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "Предыдущий период" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Годом ранее" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
import { formatDate } from "@/shared/lib";
|
||||
import {
|
||||
@@ -17,7 +18,16 @@ import {
|
||||
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
|
||||
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||
type ComparisonNotice =
|
||||
| "comparisonPending"
|
||||
| "comparisonFailed"
|
||||
| "yearUnavailable";
|
||||
|
||||
type IncomeDisplayModel = {
|
||||
previousComparison: IncomeDisplayModel["comparison"];
|
||||
previousNotice: ComparisonNotice;
|
||||
yearNotice: ComparisonNotice;
|
||||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
||||
comparison: {
|
||||
selectedPeriod: string;
|
||||
@@ -38,6 +48,9 @@ type AccountIncomePageViewModel =
|
||||
analytics: IncomeAnalytics;
|
||||
display: IncomeDisplayModel;
|
||||
formRevision: number;
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
@@ -90,6 +103,10 @@ const useAccountIncomePageViewModel = (
|
||||
});
|
||||
const [formRevision, setFormRevision] = useState(0);
|
||||
const analytics = useIncomeAnalytics(accountId, period);
|
||||
const sync = useScreenSync(
|
||||
{ screen: "income", accountId, from: period.from, to: period.to },
|
||||
[analytics],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
|
||||
@@ -112,21 +129,30 @@ const useAccountIncomePageViewModel = (
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
if (analytics.isError)
|
||||
return { state: "error", retry: () => analytics.refetch(), period };
|
||||
if (analytics.isError || analytics.meta?.sync.state === "failed")
|
||||
return { state: "error", retry: sync.refresh, period };
|
||||
return { state: "loading" };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics.data);
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
if (!display)
|
||||
return { state: "error", retry: () => analytics.refetch(), period };
|
||||
return {
|
||||
state: "error",
|
||||
retry: () => {
|
||||
void analytics.observe();
|
||||
},
|
||||
period,
|
||||
};
|
||||
return {
|
||||
state: "loaded",
|
||||
analytics: analytics.data,
|
||||
display,
|
||||
formRevision,
|
||||
refreshing: analytics.isFetching,
|
||||
refreshError: analytics.isError,
|
||||
retry: () => analytics.refetch(),
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
refreshing: analytics.isFetching || sync.refreshing,
|
||||
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||
retry: sync.refresh,
|
||||
form,
|
||||
onSubmit,
|
||||
onReset,
|
||||
@@ -157,6 +183,7 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
|
||||
|
||||
function toIncomeDisplayModel(
|
||||
analytics: IncomeAnalytics,
|
||||
meta?: IncomeMeta,
|
||||
): IncomeDisplayModel | null {
|
||||
const instruments = new Map<
|
||||
string,
|
||||
@@ -209,33 +236,53 @@ function toIncomeDisplayModel(
|
||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||
}
|
||||
|
||||
// The income endpoint returns selected, previous and year dependencies in order.
|
||||
const [, previous, year] = meta?.dependencies ?? [];
|
||||
return {
|
||||
entries,
|
||||
comparison: analytics.yearComparison
|
||||
? {
|
||||
selectedPeriod: formatComparisonPeriod(analytics.period),
|
||||
comparedPeriod: formatComparisonPeriod(
|
||||
analytics.yearComparison.period,
|
||||
),
|
||||
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
|
||||
...value,
|
||||
percentages: {
|
||||
total: formatIncomeChange(
|
||||
value.difference.total.value,
|
||||
value.compared.total.value,
|
||||
),
|
||||
coupon: formatIncomeChange(
|
||||
value.difference.coupon.value,
|
||||
value.compared.coupon.value,
|
||||
),
|
||||
dividend: formatIncomeChange(
|
||||
value.difference.dividend.value,
|
||||
value.compared.dividend.value,
|
||||
),
|
||||
},
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||
previousNotice: comparisonNotice(previous),
|
||||
yearNotice: comparisonNotice(year),
|
||||
};
|
||||
}
|
||||
|
||||
function comparisonNotice(
|
||||
dependency:
|
||||
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||
| undefined,
|
||||
): ComparisonNotice {
|
||||
if (dependency?.unavailableReason) return "yearUnavailable";
|
||||
return dependency?.sync.state === "failed"
|
||||
? "comparisonFailed"
|
||||
: "comparisonPending";
|
||||
}
|
||||
|
||||
function comparisonDisplay(
|
||||
period: IncomePeriod,
|
||||
comparison: IncomeAnalytics["yearComparison"],
|
||||
): IncomeDisplayModel["comparison"] {
|
||||
if (!comparison) return null;
|
||||
return {
|
||||
selectedPeriod: formatComparisonPeriod(period),
|
||||
comparedPeriod: formatComparisonPeriod(comparison.period),
|
||||
values: comparison.totalsByCurrency.map((value) => ({
|
||||
...value,
|
||||
percentages: {
|
||||
total: formatIncomeChange(
|
||||
value.difference.total.value,
|
||||
value.compared.total.value,
|
||||
),
|
||||
coupon: formatIncomeChange(
|
||||
value.difference.coupon.value,
|
||||
value.compared.coupon.value,
|
||||
),
|
||||
dividend: formatIncomeChange(
|
||||
value.difference.dividend.value,
|
||||
value.compared.dividend.value,
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+32
-4
@@ -11,6 +11,7 @@ import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||
@@ -34,7 +35,17 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg" className={classes.page}>
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
<Button onClick={retry} disabled={refreshing} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
<FormProvider {...form}>
|
||||
<IncomePeriodForm
|
||||
key={props.formRevision}
|
||||
@@ -49,16 +60,24 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("income.refreshError")}</Text>
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "income.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
<Button onClick={retry} variant="default">
|
||||
{t("income.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
{analytics.totalsByCurrency.length ? (
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : analytics.totalsByCurrency.length ? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
) : (
|
||||
<StateView
|
||||
@@ -67,7 +86,16 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
/>
|
||||
)}
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
<IncomeComparisons comparison={display.comparison} />
|
||||
<IncomeComparisons
|
||||
title={t("income.previousPeriod")}
|
||||
comparison={display.previousComparison}
|
||||
notice={t(`income.${display.previousNotice}`)}
|
||||
/>
|
||||
<IncomeComparisons
|
||||
title={t("income.yearAgo")}
|
||||
comparison={display.comparison}
|
||||
notice={t(`income.${display.yearNotice}`)}
|
||||
/>
|
||||
<IncomePayments entries={display.entries} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+13
-8
@@ -5,8 +5,14 @@ import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
|
||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
|
||||
type IncomeComparisonsProps = {
|
||||
comparison: IncomeDisplayModel["comparison"];
|
||||
title: string;
|
||||
notice: string;
|
||||
};
|
||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
|
||||
title: string;
|
||||
};
|
||||
type ComparisonValuesProps = {
|
||||
kind: "selected" | "compared" | "difference";
|
||||
label: string;
|
||||
@@ -15,16 +21,15 @@ type ComparisonValuesProps = {
|
||||
};
|
||||
|
||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.yearAgo")}</Title>
|
||||
<Title order={2}>{props.title}</Title>
|
||||
{props.comparison ? (
|
||||
<Comparison {...props.comparison} />
|
||||
<Comparison {...props.comparison} title={props.title} />
|
||||
) : (
|
||||
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
||||
<Text size="sm" c="gray.7">
|
||||
{t("income.yearUnavailable")}
|
||||
<Text size="sm" c="gray.7" role="status" aria-live="polite">
|
||||
{props.notice}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -35,7 +40,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
const Comparison: FC<ComparisonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
|
||||
<section aria-label={props.title} className={classes.comparison}>
|
||||
<Stack gap="md">
|
||||
{props.values.map((value) => (
|
||||
<Stack
|
||||
|
||||
+87
-23
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/entities/broker-operation";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountOperationsPage } from "./AccountOperationsPage";
|
||||
|
||||
@@ -39,11 +40,13 @@ function configureTestAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
function renderPage(search = "") {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/accounts/account-1/operations${search}`]}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
@@ -193,13 +196,15 @@ describe("AccountOperationsPage", () => {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
searches.push(url.search);
|
||||
return HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
||||
hasNext: !url.searchParams.get("cursor"),
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
||||
hasNext: !url.searchParams.get("cursor"),
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
searches.push(url.search);
|
||||
return HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => {
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
searches.push(url.search);
|
||||
return HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{
|
||||
accountId: "account-1",
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
},
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(await screen.findByText("Период загружен не полностью")).toBeVisible();
|
||||
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("recovers an invalid cursor at the first page without clearing applied filters", async () => {
|
||||
configureTestAuth();
|
||||
const searches: URLSearchParams[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
searches.push(params);
|
||||
if (params.has("cursor"))
|
||||
return HttpResponse.json(
|
||||
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
items: [operation],
|
||||
hasNext: true,
|
||||
nextCursor: "old-cursor",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(searches.length).toBe(3));
|
||||
expect(searches[2].get("cursor")).toBeNull();
|
||||
for (const key of ["from", "to", "categories"])
|
||||
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
type AccountOperationsPageViewModel =
|
||||
@@ -16,6 +17,10 @@ type AccountOperationsPageViewModel =
|
||||
| { state: "error"; retry: () => void }
|
||||
| {
|
||||
state: "loaded";
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
historyLoading?: boolean;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
@@ -82,6 +87,10 @@ const useAccountOperationsPageVm = (
|
||||
...firstPageQuery,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{ screen: "operations", accountId, ...firstPageQuery },
|
||||
[operations],
|
||||
);
|
||||
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||
limit: 100,
|
||||
}).queryKey;
|
||||
@@ -95,12 +104,12 @@ const useAccountOperationsPageVm = (
|
||||
exact: true,
|
||||
});
|
||||
resetCursors();
|
||||
} else void operations.refetch();
|
||||
} else void sync.refresh();
|
||||
};
|
||||
|
||||
if (operations.isLoading) return { state: "loading" };
|
||||
if (
|
||||
operations.isError &&
|
||||
(operations.isError || operations.meta?.sync.state === "failed") &&
|
||||
(!operations.data ||
|
||||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||
)
|
||||
@@ -115,8 +124,14 @@ const useAccountOperationsPageVm = (
|
||||
|
||||
return {
|
||||
state: "loaded",
|
||||
refreshing: operations.isFetching,
|
||||
refreshError: operations.isError,
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
partial: operations.meta?.availability !== "complete",
|
||||
historyLoading:
|
||||
operations.meta?.coverage?.historyState === "loading" ||
|
||||
operations.meta?.coverage?.historyState === "partial",
|
||||
refreshing: operations.isFetching || sync.refreshing,
|
||||
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
||||
retry,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
|
||||
+24
-4
@@ -5,6 +5,7 @@ import {
|
||||
MultiSelect,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type OperationCategory,
|
||||
operationCategories,
|
||||
} from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
type AccountOperationsPageLoadedProps = Extract<
|
||||
@@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Title order={1}>{t("ledger.title")}</Title>
|
||||
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : null}
|
||||
{props.historyLoading ? <Alert>{t("investment.history")}</Alert> : null}
|
||||
{props.refreshing ? (
|
||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||
) : null}
|
||||
{props.refreshError ? (
|
||||
<Alert color="yellow">
|
||||
{t("accounts.refreshError")}
|
||||
<Alert color="yellow" role="status" aria-live="polite">
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
<Button variant="subtle" onClick={props.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
@@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
operation={operation}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
) : !props.partial ? (
|
||||
<StateView
|
||||
title={t("ledger.emptyTitle")}
|
||||
text={t("ledger.emptyText")}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{hasNext || hasPrevious ? (
|
||||
<Group justify="space-between">
|
||||
<Button disabled={!hasPrevious} onClick={onPrevious}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
import type {
|
||||
@@ -144,27 +145,31 @@ describe("AccountPage states", () => {
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
positions:
|
||||
accountId === "all"
|
||||
? ["account-1", "account-2"].map((id) => ({
|
||||
...position,
|
||||
account: { id, name: id },
|
||||
}))
|
||||
: [position],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...portfolio,
|
||||
positions:
|
||||
accountId === "all"
|
||||
? ["account-1", "account-2"].map((id) => ({
|
||||
...position,
|
||||
account: { id, name: id },
|
||||
}))
|
||||
: [position],
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get(`*/api/v1/${collection}/:uid`, () => {
|
||||
requests();
|
||||
return HttpResponse.json({
|
||||
instrumentUid: uid,
|
||||
slug,
|
||||
name: "Название из карточки инструмента",
|
||||
ticker: "OTHER-TICKER",
|
||||
isin: "OTHER-ISIN",
|
||||
logoName: "other.png",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
instrumentUid: uid,
|
||||
slug,
|
||||
name: "Название из карточки инструмента",
|
||||
ticker: "OTHER-TICKER",
|
||||
isin: "OTHER-ISIN",
|
||||
logoName: "other.png",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureTestAuth();
|
||||
@@ -204,22 +209,24 @@ describe("AccountPage states", () => {
|
||||
const requests = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
...portfolio.positions[0],
|
||||
instrumentUid: uid,
|
||||
ticker: "SBERP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
...portfolio.positions[0],
|
||||
instrumentUid: uid,
|
||||
ticker: "SBERP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/stocks/:uid", () => {
|
||||
requests();
|
||||
return scenario === "unavailable"
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({ instrumentUid: uid, slug: uid });
|
||||
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
|
||||
}),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
|
||||
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({ ...portfolio, structure: undefined }),
|
||||
HttpResponse.json(resource({ ...portfolio, structure: undefined })),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
@@ -322,11 +329,13 @@ describe("AccountPage states", () => {
|
||||
async (hasPositions) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
totalsByCurrency: [],
|
||||
positions: hasPositions ? portfolio.positions : [],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...portfolio,
|
||||
totalsByCurrency: [],
|
||||
positions: hasPositions ? portfolio.positions : [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(snapshot),
|
||||
HttpResponse.json(resource(snapshot)),
|
||||
),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
|
||||
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
|
||||
expect(
|
||||
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
||||
).toEqual(snapshot);
|
||||
).toEqual(resource(snapshot));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([503, 404])(
|
||||
it.each([503])(
|
||||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||
async (status) => {
|
||||
const initialPortfolio: Portfolio = {
|
||||
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
|
||||
let requestCount = 0;
|
||||
let resolveRetry: ((response: Response) => void) | undefined;
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
},
|
||||
{ status: 202 },
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [
|
||||
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
|
||||
.meta,
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
|
||||
if (requestCount === 1)
|
||||
return HttpResponse.json(
|
||||
resource(initialPortfolio, { resourceKey: "portfolio" }),
|
||||
);
|
||||
if (requestCount === 3)
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
|
||||
snapshotText,
|
||||
);
|
||||
resolveRetry?.(
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{
|
||||
...portfolio,
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||
},
|
||||
{ resourceKey: "portfolio", stateVersion: "2" },
|
||||
),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
||||
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
|
||||
screen.getByRole("status", { name: "Обновляем портфель" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
resolveRefresh?.(HttpResponse.json(portfolio));
|
||||
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
@@ -28,9 +29,11 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
partial={viewModel.partial}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
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 { MemoryRouter, Route, Routes } from "react-router";
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import {
|
||||
toAccountPagePositionSections,
|
||||
@@ -86,49 +87,53 @@ function createWrapper() {
|
||||
function mockLoadedAccountPage() {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
HttpResponse.json(resource(portfolio)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [
|
||||
{
|
||||
id: "operation-1",
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
type: "Buy",
|
||||
category: "trade",
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
instrumentUid: null,
|
||||
ticker: "STK",
|
||||
instrumentType: "share",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
quantity: "1",
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [
|
||||
{
|
||||
id: "operation-1",
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
type: "Buy",
|
||||
category: "trade",
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
instrumentUid: null,
|
||||
ticker: "STK",
|
||||
instrumentType: "share",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
quantity: "1",
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ({
|
||||
type: "coupon",
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
ticker: `BND${index}`,
|
||||
displayName: `Купон ${index}`,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
})),
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ({
|
||||
type: "coupon",
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
ticker: `BND${index}`,
|
||||
displayName: `Купон ${index}`,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
})),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
HttpResponse.json(resource(portfolio)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
@@ -304,3 +309,111 @@ describe("useAccountPageViewModel", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps overview sections independent when their source data is incomplete", async () => {
|
||||
configureTestAuth();
|
||||
mockLoadedAccountPage();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(portfolio, { availability: "partial" })),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ items: [], hasNext: false, nextCursor: null },
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ events: [], incomeTotalsByCurrency: [] },
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
partial: true,
|
||||
recentOperations: { state: "partial", items: [] },
|
||||
recentEvents: { state: "partial", items: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes every overview section through one account descriptor and observational reads", async () => {
|
||||
configureTestAuth();
|
||||
const sections: Record<string, unknown> = {
|
||||
portfolio,
|
||||
operations: { items: [], hasNext: false, nextCursor: null },
|
||||
events: { events: [], incomeTotalsByCurrency: [] },
|
||||
"income-summary": { totalsByCurrency: [] },
|
||||
};
|
||||
const observed: string[] = [];
|
||||
const scopes: unknown[] = [];
|
||||
let version = "1";
|
||||
server.use(
|
||||
...Object.entries(sections).map(([path, data]) =>
|
||||
http.get(
|
||||
`*/api/v1/brokerage-accounts/:accountId/${path}`,
|
||||
({ request }) => {
|
||||
if (new URL(request.url).searchParams.get("sync") === "observe")
|
||||
observed.push(path);
|
||||
return HttpResponse.json(
|
||||
resource(data, { resourceKey: path, stateVersion: version }),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
version = "2";
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
},
|
||||
{ status: 202 },
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "overview",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 4, completed: 4, discovering: false },
|
||||
resources: Object.keys(sections).map(
|
||||
(resourceKey) =>
|
||||
resource(null, { resourceKey, stateVersion: version }).meta,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
recentOperations: { state: "empty" },
|
||||
recentEvents: { state: "empty" },
|
||||
incomeSummary: { state: "empty" },
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
if (result.current.state === "loaded") await result.current.retry();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
|
||||
);
|
||||
expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router";
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import {
|
||||
type BrokerageAccount,
|
||||
brokerageAccountsQueryKey,
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
import {
|
||||
type BrokerageEventsPreviewState,
|
||||
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||
import { useStocks } from "@/entities/stock";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageIncomeSummary =
|
||||
| { state: "loading" | "empty"; to: string }
|
||||
| { state: "loading" | "partial" | "empty"; to: string }
|
||||
| { state: "error"; to: string; retry: () => void }
|
||||
| { state: "loaded"; to: string; summary: IncomeSummary };
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
summary: IncomeSummary;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageContent = {
|
||||
sourceError?: boolean;
|
||||
partial?: boolean;
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing: boolean;
|
||||
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
});
|
||||
const events = useBrokerageEvents(accountId);
|
||||
const income = useIncomeSummary(accountId);
|
||||
const account = useQueryClient()
|
||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
||||
?.find((item) => item.id === accountId);
|
||||
const accounts = useBrokerageAccounts();
|
||||
const sync = useScreenSync({ screen: "account", accountId }, [
|
||||
accounts,
|
||||
portfolio,
|
||||
operations,
|
||||
events,
|
||||
income,
|
||||
]);
|
||||
const account = accounts.data?.find((item) => item.id === accountId);
|
||||
const recentOperations = toRecentOperations(accountId, operations);
|
||||
const recentEvents = toRecentEvents(accountId, events);
|
||||
const incomeSummary = toIncomeSummary(accountId, income);
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError)
|
||||
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: () => portfolio.refetch(),
|
||||
retry: sync.refresh,
|
||||
};
|
||||
return { state: "loading" as const };
|
||||
}
|
||||
const refresh = {
|
||||
refreshing: portfolio.isFetching,
|
||||
refreshError: portfolio.isError,
|
||||
retry: () => portfolio.refetch(),
|
||||
partial: portfolio.meta?.availability === "partial",
|
||||
refreshing: portfolio.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.refresh,
|
||||
};
|
||||
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
|
||||
if (
|
||||
portfolio.meta?.availability === "complete" &&
|
||||
!portfolio.data.positions.length &&
|
||||
!portfolio.data.cashBalances.length
|
||||
)
|
||||
return {
|
||||
state: "empty" as const,
|
||||
account,
|
||||
@@ -153,11 +177,20 @@ function toIncomeSummary(
|
||||
income: ReturnType<typeof useIncomeSummary>,
|
||||
): AccountPageIncomeSummary {
|
||||
const to = `/accounts/${accountId}/income`;
|
||||
if (income.isLoading) return { state: "loading", to };
|
||||
if (income.isError)
|
||||
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
|
||||
return { state: "loading", to };
|
||||
if (income.meta?.availability === "partial") return { state: "partial", to };
|
||||
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
|
||||
return { state: "error", to, retry: () => income.refetch() };
|
||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, summary: income.data };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
summary: income.data,
|
||||
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||
stale: income.meta?.freshness === "stale",
|
||||
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentOperations(
|
||||
@@ -165,10 +198,29 @@ function toRecentOperations(
|
||||
operations: ReturnType<typeof useBrokerageOperations>,
|
||||
): AccountPageRecentOperations {
|
||||
const to = `/accounts/${accountId}/operations`;
|
||||
if (operations.isLoading) return { state: "loading", to };
|
||||
if (operations.isError) return { state: "error", to };
|
||||
if (
|
||||
!operations.data &&
|
||||
!operations.isError &&
|
||||
operations.meta?.sync.state !== "failed"
|
||||
)
|
||||
return { state: "loading", to };
|
||||
if (operations.meta?.availability === "partial")
|
||||
return { state: "partial", to, items: operations.data?.items ?? [] };
|
||||
if (
|
||||
!operations.data &&
|
||||
(operations.isError || operations.meta?.sync.state === "failed")
|
||||
)
|
||||
return { state: "error", to };
|
||||
if (!operations.data?.items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items: operations.data.items };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
items: operations.data.items,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
stale: operations.meta?.freshness === "stale",
|
||||
refreshError:
|
||||
operations.isError || operations.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
@@ -176,11 +228,26 @@ function toRecentEvents(
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): AccountPageRecentEvents {
|
||||
const to = `/accounts/${accountId}/events`;
|
||||
if (events.isLoading) return { state: "loading", to };
|
||||
if (events.isError) return { state: "error", to };
|
||||
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
|
||||
return { state: "loading", to };
|
||||
if (events.meta?.availability === "partial")
|
||||
return {
|
||||
state: "partial",
|
||||
to,
|
||||
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
||||
};
|
||||
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
|
||||
return { state: "error", to };
|
||||
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
|
||||
if (!items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
items,
|
||||
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||
stale: events.meta?.freshness === "stale",
|
||||
refreshError: events.isError || events.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toAccountPagePositionSections(
|
||||
|
||||
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing?: boolean;
|
||||
sourceError?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
sourceError={props.sourceError}
|
||||
refreshError={props.refreshError ?? false}
|
||||
retry={props.retry}
|
||||
/>
|
||||
|
||||
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
partial?: boolean;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
sourceError?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
sourceError={props.sourceError}
|
||||
refreshError={props.refreshError ?? false}
|
||||
retry={props.retry}
|
||||
/>
|
||||
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : (
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
)}
|
||||
{!props.partial ? (
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
) : null}
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={[
|
||||
|
||||
@@ -4,18 +4,34 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
type IncomeSummaryProps =
|
||||
| { state: "loading"; to: string }
|
||||
| { state: "partial"; to: string }
|
||||
| { state: "error"; to: string; retry: () => void }
|
||||
| { state: "empty"; to: string }
|
||||
| { state: "loaded"; to: string; summary: IncomeSummaryData };
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
summary: IncomeSummaryData;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
if (props.state === "loading")
|
||||
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
|
||||
if (props.state === "loading" || props.state === "partial")
|
||||
return (
|
||||
<Text role="status" c="dimmed">
|
||||
{t(
|
||||
props.state === "partial"
|
||||
? "investment.partial"
|
||||
: "income.summaryLoading",
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
if (props.state === "error")
|
||||
return (
|
||||
<StateView
|
||||
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
|
||||
{props.refreshError ? (
|
||||
<Text role="status">{t("income.refreshError")}</Text>
|
||||
) : null}
|
||||
{props.summary.totalsByCurrency.map((total) => (
|
||||
<Paper key={total.currency} p="sm" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
|
||||
+5
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
|
||||
name: "Ошибка обновления с retry",
|
||||
args: { refreshError: true },
|
||||
};
|
||||
|
||||
export const SourceFailure: Story = {
|
||||
name: "Ошибка синхронизации сохраняет суммы",
|
||||
args: { refreshError: true, sourceError: true },
|
||||
};
|
||||
|
||||
+17
-6
@@ -3,6 +3,7 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PortfolioRefreshStateProps = {
|
||||
sourceError?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry?: () => void;
|
||||
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
|
||||
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
const { refreshing, refreshError, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={retry}
|
||||
disabled={refreshing}
|
||||
variant="light"
|
||||
w="fit-content"
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("portfolio.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "portfolio.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
|
||||
type RecentOperationsProps = {
|
||||
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{operations.state === "loaded" ? (
|
||||
<>
|
||||
{operations.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{operations.stale ? (
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
) : null}
|
||||
{operations.refreshError ? (
|
||||
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{operations.state === "loading" ? (
|
||||
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
{operations.state === "empty" ? (
|
||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{operations.state === "loaded"
|
||||
{operations.state === "partial" ? (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
) : null}
|
||||
{operations.state === "loaded" || operations.state === "partial"
|
||||
? operations.items.map((operation) => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||
@@ -80,7 +81,7 @@ describe("AccountsPage", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json([account]);
|
||||
if (requestCount === 1) return HttpResponse.json(resource([account]));
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
@@ -100,21 +101,31 @@ describe("AccountsPage", () => {
|
||||
screen.getByRole("status", { name: "Обновляем данные" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
resolveRefresh?.(HttpResponse.json([account]));
|
||||
resolveRefresh?.(HttpResponse.json(resource([account])));
|
||||
});
|
||||
|
||||
it("keeps the cached account list and offers retry 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", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json([account]);
|
||||
if (requestCount === 1) return HttpResponse.json(resource([account]));
|
||||
if (requestCount === 2)
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
return HttpResponse.json([account]);
|
||||
return HttpResponse.json(resource([account]));
|
||||
}),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
@@ -132,7 +143,8 @@ describe("AccountsPage", () => {
|
||||
expect(screen.getByText(account.name)).toBeInTheDocument();
|
||||
|
||||
screen.getByRole("button", { name: "Повторить" }).click();
|
||||
await waitFor(() => expect(requestCount).toBe(3));
|
||||
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
|
||||
expect(requestCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a cached empty account list visible after a refresh error", async () => {
|
||||
@@ -140,7 +152,7 @@ describe("AccountsPage", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json([]);
|
||||
if (requestCount === 1) return HttpResponse.json(resource([]));
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
@@ -163,3 +175,22 @@ describe("AccountsPage", () => {
|
||||
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown membership loading instead of declaring no accounts", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json(resource(null)),
|
||||
),
|
||||
);
|
||||
const client = createTestQueryClient();
|
||||
renderPage(client);
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
|
||||
data: null,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Нет инвестиционных счетов"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ const AccountsPage: FC = () => {
|
||||
if (viewModel.state === "empty")
|
||||
return (
|
||||
<AccountsPageEmpty
|
||||
lastSuccessfulAt={viewModel.lastSuccessfulAt}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
/>
|
||||
@@ -20,7 +22,9 @@ const AccountsPage: FC = () => {
|
||||
return (
|
||||
<AccountsPageLoaded
|
||||
accounts={viewModel.accounts}
|
||||
lastSuccessfulAt={viewModel.lastSuccessfulAt}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
/>
|
||||
|
||||
@@ -2,32 +2,42 @@ import {
|
||||
brokerageAccountCards,
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
function useAccountsPageViewModel() {
|
||||
const accounts = useBrokerageAccounts();
|
||||
if (!accounts.data) {
|
||||
if (accounts.isFetching) return { state: "loading" as const };
|
||||
if (accounts.isError)
|
||||
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
|
||||
if (
|
||||
!accounts.data ||
|
||||
(accounts.meta?.availability === "partial" && !accounts.data.length)
|
||||
) {
|
||||
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
|
||||
return { state: "loading" as const };
|
||||
if (accounts.isError || accounts.meta?.sync.state === "failed")
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: apiErrorCode(accounts.error),
|
||||
retry: () => accounts.refetch(),
|
||||
retry: sync.refresh,
|
||||
};
|
||||
}
|
||||
if (!accounts.data?.length)
|
||||
return {
|
||||
state: "empty" as const,
|
||||
refreshing: accounts.isFetching,
|
||||
refreshError: accounts.isError,
|
||||
retry: () => accounts.refetch(),
|
||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||
refreshing: accounts.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.refresh,
|
||||
};
|
||||
return {
|
||||
state: "loaded" as const,
|
||||
accounts: brokerageAccountCards(accounts.data),
|
||||
refreshing: accounts.isFetching,
|
||||
refreshError: accounts.isError,
|
||||
retry: () => accounts.refetch(),
|
||||
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||
refreshing: accounts.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.refresh,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const AccountsPageLoading: FC = () => {
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="30%" />
|
||||
<Skeleton height={20} width="45%" />
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" aria-hidden="true">
|
||||
<SimpleGrid cols={1} spacing="lg" aria-hidden="true">
|
||||
{[0, 1].map((index) => (
|
||||
<Card key={index} padding="xl" radius="lg" withBorder>
|
||||
<Stack gap="xl">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
type AccountsRefreshStateProps = {
|
||||
lastSuccessfulAt?: string | null;
|
||||
sourceError?: boolean;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
@@ -11,9 +14,24 @@ type AccountsRefreshStateProps = {
|
||||
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const { refreshing = false, refreshError = false, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={retry}
|
||||
disabled={refreshing}
|
||||
variant="light"
|
||||
w="fit-content"
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
@@ -23,12 +41,15 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("accounts.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { BondPage } from "./BondPage";
|
||||
|
||||
@@ -92,127 +93,145 @@ function setup(
|
||||
http.get("*/api/v1/bonds/:uid", () =>
|
||||
options.failBond
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(resolved),
|
||||
: HttpResponse.json(resource(resolved)),
|
||||
),
|
||||
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
return options.ambiguousTicker
|
||||
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||
: HttpResponse.json(resolved);
|
||||
: HttpResponse.json(resource(resolved));
|
||||
}),
|
||||
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
if (params.section === "metrics")
|
||||
return HttpResponse.json({
|
||||
creditRating:
|
||||
options.ratingState === "missing" ||
|
||||
options.ratingState === "unavailable"
|
||||
? null
|
||||
: {
|
||||
agency: "NCR",
|
||||
value: options.ratingState === "withdrawn" ? null : "AAA.ru",
|
||||
status:
|
||||
options.ratingState === "withdrawn"
|
||||
? "withdrawn"
|
||||
: "active",
|
||||
date: "2026-04-17",
|
||||
sourceUrl: "https://ratings.ru/ratings/issues/123/",
|
||||
observedAt: quote.observedAt,
|
||||
},
|
||||
coupon: money("32.88"),
|
||||
couponDate: "2026-10-27",
|
||||
couponType: "COUPON_TYPE_FLOATING",
|
||||
couponPeriodDays: 90,
|
||||
couponYieldPercent: "27.18",
|
||||
couponYieldPriceAt: quote.priceAt,
|
||||
effectiveYield: {
|
||||
value: "15.6262",
|
||||
pricePercent: "53.384",
|
||||
date: "2030-05-15",
|
||||
dateType: "MATDATE",
|
||||
tradeAt: quote.priceAt,
|
||||
},
|
||||
unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
creditRating:
|
||||
options.ratingState === "missing" ||
|
||||
options.ratingState === "unavailable"
|
||||
? null
|
||||
: {
|
||||
agency: "NCR",
|
||||
value:
|
||||
options.ratingState === "withdrawn" ? null : "AAA.ru",
|
||||
status:
|
||||
options.ratingState === "withdrawn"
|
||||
? "withdrawn"
|
||||
: "active",
|
||||
date: "2026-04-17",
|
||||
sourceUrl: "https://ratings.ru/ratings/issues/123/",
|
||||
observedAt: quote.observedAt,
|
||||
},
|
||||
coupon: money("32.88"),
|
||||
couponDate: "2026-10-27",
|
||||
couponType: "COUPON_TYPE_FLOATING",
|
||||
couponPeriodDays: 90,
|
||||
couponYieldPercent: "27.18",
|
||||
couponYieldPriceAt: quote.priceAt,
|
||||
effectiveYield: {
|
||||
value: "15.6262",
|
||||
pricePercent: "53.384",
|
||||
date: "2030-05-15",
|
||||
dateType: "MATDATE",
|
||||
tradeAt: quote.priceAt,
|
||||
},
|
||||
unavailable:
|
||||
options.ratingState === "unavailable" ? ["rating"] : [],
|
||||
observedAt: quote.observedAt,
|
||||
}),
|
||||
);
|
||||
if (params.section === "quote")
|
||||
return failQuote
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(quote);
|
||||
: HttpResponse.json(resource(quote));
|
||||
if (params.section === "position")
|
||||
return HttpResponse.json(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
resource(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
),
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json({
|
||||
candles: [
|
||||
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
|
||||
],
|
||||
currency: null,
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
candles: [
|
||||
{
|
||||
time: "2026-09-09T00:00:00Z",
|
||||
close: "99.25",
|
||||
isComplete: true,
|
||||
},
|
||||
],
|
||||
currency: null,
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
}),
|
||||
);
|
||||
if (options.failCoupons)
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
return HttpResponse.json({
|
||||
coupons: [
|
||||
{
|
||||
perBond: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
number: "1",
|
||||
couponType: "COUPON_TYPE_FLOATING",
|
||||
periodDays: 90,
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perBond: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
number: "2",
|
||||
couponType: null,
|
||||
periodDays: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
coupons: [
|
||||
{
|
||||
perBond: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
number: "1",
|
||||
couponType: "COUPON_TYPE_FLOATING",
|
||||
periodDays: 90,
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perBond: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
number: "2",
|
||||
couponType: null,
|
||||
periodDays: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
const next = url.searchParams.has("cursor");
|
||||
return HttpResponse.json({
|
||||
accountId: "all",
|
||||
observedAt: quote.observedAt,
|
||||
hasNext: !next,
|
||||
nextCursor: next ? null : "next",
|
||||
items: [
|
||||
{
|
||||
id: next ? "sold" : "dividend",
|
||||
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
|
||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "all",
|
||||
observedAt: quote.observedAt,
|
||||
hasNext: !next,
|
||||
nextCursor: next ? null : "next",
|
||||
items: [
|
||||
{
|
||||
id: next ? "sold" : "dividend",
|
||||
occurredAt: next
|
||||
? "2022-01-02T10:00:00Z"
|
||||
: "2025-01-02T10:00:00Z",
|
||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
@@ -300,9 +319,11 @@ describe("bond page", () => {
|
||||
it("keeps market content and sold-position history when coupons fail", async () => {
|
||||
setup({ noPosition: true, failCoupons: true });
|
||||
await screen.findByText("98,125 %");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить этот раздел",
|
||||
);
|
||||
expect(
|
||||
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
|
||||
'[role="status"]',
|
||||
),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -311,14 +332,36 @@ describe("bond page", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
it("preserves cached quote on refresh failure", async () => {
|
||||
const controls = setup();
|
||||
const scopes: unknown[] = [];
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
|
||||
await screen.findByText("98,125 %");
|
||||
controls.failQuote();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"последние полученные",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||
expect(
|
||||
(await screen.findByText(/последние полученные/)).closest(
|
||||
'[role="status"]',
|
||||
),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByText("98,125 %")).toBeVisible();
|
||||
expect(scopes).toEqual([
|
||||
{
|
||||
screen: "bond",
|
||||
instrumentUid: uid,
|
||||
accountId: "all",
|
||||
candlePeriod: "year",
|
||||
categories: ["trade"],
|
||||
limit: 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
|
||||
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
|
||||
|
||||
@@ -55,13 +55,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
vm.bondState.retry();
|
||||
vm.quoteState.retry();
|
||||
vm.metricsState.retry();
|
||||
}}
|
||||
onClick={vm.refresh}
|
||||
disabled={vm.refreshing}
|
||||
>
|
||||
{t("stock.refreshPrice")}
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group mt="md" gap="xs">
|
||||
@@ -80,8 +77,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{vm.bondState.error || vm.quoteState.error ? (
|
||||
<Alert role="alert" color="orange" mt="md">
|
||||
{vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
|
||||
<Alert role="status" aria-live="polite" color="orange" mt="md">
|
||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -322,7 +319,9 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
{vm.coupons ? (
|
||||
<Stack gap="md">
|
||||
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
|
||||
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{vm.upcoming.length ? (
|
||||
vm.upcoming.map((coupon) => (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -58,6 +59,17 @@ function useBondPageVm() {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "bond",
|
||||
instrumentUid: uid,
|
||||
candlePeriod: period,
|
||||
accountId: "all",
|
||||
categories: categories[filter],
|
||||
limit: 20,
|
||||
},
|
||||
[bond, quote, candles, position, metrics, coupons, operations],
|
||||
);
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(key, value);
|
||||
@@ -70,7 +82,7 @@ function useBondPageVm() {
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void operations.refetch();
|
||||
} else void sync.refresh();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
@@ -79,31 +91,34 @@ function useBondPageVm() {
|
||||
price: Number(candle.close),
|
||||
})) ?? [];
|
||||
return {
|
||||
refresh: sync.refresh,
|
||||
refreshing: sync.refreshing,
|
||||
refreshError: sync.transportError || sync.sourceError,
|
||||
canonicalTo:
|
||||
bond.data?.slug && bond.data.slug !== bondId
|
||||
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||
: null,
|
||||
bond: bond.data,
|
||||
bondState: section(bond),
|
||||
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
|
||||
code: apiErrorCode(bond.error),
|
||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||
metrics: metrics.data,
|
||||
metricsState: section(metrics),
|
||||
metricsState: section(metrics, sync.refresh),
|
||||
quote: quote.data,
|
||||
quoteState: section(quote),
|
||||
quoteState: section(quote, sync.refresh),
|
||||
candles: candles.data,
|
||||
chartData,
|
||||
priceRows: chartData.toReversed(),
|
||||
candlesState: section(candles),
|
||||
candlesState: section(candles, sync.refresh),
|
||||
period,
|
||||
setPeriod: (value: string) => setParam("period", value),
|
||||
averageLine: null,
|
||||
hasIncompleteCandle:
|
||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||
position: position.data,
|
||||
positionState: section(position),
|
||||
positionState: section(position, sync.refresh),
|
||||
coupons: coupons.data,
|
||||
couponsState: section(coupons),
|
||||
couponsState: section(coupons, sync.refresh),
|
||||
upcoming:
|
||||
coupons.data?.coupons
|
||||
.filter((event) => event.timing === "upcoming")
|
||||
@@ -121,7 +136,10 @@ function useBondPageVm() {
|
||||
),
|
||||
) ?? [],
|
||||
operations: operations.data,
|
||||
operationsState: { ...section(operations), retry: retryOperations },
|
||||
operationsState: {
|
||||
...section(operations, sync.refresh),
|
||||
retry: retryOperations,
|
||||
},
|
||||
eventRows:
|
||||
operations.data?.items.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { BondsPage } from "./BondsPage";
|
||||
|
||||
@@ -15,24 +16,26 @@ describe("bond search", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/bonds", ({ request }) => {
|
||||
query(new URL(request.url).searchParams.get("query"));
|
||||
return HttpResponse.json([
|
||||
{
|
||||
name: "ОФЗ",
|
||||
ticker: "SU26238RMFS4",
|
||||
instrumentUid: "unique-bond",
|
||||
slug: "SU26238RMFS4",
|
||||
logoName: "sber.png",
|
||||
classCode: "TQOB",
|
||||
isin: "RU0009029540",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
apiTradeAvailable: true,
|
||||
pricePercent: "98.125",
|
||||
maturityDate: "2030-05-15",
|
||||
perpetual: false,
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
name: "ОФЗ",
|
||||
ticker: "SU26238RMFS4",
|
||||
instrumentUid: "unique-bond",
|
||||
slug: "SU26238RMFS4",
|
||||
logoName: "sber.png",
|
||||
classCode: "TQOB",
|
||||
isin: "RU0009029540",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
apiTradeAvailable: true,
|
||||
pricePercent: "98.125",
|
||||
maturityDate: "2030-05-15",
|
||||
perpetual: false,
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
|
||||
@@ -37,6 +37,16 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||
{t("bond.searchSubtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={vm.retry}
|
||||
disabled={vm.refreshing || vm.query.trim().length < 2}
|
||||
w="fit-content"
|
||||
>
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{vm.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : null}
|
||||
<TextInput
|
||||
label={t("bond.searchLabel")}
|
||||
placeholder={t("bond.searchPlaceholder")}
|
||||
@@ -49,7 +59,7 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
||||
) : null}
|
||||
{vm.error ? (
|
||||
<Alert role="alert" color="orange">
|
||||
<Alert role="status" aria-live="polite" color="orange">
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{t(
|
||||
@@ -166,7 +176,9 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t("bond.noSearchResults")}</Text>
|
||||
<Text c="dimmed">
|
||||
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useBondSearch } from "@/entities/bond";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -11,15 +12,23 @@ function useBondsPageVm() {
|
||||
const query = params.get("q") ?? "";
|
||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const result = useBondSearch(debounced);
|
||||
const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]);
|
||||
const waiting = query.trim() !== debounced;
|
||||
return {
|
||||
query,
|
||||
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
||||
loading: query.trim().length >= 2 && (waiting || result.isLoading),
|
||||
error: result.isError,
|
||||
loading:
|
||||
query.trim().length >= 2 &&
|
||||
(waiting ||
|
||||
(!result.data &&
|
||||
!result.isError &&
|
||||
result.meta?.sync.state !== "failed")),
|
||||
error: result.isError || sync.sourceError || sync.transportError,
|
||||
refreshing: sync.refreshing,
|
||||
partial: result.meta?.availability === "partial",
|
||||
code: apiErrorCode(result.error),
|
||||
retry: () => {
|
||||
void result.refetch();
|
||||
void sync.refresh();
|
||||
},
|
||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||
items:
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { StockPage } from "./StockPage";
|
||||
|
||||
@@ -80,100 +81,112 @@ function setup(
|
||||
http.get("*/api/v1/stocks/:uid", () =>
|
||||
options.failStock
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(resolved),
|
||||
: HttpResponse.json(resource(resolved)),
|
||||
),
|
||||
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
return options.ambiguousTicker
|
||||
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||
: HttpResponse.json(resolved);
|
||||
: HttpResponse.json(resource(resolved));
|
||||
}),
|
||||
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
if (params.section === "quote")
|
||||
return failQuote
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(quote);
|
||||
: HttpResponse.json(resource(quote));
|
||||
if (params.section === "position")
|
||||
return HttpResponse.json(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
resource(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
),
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json({
|
||||
candles: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
candles: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
}),
|
||||
);
|
||||
if (params.section === "company")
|
||||
return options.failCompany
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({
|
||||
description: "Описание компании",
|
||||
metrics: [
|
||||
{
|
||||
key: "peRatioTtm",
|
||||
value: 5.6,
|
||||
unit: "ratio",
|
||||
currency: null,
|
||||
period: "TTM",
|
||||
},
|
||||
],
|
||||
unavailable: [],
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json({
|
||||
dividends: [
|
||||
{
|
||||
perShare: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
lastBuyDate: "2026-10-12",
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perShare: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
lastBuyDate: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
});
|
||||
: HttpResponse.json(
|
||||
resource({
|
||||
description: "Описание компании",
|
||||
metrics: [
|
||||
{
|
||||
key: "peRatioTtm",
|
||||
value: 5.6,
|
||||
unit: "ratio",
|
||||
currency: null,
|
||||
period: "TTM",
|
||||
},
|
||||
],
|
||||
unavailable: [],
|
||||
observedAt: quote.observedAt,
|
||||
}),
|
||||
);
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
dividends: [
|
||||
{
|
||||
perShare: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
lastBuyDate: "2026-10-12",
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perShare: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
lastBuyDate: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
const next = url.searchParams.has("cursor");
|
||||
return HttpResponse.json({
|
||||
accountId: "all",
|
||||
observedAt: quote.observedAt,
|
||||
hasNext: !next,
|
||||
nextCursor: next ? null : "next",
|
||||
items: [
|
||||
{
|
||||
id: next ? "sold" : "dividend",
|
||||
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
|
||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "all",
|
||||
observedAt: quote.observedAt,
|
||||
hasNext: !next,
|
||||
nextCursor: next ? null : "next",
|
||||
items: [
|
||||
{
|
||||
id: next ? "sold" : "dividend",
|
||||
occurredAt: next
|
||||
? "2022-01-02T10:00:00Z"
|
||||
: "2025-01-02T10:00:00Z",
|
||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
@@ -271,9 +284,11 @@ describe("stock page", () => {
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить этот раздел",
|
||||
);
|
||||
expect(
|
||||
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
|
||||
'[role="status"]',
|
||||
),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
||||
const { requests } = setup();
|
||||
@@ -308,14 +323,38 @@ describe("stock page", () => {
|
||||
);
|
||||
});
|
||||
it("keeps the last quote on a refresh failure", async () => {
|
||||
const controls = setup();
|
||||
const scopes: unknown[] = [];
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const controls = setup({
|
||||
path: `/stocks/${uid}?period=year&events=trades`,
|
||||
});
|
||||
await screen.findByText("300 ₽");
|
||||
controls.failQuote();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"последние полученные",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||
expect(
|
||||
(await screen.findByText(/последние полученные/)).closest(
|
||||
'[role="status"]',
|
||||
),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByText("300 ₽")).toBeVisible();
|
||||
expect(scopes).toEqual([
|
||||
{
|
||||
screen: "stock",
|
||||
instrumentUid: uid,
|
||||
accountId: "all",
|
||||
candlePeriod: "year",
|
||||
categories: ["trade"],
|
||||
limit: 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("offers recovery for an unavailable direct link", async () => {
|
||||
setup({ failStock: true });
|
||||
|
||||
@@ -63,12 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
vm.stockState.retry();
|
||||
vm.quoteState.retry();
|
||||
}}
|
||||
onClick={vm.refresh}
|
||||
disabled={vm.refreshing}
|
||||
>
|
||||
{t("stock.refreshPrice")}
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
{vm.company?.description ? (
|
||||
@@ -76,8 +74,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
{vm.company.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{vm.stockState.error || vm.quoteState.error ? (
|
||||
<Alert role="alert" color="orange" mt="md">
|
||||
{vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
|
||||
<Alert role="status" aria-live="polite" color="orange" mt="md">
|
||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -164,7 +162,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
{vm.company ? (
|
||||
<Stack gap="lg">
|
||||
{vm.company.unavailable.length ? (
|
||||
<Alert color="orange">
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("stock.partialCompany")}</Text>
|
||||
<Button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useStockQuote,
|
||||
} from "@/entities/stock";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -58,6 +59,17 @@ function useStockPageVm() {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "stock",
|
||||
instrumentUid: uid,
|
||||
candlePeriod: period,
|
||||
accountId: "all",
|
||||
categories: categories[filter],
|
||||
limit: 20,
|
||||
},
|
||||
[stock, quote, candles, position, company, dividends, operations],
|
||||
);
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(key, value);
|
||||
@@ -70,7 +82,7 @@ function useStockPageVm() {
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void operations.refetch();
|
||||
} else void sync.refresh();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
@@ -85,20 +97,23 @@ function useStockPageVm() {
|
||||
: null;
|
||||
const companyData = company.data;
|
||||
return {
|
||||
refresh: sync.refresh,
|
||||
refreshing: sync.refreshing,
|
||||
refreshError: sync.transportError || sync.sourceError,
|
||||
canonicalTo:
|
||||
stock.data?.slug && stock.data.slug !== stockId
|
||||
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||
: null,
|
||||
stock: stock.data,
|
||||
stockState: section(stock),
|
||||
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
|
||||
code: apiErrorCode(stock.error),
|
||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||
quote: quote.data,
|
||||
quoteState: section(quote),
|
||||
quoteState: section(quote, sync.refresh),
|
||||
candles: candles.data,
|
||||
chartData,
|
||||
priceRows: chartData.toReversed(),
|
||||
candlesState: section(candles),
|
||||
candlesState: section(candles, sync.refresh),
|
||||
period,
|
||||
setPeriod: (value: string) => setParam("period", value),
|
||||
averageLine:
|
||||
@@ -106,11 +121,11 @@ function useStockPageVm() {
|
||||
hasIncompleteCandle:
|
||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||
position: position.data,
|
||||
positionState: section(position),
|
||||
positionState: section(position, sync.refresh),
|
||||
company: companyData,
|
||||
companyState: section(company),
|
||||
companyState: section(company, sync.refresh),
|
||||
dividends: dividends.data,
|
||||
dividendsState: section(dividends),
|
||||
dividendsState: section(dividends, sync.refresh),
|
||||
upcoming:
|
||||
dividends.data?.dividends
|
||||
.filter((event) => event.timing === "upcoming")
|
||||
@@ -128,7 +143,10 @@ function useStockPageVm() {
|
||||
),
|
||||
) ?? [],
|
||||
operations: operations.data,
|
||||
operationsState: { ...section(operations), retry: retryOperations },
|
||||
operationsState: {
|
||||
...section(operations, sync.refresh),
|
||||
retry: retryOperations,
|
||||
},
|
||||
eventRows:
|
||||
operations.data?.items.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -23,7 +23,9 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
{vm.dividends ? (
|
||||
<Stack gap="md">
|
||||
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
|
||||
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{vm.upcoming.length ? (
|
||||
vm.upcoming.map((event) => (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { StocksPage } from "./StocksPage";
|
||||
|
||||
@@ -15,22 +16,24 @@ describe("stock search", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks", ({ request }) => {
|
||||
query(new URL(request.url).searchParams.get("query"));
|
||||
return HttpResponse.json([
|
||||
{
|
||||
name: "Сбербанк",
|
||||
ticker: "SBER",
|
||||
instrumentUid: "unique-stock",
|
||||
slug: "SBER",
|
||||
logoName: "sber.png",
|
||||
classCode: "TQBR",
|
||||
isin: "RU0009029540",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
apiTradeAvailable: true,
|
||||
price: { currency: "RUB", value: "306.12" },
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
name: "Сбербанк",
|
||||
ticker: "SBER",
|
||||
instrumentUid: "unique-stock",
|
||||
slug: "SBER",
|
||||
logoName: "sber.png",
|
||||
classCode: "TQBR",
|
||||
isin: "RU0009029540",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
apiTradeAvailable: true,
|
||||
price: { currency: "RUB", value: "306.12" },
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
|
||||
@@ -32,6 +32,16 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
{t("stock.searchSubtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={vm.retry}
|
||||
disabled={vm.refreshing || vm.query.trim().length < 2}
|
||||
w="fit-content"
|
||||
>
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{vm.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : null}
|
||||
<TextInput
|
||||
label={t("stock.searchLabel")}
|
||||
placeholder={t("stock.searchPlaceholder")}
|
||||
@@ -44,7 +54,7 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
||||
) : null}
|
||||
{vm.error ? (
|
||||
<Alert role="alert" color="orange">
|
||||
<Alert role="status" aria-live="polite" color="orange">
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{t(
|
||||
@@ -142,7 +152,9 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t("stock.noSearchResults")}</Text>
|
||||
<Text c="dimmed">
|
||||
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useStockSearch } from "@/entities/stock";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -11,15 +12,23 @@ function useStocksPageVm() {
|
||||
const query = params.get("q") ?? "";
|
||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const result = useStockSearch(debounced);
|
||||
const sync = useScreenSync({ screen: "stocks", query: debounced }, [result]);
|
||||
const waiting = query.trim() !== debounced;
|
||||
return {
|
||||
query,
|
||||
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
||||
loading: query.trim().length >= 2 && (waiting || result.isLoading),
|
||||
error: result.isError,
|
||||
loading:
|
||||
query.trim().length >= 2 &&
|
||||
(waiting ||
|
||||
(!result.data &&
|
||||
!result.isError &&
|
||||
result.meta?.sync.state !== "failed")),
|
||||
error: result.isError || sync.sourceError || sync.transportError,
|
||||
refreshing: sync.refreshing,
|
||||
partial: result.meta?.availability === "partial",
|
||||
code: apiErrorCode(result.error),
|
||||
retry: () => {
|
||||
void result.refetch();
|
||||
void sync.refresh();
|
||||
},
|
||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||
items:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import {
|
||||
useAuthenticatedQueries,
|
||||
@@ -17,14 +18,16 @@ describe("useAuthenticatedQuery", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests();
|
||||
return HttpResponse.json([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
},
|
||||
]);
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -53,7 +56,7 @@ describe("useAuthenticatedQuery", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(requests).toHaveBeenCalledOnce();
|
||||
expect(result.current.data).toEqual([
|
||||
expect(result.current.data?.data).toEqual([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { apiClient, configureAuthClient } from "./client";
|
||||
|
||||
@@ -16,14 +17,14 @@ describe("apiClient authentication", () => {
|
||||
authorization.push(request.headers.get("Authorization") ?? "");
|
||||
return authorization.length === 1
|
||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||
: HttpResponse.json([]);
|
||||
: HttpResponse.json(resource([]));
|
||||
}),
|
||||
);
|
||||
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
|
||||
|
||||
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(data).toEqual([]);
|
||||
expect(data?.data).toEqual([]);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]);
|
||||
});
|
||||
@@ -76,7 +77,7 @@ describe("apiClient authentication", () => {
|
||||
if (++calls === 1)
|
||||
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
|
||||
await pending;
|
||||
return HttpResponse.json([{ id: "previous-user-account" }]);
|
||||
return HttpResponse.json(resource([{ id: "previous-user-account" }]));
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
|
||||
@@ -14,6 +14,13 @@ type AuthHandlers = {
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const inFlight = new Set<{ pathname: string; invalidated: boolean }>();
|
||||
const latestReads = new Map<string, { sequence: number; pending: number }>();
|
||||
function invalidateApiRequests(matches: (pathname: string) => boolean) {
|
||||
for (const request of inFlight)
|
||||
if (matches(request.pathname)) request.invalidated = true;
|
||||
}
|
||||
|
||||
let auth: AuthHandlers | null = null;
|
||||
let hasAuthenticatedSession = false;
|
||||
const authSessionListeners = new Set<() => void>();
|
||||
@@ -25,6 +32,7 @@ function setAuthenticatedSession(next: boolean) {
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers: AuthHandlers | null) {
|
||||
if (!handlers) invalidateApiRequests(() => true);
|
||||
auth = handlers;
|
||||
setAuthenticatedSession(Boolean(auth?.accessToken()));
|
||||
}
|
||||
@@ -39,12 +47,47 @@ function subscribeAuthenticatedSession(listener: () => void) {
|
||||
}
|
||||
|
||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("sync");
|
||||
url.searchParams.sort();
|
||||
const key = `${request.method} ${url}`;
|
||||
const ticket = { pathname: url.pathname, invalidated: false };
|
||||
const read =
|
||||
request.method === "GET"
|
||||
? (latestReads.get(key) ?? { sequence: 0, pending: 0 })
|
||||
: null;
|
||||
if (read) {
|
||||
read.sequence += 1;
|
||||
read.pending += 1;
|
||||
latestReads.set(key, read);
|
||||
}
|
||||
const sequence = read?.sequence;
|
||||
inFlight.add(ticket);
|
||||
try {
|
||||
return await fetchCurrent(
|
||||
request,
|
||||
() => !ticket.invalidated && (!read || sequence === read.sequence),
|
||||
);
|
||||
} finally {
|
||||
inFlight.delete(ticket);
|
||||
if (read && --read.pending === 0) latestReads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrent(
|
||||
request: Request,
|
||||
isLatest: () => boolean,
|
||||
): Promise<Response> {
|
||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||
"/api/v1/auth/",
|
||||
);
|
||||
const session = auth;
|
||||
const generation = session?.generation?.();
|
||||
const isCurrent = () => generation === auth?.generation?.();
|
||||
const isCurrent = () =>
|
||||
(session?.generation
|
||||
? generation === session.generation() &&
|
||||
generation === auth?.generation?.()
|
||||
: session === auth) && isLatest();
|
||||
const token = session?.accessToken();
|
||||
const withToken = (source: Request, value: string) => {
|
||||
const headers = new Headers(source.headers);
|
||||
@@ -55,6 +98,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
token && !isAuthRequest ? withToken(request, token) : request;
|
||||
const retryRequest = authorized.clone();
|
||||
const response = await fetch(authorized);
|
||||
await response.clone().arrayBuffer();
|
||||
if (!isAuthRequest && !isCurrent())
|
||||
throw new DOMException("Session changed", "AbortError");
|
||||
if (
|
||||
@@ -78,6 +122,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
const refreshedToken = auth.accessToken();
|
||||
if (!refreshedToken) return response;
|
||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||
await retried.clone().arrayBuffer();
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
return retried;
|
||||
}
|
||||
@@ -95,5 +140,6 @@ export {
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
getAuthenticatedSessionSnapshot,
|
||||
invalidateApiRequests,
|
||||
subscribeAuthenticatedSession,
|
||||
};
|
||||
|
||||
Vendored
+436
-302
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -2,6 +2,17 @@ export {
|
||||
useAuthenticatedQueries,
|
||||
useAuthenticatedQuery,
|
||||
} from "./authenticated-query";
|
||||
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
|
||||
export {
|
||||
apiClient,
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
invalidateApiRequests,
|
||||
} from "./client";
|
||||
export type { paths } from "./generated/schema";
|
||||
export { apiErrorCode } from "./problem-details";
|
||||
|
||||
export {
|
||||
invalidateResourceAccess,
|
||||
unwrapResourceResult,
|
||||
useResourceQuery,
|
||||
} from "./resource-query";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type QueryFunctionContext,
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import type { components } from "./generated/schema";
|
||||
import { apiErrorCode } from "./problem-details";
|
||||
|
||||
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||
|
||||
type Resource = {
|
||||
data: unknown;
|
||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
||||
};
|
||||
function unwrapResourceResult<T extends Resource, E>(
|
||||
result: UseQueryResult<T, E>,
|
||||
) {
|
||||
const missing = result.isSuccess && result.data.data === null;
|
||||
return {
|
||||
...result,
|
||||
transport: {
|
||||
status: result.status,
|
||||
fetchStatus: result.fetchStatus,
|
||||
error: result.error,
|
||||
},
|
||||
data: result.data?.data ?? undefined,
|
||||
meta: result.data?.meta,
|
||||
isPending: result.isPending || missing,
|
||||
isSuccess: result.isSuccess && !missing,
|
||||
status: missing ? "pending" : result.status,
|
||||
} as UseQueryResult<NonNullable<T["data"]>, E> & {
|
||||
meta?: T["meta"];
|
||||
transport: {
|
||||
status: UseQueryResult<T, E>["status"];
|
||||
fetchStatus: UseQueryResult<T, E>["fetchStatus"];
|
||||
error: E | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateResourceAccess(
|
||||
client: QueryClient,
|
||||
group: string,
|
||||
error: unknown,
|
||||
) {
|
||||
if (
|
||||
![
|
||||
"NOT_FOUND",
|
||||
"FORBIDDEN",
|
||||
"TBANK_NOT_CONNECTED",
|
||||
"INVALID_TBANK_TOKEN",
|
||||
].includes(apiErrorCode(error) ?? "")
|
||||
)
|
||||
return;
|
||||
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
||||
accessEpochs.set(client, epochs);
|
||||
epochs.set(group, (epochs.get(group) ?? 0) + 1);
|
||||
const affected = {
|
||||
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
||||
cached.meta?.accessGroup === group,
|
||||
};
|
||||
void client.cancelQueries(affected);
|
||||
for (const cached of client.getQueryCache().findAll(affected))
|
||||
cached.setState({
|
||||
data: undefined,
|
||||
dataUpdatedAt: 0,
|
||||
status: "error",
|
||||
error: error as Error,
|
||||
fetchStatus: "idle",
|
||||
});
|
||||
}
|
||||
|
||||
// Observation changes transport mode, never the generated cache identity.
|
||||
function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
options: UseQueryOptions<T, E, T, K>,
|
||||
) {
|
||||
const client = useQueryClient();
|
||||
const group =
|
||||
typeof options.meta?.accessGroup === "string"
|
||||
? options.meta.accessGroup
|
||||
: undefined;
|
||||
let epochs = accessEpochs.get(client);
|
||||
if (!epochs) {
|
||||
epochs = new Map();
|
||||
accessEpochs.set(client, epochs);
|
||||
}
|
||||
const groupEpochs = epochs;
|
||||
const queryFn = options.queryFn;
|
||||
const guarded =
|
||||
typeof queryFn === "function"
|
||||
? async (context: QueryFunctionContext<K>) => {
|
||||
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
|
||||
const result = await queryFn(context);
|
||||
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||
throw new DOMException("Access changed", "AbortError");
|
||||
return result;
|
||||
}
|
||||
: queryFn;
|
||||
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
|
||||
useEffect(() => {
|
||||
if (group) invalidateResourceAccess(client, group, query.error);
|
||||
}, [client, group, query.error]);
|
||||
const observe = async (expectedStateVersion?: string) => {
|
||||
const queryFn = guarded;
|
||||
if (typeof queryFn !== "function") return;
|
||||
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
||||
return client.fetchQuery({
|
||||
...options,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||
const [method, path, input] = context.queryKey;
|
||||
const init = input as
|
||||
| { params?: { query?: Record<string, unknown> } }
|
||||
| undefined;
|
||||
const queryKey = [
|
||||
method,
|
||||
path,
|
||||
{
|
||||
...init,
|
||||
params: {
|
||||
...init?.params,
|
||||
query: { ...init?.params?.query, sync: "observe" },
|
||||
},
|
||||
},
|
||||
] as unknown as K;
|
||||
const response = await queryFn({ ...context, queryKey });
|
||||
if (
|
||||
expectedStateVersion &&
|
||||
response.meta.stateVersion !== expectedStateVersion
|
||||
) {
|
||||
// One more observational read; never turn a stale notification into an import.
|
||||
const current = await queryFn({ ...context, queryKey });
|
||||
if (current.meta.stateVersion !== expectedStateVersion)
|
||||
throw new DOMException("Resource changed", "AbortError");
|
||||
return current;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
};
|
||||
return { ...unwrapResourceResult(query), observe };
|
||||
}
|
||||
|
||||
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
|
||||
+1316
-336
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
||||
const ru = {
|
||||
translation: {
|
||||
investment: {
|
||||
refresh: "Обновить",
|
||||
stale: "Данные требуют обновления",
|
||||
syncFailed:
|
||||
"Источник не удалось обновить. Сохранённые данные не изменены.",
|
||||
partial: "Период загружен не полностью",
|
||||
history: "История догружается",
|
||||
sourceTime: "Данные на {{date}}",
|
||||
},
|
||||
appName: "WealthLedger",
|
||||
navigation: {
|
||||
cards: "Карты",
|
||||
@@ -623,6 +632,10 @@ const ru = {
|
||||
taxBoundary: "Налоговые операции не включены в этот результат.",
|
||||
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
|
||||
yearAgo: "Годом ранее",
|
||||
previousPeriod: "Предыдущий период",
|
||||
comparisonPending: "Период сравнения ещё не загружен полностью.",
|
||||
comparisonFailed:
|
||||
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
||||
difference: "Разница",
|
||||
total: "Итого",
|
||||
|
||||
+38
-27
@@ -1,4 +1,5 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { resource } from "@/test/resource";
|
||||
|
||||
const authentication = {
|
||||
accessToken: "test-token",
|
||||
@@ -26,40 +27,50 @@ const handlers = [
|
||||
"*/api/v1/tbank-connection",
|
||||
() => new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json([])),
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json(resource([])),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
positions: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
positions: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
period: { from: "2026-01-01", to: "2026-08-14" },
|
||||
totalsByCurrency: [],
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
period: { from: "2026-01-01", to: "2026-08-14" },
|
||||
totalsByCurrency: [],
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { paths } from "@/shared/api";
|
||||
|
||||
type Meta =
|
||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["meta"];
|
||||
function resource<T>(data: T, meta: Partial<Meta> = {}) {
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
resourceKey: "fixture",
|
||||
dataRevision: "1",
|
||||
stateVersion: "1",
|
||||
availability: data === null ? "missing" : "complete",
|
||||
freshness: "fresh",
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
sync: { id: null, state: "idle", errorCode: null },
|
||||
...meta,
|
||||
} satisfies Meta,
|
||||
};
|
||||
}
|
||||
|
||||
export { resource };
|
||||
Reference in New Issue
Block a user