refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -18,8 +18,10 @@ import {
|
||||
useAccountPageViewModel,
|
||||
} from "./AccountPage.vm";
|
||||
|
||||
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: "1" }],
|
||||
isStale: true,
|
||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||
@@ -63,8 +65,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
function configureTestAuth() {
|
||||
configureAuthClient({
|
||||
@@ -76,6 +78,7 @@ function configureTestAuth() {
|
||||
|
||||
function createWrapper() {
|
||||
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||
|
||||
const Wrapper: FC<PropsWithChildren> = props => (
|
||||
<QueryWrapper>
|
||||
<MantineProvider>
|
||||
@@ -87,6 +90,7 @@ function createWrapper() {
|
||||
</MantineProvider>
|
||||
</QueryWrapper>
|
||||
);
|
||||
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
@@ -97,7 +101,8 @@ function mockLoadedAccountPage() {
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
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: "account-1",
|
||||
hasNext: false,
|
||||
@@ -121,8 +126,9 @@ function mockLoadedAccountPage() {
|
||||
],
|
||||
nextCursor: null,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
@@ -130,18 +136,22 @@ function mockLoadedAccountPage() {
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
displayName: `Купон ${index}`,
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
ticker: `BND${index}`,
|
||||
type: "coupon",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
events: Array.from(
|
||||
{ length: 6 },
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
(_, index) =>
|
||||
({
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
displayName: `Купон ${index}`,
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
ticker: `BND${index}`,
|
||||
type: "coupon",
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -158,6 +168,7 @@ describe("useAccountPageViewModel", () => {
|
||||
"DIGITAL_FINANCIAL_ASSET",
|
||||
"FUND",
|
||||
] as const;
|
||||
|
||||
const snapshot: Portfolio = {
|
||||
...portfolio,
|
||||
cashBalances: [{ currency: "RUB", value: "1000" }],
|
||||
@@ -169,19 +180,23 @@ describe("useAccountPageViewModel", () => {
|
||||
figi: "FIGI-CURRENCY",
|
||||
type: "CURRENCY",
|
||||
},
|
||||
...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...portfolio.positions[2],
|
||||
currentValue:
|
||||
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
|
||||
figi: `FIGI-${type}-NEXT`,
|
||||
type,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
...otherTypes.map(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
type =>
|
||||
({
|
||||
...portfolio.positions[2],
|
||||
currentValue:
|
||||
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
|
||||
figi: `FIGI-${type}-NEXT`,
|
||||
type,
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
||||
};
|
||||
|
||||
const original = structuredClone(snapshot);
|
||||
|
||||
const sections = toAccountPagePositionSections(snapshot);
|
||||
@@ -210,10 +225,12 @@ describe("useAccountPageViewModel", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||
|
||||
const loaded = result.current as Extract<
|
||||
typeof result.current,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
|
||||
expect(
|
||||
loaded.positionSections.map(({ groups, kind }) => ({
|
||||
kind,
|
||||
@@ -231,35 +248,32 @@ describe("useAccountPageViewModel", () => {
|
||||
shareText: "66,7 %",
|
||||
},
|
||||
],
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
},
|
||||
{
|
||||
kind: "bonds",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Облигация",
|
||||
logoSrc: null,
|
||||
returnText: "-5 ₽",
|
||||
returnTone: "negative",
|
||||
shareText: "33,3 %",
|
||||
},
|
||||
],
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */ {
|
||||
kind: "other",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Фонд",
|
||||
logoSrc: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
|
||||
returnText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
|
||||
returnTone: undefined,
|
||||
shareText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
|
||||
},
|
||||
],
|
||||
},
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
kind: "bonds",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Облигация",
|
||||
logoSrc: null,
|
||||
returnText: "-5 ₽",
|
||||
returnTone: "negative",
|
||||
shareText: "33,3 %",
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
kind: "other",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Фонд",
|
||||
logoSrc: null,
|
||||
returnText: null,
|
||||
returnTone: undefined,
|
||||
shareText: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -276,10 +290,12 @@ describe("useAccountPageViewModel", () => {
|
||||
(result.current as { recentEvents?: { state: string } }).recentEvents,
|
||||
)?.toMatchObject({ state: "loaded" }),
|
||||
);
|
||||
|
||||
const loaded = result.current as Extract<
|
||||
typeof result.current,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
|
||||
expect(loaded.recentOperations).toMatchObject({
|
||||
items: [{ description: "Покупка акции", id: "operation-1" }],
|
||||
state: "loaded",
|
||||
@@ -335,11 +351,10 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{ hasNext: false, items: [], nextCursor: null }
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
{ availability: "partial" },
|
||||
{ hasNext: false, items: [], nextCursor: null },
|
||||
/* eslint-enable unicorn/no-null */ { availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -352,9 +367,11 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
@@ -367,14 +384,18 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
|
||||
it("refreshes every overview section through one account descriptor and observational reads", async () => {
|
||||
configureTestAuth();
|
||||
const sections: Record<string, unknown> = (/* 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 sections: Record<string, unknown>
|
||||
= {
|
||||
"events": { events: [], incomeTotalsByCurrency: [] },
|
||||
"income-summary": { totalsByCurrency: [] },
|
||||
"operations": { hasNext: false, items: [], nextCursor: null },
|
||||
portfolio,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
const observed: string[] = [];
|
||||
const scopes: unknown[] = [];
|
||||
let version = "1";
|
||||
@@ -385,6 +406,7 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
({ request }) => {
|
||||
if (new URL(request.url).searchParams.get("sync") === "observe")
|
||||
observed.push(path);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource(data, { resourceKey: path, stateVersion: version }),
|
||||
);
|
||||
@@ -394,6 +416,7 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
version = "2";
|
||||
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
@@ -404,7 +427,8 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
);
|
||||
}),
|
||||
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",
|
||||
@@ -416,13 +440,16 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
),
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
incomeSummary: { state: "empty" },
|
||||
|
||||
Reference in New Issue
Block a user