refactor!: simplify runtime coordination #7
Keep financial ownership, atomic publication and refresh CAS. Failed refresh leaves cookies untouched; logout still clears them. Remove full-history candle prefetch and special metadata retries. Use native Pino request context instead of parallel middleware. BREAKING CHANGE: remove investment stateVersion, queued sync state, and tbank-sync status progress/resources. Consumers must observe resources through sync=observe with the updated frontend contract.
This commit is contained in:
+27
-17
@@ -1,12 +1,13 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { StructuredLogger } from '../src/common/logging/structured-logger.service';
|
||||
import { loggingOptions } from '../src/common/logging/logging.module';
|
||||
import { configureApp } from '../src/main';
|
||||
import { AuthService } from '../src/modules/auth/application/auth.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
@@ -16,10 +17,22 @@ type Problem = { code: string };
|
||||
|
||||
describe('Authentication (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
const logs: Record<string, unknown>[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
logs.length = 0;
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({
|
||||
inject: [ConfigService],
|
||||
factory: (config: Parameters<typeof loggingOptions>[0]) => ({
|
||||
pinoHttp: {
|
||||
...loggingOptions(config),
|
||||
level: 'info',
|
||||
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
|
||||
},
|
||||
assignResponse: true,
|
||||
}),
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
@@ -187,10 +200,12 @@ describe('Authentication (e2e)', () => {
|
||||
]);
|
||||
const winner = responses.find((response) => response.status === 200);
|
||||
if (!winner) throw new Error('Expected one successful rotation');
|
||||
expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined();
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/refresh')
|
||||
.set('Cookie', cookie)
|
||||
.expect(401);
|
||||
.expect(401)
|
||||
.expect((response) => expect(response.headers['set-cookie']).toBeUndefined());
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/refresh')
|
||||
.set('Cookie', winner.headers['set-cookie'][0])
|
||||
@@ -304,8 +319,6 @@ describe('Authentication (e2e)', () => {
|
||||
});
|
||||
|
||||
it('exposes a server request ID and logs one completion event', async () => {
|
||||
const logger = app.get(StructuredLogger);
|
||||
const event = jest.spyOn(logger, 'event');
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Origin', 'http://localhost:5173')
|
||||
@@ -315,16 +328,12 @@ describe('Authentication (e2e)', () => {
|
||||
expect(response.headers['access-control-expose-headers']).toContain(
|
||||
'X-Request-ID',
|
||||
);
|
||||
expect(event).toHaveBeenCalledTimes(1);
|
||||
expect(event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'http.request.completed',
|
||||
expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }),
|
||||
);
|
||||
expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
|
||||
expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('logs an unhandled error with safe public context and correlation', async () => {
|
||||
const error = jest.spyOn(PinoLogger.prototype, 'error');
|
||||
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
|
||||
jest
|
||||
.spyOn(app.get(AuthService), 'login')
|
||||
@@ -342,17 +351,18 @@ describe('Authentication (e2e)', () => {
|
||||
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
|
||||
);
|
||||
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']);
|
||||
expect(completed).toEqual([
|
||||
expect.objectContaining({
|
||||
requestId: response.headers['x-request-id'],
|
||||
clientRequestId,
|
||||
status: 500,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
level: 50,
|
||||
message: 'http.request.completed',
|
||||
}),
|
||||
'http.request.completed',
|
||||
);
|
||||
expect(JSON.stringify(error.mock.calls)).not.toContain(
|
||||
]);
|
||||
expect(JSON.stringify(logs)).not.toContain(
|
||||
'SYNTHETIC_AUDIT_SECRET',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
lastSuccessfulAt: null,
|
||||
resourceKey: 'detail/uid',
|
||||
dataRevision: '1',
|
||||
stateVersion: 'detail-v1',
|
||||
sync: { id: null, state: 'idle', errorCode: null },
|
||||
},
|
||||
},
|
||||
@@ -273,7 +272,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
).toBe(abandoned?.work.attemptId);
|
||||
await reopened.read('owner', input);
|
||||
await settled();
|
||||
expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(await repository.work('owner', 'revision', 'candles:uid:month'))
|
||||
?.attemptId,
|
||||
@@ -483,52 +482,20 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }),
|
||||
).toBe(1);
|
||||
});
|
||||
it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => {
|
||||
await prisma.tbankInstrument.create({
|
||||
data: {
|
||||
provider: 'tbank',
|
||||
uid: 'opened',
|
||||
instrumentType: 'share',
|
||||
metadata: {},
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
it('loads only the requested period and reuses its saved coverage', async () => {
|
||||
const service = createService();
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
const blocked = new Promise<unknown[]>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
upstream.getCandles.mockReturnValueOnce(blocked);
|
||||
await service.prefetch('owner', 'revision', [
|
||||
{ instrumentUid: 'uid', type: 'STOCK' },
|
||||
{ instrumentUid: 'uid', type: 'STOCK' },
|
||||
{ instrumentUid: 'not-supported', type: 'FUND' },
|
||||
{ instrumentUid: null, type: 'BOND' },
|
||||
]);
|
||||
for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid');
|
||||
await service.read('owner', {
|
||||
uid: 'opened',
|
||||
const input = {
|
||||
uid: 'uid',
|
||||
instrumentType: 'share',
|
||||
period: 'month',
|
||||
});
|
||||
release([]);
|
||||
} as const;
|
||||
await service.read('owner', input);
|
||||
await settled();
|
||||
expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe(
|
||||
'opened',
|
||||
);
|
||||
expect(
|
||||
new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])),
|
||||
).toEqual(new Set(['opened', 'uid']));
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||
expect(await repository.work('owner', 'revision', 'candles:uid:all')).toBeNull();
|
||||
const count = upstream.getCandles.mock.calls.length;
|
||||
await service.prefetch('owner', 'revision', [
|
||||
{ instrumentUid: 'uid', type: 'STOCK' },
|
||||
]);
|
||||
await service.read('owner', input);
|
||||
await settled();
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
|
||||
expect(
|
||||
(await repository.work('owner', 'revision', 'candles:opened:all'))?.state,
|
||||
).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it('keeps status versions stable between observational reads of a moving period', async () => {
|
||||
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')
|
||||
@@ -276,7 +276,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(second.meta.coverage?.requested).not.toEqual(
|
||||
first.meta.coverage?.requested,
|
||||
);
|
||||
expect(second.meta.stateVersion).toBe(first.meta.stateVersion);
|
||||
expect(second.meta.dataRevision).toBe(first.meta.dataRevision);
|
||||
await prisma.tbankOperationCoverage.updateMany({
|
||||
data: {
|
||||
to: new Date(Date.now() - 60_000),
|
||||
@@ -285,10 +285,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
});
|
||||
const changed = (await observe().expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(changed.meta.stateVersion).not.toBe(second.meta.stateVersion);
|
||||
expect(changed.meta.coverage?.asOf).not.toBe(second.meta.coverage?.asOf);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('reads shared metadata from SQLite and changes stateVersion without invalidating a row cursor', async () => {
|
||||
it('reads shared metadata from SQLite without invalidating a row cursor', async () => {
|
||||
const before = (await get('limit=1&sync=observe').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
assert(before.data?.nextCursor);
|
||||
@@ -323,7 +323,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
const after = (await get('limit=1&sync=observe').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(after.data?.items[0].logoName).toBe('saved.png');
|
||||
expect(after.meta.stateVersion).not.toBe(before.meta.stateVersion);
|
||||
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
|
||||
await get(
|
||||
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
||||
|
||||
@@ -134,12 +134,6 @@ describe('T-Bank persisted read budget', () => {
|
||||
'bond',
|
||||
instruments.find((item) => item.uid === uid),
|
||||
),
|
||||
getLastPrice: (_token: string, uid: string) =>
|
||||
counted('lastPrice', {
|
||||
instrumentUid: uid,
|
||||
price: { units: '100' },
|
||||
time: { seconds: Date.UTC(2026, 7, 31) / 1000 },
|
||||
}),
|
||||
getLastPrices: (_token: string, uids: string[]) =>
|
||||
counted(
|
||||
'lastPrices',
|
||||
|
||||
@@ -103,10 +103,11 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
release([]);
|
||||
const done = await settled(ids[0]);
|
||||
expect(done.state).toBe('succeeded');
|
||||
expect(done.resources[0]).toMatchObject({
|
||||
resourceKey: 'accounts',
|
||||
availability: 'complete',
|
||||
});
|
||||
const accounts = await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts?sync=observe')
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } });
|
||||
expect(JSON.stringify(done)).not.toMatch(
|
||||
/revision|attemptId|encryptedToken|synthetic/,
|
||||
);
|
||||
@@ -297,20 +298,17 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
const first = before.body as {
|
||||
meta: { dataRevision: string; stateVersion: string };
|
||||
meta: { dataRevision: string };
|
||||
};
|
||||
const last = after.body as {
|
||||
data: { items: unknown[] };
|
||||
meta: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
sync: { id: string };
|
||||
};
|
||||
};
|
||||
expect(last.data.items).toEqual([]);
|
||||
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
|
||||
expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion);
|
||||
expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion);
|
||||
await status(last.meta.sync.id).expect(200);
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -389,7 +387,6 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
try {
|
||||
expect((await status(second).expect(200)).body).toMatchObject({
|
||||
state: 'running',
|
||||
progress: { completed: 1, total: 2, discovering: false },
|
||||
});
|
||||
await status(second, 'other').expect(404);
|
||||
} finally {
|
||||
@@ -401,7 +398,6 @@ describe('screen sync HTTP and SQLite', () => {
|
||||
expect(await settled(second)).toMatchObject({
|
||||
state: outcome,
|
||||
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
|
||||
progress: { completed: 2, total: 2, discovering: false },
|
||||
});
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
await status(second).expect(404);
|
||||
|
||||
Reference in New Issue
Block a user