📋 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
258 lines
8.0 KiB
TypeScript
258 lines
8.0 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('Gallery Page E2E Tests', () => {
|
||
const testUser = {
|
||
email: 'e2e-test@meteor.dev',
|
||
password: 'TestPassword123',
|
||
displayName: 'E2E Test User'
|
||
};
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
// 导航到首页
|
||
await page.goto('/');
|
||
});
|
||
|
||
test('should redirect to login when accessing gallery without authentication', async ({ page }) => {
|
||
// 直接访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 应该被重定向到登录页面
|
||
await expect(page).toHaveURL('/login');
|
||
|
||
// 检查登录页面元素
|
||
await expect(page.locator('h1')).toContainText('登录');
|
||
});
|
||
|
||
test('should display gallery page after successful login', async ({ page }) => {
|
||
// 先注册用户
|
||
await page.goto('/register');
|
||
await page.fill('input[name="email"]', testUser.email);
|
||
await page.fill('input[name="password"]', testUser.password);
|
||
await page.fill('input[name="displayName"]', testUser.displayName);
|
||
await page.click('button[type="submit"]');
|
||
|
||
// 等待重定向到dashboard
|
||
await expect(page).toHaveURL('/dashboard');
|
||
|
||
// 访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 检查gallery页面元素
|
||
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||
|
||
// 检查空状态消息
|
||
await expect(page.locator('text=No events captured yet')).toBeVisible();
|
||
});
|
||
|
||
test('should display loading state initially', async ({ page }) => {
|
||
// 登录用户
|
||
await loginUser(page, testUser);
|
||
|
||
// 访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 检查loading状态(可能很快消失,所以用waitFor)
|
||
const loadingText = page.locator('text=Loading events...');
|
||
if (await loadingText.isVisible()) {
|
||
await expect(loadingText).toBeVisible();
|
||
}
|
||
|
||
// 最终应该显示空状态或事件列表
|
||
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||
});
|
||
|
||
test('should handle API errors gracefully', async ({ page }) => {
|
||
// 模拟API错误
|
||
await page.route('**/api/v1/events**', route => {
|
||
route.fulfill({
|
||
status: 500,
|
||
body: JSON.stringify({ error: 'Server Error' })
|
||
});
|
||
});
|
||
|
||
// 登录用户
|
||
await loginUser(page, testUser);
|
||
|
||
// 访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 检查错误消息
|
||
await expect(page.locator('text=Error loading events')).toBeVisible();
|
||
});
|
||
|
||
test('should display events when API returns data', async ({ page }) => {
|
||
// 模拟API返回测试数据
|
||
const mockEvents = {
|
||
data: [
|
||
{
|
||
id: 'test-event-1',
|
||
deviceId: 'device-1',
|
||
eventType: 'meteor',
|
||
capturedAt: '2024-01-01T12:00:00Z',
|
||
mediaUrl: 'https://example.com/test1.jpg',
|
||
isValid: true,
|
||
createdAt: '2024-01-01T12:00:00Z',
|
||
metadata: { location: 'Test Location 1' }
|
||
},
|
||
{
|
||
id: 'test-event-2',
|
||
deviceId: 'device-1',
|
||
eventType: 'satellite',
|
||
capturedAt: '2024-01-01T11:00:00Z',
|
||
mediaUrl: 'https://example.com/test2.jpg',
|
||
isValid: true,
|
||
createdAt: '2024-01-01T11:00:00Z',
|
||
metadata: { location: 'Test Location 2' }
|
||
}
|
||
],
|
||
nextCursor: null
|
||
};
|
||
|
||
await page.route('**/api/v1/events**', route => {
|
||
route.fulfill({
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(mockEvents)
|
||
});
|
||
});
|
||
|
||
// 登录用户
|
||
await loginUser(page, testUser);
|
||
|
||
// 访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 检查事件卡片
|
||
await expect(page.locator('[data-testid="event-card"]').first()).toBeVisible();
|
||
await expect(page.locator('text=Type: meteor')).toBeVisible();
|
||
await expect(page.locator('text=Type: satellite')).toBeVisible();
|
||
await expect(page.locator('text=Location: Test Location 1')).toBeVisible();
|
||
await expect(page.locator('text=Location: Test Location 2')).toBeVisible();
|
||
});
|
||
|
||
test('should implement infinite scroll', async ({ page }) => {
|
||
// 模拟分页数据
|
||
let pageCount = 0;
|
||
await page.route('**/api/v1/events**', route => {
|
||
const url = new URL(route.request().url());
|
||
const cursor = url.searchParams.get('cursor');
|
||
|
||
if (!cursor) {
|
||
// 第一页
|
||
pageCount = 1;
|
||
route.fulfill({
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
data: Array.from({ length: 5 }, (_, i) => ({
|
||
id: `event-page1-${i}`,
|
||
deviceId: 'device-1',
|
||
eventType: 'meteor',
|
||
capturedAt: new Date(Date.now() - i * 60000).toISOString(),
|
||
mediaUrl: `https://example.com/page1-${i}.jpg`,
|
||
isValid: true,
|
||
createdAt: new Date().toISOString()
|
||
})),
|
||
nextCursor: 'page2-cursor'
|
||
})
|
||
});
|
||
} else if (cursor === 'page2-cursor') {
|
||
// 第二页
|
||
pageCount = 2;
|
||
route.fulfill({
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
data: Array.from({ length: 3 }, (_, i) => ({
|
||
id: `event-page2-${i}`,
|
||
deviceId: 'device-1',
|
||
eventType: 'satellite',
|
||
capturedAt: new Date(Date.now() - (i + 5) * 60000).toISOString(),
|
||
mediaUrl: `https://example.com/page2-${i}.jpg`,
|
||
isValid: true,
|
||
createdAt: new Date().toISOString()
|
||
})),
|
||
nextCursor: null
|
||
})
|
||
});
|
||
}
|
||
});
|
||
|
||
// 登录用户
|
||
await loginUser(page, testUser);
|
||
|
||
// 访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 等待第一页加载
|
||
await expect(page.locator('[data-testid="event-card"]')).toHaveCount(5);
|
||
|
||
// 滚动到底部触发infinite scroll
|
||
await page.evaluate(() => {
|
||
window.scrollTo(0, document.body.scrollHeight);
|
||
});
|
||
|
||
// 等待第二页加载
|
||
await expect(page.locator('[data-testid="event-card"]')).toHaveCount(8);
|
||
|
||
// 检查loading more状态
|
||
const loadingMore = page.locator('text=Loading more events...');
|
||
if (await loadingMore.isVisible()) {
|
||
await expect(loadingMore).toBeVisible();
|
||
}
|
||
});
|
||
|
||
test('should be responsive on mobile devices', async ({ page }) => {
|
||
// 设置移动设备视口
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
|
||
// 模拟事件数据
|
||
await page.route('**/api/v1/events**', route => {
|
||
route.fulfill({
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
data: [{
|
||
id: 'mobile-test-event',
|
||
deviceId: 'device-1',
|
||
eventType: 'meteor',
|
||
capturedAt: '2024-01-01T12:00:00Z',
|
||
mediaUrl: 'https://example.com/mobile-test.jpg',
|
||
isValid: true,
|
||
createdAt: '2024-01-01T12:00:00Z'
|
||
}],
|
||
nextCursor: null
|
||
})
|
||
});
|
||
});
|
||
|
||
// 登录用户
|
||
await loginUser(page, testUser);
|
||
|
||
// 访问gallery页面
|
||
await page.goto('/gallery');
|
||
|
||
// 检查移动端布局
|
||
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||
await expect(page.locator('[data-testid="event-card"]')).toBeVisible();
|
||
|
||
// 检查网格布局在移动端的响应性
|
||
const grid = page.locator('[data-testid="gallery-grid"]');
|
||
if (await grid.isVisible()) {
|
||
const gridColumns = await grid.evaluate(el =>
|
||
window.getComputedStyle(el).gridTemplateColumns
|
||
);
|
||
// 移动端应该是单列
|
||
expect(gridColumns).toContain('1fr');
|
||
}
|
||
});
|
||
|
||
// 辅助函数:登录用户
|
||
async function loginUser(page: any, user: typeof testUser) {
|
||
await page.goto('/login');
|
||
await page.fill('input[name="email"]', user.email);
|
||
await page.fill('input[name="password"]', user.password);
|
||
await page.click('button[type="submit"]');
|
||
await expect(page).toHaveURL('/dashboard');
|
||
}
|
||
}); |