test(tst-005): Goals API integration tests — validation, routing, auth

This commit is contained in:
admin-valentin 2026-07-31 15:18:35 +00:00
parent 5ac1fcb5d5
commit 417133e2d4

253
src/test/goals.e2e.spec.ts Normal file
View file

@ -0,0 +1,253 @@
/**
* TST-005: Goals API Integration Tests (CC-047)
*
* Tests HTTP contract of GoalsController:
* - routing and response shapes
* - ValidationPipe enforcement (missing fields, extra fields, invalid enums)
* - ParseUUIDPipe on path params
* - BadRequestException for invalid status filter
*
* GoalsService is mocked no real DB needed.
*/
import request from 'supertest';
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
INestApplication,
ValidationPipe,
Module,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test, type TestingModule } from '@nestjs/testing';
import helmet from 'helmet';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { SetMetadata } from '@nestjs/common';
import { GoalsController } from '../goals/goals.controller';
import { GoalsService } from '../goals/goals.service';
// ── Stub auth guard ────────────────────────────────────────────────────────
const IS_PUBLIC = 'isPublic';
const Public = () => SetMetadata(IS_PUBLIC, true);
void Public; // referenced in controller via global metadata
const FAKE_SESSION = {
requestId: 'req-1',
correlationId: 'cor-1',
sessionId: 'ses-1',
userId: '00000000-0000-0000-0000-000000000001',
tenantId: '00000000-0000-0000-0000-000000000002',
workspaceId: '00000000-0000-0000-0000-000000000003',
membershipId: '00000000-0000-0000-0000-000000000004',
role: 'owner' as const,
roles: ['owner' as const],
permissions: ['*'],
purpose: 'test',
timezone: 'UTC',
source: 'api' as const,
};
@Injectable()
class StubAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(), context.getClass(),
]);
if (isPublic) return true;
const req = context.switchToHttp().getRequest<{ headers: Record<string, string>; session?: typeof FAKE_SESSION }>();
const auth = req.headers['authorization'] ?? '';
if (!auth.startsWith('Bearer ') || !auth.slice(7).trim()) {
throw new UnauthorizedException('Missing bearer token');
}
req.session = FAKE_SESSION;
return true;
}
}
// ── Mock service ───────────────────────────────────────────────────────────
const MOCK_GOAL = {
id: '00000000-0000-0000-0000-aaaa00000001',
tenantId: FAKE_SESSION.tenantId,
ownerUserId: FAKE_SESSION.userId,
horizon: 'Q4 2026',
metric: 'MRR',
target: '100k',
status: 'not_started',
milestones: [],
createdAt: new Date('2026-07-31'),
updatedAt: new Date('2026-07-31'),
deletedAt: null,
version: 1,
};
const mockGoalsService = {
list: jest.fn().mockResolvedValue([MOCK_GOAL]),
getById: jest.fn().mockResolvedValue(MOCK_GOAL),
create: jest.fn().mockResolvedValue(MOCK_GOAL),
update: jest.fn().mockResolvedValue({ ...MOCK_GOAL, status: 'on_track' }),
softDelete: jest.fn().mockResolvedValue({ deleted: true }),
};
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
controllers: [GoalsController],
providers: [
{ provide: GoalsService, useValue: mockGoalsService },
{ provide: APP_GUARD, useClass: StubAuthGuard },
],
})
class GoalsTestModule {}
// ── Suite ──────────────────────────────────────────────────────────────────
const AUTH = 'Bearer test-token';
describe('TST-005: Goals API Integration', () => {
let app: INestApplication;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [GoalsTestModule],
}).compile();
app = module.createNestApplication();
app.use(helmet());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
app.setGlobalPrefix('v1');
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
// ── GET /v1/goals ────────────────────────────────────────────────────────
describe('GET /v1/goals', () => {
it('returns 401 without auth', async () => {
await request(app.getHttpServer()).get('/v1/goals').expect(401);
});
it('returns 200 with array when authenticated', async () => {
const res = await request(app.getHttpServer())
.get('/v1/goals')
.set('Authorization', AUTH)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(mockGoalsService.list).toHaveBeenCalledTimes(1);
});
it('passes status filter to service', async () => {
await request(app.getHttpServer())
.get('/v1/goals?status=on_track')
.set('Authorization', AUTH)
.expect(200);
expect(mockGoalsService.list).toHaveBeenCalledWith(
expect.objectContaining({ userId: FAKE_SESSION.userId }),
'on_track',
);
});
it('returns 400 for invalid status value', async () => {
await request(app.getHttpServer())
.get('/v1/goals?status=invalid_status')
.set('Authorization', AUTH)
.expect(400);
});
});
// ── POST /v1/goals ────────────────────────────────────────────────────────
describe('POST /v1/goals', () => {
it('returns 201 with valid body', async () => {
const res = await request(app.getHttpServer())
.post('/v1/goals')
.set('Authorization', AUTH)
.send({ horizon: 'Q4 2026', metric: 'MRR', target: '100k' })
.expect(201);
expect(res.body).toMatchObject({ horizon: 'Q4 2026' });
expect(mockGoalsService.create).toHaveBeenCalledTimes(1);
});
it('returns 400 when required field missing (metric)', async () => {
await request(app.getHttpServer())
.post('/v1/goals')
.set('Authorization', AUTH)
.send({ horizon: 'Q4 2026', target: '100k' })
.expect(400);
});
it('returns 400 for extra non-whitelisted field', async () => {
await request(app.getHttpServer())
.post('/v1/goals')
.set('Authorization', AUTH)
.send({ horizon: 'Q4 2026', metric: 'MRR', target: '100k', __proto__: 'injection' })
.expect(400);
});
it('returns 400 when horizon is empty string', async () => {
await request(app.getHttpServer())
.post('/v1/goals')
.set('Authorization', AUTH)
.send({ horizon: '', metric: 'MRR', target: '100k' })
.expect(400);
});
});
// ── PATCH /v1/goals/:id ───────────────────────────────────────────────────
describe('PATCH /v1/goals/:id', () => {
const VALID_UUID = '00000000-0000-0000-0000-aaaa00000001';
it('returns 200 with valid UUID and valid body', async () => {
await request(app.getHttpServer())
.patch(`/v1/goals/${VALID_UUID}`)
.set('Authorization', AUTH)
.send({ status: 'on_track' })
.expect(200);
});
it('returns 400 for non-UUID path param', async () => {
await request(app.getHttpServer())
.patch('/v1/goals/not-a-uuid')
.set('Authorization', AUTH)
.send({ status: 'on_track' })
.expect(400);
});
it('returns 400 for invalid status value in body', async () => {
await request(app.getHttpServer())
.patch(`/v1/goals/${VALID_UUID}`)
.set('Authorization', AUTH)
.send({ status: 'flying' })
.expect(400);
});
});
// ── DELETE /v1/goals/:id ──────────────────────────────────────────────────
describe('DELETE /v1/goals/:id', () => {
const VALID_UUID = '00000000-0000-0000-0000-aaaa00000001';
it('returns 200 with valid UUID', async () => {
await request(app.getHttpServer())
.delete(`/v1/goals/${VALID_UUID}`)
.set('Authorization', AUTH)
.expect(200);
expect(mockGoalsService.softDelete).toHaveBeenCalledTimes(1);
});
it('returns 400 for non-UUID path param', async () => {
await request(app.getHttpServer())
.delete('/v1/goals/not-a-uuid')
.set('Authorization', AUTH)
.expect(400);
});
});
});