/** * 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. * UUIDs are v4-compliant (version nibble=4, variant nibble=8) so @IsUUID() passes. */ 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 { GoalsController } from '../goals/goals.controller'; import { GoalsService } from '../goals/goals.service'; // ── Stub auth guard ──────────────────────────────────────────────────────── const FAKE_SESSION = { requestId: 'req-1', correlationId: 'cor-1', sessionId: 'ses-1', userId: '1a000000-0000-4000-8000-000000000001', tenantId: '1a000000-0000-4000-8000-000000000002', workspaceId: '1a000000-0000-4000-8000-000000000003', membershipId: '1a000000-0000-4000-8000-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 req = context.switchToHttp().getRequest<{ headers: Record; 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: '1a000000-0000-4000-8000-aaaa00000001', tenantId: '1a000000-0000-4000-8000-000000000002', ownerUserId: '1a000000-0000-4000-8000-000000000001', 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: '1a000000-0000-4000-8000-000000000001' }), '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', hackField: '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', () => { it('returns 200 with valid UUID and valid body', async () => { await request(app.getHttpServer()) .patch('/v1/goals/1a000000-0000-4000-8000-aaaa00000001') .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/1a000000-0000-4000-8000-aaaa00000001') .set('Authorization', AUTH) .send({ status: 'flying' }) .expect(400); }); }); // ── DELETE /v1/goals/:id ────────────────────────────────────────────────── describe('DELETE /v1/goals/:id', () => { it('returns 200 with valid UUID', async () => { await request(app.getHttpServer()) .delete('/v1/goals/1a000000-0000-4000-8000-aaaa00000001') .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); }); }); });