import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import * as request from 'supertest'; import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; import { EventsModule } from '../src/events/events.module'; import { AuthModule } from '../src/auth/auth.module'; import { DevicesModule } from '../src/devices/devices.module'; import { RawEvent } from '../src/entities/raw-event.entity'; import { Device } from '../src/entities/device.entity'; import { InventoryDevice } from '../src/entities/inventory-device.entity'; import { UserProfile } from '../src/entities/user-profile.entity'; import { UserIdentity } from '../src/entities/user-identity.entity'; import { ValidatedEvent } from '../src/entities/validated-event.entity'; import { AwsService } from '../src/aws/aws.service'; describe('Events (e2e)', () => { let app: INestApplication; let rawEventRepository: Repository; let deviceRepository: Repository; let inventoryDeviceRepository: Repository; let userProfileRepository: Repository; let userIdentityRepository: Repository; let validatedEventRepository: Repository; const testDatabaseUrl = process.env.TEST_DATABASE_URL; // Mock AWS Service for testing const mockAwsService = { uploadFile: jest.fn(), sendProcessingMessage: jest.fn(), healthCheck: jest.fn(), }; beforeAll(async () => { if (!testDatabaseUrl) { throw new Error('TEST_DATABASE_URL environment variable is required'); } const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot({ type: 'postgres', url: testDatabaseUrl, entities: [ UserProfile, UserIdentity, Device, InventoryDevice, RawEvent, ValidatedEvent, ], synchronize: false, logging: false, }), AuthModule, DevicesModule, EventsModule, JwtModule.register({ secret: 'test-secret', signOptions: { expiresIn: '1h' }, }), ], }) .overrideProvider(AwsService) .useValue(mockAwsService) .compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); await app.init(); rawEventRepository = moduleFixture.get>( getRepositoryToken(RawEvent), ); deviceRepository = moduleFixture.get>( getRepositoryToken(Device), ); inventoryDeviceRepository = moduleFixture.get>( getRepositoryToken(InventoryDevice), ); userProfileRepository = moduleFixture.get>( getRepositoryToken(UserProfile), ); userIdentityRepository = moduleFixture.get>( getRepositoryToken(UserIdentity), ); validatedEventRepository = moduleFixture.get>( getRepositoryToken(ValidatedEvent), ); }); afterAll(async () => { await app.close(); }); beforeEach(async () => { // Clean up test data await validatedEventRepository.delete({}); await rawEventRepository.delete({}); await deviceRepository.delete({}); await inventoryDeviceRepository.delete({}); await userIdentityRepository.delete({}); await userProfileRepository.delete({}); // Reset mock functions jest.clearAllMocks(); }); describe('POST /api/v1/events/upload', () => { let authToken: string; let testDevice: Device; beforeEach(async () => { // Register and login user await request(app.getHttpServer()) .post('/api/v1/auth/register-email') .send({ email: 'events-test@example.com', password: 'TestPassword123', displayName: 'Events Test User', }); const loginResponse = await request(app.getHttpServer()) .post('/api/v1/auth/login-email') .send({ email: 'events-test@example.com', password: 'TestPassword123', }); authToken = loginResponse.body.accessToken; // Create inventory device and register it const inventoryDevice = inventoryDeviceRepository.create({ hardwareId: 'EVENT_TEST_DEVICE_001', isClaimed: false, }); await inventoryDeviceRepository.save(inventoryDevice); const registerResponse = await request(app.getHttpServer()) .post('/api/v1/devices/register') .set('Authorization', `Bearer ${authToken}`) .send({ hardwareId: 'EVENT_TEST_DEVICE_001', }); testDevice = registerResponse.body.device; }); it('should reject unauthenticated requests', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .attach('file', Buffer.from('test file'), 'test.jpg') .field( 'eventData', JSON.stringify({ eventType: 'motion', eventTimestamp: new Date().toISOString(), metadata: { deviceId: testDevice.id }, }), ); expect(response.status).toBe(401); }); it('should reject requests without file', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .field( 'eventData', JSON.stringify({ eventType: 'motion', eventTimestamp: new Date().toISOString(), metadata: { deviceId: testDevice.id }, }), ); expect(response.status).toBe(400); expect(response.body.message).toContain('No file provided'); }); it('should reject requests without event data', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .attach('file', Buffer.from('test file'), 'test.jpg'); expect(response.status).toBe(400); expect(response.body.message).toContain('No event data provided'); }); it('should reject invalid event data JSON', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .attach('file', Buffer.from('test file'), 'test.jpg') .field('eventData', 'invalid json'); expect(response.status).toBe(400); expect(response.body.message).toContain('Invalid JSON'); }); it('should reject missing device ID in metadata', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .attach('file', Buffer.from('test file'), 'test.jpg') .field( 'eventData', JSON.stringify({ eventType: 'motion', eventTimestamp: new Date().toISOString(), metadata: {}, }), ); expect(response.status).toBe(400); expect(response.body.message).toContain('Device ID must be provided'); }); it('should successfully upload an event', async () => { // Mock AWS service responses mockAwsService.uploadFile.mockResolvedValue( 'events/test-device/motion/2023-07-31_uuid.jpg', ); mockAwsService.sendProcessingMessage.mockResolvedValue('msg-123'); const eventData = { eventType: 'motion', eventTimestamp: new Date().toISOString(), metadata: { deviceId: testDevice.id, location: 'front_door', confidence: 0.95, }, }; const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .attach('file', Buffer.from('fake image data'), 'motion_capture.jpg') .field('eventData', JSON.stringify(eventData)); expect(response.status).toBe(202); expect(response.body.rawEventId).toBeDefined(); expect(response.body.message).toContain('uploaded successfully'); // Verify file upload was called expect(mockAwsService.uploadFile).toHaveBeenCalledWith({ buffer: expect.any(Buffer), originalFilename: 'motion_capture.jpg', mimeType: 'image/jpeg', deviceId: testDevice.id, eventType: 'motion', }); // Verify SQS message was sent expect(mockAwsService.sendProcessingMessage).toHaveBeenCalledWith({ rawEventId: expect.any(String), deviceId: testDevice.id, userProfileId: expect.any(String), eventType: 'motion', }); // Verify database record was created const savedEvent = await rawEventRepository.findOne({ where: { id: response.body.rawEventId }, }); expect(savedEvent).toBeDefined(); expect(savedEvent?.eventType).toBe('motion'); expect(savedEvent?.deviceId).toBe(testDevice.id); expect(savedEvent?.filePath).toBe( 'events/test-device/motion/2023-07-31_uuid.jpg', ); expect(savedEvent?.sqsMessageId).toBe('msg-123'); }); it('should handle AWS upload failure', async () => { mockAwsService.uploadFile.mockRejectedValue( new Error('S3 upload failed'), ); const eventData = { eventType: 'motion', eventTimestamp: new Date().toISOString(), metadata: { deviceId: testDevice.id }, }; const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .attach('file', Buffer.from('test file'), 'test.jpg') .field('eventData', JSON.stringify(eventData)); expect(response.status).toBe(500); }); it('should handle SQS failure gracefully', async () => { mockAwsService.uploadFile.mockResolvedValue( 'events/test-device/motion/2023-07-31_uuid.jpg', ); mockAwsService.sendProcessingMessage.mockRejectedValue( new Error('SQS failed'), ); const eventData = { eventType: 'motion', eventTimestamp: new Date().toISOString(), metadata: { deviceId: testDevice.id }, }; const response = await request(app.getHttpServer()) .post('/api/v1/events/upload') .set('Authorization', `Bearer ${authToken}`) .attach('file', Buffer.from('test file'), 'test.jpg') .field('eventData', JSON.stringify(eventData)); expect(response.status).toBe(202); expect(response.body.rawEventId).toBeDefined(); // Verify the event was marked as failed in processing status const savedEvent = await rawEventRepository.findOne({ where: { id: response.body.rawEventId }, }); expect(savedEvent?.processingStatus).toBe('failed'); }); }); describe('GET /api/v1/events/user', () => { let authToken: string; beforeEach(async () => { // Register and login user await request(app.getHttpServer()) .post('/api/v1/auth/register-email') .send({ email: 'events-get-test@example.com', password: 'TestPassword123', displayName: 'Events Get Test User', }); const loginResponse = await request(app.getHttpServer()) .post('/api/v1/auth/login-email') .send({ email: 'events-get-test@example.com', password: 'TestPassword123', }); authToken = loginResponse.body.accessToken; }); it('should return empty list for user with no events', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/events/user') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.events).toEqual([]); expect(response.body.total).toBe(0); }); it('should require authentication', async () => { const response = await request(app.getHttpServer()).get( '/api/v1/events/user', ); expect(response.status).toBe(401); }); }); describe('GET /api/v1/events/health/check', () => { it('should return health status', async () => { mockAwsService.healthCheck.mockResolvedValue({ s3: true, sqs: true }); const response = await request(app.getHttpServer()).get( '/api/v1/events/health/check', ); expect(response.status).toBe(200); expect(response.body.status).toBeDefined(); expect(response.body.checks).toBeDefined(); expect(response.body.checks.database).toBeDefined(); expect(response.body.checks.aws).toBeDefined(); }); }); describe('GET /api/v1/events', () => { let authToken: string; let userProfile: UserProfile; let testDevice: Device; beforeEach(async () => { // Register and login user await request(app.getHttpServer()) .post('/api/v1/auth/register-email') .send({ email: 'validated-events-test@example.com', password: 'TestPassword123', displayName: 'Validated Events Test User', }); const loginResponse = await request(app.getHttpServer()) .post('/api/v1/auth/login-email') .send({ email: 'validated-events-test@example.com', password: 'TestPassword123', }); authToken = loginResponse.body.accessToken; // Get user profile for test data creation userProfile = await userProfileRepository.findOne({ where: {}, relations: ['identities'], }); // Create inventory device and register it const inventoryDevice = inventoryDeviceRepository.create({ hardwareId: 'VALIDATED_EVENTS_TEST_DEVICE_001', isClaimed: false, }); await inventoryDeviceRepository.save(inventoryDevice); const registerResponse = await request(app.getHttpServer()) .post('/api/v1/devices/register') .set('Authorization', `Bearer ${authToken}`) .send({ hardwareId: 'VALIDATED_EVENTS_TEST_DEVICE_001', }); testDevice = registerResponse.body.device; }); it('should require authentication', async () => { const response = await request(app.getHttpServer()).get('/api/v1/events'); expect(response.status).toBe(401); }); it('should return empty results when user has no validated events', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/events') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data).toEqual([]); expect(response.body.nextCursor).toBeNull(); }); it('should return validated events for authenticated user', async () => { // Create test validated events const validatedEvent1 = validatedEventRepository.create({ rawEventId: 'raw-event-1', deviceId: testDevice.id, userProfileId: userProfile.id, mediaUrl: 'https://example.com/media1.jpg', eventType: 'meteor', capturedAt: new Date('2023-07-31T10:00:00Z'), isValid: true, }); const validatedEvent2 = validatedEventRepository.create({ rawEventId: 'raw-event-2', deviceId: testDevice.id, userProfileId: userProfile.id, mediaUrl: 'https://example.com/media2.jpg', eventType: 'motion', capturedAt: new Date('2023-07-31T09:00:00Z'), isValid: true, }); await validatedEventRepository.save([validatedEvent1, validatedEvent2]); const response = await request(app.getHttpServer()) .get('/api/v1/events') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data).toHaveLength(2); expect(response.body.data[0].eventType).toBe('meteor'); // Newest first expect(response.body.data[1].eventType).toBe('motion'); expect(response.body.nextCursor).toBeNull(); }); it('should only return events for the authenticated user', async () => { // Create another user await request(app.getHttpServer()) .post('/api/v1/auth/register-email') .send({ email: 'other-user@example.com', password: 'TestPassword123', displayName: 'Other User', }); const otherUserProfile = await userProfileRepository.findOne({ where: {}, relations: ['identities'], order: { createdAt: 'DESC' }, }); // Create validated events for both users const userEvent = validatedEventRepository.create({ rawEventId: 'raw-event-user', deviceId: testDevice.id, userProfileId: userProfile.id, mediaUrl: 'https://example.com/user-media.jpg', eventType: 'meteor', capturedAt: new Date('2023-07-31T10:00:00Z'), isValid: true, }); const otherUserEvent = validatedEventRepository.create({ rawEventId: 'raw-event-other', deviceId: testDevice.id, userProfileId: otherUserProfile.id, mediaUrl: 'https://example.com/other-media.jpg', eventType: 'motion', capturedAt: new Date('2023-07-31T09:00:00Z'), isValid: true, }); await validatedEventRepository.save([userEvent, otherUserEvent]); const response = await request(app.getHttpServer()) .get('/api/v1/events') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data).toHaveLength(1); expect(response.body.data[0].eventType).toBe('meteor'); }); it('should apply limit parameter', async () => { // Create 5 validated events const events = Array.from({ length: 5 }, (_, i) => validatedEventRepository.create({ rawEventId: `raw-event-${i}`, deviceId: testDevice.id, userProfileId: userProfile.id, mediaUrl: `https://example.com/media${i}.jpg`, eventType: 'meteor', capturedAt: new Date( `2023-07-31T${String(10 - i).padStart(2, '0')}:00:00Z`, ), isValid: true, }), ); await validatedEventRepository.save(events); const response = await request(app.getHttpServer()) .get('/api/v1/events?limit=3') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data).toHaveLength(3); expect(response.body.nextCursor).toBeTruthy(); }); it('should handle cursor-based pagination', async () => { // Create 3 validated events const events = Array.from({ length: 3 }, (_, i) => validatedEventRepository.create({ rawEventId: `raw-event-${i}`, deviceId: testDevice.id, userProfileId: userProfile.id, mediaUrl: `https://example.com/media${i}.jpg`, eventType: 'meteor', capturedAt: new Date( `2023-07-31T${String(10 - i).padStart(2, '0')}:00:00Z`, ), isValid: true, }), ); await validatedEventRepository.save(events); // Get first page const firstPageResponse = await request(app.getHttpServer()) .get('/api/v1/events?limit=2') .set('Authorization', `Bearer ${authToken}`); expect(firstPageResponse.status).toBe(200); expect(firstPageResponse.body.data).toHaveLength(2); expect(firstPageResponse.body.nextCursor).toBeTruthy(); // Get second page using cursor const secondPageResponse = await request(app.getHttpServer()) .get( `/api/v1/events?limit=2&cursor=${firstPageResponse.body.nextCursor}`, ) .set('Authorization', `Bearer ${authToken}`); expect(secondPageResponse.status).toBe(200); expect(secondPageResponse.body.data).toHaveLength(1); expect(secondPageResponse.body.nextCursor).toBeNull(); // Verify no overlap between pages const firstPageEventIds = firstPageResponse.body.data.map( (e: any) => e.id, ); const secondPageEventIds = secondPageResponse.body.data.map( (e: any) => e.id, ); const intersection = firstPageEventIds.filter((id: string) => secondPageEventIds.includes(id), ); expect(intersection).toHaveLength(0); }); it('should validate limit parameter', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/events?limit=0') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(400); }); it('should handle invalid cursor gracefully', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/events?cursor=invalid-cursor') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(500); // Should handle gracefully with proper error handling }); it('should use default limit when not specified', async () => { // Create 25 validated events (more than default limit of 20) const events = Array.from({ length: 25 }, (_, i) => validatedEventRepository.create({ rawEventId: `raw-event-${i}`, deviceId: testDevice.id, userProfileId: userProfile.id, mediaUrl: `https://example.com/media${i}.jpg`, eventType: 'meteor', capturedAt: new Date( `2023-07-31T${String(23 - i).padStart(2, '0')}:00:00Z`, ), isValid: true, }), ); await validatedEventRepository.save(events); const response = await request(app.getHttpServer()) .get('/api/v1/events') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data).toHaveLength(20); // Default limit expect(response.body.nextCursor).toBeTruthy(); }); }); });