refactor: refactor foundation
This commit is contained in:
@@ -4,23 +4,12 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../shared/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../shared/api")>();
|
||||
return {
|
||||
...actual,
|
||||
apiClient: {
|
||||
...actual.apiClient,
|
||||
POST: vi.fn(async () => ({ data: undefined })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { AuthProvider } from "../../features/auth";
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
import {
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "../../pages/accounts";
|
||||
} from "@/pages/accounts";
|
||||
import { theme } from "../theme";
|
||||
import { AppRoutes } from "./AppRoutes";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router";
|
||||
import { useAuth } from "../../features/auth";
|
||||
import { AccountPage } from "../../pages/account";
|
||||
import { AccountsPage } from "../../pages/accounts";
|
||||
import { LoginPage } from "../../pages/login";
|
||||
import { NotFoundPage } from "../../pages/not-found";
|
||||
import { RegisterPage } from "../../pages/register";
|
||||
import { SettingsPage } from "../../pages/settings";
|
||||
import { AppShell } from "../../shared/ui/AppShell";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { AccountPage } from "@/pages/account";
|
||||
import { AccountsPage } from "@/pages/accounts";
|
||||
import { LoginPage } from "@/pages/login";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
import { RegisterPage } from "@/pages/register";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { AppShell } from "@/shared/ui";
|
||||
|
||||
function Protected({ children }: { children: React.ReactNode }) {
|
||||
const { user, ready } = useAuth();
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { AuthProvider } from "../../features/auth";
|
||||
import { AppRoutes } from "../routes";
|
||||
import { theme } from "../theme";
|
||||
import { AppRoutes } from "@/app/routes";
|
||||
import { theme } from "@/app/theme";
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../../../shared/api";
|
||||
import type { paths } from "../../../shared/api/generated/schema";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
|
||||
export const brokerageAccountsQueryKey = ["brokerage-accounts"] as const;
|
||||
|
||||
@@ -13,15 +13,6 @@ export function useBrokerageAccounts(accessToken: string | null) {
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.GET(
|
||||
"/api/v1/brokerage-accounts",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as BrokerageAccount[];
|
||||
},
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,26 +2,13 @@ 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, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
|
||||
vi.mock("../../../shared/api", () => ({ apiClient: { GET: get } }));
|
||||
|
||||
import { portfolioQueryPolicy, usePortfolio } from "./usePortfolio";
|
||||
import { usePortfolio } from "./usePortfolio";
|
||||
|
||||
describe("usePortfolio", () => {
|
||||
it("uses the account endpoint with a 30 second cache and no retry", async () => {
|
||||
const client = new QueryClient();
|
||||
get.mockResolvedValueOnce({
|
||||
data: {
|
||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
positions: [],
|
||||
cashBalances: [],
|
||||
},
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
@@ -33,13 +20,6 @@ describe("usePortfolio", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{
|
||||
params: { path: { accountId: "account-1" } },
|
||||
headers: { Authorization: "Bearer token" },
|
||||
},
|
||||
);
|
||||
expect(portfolioQueryPolicy).toEqual({ staleTime: 30_000, retry: false });
|
||||
expect(result.current.data).toMatchObject({ positions: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../../../shared/api";
|
||||
import type { paths } from "../../../shared/api/generated/schema";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
|
||||
export const portfolioQueryKey = (accountId: string) =>
|
||||
const portfolioQueryKey = (accountId: string) =>
|
||||
["portfolio", accountId] as const;
|
||||
|
||||
export const portfolioQueryPolicy = {
|
||||
const portfolioQueryPolicy = {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
} as const;
|
||||
@@ -21,16 +21,10 @@ export function usePortfolio(
|
||||
queryKey: portfolioQueryKey(accountId ?? ""),
|
||||
enabled: Boolean(accountId && accessToken),
|
||||
...portfolioQueryPolicy,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.GET(
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{
|
||||
params: { path: { accountId: accountId ?? "" } },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as Portfolio;
|
||||
},
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: accountId ?? "" } } },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
export type { Portfolio } from "./api/usePortfolio";
|
||||
export { usePortfolio } from "./api/usePortfolio";
|
||||
export {
|
||||
portfolioQueryKey,
|
||||
portfolioQueryPolicy,
|
||||
usePortfolio,
|
||||
} from "./api/usePortfolio";
|
||||
export {
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
portfolioErrorCode,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDecimal, formatMoney } from "./display";
|
||||
import { formatMoney } from "./display";
|
||||
|
||||
describe("portfolio display helpers", () => {
|
||||
it("formats decimal strings without financial arithmetic", () => {
|
||||
expect(formatDecimal("-1234567.89")).toBe("-1 234 567,89");
|
||||
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("—");
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio";
|
||||
|
||||
type Money = Portfolio["totalsByCurrency"][number];
|
||||
|
||||
export function formatDecimal(value: string): string {
|
||||
function formatDecimal(value: string): string {
|
||||
const [integer, fraction] = value.split(".");
|
||||
const sign = integer.startsWith("-") ? "-" : "";
|
||||
const grouped = (sign ? integer.slice(1) : integer).replace(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 { describe, expect, it } from "vitest";
|
||||
import { apiClient } from "@/shared/api";
|
||||
import { server } from "@/test/server";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
it("authorizes requests after restoring the initial session", async () => {
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({
|
||||
accessToken: "restored-token",
|
||||
user: { id: "user-1", email: "user@example.com" },
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(authorization).toBe("Bearer restored-token");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { apiClient, configureAuthClient } from "../../../shared/api";
|
||||
import type { paths } from "../../../shared/api/generated/schema";
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
|
||||
type User =
|
||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||
@@ -71,6 +71,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const authentication = data as Authentication;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
})
|
||||
.finally(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { brokerageAccountsQueryKey } from "../../../entities/brokerage-account";
|
||||
import { apiClient } from "../../../shared/api";
|
||||
import type { paths } from "../../../shared/api/generated/schema";
|
||||
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import { useAuth } from "../../auth";
|
||||
|
||||
type Status =
|
||||
@@ -36,26 +36,12 @@ export function TbankConnectionSection() {
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<{ token: string }>();
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
const status = useQuery({
|
||||
queryKey: ["tbank-connection"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.GET("/api/v1/tbank-connection", {
|
||||
headers,
|
||||
});
|
||||
if (error) throw error;
|
||||
return data as Status;
|
||||
},
|
||||
enabled: Boolean(accessToken),
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"),
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ token }: { token: string }) => {
|
||||
const { data, error } = await apiClient.PUT("/api/v1/tbank-connection", {
|
||||
headers,
|
||||
body: { token },
|
||||
});
|
||||
if (error) throw error;
|
||||
return data as Status;
|
||||
},
|
||||
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["tbank-connection"], data);
|
||||
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
|
||||
@@ -63,23 +49,21 @@ export function TbankConnectionSection() {
|
||||
},
|
||||
onError: (error) => setRequestError(errorCode(error)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { error } = await apiClient.DELETE("/api/v1/tbank-connection", {
|
||||
headers,
|
||||
});
|
||||
if (error) throw error;
|
||||
const remove = apiQueryClient.useMutation(
|
||||
"delete",
|
||||
"/api/v1/tbank-connection",
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData<Status>(["tbank-connection"], {
|
||||
connected: false,
|
||||
connectedAt: null,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
|
||||
setDeleteOpened(false);
|
||||
},
|
||||
onError: () => setRequestError("REQUEST_ERROR"),
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData<Status>(["tbank-connection"], {
|
||||
connected: false,
|
||||
connectedAt: null,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
|
||||
setDeleteOpened(false);
|
||||
},
|
||||
onError: () => setRequestError("REQUEST_ERROR"),
|
||||
});
|
||||
);
|
||||
const pending = update.isPending || remove.isPending;
|
||||
|
||||
if (status.isPending) return <Text>{t("states.loadingText")}</Text>;
|
||||
@@ -110,7 +94,7 @@ export function TbankConnectionSection() {
|
||||
<form
|
||||
onSubmit={handleSubmit(({ token }) => {
|
||||
setRequestError(null);
|
||||
update.mutate({ token });
|
||||
update.mutate({ body: { token } });
|
||||
})}
|
||||
>
|
||||
<fieldset disabled={pending}>
|
||||
@@ -159,7 +143,7 @@ export function TbankConnectionSection() {
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
disabled={pending}
|
||||
onClick={() => remove.mutate()}
|
||||
onClick={() => remove.mutate({})}
|
||||
>
|
||||
{t("tbank.deleteConfirm")}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
AccountPage,
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
AccountPageLoading,
|
||||
} from "./ui/AccountPage";
|
||||
export { AccountPage } from "./ui/AccountPage";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "../../../entities/portfolio";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
|
||||
@@ -15,16 +15,16 @@ import { Link, useParams } from "react-router";
|
||||
import {
|
||||
accountTypeKey,
|
||||
type BrokerageAccount,
|
||||
} from "../../../entities/brokerage-account";
|
||||
import type { Portfolio } from "../../../entities/portfolio";
|
||||
} from "@/entities/brokerage-account";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
portfolioErrorCode,
|
||||
usePortfolio,
|
||||
} from "../../../entities/portfolio";
|
||||
import { useAuth } from "../../../features/auth";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
} from "@/entities/portfolio";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import classes from "./AccountPage.module.css";
|
||||
|
||||
const accountsQueryKey = ["brokerage-accounts"] as const;
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
BrokerageAccountList,
|
||||
errorCode,
|
||||
useBrokerageAccounts,
|
||||
} from "../../../entities/brokerage-account";
|
||||
import { useAuth } from "../../../features/auth";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
} from "@/entities/brokerage-account";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
export function AccountsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
LoginPage,
|
||||
LoginPageEmpty,
|
||||
LoginPageError,
|
||||
LoginPageLoading,
|
||||
} from "./ui/LoginPage";
|
||||
export { LoginPage } from "./ui/LoginPage";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Paper, Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoginForm } from "../../../features/auth";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
import { LoginForm } from "@/features/auth";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,32 +15,3 @@ export function LoginPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
NotFoundPage,
|
||||
NotFoundPageEmpty,
|
||||
NotFoundPageError,
|
||||
NotFoundPageLoading,
|
||||
} from "./ui/NotFoundPage";
|
||||
export { NotFoundPage } from "./ui/NotFoundPage";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Button, StateView } from "../../../shared/ui/StateView";
|
||||
import { Button, StateView } from "@/shared/ui";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,29 +13,7 @@ export function NotFoundPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPageEmpty() {
|
||||
function NotFoundPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
RegisterPage,
|
||||
RegisterPageEmpty,
|
||||
RegisterPageError,
|
||||
RegisterPageLoading,
|
||||
} from "./ui/RegisterPage";
|
||||
export { RegisterPage } from "./ui/RegisterPage";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Paper, Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RegisterForm } from "../../../features/auth";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
import { RegisterForm } from "@/features/auth";
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,32 +15,3 @@ export function RegisterPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
SettingsPage,
|
||||
SettingsPageEmpty,
|
||||
SettingsPageError,
|
||||
SettingsPageLoading,
|
||||
} from "./ui/SettingsPage";
|
||||
export { SettingsPage } from "./ui/SettingsPage";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Button, Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useAuth } from "../../../features/auth";
|
||||
import { TbankConnectionSection } from "../../../features/tbank-connection";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { TbankConnectionSection } from "@/features/tbank-connection";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,32 +23,3 @@ export function SettingsPage() {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { server } from "@/test/server";
|
||||
import { apiClient, configureAuthClient } from "./client";
|
||||
|
||||
describe("apiClient authentication", () => {
|
||||
afterEach(() => {
|
||||
configureAuthClient(null);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("refreshes once and retries an unauthorized protected request", async () => {
|
||||
let token = "expired";
|
||||
@@ -13,35 +12,31 @@ describe("apiClient authentication", () => {
|
||||
token = "fresh";
|
||||
return true;
|
||||
});
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const authorization: string[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
authorization.push(request.headers.get("Authorization") ?? "");
|
||||
return authorization.length === 1
|
||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||
: HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
|
||||
|
||||
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(data).toEqual([]);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(fetch.mock.calls[0][0].headers.get("Authorization")).toBe(
|
||||
"Bearer expired",
|
||||
);
|
||||
expect(fetch.mock.calls[1][0].headers.get("Authorization")).toBe(
|
||||
"Bearer fresh",
|
||||
);
|
||||
expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]);
|
||||
});
|
||||
|
||||
it("clears the session when refresh cannot restore it", async () => {
|
||||
const clear = vi.fn();
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "expired",
|
||||
refresh: async () => false,
|
||||
@@ -51,6 +46,24 @@ describe("apiClient authentication", () => {
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not attach Bearer auth to authentication endpoints", async () => {
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
await apiClient.POST("/api/v1/auth/refresh");
|
||||
|
||||
expect(authorization).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import createQueryClient from "openapi-react-query";
|
||||
|
||||
import { apiBaseUrl } from "./config";
|
||||
import type { paths } from "./generated/schema";
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
type AuthHandlers = {
|
||||
accessToken: () => string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
@@ -56,3 +58,5 @@ export const apiClient = createClient<paths>({
|
||||
credentials: "include",
|
||||
fetch: authenticatedFetch,
|
||||
});
|
||||
|
||||
export const apiQueryClient = createQueryClient(apiClient);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const apiBaseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
|
||||
@@ -1,2 +1 @@
|
||||
export { apiClient, configureAuthClient } from "./client";
|
||||
export { apiBaseUrl } from "./config";
|
||||
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/api/v1/auth/register": {
|
||||
"post": {
|
||||
"operationId": "AuthController_register",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CredentialsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthenticationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"operationId": "AuthController_login",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CredentialsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthenticationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
"post": {
|
||||
"operationId": "AuthController_refresh",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthenticationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"operationId": "AuthController_logout",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"operationId": "AuthController_me",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/tbank-connection": {
|
||||
"get": {
|
||||
"operationId": "TbankConnectionController_status",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TbankConnectionStatusDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"tbank-connection"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "TbankConnectionController_put",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PutTbankConnectionDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TbankConnectionStatusDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"tbank-connection"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "TbankConnectionController_delete",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"tbank-connection"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/brokerage-accounts": {
|
||||
"get": {
|
||||
"operationId": "BrokerageAccountsController_list",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BrokerageAccountDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"brokerage-accounts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
|
||||
"get": {
|
||||
"operationId": "PortfolioController_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "accountId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortfolioDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetailsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"brokerage-accounts"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "WealthLedger API",
|
||||
"description": "",
|
||||
"version": "v1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
"servers": [],
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ProblemDetailsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "number",
|
||||
"example": 401
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"example": "UNAUTHORIZED"
|
||||
},
|
||||
"errors": {
|
||||
"example": [
|
||||
{
|
||||
"pointer": "#/email",
|
||||
"detail": "must be a valid email address"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
"CredentialsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "user@example.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 12,
|
||||
"maxLength": 72,
|
||||
"example": "a secure password"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"UserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"AuthenticationDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessToken",
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"TbankConnectionStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connected": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"connectedAt": {
|
||||
"type": "object",
|
||||
"nullable": true,
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"connected",
|
||||
"connectedAt"
|
||||
]
|
||||
},
|
||||
"PutTbankConnectionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"writeOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
]
|
||||
},
|
||||
"BrokerageAccountDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"BROKERAGE",
|
||||
"INDIVIDUAL_INVESTMENT",
|
||||
"INVEST_BOX",
|
||||
"MONEY_MARKET_FUND"
|
||||
]
|
||||
},
|
||||
"openedOn": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"openedOn"
|
||||
]
|
||||
},
|
||||
"MoneyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"currency",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"PortfolioPositionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"figi": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"STOCK",
|
||||
"BOND",
|
||||
"FUND",
|
||||
"CURRENCY",
|
||||
"FUTURE",
|
||||
"OPTION",
|
||||
"STRUCTURED_NOTE",
|
||||
"DIGITAL_FINANCIAL_ASSET",
|
||||
"OTHER"
|
||||
]
|
||||
},
|
||||
"quantity": {
|
||||
"type": "string"
|
||||
},
|
||||
"currentValue": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MoneyDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"averagePurchasePrice": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MoneyDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"absoluteReturn": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MoneyDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"figi",
|
||||
"displayName",
|
||||
"type",
|
||||
"quantity",
|
||||
"currentValue",
|
||||
"averagePurchasePrice",
|
||||
"absoluteReturn"
|
||||
]
|
||||
},
|
||||
"PortfolioDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lastSuccessfulAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"isStale": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"totalsByCurrency": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MoneyDto"
|
||||
}
|
||||
},
|
||||
"positions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PortfolioPositionDto"
|
||||
}
|
||||
},
|
||||
"cashBalances": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MoneyDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lastSuccessfulAt",
|
||||
"isStale",
|
||||
"totalsByCurrency",
|
||||
"positions",
|
||||
"cashBalances"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { theme } from "../../app/theme";
|
||||
import { StateView } from "./StateView";
|
||||
|
||||
describe("StateView", () => {
|
||||
@@ -9,7 +8,7 @@ describe("StateView", () => {
|
||||
|
||||
it("renders an accessible loading state with a loader", () => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<MantineProvider>
|
||||
<StateView loading text="Пожалуйста, подождите." title="Загрузка" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -21,7 +20,7 @@ describe("StateView", () => {
|
||||
|
||||
it("renders an accessible error state", () => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<MantineProvider>
|
||||
<StateView
|
||||
error
|
||||
text="Попробуйте обновить страницу позже."
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export { Button, StateView } from "./StateView";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
|
||||
const authentication = {
|
||||
accessToken: "test-token",
|
||||
user: { id: "user-1", email: "user@example.com" },
|
||||
};
|
||||
|
||||
export const handlers = [
|
||||
http.post("*/api/v1/auth/register", () => HttpResponse.json(authentication)),
|
||||
http.post("*/api/v1/auth/login", () => HttpResponse.json(authentication)),
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
),
|
||||
http.post(
|
||||
"*/api/v1/auth/logout",
|
||||
() => new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
http.get("*/api/v1/auth/me", () => HttpResponse.json(authentication.user)),
|
||||
http.get("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({ connected: false, connectedAt: null }),
|
||||
),
|
||||
http.put("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({ connected: true, connectedAt: "2026-08-14T00:00:00Z" }),
|
||||
),
|
||||
http.delete(
|
||||
"*/api/v1/tbank-connection",
|
||||
() => new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json([])),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
positions: [],
|
||||
cashBalances: [],
|
||||
}),
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
@@ -1,5 +1,11 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterAll, afterEach, beforeAll } from "vitest";
|
||||
import "../shared/i18n";
|
||||
import { server } from "./server";
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
|
||||
Reference in New Issue
Block a user