TTeamDesk 学习手册

tests/tickets.integration.test.ts

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

ts
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@/generated/prisma/client';
import { appRouter } from '@/server/api/router';
import { randomUUID, randomBytes } from 'node:crypto';
import { createAuth } from '@/server/auth';

vi.mock('server-only', () => ({}));

// 必须显式提供测试连接。只清理本轮测试自己的团队、账号和工单,不清空其他数据。
describe.skipIf(!process.env.TEST_DATABASE_URL)(
  '认证、角色与团队隔离(真实数据库)',
  () => {
    const db = new PrismaClient({
      adapter: new PrismaPg({
        connectionString: process.env.TEST_DATABASE_URL ?? '',
        max: 5,
      }),
    });
    const ids: string[] = [];
    const teamIds: string[] = [];
    const ticketIds: string[] = [];
    let a: string,
      b: string,
      agent: string,
      customer: string,
      otherCustomer: string;
    let teamA: string, teamB: string;
    const caller = (userId: string | null) =>
      appRouter.createCaller({ db, userId });
    const input = {
      title: '团队工单',
      description: '验证权限边界',
      priority: 'NORMAL' as const,
    };
    beforeAll(async () => {
      for (let n = 0; n < 5; n++) {
        const id = randomUUID();
        ids.push(id);
        await db.user.create({
          data: { id, name: `测试账号${n}`, email: `${id}@example.com` },
        });
      }
      [a, b, agent, customer, otherCustomer] = ids;
      const first = await caller(a).team.create({ name: '测试团队甲' });
      teamA = first.id;
      teamIds.push(teamA);
      const second = await caller(b).team.create({ name: '测试团队乙' });
      teamB = second.id;
      teamIds.push(teamB);
      for (const [userId, role] of [
        [agent, 'AGENT'],
        [customer, 'CUSTOMER'],
        [otherCustomer, 'CUSTOMER'],
      ] as const) {
        const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
        await caller(a).team.addMember({
          teamId: teamA,
          email: user.email,
          role,
        });
      }
    });
    afterAll(async () => {
      try {
        await db.ticket.deleteMany({
          where: {
            OR: [{ teamId: { in: teamIds } }, { id: { in: ticketIds } }],
          },
        });
        await db.team.deleteMany({ where: { id: { in: teamIds } } });
        await db.user.deleteMany({ where: { id: { in: ids } } });
      } finally {
        await db.$disconnect();
      }
    });

    it('未登录用户不能读取或创建业务数据', async () => {
      await expect(caller(null).team.list()).rejects.toMatchObject({
        code: 'UNAUTHORIZED',
      });
      await expect(
        caller(null).ticket.list({ teamId: teamA }),
      ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
      await expect(
        caller(null).ticket.create({ ...input, teamId: teamA }),
      ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
    });
    it('团队创建者成为管理员,其他用户看不到该团队', async () => {
      expect(await caller(a).team.list()).toEqual([
        { id: teamA, name: '测试团队甲', role: 'ADMIN' },
      ]);
      expect(await caller(b).team.list()).toEqual([
        { id: teamB, name: '测试团队乙', role: 'ADMIN' },
      ]);
    });
    it('跨团队伪造 teamId 或工单 ID 无法读取、创建或更新', async () => {
      const ticket = await caller(a).ticket.create({ ...input, teamId: teamA });
      await expect(
        caller(b).ticket.list({ teamId: teamA }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(b).ticket.create({ ...input, teamId: teamA }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(b).ticket.detail({ teamId: teamA, id: ticket.id }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(b).ticket.detail({ teamId: teamB, id: ticket.id }),
      ).rejects.toMatchObject({ code: 'NOT_FOUND' });
      await expect(
        caller(b).ticket.updateStatus({
          teamId: teamB,
          id: ticket.id,
          version: 0,
          status: 'CLOSED',
          reason: '测试关闭说明',
        }),
      ).rejects.toMatchObject({ code: 'NOT_FOUND' });
      expect((await caller(b).ticket.list({ teamId: teamB })).total).toBe(0);
    });
    it('客户只能读自己的工单,客服可处理团队工单', async () => {
      const mine = await caller(customer).ticket.create({
        ...input,
        teamId: teamA,
      });
      const other = await caller(otherCustomer).ticket.create({
        ...input,
        teamId: teamA,
      });
      const list = await caller(customer).ticket.list({ teamId: teamA });
      expect(list.total).toBe(1);
      expect(list.items[0].id).toBe(mine.id);
      expect(
        (await caller(customer).ticket.detail({ teamId: teamA, id: mine.id }))
          .id,
      ).toBe(mine.id);
      await expect(
        caller(customer).ticket.detail({ teamId: teamA, id: other.id }),
      ).rejects.toMatchObject({ code: 'NOT_FOUND' });
      await expect(
        caller(customer).ticket.updateStatus({
          teamId: teamA,
          id: mine.id,
          version: 0,
          status: 'CLOSED',
          reason: '测试关闭说明',
        }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      expect(
        (
          await caller(agent).ticket.updateStatus({
            teamId: teamA,
            id: mine.id,
            version: 0,
            status: 'IN_PROGRESS',
          })
        ).version,
      ).toBe(1);
    });
    it('只有管理员能添加、修改和移除成员,不能操作其他团队成员', async () => {
      await expect(
        caller(agent).team.changeRole({
          teamId: teamA,
          userId: customer,
          role: 'ADMIN',
        }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(customer).team.addMember({
          teamId: teamA,
          email: 'nobody@example.com',
          role: 'ADMIN',
        }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(customer).team.removeMember({ teamId: teamA, userId: agent }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(b).team.members({ teamId: teamA }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        caller(b).team.changeRole({ teamId: teamA, userId: b, role: 'ADMIN' }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });
    it('角色更新在下次请求生效,移除成员后旧请求身份无法继续访问', async () => {
      await caller(a).team.changeRole({
        teamId: teamA,
        userId: otherCustomer,
        role: 'AGENT',
      });
      const ticket = await caller(a).ticket.create({ ...input, teamId: teamA });
      await caller(otherCustomer).ticket.updateStatus({
        teamId: teamA,
        id: ticket.id,
        version: 0,
        status: 'IN_PROGRESS',
      });
      await caller(a).team.removeMember({
        teamId: teamA,
        userId: otherCustomer,
      });
      await expect(
        caller(otherCustomer).ticket.detail({ teamId: teamA, id: ticket.id }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });
    it('无法移除或降级最后一个管理员', async () => {
      await expect(
        caller(a).team.removeMember({ teamId: teamA, userId: a }),
      ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
      await expect(
        caller(a).team.changeRole({
          teamId: teamA,
          userId: a,
          role: 'CUSTOMER',
        }),
      ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
    });
    it('并发管理员降级不会产生无人管理的团队', async () => {
      await caller(a).team.changeRole({
        teamId: teamA,
        userId: agent,
        role: 'ADMIN',
      });
      const results = await Promise.allSettled([
        caller(a).team.changeRole({ teamId: teamA, userId: a, role: 'AGENT' }),
        caller(agent).team.changeRole({
          teamId: teamA,
          userId: agent,
          role: 'AGENT',
        }),
      ]);
      expect(
        results.filter((result) => result.status === 'fulfilled'),
      ).toHaveLength(1);
      expect(
        await db.membership.count({ where: { teamId: teamA, role: 'ADMIN' } }),
      ).toBe(1);
      // 后续测试仍使用 a 作为管理员,恢复测试自己的初始状态。
      await db.membership.updateMany({
        where: { teamId: teamA, userId: a },
        data: { role: 'ADMIN' },
      });
    });
    it('同一用户在不同团队的角色独立,不能借用管理员身份提权', async () => {
      const user = await db.user.findUniqueOrThrow({ where: { id: a } });
      await caller(b).team.addMember({
        teamId: teamB,
        email: user.email,
        role: 'CUSTOMER',
      });
      await expect(
        caller(a).team.changeRole({ teamId: teamB, userId: a, role: 'ADMIN' }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      const ticket = await caller(b).ticket.create({ ...input, teamId: teamB });
      await expect(
        caller(a).ticket.detail({ teamId: teamB, id: ticket.id }),
      ).rejects.toMatchObject({ code: 'NOT_FOUND' });
      await expect(
        caller(a).ticket.updateStatus({
          teamId: teamB,
          id: ticket.id,
          version: 0,
          status: 'CLOSED',
          reason: '测试关闭说明',
        }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });
    it('状态流转、非法输入与并发版本保护保持有效', async () => {
      await expect(
        caller(a).ticket.create({ ...input, title: ' ', teamId: teamA }),
      ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
      const ticket = await caller(a).ticket.create({ ...input, teamId: teamA });
      const results = await Promise.allSettled([
        caller(a).ticket.updateStatus({
          teamId: teamA,
          id: ticket.id,
          version: 0,
          status: 'IN_PROGRESS',
        }),
        caller(a).ticket.updateStatus({
          teamId: teamA,
          id: ticket.id,
          version: 0,
          status: 'CLOSED',
          reason: '测试关闭说明',
        }),
      ]);
      expect(
        results.filter((result) => result.status === 'fulfilled'),
      ).toHaveLength(1);
      const failure = results.find((result) => result.status === 'rejected');
      expect(failure?.status === 'rejected' && failure.reason.code).toBe(
        'CONFLICT',
      );
      const final = await caller(a).ticket.detail({
        teamId: teamA,
        id: ticket.id,
      });
      if (final.status !== 'CLOSED')
        await caller(a).ticket.updateStatus({
          teamId: teamA,
          id: ticket.id,
          version: final.version,
          status: 'CLOSED',
          reason: '测试关闭说明',
        });
      await expect(
        caller(a).ticket.updateStatus({
          teamId: teamA,
          id: ticket.id,
          version: 2,
          status: 'OPEN',
        }),
      ).rejects.toThrow();
    });
    it('Better Auth 注册、密码验证、Cookie 会话与退出失效', async () => {
      const auth = createAuth(db, {
        secret: randomBytes(48).toString('hex'),
        baseURL: 'http://localhost:3000',
      });
      const email = `${randomUUID()}@example.com`;
      const password = randomBytes(24).toString('hex');
      const request = (path: string, body: object, cookie = '') =>
        auth.handler(
          new Request(`http://localhost:3000/api/auth/${path}`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Origin: 'http://localhost:3000',
              Cookie: cookie,
            },
            body: JSON.stringify(body),
          }),
        );
      const registered = await request('sign-up/email', {
        name: '认证测试',
        email,
        password,
      });
      expect(registered.status).toBe(200);
      const user = await db.user.findUniqueOrThrow({ where: { email } });
      ids.push(user.id);
      const account = await db.account.findFirstOrThrow({
        where: { userId: user.id },
      });
      expect(account.password).not.toBe(password);
      const wrong = await request('sign-in/email', {
        email,
        password: 'wrong-password',
      });
      expect(wrong.status).toBe(401);
      const signed = await request('sign-in/email', { email, password });
      expect(signed.status).toBe(200);
      const cookies = signed.headers.getSetCookie();
      expect(cookies.some((cookie) => /httponly/i.test(cookie))).toBe(true);
      const cookie = cookies.map((item) => item.split(';')[0]).join('; ');
      const session = await auth.api.getSession({
        headers: new Headers({ cookie }),
      });
      expect(session?.user.id).toBe(user.id);
      const signedOut = await request('sign-out', {}, cookie);
      expect(signedOut.status).toBe(200);
      expect(
        await auth.api.getSession({ headers: new Headers({ cookie }) }),
      ).toBeNull();
    });
  },
);

搜索教程

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

阅读设置

外观
正文字号

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

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