@@ -1,4 +1,4 @@
import { test , expect , type Page } from '@playwright/test' ;
import { test , expect , type Page , type APIRequestContext } from '@playwright/test' ;
/ * *
* UJ : 用户旅程 E2E 测 试
@@ -6,14 +6,12 @@ import { test, expect, type Page } from '@playwright/test';
* 覆 盖 范 围 :
* UJ - 01 : 潜在客户从首页到联系表单的完整旅程
* UJ - 02 : 行业客户浏览解决方案到产品的旅程
* UJ - 03 : 内容管理员完整旅程 ( 登 录 → 仪 表 盘 → 内 容 管 理 → 编 辑 → 前 台 可 见 )
* UJ - 04 : 新闻读者浏览旅程 ( 列 表 → 详 情 → 返 回 )
* UJ - 05 : 案例浏览者筛选旅程 ( 列 表 → 筛 选 → 详 情 )
* UJ - 10 : 深度搜索者旅程 ( 分 类 浏 览 → 逐 篇 阅 读 → 内 容 发 现 )
*
* 环 境 依 赖 ( 需 admin 认 证 环 境 , 封 版 前 标 记 为 ⏳ ) :
* UJ - 03 : 内容管理员完整旅程 ( 登 录 → 创 建 → 审 核 → 发 布 → 前 台 可 见 )
* UJ - 06 : 多角色管理员权限旅程 ( 超 级 管 理 员 → 角 色 → 权 限 → 用 户 )
* UJ - 07 : 媒体管理员上传旅程
* UJ - 10 : 深度搜索者旅程 ( 分 类 浏 览 → 逐 篇 阅 读 → 内 容 发 现 )
* /
test . setTimeout ( 90000 ) ;
@@ -518,9 +516,10 @@ test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 →
const detailText = await detailContent . textContent ( ) ;
expect ( detailText ! . length ) . toBeGreaterThan ( 100 ) ;
// 验证页面包含发布日期
const dateIndicator = page . locator ( 'time, [datetime], [aria-label*="date"], text=/\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}/ ' ) . first ( ) ;
if ( await dateIndicator . count ( ) > 0 ) {
// 软 验证: 页面包含发布日期(非关键断言,不阻止测试通过)
const dateIndicator = page . locator ( 'time, [datetime], [aria-label*="date"]' ) . first ( ) ;
const hasDateElement = await dateIndicator . count ( ) > 0 ;
if ( hasDateElement ) {
await expect ( dateIndicator ) . toBeVisible ( ) ;
}
@@ -605,3 +604,393 @@ test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 →
console . log ( 'UJ-10 completed successfully' ) ;
} ) ;
} ) ;
// ==================== 辅助函数:Admin 登录 ====================
const ADMIN_CREDENTIALS = { username : 'admin' , password : 'admin123' } ;
const BASE_URL = process . env . E2E_BASE_URL || 'http://localhost:3000' ;
/ * *
* 通 过 API 登 录 admin 并 设 置 浏 览 器 cookie 和 localStorage ,
* 使 后 续 页 面 导 航 通 过 中 间 件 认 证 , 且 前 端 auth - context 能 读 取 到 用 户 状 态 。
* /
async function loginAdminAndSetCookie ( page : Page , request : APIRequestContext ) : Promise < string > {
const loginResponse = await request . post ( ` ${ BASE_URL } /api/auth/login ` , {
data : ADMIN_CREDENTIALS ,
headers : { 'Content-Type' : 'application/json' } ,
} ) ;
expect ( loginResponse . ok ( ) ) . toBeTruthy ( ) ;
const body = await loginResponse . json ( ) ;
expect ( body . accessToken ) . toBeTruthy ( ) ;
const token = body . accessToken as string ;
// 设置中间件 cookie
await page . context ( ) . addCookies ( [
{
name : 'novalon_token' ,
value : token ,
domain : 'localhost' ,
path : '/' ,
httpOnly : true ,
sameSite : 'Lax' ,
} ,
] ) ;
// 先导航到 admin 登录页建立同源上下文,再设置 localStorage
await page . goto ( ` ${ BASE_URL } /admin/login ` , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
// 设置前端 localStorage( auth-context 读取此值判断登录状态)
await page . evaluate (
( { token , user } ) = > {
localStorage . setItem ( 'novalon_admin_token' , token ) ;
localStorage . setItem ( 'novalon_admin_user' , JSON . stringify ( user ) ) ;
} ,
{ token , user : body.user }
) ;
return token ;
}
// ==================== UJ-03: 内容管理员完整旅程 ====================
test . describe ( 'UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见)' , ( ) = > {
test ( '@journey @regression @admin 管理员登录后浏览仪表盘、内容列表、编辑页面,并验证前台可见' , async ( {
page ,
request ,
} ) = > {
test . setTimeout ( 120000 ) ;
// === Step 1: 通过 API 登录并设置 cookie ===
const token = await loginAdminAndSetCookie ( page , request ) ;
expect ( token ) . toBeTruthy ( ) ;
console . log ( 'UJ-03 admin login successful' ) ;
// === Step 2: 访问 Admin 仪表盘 ===
await page . goto ( '/admin' , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
await page . waitForTimeout ( 2000 ) ;
// 验证仪表盘加载成功(未重定向到登录页)
const currentUrl = page . url ( ) ;
expect ( currentUrl ) . not . toContain ( '/admin/login' ) ;
console . log ( 'UJ-03 dashboard URL:' , currentUrl ) ;
// 验证仪表盘标题或内容存在
const bodyText = await page . locator ( 'body' ) . textContent ( ) ;
expect ( bodyText ! . length ) . toBeGreaterThan ( 0 ) ;
const hasDashboardContent =
bodyText ! . includes ( '仪表盘' ) ||
bodyText ! . includes ( 'Dashboard' ) ||
bodyText ! . includes ( 'admin' ) ||
bodyText ! . includes ( '管理' ) ;
expect ( hasDashboardContent ) . toBe ( true ) ;
// === Step 3: 导航到新闻内容管理 ===
const newsContentLink = page . locator ( 'a[href*="/admin/content/news"]' ) . first ( ) ;
if ( await newsContentLink . isVisible ( ) ) {
await newsContentLink . click ( ) ;
await page . waitForURL ( /\/admin\/content\/news/ , { timeout : 10000 } ) ;
await page . waitForTimeout ( 2000 ) ;
} else {
// 直接导航
await page . goto ( '/admin/content/news' , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
await page . waitForTimeout ( 2000 ) ;
}
// 验证内容列表页加载成功
const contentUrl = page . url ( ) ;
expect ( contentUrl ) . toContain ( '/admin/content/news' ) ;
console . log ( 'UJ-03 content list URL:' , contentUrl ) ;
// 验证页面有内容
const contentBodyText = await page . locator ( 'body' ) . textContent ( ) ;
expect ( contentBodyText ! . length ) . toBeGreaterThan ( 0 ) ;
// === Step 4: 通过 API 创建一篇测试新闻 ===
const suffix = ` uj03- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
const title = ` UJ-03 测试新闻 ${ suffix } ` ;
const slug = ` uj03-test-news- ${ suffix } ` ;
// 获取模型 ID
const modelsResponse = await request . get ( ` ${ BASE_URL } /api/admin/models ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
expect ( modelsResponse . ok ( ) ) . toBeTruthy ( ) ;
const models = await modelsResponse . json ( ) ;
const newsModel = models . find ( ( m : { code : string } ) = > m . code === 'news' ) ;
expect ( newsModel ) . toBeTruthy ( ) ;
// 创建新闻
const createResponse = await request . post ( ` ${ BASE_URL } /api/admin/items ` , {
data : {
modelId : newsModel.id ,
modelCode : 'news' ,
title ,
slug ,
status : 'draft' ,
data : {
id : slug ,
excerpt : ` UJ-03 测试摘要 ${ suffix } ` ,
date : new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ] ,
category : '公司新闻' ,
content : ` <p>UJ-03 测试正文内容 ${ suffix } </p> ` ,
featured : false ,
} ,
} ,
headers : {
'Content-Type' : 'application/json' ,
Authorization : ` Bearer ${ token } ` ,
} ,
} ) ;
expect ( createResponse . ok ( ) ) . toBeTruthy ( ) ;
const createdItem = await createResponse . json ( ) ;
const itemId = createdItem . id ;
console . log ( 'UJ-03 created item:' , itemId , slug ) ;
// === Step 5: 导航到编辑页面 ===
await page . goto ( ` /admin/content/news/ ${ itemId } ` , {
waitUntil : 'domcontentloaded' ,
timeout : 30000 ,
} ) ;
await page . waitForTimeout ( 2000 ) ;
// 验证编辑页面加载成功
const editUrl = page . url ( ) ;
expect ( editUrl ) . toContain ( itemId ) ;
const editBodyText = await page . locator ( 'body' ) . textContent ( ) ;
expect ( editBodyText ! . length ) . toBeGreaterThan ( 0 ) ;
console . log ( 'UJ-03 editor page loaded' ) ;
// === Step 6: 发布内容(API) ===
// 提交审核
const submitResponse = await request . post (
` ${ BASE_URL } /api/admin/items/ ${ itemId } /workflow ` ,
{
data : { action : 'submit' } ,
headers : {
'Content-Type' : 'application/json' ,
Authorization : ` Bearer ${ token } ` ,
} ,
}
) ;
expect ( submitResponse . ok ( ) ) . toBeTruthy ( ) ;
// 审核通过
const approveResponse = await request . post (
` ${ BASE_URL } /api/admin/items/ ${ itemId } /workflow ` ,
{
data : { action : 'approve' } ,
headers : {
'Content-Type' : 'application/json' ,
Authorization : ` Bearer ${ token } ` ,
} ,
}
) ;
expect ( approveResponse . ok ( ) ) . toBeTruthy ( ) ;
// 刷新 ISR 缓存
const revalidateSecret = process . env . CMS_REVALIDATE_SECRET || 'e2e-test-secret' ;
await request . post ( ` ${ BASE_URL } /api/cms/revalidate ` , {
data : {
event : 'content.published' ,
modelCode : 'news' ,
slug ,
secret : revalidateSecret ,
} ,
headers : { 'Content-Type' : 'application/json' } ,
} ) ;
console . log ( 'UJ-03 item published and revalidated' ) ;
// === Step 7: 验证前台可见 ===
await page . goto ( ` /news/ ${ slug } ` , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
await page . waitForTimeout ( 2000 ) ;
// 验证新闻详情页加载成功且包含标题
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 ( ` UJ-03 测试正文内容 ${ suffix } ` ) ;
console . log ( 'UJ-03 frontend verification passed' ) ;
// === Step 8: 清理测试数据 ===
await request . delete ( ` ${ BASE_URL } /api/admin/items?id= ${ itemId } ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
console . log ( 'UJ-03 cleanup completed' ) ;
} ) ;
} ) ;
// ==================== UJ-06: 多角色管理员权限旅程 ====================
test . describe ( 'UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户)' , ( ) = > {
test ( '@journey @regression @admin 管理员访问角色管理和用户管理页面' , async ( { page , request } ) = > {
test . setTimeout ( 90000 ) ;
// === Step 1: 登录并设置 cookie ===
const token = await loginAdminAndSetCookie ( page , request ) ;
expect ( token ) . toBeTruthy ( ) ;
console . log ( 'UJ-06 admin login successful' ) ;
// === Step 2: 访问角色管理页面 ===
await page . goto ( '/admin/roles' , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
await page . waitForTimeout ( 2000 ) ;
// 验证未重定向到登录页
const rolesUrl = page . url ( ) ;
expect ( rolesUrl ) . not . toContain ( '/admin/login' ) ;
console . log ( 'UJ-06 roles URL:' , rolesUrl ) ;
// 验证角色页面加载成功
const rolesBodyText = await page . locator ( 'body' ) . textContent ( ) ;
expect ( rolesBodyText ! . length ) . toBeGreaterThan ( 0 ) ;
// 验证角色列表存在
const hasRoleContent =
rolesBodyText ! . includes ( '角色' ) ||
rolesBodyText ! . includes ( 'super_admin' ) ||
rolesBodyText ! . includes ( '权限' ) ;
expect ( hasRoleContent ) . toBe ( true ) ;
// === Step 3: 通过 API 验证角色数据 ===
const rolesResponse = await request . get ( ` ${ BASE_URL } /api/admin/roles ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
expect ( rolesResponse . ok ( ) ) . toBeTruthy ( ) ;
const rolesData = await rolesResponse . json ( ) ;
const roles = rolesData . roles || rolesData ;
expect ( Array . isArray ( roles ) ) . toBe ( true ) ;
expect ( roles . length ) . toBeGreaterThanOrEqual ( 5 ) ; // super_admin, content_admin, content_editor, reviewer, readonly
console . log ( 'UJ-06 roles found:' , roles . length ) ;
// 验证关键角色存在
const roleCodes = roles . map ( ( r : { code : string } ) = > r . code ) ;
expect ( roleCodes ) . toContain ( 'super_admin' ) ;
expect ( roleCodes ) . toContain ( 'content_admin' ) ;
expect ( roleCodes ) . toContain ( 'content_editor' ) ;
expect ( roleCodes ) . toContain ( 'reviewer' ) ;
expect ( roleCodes ) . toContain ( 'readonly' ) ;
// === Step 4: 访问用户管理页面 ===
await page . goto ( '/admin/users' , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
await page . waitForTimeout ( 2000 ) ;
// 验证未重定向到登录页
const usersUrl = page . url ( ) ;
expect ( usersUrl ) . not . toContain ( '/admin/login' ) ;
console . log ( 'UJ-06 users URL:' , usersUrl ) ;
// 验证用户页面加载成功
const usersBodyText = await page . locator ( 'body' ) . textContent ( ) ;
expect ( usersBodyText ! . length ) . toBeGreaterThan ( 0 ) ;
// 验证用户列表存在
const hasUserContent =
usersBodyText ! . includes ( '用户' ) ||
usersBodyText ! . includes ( 'admin' ) ||
usersBodyText ! . includes ( '角色' ) ;
expect ( hasUserContent ) . toBe ( true ) ;
// === Step 5: 通过 API 验证用户数据 ===
const usersResponse = await request . get ( ` ${ BASE_URL } /api/admin/users ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
expect ( usersResponse . ok ( ) ) . toBeTruthy ( ) ;
const usersData = await usersResponse . json ( ) ;
const users = usersData . users || usersData . data || usersData ;
expect ( Array . isArray ( users ) ) . toBe ( true ) ;
console . log ( 'UJ-06 users found:' , users . length ) ;
} ) ;
} ) ;
// ==================== UJ-07: 媒体管理员上传旅程 ====================
test . describe ( 'UJ-07: 媒体管理员上传旅程' , ( ) = > {
test ( '@journey @regression @admin 管理员访问媒体管理页面并上传文件' , async ( { page , request } ) = > {
test . setTimeout ( 90000 ) ;
// === Step 1: 登录并设置 cookie ===
const token = await loginAdminAndSetCookie ( page , request ) ;
expect ( token ) . toBeTruthy ( ) ;
console . log ( 'UJ-07 admin login successful' ) ;
// === Step 2: 访问媒体管理页面 ===
await page . goto ( '/admin/media' , { waitUntil : 'domcontentloaded' , timeout : 30000 } ) ;
await page . waitForTimeout ( 2000 ) ;
// 验证未重定向到登录页
const mediaUrl = page . url ( ) ;
expect ( mediaUrl ) . not . toContain ( '/admin/login' ) ;
console . log ( 'UJ-07 media URL:' , mediaUrl ) ;
// 验证媒体页面加载成功
const mediaBodyText = await page . locator ( 'body' ) . textContent ( ) ;
expect ( mediaBodyText ! . length ) . toBeGreaterThan ( 0 ) ;
// 验证媒体管理相关元素存在
const hasMediaContent =
mediaBodyText ! . includes ( '媒体' ) ||
mediaBodyText ! . includes ( '上传' ) ||
mediaBodyText ! . includes ( '素材' ) ||
mediaBodyText ! . includes ( 'media' ) ;
expect ( hasMediaContent ) . toBe ( true ) ;
// === Step 3: 通过 API 获取媒体列表 ===
const mediaResponse = await request . get ( ` ${ BASE_URL } /api/admin/media ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
expect ( mediaResponse . ok ( ) ) . toBeTruthy ( ) ;
const mediaData = await mediaResponse . json ( ) ;
const mediaItems = mediaData . items || [ ] ;
console . log ( 'UJ-07 existing media items:' , mediaItems . length ) ;
// === Step 4: 上传测试文件(通过 API POST /api/admin/media 带 FormData) ===
const testFileName = ` uj07-test- ${ Date . now ( ) } .txt ` ;
const testFileContent = 'UJ-07 test file content' ;
const uploadResponse = await request . post ( ` ${ BASE_URL } /api/admin/media ` , {
multipart : {
file : {
name : testFileName ,
mimeType : 'text/plain' ,
buffer : Buffer.from ( testFileContent , 'utf-8' ) ,
} ,
} ,
headers : {
Authorization : ` Bearer ${ token } ` ,
} ,
} ) ;
expect ( uploadResponse . ok ( ) ) . toBeTruthy ( ) ;
const uploadedFile = await uploadResponse . json ( ) ;
// 单文件上传返回资产对象本身
expect ( uploadedFile . id ) . toBeTruthy ( ) ;
console . log ( 'UJ-07 uploaded file:' , uploadedFile . id , uploadedFile . name ) ;
// === Step 5: 验证上传文件在媒体列表中可见 ===
const mediaAfterUploadResponse = await request . get ( ` ${ BASE_URL } /api/admin/media ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
expect ( mediaAfterUploadResponse . ok ( ) ) . toBeTruthy ( ) ;
const mediaAfterUpload = await mediaAfterUploadResponse . json ( ) ;
const mediaAfterItems = mediaAfterUpload . items || [ ] ;
const foundUploaded = mediaAfterItems . some (
( item : { name? : string ; url? : string } ) = >
item . name === testFileName || ( item . url && item . url . includes ( testFileName ) )
) ;
expect ( foundUploaded ) . toBe ( true ) ;
console . log ( 'UJ-07 uploaded file verified in media list' ) ;
// === Step 6: 清理上传文件 ===
if ( uploadedFile . id ) {
await request . delete ( ` ${ BASE_URL } /api/admin/media?id= ${ uploadedFile . id } ` , {
headers : { Authorization : ` Bearer ${ token } ` } ,
} ) ;
console . log ( 'UJ-07 cleanup completed' ) ;
}
} ) ;
} ) ;