import { test, expect, type Page, type APIRequestContext } from './fixtures'; /** * UJ: 用户旅程 E2E 测试 * * 覆盖范围: * UJ-01: 潜在客户从首页到联系表单的完整旅程 * UJ-02: 行业客户浏览解决方案到产品的旅程 * UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见) * UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回) * UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情) * UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户) * UJ-07: 媒体管理员上传旅程 * UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现) */ test.setTimeout(90000); // 本套件为桌面旅程(desktop-navigation 在 md 断点以下 display:none), // 移动端等价旅程由 mobile-user-journeys.spec.ts 覆盖。 test.use({ viewport: { width: 1440, height: 900 } }); // ==================== 辅助函数 ==================== /** * 关闭 Cookie 同意横幅(如果存在) */ async function closeCookieBanner(page: Page) { const acceptAllButton = page.locator('button:has-text("接受所有")').first(); if (await acceptAllButton.count() > 0 && await acceptAllButton.isVisible()) { await acceptAllButton.click(); await page.waitForTimeout(500); } } /** * 从桌面主导航的产品下拉菜单中导航到指定链接 */ async function navigateFromProductDropdown(page: Page, linkText: string) { const desktopNav = page.locator('[data-testid="desktop-navigation"]').first(); const productDropdown = desktopNav.locator('button:has-text("产品")').first(); await expect(productDropdown).toBeVisible({ timeout: 5000 }); await productDropdown.hover(); await page.waitForTimeout(300); const targetLink = desktopNav.locator(`a:has-text("${linkText}")`).first(); await expect(targetLink).toBeVisible({ timeout: 5000 }); // 鼠标悬停在链接上,防止下拉菜单因 mouseleave 关闭 await targetLink.hover(); await page.waitForTimeout(200); await targetLink.click(); } // ==================== UJ-01: 潜在客户从首页到联系表单的完整旅程 ==================== test.describe('UJ-01: 潜在客户从首页到联系表单的完整旅程', () => { test('@journey @regression 访客从首页浏览产品并提交联系表单', async ({ page }) => { // === Step 1: 访问首页,验证 Hero 区域 === await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2500); await closeCookieBanner(page); // 验证 Hero 区域可见 const heroTitle = page.locator('h1').first(); await expect(heroTitle).toBeVisible({ timeout: 15000 }); const titleText = await heroTitle.textContent(); expect(titleText).toBeTruthy(); expect(titleText!.length).toBeGreaterThan(0); // 验证 Hero 描述段落可见 const heroDescription = page.locator('main p').first(); await expect(heroDescription).toBeVisible(); // === Step 2: 通过导航下拉菜单进入产品页面 === await navigateFromProductDropdown(page, '查看全部产品'); // === Step 3: 验证产品页面加载成功 === await page.waitForURL(/\/products/, { timeout: 10000 }); await expect(page).toHaveURL(/\/products/); await page.waitForTimeout(2000); // 验证产品页面有标题 const productsTitle = page.locator('h1, h2').first(); await expect(productsTitle).toBeVisible(); const productsTitleText = await productsTitle.textContent(); expect(productsTitleText).toContain('产品'); // 验证产品卡片存在 const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])'); const linkCount = await productLinks.count(); expect(linkCount).toBeGreaterThan(0); // === Step 4: 点击产品进入详情页 === const firstProductLink = productLinks.first(); await firstProductLink.click(); await page.waitForURL(/\/products\/.+/, { timeout: 10000 }); await expect(page).toHaveURL(/\/products\/.+/); await page.waitForTimeout(2000); // === Step 5: 验证产品详情页的四层叙事结构 === // L1 Hero: 产品标题可见 const detailTitle = page.locator('h1').first(); await expect(detailTitle).toBeVisible({ timeout: 10000 }); const detailTitleText = await detailTitle.textContent(); expect(detailTitleText).toBeTruthy(); expect(detailTitleText!.length).toBeGreaterThan(0); // L2 Value: 产品详情页应包含核心功能或产品价值区域 const mainContent = page.locator('main').first(); await expect(mainContent).toBeVisible(); const mainText = await mainContent.textContent(); expect(mainText!.length).toBeGreaterThan(200); // 验证功能/价值相关内容存在(核心功能或产品价值标签) const valueIndicators = ['核心功能', '产品价值', '实施流程']; let hasValueSection = false; for (const indicator of valueIndicators) { if (mainText!.includes(indicator)) { hasValueSection = true; break; } } expect(hasValueSection).toBe(true); // L3 Trust: 验证信任相关内容(客户案例或资质认证) const trustIndicators = ['客户案例', '资质认证', '可衡量的成果']; let hasTrustSection = false; for (const indicator of trustIndicators) { if (mainText!.includes(indicator)) { hasTrustSection = true; break; } } expect(hasTrustSection).toBe(true); // L4 CTA: 验证 CTA 区域存在(包含"预约演示"或"开始使用"链接) const ctaLink = page.locator('a[href="/contact"]').first(); await expect(ctaLink).toBeVisible(); // === Step 6: 导航到联系页面 === await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2500); // === Step 7: 填写联系表单 === // 验证表单字段可见 const nameInput = page.locator('[data-testid="name-input"]').first(); await expect(nameInput).toBeVisible({ timeout: 5000 }); await page.locator('[data-testid="name-input"]').fill('潜在客户'); await page.locator('[data-testid="phone-input"]').fill('13912345678'); await page.locator('[data-testid="email-input"]').fill('prospect@example.com'); await page.locator('[data-testid="subject-input"]').fill('产品咨询 - ERP 管理系统'); await page.locator('[data-testid="message-input"]').fill('您好,我在浏览了贵司的 ERP 产品后非常感兴趣,希望进一步了解产品功能和实施细节,请安排专人联系。'); // === Step 8: 拦截 API 并提交表单 === let routeHit = false; await page.route('/api/contact', async (route) => { routeHit = true; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, message: '发送成功' }), }); }); const submitButton = page.locator('[data-testid="submit-button"]').first(); await submitButton.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); // 隐藏可能覆盖底部元素的固定返回顶部按钮 await page.evaluate(() => { const backToTop = document.querySelector('[aria-label="返回顶部"]') as HTMLElement | null; if (backToTop) backToTop.style.display = 'none'; }); // 使用原生 DOM click() 触发提交 await page.evaluate(() => { const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null; btn?.click(); }); // === Step 9: 验证成功消息 === await page.waitForTimeout(2000); console.log('UJ-01 route hit:', routeHit); // 验证成功提示存在 const bodyText = await page.locator('body').textContent(); const hasSuccessIndicator = bodyText!.includes('消息已发送') || bodyText!.includes('感谢') || bodyText!.includes('发送成功') || bodyText!.includes('提交成功'); expect(hasSuccessIndicator).toBe(true); }); }); // ==================== UJ-02: 行业客户浏览解决方案到产品的旅程 ==================== test.describe('UJ-02: 行业客户浏览解决方案到产品的旅程', () => { test('@journey @regression 客户从解决方案浏览到产品详情', async ({ page }) => { // === Step 1: 访问首页 === await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2000); await closeCookieBanner(page); // 验证首页加载成功 const heroTitle = page.locator('h1').first(); await expect(heroTitle).toBeVisible({ timeout: 10000 }); // === Step 2: 导航到解决方案页面 === // 通过桌面导航的解决方案下拉菜单 const desktopNav = page.locator('[data-testid="desktop-navigation"]').first(); const solutionsDropdown = desktopNav.locator('button:has-text("解决方案")').first(); await expect(solutionsDropdown).toBeVisible({ timeout: 5000 }); await solutionsDropdown.hover(); await page.waitForTimeout(300); const viewAllSolutions = desktopNav.locator('a:has-text("查看全部解决方案")').first(); await expect(viewAllSolutions).toBeVisible({ timeout: 5000 }); await viewAllSolutions.hover(); await page.waitForTimeout(200); await viewAllSolutions.click(); await page.waitForURL(/\/solutions/, { timeout: 10000 }); await expect(page).toHaveURL(/\/solutions/); await page.waitForTimeout(2000); // 验证解决方案页面加载成功 const solutionsTitle = page.locator('h1, h2').first(); await expect(solutionsTitle).toBeVisible(); // === Step 3: 点击一个解决方案 === const solutionLinks = page.locator('a[href*="/solutions/"]:not([href$="/solutions"])'); const solutionLinkCount = await solutionLinks.count(); expect(solutionLinkCount).toBeGreaterThan(0); const firstSolutionLink = solutionLinks.first(); const solutionHref = await firstSolutionLink.getAttribute('href'); console.log('UJ-02 clicking solution link:', solutionHref); await firstSolutionLink.click(); await page.waitForURL(/\/solutions\/.+/, { timeout: 10000 }); await expect(page).toHaveURL(/\/solutions\/.+/); await page.waitForTimeout(2000); // === Step 4: 验证解决方案详情页 === const solutionDetailTitle = page.locator('h1').first(); await expect(solutionDetailTitle).toBeVisible({ timeout: 10000 }); const solutionDetailText = await solutionDetailTitle.textContent(); expect(solutionDetailText).toBeTruthy(); expect(solutionDetailText!.length).toBeGreaterThan(0); // 验证解决方案详情页包含核心内容区域 const solutionMain = page.locator('main').first(); await expect(solutionMain).toBeVisible(); const solutionMainText = await solutionMain.textContent(); expect(solutionMainText!.length).toBeGreaterThan(200); // 验证包含行业痛点/解决方案/价值主张区域 const solutionSections = ['行业痛点', '解决方案', 'Value Proposition']; let hasSolutionSection = false; for (const section of solutionSections) { if (solutionMainText!.includes(section)) { hasSolutionSection = true; break; } } expect(hasSolutionSection).toBe(true); // === Step 5: 通过"查看产品"链接进入产品页面 === // 解决方案详情页的 Hero 区有"查看产品"链接 const viewProductsLink = page.locator('a[href="/products"]').first(); await expect(viewProductsLink).toBeVisible({ timeout: 5000 }); await viewProductsLink.click(); await page.waitForURL(/\/products/, { timeout: 10000 }); await expect(page).toHaveURL(/\/products/); await page.waitForTimeout(2000); // 验证产品页面加载成功 const productsPageTitle = page.locator('h1, h2').first(); await expect(productsPageTitle).toBeVisible(); // === Step 6: 点击产品进入详情页 === const productDetailLinks = page.locator('a[href*="/products/"]:not([href$="/products"])'); const productDetailLinkCount = await productDetailLinks.count(); expect(productDetailLinkCount).toBeGreaterThan(0); const firstProductDetailLink = productDetailLinks.first(); const productHref = await firstProductDetailLink.getAttribute('href'); console.log('UJ-02 clicking product link:', productHref); await firstProductDetailLink.click(); await page.waitForURL(/\/products\/.+/, { timeout: 10000 }); await expect(page).toHaveURL(/\/products\/.+/); await page.waitForTimeout(2000); // 验证产品详情页 const productDetailTitle = page.locator('h1').first(); await expect(productDetailTitle).toBeVisible({ timeout: 10000 }); const productDetailTitleText = await productDetailTitle.textContent(); expect(productDetailTitleText).toBeTruthy(); expect(productDetailTitleText!.length).toBeGreaterThan(0); // 验证产品详情页包含完整内容 const productDetailMain = page.locator('main').first(); const productDetailText = await productDetailMain.textContent(); expect(productDetailText!.length).toBeGreaterThan(200); // 验证存在 CTA 链接 const contactLink = page.locator('a[href="/contact"]').first(); await expect(contactLink).toBeVisible(); }); }); // ==================== UJ-04: 新闻读者浏览旅程 ==================== test.describe('UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)', () => { test('@journey @regression 读者从新闻列表浏览到详情页并返回', async ({ page }) => { // === Step 1: 访问新闻列表 === await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2000); await closeCookieBanner(page); // 验证新闻列表页加载成功 const newsTitle = page.locator('h1').first(); await expect(newsTitle).toBeVisible({ timeout: 10000 }); const newsTitleText = await newsTitle.textContent(); expect(newsTitleText).toBeTruthy(); // N-27 修复:种子含 2 篇已发布新闻(company-founded、 // digital-transformation-solution),列表卡片链接 a[href*="/news/"] 恒存在。 // 原实现用 `if (articleCount > 0)` 包住整段旅程 + else 兜底 bodyText —— 新闻 // 列表消失时本例仍绿。改为硬断言,任何一步元素消失即红。 const firstArticle = page.locator('a[href*="/news/"]').first(); await expect(firstArticle).toBeVisible({ timeout: 5000 }); // === Step 2: 点击第一篇新闻进入详情页 === await Promise.all([ page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }), firstArticle.click(), ]); await page.waitForTimeout(1000); // === Step 3: 验证新闻详情页 === const detailTitle = page.locator('h1').first(); await expect(detailTitle).toBeVisible({ timeout: 10000 }); const detailTitleText = await detailTitle.textContent(); expect(detailTitleText).toBeTruthy(); expect(detailTitleText!.length).toBeGreaterThan(0); // 验证新闻详情页有正文内容 const detailContent = page.locator('main').first(); await expect(detailContent).toBeVisible(); const detailText = await detailContent.textContent(); expect(detailText!.length).toBeGreaterThan(50); // === Step 4: 通过面包屑返回新闻列表 === // news-detail-content-v3.tsx:68-71 恒渲染 breadcrumb nav 中的「新闻动态」链接 const breadcrumbNewsLink = page.locator('nav[aria-label="breadcrumb"] a[href="/news"]'); await expect(breadcrumbNewsLink).toBeVisible(); await Promise.all([ page.waitForURL(/\/news\/?$/, { timeout: 15000, waitUntil: 'commit' }), breadcrumbNewsLink.click(), ]); // 验证已返回新闻列表 await expect(page).toHaveURL(/\/news\/?$/); }); }); // ==================== UJ-05: 案例浏览者筛选旅程 ==================== test.describe('UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => { test('@journey @regression 客户访问案例页,零案例种子下经共创空态转化到联系页', async ({ page }) => { // N-27 修复:原实现用 `if (caseLinkCount > 0)` 包住详情断言 + else 兜底 // bodyText ⇒ 案例区永远空白也绿。种子刻意 0 条案例(prisma/seed.ts:628-631 // 「首批客户共创阶段,案例种子为空」),cases-content-v3.tsx:234-343 的契约是 // 「hasAnyCases=false ⇒ 渲染共创空态卡 + 行业筛选器整段缺席」。 // 「列表→筛选→详情」的真断言需要 ≥1 条已发布案例,接通前不可验证(见整改报告); // 本例改为断言当下真实的转化旅程:案例页 → 空态出口「预约咨询」→ 联系页。 await page.goto('/cases', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2000); await closeCookieBanner(page); // 页面骨架正常:hero h1 恒定渲染(cases-content-v3.tsx:142-163) const casesTitle = page.locator('h1').first(); await expect(casesTitle).toBeVisible({ timeout: 10000 }); await expect(casesTitle).toContainText('以成果为目标'); // 零案例契约:筛选按钮组与案例卡片必须不存在(种子加入案例后此处变红, // 提示把「列表→筛选→详情」断言接回来,而非静默跳过) await expect(page.getByRole('group', { name: '按行业筛选' })).toHaveCount(0); await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0); // 空态转化卡:预约咨询 CTA 存在且 href 契约正确 const emptyPrimaryCta = page.locator('[data-testid="cases-empty-primary-cta"]'); await expect(emptyPrimaryCta).toBeVisible(); await expect(emptyPrimaryCta).toHaveAttribute('href', '/contact'); // === 点击转化出口,验证抵达联系页 === await Promise.all([ page.waitForURL((url) => new URL(url).pathname === '/contact', { timeout: 15000, waitUntil: 'commit' }), emptyPrimaryCta.click(), ]); await expect(page).toHaveURL((url) => url.pathname === '/contact'); const contactTitle = page.locator('h1').first(); await expect(contactTitle).toBeVisible({ timeout: 10000 }); const contactText = await page.locator('main').first().textContent(); expect(contactText!.length).toBeGreaterThan(50); }); }); // ==================== UJ-10: 深度搜索者旅程 ==================== test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => { test('@journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => { // N-27 修复:原实现用 `if (isVisible)`/`if (count > 0)` 包住 Step 2/3/4/5/6 // 几乎所有断言 ⇒ 分类按钮或文章消失时本例只剩两条哨兵断言仍绿。 // 种子契约(prisma/dev.db):恰有 2 篇已发布新闻,各属一类—— // company-founded(公司新闻)、digital-transformation-solution(研发动态); // 分类按钮组恒渲染(news-content-v3.tsx:159-167)⇒ 全部改为硬断言。 await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2000); await closeCookieBanner(page); // 验证新闻列表页加载成功 const newsTitle = page.locator('h1').first(); await expect(newsTitle).toBeVisible({ timeout: 10000 }); const newsTitleText = await newsTitle.textContent(); expect(newsTitleText).toBeTruthy(); // 验证分类筛选按钮存在(两个分类 chip 必须可见) const companyNewsBtn = page.locator('button', { hasText: '公司新闻' }).first(); const devNewsBtn = page.locator('button', { hasText: '研发动态' }).first(); await expect(companyNewsBtn).toBeVisible(); await expect(devNewsBtn).toBeVisible(); // === Step 2: 点击「公司新闻」→ 列表应收窄到恰 1 篇 === await companyNewsBtn.click(); await expect(page.locator('a[href*="/news/"]')).toHaveCount(1); // === Step 3: 阅读第一篇新闻文章 === const firstArticle = page.locator('a[href*="/news/"]').first(); await expect(firstArticle).toContainText('正式成立'); await Promise.all([ page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }), firstArticle.click(), ]); await page.waitForTimeout(1000); // 验证新闻详情页加载成功(标题/分类徽标/正文都是恒定渲染) const detailTitle = page.locator('h1').first(); await expect(detailTitle).toContainText('四川睿新致远科技有限公司正式成立'); const detailContent = page.locator('main').first(); await expect(detailContent).toBeVisible(); const detailText = await detailContent.textContent(); expect(detailText!.length).toBeGreaterThan(100); await expect(detailContent).toContainText('公司新闻'); // 相关内容区:两篇新闻分属不同分类,相关新闻按同分类筛选 //(news/[slug]/page.tsx:43-46)⇒ 当前种子下恒为空,section 必须缺席 await expect(page.getByRole('heading', { name: '相关新闻' })).toHaveCount(0); // === Step 4: 返回新闻列表,切换「研发动态」分类 === await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1500); const devNewsBtn2 = page.locator('button', { hasText: '研发动态' }).first(); await expect(devNewsBtn2).toBeVisible(); await devNewsBtn2.click(); await expect(page.locator('a[href*="/news/"]')).toHaveCount(1); // === Step 5: 阅读第二篇新闻文章 === const secondArticle = page.locator('a[href*="/news/"]').first(); await Promise.all([ page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }), secondArticle.click(), ]); await page.waitForTimeout(1000); const secondDetailTitle = page.locator('h1').first(); await expect(secondDetailTitle).toContainText('数字化转型'); const secondDetailContent = page.locator('main').first(); await expect(secondDetailContent).toBeVisible(); const secondDetailText = await secondDetailContent.textContent(); expect(secondDetailText!.length).toBeGreaterThan(100); await expect(secondDetailContent).toContainText('研发动态'); // === Step 6: 返回全部新闻列表,验证内容发现能力 === await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1500); // 点击「全部」恢复显示所有新闻 const allBtn = page.locator('button', { hasText: '全部' }).first(); await expect(allBtn).toBeVisible(); await allBtn.click(); // 全部种子新闻文章可见(恰 2 篇) await expect(page.locator('a[href*="/news/"]')).toHaveCount(2); // 验证搜索功能(内容发现工具):输入命中词应精确收窄到 1 篇 const searchInput = page.getByLabel('搜索新闻'); await expect(searchInput).toBeVisible(); await searchInput.fill('数字化转型'); const searchResults = page.locator('a[href*="/news/"]'); await expect(searchResults).toHaveCount(1); await expect(searchResults.first()).toContainText('数字化转型'); }); }); // ==================== 辅助函数: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 { 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: `

