fix(frontend): complete audit remediation
This commit is contained in:
@@ -7,6 +7,22 @@ import { server } from "@/test/server";
|
|||||||
import { useBrokerageEvents } from "./useBrokerageEvents";
|
import { useBrokerageEvents } from "./useBrokerageEvents";
|
||||||
|
|
||||||
describe("useBrokerageEvents", () => {
|
describe("useBrokerageEvents", () => {
|
||||||
|
it("does not fetch events without an account ID", async () => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const requested = vi.fn(() => HttpResponse.json({ events: [] }));
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", requested),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useBrokerageEvents(undefined), {
|
||||||
|
wrapper: createQueryWrapper().wrapper,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.fetchStatus).toBe("idle"));
|
||||||
|
expect(requested).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
it("loads future cash events through the authenticated query seam", async () => {
|
it("loads future cash events through the authenticated query seam", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { apiClient, apiQueryClient } from "@/shared/api";
|
import { apiClient, apiQueryClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AuthProvider, useAuth } from "./auth";
|
import { AuthProvider, useAuth } from "./auth";
|
||||||
|
|
||||||
@@ -22,26 +23,36 @@ describe("AuthProvider", () => {
|
|||||||
return HttpResponse.json([]);
|
return HttpResponse.json([]);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result, rerender } = renderHook(
|
||||||
|
() => ({ ...useAuth(), queryClient: useQueryClient() }),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
expect(authorization).toBe("Bearer restored-token");
|
expect(authorization).toBe("Bearer restored-token");
|
||||||
|
queryClient.setQueryData(["rerender-proof"], { value: "preserved" });
|
||||||
|
rerender();
|
||||||
|
expect(result.current.queryClient).toBe(queryClient);
|
||||||
|
expect(result.current.queryClient.getQueryData(["rerender-proof"])).toEqual(
|
||||||
|
{ value: "preserved" },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
|
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
|
||||||
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
|
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -77,7 +88,7 @@ describe("AuthProvider", () => {
|
|||||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -110,7 +121,7 @@ describe("AuthProvider", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
const statusKey = apiQueryClient.queryOptions(
|
const statusKey = apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/tbank-connection",
|
"/api/v1/tbank-connection",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { TbankConnectionSection } from "./TbankConnectionSection";
|
import { TbankConnectionSection } from "./TbankConnectionSection";
|
||||||
|
|
||||||
@@ -42,9 +43,7 @@ function renderSection(client: QueryClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function seededClient() {
|
function seededClient() {
|
||||||
const client = new QueryClient({
|
const client = createTestQueryClient();
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
client.setQueryData(brokerageAccountsQueryKey, [
|
client.setQueryData(brokerageAccountsQueryKey, [
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
@@ -35,6 +35,37 @@ describe("AccountEventsPage", () => {
|
|||||||
} as never);
|
} as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the selected route account to the query", () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={["/accounts/selected-account/events"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/events"
|
||||||
|
element={<AccountEventsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(query).toHaveBeenCalledWith("selected-account");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the loading fallback when no result or account is available", () => {
|
||||||
|
query.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
} as never);
|
||||||
|
renderPage();
|
||||||
|
expect(query).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||||
|
"aria-busy",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders events and marks an offer without an amount", () => {
|
it("renders events and marks an offer without an amount", () => {
|
||||||
query.mockReturnValue({
|
query.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
|
||||||
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
|
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
|
||||||
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||||
|
|
||||||
const AccountEventsPage: FC = () => {
|
const AccountEventsPage: FC = () => {
|
||||||
const { accountId } = useParams();
|
const viewModel = useAccountEventsPageViewModel();
|
||||||
const events = useBrokerageEvents(accountId);
|
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||||
if (events.isLoading) return <AccountEventsPageLoading />;
|
if (viewModel.state === "error")
|
||||||
if (events.isError)
|
return <AccountEventsPageError retry={viewModel.retry} />;
|
||||||
return <AccountEventsPageError retry={() => events.refetch()} />;
|
if (viewModel.state === "empty") return <AccountEventsPageEmpty />;
|
||||||
if (!events.data) return <AccountEventsPageLoading />;
|
return <AccountEventsPageLoaded events={viewModel.events} />;
|
||||||
if (!events.data.events.length) return <AccountEventsPageEmpty />;
|
|
||||||
return <AccountEventsPageLoaded events={events.data.events} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountEventsPage };
|
export { AccountEventsPage };
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useParams } from "react-router";
|
||||||
|
import {
|
||||||
|
type BrokerageEvent,
|
||||||
|
useBrokerageEvents,
|
||||||
|
} from "@/entities/brokerage-event";
|
||||||
|
|
||||||
|
type AccountEventsPageViewModel =
|
||||||
|
| { state: "loading" }
|
||||||
|
| { state: "empty" }
|
||||||
|
| { state: "error"; retry: () => void }
|
||||||
|
| { state: "loaded"; events: BrokerageEvent[] };
|
||||||
|
|
||||||
|
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||||
|
const { accountId } = useParams();
|
||||||
|
const events = useBrokerageEvents(accountId);
|
||||||
|
if (events.isLoading) return { state: "loading" };
|
||||||
|
if (events.isError) return { state: "error", retry: () => events.refetch() };
|
||||||
|
if (!events.data) return { state: "loading" };
|
||||||
|
if (!events.data.events.length) return { state: "empty" };
|
||||||
|
return { state: "loaded", events: events.data.events };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useAccountEventsPageViewModel };
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -9,6 +15,7 @@ import { createQueryWrapper } from "@/test/query";
|
|||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountIncomePage } from "./AccountIncomePage";
|
import { AccountIncomePage } from "./AccountIncomePage";
|
||||||
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
||||||
|
import { IncomeBreakdown } from "./components/IncomeBreakdown.view";
|
||||||
|
|
||||||
const analytics = {
|
const analytics = {
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
@@ -29,7 +36,7 @@ const analytics = {
|
|||||||
],
|
],
|
||||||
instruments: [
|
instruments: [
|
||||||
{
|
{
|
||||||
key: "figi:FIGI:RUB",
|
key: "income-group-opaque-v2",
|
||||||
figi: "FIGI",
|
figi: "FIGI",
|
||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
ticker: "BOND",
|
ticker: "BOND",
|
||||||
@@ -38,10 +45,18 @@ const analytics = {
|
|||||||
coupon: money("RUB", "10"),
|
coupon: money("RUB", "10"),
|
||||||
dividend: money("RUB", "2"),
|
dividend: money("RUB", "2"),
|
||||||
total: money("RUB", "12"),
|
total: money("RUB", "12"),
|
||||||
receiptCount: 1,
|
receiptCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entries: [
|
||||||
|
entry(),
|
||||||
|
{
|
||||||
|
...entry(),
|
||||||
|
id: "dividend-1",
|
||||||
|
type: "OPERATION_TYPE_DIVIDEND",
|
||||||
|
payment: money("RUB", "2"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
entries: [entry()],
|
|
||||||
} satisfies IncomeAnalytics;
|
} satisfies IncomeAnalytics;
|
||||||
|
|
||||||
function money(currency: string, value: string) {
|
function money(currency: string, value: string) {
|
||||||
@@ -69,6 +84,7 @@ function comparison(currency: string) {
|
|||||||
function entry(): IncomeAnalytics["entries"][number] {
|
function entry(): IncomeAnalytics["entries"][number] {
|
||||||
return {
|
return {
|
||||||
id: "coupon-1",
|
id: "coupon-1",
|
||||||
|
instrumentKey: "income-group-opaque-v2",
|
||||||
occurredAt: "2026-08-01T12:00:00Z",
|
occurredAt: "2026-08-01T12:00:00Z",
|
||||||
type: "OPERATION_TYPE_COUPON",
|
type: "OPERATION_TYPE_COUPON",
|
||||||
category: "income",
|
category: "income",
|
||||||
@@ -118,6 +134,201 @@ function renderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountIncomePage", () => {
|
describe("AccountIncomePage", () => {
|
||||||
|
it("preserves every repeated-ID payment when an open drill-down updates", async () => {
|
||||||
|
const first = { ...entry(), id: "same-id", description: "Выплата A" };
|
||||||
|
const second = {
|
||||||
|
...entry(),
|
||||||
|
id: "same-id",
|
||||||
|
description: "Выплата B",
|
||||||
|
payment: money("RUB", "-2"),
|
||||||
|
};
|
||||||
|
const third = {
|
||||||
|
...entry(),
|
||||||
|
id: "other-id",
|
||||||
|
description: "Выплата C",
|
||||||
|
payment: money("RUB", "1"),
|
||||||
|
};
|
||||||
|
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
|
||||||
|
const display = toIncomeDisplayModel({
|
||||||
|
...analytics,
|
||||||
|
instruments: [
|
||||||
|
{
|
||||||
|
...analytics.instruments[0],
|
||||||
|
coupon: money("RUB", total),
|
||||||
|
dividend: money("RUB", "0"),
|
||||||
|
total: money("RUB", total),
|
||||||
|
receiptCount: entries.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
expect(display).not.toBeNull();
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<IncomeBreakdown instruments={display?.instruments ?? []} />
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { rerender } = render(breakdown([first, second, third], "9"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "BOND · 9 ₽ · 3" }));
|
||||||
|
const panel = await screen.findByRole("region", { name: "BOND · 9 ₽ · 3" });
|
||||||
|
const payments = () =>
|
||||||
|
within(panel)
|
||||||
|
.getAllByText(/^Выплата [ABC]$/)
|
||||||
|
.map((element) => element.textContent);
|
||||||
|
expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]);
|
||||||
|
|
||||||
|
rerender(breakdown([third, first, second], "9"));
|
||||||
|
expect(payments()).toEqual(["Выплата C", "Выплата A", "Выплата B"]);
|
||||||
|
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(breakdown([second, second, first], "6"));
|
||||||
|
expect(payments()).toEqual(["Выплата B", "Выплата B", "Выплата A"]);
|
||||||
|
expect(within(panel).getAllByText("−2 ₽")).toHaveLength(2);
|
||||||
|
|
||||||
|
rerender(breakdown([second, first], "8"));
|
||||||
|
expect(payments()).toEqual(["Выплата B", "Выплата A"]);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "BOND · 8 ₽ · 2" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["missing key", undefined],
|
||||||
|
["empty key", ""],
|
||||||
|
["whitespace key", " "],
|
||||||
|
["non-string key", 12],
|
||||||
|
["unknown key", "not-in-breakdown"],
|
||||||
|
])("rejects %s and recovers through retry", async (_name, instrumentKey) => {
|
||||||
|
let response = {
|
||||||
|
...analytics,
|
||||||
|
entries: [
|
||||||
|
{ ...analytics.entries[0], instrumentKey },
|
||||||
|
analytics.entries[1],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(response),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||||
|
expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument();
|
||||||
|
response = analytics;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
expect(await screen.findByText("BOND · 12 ₽ · 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"duplicate instrument keys",
|
||||||
|
{
|
||||||
|
...analytics,
|
||||||
|
instruments: [...analytics.instruments, analytics.instruments[0]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"currency mismatch",
|
||||||
|
{
|
||||||
|
...analytics,
|
||||||
|
entries: [
|
||||||
|
{ ...analytics.entries[0], payment: money("USD", "10") },
|
||||||
|
analytics.entries[1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"receipt count mismatch",
|
||||||
|
{
|
||||||
|
...analytics,
|
||||||
|
instruments: [{ ...analytics.instruments[0], receiptCount: 3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["missing entries", { ...analytics, entries: [] }],
|
||||||
|
[
|
||||||
|
"empty instrument key",
|
||||||
|
{ ...analytics, instruments: [{ ...analytics.instruments[0], key: "" }] },
|
||||||
|
],
|
||||||
|
])("shows a local error for %s", async (_name, response) => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(response),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", { name: "Повторить" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Пассивный доход" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Повторить" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
|
||||||
|
const response = {
|
||||||
|
...analytics,
|
||||||
|
instruments: [
|
||||||
|
{
|
||||||
|
...analytics.instruments[0],
|
||||||
|
key: "server-only-key",
|
||||||
|
total: money("RUB", "8"),
|
||||||
|
dividend: money("RUB", "-2"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
...analytics.entries[0],
|
||||||
|
instrumentKey: "server-only-key",
|
||||||
|
figi: null,
|
||||||
|
ticker: null,
|
||||||
|
description: "Исходная выплата",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...analytics.entries[1],
|
||||||
|
instrumentKey: "server-only-key",
|
||||||
|
instrumentUid: "unrelated-uid",
|
||||||
|
payment: money("RUB", "-2"),
|
||||||
|
description: "Отрицательная корректировка",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(response),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "BOND · 8 ₽ · 2" }),
|
||||||
|
);
|
||||||
|
const panel = await screen.findByRole("region", { name: "BOND · 8 ₽ · 2" });
|
||||||
|
expect(within(panel).getByText("Исходная выплата")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(panel).getByText("Отрицательная корректировка"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects malformed calendar dates without throwing", () => {
|
it("rejects malformed calendar dates without throwing", () => {
|
||||||
expect(isDate("2026-99-99")).toBe(false);
|
expect(isDate("2026-99-99")).toBe(false);
|
||||||
expect(isDate("2026-02-29")).toBe(false);
|
expect(isDate("2026-02-29")).toBe(false);
|
||||||
@@ -127,8 +338,8 @@ describe("AccountIncomePage", () => {
|
|||||||
it("groups income entries once in the view model", () => {
|
it("groups income entries once in the view model", () => {
|
||||||
const display = toIncomeDisplayModel(analytics);
|
const display = toIncomeDisplayModel(analytics);
|
||||||
|
|
||||||
expect(display.instruments[0].entries).toEqual([analytics.entries[0]]);
|
expect(display?.instruments[0].entries).toEqual(analytics.entries);
|
||||||
expect(display.trend[0].buckets[0].total).toEqual(
|
expect(display?.trend[0].buckets[0].total).toEqual(
|
||||||
analytics.buckets[0].totalsByCurrency[0],
|
analytics.buckets[0].totalsByCurrency[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -168,7 +379,7 @@ describe("AccountIncomePage", () => {
|
|||||||
screen.getByText("Налоговые операции не включены в этот результат."),
|
screen.getByText("Налоговые операции не включены в этот результат."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument();
|
expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument();
|
||||||
expect(screen.getByText("BOND · 12 ₽ · 1")).toBeInTheDocument();
|
expect(screen.getByText("BOND · 12 ₽ · 2")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
|
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
|
||||||
|
|||||||
@@ -92,10 +92,13 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
|||||||
return { state: "error", retry: () => analytics.refetch(), period };
|
return { state: "error", retry: () => analytics.refetch(), period };
|
||||||
return { state: "loading" };
|
return { state: "loading" };
|
||||||
}
|
}
|
||||||
|
const display = toIncomeDisplayModel(analytics.data);
|
||||||
|
if (!display)
|
||||||
|
return { state: "error", retry: () => analytics.refetch(), period };
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
analytics: analytics.data,
|
analytics: analytics.data,
|
||||||
display: toIncomeDisplayModel(analytics.data),
|
display,
|
||||||
forecast: toForecast(events),
|
forecast: toForecast(events),
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -149,14 +152,32 @@ function isDate(value: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
|
function toIncomeDisplayModel(
|
||||||
const entriesByInstrument = new Map<string, IncomeAnalytics["entries"]>();
|
analytics: IncomeAnalytics,
|
||||||
|
): IncomeDisplayModel | null {
|
||||||
|
const instruments = new Map<
|
||||||
|
string,
|
||||||
|
IncomeDisplayModel["instruments"][number]
|
||||||
|
>();
|
||||||
|
for (const instrument of analytics.instruments) {
|
||||||
|
if (
|
||||||
|
typeof instrument.key !== "string" ||
|
||||||
|
!instrument.key.trim() ||
|
||||||
|
instruments.has(instrument.key)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
instruments.set(instrument.key, { ...instrument, entries: [] });
|
||||||
|
}
|
||||||
for (const entry of analytics.entries) {
|
for (const entry of analytics.entries) {
|
||||||
const key = incomeEntryKey(entry);
|
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
|
||||||
entriesByInstrument.set(key, [
|
return null;
|
||||||
...(entriesByInstrument.get(key) ?? []),
|
const instrument = instruments.get(entry.instrumentKey);
|
||||||
entry,
|
if (!instrument || entry.payment?.currency !== instrument.total.currency)
|
||||||
]);
|
return null;
|
||||||
|
instrument.entries.push(entry);
|
||||||
|
}
|
||||||
|
for (const instrument of instruments.values()) {
|
||||||
|
if (instrument.entries.length !== instrument.receiptCount) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -169,24 +190,10 @@ function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
|
|||||||
zeroSummary(currency),
|
zeroSummary(currency),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
instruments: analytics.instruments.map((instrument) => ({
|
instruments: [...instruments.values()],
|
||||||
...instrument,
|
|
||||||
entries: entriesByInstrument.get(instrument.key) ?? [],
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string {
|
|
||||||
const base = entry.instrumentUid
|
|
||||||
? `uid:${entry.instrumentUid}`
|
|
||||||
: entry.figi
|
|
||||||
? `figi:${entry.figi}`
|
|
||||||
: entry.ticker || entry.instrumentType
|
|
||||||
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
|
|
||||||
: `operation:${entry.id}`;
|
|
||||||
return `${base}:${entry.payment?.currency ?? ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zeroSummary(
|
function zeroSummary(
|
||||||
currency: string,
|
currency: string,
|
||||||
): IncomeAnalytics["totalsByCurrency"][number] {
|
): IncomeAnalytics["totalsByCurrency"][number] {
|
||||||
|
|||||||
@@ -16,25 +16,36 @@ const IncomeBreakdown: FC<IncomeBreakdownProps> = (props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{t("income.instruments")}</Title>
|
<Title order={2}>{t("income.instruments")}</Title>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
{props.instruments.map((instrument) => (
|
{props.instruments.map((instrument) => {
|
||||||
<Accordion.Item key={instrument.key} value={instrument.key}>
|
const occurrences = new Map<string, number>();
|
||||||
<Accordion.Control>
|
return (
|
||||||
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
|
<Accordion.Item key={instrument.key} value={instrument.key}>
|
||||||
{instrument.receiptCount}
|
<Accordion.Control>
|
||||||
</Accordion.Control>
|
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
|
||||||
<Accordion.Panel>
|
{instrument.receiptCount}
|
||||||
<Text size="sm">
|
</Accordion.Control>
|
||||||
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
|
<Accordion.Panel>
|
||||||
{t("income.dividend")}: {formatMoney(instrument.dividend)}
|
<Text size="sm">
|
||||||
</Text>
|
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
|
||||||
<Stack mt="sm">
|
{t("income.dividend")}: {formatMoney(instrument.dividend)}
|
||||||
{instrument.entries.map((entry) => (
|
</Text>
|
||||||
<LedgerEntryRow key={entry.id} operation={entry} />
|
<Stack mt="sm">
|
||||||
))}
|
{instrument.entries.map((entry) => {
|
||||||
</Stack>
|
// Distinguish repeated IDs and reset image fallback when its source changes.
|
||||||
</Accordion.Panel>
|
const occurrence = occurrences.get(entry.id) ?? 0;
|
||||||
</Accordion.Item>
|
occurrences.set(entry.id, occurrence + 1);
|
||||||
))}
|
const key = JSON.stringify([
|
||||||
|
entry.id,
|
||||||
|
occurrence,
|
||||||
|
entry.logoName,
|
||||||
|
]);
|
||||||
|
return <LedgerEntryRow key={key} operation={entry} />;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+21
-1
@@ -366,6 +366,26 @@ export interface components {
|
|||||||
total: components["schemas"]["MoneyDto"];
|
total: components["schemas"]["MoneyDto"];
|
||||||
receiptCount: number;
|
receiptCount: number;
|
||||||
};
|
};
|
||||||
|
IncomeAnalyticsEntryDto: {
|
||||||
|
id: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
occurredAt: string;
|
||||||
|
type: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
category: "trade" | "income" | "tax" | "fee" | "transfer" | "other";
|
||||||
|
description: string;
|
||||||
|
figi: string | null;
|
||||||
|
instrumentUid: string | null;
|
||||||
|
ticker: string | null;
|
||||||
|
instrumentType: string | null;
|
||||||
|
logoName: string | null;
|
||||||
|
payment: components["schemas"]["MoneyDto"] | null;
|
||||||
|
quantity: string | null;
|
||||||
|
price: components["schemas"]["MoneyDto"] | null;
|
||||||
|
accruedInterest: components["schemas"]["MoneyDto"] | null;
|
||||||
|
commission: components["schemas"]["MoneyDto"] | null;
|
||||||
|
instrumentKey: string;
|
||||||
|
};
|
||||||
IncomeAnalyticsDto: {
|
IncomeAnalyticsDto: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
period: components["schemas"]["IncomePeriodDto"];
|
period: components["schemas"]["IncomePeriodDto"];
|
||||||
@@ -378,7 +398,7 @@ export interface components {
|
|||||||
bucketGranularity: "day" | "week" | "month";
|
bucketGranularity: "day" | "week" | "month";
|
||||||
buckets: components["schemas"]["IncomeBucketDto"][];
|
buckets: components["schemas"]["IncomeBucketDto"][];
|
||||||
instruments: components["schemas"]["IncomeInstrumentDto"][];
|
instruments: components["schemas"]["IncomeInstrumentDto"][];
|
||||||
entries: components["schemas"]["BrokerageOperationDto"][];
|
entries: components["schemas"]["IncomeAnalyticsEntryDto"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
|
|||||||
@@ -1887,6 +1887,116 @@
|
|||||||
"receiptCount"
|
"receiptCount"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IncomeAnalyticsEntryDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"occurredAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"trade",
|
||||||
|
"income",
|
||||||
|
"tax",
|
||||||
|
"fee",
|
||||||
|
"transfer",
|
||||||
|
"other"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"figi": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"instrumentUid": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"ticker": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"instrumentType": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"logoName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MoneyDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MoneyDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"accruedInterest": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MoneyDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commission": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MoneyDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"instrumentKey": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"occurredAt",
|
||||||
|
"type",
|
||||||
|
"category",
|
||||||
|
"description",
|
||||||
|
"figi",
|
||||||
|
"instrumentUid",
|
||||||
|
"ticker",
|
||||||
|
"instrumentType",
|
||||||
|
"logoName",
|
||||||
|
"payment",
|
||||||
|
"quantity",
|
||||||
|
"price",
|
||||||
|
"accruedInterest",
|
||||||
|
"commission",
|
||||||
|
"instrumentKey"
|
||||||
|
]
|
||||||
|
},
|
||||||
"IncomeAnalyticsDto": {
|
"IncomeAnalyticsDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1941,7 +2051,7 @@
|
|||||||
"entries": {
|
"entries": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/BrokerageOperationDto"
|
"$ref": "#/components/schemas/IncomeAnalyticsEntryDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user