fix(portfolio): publish new complete forecast regardless of request window (WealthLedger/backend#55) (#156)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m11s
Backend verify / verify (push) Failing after 7m6s

This commit was merged in pull request #156.
This commit is contained in:
2026-11-02 22:42:46 +03:00
parent 554410a271
commit 169bee1da5
5 changed files with 182 additions and 29 deletions
+139 -4
View File
@@ -82,7 +82,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
userId,
);
const refresh = async (range = window, accountId = 'account') => {
const refresh = async (
range: { from?: string; to?: string } = window,
accountId = 'account',
) => {
await app.get(UpcomingEventsService).refresh('owner', accountId, range);
await settled();
};
@@ -318,17 +321,23 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
expect(upstream.getDividends).toHaveBeenCalledTimes(count);
});
it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => {
it('serves a disjoint range as an empty saved result and never relabels a membership snapshot', async () => {
await refresh();
await settled();
await events().expect(200);
const saved = (await events().expect(200))
.body as UpcomingEventsResourceDto;
const newRange = (
await events(true, 'account', 'owner', {
from: '2099-03-01',
to: '2099-03-31',
}).expect(200)
).body as UpcomingEventsResourceDto;
expect(newRange.data).toBeNull();
expect(newRange.data).toEqual({
...saved.data,
events: [],
incomeTotalsByCurrency: [],
});
expect(newRange.data?.window).toEqual(window);
await prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { membershipRevision: { increment: 1 } },
@@ -640,6 +649,132 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
expect(moex.bondEvents).not.toHaveBeenCalled();
});
it('keeps the latest wide forecast when an explicit narrower range has a complete candidate', async () => {
await refresh();
const full = (await events().expect(200))
.body as UpcomingEventsResourceDto;
const narrowRange = { from: '2099-02-01', to: '2099-02-10' };
await refresh(narrowRange);
const narrow = (await events(true, 'account', 'owner', narrowRange).expect(
200,
)).body as UpcomingEventsResourceDto;
expect(narrow.data?.calculatedAt).toBe(full.data?.calculatedAt);
expect(narrow.data?.window).toEqual(window);
expect(narrow.data?.events.map((event) => event.date)).toEqual([
'2099-02-01',
]);
expect(await prisma.tbankForecast.count()).toBe(1);
});
describe('daily default window shift', () => {
const day0 = '2099-02-01';
const day1 = '2099-02-02';
const windowOf = (from: string, to: string) => ({ from, to });
const w0 = windowOf(day0, '2099-03-03');
const w1 = windowOf(day1, '2099-03-04');
const dividend = (date: string) => ({
recordDate: time(date),
dividendNet: { units: '10', nano: 1, currency: 'usd' },
});
const defaultEvents = async () =>
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
.body as UpcomingEventsResourceDto;
// Only Date is faked: the app and SQLite helpers need real timers.
const clock = (date: string) => {
jest.useFakeTimers({
doNotFake: [
'hrtime', 'nextTick', 'performance', 'queueMicrotask',
'requestAnimationFrame', 'cancelAnimationFrame',
'requestIdleCallback', 'cancelIdleCallback', 'setImmediate',
'clearImmediate', 'setInterval', 'clearInterval', 'setTimeout',
'clearTimeout',
],
now: new Date(`${date}T12:00:00Z`),
});
};
afterEach(() => jest.useRealTimers());
it('publishes the new window as latest and serves it for stale or disjoint explicit ranges', async () => {
clock(day0);
upstream.getDividends.mockResolvedValue([
dividend('2099-02-05'),
dividend('2099-03-04'),
]);
await portfolio([position(shareUid, '2')]);
await refresh({});
const first = await defaultEvents();
expect(first.data?.window).toEqual(w0);
expect(first.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'USD', value: '20.000000002' },
]);
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
await portfolio([position(shareUid, '5')]);
await refresh({});
const second = await defaultEvents();
expect(second.data?.window).toEqual(w1);
expect(second.data?.calculatedAt).not.toBe(first.data?.calculatedAt);
expect(second.data?.events.map((event) => event.date)).toEqual([
'2099-02-05',
'2099-03-04',
]);
expect(second.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'USD', value: '100.00000001' },
]);
const stale = (await events(true, 'account', 'owner', w0).expect(200))
.body as UpcomingEventsResourceDto;
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
expect(stale.data?.window).toEqual(w1);
const disjoint = (
await events(true, 'account', 'owner', {
from: '2100-01-01',
to: '2100-01-31',
}).expect(200)
).body as UpcomingEventsResourceDto;
expect(disjoint.data).toEqual({
...second.data,
events: [],
incomeTotalsByCurrency: [],
});
expect(await prisma.tbankForecast.count()).toBe(2);
});
it('keeps the previous complete forecast when a holding calendar fails after the shift', async () => {
clock(day0);
upstream.getDividends.mockResolvedValue([dividend('2099-02-05')]);
await refresh({});
const before = await defaultEvents();
expect(before.data?.window).toEqual(w0);
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
await portfolio([position(shareUid, '7'), position(newUid, '5')]);
upstream.getDividends.mockImplementation((_token: string, uid: string) =>
uid === newUid
? Promise.reject(new Error('new calendar unavailable'))
: Promise.resolve([dividend('2099-02-05')]),
);
await refresh({});
expect((await defaultEvents()).data).toEqual(before.data);
expect(await prisma.tbankForecast.count()).toBe(1);
});
it('publishes the shifted window even when no source depends on it', async () => {
clock(day0);
await portfolio([]);
await refresh({});
expect((await defaultEvents()).data?.window).toEqual(w0);
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
await refresh({});
expect((await defaultEvents()).data?.window).toEqual(w1);
});
});
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
let release: (value: unknown[]) => void = () => {};
upstream.getDividends.mockReturnValueOnce(