UJ-03 测试正文内容 ${suffix}

`, 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: 验收 A-4 契约 —— 非白名单扩展名即使谎报 MIME 也必须拒绝 === // 原实现在此处上传 .txt 并断言 200:那是 A-4 修复前「任意文件可落盘」的行为, // 断言的正是漏洞本身,故改为断言拒绝 + 白名单内真实图片成功。 const rejectedResponse = await request.post(`${BASE_URL}/api/admin/media`, { multipart: { file: { name: `uj07-rejected-${Date.now()}.txt`, mimeType: 'image/png', buffer: Buffer.from('UJ-07 disallowed extension probe', 'utf-8'), }, }, headers: { Authorization: `Bearer ${token}`, }, }); expect(rejectedResponse.status()).toBe(400); console.log('UJ-07 .txt upload rejected as expected'); // === Step 4b: 白名单内的真实图片上传 === // 96 字节 8×8 PNG,由 sharp 生成并回读校验(meta.format === 'png'),非手造字节 const pngBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQYlWM4Imf1Hx9mGBkKAGZzhsHMOkj6AAAAAElFTkSuQmCC', 'base64' ); const testFileName = `uj07-test-${Date.now()}.png`; const uploadResponse = await request.post(`${BASE_URL}/api/admin/media`, { multipart: { file: { name: testFileName, mimeType: 'image/png', buffer: pngBuffer, }, }, 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'); } }); });