chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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<string> {
|
||||
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<string> {
|
||||
return login(request, ADMIN_CREDENTIALS);
|
||||
}
|
||||
|
||||
async function getNewsModelId(request: APIRequestContext, token: string): Promise<string> {
|
||||
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: `<p>E2E 测试正文内容 ${suffix}</p>`,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 直接访问详情页验证 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user