refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -41,6 +41,7 @@ function configureTestAuth() {
function renderAccountRoute(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -56,6 +57,7 @@ function renderAccountRoute(queryClient: QueryClient) {
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -65,8 +67,10 @@ function renderPage(ui: React.ReactNode) {
);
}
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "0.5" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
@@ -98,8 +102,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const emptyRecentOperations: AccountPageRecentOperations = {
state: "empty",
@@ -141,16 +145,20 @@ describe("AccountPage states", () => {
const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4";
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
const position = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position
= {
...portfolio.positions[0],
displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2",
instrumentUid: uid,
logoName: hasMetadata ? "vtoraya.png" : null,
ticker: hasMetadata ? slug : null,
type,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
@@ -168,6 +176,7 @@ describe("AccountPage states", () => {
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
return HttpResponse.json(
resource({
instrumentUid: uid,
@@ -186,6 +195,7 @@ describe("AccountPage states", () => {
const links = screen.getAllByRole("link", {
name: position.displayName,
});
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute(
@@ -234,6 +244,7 @@ describe("AccountPage states", () => {
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
@@ -286,15 +297,12 @@ describe("AccountPage states", () => {
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
"allows retry for %s",
(code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
@@ -320,9 +328,11 @@ describe("AccountPage states", () => {
})}
/>,
);
const balances = within(
screen.getByRole("region", { name: "Денежные остатки" }),
);
expect(
balances.getAllByRole("definition").map(node => node.textContent),
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
@@ -376,6 +386,7 @@ describe("AccountPage states", () => {
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency,
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(snapshot)),
@@ -447,9 +458,11 @@ describe("AccountPage states", () => {
expect(
screen.getByRole("button", { name: "Облигации" }),
).toBeInTheDocument();
const positions = screen
.getAllByText(/Вторая|Первая/)
.map(element => element.textContent);
expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByText("10 ₽")).toBeInTheDocument();
expect(screen.getByText("-1 ₽")).toBeInTheDocument();
@@ -470,7 +483,8 @@ describe("AccountPage states", () => {
it("renders stock cards with logo fallback and signed unlabeled return", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -499,8 +513,9 @@ describe("AccountPage states", () => {
type: "STOCK",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("А")).toBeInTheDocument();
@@ -513,7 +528,8 @@ describe("AccountPage states", () => {
it("groups stock supporting metrics into quiet single lines", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -530,8 +546,9 @@ describe("AccountPage states", () => {
type: "STOCK",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -546,7 +563,8 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -587,8 +605,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -604,7 +623,8 @@ describe("AccountPage states", () => {
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -633,8 +653,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
@@ -653,7 +674,8 @@ describe("AccountPage states", () => {
it("renders bond cards in expanded layout with bond-specific classes", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -670,8 +692,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("О")).toBeInTheDocument();
@@ -684,7 +707,8 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -701,8 +725,9 @@ describe("AccountPage states", () => {
type: "FUND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -713,7 +738,8 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
account={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
account={
{
id: "account-1",
name: "Основной",
@@ -721,7 +747,8 @@ describe("AccountPage states", () => {
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
expect(
@@ -750,7 +777,8 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentOperations={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentOperations={
{
items: [
{
@@ -774,7 +802,8 @@ describe("AccountPage states", () => {
state: "loaded",
to: "/accounts/account-1/operations",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
@@ -783,8 +812,9 @@ describe("AccountPage states", () => {
});
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
const event: BrokerageEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const event: BrokerageEvent
= {
date: "2026-09-10",
dateKind: "event",
displayName: "Облигация",
@@ -795,12 +825,14 @@ describe("AccountPage states", () => {
projectedAmount: { currency: "RUB", value: "850.50" },
ticker: "DEMO",
type: "coupon",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentEvents={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentEvents={
{
items: [
event,
@@ -828,19 +860,22 @@ describe("AccountPage states", () => {
state: "loaded",
to: "/accounts/account-1/events",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
const section = screen.getByRole("region", { name: "Будущие события" });
const content = within(section);
const text = section.textContent ?? "";
const titles = [
"Купон · Облигация",
"Дивиденд · Акция",
"Погашение · Облигация",
"Оферта · Облигация",
];
for (const title of titles) expect(content.getByText(title)).toBeVisible();
expect(titles.map(title => text.indexOf(title))).toEqual(
titles.map(title => text.indexOf(title)).toSorted((a, b) => a - b),
@@ -870,13 +905,16 @@ describe("AccountPage states", () => {
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
);
const content = within(
screen.getByRole("region", { name: "Будущие события" }),
);
if (state === "loading") {
const skeleton = content.getByRole("status", {
name: "Загрузка будущих событий",
});
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(skeleton).toHaveAttribute("aria-busy", "true");
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -888,6 +926,7 @@ describe("AccountPage states", () => {
content.getByText("На ближайшие 30 дней событий не найдено."),
).toBeVisible();
}
expect(
content.getByRole("link", { name: "Открыть будущие события" }),
).toHaveAttribute("href", "/accounts/account-1/events");
@@ -914,8 +953,9 @@ describe("AccountPage states", () => {
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const initialPortfolio: Portfolio
= {
...portfolio,
structure: {
currencies: [
@@ -928,8 +968,9 @@ describe("AccountPage states", () => {
],
unvaluedCategories: [{ count: 1, type: "STOCK" }],
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
@@ -944,7 +985,8 @@ describe("AccountPage states", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
id: "overview",
@@ -956,8 +998,9 @@ describe("AccountPage states", () => {
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
@@ -965,11 +1008,14 @@ describe("AccountPage states", () => {
return HttpResponse.json(
resource(initialPortfolio, { resourceKey: "portfolio" }),
);
if (requestCount === 3) {
const deferred = Promise.withResolvers<Response>();
resolveRetry = deferred.resolve;
return deferred.promise;
}
return HttpResponse.json(
{ code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
{ status },
@@ -980,9 +1026,11 @@ describe("AccountPage states", () => {
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
const structure = screen.getByRole("region", {
name: "Структура портфеля",
});
expect(structure).toHaveTextContent("10 ₽");
const snapshotText = screen.getByText(/Данные получены/).textContent;
expect(snapshotText).toContain("10:00");
@@ -1057,9 +1105,11 @@ describe("AccountPage states", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),