refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
Backend verify / commits (pull_request) Successful in 1m25s
Backend verify / security (pull_request) Successful in 1m8s
Backend verify / verify (pull_request) Successful in 6m20s

This commit is contained in:
2026-11-05 22:22:48 +03:00
parent 83b515a453
commit d1c5f575cf
36 changed files with 182 additions and 832 deletions
+23 -22
View File
@@ -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);
});
});