TTeamDesk 学习手册

tests/collaboration.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';

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

搜索教程

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

阅读设置

外观
正文字号

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

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