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 { 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 { 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 { 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 { 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().accessToken; } async function call( method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, token: string, payload?: unknown, ): Promise> { const response = await app.inject({ method, url: `${API}${url}`, headers: { authorization: `Bearer ${token}` }, ...(payload === undefined ? {} : { payload: payload as Record }), }); const body = response.body.length === 0 ? (null as T) : response.json(); 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); }); });