tests/collaboration.integration.test.ts
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { randomUUID } from 'node:crypto';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@/generated/prisma/client';
import { appRouter } from '@/server/api/router';
describe.skipIf(!process.env.TEST_DATABASE_URL)(
'分配与回复权限(真实数据库)',
() => {
const db = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.TEST_DATABASE_URL ?? '',
max: 5,
}),
});
const users = Array.from({ length: 5 }, () => randomUUID());
const [admin, staff, customer, otherCustomer, outsider] = users;
const teams: string[] = [];
let teamId: string,
otherTeam: string,
staffId: string,
customerId: string,
foreignMemberId: string;
const api = (userId: string | null) =>
appRouter.createCaller({ db, userId });
const newTicket = (userId = customer) =>
api(userId).ticket.create({
teamId,
title: '第三阶段工单',
description: '测试协作',
priority: 'NORMAL',
});
beforeAll(async () => {
for (const id of users)
await db.user.create({
data: { id, email: `${id}@example.com`, name: id },
});
teamId = (await api(admin).team.create({ name: '协作测试甲' })).id;
teams.push(teamId);
otherTeam = (await api(outsider).team.create({ name: '协作测试乙' })).id;
teams.push(otherTeam);
for (const [id, role] of [
[staff, 'AGENT'],
[customer, 'CUSTOMER'],
[otherCustomer, 'CUSTOMER'],
] as const)
await db.membership.create({ data: { teamId, userId: id, role } });
staffId = (
await db.membership.findUniqueOrThrow({
where: { teamId_userId: { teamId, userId: staff } },
})
).id;
customerId = (
await db.membership.findUniqueOrThrow({
where: { teamId_userId: { teamId, userId: customer } },
})
).id;
foreignMemberId = (
await db.membership.findUniqueOrThrow({
where: { teamId_userId: { teamId: otherTeam, userId: outsider } },
})
).id;
});
afterAll(async () => {
try {
await db.ticket.deleteMany({ where: { teamId: { in: teams } } });
await db.team.deleteMany({ where: { id: { in: teams } } });
await db.user.deleteMany({ where: { id: { in: users } } });
} finally {
await db.$disconnect();
}
});
it('仅本团队客服/管理员可被分配,客户和跨团队请求被拒绝', async () => {
const ticket = await newTicket();
await expect(
api(customer).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: staffId,
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
for (const assigneeId of [customerId, foreignMemberId])
await expect(
api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId,
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
api(outsider).ticket.assign({
teamId: otherTeam,
id: ticket.id,
version: 0,
assigneeId: foreignMemberId,
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await api(staff).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: staffId,
});
expect(
(await api(admin).ticket.detail({ teamId, id: ticket.id })).assignee
?.userId,
).toBe(staff);
await expect(
api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: null,
}),
).rejects.toMatchObject({ code: 'CONFLICT' });
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 1,
assigneeId: null,
});
expect(
(await api(admin).ticket.detail({ teamId, id: ticket.id })).assignee,
).toBeNull();
});
it('筛选、总数与分页始终保留团队和客户归属条件', async () => {
const ticket = await api(customer).ticket.create({
teamId,
title: '唯一搜索词-ABC',
description: '筛选测试',
priority: 'URGENT',
});
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: staffId,
});
const list = await api(staff).ticket.list({
teamId,
q: '唯一搜索词-abc',
assignment: 'mine',
priority: 'URGENT',
page: 1,
pageSize: 1,
});
expect(list.total).toBe(1);
expect(list.items[0].id).toBe(ticket.id);
expect(
(await api(otherCustomer).ticket.list({ teamId, q: '唯一搜索词' }))
.total,
).toBe(0);
expect(
(
await api(outsider).ticket.list({
teamId: otherTeam,
q: '唯一搜索词',
})
).total,
).toBe(0);
expect(
(
await api(staff).ticket.list({
teamId,
q: '唯一搜索词',
assignment: 'unassigned',
})
).total,
).toBe(0);
});
it('客户只可回复自己的工单,团队之间不能读取或创建回复', async () => {
const ticket = await newTicket();
await expect(
api(otherCustomer).ticket.reply({
teamId,
id: ticket.id,
content: '越权',
clientRequestId: randomUUID(),
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(
api(outsider).ticket.replies({ teamId: otherTeam, id: ticket.id }),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(
api(outsider).ticket.reply({
teamId,
id: ticket.id,
content: '越权',
clientRequestId: randomUUID(),
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
api(null).ticket.reply({
teamId,
id: ticket.id,
content: '未登录',
clientRequestId: randomUUID(),
}),
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
const reply = await api(customer).ticket.reply({
teamId,
id: ticket.id,
content: ' 补充问题 ',
clientRequestId: randomUUID(),
});
expect(reply.content).toBe('补充问题');
expect(reply.authorId).toBe(customer);
expect(reply.authorRole).toBe('CUSTOMER');
await api(staff).ticket.reply({
teamId,
id: ticket.id,
content: '正在处理',
clientRequestId: randomUUID(),
});
const list = await api(customer).ticket.replies({
teamId,
id: ticket.id,
pageSize: 1,
});
expect(list.total).toBe(2);
expect(list.items).toHaveLength(1);
expect(list.items[0].authorId).toBe(staff);
expect(
(
await api(customer).ticket.replies({
teamId,
id: ticket.id,
page: 2,
pageSize: 1,
})
).items[0].id,
).toBe(reply.id);
});
it('同一请求重试或并发发送只落库一次,修改内容不能复用旧请求编号', async () => {
const ticket = await newTicket();
const clientRequestId = randomUUID();
const input = {
teamId,
id: ticket.id,
content: '网络重试的同一条回复',
clientRequestId,
};
const [one, two] = await Promise.all([
api(customer).ticket.reply(input),
api(customer).ticket.reply(input),
]);
expect(one.id).toBe(two.id);
expect(
(await api(staff).ticket.replies({ teamId, id: ticket.id })).total,
).toBe(1);
await expect(
api(customer).ticket.reply({ ...input, content: '不同内容' }),
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('拒绝空白、超长回复,关闭工单后禁止新回复和分配', async () => {
const ticket = await newTicket();
for (const content of [' ', '字'.repeat(10001)])
await expect(
api(customer).ticket.reply({
teamId,
id: ticket.id,
content,
clientRequestId: randomUUID(),
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await api(admin).ticket.updateStatus({
teamId,
id: ticket.id,
version: 0,
status: 'CLOSED',
reason: '测试关闭说明',
});
await expect(
api(customer).ticket.reply({
teamId,
id: ticket.id,
content: '已关闭',
clientRequestId: randomUUID(),
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 1,
assigneeId: staffId,
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('降级与移除成员会自动解除其分配,且旧版本失效', async () => {
const ticket = await newTicket();
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: staffId,
});
await api(admin).team.changeRole({
teamId,
userId: staff,
role: 'CUSTOMER',
});
const unassigned = await api(admin).ticket.detail({
teamId,
id: ticket.id,
});
expect(unassigned.assignee).toBeNull();
expect(unassigned.version).toBe(2);
await api(admin).team.changeRole({
teamId,
userId: staff,
role: 'AGENT',
});
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 2,
assigneeId: staffId,
});
await api(admin).team.removeMember({ teamId, userId: staff });
expect(
(await api(admin).ticket.detail({ teamId, id: ticket.id })).assignee,
).toBeNull();
await expect(
api(staff).ticket.replies({ teamId, id: ticket.id }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
},
);