refactor: align frontend ownership boundaries

Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
2026-09-06 18:20:05 +03:00
parent 175727bc93
commit c97f62bd98
118 changed files with 2190 additions and 2254 deletions
@@ -1,17 +1,13 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import {
brokerageOperationsQueryParams,
useBrokerageOperations,
} from "./useBrokerageOperations";
describe("useBrokerageOperations", () => {
afterEach(() => configureAuthClient(null));
it("normalizes query parameters and loads a page without retries", async () => {
configureAuthClient({
accessToken: () => "token",
@@ -24,20 +20,9 @@ describe("useBrokerageOperations", () => {
cursor: "next",
}),
).toEqual({ categories: ["income", "tax"], cursor: "next" });
const client = new QueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(
() => useBrokerageOperations("account-1", { limit: 5 }),
{ wrapper },
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
@@ -32,25 +35,36 @@ function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
};
}
function brokerageOperationsQueryOptions(
accountId: string | undefined,
query: BrokerageOperationsQuery = {},
) {
return apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{
params: {
path: { accountId: accountId ?? "" },
query: brokerageOperationsQueryParams(query),
},
},
);
}
function useBrokerageOperations(
accountId: string | undefined,
query: BrokerageOperationsQuery = {},
) {
const params = brokerageOperationsQueryParams(query);
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: accountId ?? "" }, query: params } },
),
...brokerageOperationsQueryOptions(accountId, query),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
});
}
export {
type BrokerageOperation,
brokerageOperationsQueryOptions,
brokerageOperationsQueryParams,
type OperationCategory,
operationCategories,
+1
View File
@@ -1,5 +1,6 @@
export {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
operationCategories,
useBrokerageOperations,
@@ -0,0 +1,53 @@
import { formatMoney } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
type PaymentPresentation = {
amount: string;
label: string;
tone: "positive" | "negative" | "neutral";
};
function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income")
return operation.type === "OPERATION_TYPE_COUPON"
? "ledger.entryLabel.income.coupon"
: "ledger.entryLabel.income.dividend";
return `ledger.category.${operation.category}`;
}
function paymentPresentation(
operation: BrokerageOperation,
): PaymentPresentation {
if (!operation.payment)
return {
amount: "—",
label: "ledger.noMoney",
tone: "neutral",
};
if (isZeroDecimal(operation.payment.value))
return {
amount: formatMoney(operation.payment),
label: "ledger.noMoney",
tone: "neutral",
};
if (operation.payment.value.startsWith("-"))
return {
amount: `−${formatMoney({
...operation.payment,
value: operation.payment.value.slice(1),
})}`,
label: entryLabelKey(operation),
tone: "negative",
};
return {
amount: `+${formatMoney(operation.payment)}`,
label: entryLabelKey(operation),
tone: "positive",
};
}
function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
export { paymentPresentation };
@@ -2,32 +2,15 @@ import { Avatar, Paper, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "@/entities/portfolio";
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { paymentPresentation } from "../model/display";
import classes from "./LedgerEntryRow.module.css";
type LedgerEntryRowProps = {
operation: BrokerageOperation;
};
type PaymentPresentation = {
amount: string;
label: string;
tone: "positive" | "negative" | "neutral";
};
function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income")
return operation.type === "OPERATION_TYPE_COUPON"
? "ledger.entryLabel.income.coupon"
: "ledger.entryLabel.income.dividend";
return `ledger.category.${operation.category}`;
}
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
const { operation } = props;
const { t } = useTranslation();
@@ -72,39 +55,4 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
);
};
function paymentPresentation(
operation: BrokerageOperation,
): PaymentPresentation {
if (!operation.payment)
return {
amount: "—",
label: "ledger.noMoney",
tone: "neutral",
};
if (isZeroDecimal(operation.payment.value))
return {
amount: formatMoney(operation.payment),
label: "ledger.noMoney",
tone: "neutral",
};
if (operation.payment.value.startsWith("-"))
return {
amount: `−${formatMoney({
...operation.payment,
value: operation.payment.value.slice(1),
})}`,
label: entryLabelKey(operation),
tone: "negative",
};
return {
amount: `+${formatMoney(operation.payment)}`,
label: entryLabelKey(operation),
tone: "positive",
};
}
function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
export { LedgerEntryRow };
@@ -1,31 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts";
function createWrapper() {
const client = new QueryClient();
const TestWrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
}
describe("useBrokerageAccounts", () => {
afterEach(() => configureAuthClient(null));
it("loads accounts through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
@@ -46,7 +27,7 @@ describe("useBrokerageAccounts", () => {
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createWrapper(),
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -83,7 +64,7 @@ describe("useBrokerageAccounts", () => {
}),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createWrapper(),
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
@@ -13,7 +16,6 @@ function useBrokerageAccounts() {
return useAuthenticatedQuery({
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
retry: false,
});
}
-1
View File
@@ -7,6 +7,5 @@ export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
} from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
@@ -16,12 +16,6 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const;
}
function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
function brokerageAccountCards(
accounts: BrokerageAccount[],
): BrokerageAccountCard[] {
@@ -44,9 +38,4 @@ function brokerageAccountCards(
});
}
export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
};
export { accountTypeKey, type BrokerageAccountCard, brokerageAccountCards };
@@ -2,7 +2,7 @@ import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import type { BrokerageAccountCard } from "../model/display";
import { accountTypeKey } from "../model/display";
import classes from "./BrokerageAccountList.module.css";
@@ -1,31 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBrokerageEvents } from "./useBrokerageEvents";
function createWrapper() {
const client = new QueryClient();
const TestWrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
}
describe("useBrokerageEvents", () => {
afterEach(() => configureAuthClient(null));
it("loads future cash events through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
@@ -54,7 +35,7 @@ describe("useBrokerageEvents", () => {
);
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
wrapper: createWrapper(),
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
@@ -15,7 +18,6 @@ function useBrokerageEvents(accountId: string | undefined) {
),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
});
}
+5 -5
View File
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type IncomeSummary =
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
@@ -15,7 +18,6 @@ function useIncomeSummary(accountId: string | undefined) {
),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
});
}
@@ -36,8 +38,6 @@ function useIncomeAnalytics(
),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
refetchOnWindowFocus: false,
});
}
@@ -1,36 +1,21 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
afterEach(() => configureAuthClient(null));
it("uses the account endpoint with a 30 second cache and no retry", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const client = new QueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper,
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -58,16 +43,9 @@ describe("usePortfolio", () => {
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const client = new QueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(() => usePortfolio("account-1"), { wrapper });
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
+15 -9
View File
@@ -1,24 +1,30 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
const portfolioQueryPolicy = {
staleTime: 30_000,
retry: false,
} as const;
type Portfolio =
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
function portfolioQueryOptions(accountId: string | undefined) {
return apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: accountId ?? "" } } },
);
}
function usePortfolio(accountId: string | undefined) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: accountId ?? "" } } },
),
...portfolioQueryOptions(accountId),
enabled: Boolean(accountId),
...portfolioQueryPolicy,
});
}
export { type Portfolio, usePortfolio };
export { type Portfolio, portfolioQueryOptions, usePortfolio };
+5 -7
View File
@@ -1,8 +1,6 @@
export { type Portfolio, usePortfolio } from "./api/usePortfolio";
export {
formatMoney,
formatShare,
formatSnapshotTime,
instrumentLogoUrl,
portfolioErrorCode,
} from "./model/display";
type Portfolio,
portfolioQueryOptions,
usePortfolio,
} from "./api/usePortfolio";
export { formatShare } from "./model/display";
+1 -21
View File
@@ -1,16 +1,7 @@
import { describe, expect, it } from "vitest";
import { formatMoney, formatShare, instrumentLogoUrl } from "./display";
import { formatShare } from "./display";
describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe(
"-1 234 567,89 ₽",
);
expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—");
});
it("formats a share as a display-only percentage", () => {
expect(
formatShare(
@@ -33,15 +24,4 @@ describe("portfolio display helpers", () => {
),
).toBeNull();
});
it("builds the brand logo url from an upstream file name", () => {
expect(instrumentLogoUrl("sber3.png")).toBe(
"https://invest-brands.cdn-tinkoff.ru/sber3x160.png",
);
expect(instrumentLogoUrl(" RU000A0JPNM1.png ")).toBe(
"https://invest-brands.cdn-tinkoff.ru/RU000A0JPNM1x160.png",
);
expect(instrumentLogoUrl(null)).toBeNull();
expect(instrumentLogoUrl(" ")).toBeNull();
});
});
+1 -45
View File
@@ -2,21 +2,6 @@ import type { Portfolio } from "../api/usePortfolio";
type Money = Portfolio["totalsByCurrency"][number];
function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".");
const sign = integer.startsWith("-") ? "-" : "";
const grouped = (sign ? integer.slice(1) : integer).replace(
/\B(?=(\d{3})+(?!\d))/g,
" ",
);
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
}
function formatMoney(money: Money | null): string {
if (!money) return "—";
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
function formatShare(
numerator: Money | null,
denominator: Money | null,
@@ -32,33 +17,4 @@ function formatShare(
return `${rounded} %`;
}
function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
function portfolioErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
const logoSize = 160;
function instrumentLogoUrl(logoName: string | null | undefined): string | null {
const stem = logoName?.trim().replace(/\.png$/i, "");
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: null;
}
export {
formatMoney,
formatShare,
formatSnapshotTime,
instrumentLogoUrl,
portfolioErrorCode,
};
export { formatShare };