import { test, expect, APIRequestContext } from '@playwright/test'; /** * CMS 内容发布工作流 E2E 测试 * * 覆盖完整流程:管理员登录 → 创建草稿 → 提交审核 → 审核通过 → 前台可见 * 以及多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见 */ const ADMIN_CREDENTIALS = { username: 'admin', password: 'admin123' }; const EDITOR_CREDENTIALS = { username: 'e2e_editor', password: 'editor123' }; const REVIEWER_CREDENTIALS = { username: 'e2e_reviewer', password: 'reviewer123' }; const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000'; const REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret'; test.setTimeout(120000); async function login( request: APIRequestContext, credentials: { username: string; password: string } ): Promise { const loginResponse = await request.post(`${BASE_URL}/api/auth/login`, { data: credentials, headers: { 'Content-Type': 'application/json' }, }); expect(loginResponse.ok()).toBeTruthy(); const loginBody = await loginResponse.json(); expect(loginBody.accessToken).toBeTruthy(); return loginBody.accessToken as string; } async function loginAdmin(request: APIRequestContext): Promise { return login(request, ADMIN_CREDENTIALS); } async function getNewsModelId(request: APIRequestContext, token: string): Promise { const response = await request.get(`${BASE_URL}/api/admin/models`, { headers: { Authorization: `Bearer ${token}` }, }); expect(response.ok()).toBeTruthy(); const models = await response.json(); const newsModel = models.find((m: { code: string }) => m.code === 'news'); expect(newsModel).toBeTruthy(); return newsModel.id; } async function createNewsDraft( request: APIRequestContext, token: string, suffix: string, modelId?: string ): Promise<{ id: string; slug: string; title: string }> { const title = `E2E 测试新闻 ${suffix}`; const slug = `e2e-test-news-${suffix}`; const resolvedModelId = modelId || (await getNewsModelId(request, token)); const response = await request.post(`${BASE_URL}/api/admin/items`, { data: { modelId: resolvedModelId, modelCode: 'news', title, slug, status: 'draft', data: { id: slug, excerpt: `这是 E2E 测试摘要 ${suffix}`, date: new Date().toISOString().split('T')[0], category: '公司新闻', image: '/images/placeholder-news.jpg', content: `

E2E 测试正文内容 ${suffix}

`, featured: false, }, }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.status).toBe('draft'); return { id: body.id, slug, title }; } async function submitForReview( request: APIRequestContext, token: string, itemId: string ): Promise { const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, { data: { action: 'submit' }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.status).toBe('review'); } async function approveItem( request: APIRequestContext, token: string, itemId: string ): Promise { const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, { data: { action: 'approve' }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.status).toBe('published'); } async function revalidateNews(request: APIRequestContext, slug: string): Promise { const response = await request.post(`${BASE_URL}/api/cms/revalidate`, { data: { event: 'content.published', modelCode: 'news', slug, secret: REVALIDATE_SECRET, }, headers: { 'Content-Type': 'application/json' }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.code).toBe(0); expect(body.data.revalidatedPaths).toContain('/news'); expect(body.data.revalidatedPaths).toContain(`/news/${slug}`); } async function ensureRolePermissions( request: APIRequestContext, adminToken: string, roleCode: string, permissions: Array<{ modelCode: string; action: string }> ): Promise { const response = await request.put(`${BASE_URL}/api/admin/roles`, { data: { roleCode, permissions }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}`, }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.roleCode).toBe(roleCode); expect(body.permissions).toHaveLength(permissions.length); } async function verifyNewsVisibleOnFrontend( page: import('@playwright/test').Page, slug: string, title: string, suffix: string ): Promise { // 直接访问详情页验证 ISR 刷新后的内容可见(比列表页更稳定,避免整页缓存波动) await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); await expect .poll( async () => { const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || ''; return h1Text.includes(title); }, { timeout: 20000, intervals: [1000, 2000, 2000] } ) .toBe(true); await expect(page.locator('body')).toContainText(`E2E 测试正文内容 ${suffix}`); } test.describe('CMS 内容发布工作流', () => { test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => { const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const token = await loginAdmin(request); const { id, slug, title } = await createNewsDraft(request, token, suffix); await submitForReview(request, token, id); await approveItem(request, token, id); await revalidateNews(request, slug); await verifyNewsVisibleOnFrontend(page, slug, title, suffix); }); test('非法状态流转会被拦截', async ({ request }) => { const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const token = await loginAdmin(request); const { id } = await createNewsDraft(request, token, suffix); const response = await request.post(`${BASE_URL}/api/admin/items/${id}/workflow`, { data: { action: 'approve' }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); expect(response.status()).toBe(400); const body = await response.json(); expect(body.error).toContain('当前状态 draft 不允许审核通过'); }); test('多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见', async ({ request, page, }) => { const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // 1. 管理员登录并获取模型 ID,然后配置角色权限 const adminToken = await loginAdmin(request); const newsModelId = await getNewsModelId(request, adminToken); await ensureRolePermissions(request, adminToken, 'content_editor', [ { modelCode: 'news', action: 'create' }, { modelCode: 'news', action: 'read' }, { modelCode: 'news', action: 'update' }, ]); await ensureRolePermissions(request, adminToken, 'reviewer', [ { modelCode: 'news', action: 'read' }, { modelCode: 'news', action: 'publish' }, ]); // 2. 编辑创建草稿并提交审核(模型 ID 由管理员提供,编辑无需 content-model 读取权限) const editorToken = await login(request, EDITOR_CREDENTIALS); const { id, slug, title } = await createNewsDraft(request, editorToken, suffix, newsModelId); await submitForReview(request, editorToken, id); // 3. 编辑尝试直接审核通过,应被 403 拒绝 const editorApproveResponse = await request.post( `${BASE_URL}/api/admin/items/${id}/workflow`, { data: { action: 'approve' }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${editorToken}`, }, } ); expect(editorApproveResponse.status()).toBe(403); // 4. 审核员审核通过 const reviewerToken = await login(request, REVIEWER_CREDENTIALS); await approveItem(request, reviewerToken, id); // 5. 刷新 ISR 缓存并验证前台可见 await revalidateNews(request, slug); await verifyNewsVisibleOnFrontend(page, slug, title, suffix); }); });