背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。 改动: - 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。 - 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。 - 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。 - 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。 - 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。 影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。 回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。 验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
121 lines
4.5 KiB
TypeScript
121 lines
4.5 KiB
TypeScript
/// <reference lib="dom" />
|
||
/**
|
||
* A3-QA-01 · 客户与行业目录端到端用例(真实 API + 真实 PG + 真实浏览器)
|
||
*
|
||
* 覆盖:登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户向导 → 客户组 → 字段审批。
|
||
* 门控:仅 A3_E2E=1 时执行;默认不跑,避免影响 CI / smoke。
|
||
*
|
||
* 前置:API 3000、Web 5173(`pnpm dev`);用例自建 `qa3.e2e` 超管账号并在结束后删除。
|
||
* 运行:A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||
*/
|
||
import { randomBytes } from 'node:crypto';
|
||
|
||
import { expect, test } from '@playwright/test';
|
||
import { PrismaClient } from '@prisma/client';
|
||
|
||
const API_BASE = process.env.A3_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
|
||
const WEB_BASE = process.env.A3_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
|
||
const ENABLED = process.env.A3_E2E === '1';
|
||
const PASSWORD = 'lingxi2026';
|
||
const PASSWORD_HASH =
|
||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||
const ACCOUNT = 'qa3.e2e';
|
||
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
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('')}`;
|
||
}
|
||
|
||
test.describe('A3 客户与行业目录', () => {
|
||
test.skip(!ENABLED, 'A3_E2E=1 时执行;需要 API 3000 + Web 5173');
|
||
|
||
test.beforeAll(async () => {
|
||
const [role, department] = await Promise.all([
|
||
prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }),
|
||
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
|
||
]);
|
||
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
|
||
await prisma.user.create({
|
||
data: {
|
||
id: ulid(),
|
||
name: 'A3 端到端',
|
||
account: ACCOUNT,
|
||
email: `${ACCOUNT}@lingxi.cn`,
|
||
phone: '13500008888',
|
||
passwordHash: PASSWORD_HASH,
|
||
departmentId: department.id,
|
||
mustChangePassword: false,
|
||
roles: { create: [{ roleId: role.id }] },
|
||
},
|
||
});
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
|
||
await prisma.$disconnect();
|
||
});
|
||
|
||
test('行业 / 客户 / 客户组 / 字段审批六页可访问且数据加载', async ({ page, request }) => {
|
||
const login = await request.post(`${API_BASE}/auth/login`, {
|
||
data: { account: ACCOUNT, password: PASSWORD },
|
||
});
|
||
expect(login.ok()).toBe(true);
|
||
const session = (await login.json()) as { accessToken: string; refreshToken: string };
|
||
const meResponse = await request.get(`${API_BASE}/me`, {
|
||
headers: { authorization: `Bearer ${session.accessToken}` },
|
||
});
|
||
expect(meResponse.ok()).toBe(true);
|
||
const me = (await meResponse.json()) as {
|
||
user: { id: string; name: string; account: string };
|
||
roles: { name: string }[];
|
||
permissions: string[];
|
||
};
|
||
await page.addInitScript(
|
||
(value) => {
|
||
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
|
||
},
|
||
{
|
||
accessToken: session.accessToken,
|
||
refreshToken: session.refreshToken,
|
||
user: {
|
||
id: me.user.id,
|
||
name: me.user.name,
|
||
account: me.user.account,
|
||
roleName: me.roles[0]?.name,
|
||
},
|
||
permissions: me.permissions,
|
||
mustChangePassword: false,
|
||
},
|
||
);
|
||
|
||
const pages = [
|
||
{ path: '/system/industries', title: '行业管理' },
|
||
{ path: '/system/customers', title: '客户管理' },
|
||
{ path: '/system/customers/new', title: '新建客户' },
|
||
{ path: '/system/customer-groups', title: '客户组' },
|
||
{ path: '/system/customer-fields/approvals', title: '客户字段审批' },
|
||
];
|
||
for (const item of pages) {
|
||
await page.goto(`${WEB_BASE}${item.path}`, { waitUntil: 'networkidle' });
|
||
await expect(page.locator('h1').first()).toHaveText(item.title);
|
||
}
|
||
|
||
await page.goto(`${WEB_BASE}/system/customers`, { waitUntil: 'networkidle' });
|
||
const customerRows = page.locator('.lx-client-table tbody tr');
|
||
const count = await customerRows.count();
|
||
expect(count).toBeGreaterThan(0);
|
||
await customerRows.first().click();
|
||
await expect(page).toHaveURL(/\/system\/customers\/[A-Z0-9]+$/);
|
||
await expect(page.locator('h1').first()).toBeVisible();
|
||
});
|
||
});
|