chore(release): 1.4.0 #158

Merged
codex-bot merged 12 commits from develop into main 2026-11-06 08:32:26 +03:00
11 changed files with 75 additions and 8 deletions
Showing only changes of commit 668f2ef70e - Show all commits
+9
View File
@@ -17,12 +17,21 @@ http {
default $http_x_forwarded_proto;
'' $scheme;
}
# One shared budget: the external proxy's client-IP forwarding is not verified.
map "$request_method:$uri" $auth_admission_key {
default "";
POST:/api/v1/auth/login auth;
POST:/api/v1/auth/register auth;
}
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
limit_req_status 429;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ {
limit_req zone=auth_admission burst=5 nodelay;
set $backend http://backend:3000;
proxy_pass $backend$request_uri;
proxy_set_header Host $http_host;
@@ -217,6 +217,7 @@ beforeEach(() => {
}),
),
incomeTotalsByCurrency: [],
window: { from: "2026-09-01", to: "2026-10-01" },
}),
),
),
@@ -176,7 +176,11 @@ describe("Upcoming event date filters", () => {
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
return HttpResponse.json(resource({
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}));
},
),
);
@@ -223,7 +227,11 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json(resource({ events: [] }))
? HttpResponse.json(resource({
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}))
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
),
);
@@ -256,6 +264,7 @@ describe("Upcoming event date filters", () => {
calculatedAt: "2099-01-01T00:00:00Z",
events: [],
incomeTotalsByCurrency: [],
window: { from: "2099-01-01", to: "2099-01-31" },
}),
);
},
@@ -264,6 +273,7 @@ describe("Upcoming event date filters", () => {
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByText(/Прогноз от .* на 01\.01\.2099 – 31\.01\.2099/)).toBeVisible();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
@@ -17,6 +17,8 @@ const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const snapshot = resource({
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-10-01",
@@ -32,6 +34,7 @@ const snapshot = resource({
},
],
incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }],
window: { from: "2026-08-14", to: "2026-09-13" },
});
function renderEvents() {
@@ -100,7 +103,13 @@ describe("AccountEventsPage read retry", () => {
async () => {
let isFailing = false;
let requests = 0;
let imports = 0;
server.use(
http.post("*/api/v1/tbank-sync", () => {
imports += 1;
return HttpResponse.json({}, { status: 202 });
}),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
requests += 1;
@@ -111,6 +120,7 @@ describe("AccountEventsPage read retry", () => {
);
const { queryClient } = renderEvents();
await screen.findByText("Купон · Synthetic coupon");
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
@@ -127,6 +137,7 @@ describe("AccountEventsPage read retry", () => {
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument();
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(requests).toBe(4);
isFailing = false;
@@ -136,6 +147,7 @@ describe("AccountEventsPage read retry", () => {
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
expect(imports).toBe(0);
},
);
@@ -47,6 +47,7 @@ describe("AccountEventsPage", () => {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
},
isError: false,
isLoading: false,
@@ -136,6 +137,7 @@ describe("AccountEventsPage", () => {
type: "offer",
},
],
window: { from: "2026-08-14", to: "2026-09-13" },
},
isError: false,
isLoading: false,
@@ -276,6 +278,7 @@ describe("AccountEventsPage", () => {
accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z",
events: [...events],
window: { from: "2026-08-14", to: "2026-09-13" },
},
isError: false,
isLoading: false,
@@ -18,6 +18,7 @@ import {
type AccountEventsPageViewModel
= | {
allAccounts: boolean;
calculatedAt: string;
canApply: boolean;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
@@ -31,6 +32,7 @@ type AccountEventsPageViewModel
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
window: { from: string; to: string };
}
| {
onReset: () => void;
@@ -110,6 +112,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
return {
allAccounts: accountId === "all",
calculatedAt: events.data.calculatedAt,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo),
@@ -133,6 +136,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
source: events.meta,
state: "loaded",
totalVisible: events.meta?.completeness === "complete",
window: events.data.window,
};
}
@@ -17,7 +17,7 @@ import {
brokerageEventGroupKey,
brokerageEventKey,
} from "@/entities/brokerage-event";
import { groupItems } from "@/shared/lib";
import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -89,6 +89,14 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Text c="dimmed">{t("events.hint")}</Text>
<Text c="dimmed" size="sm">
{t("events.forecastWindow", {
date: formatSnapshotTime(props.calculatedAt),
from: formatDate(props.window.from),
to: formatDate(props.window.to),
})}
</Text>
<Group align="end">
<DatePickerInput
allowSingleDateInRange
+22 -4
View File
@@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) {
}
describe("account position sections", () => {
it("excludes currency instruments only from display, preserving order and share denominators", () => {
it("excludes currency mirrors from display and share denominators", () => {
const otherTypes = [
"OPTION",
"OTHER",
@@ -73,12 +73,30 @@ describe("account position sections", () => {
"FIGI-FUND",
...otherTypes.map(type => `FIGI-${type}-NEXT`),
]);
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %");
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %");
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %");
expect(snapshot).toEqual(original);
});
it("keeps all-account positions from both accounts without counting mirrored cash", () => {
const result = sections([
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "100" } }),
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "50" }, type: "CURRENCY" }),
makePosition({ account: { id: "b", name: "ИИС" }, currentValue: { currency: "RUB", value: "100" } }),
]);
const visible = result.flatMap(section =>
section.groups.flatMap(group => group.positions));
expect(visible)
.toEqual(expect.arrayContaining([
expect.objectContaining({ account: { id: "a", name: "Основной" }, shareText: "50 %" }),
expect.objectContaining({ account: { id: "b", name: "ИИС" }, shareText: "50 %" }),
]));
expect(visible).toHaveLength(2);
});
it("prepares grouped position sections with derived display data", () => {
expect(
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
+1 -1
View File
@@ -152,7 +152,7 @@ function toAccountPagePositionSections(
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
if (position.type === "CURRENCY" || !position.currentValue) continue;
const currency = position.currentValue.currency;
denominators.set(
currency,
+1
View File
@@ -360,6 +360,7 @@ const ru = {
emptyTitle: "За выбранный период событий не найдено",
errorText: "Попробуйте запросить прогноз еще раз.",
errorTitle: "Не удалось загрузить будущие события",
forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}",
hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.",
noAmount: "Сумма неизвестна",
open: "Открыть будущие события",
+1
View File
@@ -66,6 +66,7 @@ const handlers = [
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}),
),
),