后端: - 新增 leai 模块:认证、Webhook、数据同步、定时对账 - 新增 LeaiConfig/RestTemplateConfig/SchedulingConfig 配置 - 新增 FlywayRepairConfig 处理迁移修复 - 新增 V5__leai_integration.sql 迁移脚本 - 扩展所有实体类添加 tenantId 等字段 - 更新 SecurityConfig 放行 leai 公开接口 - 添加 application-test.yml 测试环境配置 前端: - 添加乐读派认证 API (public.ts) - 优化 Generating.vue 生成页 - 添加 Playwright E2E 测试配置及依赖 - 添加测试 fixtures、utils、mock-h5.html - 添加 leai 模块完整 E2E 测试套件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
7.4 KiB
TypeScript
228 lines
7.4 KiB
TypeScript
import { test, expect } from '../fixtures/leai.fixture'
|
||
import { randomWorkId } from '../fixtures/leai.fixture'
|
||
import { fileURLToPath } from 'url'
|
||
import path from 'path'
|
||
|
||
/**
|
||
* P2: 端到端完整流程测试
|
||
*/
|
||
|
||
const __filename = fileURLToPath(import.meta.url)
|
||
const __dirname = path.dirname(__filename)
|
||
|
||
const MOCK_H5_PATH = path.resolve(__dirname, '../utils/mock-h5.html')
|
||
|
||
test.describe('端到端:创作完整流程', () => {
|
||
|
||
test('E2E-1: iframe 创作主流程', async ({ loggedInPage, sendWebhook }) => {
|
||
// ── 步骤 1: 拦截 token API ──
|
||
await loggedInPage.route('**/leai-auth/token', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
code: 200,
|
||
data: {
|
||
token: 'e2e_test_token',
|
||
orgId: 'gdlib',
|
||
h5Url: 'http://localhost:3001',
|
||
phone: '13800001111',
|
||
},
|
||
}),
|
||
})
|
||
})
|
||
await loggedInPage.route('http://192.168.1.72:3001/**', async (route) => {
|
||
await route.fulfill({ status: 200, contentType: 'text/html', path: MOCK_H5_PATH })
|
||
})
|
||
|
||
// ── 步骤 2: 访问创作页 ──
|
||
await loggedInPage.goto('/p/create')
|
||
const iframe = loggedInPage.locator('iframe').first()
|
||
await expect(iframe).toBeVisible({ timeout: 10_000 })
|
||
|
||
// ── 步骤 3: 模拟 Webhook status=1 (PENDING) ──
|
||
const workId = randomWorkId()
|
||
const result1 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: workId, status: 1, phone: '13800001111', title: 'E2E测试绘本' },
|
||
})
|
||
expect(result1.status).toBe(200)
|
||
|
||
// ── 步骤 4: 模拟 Webhook status=2 (PROCESSING) ──
|
||
const result2 = await sendWebhook({
|
||
event: 'work.progress',
|
||
data: { work_id: workId, status: 2, progress: 50, progressMessage: '正在绘制插画...' },
|
||
})
|
||
expect(result2.status).toBe(200)
|
||
|
||
// ── 步骤 5: 模拟 Webhook status=3 (COMPLETED) ──
|
||
const result3 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: {
|
||
work_id: workId,
|
||
status: 3,
|
||
title: 'E2E测试绘本',
|
||
pageList: [
|
||
{ imageUrl: 'https://cdn.example.com/e2e/page1.png', text: '第一页' },
|
||
{ imageUrl: 'https://cdn.example.com/e2e/page2.png', text: '第二页' },
|
||
],
|
||
},
|
||
})
|
||
expect(result3.status).toBe(200)
|
||
|
||
// ── 步骤 6: 模拟 Webhook status=5 (DUBBED) ──
|
||
const result5 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: {
|
||
work_id: workId,
|
||
status: 5,
|
||
title: 'E2E测试绘本',
|
||
author: 'E2E测试作者',
|
||
pageList: [
|
||
{ imageUrl: 'https://cdn.example.com/e2e/page1.png', text: '第一页', audioUrl: 'https://cdn.example.com/e2e/audio1.mp3' },
|
||
{ imageUrl: 'https://cdn.example.com/e2e/page2.png', text: '第二页', audioUrl: 'https://cdn.example.com/e2e/audio2.mp3' },
|
||
],
|
||
},
|
||
})
|
||
expect(result5.status).toBe(200)
|
||
|
||
// 全流程无报错即通过
|
||
})
|
||
|
||
test('E2E-2: Token 过期自动刷新', async ({ loggedInPage }) => {
|
||
let tokenCallCount = 0
|
||
let refreshCallCount = 0
|
||
|
||
await loggedInPage.route('**/leai-auth/token', async (route) => {
|
||
tokenCallCount++
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
code: 200,
|
||
data: { token: 'initial_token', orgId: 'gdlib', h5Url: 'http://localhost:3001', phone: '13800001111' },
|
||
}),
|
||
})
|
||
})
|
||
|
||
await loggedInPage.route('**/leai-auth/refresh-token', async (route) => {
|
||
refreshCallCount++
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
code: 200,
|
||
data: { token: 'refreshed_token', orgId: 'gdlib', phone: '13800001111' },
|
||
}),
|
||
})
|
||
})
|
||
|
||
await loggedInPage.route('http://192.168.1.72:3001/**', async (route) => {
|
||
await route.fulfill({ status: 200, contentType: 'text/html', path: MOCK_H5_PATH })
|
||
})
|
||
|
||
await loggedInPage.goto('/p/create')
|
||
const iframe = loggedInPage.locator('iframe').first()
|
||
await expect(iframe).toBeVisible({ timeout: 10_000 })
|
||
expect(tokenCallCount).toBe(1)
|
||
|
||
// 模拟 H5 发送 TOKEN_EXPIRED
|
||
await loggedInPage.evaluate(() => {
|
||
window.dispatchEvent(new MessageEvent('message', {
|
||
data: { source: 'leai-creation', version: 1, type: 'TOKEN_EXPIRED', payload: { messageId: 'm1' } },
|
||
origin: '*',
|
||
}))
|
||
})
|
||
|
||
await loggedInPage.waitForTimeout(2000)
|
||
expect(refreshCallCount).toBe(1)
|
||
|
||
// iframe 应继续正常显示
|
||
await expect(iframe).toBeVisible()
|
||
})
|
||
|
||
test('E2E-3: Webhook 幂等 + 状态不回退', async ({ sendWebhook }) => {
|
||
const workId = randomWorkId()
|
||
|
||
// 发送 status=1 (PENDING)
|
||
const r1 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: workId, status: 1, phone: '13800001111' },
|
||
})
|
||
expect(r1.status).toBe(200)
|
||
|
||
// 发送 status=3 (COMPLETED)
|
||
const r3 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: workId, status: 3, title: '幂等测试' },
|
||
})
|
||
expect(r3.status).toBe(200)
|
||
|
||
// 发送旧状态 status=2 (PROCESSING) — 应被忽略
|
||
const r2 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: workId, status: 2, progress: 80 },
|
||
})
|
||
expect(r2.status).toBe(200)
|
||
// V4.0 规则:status=2 <= status=3,忽略
|
||
|
||
// 发送 status=-1 (FAILED) — 强制覆盖
|
||
const rf = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: workId, status: -1, failReason: '测试强制失败' },
|
||
})
|
||
expect(rf.status).toBe(200)
|
||
// V4.0 规则:FAILED 强制更新
|
||
})
|
||
|
||
test('E2E-4: 创作失败 → 重试流程', async ({ loggedInPage, sendWebhook }) => {
|
||
const failedWorkId = randomWorkId()
|
||
|
||
// 模拟 Webhook 推送失败
|
||
const r1 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: failedWorkId, status: 1, phone: '13800001111' },
|
||
})
|
||
expect(r1.status).toBe(200)
|
||
|
||
const rf = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: failedWorkId, status: -1, failReason: 'AI处理超时' },
|
||
})
|
||
expect(rf.status).toBe(200)
|
||
|
||
// 用户回到创作页重新开始
|
||
await loggedInPage.route('**/leai-auth/token', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
code: 200,
|
||
data: { token: 'retry_token', orgId: 'gdlib', h5Url: 'http://localhost:3001', phone: '13800001111' },
|
||
}),
|
||
})
|
||
})
|
||
await loggedInPage.route('http://192.168.1.72:3001/**', async (route) => {
|
||
await route.fulfill({ status: 200, contentType: 'text/html', path: MOCK_H5_PATH })
|
||
})
|
||
|
||
await loggedInPage.goto('/p/create')
|
||
const iframe = loggedInPage.locator('iframe').first()
|
||
await expect(iframe).toBeVisible({ timeout: 10_000 })
|
||
|
||
// 新一轮创作成功
|
||
const newWorkId = randomWorkId()
|
||
const r2 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: newWorkId, status: 1, phone: '13800001111' },
|
||
})
|
||
expect(r2.status).toBe(200)
|
||
|
||
const r3 = await sendWebhook({
|
||
event: 'work.status_changed',
|
||
data: { work_id: newWorkId, status: 3, title: '重试成功绘本' },
|
||
})
|
||
expect(r3.status).toBe(200)
|
||
})
|
||
})
|