240 lines
8.1 KiB
TypeScript
240 lines
8.1 KiB
TypeScript
import type { ReactNode } from "react";
|
||
|
||
import { MantineProvider } from "@mantine/core";
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { fireEvent, render, screen } from "@testing-library/react";
|
||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||
import { http, HttpResponse } from "msw";
|
||
import { type InitialEntry, Link, MemoryRouter } from "react-router";
|
||
import { describe, expect, it } from "vitest";
|
||
|
||
import { AuthProvider } from "@/features/auth";
|
||
import { createTestQueryClient } from "@/test/query";
|
||
import { server } from "@/test/server";
|
||
|
||
import { AppRoutes } from "./AppRoutes";
|
||
|
||
function renderRoutes(path: InitialEntry, controls?: ReactNode) {
|
||
return render(
|
||
<QueryClientProvider client={createTestQueryClient()}>
|
||
<MemoryRouter initialEntries={[path]}>
|
||
{controls}
|
||
<MantineProvider
|
||
cssVariablesResolver={cssVariablesResolver}
|
||
theme={theme}
|
||
>
|
||
<AuthProvider>
|
||
<AppRoutes />
|
||
</AuthProvider>
|
||
</MantineProvider>
|
||
</MemoryRouter>
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
describe("AppRoutes", () => {
|
||
it.each([
|
||
["/", "Вход"],
|
||
["/login", "Вход"],
|
||
["/register", "Регистрация"],
|
||
["/accounts", "Вход"],
|
||
["/accounts/demo", "Вход"],
|
||
["/accounts/demo/operations", "Вход"],
|
||
["/settings", "Вход"],
|
||
["/cards?month=2026-08", "Вход"],
|
||
["/unknown", "Страница не найдена"],
|
||
])("renders %s with its page title", async (path, title) => {
|
||
renderRoutes(path);
|
||
|
||
expect(
|
||
await screen.findByRole("heading", { level: 1, name: title }),
|
||
).toBeInTheDocument();
|
||
expect(document.title).toBe(`${title} — WealthLedger`);
|
||
});
|
||
|
||
it("redirects anonymous users from accounts", async () => {
|
||
renderRoutes("/accounts");
|
||
|
||
expect(
|
||
await screen.findByRole("heading", { level: 1, name: "Вход" }),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it.each([
|
||
{ endpoint: "login", path: "/accounts", title: "Вход" },
|
||
{ endpoint: "register", path: "/register", title: "Регистрация" },
|
||
{
|
||
endpoint: "login",
|
||
path: { pathname: "/login", state: { returnTo: "//example.com" } },
|
||
title: "Вход",
|
||
},
|
||
{ endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" },
|
||
{ endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" },
|
||
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
|
||
let credentials: unknown;
|
||
server.use(
|
||
http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => {
|
||
credentials = await request.json();
|
||
|
||
return HttpResponse.json({
|
||
accessToken: "login-token",
|
||
user: { email: "user@example.com", id: "user-1" },
|
||
});
|
||
}),
|
||
);
|
||
renderRoutes(path);
|
||
|
||
await screen.findByRole("heading", { level: 1, name: title });
|
||
fireEvent.change(screen.getByLabelText("Email"), {
|
||
target: { value: " User@Example.com " },
|
||
});
|
||
fireEvent.change(screen.getByLabelText("Пароль"), {
|
||
target: { value: "a secure password" },
|
||
});
|
||
|
||
if (endpoint === "register") {
|
||
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
|
||
target: { value: "a secure password" },
|
||
});
|
||
}
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: title }));
|
||
|
||
expect(
|
||
await screen.findByRole("heading", {
|
||
level: 2,
|
||
name: "Нет инвестиционных счетов",
|
||
}),
|
||
).toBeInTheDocument();
|
||
expect(document.title).toBe("Счета — WealthLedger");
|
||
expect(credentials).toEqual({
|
||
email: "user@example.com",
|
||
password: "a secure password",
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{ isFailure: false, isNewDestination: false },
|
||
{ isFailure: true, isNewDestination: false },
|
||
{ isFailure: true, isNewDestination: true },
|
||
])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => {
|
||
const { promise: pendingLogout, resolve: finishLogout }
|
||
= Promise.withResolvers<void>();
|
||
|
||
server.use(
|
||
http.post("*/api/v1/auth/refresh", () => HttpResponse.json({
|
||
accessToken: "test-token", user: { email: "user@example.com", id: "user-1" },
|
||
})),
|
||
http.post("*/api/v1/auth/logout", async () => {
|
||
await pendingLogout;
|
||
|
||
return isFailure
|
||
? HttpResponse.error()
|
||
: new HttpResponse(null, { status: 204 });
|
||
}),
|
||
);
|
||
renderRoutes("/settings", <Link to="/settings?source=manual">Открыть настройки</Link>);
|
||
fireEvent.click(await screen.findByRole("button", { name: "Выйти" }));
|
||
await screen.findByRole("heading", { level: 1, name: "Вход" });
|
||
if (isNewDestination) fireEvent.click(screen.getByRole("link", { name: "Открыть настройки" }));
|
||
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
|
||
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
|
||
finishLogout();
|
||
|
||
const destination = isNewDestination
|
||
? { level: 1, name: "Настройки" }
|
||
: { level: 2, name: "Нет инвестиционных счетов" };
|
||
|
||
expect(await screen.findByRole("heading", destination)).toBeInTheDocument();
|
||
expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger");
|
||
});
|
||
|
||
it("returns after login to cards with the selected month without T-Bank", async () => {
|
||
server.use(
|
||
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
|
||
http.post("*/api/v1/auth/login", () =>
|
||
HttpResponse.json({
|
||
accessToken: "test",
|
||
user: { email: "user@example.com", id: "user-1" },
|
||
}),
|
||
),
|
||
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
|
||
http.get("*/api/v1/bank-cards/summary", () =>
|
||
|
||
HttpResponse.json(
|
||
{
|
||
cardId: null,
|
||
categories: [],
|
||
expenses: { currency: "RUB", value: "0" },
|
||
month: "2026-08",
|
||
net: { currency: "RUB", value: "0" },
|
||
refunds: { currency: "RUB", value: "0" },
|
||
},
|
||
),
|
||
),
|
||
);
|
||
renderRoutes("/cards?month=2026-08");
|
||
await screen.findByRole("heading", { name: "Вход" });
|
||
fireEvent.change(screen.getByLabelText("Email"), {
|
||
target: { value: "user@example.com" },
|
||
});
|
||
fireEvent.change(screen.getByLabelText("Пароль"), {
|
||
target: { value: "a secure password" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
|
||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||
"01.08.2026 – 31.08.2026",
|
||
);
|
||
expect(document.title).toBe("Карты — WealthLedger");
|
||
});
|
||
|
||
it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => {
|
||
let isOutage = true;
|
||
server.use(
|
||
http.post("*/api/v1/auth/refresh", () =>
|
||
isOutage
|
||
? new HttpResponse(null, { status: 502 })
|
||
: HttpResponse.json({
|
||
accessToken: "test-token",
|
||
user: { email: "user@example.com", id: "user-1" },
|
||
}),
|
||
),
|
||
);
|
||
renderRoutes("/accounts");
|
||
|
||
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться");
|
||
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
|
||
isOutage = false;
|
||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||
|
||
expect(
|
||
await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||
server.use(
|
||
http.post("*/api/v1/auth/refresh", () =>
|
||
HttpResponse.json({
|
||
accessToken: "test-token",
|
||
user: { email: "user@example.com", id: "user-1" },
|
||
}),
|
||
),
|
||
);
|
||
renderRoutes("/accounts");
|
||
|
||
expect(
|
||
await screen.findByRole("heading", {
|
||
level: 2,
|
||
name: "Нет инвестиционных счетов",
|
||
}),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getAllByRole("navigation", { name: "Основная навигация" }),
|
||
).toHaveLength(2);
|
||
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||
});
|
||
});
|