diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json index 08b2014..624f95f 100644 --- a/scripts/lazy-route-bundle-baseline.json +++ b/scripts/lazy-route-bundle-baseline.json @@ -1,11 +1,11 @@ { - "src/app/routes/ProtectedLayout.tsx": 3137, - "src/pages/account-events/index.ts": 4468, - "src/pages/account-operations/index.ts": 13861, - "src/pages/account/index.ts": 22366, - "src/pages/accounts/index.ts": 6174, - "src/pages/login/index.ts": 669, - "src/pages/not-found/index.ts": 850, - "src/pages/register/index.ts": 675, - "src/pages/settings/index.ts": 38876 + "src/app/routes/ProtectedLayout.tsx": 3077, + "src/pages/account-events/index.ts": 4441, + "src/pages/account-operations/index.ts": 13862, + "src/pages/account/index.ts": 22402, + "src/pages/accounts/index.ts": 6179, + "src/pages/login/index.ts": 667, + "src/pages/not-found/index.ts": 882, + "src/pages/register/index.ts": 673, + "src/pages/settings/index.ts": 39008 } diff --git a/src/app/ui/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs.test.tsx index efad977..e0caede 100644 --- a/src/app/ui/AppBreadcrumbs.test.tsx +++ b/src/app/ui/AppBreadcrumbs.test.tsx @@ -8,9 +8,6 @@ const { useBrokerageAccounts } = vi.hoisted(() => ({ })); vi.mock("@/entities/brokerage-account", () => ({ useBrokerageAccounts })); -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "token" }), -})); function renderBreadcrumbs( path: string, diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx index d78cdbe..3b5e18d 100644 --- a/src/app/ui/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; import { Link, useMatch } from "react-router"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; -import { useAuth } from "@/features/auth"; type Crumb = { label: string; to?: string }; @@ -45,8 +44,7 @@ function AccountBreadcrumbs({ events: boolean; operations: boolean; }) { - const { accessToken } = useAuth(); - const { data: accounts } = useBrokerageAccounts(accessToken); + const { data: accounts } = useBrokerageAccounts(); const { t } = useTranslation(); const accountName = accounts?.find((account) => account.id === accountId)?.name ?? diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx index 6761e89..b9bd722 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx +++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx @@ -2,14 +2,22 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; import { brokerageOperationsQueryParams, useBrokerageOperations, } from "./useBrokerageOperations"; describe("useBrokerageOperations", () => { + afterEach(() => configureAuthClient(null)); + it("normalizes query parameters and loads a page without retries", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); expect( brokerageOperationsQueryParams({ categories: ["tax", "income"], @@ -24,7 +32,7 @@ describe("useBrokerageOperations", () => { ); const { result } = renderHook( - () => useBrokerageOperations("account-1", "token", { limit: 5 }), + () => useBrokerageOperations("account-1", { limit: 5 }), { wrapper }, ); diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 04f3ead..15fb9e1 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -1,5 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiQueryClient } from "@/shared/api"; +import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; type OperationsEndpoint = @@ -37,17 +36,16 @@ export function brokerageOperationsQueryParams( export function useBrokerageOperations( accountId: string | undefined, - accessToken: string | null, query: BrokerageOperationsQuery = {}, ) { const params = brokerageOperationsQueryParams(query); - return useQuery({ + return useAuthenticatedQuery({ ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/operations", { params: { path: { accountId: accountId ?? "" }, query: params } }, ), - enabled: Boolean(accountId && accessToken), + enabled: Boolean(accountId), staleTime: 30_000, retry: false, }); diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx new file mode 100644 index 0000000..9464154 --- /dev/null +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx @@ -0,0 +1,56 @@ +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 { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { server } from "@/test/server"; +import { useBrokerageAccounts } from "./useBrokerageAccounts"; + +function createWrapper() { + const client = new QueryClient(); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe("useBrokerageAccounts", () => { + afterEach(() => configureAuthClient(null)); + + it("loads accounts through the authenticated query seam", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]), + ), + ); + + const { result } = renderHook(() => useBrokerageAccounts(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]); + }); +}); diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 118c4a1..087830f 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -1,5 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiQueryClient } from "@/shared/api"; +import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; const brokerageAccountsQueryOptions = () => @@ -11,10 +10,9 @@ export const brokerageAccountsQueryKey = export type BrokerageAccount = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number]; -export function useBrokerageAccounts(accessToken: string | null) { - return useQuery({ +export function useBrokerageAccounts() { + return useAuthenticatedQuery({ ...brokerageAccountsQueryOptions(), - enabled: Boolean(accessToken), staleTime: 60_000, retry: false, }); diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx new file mode 100644 index 0000000..f723ebb --- /dev/null +++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx @@ -0,0 +1,68 @@ +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 { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { server } from "@/test/server"; +import { useBrokerageEvents } from "./useBrokerageEvents"; + +function createWrapper() { + const client = new QueryClient(); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe("useBrokerageEvents", () => { + afterEach(() => configureAuthClient(null)); + + it("loads future cash events through the authenticated query seam", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/events", () => + HttpResponse.json({ + accountId: "account-1", + calculatedAt: "2026-08-14T00:00:00Z", + events: [ + { + type: "dividend", + date: "2026-09-01", + dateKind: "declared", + instrumentId: "figi-1", + ticker: "SBER", + displayName: "Сбер", + amount: "12.34", + currency: "RUB", + }, + ], + }), + ), + ); + + const { result } = renderHook(() => useBrokerageEvents("account-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.events).toEqual([ + { + type: "dividend", + date: "2026-09-01", + dateKind: "declared", + instrumentId: "figi-1", + ticker: "SBER", + displayName: "Сбер", + amount: "12.34", + currency: "RUB", + }, + ]); + }); +}); diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts index 4d214b3..1faac89 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.ts +++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts @@ -1,5 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiQueryClient } from "@/shared/api"; +import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; type EventsEndpoint = @@ -7,17 +6,14 @@ type EventsEndpoint = export type BrokerageEvent = EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number]; -export function useBrokerageEvents( - accountId: string | undefined, - accessToken: string | null, -) { - return useQuery({ +export function useBrokerageEvents(accountId: string | undefined) { + return useAuthenticatedQuery({ ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/events", { params: { path: { accountId: accountId ?? "" } } }, ), - enabled: Boolean(accountId && accessToken), + enabled: Boolean(accountId), staleTime: 30_000, retry: false, }); diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index 6e4cd70..2be963a 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -2,12 +2,20 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; 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 = ({ children }: { children: ReactNode }) => ( @@ -15,7 +23,7 @@ describe("usePortfolio", () => { ); - const { result } = renderHook(() => usePortfolio("account-1", "token"), { + const { result } = renderHook(() => usePortfolio("account-1"), { wrapper, }); diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts index d883346..5e58534 100644 --- a/src/entities/portfolio/api/usePortfolio.ts +++ b/src/entities/portfolio/api/usePortfolio.ts @@ -1,5 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiQueryClient } from "@/shared/api"; +import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; const portfolioQueryPolicy = { @@ -10,17 +9,14 @@ const portfolioQueryPolicy = { export type Portfolio = paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]; -export function usePortfolio( - accountId: string | undefined, - accessToken: string | null, -) { - return useQuery({ +export function usePortfolio(accountId: string | undefined) { + return useAuthenticatedQuery({ ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: accountId ?? "" } } }, ), - enabled: Boolean(accountId && accessToken), + enabled: Boolean(accountId), ...portfolioQueryPolicy, }); } diff --git a/src/pages/account-events/ui/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage.test.tsx index efe7532..5147f28 100644 --- a/src/pages/account-events/ui/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.test.tsx @@ -8,9 +8,6 @@ import { AccountEventsPage } from "./AccountEventsPage"; vi.mock("@/entities/brokerage-event", () => ({ useBrokerageEvents: vi.fn(), })); -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "token" }), -})); const query = vi.mocked(useBrokerageEvents); diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx index 7c8e21b..3b6b5c6 100644 --- a/src/pages/account-events/ui/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.tsx @@ -6,13 +6,11 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; import { formatMoney } from "@/entities/portfolio"; -import { useAuth } from "@/features/auth"; import { StateView } from "@/shared/ui"; export function AccountEventsPage() { const { accountId } = useParams(); - const { accessToken } = useAuth(); - const events = useBrokerageEvents(accountId, accessToken); + const events = useBrokerageEvents(accountId); if (events.isLoading) return ; if (events.isError) return events.refetch()} />; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx index a8a3074..238c579 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx @@ -3,15 +3,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { BrokerageOperation } from "@/entities/broker-operation"; +import { configureAuthClient } from "@/shared/api"; import { server } from "@/test/server"; import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage"; -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "token" }), -})); - const operation: BrokerageOperation = { id: "operation-1", occurredAt: "2026-08-14T10:00:00Z", @@ -29,6 +26,14 @@ const operation: BrokerageOperation = { commission: { currency: "RUB", value: "1" }, }; +function configureTestAuth() { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); +} + describe("OperationRow", () => { it("marks cashless operations without rendering operation details", () => { render( @@ -49,7 +54,10 @@ describe("OperationRow", () => { }); describe("AccountOperationsPage", () => { + afterEach(() => configureAuthClient(null)); + it("resets cursor pagination when filters change", async () => { + configureTestAuth(); const searches: string[] = []; server.use( http.get( @@ -96,6 +104,7 @@ describe("AccountOperationsPage", () => { }); it("renders an error state", async () => { + configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx index dd8603a..0216199 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx @@ -18,7 +18,6 @@ import { useBrokerageOperations, } from "@/entities/broker-operation"; import { formatMoney, formatSnapshotTime } from "@/entities/portfolio"; -import { useAuth } from "@/features/auth"; import { StateView } from "@/shared/ui"; function dateStart(value: string) { @@ -31,14 +30,13 @@ function dateEnd(value: string) { export function AccountOperationsPage() { const { accountId } = useParams(); - const { accessToken } = useAuth(); const { t } = useTranslation(); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [categories, setCategories] = useState([]); const [cursors, setCursors] = useState([]); const cursor = cursors.at(-1); - const operations = useBrokerageOperations(accountId, accessToken, { + const operations = useBrokerageOperations(accountId, { from: dateStart(from), to: dateEnd(to), ...(categories.length ? { categories } : {}), diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 1e23361..e87b69c 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -5,6 +5,7 @@ import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; +import { configureAuthClient } from "@/shared/api"; import { server } from "@/test/server"; import { AccountPage, @@ -19,10 +20,6 @@ import type { } from "./AccountPage.vm"; import { toAccountPagePositionSections } from "./AccountPage.vm"; -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "test-token" }), -})); - function renderPage(ui: React.ReactNode) { return render( @@ -33,7 +30,16 @@ function renderPage(ui: React.ReactNode) { ); } +function configureTestAuth() { + configureAuthClient({ + accessToken: () => "test-token", + refresh: async () => true, + clear: vi.fn(), + }); +} + function renderAccountRoute(queryClient: QueryClient) { + configureTestAuth(); return render( @@ -105,7 +111,10 @@ function emptyProps() { } describe("AccountPage states", () => { - afterEach(cleanup); + afterEach(() => { + configureAuthClient(null); + cleanup(); + }); it("renders loading and empty states", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx index 9693c76..919dd16 100644 --- a/src/pages/account/ui/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage.vm.test.tsx @@ -4,15 +4,12 @@ import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import type { ReactNode } from "react"; import { MemoryRouter, Route, Routes } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; +import { configureAuthClient } from "@/shared/api"; import { server } from "@/test/server"; import { useAccountPageViewModel } from "./AccountPage.vm"; -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "test-token" }), -})); - const portfolio: Portfolio = { lastSuccessfulAt: "2026-08-13T10:00:00Z", isStale: true, @@ -55,6 +52,14 @@ const portfolio: Portfolio = { cashBalances: [{ currency: "USD", value: "1" }], }; +function configureTestAuth() { + configureAuthClient({ + accessToken: () => "test-token", + refresh: async () => true, + clear: vi.fn(), + }); +} + function wrapper({ children }: { children: ReactNode }) { return ( @@ -121,7 +126,10 @@ function mockLoadedAccountPage() { } describe("useAccountPageViewModel", () => { + afterEach(() => configureAuthClient(null)); + it("prepares grouped position sections with derived display data", async () => { + configureTestAuth(); mockLoadedAccountPage(); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); @@ -175,6 +183,7 @@ describe("useAccountPageViewModel", () => { }); it("prepares recent operations and events state for presentation views", async () => { + configureTestAuth(); mockLoadedAccountPage(); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); @@ -207,6 +216,7 @@ describe("useAccountPageViewModel", () => { }); it("keeps recent errors isolated in the ViewModel presentation state", async () => { + configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(portfolio), diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index fff597d..313dd15 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -18,7 +18,6 @@ import { portfolioErrorCode, usePortfolio, } from "@/entities/portfolio"; -import { useAuth } from "@/features/auth"; type PositionSectionKind = "stocks" | "bonds" | "other"; type AccountPageReturnTone = "positive" | "negative"; @@ -86,12 +85,11 @@ export type AccountPageViewModel = export function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); - const { accessToken } = useAuth(); - const portfolio = usePortfolio(accountId, accessToken); - const operations = useBrokerageOperations(accountId, accessToken, { + const portfolio = usePortfolio(accountId); + const operations = useBrokerageOperations(accountId, { limit: 5, }); - const events = useBrokerageEvents(accountId, accessToken); + const events = useBrokerageEvents(accountId); const account = useQueryClient() .getQueryData(brokerageAccountsQueryKey) ?.find((item) => item.id === accountId); diff --git a/src/pages/accounts/ui/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage.test.tsx index 40644f3..a76525d 100644 --- a/src/pages/accounts/ui/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage.test.tsx @@ -5,13 +5,10 @@ import { HttpResponse, http } from "msw"; import { MemoryRouter } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import { brokerageAccountsQueryKey } from "@/entities/brokerage-account"; +import { configureAuthClient } from "@/shared/api"; import { server } from "@/test/server"; import { AccountsPage } from "./AccountsPage"; -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "test-token" }), -})); - const account = { id: "account-1", name: "Основной", @@ -19,7 +16,16 @@ const account = { openedOn: "2026-01-01", } as const; +function configureTestAuth() { + configureAuthClient({ + accessToken: () => "test-token", + refresh: async () => true, + clear: vi.fn(), + }); +} + function renderPage(queryClient: QueryClient) { + configureTestAuth(); return render( @@ -32,7 +38,10 @@ function renderPage(queryClient: QueryClient) { } describe("AccountsPage", () => { - afterEach(cleanup); + afterEach(() => { + configureAuthClient(null); + cleanup(); + }); it("keeps the cached account list visible during a background refresh", async () => { let requestCount = 0; diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts index d1ac50c..71fb5f0 100644 --- a/src/pages/accounts/ui/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage.vm.ts @@ -1,9 +1,7 @@ import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account"; -import { useAuth } from "@/features/auth"; export function useAccountsPageViewModel() { - const { accessToken } = useAuth(); - const accounts = useBrokerageAccounts(accessToken); + const accounts = useBrokerageAccounts(); if (!accounts.data) { if (accounts.isFetching) return { state: "loading" as const }; if (accounts.isError) diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx new file mode 100644 index 0000000..ed2a477 --- /dev/null +++ b/src/shared/api/authenticated-query.test.tsx @@ -0,0 +1,68 @@ +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 { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { server } from "@/test/server"; +import { useAuthenticatedQuery } from "./authenticated-query"; +import { apiQueryClient, configureAuthClient } from "./client"; + +function createWrapper() { + const client = new QueryClient(); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe("useAuthenticatedQuery", () => { + afterEach(() => configureAuthClient(null)); + + it("keeps protected queries idle without a session and loads after session configuration", async () => { + const requests = vi.fn(); + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests(); + return HttpResponse.json([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]); + }), + ); + + const { result } = renderHook( + () => + useAuthenticatedQuery( + apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), + ), + { wrapper: createWrapper() }, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(requests).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe("idle"); + + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(requests).toHaveBeenCalledOnce(); + expect(result.current.data).toEqual([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]); + }); +}); diff --git a/src/shared/api/authenticated-query.ts b/src/shared/api/authenticated-query.ts new file mode 100644 index 0000000..ce63bf3 --- /dev/null +++ b/src/shared/api/authenticated-query.ts @@ -0,0 +1,34 @@ +import { + type QueryKey, + type UseQueryOptions, + type UseQueryResult, + useQuery, +} from "@tanstack/react-query"; +import { useSyncExternalStore } from "react"; +import { + getAuthenticatedSessionSnapshot, + subscribeAuthenticatedSession, +} from "./client"; + +export function useAuthenticatedQuery< + TQueryFnData = unknown, + TError = Error, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UseQueryOptions, +): UseQueryResult { + const hasSession = useSyncExternalStore( + subscribeAuthenticatedSession, + getAuthenticatedSessionSnapshot, + getAuthenticatedSessionSnapshot, + ); + const enabled = options.enabled; + + return useQuery({ + ...options, + enabled: (query) => + hasSession && + (typeof enabled === "function" ? enabled(query) : (enabled ?? true)), + }); +} diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 88df76e..4556aff 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -12,9 +12,27 @@ type AuthHandlers = { }; let auth: AuthHandlers | null = null; +let hasAuthenticatedSession = false; +const authSessionListeners = new Set<() => void>(); + +function setAuthenticatedSession(next: boolean) { + if (next === hasAuthenticatedSession) return; + hasAuthenticatedSession = next; + for (const listener of authSessionListeners) listener(); +} export function configureAuthClient(handlers: AuthHandlers | null) { auth = handlers; + setAuthenticatedSession(Boolean(auth?.accessToken())); +} + +export function getAuthenticatedSessionSnapshot() { + return hasAuthenticatedSession; +} + +export function subscribeAuthenticatedSession(listener: () => void) { + authSessionListeners.add(listener); + return () => authSessionListeners.delete(listener); } async function authenticatedFetch(request: Request): Promise { diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 0a90b21..32ad258 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1 +1,2 @@ +export { useAuthenticatedQuery } from "./authenticated-query"; export { apiClient, apiQueryClient, configureAuthClient } from "./client";