refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
This commit was merged in pull request #197.
This commit is contained in:
@@ -66,7 +66,7 @@ describe('All accounts HTTP boundary', () => {
|
||||
it.each(paths)('requires authentication for /all/%s', async (path) => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
|
||||
`/api/v1/brokerage-accounts/all/${path}`,
|
||||
)
|
||||
.expect(401);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
@@ -77,7 +77,7 @@ describe('All accounts HTTP boundary', () => {
|
||||
async (path) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
|
||||
`/api/v1/brokerage-accounts/all/${path}`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
@@ -100,7 +100,7 @@ describe('All accounts HTTP boundary', () => {
|
||||
])('validates /all/%s in the configured pipeline', async (path) => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
|
||||
`/api/v1/brokerage-accounts/all/${path}`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(400);
|
||||
|
||||
@@ -125,14 +125,14 @@ describe('Bond HTTP contract', () => {
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(response.body).toEqual(metrics);
|
||||
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false);
|
||||
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid);
|
||||
});
|
||||
it('uses verified identity and supports a direct instrument URL', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/bonds/${uid}`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false);
|
||||
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid);
|
||||
});
|
||||
it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])(
|
||||
'resolves ticker %s through the authenticated endpoint',
|
||||
@@ -150,7 +150,6 @@ describe('Bond HTTP contract', () => {
|
||||
expect(bonds.getByTicker).toHaveBeenCalledWith(
|
||||
'bond-reader',
|
||||
ticker,
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -185,7 +184,6 @@ describe('Bond HTTP contract', () => {
|
||||
expect(bonds.search).toHaveBeenCalledWith(
|
||||
'bond-reader',
|
||||
'SU26238RMFS4',
|
||||
false,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
data: [item],
|
||||
|
||||
@@ -28,31 +28,11 @@ describe('OpenAPI contract (integration)', () => {
|
||||
const document = createOpenApiDocument(app);
|
||||
const schemas = document.components?.schemas;
|
||||
expect(document.paths['/api/v1/tbank-sync']).toBeUndefined();
|
||||
expect(schemas?.TbankSyncStatusDto).toBeDefined();
|
||||
for (const [model, screen] of [
|
||||
['IncomeInvestmentScreenDto', 'income'],
|
||||
['EventsInvestmentScreenDto', 'events'],
|
||||
]) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
|
||||
const schema = schemas?.[model];
|
||||
assert(schema && !('$ref' in schema));
|
||||
expect(schema.required).toContain('screen');
|
||||
expect(schema.required).toContain('accountId');
|
||||
expect(schema).toMatchObject({
|
||||
properties: {
|
||||
screen: { enum: [screen] },
|
||||
from: { type: 'string', format: 'date' },
|
||||
to: { type: 'string', format: 'date' },
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(schemas?.TimestampInvestmentScreenDto).toMatchObject({
|
||||
properties: {
|
||||
from: { type: 'string', format: 'date-time' },
|
||||
to: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
});
|
||||
expect(schemas).not.toHaveProperty('InvestmentScreenDto');
|
||||
expect(document.paths['/api/v1/tbank-sync/{id}']).toBeUndefined();
|
||||
expect(schemas?.TbankSyncStatusDto).toBeUndefined();
|
||||
expect(schemas).not.toHaveProperty('IncomeInvestmentScreenDto');
|
||||
expect(schemas).not.toHaveProperty('EventsInvestmentScreenDto');
|
||||
expect(schemas).not.toHaveProperty('TimestampInvestmentScreenDto');
|
||||
for (const scope of ['all', '{accountId}']) {
|
||||
for (const [resource, format] of [
|
||||
['income-analytics', 'date'],
|
||||
@@ -77,6 +57,15 @@ describe('OpenAPI contract (integration)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not advertise the retired observe query on investment reads', () => {
|
||||
const document = createOpenApiDocument(app);
|
||||
for (const [path, resource] of Object.entries(document.paths)) {
|
||||
if (!path.startsWith('/api/v1/')) continue;
|
||||
const parameters = resource.get?.parameters ?? [];
|
||||
expect(parameters.some((parameter) => 'name' in parameter && parameter.name === 'sync')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes only the selected income dependency role and no comparison data', () => {
|
||||
const schemas = createOpenApiDocument(app).components?.schemas;
|
||||
expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('Stock HTTP contract', () => {
|
||||
.get(`/api/v1/stocks/${uid}`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false);
|
||||
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid);
|
||||
});
|
||||
it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])(
|
||||
'resolves ticker %s through the authenticated endpoint',
|
||||
@@ -154,7 +154,6 @@ describe('Stock HTTP contract', () => {
|
||||
expect(stocks.getByTicker).toHaveBeenCalledWith(
|
||||
'stock-reader',
|
||||
ticker,
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -186,7 +185,7 @@ describe('Stock HTTP contract', () => {
|
||||
.get('/api/v1/stocks?query=SBER')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false);
|
||||
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER');
|
||||
expect(response.body).toEqual({
|
||||
data: [item],
|
||||
meta: { availability: 'complete' },
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
).toBe(false);
|
||||
expect((await repository.read('uid', range)).coverage).toEqual([]);
|
||||
});
|
||||
it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => {
|
||||
it('coalesces twenty reads and reuses closed windows after reopen', async () => {
|
||||
const service = createService();
|
||||
const input = {
|
||||
uid: 'uid',
|
||||
@@ -216,7 +216,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
);
|
||||
expect(cold.every((row) => row.data === null)).toBe(true);
|
||||
expect(await prisma.tbankSyncWork.count()).toBe(1);
|
||||
await service.read('owner', input, true);
|
||||
release([]);
|
||||
await settled();
|
||||
const count = upstream.getCandles.mock.calls.length;
|
||||
@@ -239,7 +238,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
where: { isComplete: false },
|
||||
data: { fetchedAt: new Date(0) },
|
||||
});
|
||||
const stale = await reopened.read('owner', input, true);
|
||||
const stale = await reopened.read('owner', input);
|
||||
expect(stale.meta.availability).toBe('available');
|
||||
expect(stale.meta.completeness).toBe('complete');
|
||||
expect(stale.meta.freshness).toBe('stale');
|
||||
@@ -251,7 +250,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
await settled();
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1);
|
||||
});
|
||||
it('recovers abandoned work only on a new ordinary request', async () => {
|
||||
it('recovers abandoned work on an ordinary request', async () => {
|
||||
const input = {
|
||||
uid: 'uid',
|
||||
instrumentType: 'share' as const,
|
||||
@@ -265,12 +264,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
new Set(),
|
||||
);
|
||||
const reopened = createService();
|
||||
await reopened.read('owner', input, true);
|
||||
expect(upstream.getCandles).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(await repository.work('owner', 'revision', 'candles:uid:month'))
|
||||
?.attemptId,
|
||||
).toBe(abandoned?.work.attemptId);
|
||||
await reopened.read('owner', input);
|
||||
await settled();
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||
@@ -302,7 +295,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
await settled();
|
||||
expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before);
|
||||
expect(await prisma.tbankCandle.count()).toBe(0);
|
||||
const read = await service.read('owner', input, true);
|
||||
const read = await service.read('owner', input);
|
||||
expect(read.meta.lastAttempt.state).toBe('failed');
|
||||
expect(read.data?.candles).toEqual([]);
|
||||
});
|
||||
@@ -474,7 +467,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
};
|
||||
await service.read('owner', input);
|
||||
await settled();
|
||||
const result = await service.read('owner', input, true);
|
||||
const result = await service.read('owner', input);
|
||||
expect(result.data?.currency).toBeNull();
|
||||
expect(result.data?.candles).toEqual([
|
||||
{ time: now.toISOString(), close: '99.123456789', isComplete: false },
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
type CatalogInstrument,
|
||||
type CatalogQuery,
|
||||
} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
// Real transactions prove query normalization, confirmed-empty identity and cross-user isolation of shared reads.
|
||||
describe('shared catalog discovery (SQLite)', () => {
|
||||
const prisma = new PrismaService(
|
||||
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
|
||||
);
|
||||
const tokens = new TbankTokenService(
|
||||
new ConfigService({
|
||||
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
|
||||
}) as never,
|
||||
);
|
||||
const upstream = { findShares: jest.fn(), findBonds: jest.fn() };
|
||||
const access = new TbankAccessService(prisma, tokens);
|
||||
const repository = new CatalogDiscoveryRepository(prisma);
|
||||
const createService = () =>
|
||||
new CatalogDiscoveryService(
|
||||
repository,
|
||||
access,
|
||||
upstream as never,
|
||||
{ requestMany: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
let service: CatalogDiscoveryService;
|
||||
const query: CatalogQuery = { instrumentType: 'share', query: 'SBER' };
|
||||
const instrument = {
|
||||
uid: 'uid',
|
||||
name: 'Synthetic share',
|
||||
ticker: 'SBER',
|
||||
isin: 'TEST',
|
||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
||||
};
|
||||
const seed = (userId: string) =>
|
||||
prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId,
|
||||
revision: `${userId}-revision`,
|
||||
encryptedToken: tokens.encrypt(`synthetic-${userId}`),
|
||||
connectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Writes a finished discovery directly, so the read path is tested without any producer.
|
||||
async function seedCatalog(
|
||||
query: string,
|
||||
instruments: Array<{ payload: CatalogInstrument; uid: string }>,
|
||||
) {
|
||||
for (const item of instruments) {
|
||||
await prisma.tbankInstrument.create({
|
||||
data: {
|
||||
provider: 'tbank',
|
||||
uid: item.uid,
|
||||
instrumentType: 'share',
|
||||
metadata: {},
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await prisma.tbankInstrumentSource.create({
|
||||
data: {
|
||||
provider: 'tbank',
|
||||
instrumentUid: item.uid,
|
||||
source: 'tbank',
|
||||
kind: 'catalog',
|
||||
rangeKey: 'share',
|
||||
payload: item.payload,
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.tbankCatalogDiscovery.create({
|
||||
data: {
|
||||
provider: 'tbank',
|
||||
instrumentType: 'share',
|
||||
query,
|
||||
instrumentUids: instruments.map((item) => item.uid),
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await prisma.$connect();
|
||||
for (const id of ['owner', 'other']) {
|
||||
await prisma.user.create({
|
||||
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
beforeEach(async () => {
|
||||
await prisma.tbankConnection.deleteMany();
|
||||
await prisma.tbankCatalogDiscovery.deleteMany();
|
||||
await prisma.tbankInstrumentSource.deleteMany();
|
||||
await prisma.tbankInstrument.deleteMany();
|
||||
await seed('owner');
|
||||
await seed('other');
|
||||
jest.resetAllMocks();
|
||||
service = createService();
|
||||
});
|
||||
it('treats an unknown query as unavailable and a seeded query as available for another user', async () => {
|
||||
const cold = await service.read('owner', { ...query, query: ' sber ' });
|
||||
expect(cold.data).toBeNull();
|
||||
expect(cold.meta.availability).toBe('temporarily-unavailable');
|
||||
await seedCatalog('SBER', [{ uid: 'uid', payload: instrument }]);
|
||||
const warm = await service.read('other', query);
|
||||
expect(warm.data).toEqual([instrument]);
|
||||
expect(warm.meta.availability).toBe('available');
|
||||
expect(upstream.findShares).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps a confirmed empty result distinct from an unknown query', async () => {
|
||||
await seedCatalog('GAZP', []);
|
||||
const empty = await service.read('owner', { ...query, query: 'GAZP' });
|
||||
expect(empty.data).toEqual([]);
|
||||
expect(empty.meta.availability).toBe('available');
|
||||
const unknown = await service.read('owner', { ...query, query: 'LKOH' });
|
||||
expect(unknown.data).toBeNull();
|
||||
});
|
||||
it('keeps the stored selection order and does not expose private fields', async () => {
|
||||
const first = { ...instrument, uid: 'first', name: 'First' };
|
||||
const second = { ...instrument, uid: 'second', name: 'Second' };
|
||||
await seedCatalog('SBER', [
|
||||
{ uid: 'second', payload: second },
|
||||
{ uid: 'first', payload: first },
|
||||
]);
|
||||
const result = await service.read('owner', query);
|
||||
assert(result.data);
|
||||
expect(result.data.map((item) => item.uid)).toEqual(['second', 'first']);
|
||||
expect(result.data[0]).not.toHaveProperty('accountId');
|
||||
});
|
||||
});
|
||||
@@ -34,12 +34,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
const get = (
|
||||
account = 'all',
|
||||
period = 'from=2025-01-01&to=2025-01-31',
|
||||
observe = true,
|
||||
) =>
|
||||
request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`,
|
||||
)
|
||||
.get(`/api/v1/brokerage-accounts/${account}/income-analytics?${period}`)
|
||||
.set('Authorization', authorization)
|
||||
.timeout(2000);
|
||||
const read = async (account = 'all', period?: string) =>
|
||||
@@ -127,7 +124,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it('returns exact selected sums; reopen and observe need no RPC', async () => {
|
||||
it('returns exact selected sums and reuses covered reads without RPC', async () => {
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const result = await read();
|
||||
assert(result.data);
|
||||
@@ -210,7 +207,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
expect(
|
||||
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
|
||||
).toEqual([]);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
|
||||
'synthetic', expect.objectContaining({ accountId: 'b' }),
|
||||
);
|
||||
});
|
||||
it('counts corrected identities once for the selected period', async () => {
|
||||
await prisma.tbankOperation.updateMany({
|
||||
@@ -274,7 +273,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
expect(after.meta.lastAttempt.state).toBe('none');
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('retains current-month observed completeness and does not renew source timestamps', async () => {
|
||||
it('retains current-month completeness and does not renew source timestamps', async () => {
|
||||
const now = new Date();
|
||||
const today = moscowDate(now);
|
||||
const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`);
|
||||
@@ -283,7 +282,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
await coverage(accountId, start, asOf);
|
||||
}
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts/all/income-summary?sync=observe')
|
||||
.get('/api/v1/brokerage-accounts/all/income-summary')
|
||||
.set('Authorization', authorization)
|
||||
.expect(200);
|
||||
const result = response.body as IncomeSummaryResourceDto;
|
||||
@@ -302,58 +301,32 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
where: { userId: 'income-owner' },
|
||||
data: { membershipAt: new Date('2020-01-01Z') },
|
||||
});
|
||||
const response = await get('a', undefined, false).expect(200);
|
||||
const response = await get('a').expect(200);
|
||||
const result = response.body as IncomeAnalyticsResourceDto;
|
||||
expect(result.data).not.toBeNull();
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cold income imports only the missing requested interval', async () => {
|
||||
it('ignores retired sync=observe on income reads and keeps the cold response incomplete', async () => {
|
||||
await prisma.tbankConnection.update({
|
||||
where: { userId: 'income-owner' },
|
||||
data: { membershipAt: new Date('2020-01-01Z') },
|
||||
});
|
||||
let release: () => void = () => {
|
||||
throw new Error('Upstream not initialized');
|
||||
};
|
||||
const blocked = new Promise<{ hasNext: boolean; items: never[] }>((resolve) => {
|
||||
release = () => resolve({ hasNext: false, items: [] });
|
||||
});
|
||||
upstream.getOperationsByCursor.mockReturnValue(blocked);
|
||||
const first = await get('a', 'from=2025-02-01&to=2025-02-28', false).expect(200);
|
||||
const cold = first.body as IncomeAnalyticsResourceDto;
|
||||
release();
|
||||
expect(cold.data).toBeNull();
|
||||
expect(cold.meta.completeness).toBe('incomplete');
|
||||
let result: IncomeAnalyticsResourceDto = cold;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const response = await get('a', 'from=2025-02-01&to=2025-02-28').expect(200);
|
||||
result = response.body as IncomeAnalyticsResourceDto;
|
||||
if (result.meta.completeness === 'complete') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(result.data).not.toBeNull();
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts/a/income-analytics?from=2025-02-01&to=2025-02-28&sync=observe')
|
||||
.set('Authorization', authorization)
|
||||
.expect(200);
|
||||
const result = response.body as IncomeAnalyticsResourceDto;
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.meta.completeness).toBe('incomplete');
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getOperationsByCursor.mock.calls).toEqual([
|
||||
['synthetic', expect.objectContaining({
|
||||
accountId: 'a',
|
||||
from: { seconds: '1738357199' },
|
||||
to: { seconds: '1738368001' },
|
||||
})],
|
||||
['synthetic', expect.objectContaining({
|
||||
accountId: 'a',
|
||||
from: { seconds: '1738367999' },
|
||||
to: { seconds: '1740776401' },
|
||||
})],
|
||||
]);
|
||||
});
|
||||
it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => {
|
||||
await get('absent', undefined, false).expect(404);
|
||||
await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400);
|
||||
await get('absent').expect(404);
|
||||
await get('a', 'from=2025-02-31&to=2025-03-01').expect(400);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
const response = await get('all', undefined, false).expect(200);
|
||||
const response = await get('all').expect(200);
|
||||
expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull();
|
||||
});
|
||||
it('plans only the selected period for a leap day without scheduling it', async () => {
|
||||
@@ -361,7 +334,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
|
||||
'selected',
|
||||
]);
|
||||
expect(result.meta.dependencies[0].availability).toBe('temporarily-unavailable');
|
||||
expect(result.meta.dependencies[0].availability).toBe('initial-loading');
|
||||
expect(result.data).toBeNull();
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -30,10 +30,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
};
|
||||
const auth = (owner = 'owner') =>
|
||||
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
||||
const cursorParam = (cursor?: null | string) => (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');
|
||||
const get = (query = '', account = 'all', owner = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`,
|
||||
`/api/v1/brokerage-accounts/${account}/operations?${[period, query].filter(Boolean).join('&')}`,
|
||||
)
|
||||
.set('Authorization', auth(owner))
|
||||
.timeout(30_000);
|
||||
@@ -138,7 +139,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
let complete = false;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28&sync=observe')
|
||||
.get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28')
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
const result = response.body as BrokerageOperationsResourceDto;
|
||||
@@ -162,7 +163,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
const ids: string[] = [];
|
||||
do {
|
||||
const response = await get(
|
||||
`limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
||||
`limit=2${cursorParam(cursor)}`,
|
||||
).expect(200);
|
||||
const result = response.body as BrokerageOperationsResourceDto;
|
||||
expect(result.meta.availability).toBe('available');
|
||||
@@ -243,7 +244,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
do {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
||||
`/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1${cursorParam(cursor)}`,
|
||||
)
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
@@ -258,7 +259,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
const income = (
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`,
|
||||
`/api/v1/brokerage-accounts/a/income-analytics?${period}`,
|
||||
)
|
||||
.set('Authorization', auth())
|
||||
.expect(200)
|
||||
@@ -291,10 +292,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
periodKind === 'default'
|
||||
? ''
|
||||
: `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`;
|
||||
const page = (cursor?: string, observe = false) =>
|
||||
const page = (cursor?: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v1/brokerage-accounts/all/operations?${observe ? 'sync=observe&' : ''}limit=1${query}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
||||
`/api/v1/brokerage-accounts/all/operations?limit=1${query}${cursorParam(cursor)}`,
|
||||
)
|
||||
.set('Authorization', auth());
|
||||
const first = (await page().expect(200))
|
||||
@@ -307,7 +308,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(second.meta.availability).toBe('available');
|
||||
expect(second.meta.completeness).toBe('complete');
|
||||
expect(second.meta.coverage?.missingRanges).toEqual([]);
|
||||
const observed = (await page(first.data.nextCursor, true).expect(200))
|
||||
const observed = (await page(first.data.nextCursor).expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(observed.meta.availability).toBe('available');
|
||||
expect(observed.meta.completeness).toBe('complete');
|
||||
@@ -317,7 +318,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
it('keeps data revisions stable while observational reads expose current coverage', async () => {
|
||||
const observe = () =>
|
||||
request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100')
|
||||
.get('/api/v1/brokerage-accounts/all/operations?limit=100')
|
||||
.set('Authorization', auth());
|
||||
const first = (await observe().expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
@@ -340,7 +341,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('reads shared metadata from SQLite without invalidating a row cursor', async () => {
|
||||
const before = (await get('limit=1&sync=observe').expect(200))
|
||||
const before = (await get('limit=1').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
assert(before.data?.nextCursor);
|
||||
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
@@ -371,22 +372,22 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
new Date(),
|
||||
);
|
||||
await prisma.$disconnect();
|
||||
const after = (await get('limit=1&sync=observe').expect(200))
|
||||
const after = (await get('limit=1').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(after.data?.items[0].logoName).toBe('saved.png');
|
||||
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
|
||||
await get(
|
||||
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
||||
`limit=1&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
||||
).expect(200);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => {
|
||||
const first = (
|
||||
await get('limit=1&categories=trade,income&sync=observe').expect(200)
|
||||
await get('limit=1&categories=trade,income').expect(200)
|
||||
).body as BrokerageOperationsResourceDto;
|
||||
assert(first.data?.nextCursor);
|
||||
const cursor = encodeURIComponent(first.data.nextCursor);
|
||||
const next = `limit=1&categories=income,trade,income&sync=observe&cursor=${cursor}`;
|
||||
const next = `limit=1&categories=income,trade,income&cursor=${cursor}`;
|
||||
await prisma.tbankOperationCoverage.updateMany({
|
||||
data: { fetchedAt: new Date(0) },
|
||||
});
|
||||
@@ -400,10 +401,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
data: { operationsRevision: { increment: 1 } },
|
||||
});
|
||||
await get(next).expect(400);
|
||||
const single = (await get('limit=1&sync=observe', 'a').expect(200))
|
||||
const single = (await get('limit=1', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
assert(single.data?.nextCursor);
|
||||
const singleNext = `limit=1&sync=observe&cursor=${encodeURIComponent(single.data.nextCursor)}`;
|
||||
const singleNext = `limit=1&cursor=${encodeURIComponent(single.data.nextCursor)}`;
|
||||
await prisma.tbankConnection.update({
|
||||
where: { userId: 'owner' },
|
||||
data: { membershipRevision: { increment: 1 } },
|
||||
@@ -431,7 +432,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
});
|
||||
const partial = (
|
||||
await get(
|
||||
`instrumentUid=${uid}&categories=income&limit=100&sync=observe`,
|
||||
`instrumentUid=${uid}&categories=income&limit=100`,
|
||||
'a',
|
||||
).expect(200)
|
||||
).body as BrokerageOperationsResourceDto;
|
||||
@@ -439,7 +440,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(partial.meta.completeness).toBe('incomplete');
|
||||
expect(partial.data).toBeNull();
|
||||
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
|
||||
const whole = (await get('sync=observe', 'a').expect(200))
|
||||
const whole = (await get('', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(whole.meta.availability).toBe('available');
|
||||
expect(whole.meta.completeness).toBe('complete');
|
||||
@@ -450,7 +451,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
await prisma.tbankAccount.updateMany({ data: { openedOn: null } });
|
||||
await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } });
|
||||
await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } });
|
||||
const result = (await get('sync=observe', 'a').expect(200))
|
||||
const result = (await get('', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(result.meta.coverage?.historyBoundary).toBe('unknown');
|
||||
expect(result.meta.coverage?.historyState).toBe('missing');
|
||||
@@ -474,7 +475,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(cold.data).toBeNull();
|
||||
expect(cold.meta.availability).toBe('initial-loading');
|
||||
expect(cold.meta.completeness).toBe('incomplete');
|
||||
await get('sync=observe', 'a').expect(200);
|
||||
await get('', 'a').expect(200);
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
// eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation.
|
||||
unblock({ hasNext: false, items: [] });
|
||||
@@ -485,12 +486,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
await delay(5);
|
||||
}
|
||||
const count = upstream.getOperationsByCursor.mock.calls.length;
|
||||
const result = (await get('sync=observe', 'a').expect(200))
|
||||
const result = (await get('', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(result.data?.items).toEqual([]);
|
||||
expect(result.meta.availability).toBe('available');
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
await get('sync=observe', 'a').expect(200);
|
||||
await get('', 'a').expect(200);
|
||||
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,19 +153,19 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
||||
},
|
||||
});
|
||||
const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200);
|
||||
const cold = await get('brokerage-accounts/unknown/portfolio').expect(200);
|
||||
expect((cold.body as PortfolioResourceDto).data).toBeNull();
|
||||
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
|
||||
'temporarily-unavailable',
|
||||
);
|
||||
await get('brokerage-accounts?sync=observe').expect(200);
|
||||
await get('brokerage-accounts').expect(200);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
|
||||
await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } });
|
||||
await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } });
|
||||
await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404);
|
||||
await get('brokerage-accounts?sync=observe', 'other').expect(409);
|
||||
await get('brokerage-accounts/unknown/portfolio').expect(404);
|
||||
await get('brokerage-accounts', 'other').expect(409);
|
||||
await get('brokerage-accounts?sync=unexpected').expect(200);
|
||||
});
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
await seedMembership();
|
||||
const blocked = deferred<TbankPortfolio>();
|
||||
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
||||
const cold = await get('brokerage-accounts?sync=observe').expect(200);
|
||||
const cold = await get('brokerage-accounts').expect(200);
|
||||
expect(
|
||||
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
|
||||
).toBeNull();
|
||||
@@ -270,7 +270,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
if (work) await work.done;
|
||||
await settled();
|
||||
const before = (
|
||||
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
||||
await get('brokerage-accounts/account/portfolio').expect(200)
|
||||
).body as PortfolioResourceDto;
|
||||
const fetchedAt = new Date();
|
||||
await prisma.tbankInstrument.create({
|
||||
@@ -307,7 +307,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
update: { matchedUids: ['enriched-stock'], fetchedAt },
|
||||
});
|
||||
const after = (
|
||||
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
||||
await get('brokerage-accounts/account/portfolio').expect(200)
|
||||
).body as PortfolioResourceDto;
|
||||
expect(after.data?.positions[0]).toMatchObject({
|
||||
displayName: 'Enriched name',
|
||||
@@ -333,7 +333,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
where: { userId: 'owner', scopeKey: 'portfolio:account' },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200);
|
||||
const stale = await get('brokerage-accounts/account/portfolio').expect(200);
|
||||
const previous = (stale.body as PortfolioResourceDto).data;
|
||||
expect(previous?.isStale).toBe(true);
|
||||
expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||
@@ -343,7 +343,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
});
|
||||
const failed = await get(
|
||||
'brokerage-accounts/account/portfolio?sync=observe',
|
||||
'brokerage-accounts/account/portfolio',
|
||||
).expect(200);
|
||||
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
|
||||
expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none');
|
||||
@@ -403,7 +403,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
},
|
||||
]);
|
||||
const income = await get(
|
||||
'brokerage-accounts/account/income-analytics?sync=observe',
|
||||
'brokerage-accounts/account/income-analytics',
|
||||
).expect(200);
|
||||
const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta;
|
||||
expect(incomeMeta.lastAttempt.state).toBe('partial');
|
||||
@@ -443,7 +443,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
});
|
||||
const blocked = deferred<TbankAccount[]>();
|
||||
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
|
||||
await get('brokerage-accounts?sync=observe').expect(200);
|
||||
await get('brokerage-accounts').expect(200);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
@@ -468,9 +468,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
where: { id: 'abandoned-membership' },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect((await get('brokerage-accounts/account/portfolio').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
||||
expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({
|
||||
expect((await get('brokerage-accounts')).body).toMatchObject({
|
||||
data: [{ id: 'account', portfolioSummary: null }],
|
||||
});
|
||||
});
|
||||
@@ -510,7 +510,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
}
|
||||
await delay(5);
|
||||
}
|
||||
const summaries = (await get('brokerage-accounts?sync=observe').expect(200))
|
||||
const summaries = (await get('brokerage-accounts').expect(200))
|
||||
.body as BrokerageAccountsResourceDto;
|
||||
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
|
||||
@@ -528,10 +528,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
expect(summaries.meta.availability).toBe('available');
|
||||
expect(summaries.meta.completeness).toBe('complete');
|
||||
expect(
|
||||
(await get('brokerage-accounts/all/portfolio?sync=observe')).body,
|
||||
(await get('brokerage-accounts/all/portfolio')).body,
|
||||
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect(
|
||||
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
||||
(await get(`stocks/${uid}/position`).expect(200)).body,
|
||||
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
|
||||
@@ -542,7 +542,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipRevision: { increment: 1 } },
|
||||
});
|
||||
const position = await get(`stocks/${uid}/position?sync=observe`).expect(
|
||||
const position = await get(`stocks/${uid}/position`).expect(
|
||||
200,
|
||||
);
|
||||
expect(position.body).toMatchObject({
|
||||
@@ -551,12 +551,12 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
expect(
|
||||
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
||||
(await get(`stocks/${uid}/position`).expect(200)).body,
|
||||
).toEqual(position.body);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
await prisma.tbankConnection.updateMany({ data: { invalidToken: true } });
|
||||
await get(`stocks/${uid}/position?sync=observe`).expect(400);
|
||||
await get(`stocks/${uid}/position`).expect(400);
|
||||
});
|
||||
|
||||
it('uses membership TTL for an empty aggregate without starting work through observation', async () => {
|
||||
@@ -570,7 +570,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
data: { membershipAt: new Date(Date.now() - 120_000) },
|
||||
});
|
||||
const fresh = await get(
|
||||
'brokerage-accounts/all/portfolio?sync=observe',
|
||||
'brokerage-accounts/all/portfolio',
|
||||
).expect(200);
|
||||
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
||||
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
|
||||
@@ -580,7 +580,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
data: { membershipAt: new Date(Date.now() - 301_000) },
|
||||
});
|
||||
const stale = await get(
|
||||
'brokerage-accounts/all/portfolio?sync=observe',
|
||||
'brokerage-accounts/all/portfolio',
|
||||
).expect(200);
|
||||
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
||||
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false);
|
||||
@@ -595,7 +595,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipAt: new Date() },
|
||||
});
|
||||
const empty = await get('brokerage-accounts?sync=observe').expect(200);
|
||||
const empty = await get('brokerage-accounts').expect(200);
|
||||
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
|
||||
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
|
||||
'available',
|
||||
@@ -625,7 +625,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
await late.done;
|
||||
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
||||
const replaced = await get(
|
||||
'brokerage-accounts/account/portfolio?sync=observe',
|
||||
'brokerage-accounts/account/portfolio',
|
||||
).expect(200);
|
||||
expect((replaced.body as PortfolioResourceDto).data).toBeNull();
|
||||
});
|
||||
|
||||
@@ -73,13 +73,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
.set('Authorization', `Bearer ${auth(userId)}`)
|
||||
.timeout(1500);
|
||||
const events = (
|
||||
observe = true,
|
||||
scope = 'account',
|
||||
userId = 'owner',
|
||||
range = window,
|
||||
) =>
|
||||
get(
|
||||
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
||||
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}`,
|
||||
userId,
|
||||
);
|
||||
const refresh = async (
|
||||
@@ -278,7 +277,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
{ currency: 'RUB', value: '6' },
|
||||
{ currency: 'USD', value: '20.000000002' },
|
||||
]);
|
||||
const all = (await events(true, 'all').expect(200))
|
||||
const all = (await events('all').expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(
|
||||
all.data?.events.every((event) => event.account?.id === 'account'),
|
||||
@@ -327,7 +326,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
const saved = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
const newRange = (
|
||||
await events(true, 'account', 'owner', {
|
||||
await events('account', 'owner', {
|
||||
from: '2099-03-01',
|
||||
to: '2099-03-31',
|
||||
}).expect(200)
|
||||
@@ -346,7 +345,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
expect(
|
||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
||||
).toBeNull();
|
||||
await events(true, 'foreign').expect(404);
|
||||
await events('foreign').expect(404);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -389,7 +388,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await get(`stocks/${shareUid}/dividends`).expect(200);
|
||||
await settled();
|
||||
const before = (
|
||||
await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200)
|
||||
await get(`stocks/${shareUid}/dividends`).expect(200)
|
||||
).body as StockDividendsResourceDto;
|
||||
expect(before.data?.dividends[0].estimatedAmount).toEqual({
|
||||
currency: 'RUB',
|
||||
@@ -418,7 +417,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await settled();
|
||||
expect(
|
||||
(
|
||||
(await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200))
|
||||
(await get(`stocks/${shareUid}/dividends`).expect(200))
|
||||
.body as StockDividendsResourceDto
|
||||
).data,
|
||||
).toEqual(before.data);
|
||||
@@ -433,7 +432,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await get(`bonds/${bondUid}/coupons`).expect(200);
|
||||
await settled();
|
||||
const coupons = (
|
||||
await get(`bonds/${bondUid}/coupons?sync=observe`).expect(200)
|
||||
await get(`bonds/${bondUid}/coupons`).expect(200)
|
||||
).body as BondCouponsResourceDto;
|
||||
expect(coupons.data?.coupons[0].estimatedAmount).toEqual({
|
||||
currency: 'RUB',
|
||||
@@ -467,7 +466,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await portfolio([position(uid, '3', type)]);
|
||||
const path = `${kind}/${uid}/${payout}`;
|
||||
const read = async () =>
|
||||
(await get(`${path}?sync=observe`).expect(200)).body as
|
||||
(await get(`${path}`).expect(200)).body as
|
||||
BondCouponsResourceDto | StockDividendsResourceDto;
|
||||
await get(path).expect(200);
|
||||
await settled();
|
||||
@@ -614,7 +613,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await settled();
|
||||
const result = (
|
||||
await get(
|
||||
'brokerage-accounts/account/events?from=2099-02-01&sync=observe',
|
||||
'brokerage-accounts/account/events?from=2099-02-01',
|
||||
).expect(200)
|
||||
).body as UpcomingEventsResourceDto;
|
||||
expect(result.data?.events.map((event) => event.date)).toEqual([
|
||||
@@ -636,7 +635,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
const calculatedAt = full.data?.calculatedAt;
|
||||
jest.clearAllMocks();
|
||||
|
||||
const narrow = (await events(false, 'account', 'owner', {
|
||||
const narrow = (await events('account', 'owner', {
|
||||
from: '2099-02-01',
|
||||
to: '2099-02-10',
|
||||
}).expect(200)).body as UpcomingEventsResourceDto;
|
||||
@@ -656,7 +655,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
const narrowRange = { from: '2099-02-01', to: '2099-02-10' };
|
||||
await refresh(narrowRange);
|
||||
|
||||
const narrow = (await events(true, 'account', 'owner', narrowRange).expect(
|
||||
const narrow = (await events('account', 'owner', narrowRange).expect(
|
||||
200,
|
||||
)).body as UpcomingEventsResourceDto;
|
||||
|
||||
@@ -679,7 +678,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
dividendNet: { units: '10', nano: 1, currency: 'usd' },
|
||||
});
|
||||
const defaultEvents = async () =>
|
||||
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
||||
(await get('brokerage-accounts/account/events').expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
// Only Date is faked: the app and SQLite helpers need real timers.
|
||||
const clock = (date: string) => {
|
||||
@@ -724,12 +723,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
expect(second.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'USD', value: '100.00000001' },
|
||||
]);
|
||||
const stale = (await events(true, 'account', 'owner', w0).expect(200))
|
||||
const stale = (await events('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', {
|
||||
await events('account', 'owner', {
|
||||
from: '2100-01-01',
|
||||
to: '2100-01-31',
|
||||
}).expect(200)
|
||||
@@ -794,7 +793,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await settled();
|
||||
};
|
||||
const latest = async () =>
|
||||
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
||||
(await get('brokerage-accounts/account/events').expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
const dividendRow = () =>
|
||||
prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||
@@ -924,12 +923,16 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
'to=2099-13-01',
|
||||
'from=2099-02-01T00:00:00Z',
|
||||
'from=2099-02-28&to=2099-02-01',
|
||||
'sync=invalid',
|
||||
])('rejects invalid query before source reads: %s', async (query) => {
|
||||
await get(`brokerage-accounts/account/events?${query}`).expect(400);
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores the retired sync query parameter', async () => {
|
||||
const response = await get(`brokerage-accounts/account/events?from=2099-02-01&to=2099-02-28&sync=invalid`).expect(200);
|
||||
expect(response.body).toHaveProperty('meta');
|
||||
});
|
||||
|
||||
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await refresh({ from: today, to: '2096-02-29' });
|
||||
@@ -954,7 +957,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
const range = { from: '2096-02-29', to: '2096-02-29' };
|
||||
await refresh(range);
|
||||
jest.clearAllMocks();
|
||||
const result = (await events(true, 'account', 'owner', range).expect(200))
|
||||
const result = (await events('account', 'owner', range).expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(result.data?.events).toHaveLength(1);
|
||||
expect(result.data?.events[0]).toMatchObject({
|
||||
|
||||
Reference in New Issue
Block a user