refactor: refactor foundation

This commit is contained in:
2026-08-14 09:43:19 +03:00
parent 2e32d226d3
commit da585b157a
54 changed files with 4822 additions and 535 deletions
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
coverage
*.local *.local
# Editor directories and files # Editor directories and files
+1
View File
@@ -0,0 +1 @@
npx --no-install commitlint --edit "$1"
+3
View File
@@ -0,0 +1,3 @@
npx lint-staged
npm run openapi:check
npm run deps
+1
View File
@@ -0,0 +1 @@
22
+5 -7
View File
@@ -7,21 +7,18 @@ React/Vite-клиент WealthLedger. Функциональные измене
## Проверка ## Проверка
```bash ```bash
npm run lint npm ci
npm run typecheck npm run verify
npm run test:run
npm run build
npm run check:no-todo
``` ```
`npm run openapi:generate` получает документ работающего backend по `${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json`; фактическая генерация выполняется только в задаче 5.1. `build` выполняет `tsc -b`. Node 22 обязателен (`.nvmrc`). `npm run openapi:sync` - единственная команда, обращающаяся к `${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json`; она атомарно обновляет snapshot и generated types. `openapi:generate` и `openapi:check` работают только с committed snapshot.
## Документация для агента ## Документация для агента
- Перед изменением API или transport types прочитать [`docs/API-LAYER.md`](docs/API-LAYER.md). - Перед изменением API или transport types прочитать [`docs/API-LAYER.md`](docs/API-LAYER.md).
- Перед созданием формы, страницы или UI-состояний прочитать [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md). - Перед созданием формы, страницы или UI-состояний прочитать [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md).
- Перед изменением структуры слайса или импортов прочитать [`docs/STRUCTURE.md`](docs/STRUCTURE.md). - Перед изменением структуры слайса или импортов прочитать [`docs/STRUCTURE.md`](docs/STRUCTURE.md).
- Перед кодом и тестами прочитать [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) и [`docs/TESTING.md`](docs/TESTING.md). - Перед кодом, тестами или quality checks прочитать [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) и [`docs/TESTING.md`](docs/TESTING.md).
## Границы ## Границы
@@ -30,3 +27,4 @@ npm run check:no-todo
- HTTP идет только через типизированный клиент; request/response types выводятся из generated OpenAPI `paths[...]`, а не дублируются вручную. UI-строки - только через i18n. Каждый маршрут имеет loading, error и empty состояния. - HTTP идет только через типизированный клиент; request/response types выводятся из generated OpenAPI `paths[...]`, а не дублируются вручную. UI-строки - только через i18n. Каждый маршрут имеет loading, error и empty состояния.
- Соблюдать strict TypeScript: `import type` для типов, без `enum` и `namespace`. - Соблюдать strict TypeScript: `import type` для типов, без `enum` и `namespace`.
- Не добавлять прямые вызовы T-Bank и не переносить Todo-код из `../references/`. - Не добавлять прямые вызовы T-Bank и не переносить Todo-код из `../references/`.
- Не добавлять Storybook, Playwright, CI configuration, browser MSW worker или mock development mode.
+1
View File
@@ -8,6 +8,7 @@
"**", "**",
"!dist", "!dist",
"!src/shared/api/generated", "!src/shared/api/generated",
"!src/shared/api/spec",
"!coverage", "!coverage",
"!node_modules" "!node_modules"
] ]
+1
View File
@@ -0,0 +1 @@
module.exports = { extends: ["@commitlint/config-conventional"] };
+28
View File
@@ -0,0 +1,28 @@
module.exports = {
forbidden: [
{
name: "shared-must-not-depend-on-layers",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" },
},
{
name: "entities-must-not-depend-on-higher-layers",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" },
},
{
name: "features-must-not-depend-on-higher-layers",
from: { path: "^src/features/" },
to: { path: "^src/(pages|app)/" },
},
{
name: "pages-must-not-depend-on-app",
from: { path: "^src/pages/" },
to: { path: "^src/app/" },
},
],
options: {
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" },
},
};
+6 -2
View File
@@ -1,8 +1,12 @@
# API Layer # API Layer
OpenAPI backend является источником истины. После изменения backend-контракта запускается `npm run openapi:generate`; `src/shared/api/generated/schema.d.ts` не редактируется вручную. OpenAPI backend является источником истины. После изменения backend-контракта запускается `npm run openapi:sync`; она получает опубликованный документ, валидирует генерацией и только затем атомарно обновляет `src/shared/api/spec/openapi.json` и `src/shared/api/generated/schema.d.ts`. Оба файла committed; generated файл не редактируется вручную.
Все HTTP-вызовы используют `apiClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. `npm run openapi:generate` и `npm run openapi:check` используют snapshot offline. `verify` никогда не требует backend; OpenAPI endpoint разрешён только явной sync-команде.
Все HTTP-вызовы используют `apiClient` и `apiQueryClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. Query options берутся из `apiQueryClient`, а сохранённые cache keys и policies задаются вызывающим slice.
Bearer header принадлежит только shared authenticated fetch. Auth endpoints исключены; refresh сохраняет single-flight и один retry `UNAUTHORIZED`. Не передавайте `Authorization` из query или mutation.
Request и response types выводятся из generated `paths`, например: Request и response types выводятся из generated `paths`, например:
+2
View File
@@ -8,6 +8,8 @@
- Zod-схемы не содержат пользовательских строк: использовать `validationMessage(key)` из `src/shared/i18n/zod.ts`. - Zod-схемы не содержат пользовательских строк: использовать `validationMessage(key)` из `src/shared/i18n/zod.ts`.
- Публичный импорт слайса идёт через его `index.ts`. - Публичный импорт слайса идёт через его `index.ts`.
- Тесты лежат рядом с исходником как `*.test.tsx`. - Тесты лежат рядом с исходником как `*.test.tsx`.
- Зависимости используют exact versions; не добавлять semver ranges.
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits.
## File roles ## File roles
+2
View File
@@ -9,3 +9,5 @@
- `shared` - переиспользуемый код без доменной логики: API, i18n и UI. - `shared` - переиспользуемый код без доменной логики: API, i18n и UI.
Каждый слайс предоставляет наружу только public API через `index.ts`; внутренние файлы соседний слой не импортирует напрямую. Каждый слайс предоставляет наружу только public API через `index.ts`; внутренние файлы соседний слой не импортирует напрямую.
Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias.
+3 -1
View File
@@ -1,5 +1,7 @@
# Testing # Testing
Тесты запускаются `npm run test:run` через Vitest и Testing Library. Тесты запускаются `npm run test:run` через Vitest и Testing Library. `npm run test:coverage` создаёт локальный coverage report.
MSW запускается только Vitest setup в `src/test/`; handlers покрывают текущий API. Не добавлять service worker, browser mocking или mock development mode. Для transport проверять заголовки и retry через MSW, не через реальный backend.
Проверяйте наблюдаемое поведение, а не детали реализации. UI-тест строится как Arrange, Act, Assert; для интерактивных действий предпочитайте `userEvent`. Форма должна проверять валидацию и отсутствие запроса при невалидных данных; маршруты - редиректы и доступность экрана. Проверяйте наблюдаемое поведение, а не детали реализации. UI-тест строится как Arrange, Act, Assert; для интерактивных действий предпочитайте `userEvent`. Форма должна проверять валидацию и отсутствие запроса при невалидных данных; маршруты - редиректы и доступность экрана.
+3
View File
@@ -0,0 +1,3 @@
{
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
}
+3480 -184
View File
File diff suppressed because it is too large Load Diff
+48 -28
View File
@@ -3,6 +3,9 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"engines": {
"node": "22.x"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"typecheck": "tsc -b --noEmit", "typecheck": "tsc -b --noEmit",
@@ -10,39 +13,56 @@
"lint": "biome check .", "lint": "biome check .",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"check:no-todo": "! rg -i todo src --glob '*.{ts,tsx}'", "test:coverage": "vitest run --coverage",
"openapi:generate": "openapi-typescript \"${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json\" -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:sync": "node scripts/openapi-sync.mjs",
"openapi:check": "node scripts/openapi-check.mjs",
"knip": "knip",
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build",
"prepare": "husky",
"preview": "vite preview" "preview": "vite preview"
}, },
"lint-staged": {
"*.{css,html,json,md,ts,tsx}": "biome format --write"
},
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1", "@hookform/resolvers": "5.7.1",
"@mantine/core": "^8.3.14", "@mantine/core": "9.5.1",
"@tanstack/react-query": "^5.90.3", "@tanstack/react-query": "5.101.4",
"i18next": "^26.0.2", "i18next": "26.0.2",
"openapi-fetch": "^0.17.0", "openapi-fetch": "0.17.0",
"react": "^19.2.8", "openapi-react-query": "0.5.4",
"react-dom": "^19.2.8", "react": "19.2.8",
"react-hook-form": "^7.85.0", "react-dom": "19.2.8",
"react-i18next": "^16.3.5", "react-hook-form": "7.85.0",
"react-router": "^7.9.6", "react-i18next": "16.3.5",
"zod": "^3.25.76" "react-router": "7.9.6",
"zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7", "@babel/core": "7.29.7",
"@biomejs/biome": "2.5.8", "@biomejs/biome": "2.5.8",
"@rolldown/plugin-babel": "^0.2.3", "@commitlint/cli": "21.2.2",
"@testing-library/jest-dom": "^6.9.1", "@commitlint/config-conventional": "21.2.2",
"@testing-library/react": "^16.3.0", "@rolldown/plugin-babel": "0.2.3",
"@types/babel__core": "^7.20.5", "@testing-library/jest-dom": "6.9.1",
"@types/node": "^24.13.3", "@testing-library/react": "16.3.0",
"@types/react": "^19.2.17", "@types/node": "24.13.3",
"@types/react-dom": "^19.2.3", "@types/react": "19.2.17",
"@vitejs/plugin-react": "^6.0.4", "@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "^1.0.0", "@vitejs/plugin-react": "6.0.4",
"jsdom": "^27.4.0", "@vitest/coverage-v8": "4.1.10",
"openapi-typescript": "^7.10.1", "babel-plugin-react-compiler": "1.0.0",
"typescript": "~5.9.3", "dependency-cruiser": "18.2.0",
"vite": "^8.2.0", "husky": "9.1.7",
"vitest": "^4.0.16" "jsdom": "27.4.0",
"knip": "6.32.2",
"lint-staged": "16.2.7",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
"typescript": "5.9.3",
"vite": "8.2.0",
"vitest": "4.1.10"
} }
} }
+36
View File
@@ -0,0 +1,36 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const snapshot = "src/shared/api/spec/openapi.json";
const generated = "src/shared/api/generated/schema.d.ts";
const directory = await mkdtemp(join(tmpdir(), "wealth-ledger-openapi-"));
const expected = join(directory, "schema.d.ts");
try {
await new Promise((resolve, reject) => {
const child = spawn(
"npx",
["--no-install", "openapi-typescript", snapshot, "-o", expected],
{
stdio: "inherit",
},
);
child.on("error", reject);
child.on("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`openapi-typescript exited with ${code}`)),
);
});
if (
(await readFile(expected, "utf8")) !== (await readFile(generated, "utf8"))
) {
throw new Error(
"Generated OpenAPI types drift from src/shared/api/spec/openapi.json. Run npm run openapi:generate.",
);
}
} finally {
await rm(directory, { recursive: true, force: true });
}
+44
View File
@@ -0,0 +1,44 @@
import { spawn } from "node:child_process";
import { mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const baseUrl = process.env.VITE_API_BASE_URL ?? "http://localhost:3000";
const snapshot = "src/shared/api/spec/openapi.json";
const generated = "src/shared/api/generated/schema.d.ts";
const directory = await mkdtemp(join(tmpdir(), "wealth-ledger-openapi-"));
const nextSnapshot = join(directory, "openapi.json");
const nextGenerated = join(directory, "schema.d.ts");
try {
const response = await fetch(`${baseUrl}/api/docs-json`, {
headers: { Accept: "application/json" },
});
if (!response.ok)
throw new Error(
`OpenAPI sync failed: ${response.status} ${response.statusText}`,
);
const document = await response.json();
if (!document || typeof document !== "object")
throw new Error("OpenAPI sync failed: endpoint did not return an object");
await writeFile(nextSnapshot, `${JSON.stringify(document, null, 2)}\n`);
await new Promise((resolve, reject) => {
const child = spawn(
"npx",
["--no-install", "openapi-typescript", nextSnapshot, "-o", nextGenerated],
{
stdio: "inherit",
},
);
child.on("error", reject);
child.on("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`openapi-typescript exited with ${code}`)),
);
});
await rename(nextSnapshot, snapshot);
await rename(nextGenerated, generated);
} finally {
await rm(directory, { recursive: true, force: true });
}
+2 -13
View File
@@ -4,23 +4,12 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
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";
vi.mock("../../shared/api", async (importOriginal) => { import { AuthProvider } from "@/features/auth";
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 { import {
AccountsPageEmpty, AccountsPageEmpty,
AccountsPageError, AccountsPageError,
AccountsPageLoading, AccountsPageLoading,
} from "../../pages/accounts"; } from "@/pages/accounts";
import { theme } from "../theme"; import { theme } from "../theme";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
+8 -8
View File
@@ -1,12 +1,12 @@
import { Navigate, Route, Routes, useLocation } from "react-router"; import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "../../features/auth"; import { useAuth } from "@/features/auth";
import { AccountPage } from "../../pages/account"; import { AccountPage } from "@/pages/account";
import { AccountsPage } from "../../pages/accounts"; import { AccountsPage } from "@/pages/accounts";
import { LoginPage } from "../../pages/login"; import { LoginPage } from "@/pages/login";
import { NotFoundPage } from "../../pages/not-found"; import { NotFoundPage } from "@/pages/not-found";
import { RegisterPage } from "../../pages/register"; import { RegisterPage } from "@/pages/register";
import { SettingsPage } from "../../pages/settings"; import { SettingsPage } from "@/pages/settings";
import { AppShell } from "../../shared/ui/AppShell"; import { AppShell } from "@/shared/ui";
function Protected({ children }: { children: React.ReactNode }) { function Protected({ children }: { children: React.ReactNode }) {
const { user, ready } = useAuth(); const { user, ready } = useAuth();
+3 -3
View File
@@ -1,9 +1,9 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { AuthProvider } from "../../features/auth"; import { AppRoutes } from "@/app/routes";
import { AppRoutes } from "../routes"; import { theme } from "@/app/theme";
import { theme } from "../theme"; import { AuthProvider } from "@/features/auth";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }, defaultOptions: { queries: { refetchOnWindowFocus: false } },
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../../../shared/api"; import { apiQueryClient } from "@/shared/api";
import type { paths } from "../../../shared/api/generated/schema"; import type { paths } from "@/shared/api/generated/schema";
export const brokerageAccountsQueryKey = ["brokerage-accounts"] as const; export const brokerageAccountsQueryKey = ["brokerage-accounts"] as const;
@@ -13,15 +13,6 @@ export function useBrokerageAccounts(accessToken: string | null) {
enabled: Boolean(accessToken), enabled: Boolean(accessToken),
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
queryFn: async () => { ...apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
const { data, error } = await apiClient.GET(
"/api/v1/brokerage-accounts",
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (error) throw error;
return data as BrokerageAccount[];
},
}); });
} }
@@ -2,26 +2,13 @@ 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, vi } from "vitest"; import { describe, expect, it } from "vitest";
const { get } = vi.hoisted(() => ({ get: vi.fn() })); import { usePortfolio } from "./usePortfolio";
vi.mock("../../../shared/api", () => ({ apiClient: { GET: get } }));
import { portfolioQueryPolicy, usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => { describe("usePortfolio", () => {
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 () => {
const client = new QueryClient(); const client = new QueryClient();
get.mockResolvedValueOnce({
data: {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: false,
totalsByCurrency: [],
positions: [],
cashBalances: [],
},
});
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
<MantineProvider> <MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider> <QueryClientProvider client={client}>{children}</QueryClientProvider>
@@ -33,13 +20,6 @@ describe("usePortfolio", () => {
}); });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(get).toHaveBeenCalledWith( expect(result.current.data).toMatchObject({ positions: [] });
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{
params: { path: { accountId: "account-1" } },
headers: { Authorization: "Bearer token" },
},
);
expect(portfolioQueryPolicy).toEqual({ staleTime: 30_000, retry: false });
}); });
}); });
+9 -15
View File
@@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../../../shared/api"; import { apiQueryClient } from "@/shared/api";
import type { paths } from "../../../shared/api/generated/schema"; import type { paths } from "@/shared/api/generated/schema";
export const portfolioQueryKey = (accountId: string) => const portfolioQueryKey = (accountId: string) =>
["portfolio", accountId] as const; ["portfolio", accountId] as const;
export const portfolioQueryPolicy = { const portfolioQueryPolicy = {
staleTime: 30_000, staleTime: 30_000,
retry: false, retry: false,
} as const; } as const;
@@ -21,16 +21,10 @@ export function usePortfolio(
queryKey: portfolioQueryKey(accountId ?? ""), queryKey: portfolioQueryKey(accountId ?? ""),
enabled: Boolean(accountId && accessToken), enabled: Boolean(accountId && accessToken),
...portfolioQueryPolicy, ...portfolioQueryPolicy,
queryFn: async () => { ...apiQueryClient.queryOptions(
const { data, error } = await apiClient.GET( "get",
"/api/v1/brokerage-accounts/{accountId}/portfolio", "/api/v1/brokerage-accounts/{accountId}/portfolio",
{ { params: { path: { accountId: accountId ?? "" } } },
params: { path: { accountId: accountId ?? "" } }, ),
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (error) throw error;
return data as Portfolio;
},
}); });
} }
+1 -6
View File
@@ -1,11 +1,6 @@
export type { Portfolio } from "./api/usePortfolio"; export type { Portfolio } from "./api/usePortfolio";
export { usePortfolio } from "./api/usePortfolio";
export { export {
portfolioQueryKey,
portfolioQueryPolicy,
usePortfolio,
} from "./api/usePortfolio";
export {
formatDecimal,
formatMoney, formatMoney,
formatSnapshotTime, formatSnapshotTime,
portfolioErrorCode, portfolioErrorCode,
+4 -2
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatDecimal, formatMoney } from "./display"; import { formatMoney } from "./display";
describe("portfolio display helpers", () => { describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => { 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: "10", currency: "RUB" })).toBe("10 ₽");
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD"); expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—"); expect(formatMoney(null)).toBe("—");
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio";
type Money = Portfolio["totalsByCurrency"][number]; type Money = Portfolio["totalsByCurrency"][number];
export function formatDecimal(value: string): string { function formatDecimal(value: string): string {
const [integer, fraction] = value.split("."); const [integer, fraction] = value.split(".");
const sign = integer.startsWith("-") ? "-" : ""; const sign = integer.startsWith("-") ? "-" : "";
const grouped = (sign ? integer.slice(1) : integer).replace( const grouped = (sign ? integer.slice(1) : integer).replace(
+37
View File
@@ -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");
});
});
+3 -2
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useRef, useState } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react";
import { apiClient, configureAuthClient } from "../../../shared/api"; import { apiClient, configureAuthClient } from "@/shared/api";
import type { paths } from "../../../shared/api/generated/schema"; import type { paths } from "@/shared/api/generated/schema";
type User = type User =
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; 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; const authentication = data as Authentication;
setUser(authentication.user); setUser(authentication.user);
setAccessToken(authentication.accessToken); setAccessToken(authentication.accessToken);
accessTokenRef.current = authentication.accessToken;
}) })
.finally(() => setReady(true)); .finally(() => setReady(true));
}, []); }, []);
@@ -6,13 +6,13 @@ import {
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { brokerageAccountsQueryKey } from "../../../entities/brokerage-account"; import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { apiClient } from "../../../shared/api"; import { apiQueryClient } from "@/shared/api";
import type { paths } from "../../../shared/api/generated/schema"; import type { paths } from "@/shared/api/generated/schema";
import { useAuth } from "../../auth"; import { useAuth } from "../../auth";
type Status = type Status =
@@ -36,26 +36,12 @@ export function TbankConnectionSection() {
reset, reset,
formState: { errors }, formState: { errors },
} = useForm<{ token: string }>(); } = useForm<{ token: string }>();
const headers = { Authorization: `Bearer ${accessToken}` };
const status = useQuery({ const status = useQuery({
queryKey: ["tbank-connection"], queryKey: ["tbank-connection"],
queryFn: async () => { enabled: Boolean(accessToken),
const { data, error } = await apiClient.GET("/api/v1/tbank-connection", { ...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"),
headers,
});
if (error) throw error;
return data as Status;
},
}); });
const update = useMutation({ const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
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;
},
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(["tbank-connection"], data); queryClient.setQueryData(["tbank-connection"], data);
queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey }); queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey });
@@ -63,23 +49,21 @@ export function TbankConnectionSection() {
}, },
onError: (error) => setRequestError(errorCode(error)), onError: (error) => setRequestError(errorCode(error)),
}); });
const remove = useMutation({ const remove = apiQueryClient.useMutation(
mutationFn: async () => { "delete",
const { error } = await apiClient.DELETE("/api/v1/tbank-connection", { "/api/v1/tbank-connection",
headers, {
}); onSuccess: () => {
if (error) throw error; 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; const pending = update.isPending || remove.isPending;
if (status.isPending) return <Text>{t("states.loadingText")}</Text>; if (status.isPending) return <Text>{t("states.loadingText")}</Text>;
@@ -110,7 +94,7 @@ export function TbankConnectionSection() {
<form <form
onSubmit={handleSubmit(({ token }) => { onSubmit={handleSubmit(({ token }) => {
setRequestError(null); setRequestError(null);
update.mutate({ token }); update.mutate({ body: { token } });
})} })}
> >
<fieldset disabled={pending}> <fieldset disabled={pending}>
@@ -159,7 +143,7 @@ export function TbankConnectionSection() {
color="red" color="red"
loading={remove.isPending} loading={remove.isPending}
disabled={pending} disabled={pending}
onClick={() => remove.mutate()} onClick={() => remove.mutate({})}
> >
{t("tbank.deleteConfirm")} {t("tbank.deleteConfirm")}
</Button> </Button>
+1 -6
View File
@@ -1,6 +1 @@
export { export { AccountPage } from "./ui/AccountPage";
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoading,
} from "./ui/AccountPage";
+1 -1
View File
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
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 type { Portfolio } from "../../../entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { import {
AccountPageEmpty, AccountPageEmpty,
AccountPageError, AccountPageError,
+5 -5
View File
@@ -15,16 +15,16 @@ import { Link, useParams } from "react-router";
import { import {
accountTypeKey, accountTypeKey,
type BrokerageAccount, type BrokerageAccount,
} from "../../../entities/brokerage-account"; } from "@/entities/brokerage-account";
import type { Portfolio } from "../../../entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { import {
formatMoney, formatMoney,
formatSnapshotTime, formatSnapshotTime,
portfolioErrorCode, portfolioErrorCode,
usePortfolio, usePortfolio,
} from "../../../entities/portfolio"; } from "@/entities/portfolio";
import { useAuth } from "../../../features/auth"; import { useAuth } from "@/features/auth";
import { StateView } from "../../../shared/ui/StateView"; import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css"; import classes from "./AccountPage.module.css";
const accountsQueryKey = ["brokerage-accounts"] as const; const accountsQueryKey = ["brokerage-accounts"] as const;
+3 -3
View File
@@ -5,9 +5,9 @@ import {
BrokerageAccountList, BrokerageAccountList,
errorCode, errorCode,
useBrokerageAccounts, useBrokerageAccounts,
} from "../../../entities/brokerage-account"; } from "@/entities/brokerage-account";
import { useAuth } from "../../../features/auth"; import { useAuth } from "@/features/auth";
import { StateView } from "../../../shared/ui/StateView"; import { StateView } from "@/shared/ui";
export function AccountsPage() { export function AccountsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
+1 -6
View File
@@ -1,6 +1 @@
export { export { LoginPage } from "./ui/LoginPage";
LoginPage,
LoginPageEmpty,
LoginPageError,
LoginPageLoading,
} from "./ui/LoginPage";
+1 -31
View File
@@ -1,7 +1,6 @@
import { Paper, Stack, Title } from "@mantine/core"; import { Paper, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoginForm } from "../../../features/auth"; import { LoginForm } from "@/features/auth";
import { StateView } from "../../../shared/ui/StateView";
export function LoginPage() { export function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,32 +15,3 @@ export function LoginPage() {
</main> </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
View File
@@ -1,6 +1 @@
export { export { NotFoundPage } from "./ui/NotFoundPage";
NotFoundPage,
NotFoundPageEmpty,
NotFoundPageError,
NotFoundPageLoading,
} from "./ui/NotFoundPage";
+2 -24
View File
@@ -1,7 +1,7 @@
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { Button, StateView } from "../../../shared/ui/StateView"; import { Button, StateView } from "@/shared/ui";
export function NotFoundPage() { export function NotFoundPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,29 +13,7 @@ export function NotFoundPage() {
); );
} }
export function NotFoundPageLoading() { function NotFoundPageEmpty() {
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() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
+1 -6
View File
@@ -1,6 +1 @@
export { export { RegisterPage } from "./ui/RegisterPage";
RegisterPage,
RegisterPageEmpty,
RegisterPageError,
RegisterPageLoading,
} from "./ui/RegisterPage";
+1 -31
View File
@@ -1,7 +1,6 @@
import { Paper, Stack, Title } from "@mantine/core"; import { Paper, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RegisterForm } from "../../../features/auth"; import { RegisterForm } from "@/features/auth";
import { StateView } from "../../../shared/ui/StateView";
export function RegisterPage() { export function RegisterPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,32 +15,3 @@ export function RegisterPage() {
</main> </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
View File
@@ -1,6 +1 @@
export { export { SettingsPage } from "./ui/SettingsPage";
SettingsPage,
SettingsPageEmpty,
SettingsPageError,
SettingsPageLoading,
} from "./ui/SettingsPage";
+2 -32
View File
@@ -1,9 +1,8 @@
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useAuth } from "../../../features/auth"; import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "../../../features/tbank-connection"; import { TbankConnectionSection } from "@/features/tbank-connection";
import { StateView } from "../../../shared/ui/StateView";
export function SettingsPage() { export function SettingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,32 +23,3 @@ export function SettingsPage() {
</Stack> </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")} />
);
}
+37 -24
View File
@@ -1,11 +1,10 @@
import { HttpResponse, http } from "msw";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { server } from "@/test/server";
import { apiClient, configureAuthClient } from "./client"; import { apiClient, configureAuthClient } from "./client";
describe("apiClient authentication", () => { describe("apiClient authentication", () => {
afterEach(() => { afterEach(() => configureAuthClient(null));
configureAuthClient(null);
vi.unstubAllGlobals();
});
it("refreshes once and retries an unauthorized protected request", async () => { it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired"; let token = "expired";
@@ -13,35 +12,31 @@ describe("apiClient authentication", () => {
token = "fresh"; token = "fresh";
return true; return true;
}); });
const fetch = vi const authorization: string[] = [];
.fn() server.use(
.mockResolvedValueOnce( http.get("*/api/v1/brokerage-accounts", ({ request }) => {
new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }), authorization.push(request.headers.get("Authorization") ?? "");
) return authorization.length === 1
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
vi.stubGlobal("fetch", fetch); : HttpResponse.json([]);
}),
);
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() }); configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
const { data } = await apiClient.GET("/api/v1/brokerage-accounts"); const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
expect(data).toEqual([]); expect(data).toEqual([]);
expect(refresh).toHaveBeenCalledOnce(); expect(refresh).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][0].headers.get("Authorization")).toBe( expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]);
"Bearer expired",
);
expect(fetch.mock.calls[1][0].headers.get("Authorization")).toBe(
"Bearer fresh",
);
}); });
it("clears the session when refresh cannot restore it", async () => { it("clears the session when refresh cannot restore it", async () => {
const clear = vi.fn(); const clear = vi.fn();
const fetch = vi server.use(
.fn() http.get("*/api/v1/brokerage-accounts", () =>
.mockResolvedValue( HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }), ),
); );
vi.stubGlobal("fetch", fetch);
configureAuthClient({ configureAuthClient({
accessToken: () => "expired", accessToken: () => "expired",
refresh: async () => false, refresh: async () => false,
@@ -51,6 +46,24 @@ describe("apiClient authentication", () => {
await apiClient.GET("/api/v1/brokerage-accounts"); await apiClient.GET("/api/v1/brokerage-accounts");
expect(clear).toHaveBeenCalledOnce(); 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();
}); });
}); });
+5 -1
View File
@@ -1,8 +1,10 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import createQueryClient from "openapi-react-query";
import { apiBaseUrl } from "./config";
import type { paths } from "./generated/schema"; import type { paths } from "./generated/schema";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
type AuthHandlers = { type AuthHandlers = {
accessToken: () => string | null; accessToken: () => string | null;
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
@@ -56,3 +58,5 @@ export const apiClient = createClient<paths>({
credentials: "include", credentials: "include",
fetch: authenticatedFetch, fetch: authenticatedFetch,
}); });
export const apiQueryClient = createQueryClient(apiClient);
-2
View File
@@ -1,2 +0,0 @@
export const apiBaseUrl =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
+1 -2
View File
@@ -1,2 +1 @@
export { apiClient, configureAuthClient } from "./client"; export { apiClient, apiQueryClient, configureAuthClient } from "./client";
export { apiBaseUrl } from "./config";
+936
View File
@@ -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"
]
}
}
}
}
+2 -3
View File
@@ -1,7 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { theme } from "../../app/theme";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
describe("StateView", () => { describe("StateView", () => {
@@ -9,7 +8,7 @@ describe("StateView", () => {
it("renders an accessible loading state with a loader", () => { it("renders an accessible loading state with a loader", () => {
render( render(
<MantineProvider theme={theme}> <MantineProvider>
<StateView loading text="Пожалуйста, подождите." title="Загрузка" /> <StateView loading text="Пожалуйста, подождите." title="Загрузка" />
</MantineProvider>, </MantineProvider>,
); );
@@ -21,7 +20,7 @@ describe("StateView", () => {
it("renders an accessible error state", () => { it("renders an accessible error state", () => {
render( render(
<MantineProvider theme={theme}> <MantineProvider>
<StateView <StateView
error error
text="Попробуйте обновить страницу позже." text="Попробуйте обновить страницу позже."
+2
View File
@@ -0,0 +1,2 @@
export { AppShell } from "./AppShell";
export { Button, StateView } from "./StateView";
+39
View File
@@ -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: [],
}),
),
];
+4
View File
@@ -0,0 +1,4 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
+6
View File
@@ -1,5 +1,11 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import "../shared/i18n"; import "../shared/i18n";
import { server } from "./server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
+2
View File
@@ -4,6 +4,8 @@
"target": "es2023", "target": "es2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "esnext", "module": "esnext",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"types": ["vite/client"], "types": ["vite/client"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
+2
View File
@@ -1,3 +1,4 @@
import { fileURLToPath, URL } from "node:url";
import babel from "@rolldown/plugin-babel"; import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
@@ -5,4 +6,5 @@ import { defineConfig } from "vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })], plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
}); });
+4
View File
@@ -3,8 +3,12 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
test: { test: {
environment: "jsdom", environment: "jsdom",
setupFiles: "./src/test/setup.ts", setupFiles: "./src/test/setup.ts",
coverage: { reporter: ["text", "html"] },
}, },
}); });
import { fileURLToPath, URL } from "node:url";