From 7e976c330c54715018eda4bff0764c5d4de9c8b0 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 21:25:28 +0300 Subject: [PATCH] refactor: simplify errors and focus tests --- .ai/conventions/testing.md | 2 +- .../api/useBrokerageAccounts.test.tsx | 85 ------------------- .../portfolio/api/usePortfolio.test.tsx | 66 -------------- .../TbankConnectionSection.test.tsx | 46 ++++++---- .../TbankConnectionSectionLoaded.tsx | 4 +- .../TbankConnectionSectionLoaded.vm.ts | 21 +---- .../ui/AccountPage/AccountPage.vm.test.tsx | 18 +--- .../CurrencyCards/CurrencyCards.test.tsx | 18 +--- .../bonds/ui/BondsPage/BondsPage.test.tsx | 21 +++-- .../stocks/ui/StocksPage/StocksPage.test.tsx | 21 +++-- 10 files changed, 70 insertions(+), 232 deletions(-) delete mode 100644 src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx delete mode 100644 src/entities/portfolio/api/usePortfolio.test.tsx diff --git a/.ai/conventions/testing.md b/.ai/conventions/testing.md index ab0b1af..da1e450 100644 --- a/.ai/conventions/testing.md +++ b/.ai/conventions/testing.md @@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), [client test](../../src/shared/api/client.test.ts), -[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx). +[resource lifecycle test](../../src/entities/portfolio/api/resource-lifecycle.test.tsx). Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. ## Команды diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx deleted file mode 100644 index 74cf150..0000000 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; - -import { configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; -import { resource } from "@/test/resource"; -import { server } from "@/test/server"; - -import { useBrokerageAccounts } from "./useBrokerageAccounts"; - -describe("useBrokerageAccounts", () => { - it("loads accounts through the authenticated query seam", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - server.use( - http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json( - resource([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]), - ), - ), - ); - - const { result } = renderHook(() => useBrokerageAccounts(), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]); - }); - - it("keeps the current connection snapshot after a background refetch error", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - let calls = 0; - server.use( - http.get("*/api/v1/brokerage-accounts", () => { - calls += 1; - - return calls === 1 - ? HttpResponse.json( - resource([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]), - ) - : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); - }), - ); - - const { result } = renderHook(() => useBrokerageAccounts(), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - void result.current.refetch(); - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.data?.[0].id).toBe("account-1"); - }); -}); diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx deleted file mode 100644 index 87733c5..0000000 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; - -import { configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; -import { resource } from "@/test/resource"; -import { server } from "@/test/server"; - -import { usePortfolio } from "./usePortfolio"; - -describe("usePortfolio", () => { - it("loads the account portfolio", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - - const { result } = renderHook(() => usePortfolio("account-1"), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toMatchObject({ positions: [] }); - }); - - it("keeps the current connection snapshot after a background refetch error", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - let calls = 0; - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { - calls += 1; - - return calls === 1 - ? HttpResponse.json( - resource({ - cashBalances: [], - isStale: false, - lastSuccessfulAt: "2026-08-14T00:00:00Z", - positions: [], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [], - }), - ) - : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); - }), - ); - - const { result } = renderHook(() => usePortfolio("account-1"), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - void result.current.refetch(); - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.data).toMatchObject({ - lastSuccessfulAt: "2026-08-14T00:00:00Z", - }); - }); -}); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx index 32492fd..6d7d1b6 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx @@ -292,31 +292,47 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(operationsQueryKey)).toBeUndefined(); }); - it("preserves status and financial cache when a connection update fails", async () => { - server.use( - http.put("*/api/v1/tbank-connection", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); + it.each([ + ["INVALID_TBANK_TOKEN", "Проверьте token и его права."], + ["TBANK_UNAVAILABLE", "T-Bank временно недоступен. Повторите попытку позже."], + [undefined, "Не удалось выполнить запрос. Попробуйте ещё раз."], + ])("preserves the draft and cache after %s, then retries successfully", async (code, message) => { + const { promise: response, resolve: finishUpdate } + = Promise.withResolvers(); + + const update = vi.fn() + .mockImplementationOnce(() => + HttpResponse.json({ code }, { status: 503 }), + ) + .mockImplementationOnce(() => response); + + server.use(http.put("*/api/v1/tbank-connection", update)); const client = seededClient(); renderSection(client); await screen.findByText("Не подключено"); - fireEvent.change(screen.getByLabelText("Token T-Bank"), { - target: { value: "replacement-token" }, - }); + const token = screen.getByLabelText("Token T-Bank"); + fireEvent.change(token, { target: { value: "replacement-token" } }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); - await screen.findByText( - "T-Bank временно недоступен. Повторите попытку позже.", - ); - expect( - screen.getByRole("button", { name: "Подключить" }), - ).toBeInTheDocument(); + await screen.findByText(message); + expect(token).toHaveValue("replacement-token"); + expect(screen.getByText("Не подключено")).toBeVisible(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined(); expect(client.getQueryData(portfolioQueryKey)).toEqual({ source: "previous-connection", }); + fireEvent.click(screen.getByRole("button", { name: "Подключить" })); + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)); + expect(screen.queryByText(message)).not.toBeInTheDocument(); + expect(token).toBeDisabled(); + expect(token).toHaveValue("replacement-token"); + finishUpdate(HttpResponse.json({ + connected: true, connectedAt: "2026-08-14T00:00:00Z", + })); + expect(await screen.findByText("Подключено с 14.08.2026")).toBeVisible(); + expect(token).toHaveValue(""); + expect(screen.queryByText(message)).not.toBeInTheDocument(); }); it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => { diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx index 9749021..186882f 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx @@ -41,7 +41,7 @@ const TbankConnectionSectionLoaded: FC = ( t, unavailableMessage, update, - } = useTbankConnectionSectionLoadedVm(props); + } = useTbankConnectionSectionLoadedVm(props.status); return ( @@ -92,7 +92,7 @@ const TbankConnectionSectionLoaded: FC = ( error={errors.token?.message} /> - {requestError && ( + {update.isError && ( {t( isApiErrorCode(requestError, ApiErrorCode.InvalidTbankToken) diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts index 89f3973..5ec16f6 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts @@ -14,26 +14,12 @@ import { commitTbankConnectionBoundary, type TbankConnectionStatus, } from "../../model/connection-boundary"; -enum ConnectionErrorCode { - RequestFailed = "REQUEST_ERROR", -} -type TbankConnectionSectionLoadedProps = { - refreshError: boolean; - refreshing: boolean; - retry: () => void; - status: TbankConnectionStatus; -}; - -function useTbankConnectionSectionLoadedVm( - props: TbankConnectionSectionLoadedProps, -) { - const { status } = props; +function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [isDeleteOpened, setIsDeleteOpened] = useState(false); const removalButtonRef = useRef(null); - const [requestError, setRequestError] = useState(null); const { formState: { errors }, @@ -43,8 +29,6 @@ function useTbankConnectionSectionLoadedVm( } = useForm<{ token: string }>(); const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { - onError: error => - setRequestError(apiErrorCode(error) ?? ConnectionErrorCode.RequestFailed), onSuccess: (data) => { commitTbankConnectionBoundary(queryClient, data); reset(); @@ -66,6 +50,8 @@ function useTbankConnectionSectionLoadedVm( }, ); + const requestError = apiErrorCode(update.error); + const unavailableMessage = isApiErrorCode( requestError, ApiErrorCode.TbankUnavailable, @@ -80,7 +66,6 @@ function useTbankConnectionSectionLoadedVm( }, [isDeleteOpened, remove.isError]); const onSubmit = handleSubmit(({ token }) => { - setRequestError(null); update.mutate({ body: { token } }); }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index f22b3ca..0f9ce5a 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -210,23 +210,9 @@ describe("useAccountPageViewModel", () => { expect(snapshot).toEqual(original); }); - it("prepares grouped position sections with derived display data", async () => { - configureTestAuth(); - mockLoadedAccountPage(); - - const { result } = renderHook(() => useAccountPageViewModel(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.state).toBe("loaded")); - - const loaded = result.current as Extract< - typeof result.current, - { state: "loaded" } - >; - + it("prepares grouped position sections with derived display data", () => { expect( - loaded.positionSections.map(({ groups, kind }) => ({ + toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ kind, positions: groups.flatMap(group => group.positions), })), diff --git a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx index 2f08143..24c659f 100644 --- a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx +++ b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { CurrencyCards } from "./CurrencyCards.view"; describe("CurrencyCards currency presentation", () => { - it.each([0, 1, 3, 5])( + it.each([0, 5])( "renders only the %i supplied totals in upstream order", (count) => { const totals = [ @@ -74,20 +74,4 @@ describe("CurrencyCards currency presentation", () => { ); }, ); - - it("keeps USD and CAD as distinct supplied values", () => { - render( - - - , - ); - expect( - screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1,00 $", "2,00 CAD"]); - }); }); diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx index 98767c2..3cd4d51 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx @@ -1,9 +1,9 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -12,6 +12,8 @@ import { server } from "@/test/server"; import { BondsPage } from "./BondsPage"; +afterEach(() => vi.useRealTimers()); + describe("bond search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { const query = vi.fn(); @@ -46,6 +48,7 @@ describe("bond search", () => { clear: vi.fn(), refresh: () => Promise.resolve(false), }); + vi.useFakeTimers(); render( @@ -55,10 +58,16 @@ describe("bond search", () => { , ); + const input = screen.getByLabelText("Поиск облигаций"); + fireEvent.change(input, { target: { value: "S" } }); + await act(() => vi.advanceTimersByTimeAsync(300)); expect(query).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText("Поиск облигаций"), { - target: { value: "SU26238RMFS4" }, - }); + fireEvent.change(input, { target: { value: "SB" } }); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(query).not.toHaveBeenCalled(); + // The final input uses real timers for network and query notifications. + vi.useRealTimers(); + fireEvent.change(input, { target: { value: "SU26238RMFS4" } }); expect(await screen.findByRole("link", { name: /ОФЗ/ })).toHaveAttribute( "href", "/bonds/SU26238RMFS4", @@ -67,6 +76,6 @@ describe("bond search", () => { expect(screen.getByText("Режим: TQOB")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("Мосбиржа")).toBeVisible(); - await waitFor(() => expect(query).toHaveBeenCalledWith("SU26238RMFS4")); + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SU26238RMFS4")); }); }); diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx index 522b16b..1b6ae25 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx @@ -1,9 +1,9 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -12,6 +12,8 @@ import { server } from "@/test/server"; import { StocksPage } from "./StocksPage"; +afterEach(() => vi.useRealTimers()); + describe("stock search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { const query = vi.fn(); @@ -44,6 +46,7 @@ describe("stock search", () => { clear: vi.fn(), refresh: () => Promise.resolve(false), }); + vi.useFakeTimers(); render( @@ -53,10 +56,16 @@ describe("stock search", () => { , ); + const input = screen.getByLabelText("Поиск акций"); + fireEvent.change(input, { target: { value: "S" } }); + await act(() => vi.advanceTimersByTimeAsync(300)); expect(query).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText("Поиск акций"), { - target: { value: "SBER" }, - }); + fireEvent.change(input, { target: { value: "SB" } }); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(query).not.toHaveBeenCalled(); + // The final input uses real timers for network and query notifications. + vi.useRealTimers(); + fireEvent.change(input, { target: { value: "SBER" } }); expect( await screen.findByRole("link", { name: /Сбербанк/ }), ).toHaveAttribute("href", "/stocks/SBER"); @@ -64,6 +73,6 @@ describe("stock search", () => { expect(screen.getByText("Режим: TQBR")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("Мосбиржа")).toBeVisible(); - await waitFor(() => expect(query).toHaveBeenCalledWith("SBER")); + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); }); });