📋 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
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import * as request from 'supertest';
|
||
import { Repository } from 'typeorm';
|
||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||
import { AppModule } from '../src/app.module';
|
||
import { RawEvent } from '../src/entities/raw-event.entity';
|
||
import { Device } from '../src/entities/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 { InventoryDevice } from '../src/entities/inventory-device.entity';
|
||
|
||
describe('Full Integration Tests (e2e)', () => {
|
||
let app: INestApplication;
|
||
let rawEventRepository: Repository<RawEvent>;
|
||
let deviceRepository: Repository<Device>;
|
||
let userProfileRepository: Repository<UserProfile>;
|
||
let userIdentityRepository: Repository<UserIdentity>;
|
||
let validatedEventRepository: Repository<ValidatedEvent>;
|
||
let inventoryDeviceRepository: Repository<InventoryDevice>;
|
||
|
||
const testDatabaseUrl =
|
||
process.env.TEST_DATABASE_URL ||
|
||
'postgresql://meteor_test:meteor_test_pass@localhost:5433/meteor_test';
|
||
|
||
beforeAll(async () => {
|
||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||
imports: [AppModule],
|
||
}).compile();
|
||
|
||
app = moduleFixture.createNestApplication();
|
||
app.useGlobalPipes(
|
||
new ValidationPipe({
|
||
whitelist: true,
|
||
forbidNonWhitelisted: true,
|
||
transform: true,
|
||
}),
|
||
);
|
||
|
||
app.enableCors({
|
||
origin: true,
|
||
credentials: true,
|
||
});
|
||
|
||
await app.init();
|
||
|
||
// 获取仓库
|
||
rawEventRepository = moduleFixture.get<Repository<RawEvent>>(
|
||
getRepositoryToken(RawEvent),
|
||
);
|
||
deviceRepository = moduleFixture.get<Repository<Device>>(
|
||
getRepositoryToken(Device),
|
||
);
|
||
userProfileRepository = moduleFixture.get<Repository<UserProfile>>(
|
||
getRepositoryToken(UserProfile),
|
||
);
|
||
userIdentityRepository = moduleFixture.get<Repository<UserIdentity>>(
|
||
getRepositoryToken(UserIdentity),
|
||
);
|
||
validatedEventRepository = moduleFixture.get<Repository<ValidatedEvent>>(
|
||
getRepositoryToken(ValidatedEvent),
|
||
);
|
||
inventoryDeviceRepository = moduleFixture.get<Repository<InventoryDevice>>(
|
||
getRepositoryToken(InventoryDevice),
|
||
);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
// 清理测试数据
|
||
await validatedEventRepository.delete({});
|
||
await rawEventRepository.delete({});
|
||
await deviceRepository.delete({});
|
||
await inventoryDeviceRepository.delete({});
|
||
await userIdentityRepository.delete({});
|
||
await userProfileRepository.delete({});
|
||
});
|
||
|
||
describe('Gallery Full Workflow Integration', () => {
|
||
it('should handle complete gallery workflow from registration to event display', async () => {
|
||
// 1. 用户注册
|
||
const registrationData = {
|
||
email: 'integration-test@meteor.dev',
|
||
password: 'TestPassword123',
|
||
displayName: 'Integration Test User',
|
||
};
|
||
|
||
const registerResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/auth/register-email')
|
||
.send(registrationData)
|
||
.expect(201);
|
||
|
||
// 2. 用户登录
|
||
const loginResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/auth/login-email')
|
||
.send({
|
||
email: registrationData.email,
|
||
password: registrationData.password,
|
||
})
|
||
.expect(200);
|
||
|
||
const { accessToken, userId } = loginResponse.body;
|
||
expect(accessToken).toBeDefined();
|
||
expect(userId).toBeDefined();
|
||
|
||
// 3. 创建和注册设备
|
||
const hardwareId = 'INTEGRATION_TEST_DEVICE_001';
|
||
|
||
// 先创建库存设备
|
||
const inventoryDevice = inventoryDeviceRepository.create({
|
||
hardwareId,
|
||
isClaimed: false,
|
||
});
|
||
await inventoryDeviceRepository.save(inventoryDevice);
|
||
|
||
// 注册设备到用户
|
||
const deviceResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/devices/register')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.send({ hardwareId })
|
||
.expect(201);
|
||
|
||
const device = deviceResponse.body.device;
|
||
expect(device.id).toBeDefined();
|
||
|
||
// 4. 上传事件文件
|
||
const eventData = {
|
||
eventType: 'meteor',
|
||
eventTimestamp: new Date().toISOString(),
|
||
metadata: {
|
||
deviceId: device.id,
|
||
location: 'Integration Test Location',
|
||
confidence: 0.95,
|
||
},
|
||
};
|
||
|
||
const uploadResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/events/upload')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.attach('file', Buffer.from('fake image data'), 'integration-test.jpg')
|
||
.field('eventData', JSON.stringify(eventData))
|
||
.expect(202);
|
||
|
||
const { rawEventId } = uploadResponse.body;
|
||
expect(rawEventId).toBeDefined();
|
||
|
||
// 5. 模拟处理服务完成 - 创建验证事件
|
||
const validatedEvent = validatedEventRepository.create({
|
||
rawEventId,
|
||
deviceId: device.id,
|
||
userProfileId: userId,
|
||
mediaUrl: `https://test-bucket.s3.amazonaws.com/events/${device.id}/${rawEventId}.jpg`,
|
||
eventType: eventData.eventType,
|
||
capturedAt: new Date(eventData.eventTimestamp),
|
||
isValid: true,
|
||
validationScore: '0.95',
|
||
fileSize: '1024',
|
||
fileType: 'image/jpeg',
|
||
originalFilename: 'integration-test.jpg',
|
||
metadata: eventData.metadata,
|
||
});
|
||
|
||
await validatedEventRepository.save(validatedEvent);
|
||
|
||
// 6. 获取验证事件列表 (Gallery API)
|
||
const eventsResponse = await request(app.getHttpServer())
|
||
.get('/api/v1/events')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(200);
|
||
|
||
const { data: events, nextCursor } = eventsResponse.body;
|
||
expect(events).toHaveLength(1);
|
||
expect(events[0].id).toBe(validatedEvent.id);
|
||
expect(events[0].eventType).toBe('meteor');
|
||
expect(events[0].mediaUrl).toBe(validatedEvent.mediaUrl);
|
||
expect(events[0].metadata.location).toBe('Integration Test Location');
|
||
expect(nextCursor).toBeNull();
|
||
|
||
// 7. 测试分页功能
|
||
// 创建更多测试事件
|
||
const additionalEvents = [];
|
||
for (let i = 0; i < 25; i++) {
|
||
const additionalEvent = validatedEventRepository.create({
|
||
rawEventId: `additional-${i}`,
|
||
deviceId: device.id,
|
||
userProfileId: userId,
|
||
mediaUrl: `https://test-bucket.s3.amazonaws.com/events/${device.id}/additional-${i}.jpg`,
|
||
eventType: i % 2 === 0 ? 'meteor' : 'satellite',
|
||
capturedAt: new Date(Date.now() - i * 60000),
|
||
isValid: true,
|
||
validationScore: '0.90',
|
||
metadata: { location: `Location ${i}` },
|
||
});
|
||
additionalEvents.push(additionalEvent);
|
||
}
|
||
|
||
await validatedEventRepository.save(additionalEvents);
|
||
|
||
// 测试分页查询
|
||
const paginatedResponse = await request(app.getHttpServer())
|
||
.get('/api/v1/events?limit=10')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(200);
|
||
|
||
expect(paginatedResponse.body.data).toHaveLength(10);
|
||
expect(paginatedResponse.body.nextCursor).toBeTruthy();
|
||
|
||
// 使用cursor获取下一页
|
||
const nextPageResponse = await request(app.getHttpServer())
|
||
.get(
|
||
`/api/v1/events?limit=10&cursor=${paginatedResponse.body.nextCursor}`,
|
||
)
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(200);
|
||
|
||
expect(nextPageResponse.body.data).toHaveLength(10);
|
||
|
||
// 确保没有重复的事件
|
||
const firstPageIds = paginatedResponse.body.data.map((e: any) => e.id);
|
||
const secondPageIds = nextPageResponse.body.data.map((e: any) => e.id);
|
||
const intersection = firstPageIds.filter((id: string) =>
|
||
secondPageIds.includes(id),
|
||
);
|
||
expect(intersection).toHaveLength(0);
|
||
});
|
||
|
||
it('should handle user isolation correctly', async () => {
|
||
// 创建两个用户
|
||
const user1Data = {
|
||
email: 'user1@meteor.dev',
|
||
password: 'Password123',
|
||
displayName: 'User 1',
|
||
};
|
||
|
||
const user2Data = {
|
||
email: 'user2@meteor.dev',
|
||
password: 'Password123',
|
||
displayName: 'User 2',
|
||
};
|
||
|
||
// 注册用户1
|
||
await request(app.getHttpServer())
|
||
.post('/api/v1/auth/register-email')
|
||
.send(user1Data)
|
||
.expect(201);
|
||
|
||
const user1Login = await request(app.getHttpServer())
|
||
.post('/api/v1/auth/login-email')
|
||
.send({ email: user1Data.email, password: user1Data.password })
|
||
.expect(200);
|
||
|
||
// 注册用户2
|
||
await request(app.getHttpServer())
|
||
.post('/api/v1/auth/register-email')
|
||
.send(user2Data)
|
||
.expect(201);
|
||
|
||
const user2Login = await request(app.getHttpServer())
|
||
.post('/api/v1/auth/login-email')
|
||
.send({ email: user2Data.email, password: user2Data.password })
|
||
.expect(200);
|
||
|
||
// 为每个用户创建设备
|
||
const device1HardwareId = 'USER1_DEVICE';
|
||
const device2HardwareId = 'USER2_DEVICE';
|
||
|
||
await inventoryDeviceRepository.save([
|
||
{ hardwareId: device1HardwareId, isClaimed: false },
|
||
{ hardwareId: device2HardwareId, isClaimed: false },
|
||
]);
|
||
|
||
const device1Response = await request(app.getHttpServer())
|
||
.post('/api/v1/devices/register')
|
||
.set('Authorization', `Bearer ${user1Login.body.accessToken}`)
|
||
.send({ hardwareId: device1HardwareId })
|
||
.expect(201);
|
||
|
||
const device2Response = await request(app.getHttpServer())
|
||
.post('/api/v1/devices/register')
|
||
.set('Authorization', `Bearer ${user2Login.body.accessToken}`)
|
||
.send({ hardwareId: device2HardwareId })
|
||
.expect(201);
|
||
|
||
// 为每个用户创建验证事件
|
||
const user1Event = validatedEventRepository.create({
|
||
rawEventId: 'user1-event',
|
||
deviceId: device1Response.body.device.id,
|
||
userProfileId: user1Login.body.userId,
|
||
mediaUrl: 'https://test-bucket.s3.amazonaws.com/user1-event.jpg',
|
||
eventType: 'meteor',
|
||
capturedAt: new Date(),
|
||
isValid: true,
|
||
metadata: { location: 'User 1 Location' },
|
||
});
|
||
|
||
const user2Event = validatedEventRepository.create({
|
||
rawEventId: 'user2-event',
|
||
deviceId: device2Response.body.device.id,
|
||
userProfileId: user2Login.body.userId,
|
||
mediaUrl: 'https://test-bucket.s3.amazonaws.com/user2-event.jpg',
|
||
eventType: 'satellite',
|
||
capturedAt: new Date(),
|
||
isValid: true,
|
||
metadata: { location: 'User 2 Location' },
|
||
});
|
||
|
||
await validatedEventRepository.save([user1Event, user2Event]);
|
||
|
||
// 用户1只能看到自己的事件
|
||
const user1Events = await request(app.getHttpServer())
|
||
.get('/api/v1/events')
|
||
.set('Authorization', `Bearer ${user1Login.body.accessToken}`)
|
||
.expect(200);
|
||
|
||
expect(user1Events.body.data).toHaveLength(1);
|
||
expect(user1Events.body.data[0].eventType).toBe('meteor');
|
||
expect(user1Events.body.data[0].metadata.location).toBe(
|
||
'User 1 Location',
|
||
);
|
||
|
||
// 用户2只能看到自己的事件
|
||
const user2Events = await request(app.getHttpServer())
|
||
.get('/api/v1/events')
|
||
.set('Authorization', `Bearer ${user2Login.body.accessToken}`)
|
||
.expect(200);
|
||
|
||
expect(user2Events.body.data).toHaveLength(1);
|
||
expect(user2Events.body.data[0].eventType).toBe('satellite');
|
||
expect(user2Events.body.data[0].metadata.location).toBe(
|
||
'User 2 Location',
|
||
);
|
||
});
|
||
|
||
it('should handle API rate limiting and error conditions', async () => {
|
||
// 注册和登录用户
|
||
const userData = {
|
||
email: 'ratelimit-test@meteor.dev',
|
||
password: 'Password123',
|
||
displayName: 'Rate Limit Test',
|
||
};
|
||
|
||
await request(app.getHttpServer())
|
||
.post('/api/v1/auth/register-email')
|
||
.send(userData)
|
||
.expect(201);
|
||
|
||
const loginResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/auth/login-email')
|
||
.send({ email: userData.email, password: userData.password })
|
||
.expect(200);
|
||
|
||
const { accessToken } = loginResponse.body;
|
||
|
||
// 测试无效的查询参数
|
||
await request(app.getHttpServer())
|
||
.get('/api/v1/events?limit=0')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(400);
|
||
|
||
await request(app.getHttpServer())
|
||
.get('/api/v1/events?limit=1001')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(400);
|
||
|
||
// 测试无效的cursor
|
||
await request(app.getHttpServer())
|
||
.get('/api/v1/events?cursor=invalid-cursor-format')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(500);
|
||
|
||
// 测试无效的token
|
||
await request(app.getHttpServer())
|
||
.get('/api/v1/events')
|
||
.set('Authorization', 'Bearer invalid-token')
|
||
.expect(401);
|
||
|
||
// 测试过期的token(这里简化处理)
|
||
await request(app.getHttpServer())
|
||
.get('/api/v1/events')
|
||
.set(
|
||
'Authorization',
|
||
'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.expired',
|
||
)
|
||
.expect(401);
|
||
});
|
||
});
|
||
|
||
describe('Performance and Load Testing', () => {
|
||
it('should handle concurrent requests efficiently', async () => {
|
||
// 创建测试用户
|
||
const userData = {
|
||
email: 'perf-test@meteor.dev',
|
||
password: 'Password123',
|
||
displayName: 'Performance Test',
|
||
};
|
||
|
||
await request(app.getHttpServer())
|
||
.post('/api/v1/auth/register-email')
|
||
.send(userData)
|
||
.expect(201);
|
||
|
||
const loginResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/auth/login-email')
|
||
.send({ email: userData.email, password: userData.password })
|
||
.expect(200);
|
||
|
||
const { accessToken, userId } = loginResponse.body;
|
||
|
||
// 创建设备
|
||
const hardwareId = 'PERF_TEST_DEVICE';
|
||
await inventoryDeviceRepository.save({ hardwareId, isClaimed: false });
|
||
|
||
const deviceResponse = await request(app.getHttpServer())
|
||
.post('/api/v1/devices/register')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.send({ hardwareId })
|
||
.expect(201);
|
||
|
||
const device = deviceResponse.body.device;
|
||
|
||
// 创建大量测试事件
|
||
const events = Array.from({ length: 100 }, (_, i) =>
|
||
validatedEventRepository.create({
|
||
rawEventId: `perf-event-${i}`,
|
||
deviceId: device.id,
|
||
userProfileId: userId,
|
||
mediaUrl: `https://test-bucket.s3.amazonaws.com/perf-${i}.jpg`,
|
||
eventType:
|
||
i % 3 === 0 ? 'meteor' : i % 3 === 1 ? 'satellite' : 'airplane',
|
||
capturedAt: new Date(Date.now() - i * 1000),
|
||
isValid: true,
|
||
metadata: { location: `Performance Location ${i}` },
|
||
}),
|
||
);
|
||
|
||
await validatedEventRepository.save(events);
|
||
|
||
// 并发请求测试
|
||
const concurrentRequests = Array.from({ length: 10 }, () =>
|
||
request(app.getHttpServer())
|
||
.get('/api/v1/events?limit=20')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(200),
|
||
);
|
||
|
||
const results = await Promise.all(concurrentRequests);
|
||
|
||
// 验证所有请求都成功返回
|
||
results.forEach((result) => {
|
||
expect(result.body.data).toHaveLength(20);
|
||
expect(result.body.nextCursor).toBeTruthy();
|
||
});
|
||
|
||
// 测试响应时间(简单检查)
|
||
const startTime = Date.now();
|
||
await request(app.getHttpServer())
|
||
.get('/api/v1/events?limit=50')
|
||
.set('Authorization', `Bearer ${accessToken}`)
|
||
.expect(200);
|
||
const responseTime = Date.now() - startTime;
|
||
|
||
// 响应时间应该在合理范围内(< 1秒)
|
||
expect(responseTime).toBeLessThan(1000);
|
||
});
|
||
});
|
||
});
|