241 lines
8.4 KiB
TypeScript
241 lines
8.4 KiB
TypeScript
/**
|
|
* 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<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: '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);
|
|
});
|
|
});
|
|
});
|