Files
backend/test/openapi-contract.e2e-spec.ts
T
ksv741 1d54e5bf3c
Backend verify / commits (pull_request) Successful in 36s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 6m13s
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
- income analytics returns only the selected period; previous/yearAgo
  comparison payloads, dependency roles and sync planning removed
- income entries now carry saved instrument logos for payment cards
- tbank grpc diagnostics distinguish queue wait, channel state and
  response headers without logging secrets
2026-10-13 17:45:36 +03:00

178 lines
6.0 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(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('generates the runtime contract without server startup or deployment configuration', async () => {
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 });
}
});
});