Files
lingxi/apps/api/src/modules/client/client.int.spec.ts
T
Stev_Wang 140dc386c1 feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景: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;真实浏览器六页验证通过。
2026-11-03 21:45:38 +08:00

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);
});
});