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; const screenSchema = { oneOf: [ { $ref: '#/components/schemas/IncomeInvestmentScreenDto' }, { $ref: '#/components/schemas/EventsInvestmentScreenDto' }, { $ref: '#/components/schemas/TimestampInvestmentScreenDto' }, ], discriminator: { propertyName: 'screen', mapping: { income: '#/components/schemas/IncomeInvestmentScreenDto', events: '#/components/schemas/EventsInvestmentScreenDto', operations: '#/components/schemas/TimestampInvestmentScreenDto', }, }, }; expect( document.paths['/api/v1/tbank-sync'].post?.requestBody, ).toMatchObject({ content: { 'application/json': { schema: screenSchema } }, }); expect(schemas?.TbankSyncAcceptedDto).toMatchObject({ properties: { scope: screenSchema }, }); expect(schemas?.TbankSyncStatusDto).toMatchObject({ properties: { scope: { ...screenSchema, nullable: true } }, }); for (const [model, screen] of [ ['IncomeInvestmentScreenDto', 'income'], ['EventsInvestmentScreenDto', 'events'], ]) { 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 required semantic income dependency roles', () => { 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', 'previous', 'year'] }, }, }); }); it('generates the runtime contract without server startup or deployment configuration', async () => { const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); 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( await readFile(resolve(directory, 'openapi.json'), 'utf8'), ) as unknown; expect(generated).toEqual(createOpenApiDocument(app)); } finally { await rm(directory, { recursive: true, force: true }); } }); });