📋 What Was Accomplished Backend Changes: - ✅ Enhanced API Endpoint: Updated GET /api/v1/events to accept optional date query parameter - ✅ Input Validation: Added YYYY-MM-DD format validation to PaginationQueryDto - ✅ Database Filtering: Implemented timezone-aware date filtering in EventsService - ✅ Controller Integration: Updated EventsController to pass date parameter to service Frontend Changes: - ✅ Date Picker Component: Created reusable DatePicker component following project design system - ✅ Gallery UI Enhancement: Integrated date picker into gallery page with clear labeling - ✅ State Management: Implemented reactive date state with automatic re-fetching - ✅ Clear Filter Functionality: Added "Clear Filter" button for easy reset - ✅ Enhanced UX: Improved empty states for filtered vs unfiltered views 🔍 Technical Implementation API Design: GET /api/v1/events?date=2025-08-02&limit=20&cursor=xxx Key Files Modified: - meteor-web-backend/src/events/dto/pagination-query.dto.ts - meteor-web-backend/src/events/events.service.ts - meteor-web-backend/src/events/events.controller.ts - meteor-frontend/src/components/ui/date-picker.tsx (new) - meteor-frontend/src/app/gallery/page.tsx - meteor-frontend/src/hooks/use-events.ts - meteor-frontend/src/services/events.ts ✅ All Acceptance Criteria Met 1. ✅ Backend API Enhancement: Accepts optional date parameter 2. ✅ Date Filtering Logic: Returns events for specific calendar date 3. ✅ Date Picker UI: Clean, accessible interface component 4. ✅ Automatic Re-fetching: Immediate data updates on date selection 5. ✅ Filtered Display: Correctly shows only events for selected date 6. ✅ Clear Filter: One-click reset to view all events 🧪 Quality Assurance - ✅ Backend Build: Successful compilation with no errors - ✅ Frontend Build: Successful Next.js build with no warnings - ✅ Linting: All ESLint checks pass - ✅ Functionality: Feature working as specified 🎉 Epic 2 Complete! With Story 2.9 completion, Epic 2: Commercialization & Core User Experience is now DONE! Epic 2 Achievements: - 🔐 Full-stack device status monitoring - 💳 Robust payment and subscription system - 🛡️ Subscription-based access control - 📊 Enhanced data browsing with detail pages - 📅 Date-based event filtering
665 lines
22 KiB
TypeScript
665 lines
22 KiB
TypeScript
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<RawEvent>;
|
|
let deviceRepository: Repository<Device>;
|
|
let inventoryDeviceRepository: Repository<InventoryDevice>;
|
|
let userProfileRepository: Repository<UserProfile>;
|
|
let userIdentityRepository: Repository<UserIdentity>;
|
|
let validatedEventRepository: Repository<ValidatedEvent>;
|
|
|
|
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<Repository<RawEvent>>(
|
|
getRepositoryToken(RawEvent),
|
|
);
|
|
deviceRepository = moduleFixture.get<Repository<Device>>(
|
|
getRepositoryToken(Device),
|
|
);
|
|
inventoryDeviceRepository = moduleFixture.get<Repository<InventoryDevice>>(
|
|
getRepositoryToken(InventoryDevice),
|
|
);
|
|
userProfileRepository = moduleFixture.get<Repository<UserProfile>>(
|
|
getRepositoryToken(UserProfile),
|
|
);
|
|
userIdentityRepository = moduleFixture.get<Repository<UserIdentity>>(
|
|
getRepositoryToken(UserIdentity),
|
|
);
|
|
validatedEventRepository = moduleFixture.get<Repository<ValidatedEvent>>(
|
|
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();
|
|
});
|
|
});
|
|
});
|