223 lines
7.6 KiB
TypeScript
223 lines
7.6 KiB
TypeScript
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(document.paths['/api/v1/tbank-sync/{id}']).toBeUndefined();
|
|
expect(schemas?.TbankSyncStatusDto).toBeUndefined();
|
|
expect(schemas).not.toHaveProperty('IncomeInvestmentScreenDto');
|
|
expect(schemas).not.toHaveProperty('EventsInvestmentScreenDto');
|
|
expect(schemas).not.toHaveProperty('TimestampInvestmentScreenDto');
|
|
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('does not advertise the retired observe query on investment reads', () => {
|
|
const document = createOpenApiDocument(app);
|
|
for (const [path, resource] of Object.entries(document.paths)) {
|
|
if (!path.startsWith('/api/v1/')) continue;
|
|
const parameters = resource.get?.parameters ?? [];
|
|
expect(parameters.some((parameter) => 'name' in parameter && parameter.name === 'sync')).toBe(false);
|
|
}
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|
|
});
|