refactor!: simplify query and session ownership
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:
@@ -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 %"],
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user