feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -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)));
});
});