refactor: simplify frontend and clarify module ownership #45
-32
@@ -1,32 +0,0 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
TbankConnectionError,
|
||||
TbankConnectionLoading,
|
||||
} from "./TbankConnectionSection.view";
|
||||
|
||||
describe("TbankConnectionSection state views", () => {
|
||||
it("renders loading and retries a status error", () => {
|
||||
const retry = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<MantineProvider>
|
||||
<TbankConnectionLoading />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true",
|
||||
);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<TbankConnectionError retry={retry} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,71 @@ function summary(currency: string, coupon: string, dividend: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("income membership validation", () => {
|
||||
const entry: IncomeAnalytics["entries"][number] = {
|
||||
accruedInterest: null,
|
||||
category: "income",
|
||||
commission: null,
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
id: "coupon-1",
|
||||
instrumentKey: "income-group",
|
||||
instrumentType: "bond",
|
||||
instrumentUid: null,
|
||||
logoName: null,
|
||||
occurredAt: "2026-08-01T12:00:00Z",
|
||||
payment: money("RUB", "10"),
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
};
|
||||
|
||||
const instrument: IncomeAnalytics["instruments"][number] = {
|
||||
...summary("RUB", "10", "0"),
|
||||
displayName: "BOND",
|
||||
figi: "FIGI",
|
||||
instrumentType: "bond",
|
||||
instrumentUid: null,
|
||||
key: entry.instrumentKey,
|
||||
receiptCount: 1,
|
||||
ticker: "BOND",
|
||||
};
|
||||
|
||||
const valid: IncomeAnalytics = {
|
||||
...analytics,
|
||||
entries: [entry],
|
||||
instruments: [instrument],
|
||||
totalsByCurrency: [summary("RUB", "10", "0")],
|
||||
};
|
||||
|
||||
it.each([
|
||||
["missing key", undefined],
|
||||
["empty key", ""],
|
||||
["whitespace key", " "],
|
||||
["non-string key", 12],
|
||||
["unknown key", "not-in-breakdown"],
|
||||
])("rejects an entry with %s", (_name, instrumentKey) => {
|
||||
// Deliberately malformed API data exercises the runtime validation boundary.
|
||||
const response = {
|
||||
...valid,
|
||||
entries: [{ ...entry, instrumentKey }],
|
||||
} as unknown as IncomeAnalytics;
|
||||
|
||||
expect(toIncomeDisplayModel(response)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["duplicate instrument keys", { instruments: [instrument, instrument] }],
|
||||
["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }],
|
||||
["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }],
|
||||
["missing entries", { entries: [] }],
|
||||
["empty instrument key", { instruments: [{ ...instrument, key: "" }] }],
|
||||
])("rejects %s", (_name, invalid) => {
|
||||
expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("income comparison display", () => {
|
||||
it.each([
|
||||
["25", "100", "+25 %"],
|
||||
|
||||
@@ -324,17 +324,11 @@ describe("AccountIncomePage", () => {
|
||||
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
|
||||
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) => {
|
||||
it("rejects inconsistent analytics and recovers through retry", async () => {
|
||||
let response = {
|
||||
...analytics,
|
||||
entries: [
|
||||
{ ...analytics.entries[0], instrumentKey },
|
||||
{ ...analytics.entries[0], instrumentKey: "not-in-breakdown" },
|
||||
analytics.entries[1],
|
||||
],
|
||||
};
|
||||
@@ -356,51 +350,6 @@ describe("AccountIncomePage", () => {
|
||||
).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(resource(response)),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Выплаты" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts an empty analytics response", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
@@ -870,8 +819,6 @@ describe("income period and snapshot boundary", () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Выплаты" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 4 })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("RUB")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("USD")).not.toBeInTheDocument();
|
||||
const cards = screen.getAllByRole("listitem");
|
||||
@@ -881,7 +828,6 @@ describe("income period and snapshot boundary", () => {
|
||||
expect(cards[2]).toHaveTextContent("−3 ₽");
|
||||
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Купон")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -302,7 +302,9 @@ describe("AccountPage states", () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Повторить" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link")).toHaveAttribute(
|
||||
expect(screen.getByRole("link", {
|
||||
name: code === "NOT_FOUND" ? "К счетам" : "Открыть настройки T-Bank",
|
||||
})).toHaveAttribute(
|
||||
"href",
|
||||
code === "NOT_FOUND" ? "/accounts" : "/settings",
|
||||
);
|
||||
@@ -427,34 +429,13 @@ describe("AccountPage states", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("renders loading and empty states", () => {
|
||||
renderPage(<AccountPageLoading />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
|
||||
it("renders the empty state", () => {
|
||||
renderPage(<AccountPageEmpty {...emptyProps()} />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders allowed error actions", () => {
|
||||
const retry = vi.fn();
|
||||
renderPage(<AccountPageError code="NOT_FOUND" retry={retry} />);
|
||||
expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute(
|
||||
"href",
|
||||
"/accounts",
|
||||
);
|
||||
|
||||
renderPage(<AccountPageError code="TBANK_NOT_CONNECTED" retry={retry} />);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
|
||||
).toHaveAttribute("href", "/settings");
|
||||
|
||||
renderPage(<AccountPageError code="TBANK_UNAVAILABLE" retry={retry} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fn } from "storybook/test";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import preview from "../../.storybook/preview";
|
||||
@@ -56,18 +55,4 @@ describe("Storybook isolated product examples", () => {
|
||||
|
||||
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps synthetic totals visible while the refresh example calls a local retry", () => {
|
||||
const { RefreshError } = composeStories(RefreshStories, preview);
|
||||
const retry = fn();
|
||||
const view = render(<RefreshError retry={retry} />);
|
||||
expect(screen.getByText(/125\s*000,50/)).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
view.rerender(
|
||||
<RefreshError refreshError={false} refreshing retry={retry} />,
|
||||
);
|
||||
expect(screen.getByText(/125\s*000,50/)).toBeVisible();
|
||||
expect(screen.getByRole("status")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user