27 · 任务验收与排错:把等待、失败和跳过查成证据#
小陈说:“导出一直等,提醒却显示完成,但我没收到通知。”这句话可能同时描述两件正常事:导出没有 Worker,旧提醒被取消后正常跳过;也可能确有投递故障。第五阶段的验收,要把每句话落到任务 ID、状态、时间、权限与结果上。
本章分开执行基础规则、真实数据库业务、真实 Redis 调度、正式 Worker 消费恢复和页面故事。它们不能互相替代;下面给的是可执行步骤与预期证据,实际通过与否以你运行的输出为准。
1. 先给问题建立一张记录卡#
遇到异常先记 taskId、kind、teamId、当前账号、run、attempts、status、runAt、updatedAt、摘要 / 错误。不要把连接串、Cookie 或整份 CSV 贴到日志里。
| 现象 | 第一份证据 | 接下来区分什么 |
|---|---|---|
| 提交失败、没有 ID | Network 中 tRPC 错误 | 未登录、非成员、限流、数据库不可用 |
| WAITING | runAt、dispatchedAt、Worker 投递日志 | 未来延迟、尚未投递、同轮重试 |
| RUNNING 无推进 | updatedAt、executionId、taskId/run 日志 | 正在读取、执行卡住、恢复扫描未完成 |
| FAILED | lastError、attempts、run | 明确拒绝还是次数耗尽,重试是否有意义 |
| COMPLETED 无通知 | resultSummary、工单 revision、通知接收人 | 旧提醒跳过、当前负责人不是我、无接收者 |
| COMPLETED 无法下载 | ownerId、当前成员、scopeCreatorId、expiresAt | 身份、范围、期限或正文变化 |
任务健康查询读取 Redis 和近期心跳,业务列表读取 PostgreSQL。绿色健康标记不是某个任务正确的断言;红色标记也不代表已受理账本消失。先确认自己在同一环境查同一 ID。
2. 第一层:无需数据库的三个基础规则#
在项目根目录运行:
pnpm exec vitest run tests/tasks.test.ts --reporter=verbosetasks.test.ts 的三个 it 验证:CSV 逗号 / 引号 / 换行结构;等号等公式开头与前置空白防护;固定 now 下优先级的 1/4/24/48 小时差值。
这些用例应实际 passed。它们不启动 Worker,不验证成员资格、数据库回滚、队列延迟,也不证明用 Excel 打开后的所有显示行为。固定日期减少时钟干扰,但不意味着已经实现工作日历。
3. 第二层:准备独立数据库再测试业务#
先启动 Docker 引擎,在项目根目录逐条执行:
pnpm db:up
# 只在该学习测试库尚不存在时执行;存在则跳过,不删库重建
docker compose exec -T postgres createdb -U teamdesk teamdesk_stage5_test
DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test pnpm db:migrate
pnpm db:generate临时 DATABASE_URL 只用于迁移命令,不改 .env。生成 Client 不会建表;迁移必须指向测试实际使用的同一个库。不要将开发 Worker 指向这个测试库,测试文件会自己控制执行时刻。
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
pnpm exec vitest run tests/tasks.integration.test.ts --reporter=verbose只运行此文件不需要 TEST_REDIS_URL。它连接真实 PostgreSQL,但 execute 直接调用 processTask,updateProgress 是可控函数;Redis 故障用例传的是会抛错的队列桩。这样可以明确在指定业务位置失败,不依赖网络故障碰运气。
4. 先读夹具,防止误把准备动作当业务保证#
打开 tasks.integration.test.ts,按 describe.skipIf → beforeAll → api / ticket / exportTask / execute → afterAll 阅读:
| 帮助函数 | 实际作用 | 读断言时的边界 |
|---|---|---|
| api(userId) | appRouter.createCaller 注入上下文 | 测服务授权,不经过 Cookie 与 HTTP |
| ticket() | 客户通过服务创建工单 | 会附带创建默认提醒待办 |
| exportTask() | 直接在表里建立受控导出任务 | 绕开受理限流,不证明 createExport 全流程 |
| execute(id, attemptsMade) | 查当前 run,直接调用 processor | 不经过 BullMQ 延迟和自动退避 |
| afterAll | 清理本轮团队、工单与账号并断连 | 不清全库;强杀可能留下夹具 |
部分测试通过直接改 dueAt、assigneeMembershipId 或成员角色准备边界状态。这样能短而稳定地测执行分支,但这些修改不是建议用户在正常业务库中操作的方法。
整个文件共享这一轮团队夹具;不要假设每个用例开始时只有一张工单或一个任务。精确检查时按本用例 ID、唯一标题或操作前数量定位。
5. 把实际断言映射到业务保证#
| 测试场景 | 当前真正检查的证据 | 不能顺带宣称 |
|---|---|---|
| 匿名与跨团队提交 | UNAUTHORIZED / FORBIDDEN | 浏览器登录 Cookie 传输已验证 |
| 客户导出 | 有 100 进度、公式前缀、无秘密标题、他人下载 NOT_FOUND | 所有相同筛选下的归属攻击都覆盖 |
| 排队后降权 | 待执行被拒绝,已有文件下载 FORBIDDEN | 排队用例本身证明全部并发窗口 |
| 移出团队 | 执行被拒绝 | 同名用例还调用了下载;它没有 |
| 过期文件 | 改 expiresAt 后下载 NOT_FOUND | 定时清理已真实等过 24 小时 |
| 默认提醒和设置权限 | 创建后有一个任务,客户 / 外团队不能设置 | 通过失败注入证明所有创建关联写入回滚 |
| 提醒重复执行 | 通知 1 条、系统事件 1 条、已读更新 | 两个并发 Worker 的全部竞争均覆盖 |
| 提醒并发接手 | E1 回滚后四项一致,E2 完成后四项一致;恢复清租约同样验证 | 真实 BullMQ stalled 接手全过程已测试 |
| 提醒事件后故障 | 三项业务写入回滚、任务 WAITING,重试只留一份结果 | 所有数据库失败点都已穷举 |
| 已解决后重新打开 | 旧任务无通知,重开后任务总数变 2 | 新任务到期后一定成功通知 |
| 手动重试 | 非 FAILED 拒绝、他人拒绝、旧 run 不执行、新 run=1 | 该旧 run 用例也证明同 run 接手 |
| 导出并发接手 | 两个暂停点,旧执行者无 100,新租约不变,B 的 CSV 最终保存 | 真实 Worker stalled 全过程已测试 |
| 导出读取中撤权 | 合法降权 / 移除后不发布,无正文、期限,下载拒绝 | 能撤回已下载副本 |
| 导出最终授权锁 | 权限连接真实等待数据库锁;提交后降权拒绝下载 | 任意绕过团队锁的直接改表也安全 |
| 导出结果写入故障 | 事务内 UPDATE 后抛错整笔回滚;提交后汇报失败保留结果 | PostgreSQL 与 Redis 已是同一事务 |
| 205 行导出 | 有中间进度,标题在 CSV 中出现 205 次 | 5 MiB 边界与动态快照压力已测试 |
这是测试阅读练习,不是否定现有测试。明确证据边界,才能选出下一份有价值的测试,而不是重复已有 happy path。
6. 亲手跑一次中途故障与恢复#
在已准备好的独立库上聚焦:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
pnpm exec vitest run tests/tasks.integration.test.ts \
-t '中途进度写入故障' --reporter=verbose用例连续传 attemptsMade=0、1、2,updateProgress 每次抛错。它依次断言 WAITING、WAITING、FAILED,attempts 分别 1、2、3,resultCsv 始终 null;然后调用 retry,正常 execute,最终 COMPLETED。
这里预期业务调用 reject,而用例应 passed,因为拒绝与回查数据符合预期。其他 it 因名称过滤跳过是正常的;整组因没配置 TEST_DATABASE_URL 而 skipped 则表示数据库实验没有执行。
投递失败用例仅断言任务仍 WAITING,不能代替提醒事务回滚证据。现在还有专门的真实数据库故障注入:Prisma 查询扩展在事件 INSERT 完成后抛错,通知、version、事件都应撤销;任务回到 WAITING、executionId=null,第二次执行才提交一份结果。这里不用数据库触发器,也不修改生产代码来添加测试暂停接口。
继续运行并发场景:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
pnpm exec vitest run tests/tasks.integration.test.ts \
-t '提醒并发接手|提醒写入事件后故障' --reporter=verbose“提醒并发接手”有两条参数化用例。A 在真实事务内读到 E1 后等待;一条让 B 的认领真正提交 E2 后等待,另一条模拟恢复扫描清租约并置 WAITING。释放 A 后,先检查通知 0、事件 0、工单 version 不变,任务与接手后的整行快照一致。再释放 B 或重新执行,检查通知和事件各 1、事件版本与工单新版本一致、提醒意图版本未变、任务 COMPLETED / progress=100 / executionId=null / lastError=null。最后再执行一次也不追加结果。
这会发现旧实现中“末尾更新 count=0 却仍提交副作用”的问题;仅检查最终通知数会被唯一约束掩盖。同步关卡决定交错顺序,超时只负责防止测试无限等待;失败清理也必须放行并等待所有执行者。第 26 章 解释为什么失去提交资格要在事务内抛错、在事务外安静退出。这里直接调用 processor,不测试真实 stalled 检测、两分钟恢复时钟或 Redis 重投。
导出也要区分旧资格、当前授权和发布结果#
第 25 章 列出八条导出用例。运行:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
pnpm exec vitest run tests/tasks.integration.test.ts \
-t '导出并发接手|导出读取中|导出旧执行者|导出最终写入后|导出最终授权|导出已提交' --reporter=verbose两条接手用例检查 E2 已提交而尚未读取时,A 不改任务、不报 100;随后 B 才保存带“新快照”标记的文件。权限变化用例经过真正的成员服务;最终锁用例额外观察 PostgreSQL 的真实阻塞,证明成员降权无法插入授权与保存之间。旧权限错误用例让 A 读到客户身份后暂停、恢复为客服并让 B 接手,检验旧 TaskRejected 不再终止新执行。
提交失败要分前后:事务内结果 UPDATE 后抛错,CSV、摘要、期限和完成状态全回滚,任务回 WAITING;事务外报告 100 失败,已完成账本与文件保留。这个区分比“只断言最后 COMPLETED”更能发现半份结果或无效完成汇报。
7. 第三层:真实 Redis 调度单独验证#
开发 Compose 已把 Redis 暴露在本机 6380。运行:
TEST_REDIS_URL=redis://127.0.0.1:6380 \
pnpm exec vitest run tests/queue.integration.test.ts --reporter=verbosequeue.integration.test.ts 创建随机 test-UUID 前缀和 test-tasks 队列,结束只 obliterate 本轮队列,不清空开发前缀。三个场景分别检查两次临时失败后成功并保留进度、三次失败后停止且可调用队列 job.retry、同 jobId 的 300ms 延迟任务重复投递只执行一次。
测试退避起点是 30ms,不是应用配置的 2000ms;延迟断言允许至少 250ms,不能据此声称验证了毫秒级精确调度。第二个场景调用的是 BullMQ job.retry,不是项目服务里 run+1 的手动重试,两种 API 的语义不同。
这个测试的处理函数是小型测试函数,没有调用本项目 processTask,也不连接 PostgreSQL。它证明真实队列行为,不能单独证明提醒权限或导出内容。
第三层补充:让正式 Worker 真正接手#
此前 queue.integration 的小型函数与 tasks.integration 的处理器直调各有边界。worker.integration.test.ts 把这两段接起来,复用 正式 Worker 工厂。两个测试连接缺一都会跳过整组:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
TEST_REDIS_URL=redis://127.0.0.1:6380 \
pnpm exec vitest run tests/worker.integration.test.ts --reporter=verbose它为每例创建 worker-test-UUID 队列前缀,在本轮团队里直接准备工单与任务。dispatcher 的测试查询扩展只额外限定本轮 teamId,避免其他套件的任务被投到本例队列;正式恢复条件保持不变。
| 用例 | 可控故障 | 同时检查的结果 |
|---|---|---|
| 导出 stalled 接手 | A 停止续锁,B 真实检测后换租约,A 晚到读取错误 | E2 中间态不变;队列 completed / 100,账本 COMPLETED、CSV / 摘要 / 期限正确 |
| 提醒 stalled 接手 | 同样交错,A 在提醒事务中暂停 | E2 不被改写;通知、系统事件各 1,工单 version 仅加 1,任务完成 |
| failed 回调迟到 | A 的失败状态写入故障、队列 failed,单个测试 job 丢失后恢复补投 | 旧回调不能清 E2;新执行者完成且正文与摘要正确 |
| 真实自动重试 | 首次读取故障 | failed 日志 exhausted=false,下一次成功,数据库与队列尝试计数均为 2 |
| 永久拒绝 | 无效筛选 | 两边 FAILED,只尝试 1 次;数据库保留准确业务错误 |
| stalled 上限耗尽 | maxStalledCount=0,等待真实锁过期 | 队列先 failed,账本经 RUNNING → WAITING → FAILED,旧执行者不改终态 |
事件决定何时继续,Promise 关卡决定谁暂停;没有删除 Redis 锁或手动 emit 事件。测试在事务读取、失败状态写入等边界注入错误,这是为了稳定命中特定故障窗口,并不是伪造数据库或队列返回值。迟到回调的数据库扩展只延迟旧实现可能发出的 SQL;恢复旧无租约 UPDATE 后,E2 会实际被改成 FAILED,断言因此失败。
测试最后放行关卡、等待 Worker 关闭,再关 QueueEvents,只 obliterate 本例随机前缀,清理本轮数据库团队与账号。不连接开发队列,不运行 FLUSHDB。锁自然过期真实等待约一秒;两分钟恢复阈值用调旧本任务 updatedAt 加速,不能据此声称测过真实两分钟计时。
数据库与 Redis 准备好后,可一次运行本阶段四个自动测试文件:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
TEST_REDIS_URL=redis://127.0.0.1:6380 \
pnpm exec vitest run tests/tasks.test.ts tests/tasks.integration.test.ts tests/queue.integration.test.ts tests/worker.integration.test.ts --reporter=verbose进程级的心跳、SIGTERM、退出期限和重启接手,另见 第 30 章,运行 tests/worker-process.integration.test.ts。该文件已加入 pnpm test:integration,CI 的 pnpm test 也会自动发现;它要求两个测试连接,缺一则整组跳过。
8. 第四层:用页面补上真实进程之间的接棒#
在独立学习团队、常规开发库中进行,不与上面的自动测试库混用:
- 停止你自己的开发 Worker,提交一份带唯一筛选的导出。记任务 ID,确认原账本 WAITING;界面健康提示可能要等心跳过期和下一次查询。
- 用同一开发配置启动
pnpm worker,按 taskId / run 找任务日志,观察原任务完成,下载核对范围与内容;不要靠再提交一份完成的任务替代恢复证据。 - 新工单设一分钟提醒,分配给小陈;到期后核对通知归属、系统事件和工单版本。页面只有站内通知,不会出现邮件或系统弹窗。
- 另一新工单设后取消,核对旧任务跳过且没有通知;重新设置需携带最新版本。再走解决 / 重新打开,核对新时限与新待办。
- 另一账号访问导出下载地址应被拒绝;切回所有者核对有效结果。再结合自动测试中的降权、过期与重试断言完成记录。
第 1~2 步证明真实 Web、数据库、Worker、Redis 接棒,不故意破坏 Redis。新 Worker 集成用例已覆盖自然锁过期接手、stalled 上限耗尽和单个 job 记录丢失后的补投;真实网络断线、Redis 整库丢失、完整 Web / 容器中断仍是其他故障窗口,不能将这六条测试扩大为全部端到端覆盖。若继续扩展,应使用一次性隔离环境,不通过删除开发队列验证。
9. 常见失败按哪一层排查#
| 输出 / 现象 | 下一步 |
|---|---|
| Docker socket 不存在 | 启动 Docker 引擎,再检查 docker compose ps |
| 数据库拒绝连接 / 缺表 | 核对 5433、库名、迁移命令的临时 URL |
| 测试全 skipped | 检查 TEST_DATABASE_URL / TEST_REDIS_URL 是否给到该命令 |
| 同任务重复通知 | 按 ticketId+revision+userId 查行,再查事件与任务完成事务 |
| 等待超过两分钟 | 先看是否 RUNNING 过期,再看扫描是否成功走到恢复末尾 |
| 第一次失败立即终止 | 检查是否 TaskRejected,不能一律判断自动重试失效 |
| 下载报错但账本完成 | 核对当前所有者资格、expiresAt、正文,而不篡改 status |
记录“做了什么、读到什么、是否符合预期、还有哪层没验证”。不要用测试通过截图代替输出中被跳过的真实依赖测试。
拓展阅读怎样选择下一份最有价值的测试?
围绕失败窗口补证据:同筛选不同客户的范围夹具、客户升权后原始 scope 保持、更多提醒失败点、5 MiB 字节上限、入队成功而确认失败后的补投。每次只让一个故障点可控,检查业务结果而不复刻实现内部调用顺序。
10. 练习与答案#
练习 A:四个自动测试文件全 passed,能宣布完整 Worker 上线验收完成吗#
动手练习查看参考答案
参考答案:不能。新增 Worker 集成已经复用正式处理器与事件监听器,证明消费、租约和特定恢复窗口;它仍不启动完整 Web;第 30 章另有正式生命周期的真实子进程测试,覆盖心跳与信号退出,容器守护仍需单独验收。还需页面接棒及部署验证,不能替代第六阶段镜像、迁移、健康与升级验收。
练习 B:WAITING、runAt 在明天、健康正常,需要点重试吗#
动手练习查看参考答案
参考答案:不需要。延迟任务应等待到期,且 retry 只接受 FAILED。先判断任务意图与时间,不能为让状态变绿手工改表。
练习 C:如何证明客户边界,而不是碰巧没有泄漏#
动手练习查看参考答案
参考答案:让其他客户和外团队的数据也匹配同一筛选,再断言只有当前客户数据被导出;同时测试他人下载被拒绝。数据集合边界和文件所有权是两份证据,缺一不可。
第五阶段最终链路:前端提交筛选 / 时限 → 接口校验身份、成员与版本 → 数据库原子保存意图与待办 → dispatcher 投递 ID 和轮次 → Worker 重查权限、提醒版本与执行租约 → 业务结果落库 → 页面轮询及授权下载。 每一段都应有对应证据,才能解释任务为什么等待、为什么失败、为什么正常跳过。