From 417133e2d413e4f269b58b4723bb54e5508e705b Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 15:18:35 +0000 Subject: [PATCH] =?UTF-8?q?test(tst-005):=20Goals=20API=20integration=20te?= =?UTF-8?q?sts=20=E2=80=94=20validation,=20routing,=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/goals.e2e.spec.ts | 253 +++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 src/test/goals.e2e.spec.ts diff --git a/src/test/goals.e2e.spec.ts b/src/test/goals.e2e.spec.ts new file mode 100644 index 0000000..011bc0b --- /dev/null +++ b/src/test/goals.e2e.spec.ts @@ -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('isPublic', [ + context.getHandler(), context.getClass(), + ]); + if (isPublic) return true; + 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: '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); + }); + }); +});