TTeamDesk 学习手册

tests/ticket-events.integration.test.ts

此代码来自构建时的仓库快照。修改源码后重新构建即可更新。

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}()`);
      }
    });
  },
);

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。