refactor: centralize frontend auth query gating
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"src/app/routes/ProtectedLayout.tsx": 3137,
|
"src/app/routes/ProtectedLayout.tsx": 3077,
|
||||||
"src/pages/account-events/index.ts": 4468,
|
"src/pages/account-events/index.ts": 4441,
|
||||||
"src/pages/account-operations/index.ts": 13861,
|
"src/pages/account-operations/index.ts": 13862,
|
||||||
"src/pages/account/index.ts": 22366,
|
"src/pages/account/index.ts": 22402,
|
||||||
"src/pages/accounts/index.ts": 6174,
|
"src/pages/accounts/index.ts": 6179,
|
||||||
"src/pages/login/index.ts": 669,
|
"src/pages/login/index.ts": 667,
|
||||||
"src/pages/not-found/index.ts": 850,
|
"src/pages/not-found/index.ts": 882,
|
||||||
"src/pages/register/index.ts": 675,
|
"src/pages/register/index.ts": 673,
|
||||||
"src/pages/settings/index.ts": 38876
|
"src/pages/settings/index.ts": 39008
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ const { useBrokerageAccounts } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/entities/brokerage-account", () => ({ useBrokerageAccounts }));
|
vi.mock("@/entities/brokerage-account", () => ({ useBrokerageAccounts }));
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderBreadcrumbs(
|
function renderBreadcrumbs(
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useMatch } from "react-router";
|
import { Link, useMatch } from "react-router";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
|
|
||||||
type Crumb = { label: string; to?: string };
|
type Crumb = { label: string; to?: string };
|
||||||
|
|
||||||
@@ -45,8 +44,7 @@ function AccountBreadcrumbs({
|
|||||||
events: boolean;
|
events: boolean;
|
||||||
operations: boolean;
|
operations: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { accessToken } = useAuth();
|
const { data: accounts } = useBrokerageAccounts();
|
||||||
const { data: accounts } = useBrokerageAccounts(accessToken);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const accountName =
|
const accountName =
|
||||||
accounts?.find((account) => account.id === accountId)?.name ??
|
accounts?.find((account) => account.id === accountId)?.name ??
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "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 {
|
import {
|
||||||
brokerageOperationsQueryParams,
|
brokerageOperationsQueryParams,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "./useBrokerageOperations";
|
} from "./useBrokerageOperations";
|
||||||
|
|
||||||
describe("useBrokerageOperations", () => {
|
describe("useBrokerageOperations", () => {
|
||||||
|
afterEach(() => configureAuthClient(null));
|
||||||
|
|
||||||
it("normalizes query parameters and loads a page without retries", async () => {
|
it("normalizes query parameters and loads a page without retries", async () => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
brokerageOperationsQueryParams({
|
brokerageOperationsQueryParams({
|
||||||
categories: ["tax", "income"],
|
categories: ["tax", "income"],
|
||||||
@@ -24,7 +32,7 @@ describe("useBrokerageOperations", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBrokerageOperations("account-1", "token", { limit: 5 }),
|
() => useBrokerageOperations("account-1", { limit: 5 }),
|
||||||
{ wrapper },
|
{ wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
import type { paths } from "@/shared/api/generated/schema";
|
||||||
|
|
||||||
type OperationsEndpoint =
|
type OperationsEndpoint =
|
||||||
@@ -37,17 +36,16 @@ export function brokerageOperationsQueryParams(
|
|||||||
|
|
||||||
export function useBrokerageOperations(
|
export function useBrokerageOperations(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
accessToken: string | null,
|
|
||||||
query: BrokerageOperationsQuery = {},
|
query: BrokerageOperationsQuery = {},
|
||||||
) {
|
) {
|
||||||
const params = brokerageOperationsQueryParams(query);
|
const params = brokerageOperationsQueryParams(query);
|
||||||
return useQuery({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts/{accountId}/operations",
|
"/api/v1/brokerage-accounts/{accountId}/operations",
|
||||||
{ params: { path: { accountId: accountId ?? "" }, query: params } },
|
{ params: { path: { accountId: accountId ?? "" }, query: params } },
|
||||||
),
|
),
|
||||||
enabled: Boolean(accountId && accessToken),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
import type { paths } from "@/shared/api/generated/schema";
|
||||||
|
|
||||||
const brokerageAccountsQueryOptions = () =>
|
const brokerageAccountsQueryOptions = () =>
|
||||||
@@ -11,10 +10,9 @@ export const brokerageAccountsQueryKey =
|
|||||||
export type BrokerageAccount =
|
export type BrokerageAccount =
|
||||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
|
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
|
||||||
|
|
||||||
export function useBrokerageAccounts(accessToken: string | null) {
|
export function useBrokerageAccounts() {
|
||||||
return useQuery({
|
return useAuthenticatedQuery({
|
||||||
...brokerageAccountsQueryOptions(),
|
...brokerageAccountsQueryOptions(),
|
||||||
enabled: Boolean(accessToken),
|
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
import type { paths } from "@/shared/api/generated/schema";
|
||||||
|
|
||||||
type EventsEndpoint =
|
type EventsEndpoint =
|
||||||
@@ -7,17 +6,14 @@ type EventsEndpoint =
|
|||||||
export type BrokerageEvent =
|
export type BrokerageEvent =
|
||||||
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
|
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
|
||||||
|
|
||||||
export function useBrokerageEvents(
|
export function useBrokerageEvents(accountId: string | undefined) {
|
||||||
accountId: string | undefined,
|
return useAuthenticatedQuery({
|
||||||
accessToken: string | null,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts/{accountId}/events",
|
"/api/v1/brokerage-accounts/{accountId}/events",
|
||||||
{ params: { path: { accountId: accountId ?? "" } } },
|
{ params: { path: { accountId: accountId ?? "" } } },
|
||||||
),
|
),
|
||||||
enabled: Boolean(accountId && accessToken),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "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";
|
import { usePortfolio } from "./usePortfolio";
|
||||||
|
|
||||||
describe("usePortfolio", () => {
|
describe("usePortfolio", () => {
|
||||||
|
afterEach(() => configureAuthClient(null));
|
||||||
|
|
||||||
it("uses the account endpoint with a 30 second cache and no retry", async () => {
|
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 client = new QueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
@@ -15,7 +23,7 @@ describe("usePortfolio", () => {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => usePortfolio("account-1", "token"), {
|
const { result } = renderHook(() => usePortfolio("account-1"), {
|
||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
import type { paths } from "@/shared/api/generated/schema";
|
||||||
|
|
||||||
const portfolioQueryPolicy = {
|
const portfolioQueryPolicy = {
|
||||||
@@ -10,17 +9,14 @@ const portfolioQueryPolicy = {
|
|||||||
export type Portfolio =
|
export type Portfolio =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
|
||||||
export function usePortfolio(
|
export function usePortfolio(accountId: string | undefined) {
|
||||||
accountId: string | undefined,
|
return useAuthenticatedQuery({
|
||||||
accessToken: string | null,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||||
{ params: { path: { accountId: accountId ?? "" } } },
|
{ params: { path: { accountId: accountId ?? "" } } },
|
||||||
),
|
),
|
||||||
enabled: Boolean(accountId && accessToken),
|
enabled: Boolean(accountId),
|
||||||
...portfolioQueryPolicy,
|
...portfolioQueryPolicy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { AccountEventsPage } from "./AccountEventsPage";
|
|||||||
vi.mock("@/entities/brokerage-event", () => ({
|
vi.mock("@/entities/brokerage-event", () => ({
|
||||||
useBrokerageEvents: vi.fn(),
|
useBrokerageEvents: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const query = vi.mocked(useBrokerageEvents);
|
const query = vi.mocked(useBrokerageEvents);
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
|
||||||
export function AccountEventsPage() {
|
export function AccountEventsPage() {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { accessToken } = useAuth();
|
const events = useBrokerageEvents(accountId);
|
||||||
const events = useBrokerageEvents(accountId, accessToken);
|
|
||||||
if (events.isLoading) return <AccountEventsPageLoading />;
|
if (events.isLoading) return <AccountEventsPageLoading />;
|
||||||
if (events.isError)
|
if (events.isError)
|
||||||
return <AccountEventsPageError retry={() => events.refetch()} />;
|
return <AccountEventsPageError retry={() => events.refetch()} />;
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
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 type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
|
import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const operation: BrokerageOperation = {
|
const operation: BrokerageOperation = {
|
||||||
id: "operation-1",
|
id: "operation-1",
|
||||||
occurredAt: "2026-08-14T10:00:00Z",
|
occurredAt: "2026-08-14T10:00:00Z",
|
||||||
@@ -29,6 +26,14 @@ const operation: BrokerageOperation = {
|
|||||||
commission: { currency: "RUB", value: "1" },
|
commission: { currency: "RUB", value: "1" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function configureTestAuth() {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("OperationRow", () => {
|
describe("OperationRow", () => {
|
||||||
it("marks cashless operations without rendering operation details", () => {
|
it("marks cashless operations without rendering operation details", () => {
|
||||||
render(
|
render(
|
||||||
@@ -49,7 +54,10 @@ describe("OperationRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AccountOperationsPage", () => {
|
describe("AccountOperationsPage", () => {
|
||||||
|
afterEach(() => configureAuthClient(null));
|
||||||
|
|
||||||
it("resets cursor pagination when filters change", async () => {
|
it("resets cursor pagination when filters change", async () => {
|
||||||
|
configureTestAuth();
|
||||||
const searches: string[] = [];
|
const searches: string[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
http.get(
|
http.get(
|
||||||
@@ -96,6 +104,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders an error state", async () => {
|
it("renders an error state", async () => {
|
||||||
|
configureTestAuth();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { formatMoney, formatSnapshotTime } from "@/entities/portfolio";
|
import { formatMoney, formatSnapshotTime } from "@/entities/portfolio";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
|
||||||
function dateStart(value: string) {
|
function dateStart(value: string) {
|
||||||
@@ -31,14 +30,13 @@ function dateEnd(value: string) {
|
|||||||
|
|
||||||
export function AccountOperationsPage() {
|
export function AccountOperationsPage() {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { accessToken } = useAuth();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [from, setFrom] = useState("");
|
const [from, setFrom] = useState("");
|
||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState("");
|
||||||
const [categories, setCategories] = useState<OperationCategory[]>([]);
|
const [categories, setCategories] = useState<OperationCategory[]>([]);
|
||||||
const [cursors, setCursors] = useState<string[]>([]);
|
const [cursors, setCursors] = useState<string[]>([]);
|
||||||
const cursor = cursors.at(-1);
|
const cursor = cursors.at(-1);
|
||||||
const operations = useBrokerageOperations(accountId, accessToken, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
from: dateStart(from),
|
from: dateStart(from),
|
||||||
to: dateEnd(to),
|
to: dateEnd(to),
|
||||||
...(categories.length ? { categories } : {}),
|
...(categories.length ? { categories } : {}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HttpResponse, http } from "msw";
|
|||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import {
|
import {
|
||||||
AccountPage,
|
AccountPage,
|
||||||
@@ -19,10 +20,6 @@ import type {
|
|||||||
} from "./AccountPage.vm";
|
} from "./AccountPage.vm";
|
||||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "test-token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderPage(ui: React.ReactNode) {
|
function renderPage(ui: React.ReactNode) {
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
@@ -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) {
|
function renderAccountRoute(queryClient: QueryClient) {
|
||||||
|
configureTestAuth();
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
@@ -105,7 +111,10 @@ function emptyProps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountPage states", () => {
|
describe("AccountPage states", () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
configureAuthClient(null);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
it("renders loading and empty states", () => {
|
it("renders loading and empty states", () => {
|
||||||
renderPage(<AccountPageLoading />);
|
renderPage(<AccountPageLoading />);
|
||||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import { renderHook, waitFor } from "@testing-library/react";
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
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 type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "test-token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const portfolio: Portfolio = {
|
const portfolio: Portfolio = {
|
||||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||||
isStale: true,
|
isStale: true,
|
||||||
@@ -55,6 +52,14 @@ const portfolio: Portfolio = {
|
|||||||
cashBalances: [{ currency: "USD", value: "1" }],
|
cashBalances: [{ currency: "USD", value: "1" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function configureTestAuth() {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "test-token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function wrapper({ children }: { children: ReactNode }) {
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
@@ -121,7 +126,10 @@ function mockLoadedAccountPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("useAccountPageViewModel", () => {
|
describe("useAccountPageViewModel", () => {
|
||||||
|
afterEach(() => configureAuthClient(null));
|
||||||
|
|
||||||
it("prepares grouped position sections with derived display data", async () => {
|
it("prepares grouped position sections with derived display data", async () => {
|
||||||
|
configureTestAuth();
|
||||||
mockLoadedAccountPage();
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||||
@@ -175,6 +183,7 @@ describe("useAccountPageViewModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prepares recent operations and events state for presentation views", async () => {
|
it("prepares recent operations and events state for presentation views", async () => {
|
||||||
|
configureTestAuth();
|
||||||
mockLoadedAccountPage();
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||||
@@ -207,6 +216,7 @@ describe("useAccountPageViewModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps recent errors isolated in the ViewModel presentation state", async () => {
|
it("keeps recent errors isolated in the ViewModel presentation state", async () => {
|
||||||
|
configureTestAuth();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json(portfolio),
|
HttpResponse.json(portfolio),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
portfolioErrorCode,
|
portfolioErrorCode,
|
||||||
usePortfolio,
|
usePortfolio,
|
||||||
} from "@/entities/portfolio";
|
} from "@/entities/portfolio";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
|
|
||||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||||
type AccountPageReturnTone = "positive" | "negative";
|
type AccountPageReturnTone = "positive" | "negative";
|
||||||
@@ -86,12 +85,11 @@ export type AccountPageViewModel =
|
|||||||
|
|
||||||
export function useAccountPageViewModel(): AccountPageViewModel {
|
export function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { accessToken } = useAuth();
|
const portfolio = usePortfolio(accountId);
|
||||||
const portfolio = usePortfolio(accountId, accessToken);
|
const operations = useBrokerageOperations(accountId, {
|
||||||
const operations = useBrokerageOperations(accountId, accessToken, {
|
|
||||||
limit: 5,
|
limit: 5,
|
||||||
});
|
});
|
||||||
const events = useBrokerageEvents(accountId, accessToken);
|
const events = useBrokerageEvents(accountId);
|
||||||
const account = useQueryClient()
|
const account = useQueryClient()
|
||||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
||||||
?.find((item) => item.id === accountId);
|
?.find((item) => item.id === accountId);
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ import { HttpResponse, http } from "msw";
|
|||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountsPage } from "./AccountsPage";
|
import { AccountsPage } from "./AccountsPage";
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "test-token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const account = {
|
const account = {
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
@@ -19,7 +16,16 @@ const account = {
|
|||||||
openedOn: "2026-01-01",
|
openedOn: "2026-01-01",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function configureTestAuth() {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "test-token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderPage(queryClient: QueryClient) {
|
function renderPage(queryClient: QueryClient) {
|
||||||
|
configureTestAuth();
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
@@ -32,7 +38,10 @@ function renderPage(queryClient: QueryClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountsPage", () => {
|
describe("AccountsPage", () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
configureAuthClient(null);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the cached account list visible during a background refresh", async () => {
|
it("keeps the cached account list visible during a background refresh", async () => {
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
|
|
||||||
export function useAccountsPageViewModel() {
|
export function useAccountsPageViewModel() {
|
||||||
const { accessToken } = useAuth();
|
const accounts = useBrokerageAccounts();
|
||||||
const accounts = useBrokerageAccounts(accessToken);
|
|
||||||
if (!accounts.data) {
|
if (!accounts.data) {
|
||||||
if (accounts.isFetching) return { state: "loading" as const };
|
if (accounts.isFetching) return { state: "loading" as const };
|
||||||
if (accounts.isError)
|
if (accounts.isError)
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TQueryFnData, TError, TData, TQueryKey>,
|
||||||
|
): UseQueryResult<TData, TError> {
|
||||||
|
const hasSession = useSyncExternalStore(
|
||||||
|
subscribeAuthenticatedSession,
|
||||||
|
getAuthenticatedSessionSnapshot,
|
||||||
|
getAuthenticatedSessionSnapshot,
|
||||||
|
);
|
||||||
|
const enabled = options.enabled;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
...options,
|
||||||
|
enabled: (query) =>
|
||||||
|
hasSession &&
|
||||||
|
(typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,9 +12,27 @@ type AuthHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let auth: AuthHandlers | null = null;
|
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) {
|
export function configureAuthClient(handlers: AuthHandlers | null) {
|
||||||
auth = handlers;
|
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<Response> {
|
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
|
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
|
||||||
|
|||||||
Reference in New Issue
Block a user