2026-03-23 13:02:36 +08:00
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
|
import * as path from 'path';
|
|
|
|
|
|
import { fileURLToPath } from 'url';
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-23 13:02:36 +08:00
|
|
|
|
* 用户核心旅程测试(严格模式)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 双模式执行:
|
|
|
|
|
|
* - 无真实凭证:显式跳过(test.skip)
|
|
|
|
|
|
* - 有真实凭证:严格断言 2xx/3xx
|
2026-03-02 13:31:54 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
2026-03-23 18:42:57 +08:00
|
|
|
|
const FRONTEND_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5176';
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
const DEFAULT_TEST_API_KEY = 'test-api-key-000000000000';
|
|
|
|
|
|
const DEFAULT_TEST_USER_TOKEN = 'test-e2e-token';
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
interface TestData {
|
|
|
|
|
|
activityId: number;
|
|
|
|
|
|
apiKey: string;
|
|
|
|
|
|
userToken: string;
|
|
|
|
|
|
userId: number;
|
|
|
|
|
|
shortCode: string;
|
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
|
apiBaseUrl: string;
|
|
|
|
|
|
}
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
function loadTestData(): TestData {
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
|
|
const testDataPath = path.join(__dirname, '..', '.e2e-test-data.json');
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
const defaultData: TestData = {
|
|
|
|
|
|
activityId: 1,
|
|
|
|
|
|
apiKey: DEFAULT_TEST_API_KEY,
|
|
|
|
|
|
userToken: process.env.E2E_USER_TOKEN || DEFAULT_TEST_USER_TOKEN,
|
|
|
|
|
|
userId: 10001,
|
|
|
|
|
|
shortCode: 'test123',
|
2026-03-23 18:42:57 +08:00
|
|
|
|
baseUrl: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5176',
|
2026-03-23 13:02:36 +08:00
|
|
|
|
apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8080',
|
|
|
|
|
|
};
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(testDataPath)) {
|
|
|
|
|
|
const data = JSON.parse(fs.readFileSync(testDataPath, 'utf-8'));
|
|
|
|
|
|
return { ...defaultData, ...data };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('无法加载测试数据,使用默认值');
|
|
|
|
|
|
}
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
return defaultData;
|
|
|
|
|
|
}
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
function hasRealApiCredentials(data: TestData): boolean {
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
data.apiKey &&
|
|
|
|
|
|
data.userToken &&
|
|
|
|
|
|
data.apiKey !== DEFAULT_TEST_API_KEY &&
|
|
|
|
|
|
data.userToken !== DEFAULT_TEST_USER_TOKEN
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载测试数据
|
|
|
|
|
|
const testData = loadTestData();
|
|
|
|
|
|
const useRealCredentials = hasRealApiCredentials(testData);
|
|
|
|
|
|
const E2E_STRICT = process.env.E2E_STRICT === 'true';
|
|
|
|
|
|
|
|
|
|
|
|
test.describe('🎯 用户核心旅程测试', () => {
|
|
|
|
|
|
// 首页不需要凭证,始终执行
|
|
|
|
|
|
test('🏠 首页加载(无需凭证)', async ({ page }) => {
|
|
|
|
|
|
await test.step('访问首页', async () => {
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
if (!useRealCredentials) {
|
|
|
|
|
|
// 严格模式下无真实凭证时必须失败,非严格模式才跳过
|
|
|
|
|
|
if (E2E_STRICT) {
|
|
|
|
|
|
test('📊 活动列表API(需要真实凭证)', async () => {
|
|
|
|
|
|
throw new Error('严格模式需要真实凭证(E2E_USER_TOKEN),但未提供有效凭证,测试失败');
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
test.skip('📊 活动列表API(需要真实凭证)', async ({ request }) => {
|
|
|
|
|
|
// 此测试需要真实凭证,无凭证时跳过
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 有真实凭证时严格断言
|
|
|
|
|
|
test('🏠 首页加载', async ({ page }) => {
|
|
|
|
|
|
await test.step('访问首页', async () => {
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-03-02 13:31:54 +08:00
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('📊 活动列表API - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.get(`${API_BASE_URL}/api/v1/activities`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-API-Key': testData.apiKey,
|
|
|
|
|
|
'Authorization': `Bearer ${testData.userToken}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 严格断言:只接受 2xx/3xx
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`活动列表API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeGreaterThanOrEqual(200);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`活动列表API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeLessThan(400);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('📊 活动详情API - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.get(`${API_BASE_URL}/api/v1/activities/${testData.activityId}`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-API-Key': testData.apiKey,
|
|
|
|
|
|
'Authorization': `Bearer ${testData.userToken}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 严格断言:2xx/3xx
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`活动详情API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeGreaterThanOrEqual(200);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`活动详情API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeLessThan(400);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('🏆 排行榜API - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.get(`${API_BASE_URL}/api/v1/activities/${testData.activityId}/leaderboard`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-API-Key': testData.apiKey,
|
|
|
|
|
|
'Authorization': `Bearer ${testData.userToken}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 严格断言:2xx/3xx
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`排行榜API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeGreaterThanOrEqual(200);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`排行榜API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeLessThan(400);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('🔗 短链API - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.post(
|
|
|
|
|
|
`${API_BASE_URL}/api/v1/internal/shorten`,
|
|
|
|
|
|
{
|
|
|
|
|
|
data: {
|
|
|
|
|
|
originalUrl: 'https://example.com/test',
|
|
|
|
|
|
activityId: testData.activityId,
|
|
|
|
|
|
},
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'X-API-Key': testData.apiKey,
|
|
|
|
|
|
'Authorization': `Bearer ${testData.userToken}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 严格断言:201创建成功或2xx
|
|
|
|
|
|
expect(
|
|
|
|
|
|
[200, 201],
|
|
|
|
|
|
`短链API应返回200/201,实际${status}`
|
|
|
|
|
|
).toContain(status);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('📈 分享统计API - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.get(`${API_BASE_URL}/api/v1/share/metrics?activityId=${testData.activityId}`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-API-Key': testData.apiKey,
|
|
|
|
|
|
'Authorization': `Bearer ${testData.userToken}`,
|
|
|
|
|
|
},
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
2026-03-23 13:02:36 +08:00
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 严格断言:2xx/3xx
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`分享统计API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeGreaterThanOrEqual(200);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`分享统计API应返回2xx/3xx,实际${status}`
|
|
|
|
|
|
).toBeLessThan(400);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('🎫 API Key验证端点 - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.post(
|
|
|
|
|
|
`${API_BASE_URL}/api/v1/keys/validate`,
|
|
|
|
|
|
{
|
|
|
|
|
|
data: { apiKey: testData.apiKey },
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 严格断言:200成功
|
|
|
|
|
|
expect(
|
|
|
|
|
|
status,
|
|
|
|
|
|
`API Key验证应返回200,实际${status}`
|
|
|
|
|
|
).toBe(200);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
2026-03-23 13:02:36 +08:00
|
|
|
|
}
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test.describe('📱 响应式布局测试', () => {
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('移动端布局检查', async ({ page }) => {
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await page.goto(FRONTEND_URL);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('平板端布局检查', async ({ page }) => {
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await page.goto(FRONTEND_URL);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('桌面端布局检查', async ({ page }) => {
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await page.goto(FRONTEND_URL);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test.describe('⚡ 性能测试', () => {
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('后端健康检查响应时间', async ({ request }) => {
|
2026-03-02 13:31:54 +08:00
|
|
|
|
const startTime = Date.now();
|
2026-03-23 13:02:36 +08:00
|
|
|
|
const response = await request.get(`${API_BASE_URL}/actuator/health`);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
const responseTime = Date.now() - startTime;
|
2026-03-23 13:02:36 +08:00
|
|
|
|
|
|
|
|
|
|
expect(response.status()).toBe(200);
|
|
|
|
|
|
expect(responseTime, '健康检查响应时间应小于 2000ms').toBeLessThan(2000);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('前端页面加载时间', async ({ page }) => {
|
2026-03-02 13:31:54 +08:00
|
|
|
|
const startTime = Date.now();
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await page.goto(FRONTEND_URL);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
const loadTime = Date.now() - startTime;
|
2026-03-23 13:02:36 +08:00
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
2026-03-26 15:59:53 +08:00
|
|
|
|
// E2E环境可能有波动,放宽到10000ms避免偶发失败
|
|
|
|
|
|
expect(loadTime, '页面加载时间应小于 10000ms').toBeLessThan(10000);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test.describe('🔒 错误处理测试', () => {
|
|
|
|
|
|
test('处理无效的活动ID', async ({ page }) => {
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await page.goto(`${FRONTEND_URL}/?activityId=999999999`);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
2026-03-23 13:02:36 +08:00
|
|
|
|
await expect(page.locator('#app')).toBeAttached();
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-23 13:02:36 +08:00
|
|
|
|
test('处理无效 API 端点 - 严格断言', async ({ request }) => {
|
|
|
|
|
|
const response = await request.get(`${API_BASE_URL}/api/v1/non-existent-endpoint`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-API-Key': testData.apiKey,
|
|
|
|
|
|
'Authorization': `Bearer ${testData.userToken}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const status = response.status();
|
|
|
|
|
|
// 无效端点应返回404(而不是500或2xx)
|
|
|
|
|
|
// 但如果用了真实凭证且有权限,可能返回403(禁止访问不存在的资源)
|
|
|
|
|
|
// 所以这里只排除服务器错误和成功响应
|
|
|
|
|
|
// 4xx 客户端错误是预期行为
|
|
|
|
|
|
expect(
|
|
|
|
|
|
[400, 401, 403, 404, 499],
|
|
|
|
|
|
`无效API端点应返回4xx客户端错误,实际${status}`
|
|
|
|
|
|
).toContain(status);
|
2026-03-02 13:31:54 +08:00
|
|
|
|
});
|
2026-03-23 13:02:36 +08:00
|
|
|
|
});
|