TTeamDesk 学习手册

tests/tasks.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 { processTask } from '@/server/jobs/processor';
import { downloadExport } from '@/server/services/tasks';
import { dispatchTasks } from '@/server/jobs/dispatcher';
import { createTaskQueue } from '@/server/jobs/queue';

// 通过数据库调用边界控制交错,不靠 sleep 猜测哪个 Worker 先执行。
function gate() {
  let open!: () => void;
  const promise = new Promise<void>((resolve) => {
    open = resolve;
  });
  return { promise, open };
}
async function reached(promise: Promise<void>) {
  let timer!: ReturnType<typeof setTimeout>;
  try {
    await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error('并发同步点未到达')), 4000);
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }
}

// 只清理本套测试创建的团队和账号,不重置已有业务数据。
describe.skipIf(!process.env.TEST_DATABASE_URL)(
  '后台任务权限与事务(真实数据库)',
  () => {
    const db = new PrismaClient({
      adapter: new PrismaPg({
        connectionString: process.env.TEST_DATABASE_URL ?? '',
        max: 5,
      }),
    });
    const admin = randomUUID(),
      customer = randomUUID(),
      other = randomUUID(),
      outsider = randomUUID();
    let teamId: string, foreignTeam: string;
    const api = (userId: string | null) =>
      appRouter.createCaller({ db, userId });
    const execute = async (id: string, attemptsMade = 0) => {
      const task = await db.backgroundTask.findUniqueOrThrow({ where: { id } });
      const values: number[] = [];
      await processTask(db, {
        data: { taskId: id, run: task.run },
        attemptsMade,
        updateProgress: async (value) => {
          values.push(value as number);
        },
      });
      return values;
    };
    const exportTask = async (ownerId = admin) =>
      db.backgroundTask.create({
        data: {
          teamId,
          ownerId,
          kind: 'EXPORT',
          filters: {},
          scopeCreatorId: ownerId === customer ? customer : null,
        },
      });
    const ticket = () =>
      api(customer).ticket.create({
        teamId,
        title: '=测试导出与提醒',
        description: '学习后台任务',
        priority: 'NORMAL',
      });
    beforeAll(async () => {
      for (const id of [admin, customer, other, outsider])
        await db.user.create({
          data: { id, email: `${id}@example.com`, name: id },
        });
      teamId = (await api(admin).team.create({ name: '任务测试团队' })).id;
      foreignTeam = (await api(outsider).team.create({ name: '任务隔离团队' }))
        .id;
      for (const userId of [customer, other])
        await db.membership.create({
          data: { teamId, userId, role: 'CUSTOMER' },
        });
      await ticket();
      await api(other).ticket.create({
        teamId,
        title: '其他客户的秘密',
        description: '不可导出给客户',
        priority: 'NORMAL',
      });
      await api(outsider).ticket.create({
        teamId: foreignTeam,
        title: '其他团队的秘密',
        description: '不可跨团队',
        priority: 'NORMAL',
      });
    });
    afterAll(async () => {
      await db.ticket.deleteMany({
        where: { teamId: { in: [teamId, foreignTeam].filter(Boolean) } },
      });
      await db.team.deleteMany({
        where: { id: { in: [teamId, foreignTeam].filter(Boolean) } },
      });
      await db.user.deleteMany({
        where: { id: { in: [admin, customer, other, outsider] } },
      });
      await db.$disconnect();
    });
    it('匿名与跨团队用户不能提交导出', async () => {
      await expect(
        api(null).task.createExport({ teamId, filters: {} }),
      ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
      await expect(
        api(outsider).task.createExport({ teamId, filters: {} }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });
    it('客户仅导出自己的匹配数据,进度最终 100%,CSV 保护公式', async () => {
      const task = await api(customer).task.createExport({
        teamId,
        filters: { q: '测试导出' },
      });
      expect(await execute(task.id)).toContain(100);
      const csv = await downloadExport(db, customer, task.id);
      expect(csv).toContain("'=测试导出与提醒");
      expect(csv).not.toContain('其他客户的秘密');
      expect(csv).not.toContain('其他团队的秘密');
      await expect(downloadExport(db, admin, task.id)).rejects.toMatchObject({
        code: 'NOT_FOUND',
      });
    });
    it('管理员导出也不能跨团队,筛选覆盖全部匹配结果', async () => {
      const task = await exportTask();
      await execute(task.id);
      const csv = await downloadExport(db, admin, task.id);
      expect(csv).toContain('其他客户的秘密');
      expect(csv).not.toContain('其他团队的秘密');
    });
    it('排队后被降权会终止执行,已生成文件也不能再下载', async () => {
      const pending = await exportTask();
      const finished = await exportTask();
      await execute(finished.id);
      const member = await db.membership.create({
        data: { teamId, userId: outsider, role: 'ADMIN' },
      });
      try {
        await api(outsider).team.changeRole({
          teamId,
          userId: admin,
          role: 'CUSTOMER',
        });
        await expect(execute(pending.id)).rejects.toThrow('成员权限已变化');
        await expect(
          downloadExport(db, admin, finished.id),
        ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      } finally {
        await db.membership.update({
          where: { teamId_userId: { teamId, userId: admin } },
          data: { role: 'ADMIN' },
        });
        await db.membership.delete({ where: { id: member.id } });
      }
    });
    it('已移出团队的任务所有者不能继续执行或下载', async () => {
      const task = await exportTask(other);
      await db.membership.delete({
        where: { teamId_userId: { teamId, userId: other } },
      });
      try {
        await expect(execute(task.id)).rejects.toThrow('成员权限已变化');
      } finally {
        await db.membership.create({
          data: { teamId, userId: other, role: 'CUSTOMER' },
        });
      }
    });
    it('过期文件不能下载', async () => {
      const task = await exportTask();
      await execute(task.id);
      await db.backgroundTask.update({
        where: { id: task.id },
        data: { expiresAt: new Date(0) },
      });
      await expect(downloadExport(db, admin, task.id)).rejects.toMatchObject({
        code: 'NOT_FOUND',
      });
    });
    it('新工单和提醒任务原子创建,客户不能设置提醒', async () => {
      const item = await ticket();
      expect(
        await db.backgroundTask.count({ where: { ticketId: item.id } }),
      ).toBe(1);
      await expect(
        api(customer).task.schedule({
          teamId,
          id: item.id,
          version: item.version,
          minutes: 1,
        }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
      await expect(
        api(outsider).task.schedule({
          teamId,
          id: item.id,
          version: item.version,
          minutes: 1,
        }),
      ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });
    it('超时通知和系统记录幂等,未分配时提醒管理员', async () => {
      const item = await ticket();
      await db.ticket.update({
        where: { id: item.id },
        data: { dueAt: new Date(0) },
      });
      const task = await db.backgroundTask.findFirstOrThrow({
        where: { ticketId: item.id },
      });
      await execute(task.id);
      await execute(task.id);
      expect(
        await db.reminderNotification.count({
          where: { ticketId: item.id, userId: admin },
        }),
      ).toBe(1);
      expect(
        await db.ticketEvent.count({
          where: { ticketId: item.id, kind: 'TIMEOUT_REMINDED' },
        }),
      ).toBe(1);
      const notices = await api(admin).task.notifications({ teamId });
      const notice = notices.find((n) => n.ticket.id === item.id)!;
      await api(admin).task.read({ teamId, id: notice.id });
      expect(
        (await api(admin).task.notifications({ teamId })).find(
          (n) => n.id === notice.id,
        )?.readAt,
      ).toBeTruthy();
      expect(await api(customer).task.notifications({ teamId })).toEqual([]);
    });
    it.each(['新执行者认领', '恢复扫描清除租约'])(
      '提醒并发接手:%s 时旧事务撤销全部副作用',
      async (takeover) => {
        const item = await ticket();
        await db.ticket.update({
          where: { id: item.id },
          data: { dueAt: new Date(0) },
        });
        const task = await db.backgroundTask.findFirstOrThrow({
          where: { ticketId: item.id },
        });
        const readA = gate(),
          resumeA = gate(),
          claimedB = gate(),
          resumeB = gate();
        const oldProgress: number[] = [];
        const newProgress: number[] = [];
        const job = (values: number[]) => ({
          data: { taskId: task.id, run: task.run },
          attemptsMade: 0,
          updateProgress: async (value: string | number | object) => {
            values.push(value as number);
          },
        });
        const oldDb = db.$extends({
          query: {
            backgroundTask: {
              async findFirst({ args, query }) {
                const result = await query(args);
                // A 已在真实事务内持有团队锁,并读到了有效 E1。
                readA.open();
                await resumeA.promise;
                return result;
              },
            },
          },
        });
        const newDb = db.$extends({
          query: {
            backgroundTask: {
              async updateMany({ args, query }) {
                const result = await query(args);
                if (args.data.status === 'RUNNING') {
                  // B 的 E2 已真实提交,先不让 B 进入需要团队锁的事务。
                  claimedB.open();
                  await resumeB.promise;
                }
                return result;
              },
            },
          },
        });
        const assertEffects = async (count: number) => {
          const notices = await db.reminderNotification.findMany({
            where: { ticketId: item.id },
          });
          const events = await db.ticketEvent.findMany({
            where: { ticketId: item.id, kind: 'TIMEOUT_REMINDED' },
          });
          const current = await db.ticket.findUniqueOrThrow({
            where: { id: item.id },
          });
          expect(notices).toHaveLength(count);
          expect(events).toHaveLength(count);
          expect(current.version).toBe(item.version + count);
          expect(current.reminderRevision).toBe(item.reminderRevision);
          if (count) {
            expect(notices[0]).toMatchObject({
              teamId,
              userId: admin,
              reminderRevision: item.reminderRevision,
            });
            expect(events[0]).toMatchObject({
              teamId,
              version: current.version,
              actorName: '系统',
            });
          }
        };
        const running: Promise<unknown>[] = [];
        try {
          const a = processTask(
            oldDb as unknown as PrismaClient,
            job(oldProgress),
          );
          running.push(a);
          void a.catch(() => {});
          await reached(readA.promise);
          const firstLease = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          let replacement;
          if (takeover === '新执行者认领') {
            const b = processTask(
              newDb as unknown as PrismaClient,
              job(newProgress),
            );
            running.push(b);
            void b.catch(() => {});
            await reached(claimedB.promise);
            replacement = await db.backgroundTask.findUniqueOrThrow({
              where: { id: task.id },
            });
            expect(replacement.executionId).not.toBe(firstLease.executionId);
            expect(replacement.executionId).not.toBeNull();
          } else {
            // 模拟恢复扫描的数据库状态迁移,限定本任务,不触碰其他夹具。
            await db.backgroundTask.updateMany({
              where: {
                id: task.id,
                executionId: firstLease.executionId,
                status: 'RUNNING',
              },
              data: {
                status: 'WAITING',
                executionId: null,
                dispatchedAt: null,
              },
            });
            replacement = await db.backgroundTask.findUniqueOrThrow({
              where: { id: task.id },
            });
          }
          resumeA.open();
          await a;
          // 检查交接中间态:旧事务必须整笔撤销,且不能把新租约标成失败。
          await assertEffects(0);
          expect(oldProgress).toEqual([]);
          expect(
            await db.backgroundTask.findUnique({ where: { id: task.id } }),
          ).toEqual(replacement);
          resumeB.open();
          if (takeover === '新执行者认领') await running[1];
          else await execute(task.id);
          await assertEffects(1);
          expect(
            await db.backgroundTask.findUnique({ where: { id: task.id } }),
          ).toMatchObject({
            status: 'COMPLETED',
            progress: 100,
            executionId: null,
            lastError: null,
            run: task.run,
            resultSummary: '已生成 1 条站内提醒',
          });
          if (takeover === '新执行者认领') expect(newProgress).toEqual([100]);
          await execute(task.id);
          await assertEffects(1);
        } finally {
          // 断言失败也放行所有执行者,避免留下占锁事务污染后续测试。
          resumeA.open();
          resumeB.open();
          await Promise.allSettled(running);
        }
      },
    );
    it('提醒写入事件后故障整笔回滚,重试只提交一份业务结果', async () => {
      const item = await ticket();
      await db.ticket.update({
        where: { id: item.id },
        data: { dueAt: new Date(0) },
      });
      const task = await db.backgroundTask.findFirstOrThrow({
        where: { ticketId: item.id },
      });
      const failingDb = db.$extends({
        query: {
          ticketEvent: {
            async create({ args, query }) {
              await query(args);
              // 通知、版本、事件均已写入事务,随后抛错检验真实数据库回滚。
              throw new Error('测试注入:事件写入后失败');
            },
          },
        },
      });
      await expect(
        processTask(failingDb as unknown as PrismaClient, {
          data: { taskId: task.id, run: task.run },
          attemptsMade: 0,
          updateProgress: async () => {},
        }),
      ).rejects.toThrow('后台任务执行失败');
      expect(
        await db.reminderNotification.count({ where: { ticketId: item.id } }),
      ).toBe(0);
      expect(
        await db.ticketEvent.count({
          where: { ticketId: item.id, kind: 'TIMEOUT_REMINDED' },
        }),
      ).toBe(0);
      expect(
        await db.ticket.findUnique({ where: { id: item.id } }),
      ).toMatchObject({ version: item.version });
      expect(
        await db.backgroundTask.findUnique({ where: { id: task.id } }),
      ).toMatchObject({
        status: 'WAITING',
        progress: 0,
        executionId: null,
        attempts: 1,
      });
      await execute(task.id, 1);
      expect(
        await db.reminderNotification.count({ where: { ticketId: item.id } }),
      ).toBe(1);
      const events = await db.ticketEvent.findMany({
        where: { ticketId: item.id, kind: 'TIMEOUT_REMINDED' },
      });
      expect(events).toHaveLength(1);
      expect(events[0].version).toBe(item.version + 1);
      expect(
        await db.ticket.findUnique({ where: { id: item.id } }),
      ).toMatchObject({ version: item.version + 1 });
      expect(
        await db.backgroundTask.findUnique({ where: { id: task.id } }),
      ).toMatchObject({
        status: 'COMPLETED',
        progress: 100,
        executionId: null,
        lastError: null,
        attempts: 2,
      });
    });
    it('已解决的工单不发送旧提醒;重新打开建立新提醒', async () => {
      let item = await ticket();
      const old = await db.backgroundTask.findFirstOrThrow({
        where: { ticketId: item.id },
      });
      item = await api(admin).ticket.updateStatus({
        teamId,
        id: item.id,
        version: item.version,
        status: 'IN_PROGRESS',
      });
      item = await api(admin).ticket.updateStatus({
        teamId,
        id: item.id,
        version: item.version,
        status: 'RESOLVED',
        reason: '完成',
      });
      await execute(old.id);
      expect(
        await db.reminderNotification.count({ where: { ticketId: item.id } }),
      ).toBe(0);
      item = await api(admin).ticket.updateStatus({
        teamId,
        id: item.id,
        version: item.version,
        status: 'OPEN',
        reason: '重新打开',
      });
      expect(
        await db.backgroundTask.count({ where: { ticketId: item.id } }),
      ).toBe(2);
    });
    it('取消和重新设置提醒后旧任务失效,版本冲突拒绝写入', async () => {
      const item = await ticket();
      const old = await db.backgroundTask.findFirstOrThrow({
        where: { ticketId: item.id },
      });
      await api(admin).task.schedule({
        teamId,
        id: item.id,
        version: item.version,
        minutes: null,
      });
      await execute(old.id);
      expect(
        await db.reminderNotification.count({ where: { ticketId: item.id } }),
      ).toBe(0);
      await expect(
        api(admin).task.schedule({
          teamId,
          id: item.id,
          version: item.version,
          minutes: 1,
        }),
      ).rejects.toMatchObject({ code: 'CONFLICT' });
    });
    it('手动重试只允许失败任务,生成新的执行轮次并防止旧任务覆盖', async () => {
      const task = await exportTask();
      await expect(
        api(admin).task.retry({ teamId, id: task.id }),
      ).rejects.toMatchObject({ code: 'CONFLICT' });
      await db.backgroundTask.update({
        where: { id: task.id },
        data: { status: 'FAILED' },
      });
      await expect(
        api(customer).task.retry({ teamId, id: task.id }),
      ).rejects.toMatchObject({ code: 'NOT_FOUND' });
      await api(admin).task.retry({ teamId, id: task.id });
      await processTask(db, {
        data: { taskId: task.id, run: 0 },
        attemptsMade: 0,
        updateProgress: async () => {},
      });
      expect(
        (await db.backgroundTask.findUniqueOrThrow({ where: { id: task.id } }))
          .status,
      ).toBe('WAITING');
      await execute(task.id);
      expect(
        (await db.backgroundTask.findUniqueOrThrow({ where: { id: task.id } }))
          .run,
      ).toBe(1);
    });
    it('Redis 投递失败时任务仍留在数据库待恢复', async () => {
      const task = await exportTask();
      const broken = {
        getJob: async () => null,
        add: async () => {
          throw new Error('Redis 离线');
        },
      } as unknown as ReturnType<typeof createTaskQueue>;
      await expect(dispatchTasks(db, broken)).rejects.toThrow('Redis 离线');
      expect(
        (await db.backgroundTask.findUniqueOrThrow({ where: { id: task.id } }))
          .status,
      ).toBe('WAITING');
    });
    it('中途进度写入故障保留任务,第三次失败后可手动重试恢复', async () => {
      const task = await exportTask();
      for (let attempt = 0; attempt < 3; attempt++) {
        await expect(
          processTask(db, {
            data: { taskId: task.id, run: 0 },
            attemptsMade: attempt,
            updateProgress: async () => {
              throw new Error('模拟队列进度写入故障');
            },
          }),
        ).rejects.toThrow('后台任务执行失败');
        const current = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(current.status).toBe(attempt === 2 ? 'FAILED' : 'WAITING');
        expect(current.attempts).toBe(attempt + 1);
        expect(current.resultCsv).toBeNull();
      }
      await api(admin).task.retry({ teamId, id: task.id });
      await execute(task.id);
      expect(
        (await db.backgroundTask.findUniqueOrThrow({ where: { id: task.id } }))
          .status,
      ).toBe('COMPLETED');
    });
    it('已分配工单仅提醒当前负责人', async () => {
      const member = await db.membership.create({
        data: { teamId, userId: outsider, role: 'AGENT' },
      });
      try {
        const item = await ticket();
        await db.ticket.update({
          where: { id: item.id },
          data: { dueAt: new Date(0), assigneeMembershipId: member.id },
        });
        const task = await db.backgroundTask.findFirstOrThrow({
          where: { ticketId: item.id },
        });
        await execute(task.id);
        expect(
          (
            await db.reminderNotification.findMany({
              where: { ticketId: item.id },
            })
          ).map((n) => n.userId),
        ).toEqual([outsider]);
      } finally {
        await db.ticket.updateMany({
          where: { teamId, assigneeMembershipId: member.id },
          data: { assigneeMembershipId: null },
        });
        await db.membership.delete({ where: { id: member.id } });
      }
    });
    it('导出分批读取不受列表分页限制,并报告中间进度', async () => {
      const title = `分页-${randomUUID()}`;
      await db.ticket.createMany({
        data: Array.from({ length: 205 }, () => ({
          teamId,
          creatorId: admin,
          title,
          description: '分页导出',
        })),
      });
      try {
        const task = await db.backgroundTask.create({
          data: {
            teamId,
            ownerId: admin,
            kind: 'EXPORT',
            filters: { q: title },
          },
        });
        const progress = await execute(task.id);
        expect(progress.some((p) => p > 0 && p < 95)).toBe(true);
        const csv = await downloadExport(db, admin, task.id);
        expect(csv.split(title).length - 1).toBe(205);
      } finally {
        await db.ticket.deleteMany({ where: { teamId, title } });
      }
    });
    it('超过导出条数限制时不生成不完整文件', async () => {
      const title = `上限-${randomUUID()}`;
      await db.ticket.createMany({
        data: Array.from({ length: 5001 }, () => ({
          teamId,
          creatorId: admin,
          title,
          description: '导出上限',
        })),
      });
      try {
        const task = await db.backgroundTask.create({
          data: {
            teamId,
            ownerId: admin,
            kind: 'EXPORT',
            filters: { q: title },
          },
        });
        await expect(execute(task.id)).rejects.toThrow('最多导出 5000 条');
        expect(
          (
            await db.backgroundTask.findUniqueOrThrow({
              where: { id: task.id },
            })
          ).resultCsv,
        ).toBeNull();
      } finally {
        await db.ticket.deleteMany({ where: { teamId, title } });
      }
    });
    const exportFixture = async (ownerId = admin) => {
      const marker = `导出竞争-${randomUUID()}`;
      const item = await ticket();
      await db.ticket.update({
        where: { id: item.id },
        data: { title: `${marker}-旧快照` },
      });
      const task = await exportTask(ownerId);
      await db.backgroundTask.update({
        where: { id: task.id },
        data: { filters: { q: marker } },
      });
      return { item, task, marker };
    };
    const exportJob = (
      task: { id: string; run: number },
      values: number[],
    ) => ({
      data: { taskId: task.id, run: task.run },
      attemptsMade: 0,
      updateProgress: async (value: string | number | object) => {
        values.push(value as number);
      },
    });
    it.each(['进度写入前', '最终授权后'])(
      '导出并发接手:%s 失去租约,旧执行者不能发布或报告完成',
      async (point) => {
        const { item, task, marker } = await exportFixture();
        const pausedA = gate(),
          resumeA = gate(),
          claimedB = gate(),
          resumeB = gate();
        const oldProgress: number[] = [],
          newProgress: number[] = [];
        let memberReads = 0;
        const oldDb = db.$extends({
          query: {
            ticket: {
              async findMany({ args, query }) {
                const result = await query(args);
                if (point === '进度写入前' && result.length) {
                  pausedA.open();
                  await resumeA.promise;
                }
                return result;
              },
            },
            membership: {
              async findUnique({ args, query }) {
                const result = await query(args);
                // 第一次是执行前授权,第二次是团队锁内的提交前授权。
                if (++memberReads === 2 && point === '最终授权后') {
                  pausedA.open();
                  await resumeA.promise;
                }
                return result;
              },
            },
          },
        });
        const newDb = db.$extends({
          query: {
            backgroundTask: {
              async updateMany({ args, query }) {
                const result = await query(args);
                if (args.data.status === 'RUNNING') {
                  claimedB.open();
                  await resumeB.promise;
                }
                return result;
              },
            },
          },
        });
        const running: Promise<unknown>[] = [];
        try {
          const a = processTask(
            oldDb as unknown as PrismaClient,
            exportJob(task, oldProgress),
          );
          running.push(a);
          void a.catch(() => {});
          await reached(pausedA.promise);
          const first = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          // A 已读取旧标题,B 应发布新标题,避免两份相同 CSV 掩盖旧结果覆盖。
          await db.ticket.update({
            where: { id: item.id },
            data: { title: `${marker}-新快照` },
          });
          const b = processTask(
            newDb as unknown as PrismaClient,
            exportJob(task, newProgress),
          );
          running.push(b);
          void b.catch(() => {});
          await reached(claimedB.promise);
          const replacement = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          expect(replacement.executionId).not.toBe(first.executionId);
          expect(replacement.executionId).not.toBeNull();
          resumeA.open();
          await a;
          expect(oldProgress).toEqual(point === '进度写入前' ? [] : [95]);
          expect(
            await db.backgroundTask.findUnique({ where: { id: task.id } }),
          ).toEqual(replacement);
          expect(replacement).toMatchObject({
            status: 'RUNNING',
            resultCsv: null,
            resultSummary: null,
            expiresAt: null,
          });
          resumeB.open();
          await b;
          const completed = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          expect(completed).toMatchObject({
            status: 'COMPLETED',
            progress: 100,
            run: task.run,
            executionId: null,
            lastError: null,
            resultSummary: '已导出 1 条工单',
          });
          expect(completed.resultCsv).toContain(`${marker}-新快照`);
          expect(completed.resultCsv).not.toContain(`${marker}-旧快照`);
          expect(completed.expiresAt!.getTime()).toBeGreaterThan(Date.now());
          expect(newProgress).toEqual([95, 100]);
          expect(await downloadExport(db, admin, task.id)).toBe(
            completed.resultCsv,
          );
          // 已完成后重复执行不刷新文件内容、有效期或任务状态。
          await execute(task.id);
          expect(
            await db.backgroundTask.findUnique({ where: { id: task.id } }),
          ).toEqual(completed);
        } finally {
          resumeA.open();
          resumeB.open();
          await Promise.allSettled(running);
        }
      },
    );
    it.each(['降为客户', '移出团队'])(
      '导出读取中%s:提交前重查授权,不能发布已生成的团队文件',
      async (change) => {
        await db.membership.create({
          data: { teamId, userId: outsider, role: 'AGENT' },
        });
        const { task } = await exportFixture(outsider);
        const paused = gate(),
          resume = gate();
        const values: number[] = [];
        const job = exportJob(task, values);
        const running = processTask(db, {
          ...job,
          updateProgress: async (value) => {
            values.push(value as number);
            if (value === 95) {
              paused.open();
              await resume.promise;
            }
          },
        });
        void running.catch(() => {});
        try {
          await reached(paused.promise);
          if (change === '降为客户')
            await api(admin).team.changeRole({
              teamId,
              userId: outsider,
              role: 'CUSTOMER',
            });
          else await api(admin).team.removeMember({ teamId, userId: outsider });
          resume.open();
          await expect(running).rejects.toThrow('成员权限已变化');
          expect(values).toEqual([95]);
          expect(
            await db.backgroundTask.findUnique({ where: { id: task.id } }),
          ).toMatchObject({
            status: 'FAILED',
            executionId: null,
            resultCsv: null,
            resultSummary: null,
            expiresAt: null,
            lastError: '成员权限已变化,请重新创建导出',
          });
          await expect(
            downloadExport(db, outsider, task.id),
          ).rejects.toMatchObject({ code: 'FORBIDDEN' });
        } finally {
          resume.open();
          await Promise.allSettled([running]);
          await db.membership.deleteMany({
            where: { teamId, userId: outsider },
          });
        }
      },
    );
    it('导出旧执行者的权限拒绝晚到,不得报永久失败或破坏新租约', async () => {
      await db.membership.create({
        data: { teamId, userId: outsider, role: 'CUSTOMER' },
      });
      // 任务最初受理的是团队范围;A 读到降权状态,B 在合法恢复为客服后接手。
      const { task, marker } = await exportFixture(outsider);
      const readA = gate(),
        resumeA = gate(),
        claimedB = gate(),
        resumeB = gate();
      const oldProgress: number[] = [],
        newProgress: number[] = [];
      const oldDb = db.$extends({
        query: {
          membership: {
            async findUnique({ args, query }) {
              const result = await query(args);
              readA.open();
              await resumeA.promise;
              return result;
            },
          },
        },
      });
      const newDb = db.$extends({
        query: {
          backgroundTask: {
            async updateMany({ args, query }) {
              const result = await query(args);
              if (args.data.status === 'RUNNING') {
                claimedB.open();
                await resumeB.promise;
              }
              return result;
            },
          },
        },
      });
      const running: Promise<unknown>[] = [];
      try {
        const a = processTask(
          oldDb as unknown as PrismaClient,
          exportJob(task, oldProgress),
        );
        running.push(a);
        void a.catch(() => {});
        await reached(readA.promise);
        await api(admin).team.changeRole({
          teamId,
          userId: outsider,
          role: 'AGENT',
        });
        const b = processTask(
          newDb as unknown as PrismaClient,
          exportJob(task, newProgress),
        );
        running.push(b);
        void b.catch(() => {});
        await reached(claimedB.promise);
        const replacement = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        resumeA.open();
        await a;
        expect(oldProgress).toEqual([]);
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toEqual(replacement);
        resumeB.open();
        await b;
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toMatchObject({
          status: 'COMPLETED',
          executionId: null,
          lastError: null,
          progress: 100,
        });
        expect(await downloadExport(db, outsider, task.id)).toContain(marker);
      } finally {
        resumeA.open();
        resumeB.open();
        await Promise.allSettled(running);
        await db.membership.deleteMany({ where: { teamId, userId: outsider } });
      }
    });
    it('导出最终写入后故障:正文、有效期与完成状态一起回滚,重试才发布', async () => {
      const { task, marker } = await exportFixture();
      const values: number[] = [];
      const failingDb = db.$extends({
        query: {
          backgroundTask: {
            async updateMany({ args, query }) {
              const result = await query(args);
              if (args.data.status === 'COMPLETED')
                throw new Error('测试注入:导出结果写入后失败');
              return result;
            },
          },
        },
      });
      await expect(
        processTask(
          failingDb as unknown as PrismaClient,
          exportJob(task, values),
        ),
      ).rejects.toThrow('后台任务执行失败');
      expect(values).toEqual([95]);
      expect(
        await db.backgroundTask.findUnique({ where: { id: task.id } }),
      ).toMatchObject({
        status: 'WAITING',
        progress: 95,
        executionId: null,
        resultCsv: null,
        resultSummary: null,
        expiresAt: null,
      });
      await expect(downloadExport(db, admin, task.id)).rejects.toMatchObject({
        code: 'NOT_FOUND',
      });
      await execute(task.id, 1);
      const completed = await db.backgroundTask.findUniqueOrThrow({
        where: { id: task.id },
      });
      expect(completed).toMatchObject({
        status: 'COMPLETED',
        progress: 100,
        executionId: null,
        attempts: 2,
        lastError: null,
        resultSummary: '已导出 1 条工单',
      });
      expect(completed.resultCsv).toContain(marker);
      expect(completed.expiresAt!.getTime()).toBeGreaterThan(Date.now());
      expect(await downloadExport(db, admin, task.id)).toBe(
        completed.resultCsv,
      );
    });
    it('导出最终授权与保存持有同一团队锁,降权必须等提交后生效', async () => {
      await db.membership.create({
        data: { teamId, userId: outsider, role: 'AGENT' },
      });
      const { task } = await exportFixture(outsider);
      const paused = gate(),
        resume = gate();
      let reads = 0;
      const committingDb = db.$extends({
        query: {
          membership: {
            async findUnique({ args, query }) {
              const result = await query(args);
              if (++reads === 2) {
                paused.open();
                await resume.promise;
              }
              return result;
            },
          },
        },
      });
      // 独立连接名只用于观察 PostgreSQL 的真实锁等待,不把“请求已发出”当作已阻塞。
      const connectionName = `export-permission-${randomUUID()}`;
      const permissionDb = new PrismaClient({
        adapter: new PrismaPg({
          connectionString: process.env.TEST_DATABASE_URL!,
          application_name: connectionName,
          max: 1,
        }),
      });
      const values: number[] = [];
      const running = processTask(
        committingDb as unknown as PrismaClient,
        exportJob(task, values),
      );
      void running.catch(() => {});
      let changing: Promise<unknown> | undefined;
      try {
        await reached(paused.promise);
        changing = appRouter
          .createCaller({ db: permissionDb, userId: admin })
          .team.changeRole({
            teamId,
            userId: outsider,
            role: 'CUSTOMER',
          });
        void changing.catch(() => {});
        const deadline = Date.now() + 4000;
        while (true) {
          const [state] = await db.$queryRaw<{ waiting: boolean }[]>`
            SELECT EXISTS (
              SELECT 1 FROM pg_stat_activity
              WHERE application_name = ${connectionName}
                AND cardinality(pg_blocking_pids(pid)) > 0
            ) AS waiting`;
          if (state.waiting) break;
          if (Date.now() >= deadline)
            throw new Error('降权没有等待最终提交的团队锁');
        }
        expect(
          await db.membership.findUnique({
            where: { teamId_userId: { teamId, userId: outsider } },
          }),
        ).toMatchObject({ role: 'AGENT' });
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toMatchObject({ status: 'RUNNING', resultCsv: null });
        resume.open();
        await running;
        await changing;
        const completed = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(completed).toMatchObject({
          status: 'COMPLETED',
          progress: 100,
          executionId: null,
          lastError: null,
        });
        expect(completed.resultCsv).not.toBeNull();
        expect(values).toEqual([95, 100]);
        // 保存发生在合法降权之前;降权生效后下载仍重新授权,账本不被倒改。
        await expect(
          downloadExport(db, outsider, task.id),
        ).rejects.toMatchObject({ code: 'FORBIDDEN' });
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toEqual(completed);
      } finally {
        resume.open();
        await Promise.allSettled([running, ...(changing ? [changing] : [])]);
        await permissionDb.$disconnect();
        await db.membership.deleteMany({ where: { teamId, userId: outsider } });
      }
    });
    it('导出已提交后汇报100失败,不把已保存文件退回等待或报永久失败', async () => {
      const { task, marker } = await exportFixture();
      await expect(
        processTask(db, {
          ...exportJob(task, []),
          updateProgress: async (value) => {
            if (value === 100) throw new Error('测试注入:队列进度汇报失败');
          },
        }),
      ).resolves.toBeUndefined();
      const completed = await db.backgroundTask.findUniqueOrThrow({
        where: { id: task.id },
      });
      expect(completed).toMatchObject({
        status: 'COMPLETED',
        progress: 100,
        executionId: null,
        lastError: null,
        resultSummary: '已导出 1 条工单',
      });
      expect(completed.expiresAt!.getTime()).toBeGreaterThan(Date.now());
      expect(await downloadExport(db, admin, task.id)).toContain(marker);
      await execute(task.id);
      expect(
        await db.backgroundTask.findUnique({ where: { id: task.id } }),
      ).toEqual(completed);
    });
    it('任务列表客户仅能查看自己的导出', async () => {
      const result = await api(customer).task.list({ teamId });
      expect(result.items.every((task) => task.kind === 'EXPORT')).toBe(true);
      await expect(api(outsider).task.list({ teamId })).rejects.toMatchObject({
        code: 'FORBIDDEN',
      });
    });
  },
);

搜索教程

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

阅读设置

外观
正文字号

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

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