fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation, currency presentation, accessibility, and route error recovery. Correct Docker and dependency checks and consume explicit income roles.
This commit is contained in:
+10
-7
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
},
|
},
|
||||||
"muted": {
|
"muted": {
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
"shade": 6,
|
"shade": 7,
|
||||||
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
||||||
},
|
},
|
||||||
"border": {
|
"border": {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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) ||
|
||||||
|
|||||||
+119
-7
@@ -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 };
|
||||||
|
|||||||
+9
-2
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
|
|||||||
const status = useAuthenticatedQuery({
|
const status = useAuthenticatedQuery({
|
||||||
...tbankConnectionQueryOptions(),
|
...tbankConnectionQueryOptions(),
|
||||||
});
|
});
|
||||||
|
if (!status.data) {
|
||||||
if (status.isPending) return { state: "loading" as const };
|
if (status.isPending) return { state: "loading" as const };
|
||||||
if (status.isError)
|
|
||||||
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 };
|
||||||
|
|||||||
+36
-3
@@ -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 };
|
||||||
|
|||||||
+10
-3
@@ -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 () => {
|
||||||
server.use(
|
const dependencies = [
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
{ ...resource(analytics).meta, role: "selected" as const },
|
||||||
HttpResponse.json(
|
{ ...resource(analytics).meta, role: "previous" as const },
|
||||||
resource(analytics, {
|
|
||||||
dependencies: [
|
|
||||||
resource(analytics).meta,
|
|
||||||
resource(analytics).meta,
|
|
||||||
{
|
{
|
||||||
...resource(null).meta,
|
...resource(null).meta,
|
||||||
|
role: "year" as const,
|
||||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
}),
|
server.use(
|
||||||
),
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(resource(analytics, { dependencies })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
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(
|
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: 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 () => {
|
||||||
|
const dependencies = (["year", "selected", "previous"] as const).map(
|
||||||
|
(role) => ({
|
||||||
|
...resource(role === "selected" ? analytics : null).meta,
|
||||||
|
role,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
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 () => {
|
||||||
server.use(
|
const dependencies = [
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
{ ...resource(analytics).meta, role: "previous" as const },
|
||||||
HttpResponse.json(
|
|
||||||
resource(analytics, {
|
|
||||||
dependencies: [
|
|
||||||
resource(analytics).meta,
|
|
||||||
resource(analytics).meta,
|
|
||||||
{
|
{
|
||||||
...resource(null).meta,
|
...resource(null).meta,
|
||||||
|
role: "year" as const,
|
||||||
sync: {
|
sync: {
|
||||||
id: null,
|
id: null,
|
||||||
state: "failed",
|
state: "failed" as const,
|
||||||
errorCode: "TBANK_UNAVAILABLE",
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
{ ...resource(analytics).meta, role: "selected" as const },
|
||||||
}),
|
];
|
||||||
),
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(resource(analytics, { dependencies })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
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";
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
? "%"
|
? "%"
|
||||||
: "×"}
|
: "×"}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Vendored
+96
-15
@@ -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: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Загрузка",
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user