refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped

Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
This commit is contained in:
2026-10-03 11:45:04 +03:00
parent 6a89b08ee5
commit 69997075e5
25 changed files with 151 additions and 551 deletions
@@ -44,71 +44,6 @@ function summary(currency: string, coupon: string, dividend: string) {
};
}
describe("income membership validation", () => {
const entry: IncomeAnalytics["entries"][number] = {
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
const instrument: IncomeAnalytics["instruments"][number] = {
...summary("RUB", "10", "0"),
displayName: "BOND",
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: entry.instrumentKey,
receiptCount: 1,
ticker: "BOND",
};
const valid: IncomeAnalytics = {
...analytics,
entries: [entry],
instruments: [instrument],
totalsByCurrency: [summary("RUB", "10", "0")],
};
it.each([
["missing key", undefined],
["empty key", ""],
["whitespace key", " "],
["non-string key", 12],
["unknown key", "not-in-breakdown"],
])("rejects an entry with %s", (_name, instrumentKey) => {
// Deliberately malformed API data exercises the runtime validation boundary.
const response = {
...valid,
entries: [{ ...entry, instrumentKey }],
} as unknown as IncomeAnalytics;
expect(toIncomeDisplayModel(response)).toBeNull();
});
it.each([
["duplicate instrument keys", { instruments: [instrument, instrument] }],
["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }],
["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }],
["missing entries", { entries: [] }],
["empty instrument key", { instruments: [{ ...instrument, key: "" }] }],
])("rejects %s", (_name, invalid) => {
expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull();
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
+1 -41
View File
@@ -97,49 +97,14 @@ function formatIncomeChange(
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
{
currency: string;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
includedCount: 0,
receiptCount: instrument.receiptCount,
});
}
): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
occurrences.set(occurrenceKey, occurrence + 1);
instrument.includedCount += 1;
entries.push({
key: JSON.stringify([
entry.instrumentKey,
@@ -151,11 +116,6 @@ function toIncomeDisplayModel(
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
return null;
}
const dependencies = meta?.dependencies ?? [];
const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
@@ -324,32 +324,6 @@ describe("AccountIncomePage", () => {
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
});
it("rejects inconsistent analytics and recovers through retry", async () => {
let response = {
...analytics,
entries: [
{ ...analytics.entries[0], instrumentKey: "not-in-breakdown" },
analytics.entries[1],
],
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
),
);
renderPage();
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
response = analytics;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).toBeInTheDocument();
});
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -709,15 +683,13 @@ describe("income period and snapshot boundary", () => {
errorCode: null,
id: "income",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [resource(null, { stateVersion: "2" }).meta],
scope: null,
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
HttpResponse.json(resource(analytics, { dataRevision: "2" })),
),
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
@@ -89,12 +89,6 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
period,
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
@@ -786,11 +786,6 @@ describe("AccountPage states", () => {
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
@@ -871,7 +866,7 @@ describe("AccountPage states", () => {
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
{ dataRevision: "2", resourceKey: "portfolio" },
),
),
);
@@ -231,7 +231,7 @@ it("refreshes every overview section through one account descriptor and observat
observed.push(path);
return HttpResponse.json(
resource(data, { resourceKey: path, stateVersion: version }),
resource(data, { dataRevision: version, resourceKey: path }),
);
},
),
@@ -256,11 +256,6 @@ it("refreshes every overview section through one account descriptor and observat
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
progress: { completed: 4, discovering: false, total: 4 },
resources: Object.keys(sections).map(
resourceKey =>
resource(null, { resourceKey, stateVersion: version }).meta,
),
scope: null,
state: "succeeded",
},