fix(events): refresh saved payout calendars once per hourly pass (WealthLedger/backend#53) (#157)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m50s

This commit was merged in pull request #157.
This commit is contained in:
2026-11-02 23:06:14 +03:00
parent 169bee1da5
commit aeb4647fda
4 changed files with 131 additions and 2 deletions
+115
View File
@@ -775,6 +775,121 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
});
});
describe('hourly pass refreshes saved payout calendars', () => {
const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const dividend = (units: string, dividendType = 'Regular') => ({
recordDate: time(date),
dividendType,
dividendNet: { units, currency: 'usd' },
});
// One hourly walk: the scheduler stamps it once and passes it to every account.
const startWalk = async () => {
await delay(10);
return new Date();
};
const pass = async (walkStartedAt: Date, accountId = 'account') => {
await app
.get(UpcomingEventsService)
.refresh('owner', accountId, {}, walkStartedAt);
await settled();
};
const latest = async () =>
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
.body as UpcomingEventsResourceDto;
const dividendRow = () =>
prisma.tbankInstrumentSource.findFirstOrThrow({
where: { instrumentUid: shareUid, kind: 'dividends' },
});
it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => {
upstream.getDividends.mockResolvedValue([dividend('10')]);
await pass(await startWalk());
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
{ currency: 'USD', value: '20' },
]);
const first = await dividendRow();
upstream.getDividends.mockResolvedValue([dividend('30')]);
await pass(await startWalk());
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
{ currency: 'USD', value: '60' },
]);
expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1);
upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);
await pass(await startWalk());
expect((await latest()).data?.events).toEqual([]);
});
it('keeps the first complete calendar, time and forecast when the next refresh fails', async () => {
upstream.getDividends.mockResolvedValue([dividend('10')]);
await pass(await startWalk());
const before = await latest();
const row = await dividendRow();
upstream.getDividends.mockRejectedValue(new Error('outage'));
await pass(await startWalk());
const after = await latest();
expect(after.data).toEqual(before.data);
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
expect(
after.meta.dependencies?.some(
(dependency) => dependency.lastAttempt.state === 'failed',
),
).toBe(true);
const kept = await dividendRow();
expect(kept.dataRevision).toBe(row.dataRevision);
expect(kept.fetchedAt).toEqual(row.fetchedAt);
});
it('loads one shared calendar once per walk across accounts and leaves detail, GET and a plain refresh alone', async () => {
await prisma.tbankAccount.create({
data: {
userId: 'owner',
revision: 'owner-revision',
accountId: 'second',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Second',
upstreamOrder: 1,
},
});
await portfolio([position()], 'owner', 'second');
upstream.getDividends.mockResolvedValue([dividend('10')]);
await pass(await startWalk());
const detailCalls = () =>
upstream.getShare.mock.calls.length +
upstream.getInstrumentByUid.mock.calls.length;
const detailBefore = detailCalls();
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
const walk = await startWalk();
await pass(walk, 'account');
await pass(walk, 'second');
expect(upstream.getDividends).toHaveBeenCalledTimes(2);
await app.get(UpcomingEventsService).refresh('owner', 'account', {});
await settled();
await latest();
await get('brokerage-accounts/all/events').expect(200);
expect(upstream.getDividends).toHaveBeenCalledTimes(2);
expect(detailCalls()).toBe(detailBefore);
});
it('re-reads the bond calendar whose key never changes, only on a walk pass', async () => {
await portfolio([position(bondUid, '3', 'BOND')]);
await pass(await startWalk());
expect(moex.bondEvents).toHaveBeenCalledTimes(1);
await app.get(UpcomingEventsService).refresh('owner', 'account', {});
await settled();
expect(moex.bondEvents).toHaveBeenCalledTimes(1);
await pass(await startWalk());
expect(moex.bondEvents).toHaveBeenCalledTimes(2);
});
});
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(