import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { createOpenApiDocument } from '../src/openapi'; describe('OpenAPI contract (integration)', () => { let app: INestApplication; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = module.createNestApplication(); configureApp(app); await app.init(); }); afterAll(() => app.close()); it('distinguishes calendar screen dates from operation instants in requests and responses', () => { 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'); for (const scope of ['all', '{accountId}']) { for (const [resource, format] of [ ['income-analytics', 'date'], ['events', 'date'], ['operations', 'date-time'], ]) { const parameters = document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get ?.parameters; for (const name of ['from', 'to']) { expect( parameters?.find( (parameter) => 'name' in parameter && parameter.name === name, ), ).toMatchObject({ name, in: 'query', schema: { format }, }); } } } }); it('exposes only the selected income dependency role and no comparison data', () => { const schemas = createOpenApiDocument(app).components?.schemas; expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({ properties: { meta: { $ref: '#/components/schemas/IncomeAnalyticsMetaDto' }, }, }); const meta = schemas?.IncomeAnalyticsMetaDto; assert(meta && !('$ref' in meta)); expect(meta.required).toContain('dependencies'); expect(meta).toMatchObject({ properties: { dependencies: { type: 'array', items: { $ref: '#/components/schemas/IncomeDependencyDto' }, }, }, }); const dependency = schemas?.IncomeDependencyDto; assert(dependency && !('$ref' in dependency)); expect(dependency.required).toContain('role'); expect(dependency).toMatchObject({ properties: { role: { type: 'string', enum: ['selected'] }, }, }); const analytics = schemas?.IncomeAnalyticsDto; assert(analytics && !('$ref' in analytics)); expect(analytics.required).toEqual( expect.arrayContaining([ 'accountId', 'period', 'totalsByCurrency', 'calculatedAt', 'bucketGranularity', 'buckets', 'instruments', 'entries', ]), ); expect(analytics.properties).not.toHaveProperty('previous'); expect(analytics.properties).not.toHaveProperty('yearComparison'); }); it('publishes every emitted problem detail code as a named enum', () => { const schemas = createOpenApiDocument(app).components?.schemas; const expectedCodes = [ 'BAD_REQUEST', 'INTERNAL_SERVER_ERROR', 'NOT_FOUND', 'UNAUTHORIZED', 'UNSUPPORTED_MEDIA_TYPE', 'VALIDATION_ERROR', 'EMAIL_ALREADY_REGISTERED', 'INVALID_CREDENTIALS', 'INVALID_OPERATIONS_CURSOR', 'BOND_HISTORY_START_UNAVAILABLE', 'BOND_TICKER_AMBIGUOUS', 'STOCK_HISTORY_START_UNAVAILABLE', 'STOCK_TICKER_AMBIGUOUS', 'INVALID_TBANK_TOKEN', 'TBANK_NOT_CONNECTED', 'TBANK_UNAVAILABLE', 'BANK_CARD_ARCHIVED', 'BANK_DEPENDENT_REFUNDS', 'BANK_DUPLICATE_CATEGORY', 'BANK_IMMUTABLE_OPERATION', 'BANK_INTENT_CONFLICT', 'BANK_INVALID_DATE', 'BANK_INVALID_INPUT', 'BANK_INVALID_MONEY', 'BANK_INVALID_REFUND', 'BANK_NOT_FOUND', 'BANK_OPENING_DATE_CONFLICT', 'BANK_OPERATION_DELETED', 'BANK_REFUND_LIMIT', 'BANK_VERSION_CONFLICT', 'BANK_WRITE_CONFLICT', 'MOEX_UNAVAILABLE', 'NCR_UNAVAILABLE', 'INVALID_EVENTS_PERIOD', 'INVALID_INCOME_PERIOD', ]; const problem = schemas?.ProblemDetailsDto; assert(problem && !('$ref' in problem)); const codeSchema = problem.properties?.code; assert(codeSchema && !('$ref' in codeSchema)); expect(codeSchema.type).toBe('string'); expect(codeSchema.enum).toEqual(expect.arrayContaining(expectedCodes)); expect(codeSchema.enum).toHaveLength(expectedCodes.length); }); it('generates the runtime contract without server startup or deployment configuration', async () => { const schemas = createOpenApiDocument(app).components?.schemas; expect(schemas?.StockScreenerCriteriaDto).toMatchObject({ additionalProperties: false, }); expect(schemas?.StockScreenerItemDto).toMatchObject({ properties: { lot: { type: 'integer', minimum: 1, nullable: true } }, }); expect( createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto, ).toMatchObject({ properties: { errors: { type: 'array', items: { type: 'object', required: ['pointer', 'detail'], properties: { pointer: { type: 'string' }, detail: { type: 'string' }, }, }, }, }, }); const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await writeFile( networkGuard, ` const net = require('node:net'); const forbidden = () => { throw new Error('Schema generation must not use the network'); }; net.Server.prototype.listen = forbidden; net.Socket.prototype.connect = forbidden; `, ); execFileSync( process.execPath, [ '--require', networkGuard, resolve(__dirname, '../scripts/generate-openapi.cjs'), ], { cwd: directory, env: { PATH: process.env.PATH }, timeout: 30_000, }, ); const generated = JSON.parse( // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await readFile(resolve(directory, 'openapi.json'), 'utf8'), ) as unknown; expect(generated).toEqual(createOpenApiDocument(app)); } finally { await rm(directory, { recursive: true, force: true }); } }); });