tests/ticket-events.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';
import type { TicketStatus } from '@/shared/ticket';
const statuses: TicketStatus[] = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
// 测试从业务约定列出合法路径,而不是读取实现本身来产生预期结果。
const permitted = new Set([
'OPEN>IN_PROGRESS',
'OPEN>CLOSED',
'IN_PROGRESS>OPEN',
'IN_PROGRESS>RESOLVED',
'IN_PROGRESS>CLOSED',
'RESOLVED>OPEN',
'RESOLVED>CLOSED',
]);
describe.skipIf(!process.env.TEST_DATABASE_URL)(
'操作记录与状态权限(真实数据库)',
() => {
const db = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.TEST_DATABASE_URL ?? '',
max: 5,
}),
});
const [admin, agent, customer, otherCustomer, outsider] = Array.from(
{ length: 5 },
() => randomUUID(),
);
const ids = [admin, agent, customer, otherCustomer, outsider];
const teams: string[] = [];
let teamId: string, foreignTeam: string, agentMembership: string;
const api = (userId: string | null) =>
appRouter.createCaller({ db, userId });
const create = () =>
api(customer).ticket.create({
teamId,
title: '记录与规则测试',
description: '验证事务及权限',
priority: 'NORMAL',
});
const history = (id: string) =>
api(admin).ticket.events({ teamId, id, pageSize: 50 });
async function prepare(status: TicketStatus) {
let ticket = await create();
if (status === 'IN_PROGRESS' || status === 'RESOLVED')
ticket = await api(admin).ticket.updateStatus({
teamId,
id: ticket.id,
version: ticket.version,
status: 'IN_PROGRESS',
});
if (status === 'RESOLVED' || status === 'CLOSED')
ticket = await api(admin).ticket.updateStatus({
teamId,
id: ticket.id,
version: ticket.version,
status,
reason: '准备测试状态',
});
return ticket;
}
beforeAll(async () => {
for (const id of ids)
await db.user.create({
data: {
id,
email: `${id}@example.com`,
name: id === admin ? '操作管理员' : id,
},
});
teamId = (await api(admin).team.create({ name: '记录测试甲' })).id;
teams.push(teamId);
foreignTeam = (await api(outsider).team.create({ name: '记录测试乙' }))
.id;
teams.push(foreignTeam);
for (const [userId, role] of [
[agent, 'AGENT'],
[customer, 'CUSTOMER'],
[otherCustomer, 'CUSTOMER'],
] as const)
await db.membership.create({ data: { teamId, userId, role } });
agentMembership = (
await db.membership.findUniqueOrThrow({
where: { teamId_userId: { teamId, userId: agent } },
})
).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: ids } } });
} finally {
await db.$disconnect();
}
});
for (const from of statuses)
for (const to of statuses) {
it(`状态路径 ${from} → ${to}`, async () => {
const ticket = await prepare(from);
const before = (await history(ticket.id)).total;
const promise = api(agent).ticket.updateStatus({
teamId,
id: ticket.id,
version: ticket.version,
status: to,
reason: '状态路径测试说明',
});
if (permitted.has(`${from}>${to}`)) {
const updated = await promise;
expect(updated.version).toBe(ticket.version + 1);
const events = await history(ticket.id);
expect(events.total).toBe(before + 1);
expect(events.items[0]).toMatchObject({
kind: 'STATUS_CHANGED',
fromStatus: from,
toStatus: to,
actorRole: 'AGENT',
reason: '状态路径测试说明',
});
} else {
await expect(promise).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect((await history(ticket.id)).total).toBe(before);
expect(
(await api(admin).ticket.detail({ teamId, id: ticket.id }))
.version,
).toBe(ticket.version);
}
});
}
it('解决、关闭与重新打开需要说明,空白或超长说明均拒绝', async () => {
for (const [from, to] of [
['IN_PROGRESS', 'RESOLVED'],
['OPEN', 'CLOSED'],
['RESOLVED', 'OPEN'],
] as const) {
const ticket = await prepare(from);
for (const reason of ['', ' ', '字'.repeat(1001)])
await expect(
api(admin).ticket.updateStatus({
teamId,
id: ticket.id,
version: ticket.version,
status: to,
reason,
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
}
});
it('客户不能变更任何状态,且不能伪造团队或借其他团队角色提权', async () => {
const ticket = await create();
for (const status of statuses)
await expect(
api(customer).ticket.updateStatus({
teamId,
id: ticket.id,
version: 0,
status,
reason: '伪造理由',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
api(outsider).ticket.updateStatus({
teamId,
id: ticket.id,
version: 0,
status: 'CLOSED',
reason: '越权',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
api(outsider).ticket.updateStatus({
teamId: foreignTeam,
id: ticket.id,
version: 0,
status: 'CLOSED',
reason: '越权',
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
expect((await history(ticket.id)).total).toBe(1);
});
it('记录遵循工单可见性,分页总数也不泄露其他客户或团队信息', async () => {
const ticket = await create();
expect(
(await api(customer).ticket.events({ teamId, id: ticket.id })).total,
).toBe(1);
for (const user of [otherCustomer, outsider])
await expect(
api(user).ticket.events({ teamId, id: ticket.id }),
).rejects.toMatchObject({
code: user === outsider ? 'FORBIDDEN' : 'NOT_FOUND',
});
await expect(
api(outsider).ticket.events({ teamId: foreignTeam, id: ticket.id }),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(
api(null).ticket.events({ teamId, id: ticket.id }),
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
api(admin).ticket.events({ teamId, id: ticket.id, pageSize: 1000 }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('有效修改顺序记录;重复分配与重试回复不制造假历史,操作者不可伪造', async () => {
const ticket = await create();
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: agentMembership,
});
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 1,
assigneeId: agentMembership,
});
expect((await history(ticket.id)).total).toBe(2);
const reply = {
teamId,
id: ticket.id,
content: '跟进说明',
clientRequestId: randomUUID(),
};
await api(customer).ticket.reply(reply);
await api(customer).ticket.reply(reply);
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 2,
assigneeId: null,
});
const events = await history(ticket.id);
expect(events.items.map((event) => event.kind)).toEqual([
'UNASSIGNED',
'REPLIED',
'ASSIGNED',
'CREATED',
]);
expect(events.items.map((event) => event.version)).toEqual([3, 2, 1, 0]);
expect(events.items[0]).toMatchObject({
actorName: '操作管理员',
actorRole: 'ADMIN',
fromAssigneeName: agent,
});
const first = await api(customer).ticket.events({
teamId,
id: ticket.id,
pageSize: 2,
});
const second = await api(customer).ticket.events({
teamId,
id: ticket.id,
page: 2,
pageSize: 2,
});
expect(first.total).toBe(4);
expect(second.total).toBe(4);
expect(
new Set([...first.items, ...second.items].map((event) => event.id))
.size,
).toBe(4);
const spoofed = {
teamId,
id: ticket.id,
version: 3,
status: 'IN_PROGRESS' as const,
actorName: '伪造管理员',
actorRole: 'CUSTOMER',
};
await api(admin).ticket.updateStatus(spoofed);
expect((await history(ticket.id)).items[0].actorName).toBe('操作管理员');
});
it('移除或降级负责人自动解除分配并记录,成员失效后不可读记录', async () => {
const ticket = await create();
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: agentMembership,
});
await api(admin).team.changeRole({
teamId,
userId: agent,
role: 'CUSTOMER',
});
expect((await history(ticket.id)).items[0]).toMatchObject({
kind: 'ASSIGNEE_RELEASED',
fromAssigneeName: agent,
actorName: '操作管理员',
});
await expect(
api(agent).ticket.events({ teamId, id: ticket.id }),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await api(admin).team.changeRole({
teamId,
userId: agent,
role: 'AGENT',
});
await api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 2,
assigneeId: agentMembership,
});
await api(admin).team.removeMember({ teamId, userId: agent });
expect((await history(ticket.id)).items[0].reason).toContain('移出');
await expect(
api(agent).ticket.events({ teamId, id: ticket.id }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
// 恢复本轮测试成员,后续用新的成员关系编号。
const member = await db.membership.create({
data: { teamId, userId: agent, role: 'AGENT' },
});
agentMembership = member.id;
});
it('并发修改只有一个成功,失败请求不留下记录', async () => {
const ticket = await create();
const results = await Promise.allSettled([
api(admin).ticket.updateStatus({
teamId,
id: ticket.id,
version: 0,
status: 'IN_PROGRESS',
}),
api(agent).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: agentMembership,
}),
]);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect((await history(ticket.id)).total).toBe(2);
});
it('记录数据库写入失败时,创建/状态/分配/回复/成员变更全部回滚', async () => {
const ticket = await create();
const assigned = await create();
await api(admin).ticket.assign({
teamId,
id: assigned.id,
version: 0,
assigneeId: agentMembership,
});
const before = await db.ticket.count({ where: { teamId } });
// 测试专用触发器模拟真实数据库拒绝写记录,仅作用于本测试团队。
// 标识符由随机 UUID 生成,无用户输入,不修改生产表结构或清空数据。
const suffix = randomUUID().replaceAll('-', '');
const name = `fail_event_${suffix}`;
await db.$executeRawUnsafe(
`CREATE FUNCTION ${name}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW."teamId" = '${teamId}'::uuid THEN RAISE EXCEPTION '模拟操作记录存储失败'; END IF; RETURN NEW; END $$`,
);
await db.$executeRawUnsafe(
`CREATE TRIGGER ${name} BEFORE INSERT ON "TicketEvent" FOR EACH ROW EXECUTE FUNCTION ${name}()`,
);
try {
await expect(create()).rejects.toThrow();
await expect(
api(admin).ticket.updateStatus({
teamId,
id: ticket.id,
version: 0,
status: 'IN_PROGRESS',
}),
).rejects.toThrow();
await expect(
api(admin).ticket.assign({
teamId,
id: ticket.id,
version: 0,
assigneeId: agentMembership,
}),
).rejects.toThrow();
await expect(
api(customer).ticket.reply({
teamId,
id: ticket.id,
content: '应该回滚',
clientRequestId: randomUUID(),
}),
).rejects.toThrow();
await expect(
api(admin).team.changeRole({
teamId,
userId: agent,
role: 'CUSTOMER',
}),
).rejects.toThrow();
expect(await db.ticket.count({ where: { teamId } })).toBe(before);
expect(
await api(admin).ticket.detail({ teamId, id: ticket.id }),
).toMatchObject({
status: 'OPEN',
version: 0,
assigneeMembershipId: null,
});
expect(
(await api(admin).ticket.replies({ teamId, id: ticket.id })).total,
).toBe(0);
expect((await history(ticket.id)).total).toBe(1);
expect(
(await api(admin).ticket.detail({ teamId, id: assigned.id }))
.assigneeMembershipId,
).toBe(agentMembership);
expect(
(
await db.membership.findUniqueOrThrow({
where: { id: agentMembership },
})
).role,
).toBe('AGENT');
} finally {
await db.$executeRawUnsafe(`DROP TRIGGER ${name} ON "TicketEvent"`);
await db.$executeRawUnsafe(`DROP FUNCTION ${name}()`);
}
});
},
);