Files
TREK/server/tests/unit/nest/common/response-contract.test.ts
Maurice 87071bf738 feat(server): @ResponseContract checks a handler's responses against its shared schema in tests
The decorator records the @trek/shared response schema a route answers
with. Under NODE_ENV=test it adds an interceptor that parses every response
and fails the request with a logged 500 naming the handler and the issues,
so the e2e and integration suites catch a response that drifted from its
contract; in production nothing is added to the request path and the body
is never rewritten. lint:response-contracts holds the handlers still without
one to a per-file baseline that only shrinks, and runs in server-quality.
2026-10-10 20:16:08 +02:00

145 lines
5.2 KiB
TypeScript

/**
* @ResponseContract (src/nest/common/response-contract.ts): under NODE_ENV=test every
* response of a decorated handler is parsed against its shared schema, and one the
* schema does not describe fails the request; the body itself is never rewritten.
*/
import {
RESPONSE_CONTRACT_KEY,
ResponseContract,
ResponseContractInterceptor,
ResponseContractViolation,
assertResponseContract,
responseContractsEnforced,
} from '../../../../src/nest/common/response-contract';
import { TrekExceptionFilter } from '../../../../src/nest/common/trek-exception.filter';
import { Controller, Get, Module, type CallHandler, type ExecutionContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { lastValueFrom, of } from 'rxjs';
import request from 'supertest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
const itemSchema = z.object({ id: z.number(), name: z.string() });
function context(): ExecutionContext {
class ItemsController {}
function show() {}
return { getClass: () => ItemsController, getHandler: () => show } as unknown as ExecutionContext;
}
const handled = (body: unknown): CallHandler => ({ handle: () => of(body) });
describe('assertResponseContract', () => {
it('passes a body the schema describes', () => {
expect(() => assertResponseContract(itemSchema, { id: 1, name: 'a' }, 'X.y')).not.toThrow();
});
it('names the handler and the first issues of a body it does not describe', () => {
expect(() => assertResponseContract(itemSchema, { id: '1' }, 'ItemsController.show')).toThrow(
/^ItemsController\.show answered outside its response contract: id: .*; name: /,
);
});
it('marks the root when the body itself is wrong', () => {
expect(() => assertResponseContract(itemSchema, undefined, 'X.y')).toThrow(/: \(root\): /);
});
it('names at most five issues', () => {
const wide = z.object(Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`f${i}`, z.string()])));
try {
assertResponseContract(wide, {}, 'X.y');
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(ResponseContractViolation);
expect((e as Error).message.split('; ')).toHaveLength(5);
}
});
});
describe('ResponseContractInterceptor', () => {
it('hands the body on untouched, extra keys included', async () => {
const body = { id: 1, name: 'a', extra: true };
const out = await lastValueFrom(new ResponseContractInterceptor(itemSchema).intercept(context(), handled(body)));
expect(out).toBe(body);
});
it('fails a body outside the schema with the handler named', async () => {
const run = lastValueFrom(new ResponseContractInterceptor(itemSchema).intercept(context(), handled({ id: 1 })));
await expect(run).rejects.toThrow(/ItemsController\.show answered outside its response contract: name: /);
});
});
describe('ResponseContract', () => {
afterEach(() => vi.unstubAllEnvs());
it('is enforced under NODE_ENV=test only', () => {
expect(responseContractsEnforced()).toBe(true);
vi.stubEnv('NODE_ENV', 'production');
expect(responseContractsEnforced()).toBe(false);
});
it('records the schema on the handler', () => {
class C {
@ResponseContract(itemSchema)
show() {
return { id: 1, name: 'a' };
}
}
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, C.prototype.show)).toBe(itemSchema);
expect(Reflect.getMetadata('__interceptors__', C.prototype.show)).toHaveLength(1);
});
it('adds no interceptor outside of tests, so production requests carry no check', () => {
vi.stubEnv('NODE_ENV', 'production');
class C {
@ResponseContract(itemSchema)
show() {
return { id: 1 };
}
}
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, C.prototype.show)).toBe(itemSchema);
expect(Reflect.getMetadata('__interceptors__', C.prototype.show)).toBeUndefined();
});
it('answers a drifted response with the generic 500 and logs which handler drifted', async () => {
@Controller('items')
class ItemsController {
@Get('good')
@ResponseContract(itemSchema)
good() {
return { id: 1, name: 'a' };
}
@Get('bad')
@ResponseContract(itemSchema)
bad() {
return { id: 1, title: 'a' };
}
}
@Module({ controllers: [ItemsController] })
class ItemsModule {}
const moduleRef = await Test.createTestingModule({ imports: [ItemsModule] }).compile();
const app = moduleRef.createNestApplication();
app.useGlobalFilters(new TrekExceptionFilter());
await app.init();
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const good = await request(app.getHttpServer()).get('/items/good');
expect(good.status).toBe(200);
expect(good.body).toEqual({ id: 1, name: 'a' });
const bad = await request(app.getHttpServer()).get('/items/bad');
expect(bad.status).toBe(500);
expect(bad.body).toEqual({ error: 'Internal server error' });
expect(String(logged.mock.calls[0]?.[1])).toMatch(
/ItemsController\.bad answered outside its response contract: name: /,
);
} finally {
logged.mockRestore();
await app.close();
}
});
});