diff --git a/src/test/transactions.e2e.spec.ts b/src/test/transactions.e2e.spec.ts new file mode 100644 index 0000000..82bffbe --- /dev/null +++ b/src/test/transactions.e2e.spec.ts @@ -0,0 +1,177 @@ +/** + * TST-005: Transactions API Integration Tests (CC-047) + * + * Tests HTTP contract of TransactionsController: + * - validation of amountMinorUnits as integer + * - currency and evidenceStatus validation + * - ParseUUIDPipe on path params + * - response shapes + */ +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 { TransactionsController } from '../transactions/transactions.controller'; +import { TransactionsService } from '../transactions/transactions.service'; + +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 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(); + req.session = FAKE_SESSION; + return true; + } +} + +const MOCK_TX = { + id: '00000000-0000-0000-0000-bbbb00000001', + tenantId: FAKE_SESSION.tenantId, + organizationId: '00000000-0000-0000-0000-000000000005', + type: 'invoice', + amountMinorUnits: '123400', + currency: 'EUR', + transactionDate: '2026-07-31', + evidenceStatus: 'missing', + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockTxService = { + list: jest.fn().mockResolvedValue([MOCK_TX]), + getById: jest.fn().mockResolvedValue(MOCK_TX), + create: jest.fn().mockResolvedValue(MOCK_TX), + updateEvidence: jest.fn().mockResolvedValue({ ...MOCK_TX, evidenceStatus: 'complete' }), +}; + +@Module({ + imports: [ConfigModule.forRoot({ isGlobal: true })], + controllers: [TransactionsController], + providers: [ + { provide: TransactionsService, useValue: mockTxService }, + { provide: APP_GUARD, useClass: StubAuthGuard }, + ], +}) +class TxTestModule {} + +const AUTH = 'Bearer test-token'; +const VALID_UUID = '00000000-0000-0000-0000-bbbb00000001'; +const VALID_ORG_UUID = '00000000-0000-0000-0000-000000000005'; + +describe('TST-005: Transactions API Integration', () => { + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ imports: [TxTestModule] }).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()); + + describe('POST /v1/transactions', () => { + const VALID_BODY = { + organizationId: VALID_ORG_UUID, + type: 'invoice', + amountMinorUnits: 123400, + currency: 'EUR', + transactionDate: '2026-07-31', + evidenceStatus: 'missing', + }; + + it('returns 201 with valid body', async () => { + await request(app.getHttpServer()) + .post('/v1/transactions') + .set('Authorization', AUTH) + .send(VALID_BODY) + .expect(201); + expect(mockTxService.create).toHaveBeenCalledTimes(1); + }); + + it('returns 400 when amountMinorUnits is float', async () => { + await request(app.getHttpServer()) + .post('/v1/transactions') + .set('Authorization', AUTH) + .send({ ...VALID_BODY, amountMinorUnits: 1234.56 }) + .expect(400); + }); + + it('returns 400 when amountMinorUnits is negative', async () => { + await request(app.getHttpServer()) + .post('/v1/transactions') + .set('Authorization', AUTH) + .send({ ...VALID_BODY, amountMinorUnits: -100 }) + .expect(400); + }); + + it('returns 400 when evidenceStatus is invalid', async () => { + await request(app.getHttpServer()) + .post('/v1/transactions') + .set('Authorization', AUTH) + .send({ ...VALID_BODY, evidenceStatus: 'unknown_status' }) + .expect(400); + }); + + it('returns 400 when organizationId is not UUID', async () => { + await request(app.getHttpServer()) + .post('/v1/transactions') + .set('Authorization', AUTH) + .send({ ...VALID_BODY, organizationId: 'not-a-uuid' }) + .expect(400); + }); + + it('returns 400 when required field missing (currency)', async () => { + const { currency: _c, ...bodyWithoutCurrency } = VALID_BODY; + await request(app.getHttpServer()) + .post('/v1/transactions') + .set('Authorization', AUTH) + .send(bodyWithoutCurrency) + .expect(400); + }); + }); + + describe('PATCH /v1/transactions/:id/evidence', () => { + it('returns 200 with valid UUID and valid body', async () => { + await request(app.getHttpServer()) + .patch(`/v1/transactions/${VALID_UUID}/evidence`) + .set('Authorization', AUTH) + .send({ evidenceStatus: 'complete' }) + .expect(200); + }); + + it('returns 400 for non-UUID path param', async () => { + await request(app.getHttpServer()) + .patch('/v1/transactions/not-a-uuid/evidence') + .set('Authorization', AUTH) + .send({ evidenceStatus: 'complete' }) + .expect(400); + }); + }); +});