From bc631e72de0d9b0ff7b261b1ea644a73c2943d2a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 9 Oct 2026 22:21:22 +0300 Subject: [PATCH] test: align resource availability metadata --- eslint-suppressions.json | 5 ---- .../income/income-analytics.service.ts | 5 +++- .../investments/investment-resource.mapper.ts | 5 ++++ .../application/portfolio.service.spec.ts | 1 + .../application/portfolio.service.ts | 18 ++++++++----- test/tbank-catalog-discovery.e2e-spec.ts | 5 ++-- test/tbank-income-local.e2e-spec.ts | 19 ++++++++------ test/tbank-ledger-local.e2e-spec.ts | 26 ++++++++++++------- test/tbank-local-reads.e2e-spec.ts | 17 ++++++------ 9 files changed, 62 insertions(+), 39 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d032e2c..aa4eafc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -136,11 +136,6 @@ "count": 1 } }, - "src/modules/investments/portfolio/application/portfolio.service.ts": { - "sonarjs/no-nested-conditional": { - "count": 1 - } - }, "src/modules/investments/tbank-sync/tbank-screen.ts": { "sonarjs/cognitive-complexity": { "count": 1 diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index fad1401..dffd297 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -119,7 +119,10 @@ export class IncomeAnalyticsService { ...investmentResourceMeta({ resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, dataRevision: current.meta.dataRevision, - availability: current.meta.availability, + availability: + current.items === null + ? 'temporarily-unavailable' + : 'available', fetchedAt: current.meta.lastSuccessfulAt ? new Date(current.meta.lastSuccessfulAt) : null, diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index 0a08e65..8cc265b 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -113,6 +113,11 @@ function availability(value: LegacyAvailability, work: Work) { return value; } if (value === 'complete') return 'available' as const; + if (value === 'partial') { + return work?.state === 'running' + ? ('initial-loading' as const) + : ('temporarily-unavailable' as const); + } return work?.state === 'running' ? ('initial-loading' as const) : ('temporarily-unavailable' as const); diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 433bc75..2b12f43 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -194,6 +194,7 @@ describe('persistent portfolio projection', () => { const partial = await service.read('user', 'all'); expect(partial.data).toBeNull(); expect(partial.meta.availability).toBe('temporarily-unavailable'); + expect(partial.meta.completeness).toBe('incomplete'); const saved = await service.saved('user', 'all'); expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual( snapshot.totalsByCurrency, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 2e8a227..61852ff 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -32,6 +32,7 @@ import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper'; import { PortfolioDto } from '../presentation/portfolio.dto'; const MISSING_DATA_REVISION = 0; +const NO_ACCOUNTS = 0; const membershipFreshForMs = tbankSourceTtlMs.membership; @@ -113,7 +114,11 @@ export class PortfolioService { const { hourly, ...saved } = await this.saved(userId, accountId); const missing = saved.membership.completeness === 'incomplete' || - saved.accounts.some((account) => !account.snapshot); + (saved.accounts.length > NO_ACCOUNTS && saved.accounts.some((account) => !account.snapshot)); + const available = + !missing && + (saved.membership.availability === 'available' || + saved.accounts.some((account) => account.snapshot)); const metadata = await this.metadata.savedMany( saved.accounts.flatMap((account) => account.snapshot?.positions ?? []), ); @@ -162,6 +167,11 @@ export class PortfolioService { ] : [], ); + let availability: 'available' | 'complete' | 'missing' | 'partial'; + if (available) availability = 'available'; + else if (!missing) availability = 'complete'; + else if (portfolios.length > NO_ACCOUNTS) availability = 'partial'; + else availability = 'missing'; const lastSuccessfulAt = // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? @@ -205,11 +215,7 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing - ? portfolios.length - ? 'partial' - : 'missing' - : 'complete', + availability, fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, freshForMs: portfolios.length ? portfolioFreshForMs diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 2658aa3..cab7432 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -85,14 +85,15 @@ describe('shared catalog discovery (SQLite)', () => { it('persists normalized query identity and confirmed empty separately from unknown, across users and reopen', async () => { const cold = await service.read('owner', { ...query, query: ' sber ' }); expect(cold.data).toBeNull(); - expect(cold.meta.availability).toBe('missing'); + expect(cold.meta.availability).toBe('initial-loading'); await settled(); await prisma.$disconnect(); service = createService(); const decrypt = jest.spyOn(tokens, 'decrypt'); const warm = await service.read('other', query); expect(warm.data).toEqual([instrument]); - expect(warm.meta.availability).toBe('complete'); + expect(warm.meta.availability).toBe('available'); + expect(warm.meta.completeness).toBe('complete'); expect(warm.meta.sync.id).toBeNull(); expect(decrypt).not.toHaveBeenCalled(); expect(upstream.findShares).toHaveBeenCalledTimes(1); diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 9c08e50..cbba85d 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -128,7 +128,8 @@ describe('local income completeness and exact money (SQLite)', () => { for (let index = 0; index < 2; index++) { const result = await read(); assert(result.data); - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([ { currency: 'RUB', value: '15.000000002' }, { currency: 'USD', value: '6' }, @@ -146,10 +147,9 @@ describe('local income completeness and exact money (SQLite)', () => { 'previous', 'year', ]); - expect( - result.meta.dependencies.find(({ role }) => role === 'previous') - ?.availability, - ).toBe('missing'); + const previous = result.meta.dependencies.find(({ role }) => role === 'previous'); + expect(previous?.availability).toBe('temporarily-unavailable'); + expect(previous?.completeness).toBe('incomplete'); expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); await prisma.$disconnect(); } @@ -161,7 +161,9 @@ describe('local income completeness and exact money (SQLite)', () => { where: { accountId: 'b' }, }); expect((await read()).data).toBeNull(); - expect((await read()).meta.availability).toBe('partial'); + const incomplete = await read(); + expect(incomplete.meta.availability).toBe('temporarily-unavailable'); + expect(incomplete.meta.completeness).toBe('incomplete'); expect((await read('a')).data).not.toBeNull(); expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull(); await coverage( @@ -241,7 +243,7 @@ describe('local income completeness and exact money (SQLite)', () => { await repo.fail(fence, false); const after = await read(); expect(after.data).toEqual(before.data); - expect(after.meta.sync.state).toBe('failed'); + expect(after.meta.lastAttempt.state).toBe('failed'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains current-month observed completeness and does not renew source timestamps', async () => { @@ -261,7 +263,8 @@ describe('local income completeness and exact money (SQLite)', () => { from: `${today.slice(0, 7)}-01`, to: today, }); - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); expect(result.meta.coverage?.asOf).toBe(asOf.toISOString()); expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index cce6abc..58725dc 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -118,7 +118,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { `limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, ).expect(200); const result = response.body as BrokerageOperationsResourceDto; - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); assert(result.data); ids.push( ...result.data.items.map((item) => `${item.account?.id}/${item.id}`), @@ -251,15 +252,18 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { .set('Authorization', auth()); const first = (await page().expect(200)) .body as BrokerageOperationsResourceDto; - expect(first.meta.availability).toBe('complete'); + expect(first.meta.availability).toBe('available'); + expect(first.meta.completeness).toBe('complete'); assert(first.data?.nextCursor); const second = (await page(first.data.nextCursor).expect(200)) .body as BrokerageOperationsResourceDto; - expect(second.meta.availability).toBe('complete'); + 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)) .body as BrokerageOperationsResourceDto; - expect(observed.meta.availability).toBe('complete'); + expect(observed.meta.availability).toBe('available'); + expect(observed.meta.completeness).toBe('complete'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }, ); @@ -384,12 +388,14 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { 'a', ).expect(200) ).body as BrokerageOperationsResourceDto; - expect(partial.meta.availability).toBe('partial'); - expect(partial.data?.hasNext).toBe(false); + expect(partial.meta.availability).toBe('initial-loading'); + 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)) .body as BrokerageOperationsResourceDto; - expect(whole.meta.availability).toBe('complete'); + expect(whole.meta.availability).toBe('available'); + expect(whole.meta.completeness).toBe('complete'); expect(whole.data?.items).toEqual([]); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThan(0); }); @@ -419,7 +425,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const cold = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(cold.data).toBeNull(); - expect(cold.meta.availability).toBe('missing'); + expect(cold.meta.availability).toBe('initial-loading'); + expect(cold.meta.completeness).toBe('incomplete'); await get('sync=observe', '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. @@ -434,7 +441,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const result = (await get('sync=observe', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(result.data?.items).toEqual([]); - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); await get('sync=observe', 'a').expect(200); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count); }); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 907867f..a90401a 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -151,7 +151,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200); expect((cold.body as PortfolioResourceDto).data).toBeNull(); expect((cold.body as PortfolioResourceDto).meta.availability).toBe( - 'missing', + 'temporarily-unavailable', ); await get('brokerage-accounts?sync=observe').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); @@ -321,7 +321,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { 'brokerage-accounts/account/portfolio?sync=observe', ).expect(200); expect((failed.body as PortfolioResourceDto).data).toEqual(previous); - expect((failed.body as PortfolioResourceDto).meta.sync.state).toBe('idle'); + expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none'); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); @@ -375,7 +375,7 @@ 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: 'missing' } }); + expect((await get('brokerage-accounts/account/portfolio?sync=observe').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({ data: [{ id: 'account', portfolioSummary: null }], @@ -423,13 +423,14 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { { currency: 'RUB', value: '21.1' }, ]); expect(summaries.data?.[1].portfolioSummary).toBeNull(); - expect(summaries.meta.availability).toBe('partial'); + expect(summaries.meta.availability).toBe('available'); + expect(summaries.meta.completeness).toBe('complete'); expect( (await get('brokerage-accounts/all/portfolio?sync=observe')).body, - ).toMatchObject({ data: null, meta: { availability: 'partial' } }); + ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect( (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, - ).toMatchObject({ data: null, meta: { availability: 'partial' } }); + ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); await prisma.tbankSyncWork.updateMany({ @@ -444,7 +445,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ); expect(position.body).toMatchObject({ data: { quantity: '2', accounts: [{ account: { id: 'account' } }] }, - meta: { availability: 'complete' }, + meta: { availability: 'available', completeness: 'complete' }, }); await prisma.$disconnect(); expect( @@ -495,7 +496,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { const empty = await get('brokerage-accounts?sync=observe').expect(200); expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( - 'complete', + 'available', ); await put().expect(200); await seedMembership();