refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user