feat(income): add account income analytics
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
|
||||
const analytics = {
|
||||
accountId: "account-1",
|
||||
period: { from: "2026-08-01", to: "2026-08-27" },
|
||||
calculatedAt: "2026-08-27T00:00:00Z",
|
||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
||||
previous: {
|
||||
period: { from: "2026-07-01", to: "2026-07-31" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
yearComparison: null,
|
||||
bucketGranularity: "day",
|
||||
buckets: [
|
||||
{
|
||||
period: { from: "2026-08-01", to: "2026-08-01" },
|
||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
||||
},
|
||||
],
|
||||
instruments: [
|
||||
{
|
||||
key: "figi:FIGI:RUB",
|
||||
figi: "FIGI",
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
instrumentType: "bond",
|
||||
displayName: "BOND",
|
||||
coupon: money("RUB", "10"),
|
||||
dividend: money("RUB", "2"),
|
||||
total: money("RUB", "12"),
|
||||
receiptCount: 1,
|
||||
},
|
||||
],
|
||||
entries: [entry()],
|
||||
};
|
||||
|
||||
function money(currency: string, value: string) {
|
||||
return { currency, value };
|
||||
}
|
||||
|
||||
function summary(currency: string, coupon: string, dividend: string) {
|
||||
return {
|
||||
currency,
|
||||
coupon: money(currency, coupon),
|
||||
dividend: money(currency, dividend),
|
||||
total: money(currency, String(Number(coupon) + Number(dividend))),
|
||||
};
|
||||
}
|
||||
|
||||
function comparison(currency: string) {
|
||||
return {
|
||||
currency,
|
||||
selected: summary(currency, "10", "2"),
|
||||
compared: summary(currency, "1", "0"),
|
||||
difference: summary(currency, "9", "2"),
|
||||
};
|
||||
}
|
||||
|
||||
function entry() {
|
||||
return {
|
||||
id: "coupon-1",
|
||||
occurredAt: "2026-08-01T12:00:00Z",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
category: "income",
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
instrumentType: "bond",
|
||||
logoName: null,
|
||||
payment: money("RUB", "10"),
|
||||
quantity: null,
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const Wrapper: FC<PropsWithChildren> = (props) => (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
return render(
|
||||
<Wrapper>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={<AccountIncomePage />}
|
||||
/>
|
||||
<Route path="/accounts/:accountId/events" element={<h1>Events</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Wrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AccountIncomePage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
configureAuthClient(null);
|
||||
});
|
||||
|
||||
it("renders realized analytics, drill-down, and the independent forecast", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(analytics),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-27T00:00:00Z",
|
||||
incomeTotalsByCurrency: [money("RUB", "3")],
|
||||
events: [
|
||||
{
|
||||
type: "coupon",
|
||||
date: "2026-08-28",
|
||||
dateKind: "event",
|
||||
figi: "FIGI",
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
displayName: "BOND",
|
||||
projectedAmount: money("RUB", "3"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Пассивный доход" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Налоговые операции не включены в этот результат."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument();
|
||||
expect(screen.getByText("BOND · 12 ₽ · 1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Разница: Купоны 9 ₽ · Дивиденды 2 ₽ · Итого 11 ₽"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty trend bucket as zero money", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json({
|
||||
...analytics,
|
||||
buckets: [
|
||||
...analytics.buckets,
|
||||
{
|
||||
period: { from: "2026-08-02", to: "2026-08-02" },
|
||||
totalsByCurrency: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("2026-08-02 — 2026-08-02: Купоны 0 ₽, Дивиденды 0 ₽"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders signed coupon and dividend segments on opposite baseline sides", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json({
|
||||
...analytics,
|
||||
buckets: [
|
||||
{
|
||||
period: { from: "2026-08-01", to: "2026-08-01" },
|
||||
totalsByCurrency: [summary("RUB", "-10", "2")],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("trend-negative-coupon-2026-08-01"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("trend-positive-dividend-2026-08-01"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a recoverable realized error", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
() => new HttpResponse(null, { status: 503 }),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user