Files
photo-live/apps/admin/e2e/albums-governance.spec.ts
T
浅念 e44b35db28 feat(albums): date-only pickers for start/end (create & edit, admin + app)
- 管理后台:DatePicker type=datetime → date;提交归一(开始=当天 00:00、
  结束=当天 23:59 整天活动语义;契约仍 datetime 仅交互粒度)
- App:去掉时间选择步骤(选日期即定;同上归一);表单显示只显 yyyy/M/d
- e2e 日期填入改 '2026-10-01'(date 格式);widget 断言日期粒度
- admin 87/87 + build、app 397/397 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 14:13:58 +08:00

289 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 相册治理增量 e2e(P4a Task 9,真实后端 + compose 基础设施):批量隐藏
// (多选 2 → toast 成功汇总 + 行原位已隐藏 + cn 公开列表仅剩 1)→ 负责人交接
// (前缀搜索 staff → API 断言 ownerId 变更)→ 水印(配置保存 → 任务受理 →
// fixture 直驱 worker 核心到 done → UI 轮询见已完成 + 公开 browse URL 指向
// /watermark.jpg)→ 用量(阈值调小触发告警 → 上调解除)→ 分享二维码
// (canvas 非空 + URL 复制)。选择器一律 data-test;文案断言仅用稳定短语。
import { expect, test, type Page } from '@playwright/test'
import { adminCredentials, loginAsAdmin, seedFixture } from './helpers'
test.use({ testIdAttribute: 'data-test' })
test.beforeAll(() => {
// 幂等种子 staff 账号(交接目标;密码占位不可登录,仅用于查询与交接)
seedFixture('staff', 'staff-e2e', 'Staff E2E')
})
/** UI 创建相册(zh+en 标题),返回标题后缀(同 albums.spec 模式) */
async function createAlbumViaUi(page: Page): Promise<string> {
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
await page.getByTestId('album-create-btn').click()
// UI06:创建改整页表单路由
await expect(page.getByTestId('album-form-page')).toBeVisible()
await page.getByTestId('album-form-title-zh-CN').fill(`E2E治理相册 ${suffix}`)
await page.getByRole('tab', { name: '英文' }).click()
await page.getByTestId('album-form-title-en').fill(`E2E Gov Album ${suffix}`)
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01') // UI06:日期粒度
await page.keyboard.press('Enter')
await page.getByTestId('album-form-submit').click()
// 保存成功后跳相册详情;返回列表做断言
await expect(page.getByTestId('album-form-page')).toBeHidden()
await page.goBack()
// UI06:列表默认封面卡片网格;表格断言前先切换
await page.getByTestId('album-view-table').click()
await expect(
page.getByTestId('album-list-table').getByText(`E2E治理相册 ${suffix}`)
).toBeVisible()
return suffix
}
/** 列表内定位目标相册行 */
function albumRow(page: Page, suffix: string) {
return page.getByTestId('album-list-table').locator('tr', { hasText: `E2E治理相册 ${suffix}` })
}
/** 进入详情页,返回 albumId */
async function openAlbumDetail(page: Page, suffix: string): Promise<string> {
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
await expect(page.getByTestId('album-summary-cover')).toBeVisible()
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
expect(albumId).not.toBe('')
return albumId
}
/** API 登录(UI 之外的断言用):返回 Bearer 会话令牌 */
async function apiLogin(page: Page): Promise<string> {
const { account, password } = adminCredentials()
const res = await page.request.post('/v1/auth/password', { data: { account, password } })
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { sessionToken: string }
return body.sessionToken
}
/** cn 公开列表条数(批量隐藏的观众侧断言) */
async function publicPhotoCount(page: Page, token: string): Promise<number> {
const res = await page.request.get(`/v1/public/albums/${token}/photos`)
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { items: unknown[] }
return body.items.length
}
/** ElSwitch 幂等设到目标态(is-checked 类为准;已同态不点击)。 */
async function setSwitchState(page: Page, testId: string, on: boolean): Promise<void> {
const isOn = await page.getByTestId(testId).evaluate((el) => el.classList.contains('is-checked'))
if (isOn !== on) {
await page.getByTestId(testId).click()
}
}
test('批量隐藏:多选 2 → 汇总 toast + 行原位已隐藏 + 公开列表仅剩 1', async ({ page }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
const albumId = await openAlbumDetail(page, suffix)
seedFixture('gov-photos', albumId, adminCredentials().account, '3')
await page.reload()
// UI06:照片默认网格视图;表格断言前先切换
await page.getByTestId('photo-view-table').click()
await expect(page.getByTestId('photo-list-table')).toBeVisible()
const rows = page.getByTestId('photo-list-table').locator('tbody tr')
await expect(rows).toHaveCount(3)
for (const row of await rows.all()) {
await expect(row.getByTestId('photo-visibility-tag')).toHaveText('已发布')
}
// 勾选前两行 → 批量按钮亮起(计数 2)→ 确认
await rows.nth(0).locator('.el-checkbox').click()
await rows.nth(1).locator('.el-checkbox').click()
const batchBtn = page.getByTestId('photo-batch-hide-btn')
await expect(batchBtn).toBeEnabled()
await expect(batchBtn).toContainText('2')
await batchBtn.click()
await page.getByRole('button', { name: '确定' }).click()
// 汇总 toast(全部成功分支)+ 两行原位已隐藏、一行保持已发布
await expect(page.locator('.el-message').filter({ hasText: '成功 2 张' })).toBeVisible()
await expect(rows.nth(0).getByTestId('photo-visibility-tag')).toHaveText('已隐藏')
await expect(rows.nth(1).getByTestId('photo-visibility-tag')).toHaveText('已隐藏')
await expect(rows.nth(2).getByTestId('photo-visibility-tag')).toHaveText('已发布')
// 观众侧:公开列表 3 → 1
const token = await page.getByTestId('album-detail-token').textContent()
expect(await publicPhotoCount(page, token ?? '')).toBe(1)
})
test('负责人交接:搜索 staff → 交接 → ownerId 变更(API + 详情回显)', async ({ page }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
const albumId = await openAlbumDetail(page, suffix)
const sessionToken = await apiLogin(page)
// 交接前 owner 是 admin 自己
const before = (await (
await page.request.get(`/v1/albums/${albumId}`, {
headers: { Authorization: `Bearer ${sessionToken}` }
})
).json()) as { ownerId: string }
const staffId = (
(
(await (
await page.request.get('/v1/admin/users', {
params: { accountPrefix: 'staff-e2e' },
headers: { Authorization: `Bearer ${sessionToken}` }
})
).json()) as { items: { id: string }[] }
).items[0] ?? { id: '' }
).id
expect(staffId).not.toBe('')
expect(before.ownerId).not.toBe(staffId)
// 交接对话框:前缀搜索(≥2 字符)→ 选中 → 保留协作者开关默认开 → 提交
await page.getByTestId('album-detail-transfer').click()
await expect(page.getByTestId('transfer-dialog')).toBeVisible()
const targetSelect = page.getByTestId('transfer-target-select')
await targetSelect.click()
await targetSelect.locator('input').fill('staff-e2e')
await page.getByTestId('transfer-option-staff-e2e').click()
await expect(page.getByTestId('transfer-keep-switch')).toHaveClass(/is-checked/)
await page.getByTestId('transfer-submit').click()
await expect(page.getByTestId('transfer-dialog')).toBeHidden()
await expect(page.locator('.el-message').filter({ hasText: '负责人已交接' })).toBeVisible()
// 详情回显 + API 权威断言
await expect(page.getByTestId('album-detail-owner-id')).toHaveText(staffId)
const after = (await (
await page.request.get(`/v1/albums/${albumId}`, {
headers: { Authorization: `Bearer ${sessionToken}` }
})
).json()) as { ownerId: string }
expect(after.ownerId).toBe(staffId)
})
test('水印:配置保存 → 任务受理 → 直驱到已完成 → 公开 browse URL 指向水印图', async ({ page }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
const albumId = await openAlbumDetail(page, suffix)
const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim()
// 真实对象照片先行(任务 total=3,非空相册 autoDone 分支不触发)
seedFixture('wm-photos', albumId, adminCredentials().account, '3')
await page.getByTestId('album-watermark-tab').click()
await expect(page.getByTestId('watermark-panel')).toBeVisible()
await setSwitchState(page, 'watermark-enabled-switch', true)
await page.getByTestId('watermark-text-content').fill(`E2E水印 ${suffix}`)
// 保存 → 202 受理(v1)→ 任务行进入列表(pending/running)
await page.getByTestId('watermark-save-btn').click()
await expect(page.locator('.el-message').filter({ hasText: 'v1' })).toBeVisible()
const stateTag = page.getByTestId('watermark-jobs-table').locator('[data-state]')
await expect(stateTag.first()).toBeVisible()
await expect(stateTag.first()).toHaveText(/等待中|进行中/)
// e2e 无常驻 worker:fixture 直驱 worker dist 服务核心把任务跑到终态
// (页面进程独立,轮询不受 execSync 阻塞影响)
seedFixture('watermark-run', albumId)
// UI 3s 轮询翻到已完成;配置卡回显开启
await expect(stateTag.first()).toHaveText('已完成')
await expect(page.getByTestId('watermark-current-tag')).toBeVisible()
// 公开侧:album.watermarkEnabled + watermark 行存在 → browse 现签水印键
// (SignedUrlResponse 形状:{url, expiresAt})
const res = await page.request.get(`/v1/public/albums/${token}/photos`)
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { items: { browse: { url: string } }[] }
expect(body.items.length).toBe(3)
for (const item of body.items) {
expect(item.browse.url).toContain('/watermark.jpg')
}
})
test('用量:阈值调小触发告警 → 上调解除;合计与相册表可见', async ({ page }) => {
await loginAsAdmin(page)
// 独立数据:API 建相册 + 大字节照片(2MB > 0.00001GB 阈值),与 UI 顺序解耦
const sessionToken = await apiLogin(page)
const created = (await (
await page.request.post('/v1/albums', {
headers: { Authorization: `Bearer ${sessionToken}` },
data: {
defaultLocale: 'zh-CN',
startAt: '2026-10-01T10:00:00.000Z',
translations: [{ locale: 'zh-CN', title: `E2E用量相册 ${Date.now().toString(36)}` }]
}
})
).json()) as { id: string }
seedFixture('gov-photos', created.id, adminCredentials().account, '2', '2097152')
await page.goto('/#/system/usage')
await expect(page.getByTestId('usage-totals')).toBeVisible()
await expect(page.getByTestId('usage-albums-table')).toBeVisible()
// 合计已加载(种子照片计入,非空占位)
await expect(page.getByTestId('usage-total-photos')).not.toHaveText('-')
// 阈值调小(perAlbum 0.00001 GB ≈ 10KB < 2MB)→ 即刻评估出未解决告警。
// 开关状态感知:共享开发库可能残留上次运行的已存阈值(开关已开)——
// 幂等设到目标态而非盲目点击。
await page.getByTestId('usage-thresholds-btn').click()
await expect(page.getByTestId('thresholds-dialog')).toBeVisible()
await setSwitchState(page, 'thresholds-per-album-switch', true)
await page.getByTestId('thresholds-per-album-input').locator('input').fill('0.00001')
await page.keyboard.press('Enter')
await page.getByTestId('thresholds-submit').click()
await expect(page.getByTestId('thresholds-dialog')).toBeHidden()
await expect(
page.getByTestId('usage-alerts-table').locator('.alert-row-active').first()
).toBeVisible()
await expect(
page
.getByTestId('usage-alerts-table')
.locator('.alert-row-active')
.first()
.getByTestId('alert-state-tag')
).toHaveText('未解决')
// 上调到 100GB(storage 保持关闭=显式清除,避免历史阈值残留干扰)→ 解除
await page.getByTestId('usage-thresholds-btn').click()
await expect(page.getByTestId('thresholds-dialog')).toBeVisible()
await setSwitchState(page, 'thresholds-per-album-switch', true)
await setSwitchState(page, 'thresholds-storage-switch', false)
await page.getByTestId('thresholds-per-album-input').locator('input').fill('100')
await page.keyboard.press('Enter')
await page.getByTestId('thresholds-submit').click()
await expect(page.getByTestId('thresholds-dialog')).toBeHidden()
await expect(page.getByTestId('usage-alerts-table').locator('.alert-row-active')).toHaveCount(0)
await expect(
page.getByTestId('usage-alerts-table').locator('.alert-row-resolved').first()
).toBeVisible()
})
test('分享二维码:canvas 非空 + viewer URL + 复制链接', async ({ page, context }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
await openAlbumDetail(page, suffix)
const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim()
await page.getByTestId('album-share-tab').click()
await expect(page.getByTestId('share-panel')).toBeVisible()
// URL = VITE_VIEWER_BASE + /a/{token}
const urlText = ((await page.getByTestId('share-url').textContent()) ?? '').trim()
expect(urlText).toBe(`http://127.0.0.1:5175/a/${token}`)
// canvas 已绘制(空白 canvas 的 dataURL 约几十字节,绘制后远大于 100)
const dataUrlLength = await page
.getByTestId('share-qr-canvas')
.evaluate((canvas: HTMLCanvasElement) => canvas.toDataURL().length)
expect(dataUrlLength).toBeGreaterThan(100)
// 复制 → toast + 剪贴板内容一致(授 chromium 剪贴板权限)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.getByTestId('share-copy-btn').click()
await expect(page.locator('.el-message').filter({ hasText: '已复制' })).toBeVisible()
const clipped = await page.evaluate(() => navigator.clipboard.readText())
expect(clipped).toBe(urlText)
})