背景: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;真实浏览器六页验证通过。
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
|
import { loadDotenv } from '../../config/load-dotenv';
|
|
|
|
const PASSWORD_HASH =
|
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
|
const PASSWORD = 'lingxi2026';
|
|
const PREFIX = 'itqa3.';
|
|
const API = API_PREFIX;
|
|
|
|
loadDotenv();
|
|
const prisma = new PrismaClient();
|
|
|
|
interface TokenBody {
|
|
accessToken: string;
|
|
}
|
|
|
|
interface JsonResult<T> {
|
|
status: number;
|
|
body: T;
|
|
}
|
|
|
|
interface FixtureUser {
|
|
id: string;
|
|
account: string;
|
|
}
|
|
|
|
let phoneCounter = 0;
|
|
let app: CreatedApiApp['app'];
|
|
let superToken = '';
|
|
let scopedToken = '';
|
|
let approverToken = '';
|
|
let scopedUserId = '';
|
|
let rootIndustryId = '';
|
|
let leafIndustryId = '';
|
|
let customerId = '';
|
|
let groupId = '';
|
|
const createdUserIds: string[] = [];
|
|
const createdRoleIds: string[] = [];
|
|
|
|
function nextPhone(): string {
|
|
phoneCounter += 1;
|
|
return `136${String(phoneCounter).padStart(8, '0')}`;
|
|
}
|
|
|
|
async function createRole(
|
|
suffix: string,
|
|
permissions: string[],
|
|
dataScope: { type: string; targetId?: string },
|
|
): Promise<string> {
|
|
const id = ulid();
|
|
await prisma.role.create({
|
|
data: {
|
|
id,
|
|
code: `${PREFIX}role_${suffix}`,
|
|
name: `${PREFIX}角色-${suffix}`,
|
|
description: 'A3-QA-01 集成测试角色',
|
|
status: 'ACTIVE',
|
|
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
|
dataScopes: {
|
|
create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }],
|
|
},
|
|
},
|
|
});
|
|
createdRoleIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function createUser(suffix: string, roleId: string): Promise<FixtureUser> {
|
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
|
const account = `${PREFIX}${suffix}`;
|
|
const id = ulid();
|
|
await prisma.user.create({
|
|
data: {
|
|
id,
|
|
name: `A3 集成测试${suffix}`,
|
|
account,
|
|
email: `${account}@lingxi.cn`,
|
|
phone: nextPhone(),
|
|
passwordHash: PASSWORD_HASH,
|
|
departmentId: department.id,
|
|
mustChangePassword: false,
|
|
roles: { create: [{ roleId }] },
|
|
},
|
|
});
|
|
createdUserIds.push(id);
|
|
return { id, account };
|
|
}
|
|
|
|
async function login(account: string): Promise<string> {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: `${API}/auth/login`,
|
|
payload: { account, password: PASSWORD },
|
|
});
|
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
|
}
|
|
return response.json<TokenBody>().accessToken;
|
|
}
|
|
|
|
async function call<T>(
|
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
url: string,
|
|
token: string,
|
|
payload?: unknown,
|
|
): Promise<JsonResult<T>> {
|
|
const response = await app.inject({
|
|
method,
|
|
url: `${API}${url}`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
|
});
|
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
|
return { status: response.statusCode, body };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await prisma.customerField.deleteMany({
|
|
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
|
});
|
|
await prisma.customerGroupMember.deleteMany({
|
|
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
|
});
|
|
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
|
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
|
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
|
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
|
await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } });
|
|
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
|
await createUser('admin', superAdminRole.id);
|
|
const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], {
|
|
type: 'created_by_me',
|
|
});
|
|
const scopedUser = await createUser('scoped', scopedRole);
|
|
scopedUserId = scopedUser.id;
|
|
const approverRole = await createRole(
|
|
'approver',
|
|
[Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE],
|
|
{ type: 'all' },
|
|
);
|
|
await createUser('approver', approverRole);
|
|
|
|
const created = await createApp();
|
|
app = created.app;
|
|
await app.init();
|
|
const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } });
|
|
if (adminRow === null) throw new Error('fixture user missing');
|
|
superToken = await login(`${PREFIX}admin`);
|
|
scopedToken = await login(`${PREFIX}scoped`);
|
|
approverToken = await login(`${PREFIX}approver`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.customerField.deleteMany({
|
|
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
|
});
|
|
await prisma.customerGroupMember.deleteMany({
|
|
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
|
});
|
|
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
|
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
|
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
|
if (createdRoleIds.length > 0) {
|
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
|
}
|
|
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
|
if (app) await app.close();
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => {
|
|
it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => {
|
|
const root = await call<{ id: string; level: number; status: string }>(
|
|
'POST',
|
|
'/industries',
|
|
superToken,
|
|
{
|
|
name: 'ITQA3-电商',
|
|
sortOrder: 10,
|
|
},
|
|
);
|
|
expect(root.status).toBe(201);
|
|
expect(root.body.level).toBe(1);
|
|
rootIndustryId = root.body.id;
|
|
|
|
const child = await call<{ id: string; parentId: string; level: number }>(
|
|
'POST',
|
|
'/industries',
|
|
superToken,
|
|
{
|
|
name: 'ITQA3-服饰',
|
|
parentId: rootIndustryId,
|
|
sortOrder: 10,
|
|
},
|
|
);
|
|
expect(child.status).toBe(201);
|
|
expect(child.body.level).toBe(2);
|
|
leafIndustryId = child.body.id;
|
|
|
|
const duplicate = await call<{ code: number }>('POST', '/industries', superToken, {
|
|
name: 'ITQA3-电商',
|
|
});
|
|
expect(duplicate.status).toBe(409);
|
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
|
|
|
const blockedStop = await call<{ code: number }>(
|
|
'PATCH',
|
|
`/industries/${rootIndustryId}/status`,
|
|
superToken,
|
|
{ status: 'disabled' },
|
|
);
|
|
expect(blockedStop.status).toBe(409);
|
|
expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
|
|
|
const blockedDelete = await call<{ code: number }>(
|
|
'DELETE',
|
|
`/industries/${rootIndustryId}`,
|
|
superToken,
|
|
);
|
|
expect(blockedDelete.status).toBe(422);
|
|
expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
|
});
|
|
|
|
it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => {
|
|
const missingShort = await call<{ code: number }>('POST', '/customers', superToken, {
|
|
industryId: leafIndustryId,
|
|
name: 'ITQA3-花间集',
|
|
ownerUserId: scopedUserId,
|
|
});
|
|
expect(missingShort.status).toBe(400);
|
|
expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID);
|
|
|
|
const customer = await call<{ id: string; genFieldCount: number }>(
|
|
'POST',
|
|
'/customers',
|
|
superToken,
|
|
{
|
|
industryId: leafIndustryId,
|
|
name: 'ITQA3-花间集',
|
|
shortName: 'HJ3',
|
|
ownerUserId: scopedUserId,
|
|
baseFields: [{ key: '门店', value: '沪-001' }],
|
|
},
|
|
);
|
|
expect(customer.status).toBe(201);
|
|
customerId = customer.body.id;
|
|
expect(customer.body.genFieldCount).toBe(0);
|
|
|
|
const duplicate = await call<{ code: number }>('POST', '/customers', superToken, {
|
|
industryId: leafIndustryId,
|
|
name: 'ITQA3-花间集',
|
|
shortName: 'HJ3B',
|
|
ownerUserId: scopedUserId,
|
|
});
|
|
expect(duplicate.status).toBe(409);
|
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
|
|
|
const crossCustomer = await call<{ code: number }>(
|
|
'GET',
|
|
`/customers/${customerId}`,
|
|
scopedToken,
|
|
);
|
|
expect(crossCustomer.status).toBe(404);
|
|
expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND);
|
|
});
|
|
|
|
it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => {
|
|
const group = await call<{ id: string; memberCount: number }>(
|
|
'POST',
|
|
'/customer-groups',
|
|
superToken,
|
|
{
|
|
name: 'ITQA3-客户组',
|
|
description: 'A3 集成测试客户组',
|
|
customerIds: [customerId],
|
|
},
|
|
);
|
|
expect(group.status).toBe(201);
|
|
groupId = group.body.id;
|
|
expect(group.body.memberCount).toBe(1);
|
|
|
|
const again = await call<{ memberCount: number }>(
|
|
'POST',
|
|
`/customer-groups/${groupId}/members`,
|
|
superToken,
|
|
{
|
|
addCustomerIds: [customerId],
|
|
removeCustomerIds: [],
|
|
},
|
|
);
|
|
expect(again.status).toBe(200);
|
|
expect(again.body.memberCount).toBe(1);
|
|
|
|
const groupRole = await createRole('group', [Permission.CUSTOMER_READ], {
|
|
type: 'customer_groups',
|
|
targetId: groupId,
|
|
});
|
|
await createUser('group', groupRole);
|
|
const groupToken = await login(`${PREFIX}group`);
|
|
const visible = await call<{ items: { id: string }[] }>(
|
|
'GET',
|
|
`/customers?limit=50`,
|
|
groupToken,
|
|
);
|
|
expect(visible.status).toBe(200);
|
|
expect(visible.body.items.some((item) => item.id === customerId)).toBe(true);
|
|
});
|
|
|
|
it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => {
|
|
const apply = await call<{ id: string; status: string; entersGeneration: boolean }>(
|
|
'POST',
|
|
`/customers/${customerId}/fields`,
|
|
superToken,
|
|
{
|
|
name: 'ITQA3-活动编码',
|
|
code: 'itqa3_campaign',
|
|
type: 'text',
|
|
purpose: '活动素材命名与检索',
|
|
entersGeneration: true,
|
|
},
|
|
);
|
|
expect(apply.status).toBe(201);
|
|
expect(apply.body.status).toBe('pending');
|
|
expect(apply.body.entersGeneration).toBe(true);
|
|
|
|
const pending = await call<{ items: { id: string; status: string }[] }>(
|
|
'GET',
|
|
'/customer-fields?status=pending&limit=50',
|
|
approverToken,
|
|
);
|
|
expect(pending.status).toBe(200);
|
|
expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true);
|
|
|
|
const rejectWithoutReason = await call<{ code: number }>(
|
|
'POST',
|
|
`/customer-fields/${apply.body.id}/approve`,
|
|
approverToken,
|
|
{ decision: 'reject' },
|
|
);
|
|
expect(rejectWithoutReason.status).toBe(400);
|
|
expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID);
|
|
|
|
const approve = await call<{ status: string }>(
|
|
'POST',
|
|
`/customer-fields/${apply.body.id}/approve`,
|
|
approverToken,
|
|
{ decision: 'approve' },
|
|
);
|
|
expect(approve.status).toBe(200);
|
|
expect(approve.body.status).toBe('active');
|
|
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const extra = await call<{ code?: number }>(
|
|
'POST',
|
|
`/customers/${customerId}/fields`,
|
|
superToken,
|
|
{
|
|
name: `ITQA3-字段${index}`,
|
|
code: `itqa3_extra_${index}`,
|
|
type: 'text',
|
|
purpose: '补足进入生成字段上限',
|
|
entersGeneration: true,
|
|
},
|
|
);
|
|
expect(extra.status).toBe(201);
|
|
}
|
|
const overflow = await call<{ code: number }>(
|
|
'POST',
|
|
`/customers/${customerId}/fields`,
|
|
superToken,
|
|
{
|
|
name: 'ITQA3-超限字段',
|
|
code: 'itqa3_overflow',
|
|
type: 'text',
|
|
purpose: '第六个进入生成字段应被拒绝',
|
|
entersGeneration: true,
|
|
},
|
|
);
|
|
expect(overflow.status).toBe(400);
|
|
expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID);
|
|
});
|
|
|
|
it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => {
|
|
const forbidden = await call<{ code: number }>(
|
|
'POST',
|
|
`/customers/${customerId}/transfer`,
|
|
scopedToken,
|
|
{
|
|
ownerUserId: scopedUserId,
|
|
},
|
|
);
|
|
expect(forbidden.status).toBe(403);
|
|
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
|
|
|
const transferred = await call<{ ownerUserId: string }>(
|
|
'POST',
|
|
`/customers/${customerId}/transfer`,
|
|
superToken,
|
|
{ ownerUserId: createdUserIds[0] },
|
|
);
|
|
expect(transferred.status).toBe(200);
|
|
expect(transferred.body.ownerUserId).toBe(createdUserIds[0]);
|
|
});
|
|
it('客户状态机:暂停 / 结束只读', async () => {
|
|
const paused = await call<{ status: string }>(
|
|
'PATCH',
|
|
`/customers/${customerId}/status`,
|
|
superToken,
|
|
{
|
|
status: 'paused',
|
|
},
|
|
);
|
|
expect(paused.status).toBe(200);
|
|
expect(paused.body.status).toBe('paused');
|
|
const editPaused = await call<{ code: number }>(
|
|
'PATCH',
|
|
`/customers/${customerId}`,
|
|
superToken,
|
|
{
|
|
shortName: 'HJ3C',
|
|
},
|
|
);
|
|
expect(editPaused.status).toBe(409);
|
|
expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
|
|
|
const ended = await call<{ status: string }>(
|
|
'PATCH',
|
|
`/customers/${customerId}/status`,
|
|
superToken,
|
|
{
|
|
status: 'ended',
|
|
},
|
|
);
|
|
expect(ended.status).toBe(200);
|
|
expect(ended.body.status).toBe('ended');
|
|
const editEnded = await call<{ code: number }>(
|
|
'PATCH',
|
|
`/customers/${customerId}`,
|
|
superToken,
|
|
{
|
|
shortName: 'HJ3D',
|
|
},
|
|
);
|
|
expect(editEnded.status).toBe(409);
|
|
});
|
|
});
|