grabbit 46d8af6084 🎉 Epic 2 Milestone: Successfully completed the final story of Epic 2: Commercialization & Core User Experience with full-stack date filtering functionality.
📋 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
2025-08-03 10:30:29 +08:00

472 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});
});