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
@@ -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" }]);
});