fix: resolve frontend audit findings (#39)
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m27s

This commit was merged in pull request #39.
This commit is contained in:
2026-09-27 12:11:44 +03:00
50 changed files with 2017 additions and 215 deletions
+10 -7
View File
@@ -22,13 +22,7 @@
!packages/ui/vite.config.ts !packages/ui/vite.config.ts
!packages/ui/tsconfig.json !packages/ui/tsconfig.json
!packages/ui/src/ !packages/ui/src/
!packages/ui/src/theme.ts !packages/ui/src/**
!packages/ui/src/styles.css
!packages/ui/src/index.ts
!packages/ui/src/StateView.tsx
!packages/ui/src/LoadingSkeleton.tsx
!packages/ui/src/EntryCard.tsx
!packages/ui/src/EntryCard.module.css
!packages/icons/ !packages/icons/
!packages/icons/package.json !packages/icons/package.json
!packages/icons/tsconfig.json !packages/icons/tsconfig.json
@@ -40,3 +34,12 @@
!packages/icons/src/svg/AddIcon.tsx !packages/icons/src/svg/AddIcon.tsx
!packages/icons/src/svg/EditIcon.tsx !packages/icons/src/svg/EditIcon.tsx
!packages/icons/src/svg/DeleteIcon.tsx !packages/icons/src/svg/DeleteIcon.tsx
# Keep local dependencies, outputs and secrets out of allowed source trees.
**/node_modules
**/dist
**/coverage
**/storybook-static
**/.env*
**/*.pem
**/*.key
+11 -1
View File
@@ -1,5 +1,11 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{
name: "no-unresolved",
severity: "error",
from: {},
to: { couldNotResolve: true },
},
{ {
name: "packages-must-not-depend-on-app", name: "packages-must-not-depend-on-app",
severity: "error", severity: "error",
@@ -57,7 +63,11 @@ module.exports = {
options: { options: {
exclude: "^packages/[^/]+/dist/", exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" }, doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" }, tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
tsPreCompilationDeps: true, tsPreCompilationDeps: true,
}, },
}; };
+1 -1
View File
@@ -21,7 +21,7 @@
"openapi:check": "node scripts/check-openapi.mjs", "openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
"prepare": "husky", "prepare": "husky",
"preview": "vite preview", "preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
+1 -1
View File
@@ -131,7 +131,7 @@
}, },
"muted": { "muted": {
"color": "gray", "color": "gray",
"shade": 6, "shade": 7,
"description": "Вторичный текст; проверяйте контраст при выборе фона" "description": "Вторичный текст; проверяйте контраст при выборе фона"
}, },
"border": { "border": {
+11 -1
View File
@@ -4,7 +4,7 @@ import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
describe("EntryCard", () => { describe("EntryCard", () => {
it("keeps the fallback after an image fails", () => { it("keeps a failed image fallback until a new source is supplied", () => {
const props = { const props = {
title: "Пример", title: "Пример",
metadata: "Описание", metadata: "Описание",
@@ -31,5 +31,15 @@ describe("EntryCard", () => {
expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("У")).toBeInTheDocument(); expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument(); expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
rerender(
<MantineProvider>
<EntryCard {...props} imageSrc="/replacement-logo.png" />
</MantineProvider>,
);
expect(container.querySelector("img")).toHaveAttribute(
"src",
"/replacement-logo.png",
);
expect(screen.queryByText("У")).not.toBeInTheDocument();
}); });
}); });
+2 -4
View File
@@ -1,5 +1,5 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core"; import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react"; import type { FC, ReactNode } from "react";
import classes from "./EntryCard.module.css"; import classes from "./EntryCard.module.css";
type EntryCardProps = { type EntryCardProps = {
@@ -17,7 +17,6 @@ type EntryCardProps = {
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false);
return ( return (
<Paper <Paper
className={classes.row} className={classes.row}
@@ -35,8 +34,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
aria-hidden="true" aria-hidden="true"
radius="md" radius="md"
size={40} size={40}
src={imageFailed ? null : props.imageSrc} src={props.imageSrc}
onError={() => setImageFailed(true)}
> >
{props.icon ?? props.initial} {props.icon ?? props.initial}
</Avatar> </Avatar>
+3
View File
@@ -25,5 +25,8 @@ describe("design theme adapter", () => {
expect(Object.keys(variables)).toHaveLength( expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length, Object.keys(source.color.roles).length,
); );
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
variables["--wl-color-muted"],
);
}); });
}); });
+3 -1
View File
@@ -79,7 +79,9 @@ const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
getThemeColor(reference, resolvedTheme), getThemeColor(reference, resolvedTheme),
]), ]),
), ),
light: {}, light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
dark: {}, dark: {},
}); });
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { FC } from "react"; import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary"; import { RouteErrorBoundary } from "./RouteErrorBoundary";
@@ -52,6 +52,74 @@ function renderBoundary(path: string) {
} }
describe("RouteErrorBoundary", () => { describe("RouteErrorBoundary", () => {
it.each(["/accounts", "/login"])(
"recovers a transient failure through the fallback on %s itself",
async (path) => {
authenticated = path === "/accounts";
let failing = true;
const RecoverableRoute: FC = () => {
if (failing) throw new Error("temporary failure");
return <div>recovered route</div>;
};
render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteErrorBoundary>
<RecoverableRoute />
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
const action = screen.getByRole("link", { name: "Продолжить" });
failing = false;
fireEvent.click(action);
expect(await screen.findByText("recovered route")).toBeInTheDocument();
},
);
it("keeps a persistent failure recoverable without an automatic reset loop", () => {
authenticated = true;
renderBoundary("/accounts");
fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
expect(
screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
).toBeInTheDocument();
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Перезагрузить страницу" }),
).toHaveAttribute("href", "/accounts");
});
it("offers document navigation when React.lazy caches a rejected chunk", async () => {
authenticated = false;
const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
const Chunk = lazy(load);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/login"]}>
<RouteErrorBoundary>
<Suspense fallback={<div>loading chunk</div>}>
<Chunk />
</Suspense>
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
expect(load).toHaveBeenCalledTimes(1);
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let intercepted = true;
const preventNavigation = (event: MouseEvent) => {
intercepted = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload);
expect(intercepted).toBe(false);
});
it("offers login after a public route error without error details", () => { it("offers login after a public route error without error details", () => {
authenticated = false; authenticated = false;
renderBoundary("/login"); renderBoundary("/login");
@@ -51,17 +51,23 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
<Button component={Link} to={to}> <Button component={Link} to={to}>
{t("routeError.action")} {t("routeError.action")}
</Button> </Button>
<Button component="a" href={to} variant="default">
{t("routeError.reload")}
</Button>
</Stack> </Stack>
); );
}; };
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => { const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { pathname, search } = useLocation(); const { pathname, search, key } = useLocation();
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}> <Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children} {children}
</Boundary> </Boundary>
); );
@@ -16,7 +16,7 @@ function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
} }
function bankMonth(value: string | null) { function bankMonth(value: string | null) {
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value ? value
: bankToday().slice(0, ISO_MONTH_LENGTH); : bankToday().slice(0, ISO_MONTH_LENGTH);
} }
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from "vitest";
import { bankPeriod } from "./period";
afterEach(() => vi.useRealTimers());
it.each([
["0001-01", "0001-01-01", "0001-01-31"],
["0099-12", "0099-12-01", "0099-12-31"],
["2026-09", "2026-09-01", "2026-09-30"],
["2024-02", "2024-02-01", "2024-02-29"],
["0004-02", "0004-02-01", "0004-02-29"],
])("keeps the ISO year for legacy month %s", (month, from, to) => {
expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to });
});
it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({
from: "2026-09-01",
to: "2026-09-30",
});
}
});
+4 -2
View File
@@ -1,10 +1,12 @@
import dayjs from "dayjs";
import { z } from "zod"; import { z } from "zod";
import { bankMonth } from "./form-validation"; import { bankMonth } from "./form-validation";
const calendarDate = z.string().date(); const calendarDate = z.string().date();
const ISO_DATE_LENGTH = 10;
function bankPeriod(params: URLSearchParams) { function bankPeriod(params: URLSearchParams) {
const month = bankMonth(params.get("month")); const month = bankMonth(params.get("month"));
const monthEnd = new Date(`${month}-01T00:00:00Z`);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = params.get("from"); const from = params.get("from");
const to = params.get("to"); const to = params.get("to");
return from && return from &&
@@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) {
? { from, to } ? { from, to }
: { : {
from: `${month}-01`, from: `${month}-01`,
to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"), to: monthEnd.toISOString().slice(0, ISO_DATE_LENGTH),
}; };
} }
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager"; import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => { describe("CategoryManager", () => {
it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const categories = [
{ id: "a", name: "Первая", isDefault: false, version: 1 },
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
];
let reads = 0;
let release = () => {};
const reloaded = new Promise<void>((resolve) => {
release = resolve;
});
const patches: { id: unknown; body: unknown }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map((category) =>
category.id === "a" && reads > 1
? {
...category,
name: reads >= 3 ? "Первая обновлена" : category.name,
version: 2,
}
: category,
),
);
}),
http.patch(
"*/api/v1/bank-cards/categories/:id",
async ({ request, params }) => {
patches.push({ id: params.id, body: await request.json() });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(categories[1]);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await waitFor(() => expect(reads).toBe(3));
fireEvent.click(
screen.getAllByRole("button", { name: "Переименовать" })[1],
);
fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" },
});
await act(async () => {
release();
await reloaded;
});
await screen.findByText("Первая обновлена");
await waitFor(() =>
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText(/Название категории/)).toHaveValue(
"Новая вторая",
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() =>
expect(patches).toEqual([
{ id: "a", body: { name: "Первая", version: 1 } },
{ id: "b", body: { name: "Новая вторая", version: 4 } },
]),
);
});
it.each(["Отмена", "Сохранить"])(
"clears reloaded recovery after %s",
async (action) => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
let version = 1;
let writes = 0;
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "a", name: "Первая", isDefault: false, version },
]),
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2;
return ++writes === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json({});
}),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Переименовать" }),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await screen.findByRole("button", {
name: /Использовать актуальную версию/,
});
fireEvent.click(screen.getByRole("button", { name: action }));
await screen.findByRole("button", { name: "Добавить категорию" });
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument();
},
);
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => { it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "test", accessToken: () => "test",
@@ -8,7 +8,7 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { type FC, useState } from "react"; import { type FC, useRef, useState } from "react";
import { import {
Controller, Controller,
FormProvider, FormProvider,
@@ -60,6 +60,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
const categories = useBankCategories(); const categories = useBankCategories();
const mutation = useBankMutations(); const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>(); const [editing, setEditing] = useState<ExpenseCategory>();
const editSession = useRef(0);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>(); const [latest, setLatest] = useState<ExpenseCategory>();
const methods = useForm<CategoryValues>({ const methods = useForm<CategoryValues>({
@@ -114,7 +115,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
}); });
if (result) props.onCreated?.(result.id); if (result) props.onCreated?.(result.id);
} }
editSession.current++;
setEditing(undefined); setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" }); methods.reset({ name: "" });
} catch (failure) { } catch (failure) {
setError(failure); setError(failure);
@@ -138,6 +141,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button <Button
variant="subtle" variant="subtle"
onClick={() => { onClick={() => {
editSession.current++;
setEditing(category); setEditing(category);
methods.reset({ name: category.name }); methods.reset({ name: category.name });
setError(undefined); setError(undefined);
@@ -167,7 +171,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button <Button
variant="default" variant="default"
onClick={async () => { onClick={async () => {
const session = editSession.current;
const response = await categories.refetch(); const response = await categories.refetch();
if (session !== editSession.current) return;
if (response.error) setError(response.error); if (response.error) setError(response.error);
else else
setLatest( setLatest(
@@ -180,7 +186,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)} )}
</> </>
) : null} ) : null}
{latest && ( {latest && latest.id === editing?.id && (
<> <>
<Text>{t("bank.versionHint", { details: latest.name })}</Text> <Text>{t("bank.versionHint", { details: latest.name })}</Text>
<Button <Button
@@ -202,7 +208,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button <Button
variant="default" variant="default"
onClick={() => { onClick={() => {
editSession.current++;
setEditing(undefined); setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" }); methods.reset({ name: "" });
setError(undefined); setError(undefined);
}} }}
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api"; import { useStockQuote } from "@/entities/stock";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
@@ -16,6 +18,207 @@ function auth() {
}); });
} }
describe("page-owned screen synchronization", () => { describe("page-owned screen synchronization", () => {
it.each([false, true])(
"retries a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (string | null)[] = [];
let unavailable = !cached;
let posts = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return unavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { stateVersion: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return new HttpResponse(null, { status: 500 });
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
disabled,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
if (cached) {
await waitFor(() =>
expect(result.current.accounts.isSuccess).toBe(true),
);
unavailable = true;
await act(() => result.current.accounts.refetch());
}
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
unavailable = false;
await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.meta?.stateVersion).toBe(
cached ? "3" : "2",
);
expect(modes).toEqual(
cached ? [null, null, "observe"] : [null, "observe"],
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it.each([
["logout", true],
["connection", true],
["session", false],
["connection", false],
["access", false],
] as const)(
"discards recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
let recovering = false;
let changed = false;
server.use(
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
if (boundary === "connection" && changed && !request.signal.aborted)
return HttpResponse.json(resource([], { stateVersion: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return changed
? HttpResponse.json(resource([], { stateVersion: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
recovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "old" }));
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const published: unknown[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
let recovery!: Promise<void>;
act(() => {
recovery = result.current.sync.retry();
});
if (pending) await waitFor(() => expect(recovering).toBe(true));
await act(async () => {
changed = true;
if (boundary === "logout") {
configureAuthClient(null);
queryClient.clear();
} else if (boundary === "session") {
auth();
queryClient.clear();
} else if (boundary === "access") {
invalidateResourceAccess(queryClient, "brokerage", {
code: "FORBIDDEN",
});
} else
commitTbankConnectionBoundary(queryClient, {
connected: true,
connectedAt: "2026-09-27T00:00:00Z",
});
release();
await recovery;
});
if (boundary === "connection")
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "old" }),
}),
);
unsubscribe();
},
);
it.each([false, true])(
"recovers an initial GET failure after forced sync without metadata (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (string | null)[] = [];
let polls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ id: "recover" }, { status: 202 }),
),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
state: "succeeded",
resources: [resource([]).meta],
});
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.meta).toBeUndefined();
await act(() => result.current.sync.refresh());
if (retryStatus) {
await waitFor(() =>
expect(result.current.sync.transportError).toBe(true),
);
await act(() => result.current.sync.retry());
}
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
expect(modes).toEqual([null, "observe"]);
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it("posts only its scope once and observes completion without importing again", async () => { it("posts only its scope once and observes completion without importing again", async () => {
auth(); auth();
const modes: (string | null)[] = []; const modes: (string | null)[] = [];
@@ -1,4 +1,4 @@
import { useQueryClient } from "@tanstack/react-query"; import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
apiErrorCode, apiErrorCode,
@@ -12,7 +12,12 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta = type ResourceMeta =
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = { type ScreenResource = {
queryKey: QueryKey;
meta?: ResourceMeta; meta?: ResourceMeta;
transport: {
status: "pending" | "success" | "error";
fetchStatus: "idle" | "fetching" | "paused";
};
observe: (version?: string) => Promise<unknown>; observe: (version?: string) => Promise<unknown>;
}; };
const STATUS_INTERVAL_MS = 1000; const STATUS_INTERVAL_MS = 1000;
@@ -81,7 +86,21 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
latest.set(dependency.resourceKey, dependency); latest.set(dependency.resourceKey, dependency);
} }
for (const resource of resources) { for (const resource of resources) {
if (!resource.meta) continue; if (!resource.meta) {
if (resource.transport.fetchStatus !== "idle") continue;
const completed = statuses.find(
(status) =>
requested?.scope === scopeKey &&
status.data?.id === requested.id &&
status.data.state !== "running",
)?.data;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (completed && observed.current.get(key) !== completed.id) {
observed.current.set(key, completed.id);
void resource.observe().catch(() => undefined);
}
continue;
}
const changed = [ const changed = [
resource.meta, resource.meta,
...(resource.meta.dependencies ?? []), ...(resource.meta.dependencies ?? []),
@@ -103,7 +122,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
); );
void resource.observe(own?.stateVersion).catch(() => undefined); void resource.observe(own?.stateVersion).catch(() => undefined);
} }
}, [client, statuses, resources]); }, [client, statuses, resources, requested, scopeKey]);
const refreshing = const refreshing =
(currentMutation && mutation.isPending) || (currentMutation && mutation.isPending) ||
statuses.some( statuses.some(
@@ -133,8 +152,23 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
if (requestInFlight.current === scopeKey) requestInFlight.current = null; if (requestInFlight.current === scopeKey) requestInFlight.current = null;
} }
}; };
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
(resource) => resource.transport.status === "error",
);
const failedStatuses = statuses.filter((status) => status.isError);
if (failed.length || failedStatuses.length) {
await Promise.allSettled([
...failed.map((resource) => resource.observe()),
...failedStatuses.map((status) => status.refetch()),
]);
} else await refresh();
};
return { return {
refresh, refresh,
retry,
refreshing, refreshing,
transportError: transportError:
(currentMutation && mutation.isError) || (currentMutation && mutation.isError) ||
@@ -1,11 +1,18 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
import { TbankConnectionSection } from "./TbankConnectionSection"; import { TbankConnectionSection } from "./TbankConnectionSection";
const brokerageAccountsQueryKey = apiQueryClient.queryOptions( const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
@@ -78,6 +85,74 @@ function seededClient() {
} }
describe("TbankConnectionSection", () => { describe("TbankConnectionSection", () => {
it("preserves cached status and a draft entered during a delayed failed refetch", async () => {
const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" };
let finishRefetch: (() => void) | undefined;
const response = new Promise<void>((resolve) => {
finishRefetch = resolve;
});
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", async () => {
reads += 1;
if (reads === 1) {
await response;
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}
return HttpResponse.json(status);
}),
);
const client = seededClient();
client.setQueryData(tbankConnectionQueryOptions().queryKey, status);
renderSection(client);
await waitFor(() => expect(reads).toBe(1));
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.();
expect(
await screen.findByText(/Не удалось обновить статус подключения/),
).toBeVisible();
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
expect(client.getQueryData(tbankConnectionQueryOptions().queryKey)).toEqual(
status,
);
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2));
await waitFor(() =>
expect(
screen.queryByText(/Не удалось обновить статус подключения/),
).not.toBeInTheDocument(),
);
expect(token).toHaveValue("replacement-draft");
});
it("shows a cold-start error without a form and recovers by retrying status", async () => {
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", () => {
reads += 1;
return reads === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ connected: false, connectedAt: null });
}),
);
renderSection(createTestQueryClient());
expect(
await screen.findByText(/Не удалось загрузить статус подключения/),
).toBeVisible();
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
expect(reads).toBe(2);
});
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => { it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient(); const client = seededClient();
renderSection(client); renderSection(client);
@@ -225,7 +300,12 @@ describe("TbankConnectionSection", () => {
}); });
}); });
it("preserves connected status and financial cache when deletion fails", async () => { it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
let deletes = 0;
let finishDelete: (() => void) | undefined;
const response = new Promise<void>((resolve) => {
finishDelete = resolve;
});
server.use( server.use(
http.get("*/api/v1/tbank-connection", () => http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({ HttpResponse.json({
@@ -233,9 +313,13 @@ describe("TbankConnectionSection", () => {
connectedAt: "2026-08-14T00:00:00Z", connectedAt: "2026-08-14T00:00:00Z",
}), }),
), ),
http.delete("*/api/v1/tbank-connection", () => http.delete("*/api/v1/tbank-connection", async () => {
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), deletes += 1;
), if (deletes === 1) await response;
return deletes === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: new HttpResponse(null, { status: 204 });
}),
); );
const client = seededClient(); const client = seededClient();
renderSection(client); renderSection(client);
@@ -243,9 +327,30 @@ describe("TbankConnectionSection", () => {
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }), await screen.findByRole("button", { name: "Удалить подключение" }),
); );
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ })); const dialog = await screen.findByRole("dialog", {
name: "Удалить подключение?",
});
await waitFor(() =>
expect(dialog.contains(document.activeElement)).toBe(true),
);
const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ });
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(confirm).toBeDisabled());
expect(
within(dialog).getByRole("button", { name: "Закрыть" }),
).toBeDisabled();
// JSDOM does not move focus when a focused button becomes disabled.
document.body.tabIndex = -1;
document.body.focus();
document.body.removeAttribute("tabindex");
expect(document.activeElement).toBe(document.body);
finishDelete?.();
await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз."); expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Не удалось выполнить запрос. Попробуйте ещё раз.",
);
expect(confirm).toHaveFocus();
expect( expect(
screen.getByRole("button", { name: "Заменить token" }), screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -253,5 +358,12 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(portfolioQueryKey)).toEqual({ expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection", source: "previous-connection",
}); });
fireEvent.click(within(dialog).getByRole("button", { name: /^Удалить$/ }));
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(screen.getByText("Не подключено")).toBeVisible();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(deletes).toBe(2);
}); });
}); });
@@ -11,7 +11,7 @@ const TbankConnectionSection: FC = () => {
if (viewModel.state === "loading") return <TbankConnectionLoading />; if (viewModel.state === "loading") return <TbankConnectionLoading />;
if (viewModel.state === "error") if (viewModel.state === "error")
return <TbankConnectionError retry={viewModel.retry} />; return <TbankConnectionError retry={viewModel.retry} />;
return <TbankConnectionSectionLoaded status={viewModel.status} />; return <TbankConnectionSectionLoaded {...viewModel} />;
}; };
export { TbankConnectionSection }; export { TbankConnectionSection };
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({ const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(), ...tbankConnectionQueryOptions(),
}); });
if (status.isPending) return { state: "loading" as const }; if (!status.data) {
if (status.isError) if (status.isPending) return { state: "loading" as const };
return { state: "error" as const, retry: () => status.refetch() }; return { state: "error" as const, retry: () => status.refetch() };
return { state: "loaded" as const, status: status.data }; }
return {
state: "loaded" as const,
status: status.data,
refreshing: status.isFetching,
refreshError: status.isError,
retry: () => status.refetch(),
};
} }
export { useTbankConnectionViewModel }; export { useTbankConnectionViewModel };
@@ -8,7 +8,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react"; import { useEffect, useRef, 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 { apiErrorCode, apiQueryClient } from "@/shared/api"; import { apiErrorCode, apiQueryClient } from "@/shared/api";
@@ -19,6 +19,9 @@ import {
type TbankConnectionSectionLoadedProps = { type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus; status: TbankConnectionStatus;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
}; };
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = ( const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
@@ -28,6 +31,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false); const [deleteOpened, setDeleteOpened] = useState(false);
const deleteButton = useRef<HTMLButtonElement>(null);
const [requestError, setRequestError] = useState<string | null>(null); const [requestError, setRequestError] = useState<string | null>(null);
const { const {
register, register,
@@ -53,11 +57,13 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
}); });
setDeleteOpened(false); setDeleteOpened(false);
}, },
onError: () => setRequestError("REQUEST_ERROR"),
}, },
); );
const pending = update.isPending || remove.isPending; const pending = update.isPending || remove.isPending;
const connected = status.connected; const connected = status.connected;
useEffect(() => {
if (deleteOpened && remove.isError) deleteButton.current?.focus();
}, [deleteOpened, remove.isError]);
return ( return (
<Stack mt="xl"> <Stack mt="xl">
@@ -74,6 +80,25 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("tbank.hint")} {t("tbank.hint")}
</Text> </Text>
{props.refreshing && (
<Text role="status" size="sm" c="dimmed">
{t("tbank.refreshing")}
</Text>
)}
{props.refreshError && (
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("tbank.refreshError")}</Text>
<Button
variant="default"
onClick={props.retry}
disabled={props.refreshing || pending}
>
{t("tbank.retry")}
</Button>
</Stack>
</Alert>
)}
<form <form
onSubmit={handleSubmit(({ token }) => { onSubmit={handleSubmit(({ token }) => {
setRequestError(null); setRequestError(null);
@@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Button <Button
variant="outline" variant="outline"
color="red" color="red"
onClick={() => setDeleteOpened(true)} onClick={() => {
remove.reset();
setDeleteOpened(true);
}}
> >
{t("tbank.delete")} {t("tbank.delete")}
</Button> </Button>
@@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
title={t("tbank.deleteTitle")} title={t("tbank.deleteTitle")}
closeOnClickOutside={!pending} closeOnClickOutside={!pending}
closeOnEscape={!pending} closeOnEscape={!pending}
closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }}
> >
<Stack> <Stack>
<Text>{t("tbank.deleteText")}</Text> <Text>{t("tbank.deleteText")}</Text>
{remove.isError && (
<Alert color="red">{t("tbank.requestError")}</Alert>
)}
<Button <Button
ref={deleteButton}
color="red" color="red"
loading={remove.isPending} loading={remove.isPending}
disabled={pending} disabled={pending}
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { import {
act,
fireEvent, fireEvent,
render, render,
renderHook, renderHook,
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
<button type="button" onClick={() => navigate(-1)}> <button type="button" onClick={() => navigate(-1)}>
History back History back
</button> </button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</> </>
); );
}; };
describe("Upcoming event date filters", () => { describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it.each([
"from=2099-02-01&to=2099-02-28",
"from=2099-02-01",
"to=2099-02-28",
])(
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
act(() => {
if (result.current.state === "error") result.current.retry();
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
]),
);
},
);
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01",
),
);
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
},
);
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s", "applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => { async (accountId) => {
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({ vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({ useScreenSync: () => ({
refresh, refresh,
retry: refresh,
refreshing: false, refreshing: false,
sourceError: false, sourceError: false,
transportError: false, transportError: false,
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(), refetch: vi.fn(),
} as never); } as never);
renderPage(); renderPage();
expect(query).toHaveBeenCalledWith(undefined, {}); expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy", "aria-busy",
"true", "true",
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId); const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />; if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error") if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />; return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />; return <AccountEventsPageLoaded {...viewModel} />;
}; };
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router"; import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel = type AccountEventsPageViewModel =
| { state: "loading" } | { state: "loading" }
| { state: "error"; retry: () => void } | { state: "error"; retry: () => void; onReset: () => void }
| { | {
state: "loaded"; state: "loaded";
events: BrokerageEvent[]; events: BrokerageEvent[];
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
scopeId?: string, scopeId?: string,
): AccountEventsPageViewModel { ): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams(); const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId; const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from"); const urlFrom = searchParams.get("from");
const appliedTo = searchParams.get("to"); const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom); const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo); const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => { useEffect(() => {
setDraftFrom(appliedFrom); setDraftFrom(appliedFrom);
setDraftTo(appliedTo); setDraftTo(appliedTo);
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
}, },
[events], [events],
); );
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
};
if (events.isLoading) return { state: "loading" }; if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data) if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return { return {
state: "error", state: "error",
onReset,
retry: () => { retry: () => {
void sync.refresh(); void sync.retry();
}, },
}; };
if (!events.data) return { state: "loading" }; if (!events.data) return { state: "loading" };
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
refreshing: events.isFetching || sync.refreshing, refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError, refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => { retry: () => {
void sync.refresh(); void sync.retry();
}, },
draftFrom, draftFrom,
draftTo, draftTo,
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) && (draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo), isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom, onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo, onDraftToChange: setDraftTo,
onApply: () => onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({ setSearchParams({
...(draftFrom ? { from: draftFrom } : {}), ...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}), ...(draftTo ? { to: draftTo } : {}),
}), });
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
}, },
onReset,
}; };
} }
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel }; export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -1,9 +1,9 @@
import { Button } from "@mantine/core"; import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void }; type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => { const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
error error
title={t("events.errorTitle")} title={t("events.errorTitle")}
text={t("events.errorText")} text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>} action={
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/> />
); );
}; };
@@ -6,6 +6,7 @@ import {
act, act,
fireEvent, fireEvent,
render, render,
renderHook,
screen, screen,
waitFor, waitFor,
within, within,
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { isDate } from "../../model/period"; import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage"; import { AccountIncomePage } from "./AccountIncomePage";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm"; import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; import { IncomePayments } from "./components/IncomePayments.view";
const analytics = { const analytics = {
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
} }
describe("AccountIncomePage", () => { describe("AccountIncomePage", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
renderPage("from=2024-02-29&to=2024-03-01");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "income",
accountId: "account-1",
from: "2024-02-29",
to: "2024-03-01",
},
]),
);
});
it("requests a multi-year period from the URL without resetting it", async () => { it("requests a multi-year period from the URL without resetting it", async () => {
const requested: string[] = []; const requested: string[] = [];
server.use( server.use(
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
}); });
it("shows the unavailable year state without falling back to the previous period", async () => { it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json( HttpResponse.json(resource(analytics, { dependencies })),
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
],
}),
),
), ),
); );
renderPage(); renderPage();
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
}); });
}); });
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("shows selected income before comparison coverage without inventing a zero baseline", async () => { it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
(role) => ({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
);
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json( HttpResponse.json(
resource({ ...analytics, previous: null, yearComparison: null }), resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
), ),
), ),
); );
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
}); });
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => { it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
},
{ ...resource(analytics).meta, role: "selected" as const },
];
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json( HttpResponse.json(resource(analytics, { dependencies })),
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
],
}),
),
), ),
); );
renderPage(); renderPage();
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice = type ComparisonNotice =
| "comparisonPending" | "comparisonPending"
| "comparisonFailed" | "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable"; | "yearUnavailable";
type IncomeDisplayModel = { type IncomeDisplayModel = {
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
scopeId?: string, scopeId?: string,
): AccountIncomePageViewModel => { ): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams(); const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId; const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo( const requested = useMemo(
() => ({ () => ({
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
}; };
if (analytics.isLoading || !analytics.data) { if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed") if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period }; return { state: "error", retry: sync.retry, period };
return { state: "loading" }; return { state: "loading" };
} }
const display = toIncomeDisplayModel(analytics.data, analytics.meta); const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
partial: analytics.meta?.availability !== "complete", partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing, refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError, refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh, retry: sync.retry,
form, form,
onSubmit, onSubmit,
onReset, onReset,
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null; if (instrument.includedCount !== instrument.receiptCount) return null;
} }
// The income endpoint returns selected, previous and year dependencies in order. const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const [, previous, year] = meta?.dependencies ?? []; const year = meta?.dependencies?.find(({ role }) => role === "year");
return { return {
entries, entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous), previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison), comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous), previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year), yearNotice: comparisonNotice(year, "yearUnavailable"),
}; };
} }
@@ -251,9 +252,10 @@ function comparisonNotice(
dependency: dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined, | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice { ): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable"; if (!dependency || dependency.unavailableReason) return unavailable;
return dependency?.sync.state === "failed" return dependency.sync.state === "failed"
? "comparisonFailed" ? "comparisonFailed"
: "comparisonPending"; : "comparisonPending";
} }
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router"; import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
type BrokerageOperation, type BrokerageOperation,
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
commission: { currency: "RUB", value: "1" }, commission: { currency: "RUB", value: "1" },
}; };
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
function configureTestAuth() { function configureTestAuth() {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
}); });
describe("AccountOperationsPage", () => { describe("AccountOperationsPage", () => {
it.each(["account-1", "all"])(
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
async (accountId) => {
configureTestAuth();
const searches: URLSearchParams[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
searches.push(new URL(request.url).searchParams);
return HttpResponse.json(
resource({
items: [operation],
nextCursor: "next",
hasNext: true,
}),
);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2026-02-28&categories=trade",
"?from=2026-02-30&categories=trade",
"?from=2026-03-01&to=2026-02-28&categories=trade",
"?from=2026-02-01&categories=trade",
"?to=2026-02-28&categories=trade",
].map((search) => `/accounts/${accountId}/operations${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
expect(searches[0].get("from")).toBeNull();
expect(searches[0].get("to")).toBeNull();
for (const expected of [
"?categories=trade",
"?categories=trade",
"?from=2026-02-01&categories=trade",
]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
expect(searches.at(-1)?.get("to")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
await screen.findByLabelText("Период");
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
await screen.findByLabelText("Период");
await waitFor(() =>
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
);
expect(searches.at(-1)?.get("from")).toBeNull();
expect(searches.at(-1)?.get("cursor")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
);
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(searches.map((query) => query.get("from"))).not.toContain(
"badT00:00:00.000Z",
);
expect(
searches.every((query) => query.get("categories") === "trade"),
).toBe(true);
},
);
it.each(["account-1", "all"])(
"allows resetting filters after a request failure for %s",
async (accountId) => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
resource({ items: [], hasNext: false, nextCursor: null }),
),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/operations?from=2026-02-01`,
]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it("resets cursor pagination when filters change", async () => { it("resets cursor pagination when filters change", async () => {
configureTestAuth(); configureTestAuth();
const searches: string[] = []; const searches: string[] = [];
@@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => {
}); });
}); });
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
requested.push(Object.fromEntries(new URL(request.url).searchParams));
return HttpResponse.json(
resource(null, {
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual([
{
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: "trade",
limit: "100",
},
]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "operations",
accountId: "account-1",
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: ["trade"],
limit: 100,
},
]),
);
});
it("does not label incomplete empty operations as a confirmed empty period", async () => { it("does not label incomplete empty operations as a confirmed empty period", async () => {
configureTestAuth(); configureTestAuth();
server.use( server.use(
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router"; import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { import {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel = type AccountOperationsPageViewModel =
| { state: "loading" } | { state: "loading" }
| { state: "error"; retry: () => void } | { state: "error"; retry: () => void; onReset: () => void }
| { | {
state: "loaded"; state: "loaded";
sourceError?: boolean; sourceError?: boolean;
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
const accountId = scopeId ?? routeAccountId; const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from"); const urlFrom = searchParams.get("from");
const appliedTo = searchParams.get("to"); const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const appliedCategories = operationCategories.filter((category) => const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category), (searchParams.get("categories") ?? "").split(",").includes(category),
); );
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
useState<OperationCategory[]>(appliedCategories); useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([ const scopeKey = JSON.stringify([
accountId, accountId,
appliedFrom, urlFrom,
appliedTo, urlTo,
appliedCategories, appliedCategories,
]); ]);
const [pagination, setPagination] = useState<{ const [pagination, setPagination] = useState<{
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1); const cursor = cursors.at(-1);
useEffect(() => { useEffect(() => {
setDraftFrom(searchParams.get("from")); setPagination((current) =>
setDraftTo(searchParams.get("to")); current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
);
}, [scopeKey]);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories( setDraftCategories(
operationCategories.filter((category) => operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category), (searchParams.get("categories") ?? "").split(",").includes(category),
), ),
); );
}, [searchParams]); }, [appliedFrom, appliedTo, searchParams]);
const firstPageQuery = { const firstPageQuery = {
from: dateStart(appliedFrom), from: dateStart(appliedFrom),
to: dateEnd(appliedTo), to: dateEnd(appliedTo),
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
exact: true, exact: true,
}); });
resetCursors(); resetCursors();
} else void sync.refresh(); } else void sync.retry();
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
}; };
if (operations.isLoading) return { state: "loading" }; if (operations.isLoading) return { state: "loading" };
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
(!operations.data || (!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
) )
return { state: "error", retry }; return { state: "error", retry, onReset };
if (!operations.data) return { state: "loading" }; if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
items: operations.data.items, items: operations.data.items,
hasNext: Boolean(operations.data.hasNext && nextCursor), hasNext: Boolean(operations.data.hasNext && nextCursor),
hasPrevious: Boolean(cursors.length), hasPrevious: Boolean(cursors.length),
canApply: isDirty, canApply: isDirty && isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom, onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo, onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories, onDraftCategoriesChange: setDraftCategories,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({ setSearchParams({
...(draftFrom ? { from: draftFrom } : {}), ...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}), ...(draftTo ? { to: draftTo } : {}),
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
}); });
resetCursors(); resetCursors();
}, },
onReset: () => { onReset,
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
},
onNext: () => { onNext: () => {
if (nextCursor) if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] }); setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
@@ -170,6 +188,16 @@ const useAccountOperationsPageVm = (
}; };
}; };
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
function dateStart(value: string | null) { function dateStart(value: string | null) {
return value ? `${value}T00:00:00.000Z` : undefined; return value ? `${value}T00:00:00.000Z` : undefined;
} }
@@ -1,4 +1,4 @@
import { Button } from "@mantine/core"; import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
error error
title={t("ledger.errorTitle")} title={t("ledger.errorTitle")}
text={t("ledger.errorText")} text={t("ledger.errorText")}
action={<Button onClick={retry}>{t("ledger.retry")}</Button>} action={
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/> />
); );
}; };
@@ -131,7 +131,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
return { return {
state: "error" as const, state: "error" as const,
code: apiErrorCode(portfolio.error), code: apiErrorCode(portfolio.error),
retry: sync.refresh, retry: sync.retry,
}; };
return { state: "loading" as const }; return { state: "loading" as const };
} }
@@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
refreshing: portfolio.isFetching || sync.refreshing, refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError, sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError, refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.refresh, retry: sync.retry,
}; };
if ( if (
portfolio.meta?.availability === "complete" && portfolio.meta?.availability === "complete" &&
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) {
} }
describe("AccountsPage", () => { describe("AccountsPage", () => {
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findByText(account.name)).toBeVisible();
expect(modes).toEqual([null, "observe"]);
});
it.each([ it.each([
["loading", AccountsPageLoading, "status"], ["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined], ["empty", AccountsPageEmpty, undefined],
@@ -143,8 +158,10 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click(); screen.getByRole("button", { name: "Повторить" }).click();
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }])); await waitFor(() => expect(requestCount).toBe(3));
expect(requestCount).toBe(2); await waitFor(() => expect(warning).not.toBeInTheDocument());
expect(screen.getByText(account.name)).toBeVisible();
expect(requests).toEqual([]);
}); });
it("keeps a cached empty account list visible after a refresh error", async () => { it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -18,7 +18,7 @@ function useAccountsPageViewModel() {
return { return {
state: "error" as const, state: "error" as const,
code: apiErrorCode(accounts.error), code: apiErrorCode(accounts.error),
retry: sync.refresh, retry: sync.retry,
}; };
} }
if (!accounts.data?.length) if (!accounts.data?.length)
@@ -28,7 +28,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing, refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError, sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError, refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh, retry: sync.retry,
}; };
return { return {
state: "loaded" as const, state: "loaded" as const,
@@ -37,7 +37,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing, refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError, sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError, refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh, retry: sync.retry,
}; };
} }
+8 -8
View File
@@ -82,7 +82,7 @@ function useBondPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true, exact: true,
}); });
} else void sync.refresh(); } else void sync.retry();
}; };
const chartData = const chartData =
candles.data?.candles.map((candle) => ({ candles.data?.candles.map((candle) => ({
@@ -99,26 +99,26 @@ function useBondPageVm() {
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}` ? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null, : null,
bond: bond.data, bond: bond.data,
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()), bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
code: apiErrorCode(bond.error), code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName), logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data, metrics: metrics.data,
metricsState: section(metrics, sync.refresh), metricsState: section(metrics, sync.retry),
quote: quote.data, quote: quote.data,
quoteState: section(quote, sync.refresh), quoteState: section(quote, sync.retry),
candles: candles.data, candles: candles.data,
chartData, chartData,
priceRows: chartData.toReversed(), priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh), candlesState: section(candles, sync.retry),
period, period,
setPeriod: (value: string) => setParam("period", value), setPeriod: (value: string) => setParam("period", value),
averageLine: null, averageLine: null,
hasIncompleteCandle: hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false, candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data, position: position.data,
positionState: section(position, sync.refresh), positionState: section(position, sync.retry),
coupons: coupons.data, coupons: coupons.data,
couponsState: section(coupons, sync.refresh), couponsState: section(coupons, sync.retry),
upcoming: upcoming:
coupons.data?.coupons coupons.data?.coupons
.filter((event) => event.timing === "upcoming") .filter((event) => event.timing === "upcoming")
@@ -137,7 +137,7 @@ function useBondPageVm() {
) ?? [], ) ?? [],
operations: operations.data, operations: operations.data,
operationsState: { operationsState: {
...section(operations, sync.refresh), ...section(operations, sync.retry),
retry: retryOperations, retry: retryOperations,
}, },
eventRows: eventRows:
+1 -1
View File
@@ -28,7 +28,7 @@ function useBondsPageVm() {
partial: result.meta?.availability === "partial", partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error), code: apiErrorCode(result.error),
retry: () => { retry: () => {
void sync.refresh(); void sync.retry();
}, },
ready: debounced.length >= 2 && !waiting && Boolean(result.data), ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items: items:
+24 -1
View File
@@ -46,6 +46,7 @@ const operation = {
}; };
function setup( function setup(
options: { options: {
month?: string;
missing?: boolean; missing?: boolean;
summaryError?: boolean; summaryError?: boolean;
cardsError?: boolean; cardsError?: boolean;
@@ -101,7 +102,9 @@ function setup(
render( render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<MantineProvider env="test"> <MantineProvider env="test">
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}> <MemoryRouter
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
>
<Routes> <Routes>
<Route path="/cards/:cardId" element={<CardPage />} /> <Route path="/cards/:cardId" element={<CardPage />} />
</Routes> </Routes>
@@ -112,6 +115,26 @@ function setup(
return { requests, summaryRequests, client }; return { requests, summaryRequests, client };
} }
describe("CardPage", () => { describe("CardPage", () => {
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
["2026-09", "2026-09-30"],
["2024-02", "2024-02-29"],
])(
"sends the same legacy month %s range to history and summary",
async (month, to) => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
expect(query.get("to")).toBe(to);
expect(query.has("month")).toBe(false);
}
},
);
it("shares date range with history, pages the journal and keeps current balance", async () => { it("shares date range with history, pages the journal and keeps current balance", async () => {
const { requests, summaryRequests } = setup(); const { requests, summaryRequests } = setup();
await screen.findByRole("heading", { name: "Повседневная" }); await screen.findByRole("heading", { name: "Повседневная" });
@@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StockPage } from "./StockPage"; import { StockPage } from "./StockPage";
import type { StockPageVm } from "./StockPage.vm";
const LocationProbe = () => { const LocationProbe = () => {
const location = useLocation(); const location = useLocation();
@@ -72,6 +73,7 @@ function setup(
path?: string; path?: string;
slug?: string; slug?: string;
ambiguousTicker?: boolean; ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {}, } = {},
) { ) {
const requests: URL[] = []; const requests: URL[] = [];
@@ -118,7 +120,7 @@ function setup(
: HttpResponse.json( : HttpResponse.json(
resource({ resource({
description: "Описание компании", description: "Описание компании",
metrics: [ metrics: options.metrics ?? [
{ {
key: "peRatioTtm", key: "peRatioTtm",
value: 5.6, value: 5.6,
@@ -215,6 +217,63 @@ function setup(
}; };
} }
describe("stock page", () => { describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup({
metrics: [
{
key: "marketCapitalization",
value: 1234567.89,
unit: "money",
currency: null,
period: null,
},
{
key: "revenueTtm",
value: 1234567.89,
unit: "money",
currency: "RUB",
period: "TTM",
},
{
key: "ebitdaTtm",
value: 1234567.89,
unit: "money",
currency: "USD",
period: "TTM",
},
{
key: "netIncomeTtm",
value: 1234567.89,
unit: "money",
currency: "CAD",
period: "TTM",
},
{
key: "peRatioTtm",
value: 5.678,
unit: "ratio",
currency: null,
period: "TTM",
},
{
key: "roe",
value: 12.3456,
unit: "percent",
currency: null,
period: null,
},
],
});
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
"1,23 млн USD",
"1,23 млн CAD",
"5,68 ×",
"12,35 %",
])
expect(screen.getByText(value)).toBeVisible();
});
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => { it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" }); const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");
@@ -290,6 +349,31 @@ describe("stock page", () => {
), ),
).toHaveAttribute("aria-live", "polite"); ).toHaveAttribute("aria-live", "polite");
}); });
it("recovers an initially missing company section without reloading successful sections", async () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
}),
);
}),
);
const previousRequests = requests.length;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
expect(modes).toEqual(["observe"]);
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => { it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup(); const { requests } = setup();
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");
@@ -190,13 +190,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
metric.unit === "money" ? "compact" : "standard", metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "} }).format(metric.value)}{" "}
{metric.unit === "money" {metric.unit === "money"
? new Intl.NumberFormat("ru-RU", { ? metric.currency === "RUB"
style: "currency", ? "₽"
currency: metric.currency ?? "RUB", : (metric.currency ?? t("stock.currencyUnknown"))
currencyDisplay: "narrowSymbol",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value
: metric.unit === "percent" : metric.unit === "percent"
? "%" ? "%"
: "×"} : "×"}
+8 -8
View File
@@ -82,7 +82,7 @@ function useStockPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true, exact: true,
}); });
} else void sync.refresh(); } else void sync.retry();
}; };
const chartData = const chartData =
candles.data?.candles.map((candle) => ({ candles.data?.candles.map((candle) => ({
@@ -105,15 +105,15 @@ function useStockPageVm() {
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}` ? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null, : null,
stock: stock.data, stock: stock.data,
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()), stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
code: apiErrorCode(stock.error), code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName), logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data, quote: quote.data,
quoteState: section(quote, sync.refresh), quoteState: section(quote, sync.retry),
candles: candles.data, candles: candles.data,
chartData, chartData,
priceRows: chartData.toReversed(), priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh), candlesState: section(candles, sync.retry),
period, period,
setPeriod: (value: string) => setParam("period", value), setPeriod: (value: string) => setParam("period", value),
averageLine: averageLine:
@@ -121,11 +121,11 @@ function useStockPageVm() {
hasIncompleteCandle: hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false, candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data, position: position.data,
positionState: section(position, sync.refresh), positionState: section(position, sync.retry),
company: companyData, company: companyData,
companyState: section(company, sync.refresh), companyState: section(company, sync.retry),
dividends: dividends.data, dividends: dividends.data,
dividendsState: section(dividends, sync.refresh), dividendsState: section(dividends, sync.retry),
upcoming: upcoming:
dividends.data?.dividends dividends.data?.dividends
.filter((event) => event.timing === "upcoming") .filter((event) => event.timing === "upcoming")
@@ -144,7 +144,7 @@ function useStockPageVm() {
) ?? [], ) ?? [],
operations: operations.data, operations: operations.data,
operationsState: { operationsState: {
...section(operations, sync.refresh), ...section(operations, sync.retry),
retry: retryOperations, retry: retryOperations,
}, },
eventRows: eventRows:
@@ -28,7 +28,7 @@ function useStocksPageVm() {
partial: result.meta?.availability === "partial", partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error), code: apiErrorCode(result.error),
retry: () => { retry: () => {
void sync.refresh(); void sync.retry();
}, },
ready: debounced.length >= 2 && !waiting && Boolean(result.data), ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items: items:
+96 -15
View File
@@ -1124,9 +1124,41 @@ export interface components {
instruments: components["schemas"]["IncomeInstrumentDto"][]; instruments: components["schemas"]["IncomeInstrumentDto"][];
entries: components["schemas"]["IncomeAnalyticsEntryDto"][]; entries: components["schemas"]["IncomeAnalyticsEntryDto"][];
}; };
IncomeDependencyDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
/** @enum {string} */
role: "selected" | "previous" | "year";
};
IncomeAnalyticsMetaDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
dependencies: components["schemas"]["IncomeDependencyDto"][];
};
IncomeAnalyticsResourceDto: { IncomeAnalyticsResourceDto: {
data: components["schemas"]["IncomeAnalyticsDto"] | null; data: components["schemas"]["IncomeAnalyticsDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["IncomeAnalyticsMetaDto"];
}; };
UpcomingEventDto: { UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"]; account?: components["schemas"]["AccountReferenceDto"];
@@ -1515,20 +1547,50 @@ export interface components {
PutTbankConnectionDto: { PutTbankConnectionDto: {
token: string; token: string;
}; };
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
EventsInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "events";
/**
* Format: date
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
*/
from?: string;
/**
* Format: date
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
*/
to?: string;
};
SyncCandleRangeDto: { SyncCandleRangeDto: {
/** Format: date-time */ /** Format: date-time */
from: string; from: string;
/** Format: date-time */ /** Format: date-time */
to: string; to: string;
}; };
InvestmentScreenDto: { TimestampInvestmentScreenDto: {
/** @enum {string} */
screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond";
accountId?: string; accountId?: string;
/** Format: date-time */
from?: string;
/** Format: date-time */
to?: string;
/** Format: uuid */ /** Format: uuid */
instrumentUid?: string; instrumentUid?: string;
query?: string; query?: string;
@@ -1537,11 +1599,26 @@ export interface components {
candleRange?: components["schemas"]["SyncCandleRangeDto"]; candleRange?: components["schemas"]["SyncCandleRangeDto"];
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[]; categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
limit?: number; limit?: number;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
/**
* Format: date-time
* @description Start instant for operations.
*/
from?: string;
/**
* Format: date-time
* @description End instant for operations.
*/
to?: string;
}; };
TbankSyncAcceptedDto: { TbankSyncAcceptedDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
scope: components["schemas"]["InvestmentScreenDto"]; scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
/** Format: date-time */ /** Format: date-time */
now: string; now: string;
}; };
@@ -1553,7 +1630,7 @@ export interface components {
TbankSyncStatusDto: { TbankSyncStatusDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
scope: components["schemas"]["InvestmentScreenDto"] | null; scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
/** Format: date-time */ /** Format: date-time */
now: string; now: string;
/** @enum {string} */ /** @enum {string} */
@@ -3525,7 +3602,9 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
sync?: "observe"; sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string; from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
to?: string; to?: string;
}; };
header?: never; header?: never;
@@ -3661,7 +3740,9 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
sync?: "observe"; sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string; from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
to?: string; to?: string;
}; };
header?: never; header?: never;
@@ -3731,9 +3812,9 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
sync?: "observe"; sync?: "observe";
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */ /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string; from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */ /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string; to?: string;
}; };
header?: never; header?: never;
@@ -3801,9 +3882,9 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
sync?: "observe"; sync?: "observe";
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */ /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string; from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */ /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string; to?: string;
}; };
header?: never; header?: never;
@@ -5160,7 +5241,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["InvestmentScreenDto"]; "application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
}; };
}; };
responses: { responses: {
+14 -3
View File
@@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuthenticatedQuery } from "./authenticated-query"; import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import type { components } from "./generated/schema"; import type { components } from "./generated/schema";
import { apiErrorCode } from "./problem-details"; import { apiErrorCode } from "./problem-details";
@@ -107,8 +108,14 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
}, [client, group, query.error]); }, [client, group, query.error]);
const observe = async (expectedStateVersion?: string) => { const observe = async (expectedStateVersion?: string) => {
const queryFn = guarded; const queryFn = guarded;
if (typeof queryFn !== "function") return; if (
await client.cancelQueries({ queryKey: options.queryKey, exact: true }); !query.isEnabled ||
!getAuthenticatedSessionSnapshot() ||
typeof queryFn !== "function"
)
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ queryKey: options.queryKey, exact: true });
return client.fetchQuery({ return client.fetchQuery({
...options, ...options,
staleTime: 0, staleTime: 0,
@@ -144,7 +151,11 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
}, },
}); });
}; };
return { ...unwrapResourceResult(query), observe }; return {
...unwrapResourceResult(query),
queryKey: options.queryKey,
observe,
};
} }
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery }; export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
+285 -35
View File
@@ -2668,6 +2668,7 @@
"name": "from", "name": "from",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -2677,6 +2678,7 @@
"name": "to", "name": "to",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -2878,6 +2880,7 @@
"name": "from", "name": "from",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -2887,6 +2890,7 @@
"name": "to", "name": "to",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -2984,7 +2988,7 @@
"name": "from", "name": "from",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.", "description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -2994,7 +2998,7 @@
"name": "to", "name": "to",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.", "description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -3100,7 +3104,7 @@
"name": "from", "name": "from",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.", "description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -3110,7 +3114,7 @@
"name": "to", "name": "to",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.", "description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
"schema": { "schema": {
"format": "date", "format": "date",
"type": "string" "type": "string"
@@ -5001,7 +5005,31 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/InvestmentScreenDto" "oneOf": [
{
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
}
} }
} }
} }
@@ -6878,6 +6906,129 @@
"entries" "entries"
] ]
}, },
"IncomeDependencyDto": {
"type": "object",
"properties": {
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
"coverage": {
"$ref": "#/components/schemas/InvestmentCoverageDto"
},
"resourceKey": {
"type": "string"
},
"availability": {
"type": "string",
"enum": [
"missing",
"partial",
"complete"
]
},
"freshness": {
"type": "string",
"enum": [
"unknown",
"fresh",
"stale"
]
},
"lastSuccessfulAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"sync": {
"$ref": "#/components/schemas/InvestmentSyncDto"
},
"role": {
"type": "string",
"enum": [
"selected",
"previous",
"year"
]
}
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
"lastSuccessfulAt",
"sync",
"role"
]
},
"IncomeAnalyticsMetaDto": {
"type": "object",
"properties": {
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
"coverage": {
"$ref": "#/components/schemas/InvestmentCoverageDto"
},
"resourceKey": {
"type": "string"
},
"availability": {
"type": "string",
"enum": [
"missing",
"partial",
"complete"
]
},
"freshness": {
"type": "string",
"enum": [
"unknown",
"fresh",
"stale"
]
},
"lastSuccessfulAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"sync": {
"$ref": "#/components/schemas/InvestmentSyncDto"
},
"dependencies": {
"description": "Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.",
"type": "array",
"items": {
"$ref": "#/components/schemas/IncomeDependencyDto"
}
}
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
"lastSuccessfulAt",
"sync",
"dependencies"
]
},
"IncomeAnalyticsResourceDto": { "IncomeAnalyticsResourceDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6891,7 +7042,7 @@
] ]
}, },
"meta": { "meta": {
"$ref": "#/components/schemas/InvestmentResourceMetaDto" "$ref": "#/components/schemas/IncomeAnalyticsMetaDto"
} }
}, },
"required": [ "required": [
@@ -8677,6 +8828,62 @@
"token" "token"
] ]
}, },
"IncomeInvestmentScreenDto": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"screen": {
"type": "string",
"enum": [
"income"
]
},
"from": {
"type": "string",
"format": "date",
"description": "Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month."
},
"to": {
"type": "string",
"format": "date",
"description": "Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now."
}
},
"required": [
"accountId",
"screen"
]
},
"EventsInvestmentScreenDto": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"screen": {
"type": "string",
"enum": [
"events"
]
},
"from": {
"type": "string",
"format": "date",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC."
},
"to": {
"type": "string",
"format": "date",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end."
}
},
"required": [
"accountId",
"screen"
]
},
"SyncCandleRangeDto": { "SyncCandleRangeDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8694,34 +8901,12 @@
"to" "to"
] ]
}, },
"InvestmentScreenDto": { "TimestampInvestmentScreenDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"screen": {
"type": "string",
"enum": [
"accounts",
"account",
"operations",
"income",
"events",
"stocks",
"bonds",
"stock",
"bond"
]
},
"accountId": { "accountId": {
"type": "string" "type": "string"
}, },
"from": {
"type": "string",
"format": "date-time"
},
"to": {
"type": "string",
"format": "date-time"
},
"instrumentUid": { "instrumentUid": {
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"
@@ -8759,6 +8944,28 @@
"type": "number", "type": "number",
"minimum": 1, "minimum": 1,
"maximum": 1000 "maximum": 1000
},
"screen": {
"type": "string",
"enum": [
"accounts",
"account",
"operations",
"stocks",
"bonds",
"stock",
"bond"
]
},
"from": {
"type": "string",
"format": "date-time",
"description": "Start instant for operations."
},
"to": {
"type": "string",
"format": "date-time",
"description": "End instant for operations."
} }
}, },
"required": [ "required": [
@@ -8773,7 +8980,31 @@
"format": "uuid" "format": "uuid"
}, },
"scope": { "scope": {
"$ref": "#/components/schemas/InvestmentScreenDto" "oneOf": [
{
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
}
}, },
"now": { "now": {
"type": "string", "type": "string",
@@ -8813,13 +9044,32 @@
"format": "uuid" "format": "uuid"
}, },
"scope": { "scope": {
"nullable": true, "oneOf": [
"type": "object",
"allOf": [
{ {
"$ref": "#/components/schemas/InvestmentScreenDto" "$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
} }
] ],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
},
"nullable": true
}, },
"now": { "now": {
"type": "string", "type": "string",
+7
View File
@@ -269,6 +269,7 @@ const ru = {
}, },
}, },
stock: { stock: {
currencyUnknown: "валюта неизвестна",
searchTitle: "Акции", searchTitle: "Акции",
searchSubtitle: searchSubtitle:
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.", "Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
@@ -636,6 +637,7 @@ const ru = {
comparisonPending: "Период сравнения ещё не загружен полностью.", comparisonPending: "Период сравнения ещё не загружен полностью.",
comparisonFailed: comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.", "Не удалось загрузить период сравнения. Повторите обновление.",
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
yearUnavailable: "Сравнение с годом ранее недоступно.", yearUnavailable: "Сравнение с годом ранее недоступно.",
difference: "Разница", difference: "Разница",
total: "Итого", total: "Итого",
@@ -659,6 +661,10 @@ const ru = {
}, },
settings: { title: "Настройки" }, settings: { title: "Настройки" },
tbank: { tbank: {
close: "Закрыть",
refreshError:
"Не удалось обновить статус подключения. Показаны последние полученные данные.",
refreshing: "Обновляем статус подключения…",
title: "T-Bank Invest", title: "T-Bank Invest",
disconnected: "Не подключено", disconnected: "Не подключено",
connected: "Подключено с {{date}}", connected: "Подключено с {{date}}",
@@ -683,6 +689,7 @@ const ru = {
title: "Не удалось открыть страницу", title: "Не удалось открыть страницу",
text: "Перейдите в безопасный раздел и попробуйте снова.", text: "Перейдите в безопасный раздел и попробуйте снова.",
action: "Продолжить", action: "Продолжить",
reload: "Перезагрузить страницу",
}, },
states: { states: {
loadingTitle: "Загрузка", loadingTitle: "Загрузка",
+27
View File
@@ -177,6 +177,33 @@ const meta = {
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> }; export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
export const SecondaryText: Story = {
name: "Вторичный текст",
render: () => (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{[
"var(--wl-color-surface)",
"var(--wl-color-canvas)",
"var(--wl-color-subtle)",
...Object.keys(designTokens.color.palettes)
.filter((palette) => palette !== "dark")
.flatMap((palette) => [
`var(--mantine-color-${palette}-0)`,
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
]),
].map((background) => (
<Paper key={background} p="md" withBorder style={{ background }}>
<Text size="sm" c="dimmed">
22.09.2026 · Синтетические данные
</Text>
<Text size="xs" c="dimmed">
Не удалось обновить данные. Сохранён предыдущий результат.
</Text>
</Paper>
))}
</SimpleGrid>
),
};
export const Typography: Story = { export const Typography: Story = {
name: "Типографика", name: "Типографика",
render: () => <TypographyView />, render: () => <TypographyView />,
+8 -3
View File
@@ -9,16 +9,21 @@ function luminance(color: string) {
); );
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
} }
describe("financial text contrast", () => { describe("semantic text contrast", () => {
it.each(["positive", "negative"] as const)( it.each(["positive", "negative", "muted"] as const)(
"keeps %s readable on journal, position and tinted card surfaces", "keeps %s readable on canvas, white, subtle and tinted surfaces",
(tone) => { (tone) => {
const role = tokens.color.roles[tone]; const role = tokens.color.roles[tone];
const palettes = tokens.color.palettes; const palettes = tokens.color.palettes;
const foreground = const foreground =
palettes[role.color as keyof typeof palettes][role.shade]; palettes[role.color as keyof typeof palettes][role.shade];
const surfaces = [ const surfaces = [
"#ffffff",
palettes.gray[0], palettes.gray[0],
palettes.gray[2],
...Object.entries(palettes)
.filter(([name]) => name !== "dark")
.map(([, palette]) => palette[0]),
...Object.entries(palettes) ...Object.entries(palettes)
.filter(([name]) => name !== "dark") .filter(([name]) => name !== "dark")
.map( .map(
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"paths": {
"@/*": ["src/*"],
"@wealthledger/ui": ["packages/ui/src/index.ts"],
"@wealthledger/ui/theme": ["packages/ui/src/theme.ts"],
"@wealthledger/ui/styles.css": ["packages/ui/src/styles.css"],
"@wealthledger/icons": ["packages/icons/src/index.ts"]
}
}
}
+1
View File
@@ -22,6 +22,7 @@
}, },
"include": [ "include": [
"vite.config.ts", "vite.config.ts",
"vitest.config.ts",
".storybook/main.ts", ".storybook/main.ts",
"packages/*/vite.config.ts" "packages/*/vite.config.ts"
] ]
-1
View File
@@ -14,7 +14,6 @@ export default defineConfig({
exclude: configDefaults.exclude, exclude: configDefaults.exclude,
setupFiles: "./src/test/setup.ts", setupFiles: "./src/test/setup.ts",
coverage: { coverage: {
all: true,
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"], exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
reporter: ["text", "html", "lcov"], reporter: ["text", "html", "lcov"],