refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -16,9 +16,11 @@ import { StockPage } from "./StockPage";
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<output data-testid="location">
|
||||
{location.pathname}
|
||||
|
||||
{location.search}
|
||||
</output>
|
||||
);
|
||||
@@ -26,8 +28,11 @@ const LocationProbe = () => {
|
||||
|
||||
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
const stock = (/* 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 stock
|
||||
= {
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
instrumentUid: uid,
|
||||
@@ -37,8 +42,9 @@ const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the produc
|
||||
sector: "financial",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
ticker: "TEST",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
const position = {
|
||||
absoluteReturn: money("1000"),
|
||||
accounts: [
|
||||
@@ -59,6 +65,7 @@ const position = {
|
||||
quantity: "10",
|
||||
returnPercent: "50",
|
||||
};
|
||||
|
||||
const quote = {
|
||||
change: money("10"),
|
||||
changePercent: "3.448276",
|
||||
@@ -70,6 +77,7 @@ const quote = {
|
||||
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
|
||||
unavailable: [],
|
||||
};
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
ambiguousTicker?: boolean;
|
||||
@@ -95,6 +103,7 @@ function setup(
|
||||
),
|
||||
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
|
||||
return options.ambiguousTicker
|
||||
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||
: HttpResponse.json(resource(resolved));
|
||||
@@ -115,20 +124,23 @@ function setup(
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json(
|
||||
resource((/* 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. */
|
||||
resource(
|
||||
{
|
||||
candles: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
if (params.section === "company")
|
||||
return options.failCompany
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(
|
||||
resource((/* 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. */
|
||||
resource(
|
||||
{
|
||||
description: "Описание компании",
|
||||
metrics: options.metrics ?? [
|
||||
@@ -142,11 +154,14 @@ function setup(
|
||||
],
|
||||
observedAt: quote.observedAt,
|
||||
unavailable: [],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* 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. */
|
||||
resource(
|
||||
{
|
||||
dividends: [
|
||||
{
|
||||
@@ -172,8 +187,9 @@ function setup(
|
||||
positionAt: null,
|
||||
positionIsStale: false,
|
||||
to: "2027-09-10",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
@@ -182,8 +198,10 @@ function setup(
|
||||
const isNext = url.searchParams.has("cursor");
|
||||
if (isNext && cursorError)
|
||||
return HttpResponse.json({ code: cursorError }, { status: 400 });
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* 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. */
|
||||
resource(
|
||||
{
|
||||
accountId: "all",
|
||||
hasNext: !isNext,
|
||||
@@ -199,13 +217,16 @@ function setup(
|
||||
payment: money(operationPayment),
|
||||
price: isNext ? money("100") : null,
|
||||
quantity: isNext ? "2" : null,
|
||||
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
|
||||
type: isNext
|
||||
? "OPERATION_TYPE_SELL"
|
||||
: "OPERATION_TYPE_DIVIDEND",
|
||||
},
|
||||
],
|
||||
nextCursor: isNext ? null : "next",
|
||||
observedAt: quote.observedAt,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -220,6 +241,7 @@ function setup(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
|
||||
<LocationProbe />
|
||||
|
||||
<Routes>
|
||||
<Route element={<StockPage />} path="/stocks/:stockId" />
|
||||
</Routes>
|
||||
@@ -227,6 +249,7 @@ function setup(
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
failQuote: () => {
|
||||
isFailQuote = true;
|
||||
@@ -238,9 +261,11 @@ function setup(
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stock page", () => {
|
||||
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
|
||||
setup((/* 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. */
|
||||
setup(
|
||||
{
|
||||
metrics: [
|
||||
{
|
||||
@@ -286,8 +311,9 @@ describe("stock page", () => {
|
||||
value: 12.3456,
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */));
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
);
|
||||
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
|
||||
for (const value of [
|
||||
"1,23 млн ₽",
|
||||
@@ -370,9 +396,7 @@ describe("stock page", () => {
|
||||
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
message.closest(
|
||||
"[role=\"status\"]",
|
||||
),
|
||||
message.closest("[role=\"status\"]"),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
it("recovers an initially missing company section without reloading successful sections", async () => {
|
||||
@@ -383,6 +407,7 @@ describe("stock page", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
|
||||
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
description: "Восстановленное описание",
|
||||
@@ -441,7 +466,12 @@ describe("stock page", () => {
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
["INVALID_OPERATIONS_CURSOR", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, null],
|
||||
[
|
||||
"INVALID_OPERATIONS_CURSOR",
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
null,
|
||||
null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
|
||||
],
|
||||
["TBANK_UNAVAILABLE", "next", "observe"],
|
||||
])(
|
||||
"recovers history after %s with fresh data and the correct cursor",
|
||||
@@ -475,24 +505,25 @@ describe("stock page", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const controls = setup({
|
||||
path: `/stocks/${uid}?period=year&events=trades`,
|
||||
});
|
||||
|
||||
await screen.findByText("300 ₽");
|
||||
controls.failQuote();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||
const message = await screen.findByText(/последние полученные/);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
message.closest(
|
||||
"[role=\"status\"]",
|
||||
),
|
||||
message.closest("[role=\"status\"]"),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByText("300 ₽")).toBeVisible();
|
||||
expect(scopes).toEqual([
|
||||
|
||||
Reference in New Issue
Block a user