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