Files
lingxi/e2e/a4-dicts-settings.spec.ts
T
Stev_Wang f16d04fe02 test(e2e): A4 字典与配置端到端用例
背景:A4-QA-01 需要真实 API + Web + PG 的浏览器回归。\n改动:新增 e2e/a4-dicts-settings.spec.ts,覆盖三层合并 / 编码只读 / 树词表切换 / 类型与条目启停 / 命名预览 / 阈值二次确认 / 变更记录 / 权限三态;e2e/README 登记 A4 用例与 Semi 选择器口径。\n影响面:仅测试文件;A4_E2E=1 才执行,默认 skip。\n回滚:删除本文件即可。\n验证:A4_E2E=1 运行 7 passed(约 1.1m);eslint/prettier 通过。
2026-11-04 11:50:11 +08:00

679 lines
30 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.
/// <reference lib="dom" />
/**
* A4-QA-01 · 数据字典与系统配置端到端用例(真实 API + 真实 PG + 真实浏览器)
*
* 覆盖:
* ① /system/dicts:h1「数据字典」、左侧「字典类型」、右栏类型头、三层 chips、树 / 词表切换、行业作用域;
* ② 默认类型 script_direction + 行业视角「电商 / 服饰鞋包」→ 生效 7 项,并可见「种草短视频 / 直播切片 /
* 穿搭教程」与「已覆盖」标签;
* ③ 新增条目 → 列表出现 → 打开编辑时编码只读 → 停用 / 启用(Modal.confirm)→ 行状态变化;
* ④ 新建字典类型(编码前缀 a4e2e_)→ 类型列表可见 → 打开编辑验证编码只读;
* ⑤ /system/settings:h1「系统配置」、左侧 6 组、命名规则占位符 chips 与实时预览、模板行内编辑;
* ⑥ 修改「客户基础字段上限」为 12 → 保存全部更改 → 二次确认 → toast / 版本 +1 / 变更记录抽屉。
* ⑦ 无 dict:manage / system:configure 的账号访问 /system/dicts 与 /system/settings → 403;
* ⑧ 未登录访问 /system/dicts → 重定向登录页。
*
* 门控:仅 A4_E2E=1 时执行;默认 skip,不影响 smoke / CI。
* 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。
* 数据自建自清:临时账号 qa4.dicts.e2e(超管)+ qa4.dicts.ro(素材运营,无字典 / 配置权限),
* 用例结束删除;字典条目 / 类型按 `a4e2e_` 前缀清理;系统配置保存后通过 Prisma 恢复原值并删除本次历史行。
*
* 运行:A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
*/
import { randomBytes } from 'node:crypto';
import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const API_BASE = process.env.A4_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A4_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A4_E2E === '1';
const PASSWORD = 'lingxi2026';
/** 与 a3 / a1 共用的 argon2id 哈希(明文 lingxi2026) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const ADMIN_ACCOUNT = 'qa4.dicts.e2e';
const READONLY_ACCOUNT = 'qa4.dicts.ro';
/** 类型 / 条目编码前缀:用于自清理与识别异常中断遗留数据 */
const TYPE_CODE_PREFIX = 'a4e2e_';
const ITEM_CODE_PREFIX = 'a4e2e_item_';
const SEED_BASE_FIELD_LIMIT = 10;
const BASE_FIELD_KEY = 'limit.customer.base_field';
const SAVE_REASON = '在系统配置页调整';
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const prisma = new PrismaClient();
let apiContext: APIRequestContext | null = null;
let adminSession: QaSession | null = null;
let readonlySession: QaSession | null = null;
let bootstrapError: string | null = null;
let baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
let baseFieldBeforeVersion = 1;
let baseFieldAfterVersion: number | null = null;
/** 本次运行创建的字典数据(失败时 afterAll 兜底清理) */
let createdItemId: string | null = null;
let createdTypeCode: string | null = null;
interface QaSession {
accessToken: string;
refreshToken: string;
user: { id: string; name: string; account: string; roleName?: string };
permissions: string[];
mustChangePassword: boolean;
}
test.skip(!ENABLED, 'A4_E2E=1 才运行数据字典 / 系统配置 e2e(依赖 API :3000 + web :5173 + PG)');
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
// ---------------------------------------------------------------------------
// 基础工具
// ---------------------------------------------------------------------------
function randomSuffix(): string {
return randomBytes(3).toString('hex');
}
function ulid(): string {
let time = Date.now();
const timeChars = new Array<string>(10);
for (let index = 9; index >= 0; index -= 1) {
timeChars[index] = ENCODING[time % 32] ?? '0';
time = Math.floor(time / 32);
}
const random = randomBytes(16);
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`${label} 不是 JSON 对象`);
}
return value;
}
function requireString(source: Record<string, unknown>, key: string, label: string): string {
const value = source[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${label}.${key} 缺失或不是字符串`);
}
return value;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function requireApi(): APIRequestContext {
if (apiContext === null) {
throw new Error('APIRequestContext 未初始化');
}
return apiContext;
}
function requireAdminSession(): QaSession {
if (adminSession === null) {
throw new Error('超管会话未就绪');
}
return adminSession;
}
function requireReadonlySession(): QaSession {
if (readonlySession === null) {
throw new Error('只读账号会话未就绪');
}
return readonlySession;
}
/** 把登录态写入 localStorage(auth-store 的持久化 key) */
async function injectSession(page: Page, session: QaSession): Promise<void> {
const persisted = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
user: session.user,
permissions: session.permissions,
mustChangePassword: session.mustChangePassword,
};
await page.addInitScript((value: unknown) => {
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
}, persisted);
}
async function loginViaApi(account: string): Promise<QaSession> {
const context = requireApi();
const login = await context.post(`${API_BASE}/auth/login`, {
data: { account, password: PASSWORD },
});
expect(login.status(), `POST /auth/login ${account}`).toBe(200);
const body = requireRecord((await login.json()) as unknown, '登录响应');
const accessToken = requireString(body, 'accessToken', '登录响应');
const refreshToken = requireString(body, 'refreshToken', '登录响应');
const meResponse = await context.get(`${API_BASE}/me`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(meResponse.status(), `GET /me ${account}`).toBe(200);
const me = requireRecord((await meResponse.json()) as unknown, '/me 响应');
const user = requireRecord(me.user, '/me.user');
const roles = Array.isArray(me.roles) ? me.roles : [];
const firstRole = isRecord(roles[0]) ? roles[0] : null;
return {
accessToken,
refreshToken,
user: {
id: requireString(user, 'id', '/me.user'),
name: requireString(user, 'name', '/me.user'),
account: requireString(user, 'account', '/me.user'),
...(firstRole === null ? {} : { roleName: requireString(firstRole, 'name', '/me.roles[0]') }),
},
permissions: stringArray(me.permissions),
mustChangePassword: me.mustChangePassword === true,
};
}
async function createUser(account: string, name: string, roleCode: string): Promise<void> {
const [role, department] = await Promise.all([
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
]);
await prisma.user.deleteMany({ where: { account } });
await prisma.user.create({
data: {
id: ulid(),
name,
account,
email: `${account}@lingxi.cn`,
phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
}
/** 清理异常中断运行遗留的字典数据(前缀匹配,避免影响 seed 与其他用例) */
async function cleanupDictLeftovers(): Promise<void> {
const staleTypes = await prisma.dictType.findMany({
where: { code: { startsWith: TYPE_CODE_PREFIX } },
select: { id: true },
});
if (staleTypes.length > 0) {
await prisma.dictItem.deleteMany({
where: { typeId: { in: staleTypes.map((row) => row.id) } },
});
await prisma.dictType.deleteMany({ where: { id: { in: staleTypes.map((row) => row.id) } } });
}
await prisma.dictItem.deleteMany({ where: { code: { startsWith: ITEM_CODE_PREFIX } } });
}
/** 读取并记录「客户基础字段上限」的原始值 / 版本;异常遗留值先恢复 seed 默认值 */
async function prepareBaseFieldConfig(): Promise<void> {
const row = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
const original = typeof row?.configValue === 'number' ? row.configValue : SEED_BASE_FIELD_LIMIT;
baseFieldOriginalValue = original;
baseFieldBeforeVersion = row?.version ?? 1;
if (original !== SEED_BASE_FIELD_LIMIT) {
// 上次运行可能异常中断;恢复 seed 默认值,保证本次「改成 12」一定产生变更。
await prisma.systemConfig.update({
where: { configKey: BASE_FIELD_KEY },
data: { configValue: SEED_BASE_FIELD_LIMIT },
});
baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
}
}
interface DictTypeRow {
id: string;
code: string;
}
async function readDictTypeByCode(code: string): Promise<DictTypeRow | null> {
return prisma.dictType.findUnique({ where: { code }, select: { id: true, code: true } });
}
// ---------------------------------------------------------------------------
// 浏览器 / 页面辅助
// ---------------------------------------------------------------------------
async function openDicts(page: Page): Promise<void> {
await injectSession(page, requireAdminSession());
await page.goto('/system/dicts');
await expect(page.getByRole('heading', { name: '数据字典' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible();
}
async function gotoSystem(page: Page, path: string): Promise<void> {
await injectSession(page, requireAdminSession());
await page.goto(path);
}
function dictItemRow(page: Page, text: string) {
return page.locator('.lx-dict-table tbody tr', { hasText: text });
}
/**
* 选择 Semi Select 下拉项。
* 注意:Semi Select option 的可访问名会带上勾选图标的 aria-label(tick),
* `getByRole('option', { name, exact: true })` 匹配不到,这里统一按选项文本定位。
*/
async function pickSelectOption(page: Page, optionName: string): Promise<void> {
const option = page.locator('.semi-select-option').filter({ hasText: optionName }).first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click();
}
async function waitToast(page: Page, text: string | RegExp): Promise<void> {
await expect(page.locator('[role="alert"]', { hasText: text }).first()).toBeVisible({
timeout: 15_000,
});
}
// ---------------------------------------------------------------------------
// 全局编排
// ---------------------------------------------------------------------------
test.beforeAll(async () => {
if (!ENABLED) return;
apiContext = await request.newContext();
try {
const web = await apiContext.get(`${WEB_BASE}/`);
expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
await cleanupDictLeftovers();
await prepareBaseFieldConfig();
await createUser(ADMIN_ACCOUNT, 'A4 端到端超管', 'super_admin');
await createUser(READONLY_ACCOUNT, 'A4 端到端只读', 'material_operator');
adminSession = await loginViaApi(ADMIN_ACCOUNT);
readonlySession = await loginViaApi(READONLY_ACCOUNT);
expect(adminSession.permissions, '超管应含 dict:manage').toContain('dict:manage');
expect(adminSession.permissions, '超管应含 system:configure').toContain('system:configure');
expect(readonlySession.permissions, '素材运营不应含 dict:manage').not.toContain('dict:manage');
expect(readonlySession.permissions, '素材运营不应含 system:configure').not.toContain(
'system:configure',
);
} catch (error) {
bootstrapError = error instanceof Error ? error.message : String(error);
}
});
test.afterAll(async () => {
if (!ENABLED) return;
try {
// 字典:先删条目再删类型(按本次记录 + 前缀双保险)
if (createdItemId !== null) {
await prisma.dictItem.deleteMany({ where: { id: createdItemId } });
}
if (createdTypeCode !== null) {
const type = await readDictTypeByCode(createdTypeCode);
if (type !== null) {
await prisma.dictItem.deleteMany({ where: { typeId: type.id } });
await prisma.dictType.deleteMany({ where: { id: type.id } });
}
}
await cleanupDictLeftovers();
// 系统配置:恢复原值 / 原版本,并删除本次产生的历史行
if (baseFieldAfterVersion !== null) {
await prisma.systemConfigVersion.deleteMany({
where: { configKey: BASE_FIELD_KEY, version: baseFieldAfterVersion },
});
}
const configRow = await prisma.systemConfig.findUnique({
where: { configKey: BASE_FIELD_KEY },
});
if (configRow !== null) {
await prisma.systemConfig.update({
where: { configKey: BASE_FIELD_KEY },
data: { configValue: baseFieldOriginalValue, version: baseFieldBeforeVersion },
});
}
await prisma.user.deleteMany({ where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT] } } });
} catch {
// 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按前缀清理
}
await apiContext?.dispose();
apiContext = null;
await prisma.$disconnect();
});
test.beforeEach(() => {
test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
});
// ---------------------------------------------------------------------------
// ① / ② 字典主从页:默认类型、三层 chips、树表切换、行业作用域
// ---------------------------------------------------------------------------
test('数据字典:默认类型 / 三层 chips / 树词表切换 / 行业作用域', async ({ page }) => {
test.setTimeout(120_000);
await openDicts(page);
// 左侧类型栏与右栏类型头
await expect(page.getByRole('complementary', { name: '字典类型' })).toBeVisible();
await expect(page.locator('.lx-dict-list__title')).toHaveText('字典类型');
const detailHead = page.locator('.lx-dict-head');
await expect(detailHead.locator('.lx-dict-head__name b').first()).toHaveText('脚本方向');
await expect(detailHead.locator('.lx-dict-head__meta code').first()).toHaveText(
'script_direction',
);
// 默认行业视角:电商 / 服饰鞋包
await expect(page.locator('.lx-dict-scope__select')).toContainText('电商 / 服饰鞋包');
// 三层 chips:通用 5 + 父行业 2 + 本行业 1 = 生效 7
const chips = page.locator('.lx-dict-scope__layers .lx-dict-layer-chip');
await expect(chips.filter({ hasText: '通用' })).toContainText('5');
await expect(chips.filter({ hasText: '父行业' })).toContainText('2');
await expect(chips.filter({ hasText: '本行业' })).toContainText('1');
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('生效');
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
await expect(page.locator('.lx-dict-viewrow__hint')).toContainText('生效 7 项');
// 词表视图:种短视频 / 直播切片 / 穿搭教程 + 「已覆盖」标签
await expect(page.locator('.lx-dict-table')).toBeVisible();
for (const name of ['种草短视频', '直播切片', '穿搭教程']) {
await expect(dictItemRow(page, name)).toBeVisible();
}
await expect(dictItemRow(page, '种草短视频').getByText('已覆盖')).toBeVisible();
await expect(dictItemRow(page, '直播切片').getByText('已覆盖')).toHaveCount(0);
// 树 / 词表切换:点击「树视图」→ URL ?view=tree 且渲染树
// Semi RadioGroup 的隐藏 input 被 addon 覆盖,直接点击可见的 addon 文本。
const viewSwitcher = page.locator('.lx-dict-viewrow');
await viewSwitcher
.getByText(/树视图/)
.first()
.click();
await expect(page).toHaveURL(/view=tree/);
await expect(page.locator('.lx-dict-tree')).toBeVisible();
await expect(page.locator('.lx-dict-table')).toHaveCount(0);
await viewSwitcher
.getByText(/词表视图/)
.first()
.click();
await expect(page).toHaveURL(/view=list/);
await expect(page.locator('.lx-dict-table')).toBeVisible();
// 行业作用域:切到全平台 → 只保留通用层(生效 5)
await page.locator('.lx-dict-scope__select').click();
await pickSelectOption(page, '全平台');
await expect(page).toHaveURL(/industry=all/);
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('5');
await expect(page.locator('.lx-dict-layer-chip', { hasText: '父行业' })).toHaveCount(0);
await expect(dictItemRow(page, '直播切片')).toHaveCount(0);
await expect(dictItemRow(page, '口播')).toBeVisible();
// 切回电商 / 服饰鞋包 → 三层恢复为 7
await page.locator('.lx-dict-scope__select').click();
await pickSelectOption(page, '电商 / 服饰鞋包');
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
await expect(dictItemRow(page, '直播切片')).toBeVisible();
});
// ---------------------------------------------------------------------------
// ③ 条目:新增 → 编辑编码只读 → 停用 / 启用
// ---------------------------------------------------------------------------
test('数据字典:新增条目 → 编辑编码只读 → 停用 / 启用', async ({ page }) => {
test.setTimeout(150_000);
await openDicts(page);
await expect(page.locator('.lx-dict-table tbody tr').first()).toBeVisible();
const suffix = randomSuffix();
const itemCode = `${ITEM_CODE_PREFIX}${suffix}`;
const itemName = `A4E2E测试条目${suffix}`;
await page.getByRole('button', { name: '新增条目' }).first().click();
const sheet = page.locator('.semi-sidesheet');
await expect(sheet).toBeVisible();
await expect(sheet.locator('.semi-sidesheet-title')).toHaveText('新增条目');
await sheet.getByPlaceholder('如:口播').fill(itemName);
await sheet.getByPlaceholder('字母、数字、下划线或短横线').fill(itemCode);
await sheet.getByRole('button', { name: '保存', exact: true }).click();
await waitToast(page, '条目已创建');
const row = dictItemRow(page, itemCode);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toContainText(itemName);
await expect(row.locator('.lx-dict-status--active')).toBeVisible();
// 记录 id,afterAll 兜底清理
const createdItem = await prisma.dictItem.findFirst({
where: { code: itemCode },
select: { id: true },
});
createdItemId = createdItem?.id ?? null;
expect(createdItemId, '新增条目应落库').not.toBeNull();
// 打开编辑:编码输入禁用且值不可改
await row.getByRole('button', { name: '编辑', exact: true }).click();
const editSheet = page.locator('.semi-sidesheet');
await expect(editSheet.locator('.semi-sidesheet-title')).toHaveText('编辑条目');
const codeInput = editSheet.getByPlaceholder('字母、数字、下划线或短横线');
await expect(codeInput).toBeDisabled();
await expect(codeInput).toHaveValue(itemCode);
await expect(editSheet.getByText('编码创建后不可修改')).toBeVisible();
await editSheet.getByRole('button', { name: '取消', exact: true }).click();
await expect(editSheet).toHaveCount(0);
// 停用条目:Modal.confirm 点「停用」(Semi 确认按钮的 aria-label 固定为 confirm)
await row.getByRole('button', { name: '停用', exact: true }).click();
const disableDialog = page.getByRole('dialog', { name: new RegExp(`停用条目「${itemName}」`) });
await expect(disableDialog).toBeVisible();
await disableDialog.getByRole('button', { name: 'confirm' }).click();
await waitToast(page, `已停用「${itemName}」`);
await expect(row.locator('.lx-dict-status--disabled')).toBeVisible();
await expect(row).toHaveClass(/is-disabled/);
// 启用条目:Modal.confirm 点「启用」
await row.getByRole('button', { name: '启用', exact: true }).click();
const enableDialog = page.getByRole('dialog', { name: new RegExp(`启用条目「${itemName}」`) });
await expect(enableDialog).toBeVisible();
await enableDialog.getByRole('button', { name: 'confirm' }).click();
await waitToast(page, `已启用「${itemName}」`);
await expect(row.locator('.lx-dict-status--active')).toBeVisible();
await expect(row).not.toHaveClass(/is-disabled/);
});
// ---------------------------------------------------------------------------
// ④ 字典类型:新建 → 列表可见 → 编辑编码只读
// ---------------------------------------------------------------------------
test('数据字典:新建类型 → 列表可见 → 编辑类型编码只读', async ({ page }) => {
test.setTimeout(120_000);
await openDicts(page);
const suffix = randomSuffix();
const typeCode = `${TYPE_CODE_PREFIX}type_${suffix}`;
const typeName = `A4E2E测试类型${suffix}`;
createdTypeCode = typeCode;
await page.getByRole('button', { name: '新建字典类型' }).click();
const modal = page.getByRole('dialog', { name: '新建字典类型' });
await expect(modal).toBeVisible();
await modal.getByPlaceholder('如:投放位置').fill(typeName);
await modal.getByPlaceholder('小写字母、数字与下划线').fill(typeCode);
// 所属分组:质量与合规
const groupField = modal.locator('.lx-dict-field', { hasText: '所属分组' });
await groupField.locator('.semi-select').click();
await pickSelectOption(page, '质量与合规');
// 形态:树
const kindField = modal.locator('.lx-dict-field', { hasText: '形态' });
await kindField.locator('.semi-select').click();
await pickSelectOption(page, '树');
await expect(kindField.locator('.semi-select')).toContainText('树');
await modal.getByRole('button', { name: '保存', exact: true }).click();
await waitToast(page, '字典类型已创建');
// 保存后自动进入新类型详情;左侧类型列表可见
await expect(page).toHaveURL(new RegExp(`/system/dicts/${typeCode}`));
await expect(page.locator('.lx-dict-head__name b').first()).toHaveText(typeName);
const typeRow = page.locator('.lx-dict-type', { hasText: typeCode });
await expect(typeRow).toBeVisible({ timeout: 15_000 });
await expect(typeRow).toContainText(typeName);
// 编辑类型:编码只读、形态只读
await page.getByRole('button', { name: '编辑类型' }).click();
const editModal = page.getByRole('dialog', { name: '编辑字典类型' });
await expect(editModal).toBeVisible();
const codeField = editModal.locator('.lx-dict-field', { hasText: '类型编码' });
await expect(codeField.locator('input')).toBeDisabled();
await expect(codeField.locator('input')).toHaveValue(typeCode);
await expect(editModal.getByText('创建后不可修改').first()).toBeVisible();
const editKindField = editModal.locator('.lx-dict-field', { hasText: '形态' });
await expect(editKindField.locator('.semi-select-disabled')).toBeVisible();
await editModal.getByRole('button', { name: '取消', exact: true }).click();
await expect(editModal).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// ⑤ 系统配置:6 组、命名占位符与实时预览、模板行内编辑
// ---------------------------------------------------------------------------
test('系统配置:6 组导航 / 命名占位符 / 实时预览 / 模板编辑', async ({ page }) => {
test.setTimeout(120_000);
await gotoSystem(page, '/system/settings');
await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible();
// 左侧 6 组
await expect(page.getByRole('complementary', { name: '配置分组' })).toBeVisible();
const navItems = page.locator('.lx-settings-nav__item');
await expect(navItems).toHaveCount(6);
for (const groupName of [
'基础信息',
'命名规则',
'导出与归档',
'通知开关',
'阈值与上限',
'业务阈值',
]) {
await expect(navItems.filter({ hasText: groupName })).toBeVisible();
}
// 命名规则:占位符 chips + 默认实时预览
await expect(page.locator('.lx-settings-naming-aside')).toBeVisible();
await expect(page.locator('.lx-settings-aside-head')).toContainText('命名占位符');
await expect(page.locator('.lx-settings-token')).toHaveCount(6);
await expect(page.locator('.lx-settings-preview__value')).toHaveText(
'电商-花间集-口播-20261104-01',
);
const originalPreview = await page.locator('.lx-settings-preview__value').innerText();
// 点击「编辑」改模板 → 预览实时变化 → 放弃更改恢复
const patternRow = page
.locator('.lx-settings-config.is-pattern')
.filter({ hasText: '脚本命名规则' });
await patternRow.getByRole('button', { name: '编辑', exact: true }).click();
const patternInput = page.getByLabel('命名规则');
await expect(patternInput).toBeVisible();
await patternInput.fill('{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}');
await expect(page.locator('.lx-settings-preview__value')).toHaveText(
'电商-花间集-口播-20261104-01-1080x1920',
);
await page
.locator('.lx-settings-form__foot')
.getByRole('button', { name: '放弃更改', exact: true })
.click();
await expect(page.locator('.lx-settings-preview__value')).toHaveText(originalPreview);
await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
});
// ---------------------------------------------------------------------------
// ⑥ 阈值修改:保存 → 二次确认 → toast / 版本 +1 / 变更记录
// ---------------------------------------------------------------------------
test('系统配置:阈值修改 → 二次确认 → 版本 +1 → 变更记录', async ({ page }) => {
test.setTimeout(150_000);
await gotoSystem(page, '/system/settings');
await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
// 进入「阈值与上限」
await page.locator('.lx-settings-nav__item', { hasText: '阈值与上限' }).click();
await expect(page).toHaveURL(/group=limits/);
await expect(page.locator('.lx-settings-form__head h2')).toHaveText('阈值与上限');
const baseFieldRow = page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' });
await expect(baseFieldRow).toBeVisible();
await baseFieldRow.getByLabel('客户基础字段上限').fill('12');
await expect(page.locator('.lx-settings-headindicator')).toContainText('未保存 1 项');
await page
.locator('.lx-settings-form__foot')
.getByRole('button', { name: '保存全部更改', exact: true })
.click();
// 阈值类配置二次确认
const confirmDialog = page.getByRole('dialog', { name: /确认修改阈值与上限/ });
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog.getByText('客户基础字段上限')).toBeVisible();
await expect(confirmDialog).toContainText('12');
await confirmDialog.getByRole('button', { name: 'confirm' }).click();
await waitToast(page, '配置已保存,版本已更新');
await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
// 版本 +1(值来自 Prisma 当前行)
const saved = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
expect(saved?.configValue, '配置值应为 12').toBe(12);
baseFieldAfterVersion = saved?.version ?? null;
expect(baseFieldAfterVersion, '配置版本应 +1').toBe(baseFieldBeforeVersion + 1);
await expect(
page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' }),
).toContainText(`v${String(baseFieldAfterVersion)}`);
// 变更记录抽屉:出现本次记录(版本 / 前后值 / 原因)
await page.getByRole('button', { name: '变更记录' }).click();
const historySheet = page.locator('.semi-sidesheet');
await expect(historySheet).toBeVisible();
await expect(historySheet.locator('.semi-sidesheet-title')).toHaveText('配置变更记录');
const historyItem = page
.locator('.lx-settings-history-item')
.filter({ hasText: '客户基础字段上限' })
.filter({ hasText: `v${String(baseFieldAfterVersion)}` });
await expect(historyItem).toBeVisible({ timeout: 15_000 });
await expect(historyItem).toContainText('12');
await expect(historyItem).toContainText(SAVE_REASON);
});
// ---------------------------------------------------------------------------
// ⑦ 权限:无 dict:manage / system:configure 的账号 → 403
// ---------------------------------------------------------------------------
test('权限:无字典 / 配置权限的账号访问 A4 页面显示 403', async ({ page }) => {
test.setTimeout(90_000);
await injectSession(page, requireReadonlySession());
await page.goto('/system/dicts');
await expect(page).toHaveURL(/\/403/);
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
await page.goto('/system/settings');
await expect(page).toHaveURL(/\/403/);
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
});
// ---------------------------------------------------------------------------
// ⑧ 未登录:访问 /system/dicts 重定向登录页
// ---------------------------------------------------------------------------
test('未登录访问 /system/dicts 重定向到登录页', async ({ page }) => {
await page.goto('/system/dicts');
await expect(page).toHaveURL(/\/login\?redirect=/);
await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
});