# 会议出勤（Meeting Attendance）- 测试场景文档

> **版本**: v1.3
> **最后更新**: 2026-04-27
> **维护者**: 测试团队

> **参考标准**: `../../../.agents/skills/test-backend/references/testing-standards.md`

---

## ✅ 机器读取区（必填）

### 用例清单

| 用例 | 类型 | 优先级 | 入口 | 结果 |
|------|------|--------|------|------|
| 会议创建 | Integration | P0 | POST /meeting-attendance/meetings | 创建成功，二维码生成 |
| 系统用户签到 | Integration | P0 | POST /meeting-attendance/meetings/:id/checkin | 出勤记录创建或更新 |
| 访客免登录签到 | Integration | P0 | POST /meeting-attendance/meetings/:id/guest-checkin | 仅名单内系统用户允许签到 |
| 签到过早拦截 | Integration | P0 | POST /meeting-attendance/meetings/:id/checkin | 返回过早提示 |
| 设备限制 | Integration | P1 | POST /meeting-attendance/meetings/:id/guest-checkin | 同设备仅一次 |
| 系列会议创建 | Integration | P1 | POST /meeting-attendance/series | 实例生成且二维码有效 |
| 系列会议改期 | Integration | P1 | POST /meeting-attendance/series/:id/update-schedule | 会议时间更新 |
| 报表生成 | Integration | P1 | GET /meeting-attendance/reports/series | 报表统计正确 |
| 会议删除软/硬删 | Integration | P2 | DELETE /meeting-attendance/meetings/:id | 有出勤软删，无出勤硬删 |
| 自动纳管管理员邮箱 | Integration | P0 | GET /meeting-attendance/integrations/outlook/candidates（未传 mailboxId） | 自动创建并启用管理员个人邮箱，候选会议可返回 |
| 首次进入快照初始化 | Integration | P0 | GET /meeting-attendance/integrations/outlook/candidates（邮箱无 cursor） | 后台异步初始化候选快照（含系列实例），响应 `snapshotInitializing=true`，后续刷新可见 |
| 候选历史过滤严格未来 | Integration | P0 | GET /meeting-attendance/integrations/outlook/candidates（includePast=false） | 仅返回 `startTime >= now` 的候选会议；过去单次会议不展示 |
| 已结束系列不出现在候选页 | Integration | P0 | GET /meeting-attendance/integrations/outlook/candidates（includePast=false） | 已结束 `seriesMaster` 不展示为候选；已纳管列表不受影响 |
| 系列展开优先读快照 | Integration | P0 | GET /meeting-attendance/integrations/outlook/candidates/:seriesMasterId/children | 快照已有子项时直接返回本地结果，不重复回源 Outlook |
| 已结束系列不展示候选子项 | Integration | P0 | GET /meeting-attendance/integrations/outlook/candidates/:seriesMasterId/children（includePast=false） | 系列已结束时返回空列表，忽略本地残留未来快照 |
| 对账限量补齐候选系列子快照 | Integration | P0 | POST /meeting-attendance/integrations/outlook/sync/reconcile | 对账完成后按批次补齐当前邮箱候选窗口内缺少子快照的 `seriesMaster`，无需逐个手工展开 |
| 候选会议纳管 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings | single 直接生成本地会议与签到项；seriesMaster 仅建系列锚点 |
| 删除未签到参会人成功 | Integration | P0 | DELETE /meeting-attendance/meetings/:id/required-attendees/:userId | 删除名单与占位 attendance 成功；若会议已纳管则切换为 `LOCKED_BY_LOCAL_EDIT` |
| 删除已签到参会人被拦截 | Integration | P0 | DELETE /meeting-attendance/meetings/:id/required-attendees/:userId | 返回 409，名单与 attendance 不变 |
| 单次会议人工修改后冻结同步 | Integration | P0 | PUT /meeting-attendance/meetings/:id 或参会人增删 | 对应 Outlook binding 标记 `LOCKED_BY_LOCAL_EDIT`，记录锁定元数据 |
| 锁定后同步仅记日志不覆盖本地 | Integration | P0 | Delta/Reconcile + GET /meeting-attendance/integrations/outlook/bindings/:id/history | 本地 meeting/attendees 保持人工修改结果，同时出现 `SYNC_SKIPPED_LOCAL_OVERRIDE` 历史记录 |
| 已结束系列纳管拦截 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings | 已结束 `seriesMaster` 返回 400 与明确提示，不创建绑定/系列/会议 |
| 同事件全局唯一绑定 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings | 已绑定时返回冲突并给出绑定信息 |
| 强制接管绑定 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings/:id/takeover | 绑定人/来源邮箱/syncFrom更新并生成审计事件 |
| 系列纳管自动覆盖实例 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings + Delta/Reconcile | 纳管 seriesMaster 后 occurrence/exception 自动生成本地会议并入同步（seriesMaster 本身不生成单场会议） |
| 系列单次实例排除 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings/:id/exclusions | 指定 occurrence 不再进入签到同步 |
| 移除实例排除 | Integration | P1 | POST /meeting-attendance/integrations/outlook/exclusions/:id/remove | 排除移除后实例可重新纳入同步 |
| 接管后同步来源切换 | Integration | P0 | POST /meeting-attendance/integrations/outlook/bindings/:id/takeover | 同步来源邮箱与绑定人同时切换，后续同步按新来源执行 |
| 绑定同步历史时间线 | Integration | P1 | GET /meeting-attendance/integrations/outlook/bindings/:id/history | 支持分页、时间范围、事件类型与失败过滤，按时间倒序 |
| 同步历史返回官方源差异摘要 | Integration | P0 | GET /meeting-attendance/integrations/outlook/bindings/:id/history | 每条 `SYNC_UPDATED` 可返回 Outlook 官方更新时间、获取时间、变化字段与人数摘要 |
| 相同 payload 不重复落版本 | Integration | P0 | Delta/Reconcile | 同一绑定下标准化 payload hash 未变化时不重复创建版本记录 |
| 参会人口径摘要一致 | Integration | P0 | Delta/Reconcile + GET history | 差异摘要同时输出 `graph attendees`、`internal matched`、`meeting required` 三类数量，口径与业务表一致 |
| 管理员全局绑定总览 | Integration | P1 | GET /meeting-attendance/integrations/outlook/bindings/all | 仅 Administrator 可访问，返回跨邮箱分页列表 |
| 管理员总览页权限 | E2E | P1 | /meetingattendance/integrations/outlook/bindings-all | Administrator 可见，非 Administrator 403/无权限提示 |
| 同步历史失败过滤 | E2E | P2 | Outlook 详情弹窗 | 勾选仅失败事件后仅展示 resultStatus=ERROR 记录 |
| 同步历史 CSV 导出 | Integration | P2 | GET /meeting-attendance/integrations/outlook/bindings/:id/history/export.csv | 导出结果与筛选条件一致 |
| Outlook 取消映射 | Integration | P0 | Webhook + Delta | 本地会议状态变为 CANCELLED，记录取消原因 |
| Outlook 删除映射 | Integration | P0 | Webhook + Delta | 本地会议状态变为 CANCELLED，记录删除原因 |
| 定时对账兜底 | Integration | P1 | Scheduled Reconcile | 漏通知场景可自动修复 |
| 同步设置更新 | Integration | P1 | PATCH /meeting-attendance/integrations/outlook/settings | 设置生效并重载对账任务 |
| Webhook 验证回调 | Integration | P1 | POST /meeting-attendance/integrations/outlook/webhooks/notifications | validationToken 可正确回显 |
| 签到方式校验关闭不限制（v1.2） | Integration | P0 | POST /meetings/:id/guest-checkin（meeting.enforceCheckinMode=false） | 扫任何二维码都能签到；行为与 v1.0 完全一致 |
| 城市派生 - 工作地=会议地=线下（v1.2） | Integration | P0 | workCity=`Los Angeles`, meeting.city=`Los Angeles`, enforce=true, qrType=on_site | 签到成功；attendance.status=ON_SITE |
| 城市派生 - 工作地≠会议地=线上（v1.2） | Integration | P0 | workCity=`Shanghai`, meeting.city=`Los Angeles`, enforce=true, qrType=online | 签到成功；attendance.status=ONLINE |
| 扫错码拒签（v1.2） | Integration | P0 | workCity=`Los Angeles`, meeting.city=`Los Angeles`, qrType=online | 返回 MEETING_ATTENDANCE_033，不创建 attendance |
| 用户工作地未配置拒签（v1.2） | Integration | P0 | user.workCity=null, enforce=true | 返回 MEETING_ATTENDANCE_034，不创建 attendance |
| 会议地点未配置被 guard 拦截（v1.2） | Integration | P0 | PATCH /meetings/:id/enforce-checkin-mode（enforce=true, city=null） | 返回 MEETING_ATTENDANCE_036；开关保持 false |
| 城市比较 trim 但区分大小写（v1.2） | Integration | P0 | workCity=`Los Angeles`, meeting.city=`  Los Angeles  `（前后有空格） | 匹配成功=线下 |
| 系列级覆盖优先城市派生（v1.2） | Integration | P0 | workCity=`Los Angeles`, meeting.city=`Los Angeles`, seriesAttendeePreference=ONLINE, qrType=online | 签到成功；allowedModeSource=SERIES_PREFERENCE |
| 会议级覆盖优先系列级（v1.2） | Integration | P0 | seriesPref=ONLINE, meetingOverride=ON_SITE, qrType=on_site | 签到成功；allowedModeSource=MEETING_OVERRIDE |
| 恢复默认清空会议级覆盖（v1.2） | Integration | P0 | PATCH /meetings/:id/required-attendees/:userId/checkin-mode（checkinMode=null） | 字段置 null，下次签到回退到系列级→城市派生 |
| 覆盖后 attendanceStatus 派生自 qrType（v1.2） | Integration | P0 | POST /meetings/:id/guest-checkin（前端尝试传入不一致 attendanceStatus） | 忽略前端值，attendance.status 按 qrType 写 |
| 切换单会议开关不触发 lock（v1.2） | Integration | P0 | PATCH /meetings/:id/enforce-checkin-mode + GET /meetings/:id | outlookSync.syncMode 保持 AUTO，不出现 LOCKED_BY_LOCAL_EDIT |
| 切换系列开关级联下属会议（v1.2） | Integration | P0 | PATCH /series/:id/enforce-checkin-mode（city 已填）| updatedMeetingCount 等于下属 meeting 数；所有实例开关与 city 同步刷新 |
| Outlook 同步继承系列字段（v1.2） | Integration | P0 | Outlook 新增 occurrence 同步进来 | 新 meeting.city = series.city, enforceCheckinMode 同步继承 |
| 参会人会议级覆盖写入不触发 lock（v1.2） | Integration | P0 | PATCH /meetings/:id/required-attendees/:userId/checkin-mode | outlook binding.syncMode 保持 AUTO；audit log 写入 ATTENDEE_CHECKIN_MODE_OVERRIDE |
| 系列级参会人批量覆盖（v1.2） | Integration | P0 | PUT /series/:id/attendee-preferences（preferences=多人） | upsert 成功；created/updated 计数正确；skippedUnknownUserIds 列出非参会人 |
| 系列级参会人搜索（v1.2） | Integration | P1 | GET /series/:id/attendee-preferences?keyword=alice | 只返回姓名/邮箱含 alice 的参会人 |
| 删除系列级覆盖恢复派生（v1.2） | Integration | P0 | DELETE /series/:id/attendee-preferences/:userId | 记录删除；下次签到回退到城市派生 |
| 城市自动补全去重（v1.2） | Integration | P0 | GET /cities/suggestions | 返回 user.workCity + meeting.city + meetingSeries.city 非空去重集合 |
| Outlook 同步 upsert 不冲掉 checkinMode（v1.2） | Integration | P0 | Outlook 同步刷新 requiredAttendees | upsert.update 仅更新 role；checkinMode 保持原值 |
| Excel 工作地导入预览分类（v1.2） | Integration | P0 | POST /organization/users/import-work-city?preview=true | 返回 exact/similar/new 三类；编辑距离 ≤ 2 且长度差 ≤ 2 归入 similar |
| Excel 相似度排除缩写（v1.2） | Integration | P0 | Excel 含 `LA`，系统已有 `Los Angeles` | `LA` 归入 new 类（长度差 9，超阈值），不归入 similar |
| Excel 工作地导入确认落库（v1.2） | Integration | P0 | POST /organization/users/import-work-city?preview=false + approvals | 按管理员选择写入 User.workCity；不匹配邮箱不落库 |
| 单场报表新增允许方式列（v1.2） | Integration | P0 | GET /reports/single-meeting | personalStats[].allowedMode 与 allowedModeSource 正确；顶部 enforceCheckinMode + city 与 meeting 一致 |
| 系列报表调整次数仅计会议级（v1.2） | Integration | P0 | GET /reports/series | adjustmentCount 等于该人在该系列下 MeetingRequiredAttendee.checkinMode 非空场次数；SeriesAttendeePreference 不计入 |
| 自动入组 - 真人通过判定（v1.3） | Integration | P0 | Outlook 同步触发 syncAttendees，邮箱不在 users 表，Graph 返回 active Member + mail 匹配 + mailNickname=`firstname.lastname` | 调 Graph POST groups/$ref 返回 204；audit_logs 写入 `OUTLOOK_ATTENDEE_AUTO_ADD` decision=`added`，source=`OUTLOOK_AUTO_SYNC` |
| 自动入组 - 拒绝 disabled（v1.3） | Integration | P0 | Graph 返回 accountEnabled=false | 不调 Graph $ref；audit decision=`skipped_disabled` |
| 自动入组 - 拒绝 Guest（v1.3） | Integration | P0 | Graph 返回 userType=`Guest` | 不调 Graph $ref；audit decision=`skipped_guest` |
| 自动入组 - 拒绝无邮箱（v1.3） | Integration | P0 | Graph 返回 mail=null | audit decision=`skipped_no_mail` |
| 自动入组 - 拒绝 mail 不匹配（v1.3） | Integration | P0 | Graph 返回 mail 与同步邮箱大小写不一致以外的差异（防 alias） | audit decision=`skipped_email_mismatch` |
| 自动入组 - 拒绝资源邮箱前缀（v1.3） | Integration | P0 | mailNickname=`Conf_Floor2_Treehouse` | audit decision=`skipped_resource_naming` matched_rule=`mailNickname_prefix:conf_` |
| 自动入组 - 拒绝 ! 前缀资源（v1.3） | Integration | P0 | mailNickname=`!Equip_Test1` | audit decision=`skipped_resource_naming` matched_rule=`mailNickname_prefix:!` |
| 自动入组 - 拒绝服务账号 displayName（v1.3） | Integration | P0 | displayName 含 `(ServiceAccount)`，mailNickname 是 `firstname.lastname` 形式 | audit decision=`skipped_resource_naming` matched_rule=`displayName:(serviceaccount)` |
| 自动入组 - Graph 用户找不到（v1.3） | Integration | P1 | Graph `/users` 查询返回 404 | audit decision=`skipped_user_not_found`；不阻塞 sync |
| 自动入组 - Graph 调用失败不阻塞 sync（v1.3） | Integration | P0 | Graph POST $ref 返回 502 | audit decision=`failed` graph_response_status=502；syncAttendees 主流程继续完成 external_attendee 写入 |
| 自动入组 - 乐观放行（v1.3） | Integration | P1 | mailNickname=`Andy.admin`（含 admin 但是真人） | 通过判定；audit decision=`added`（admin 不进黑名单） |
| 自动入组 - audit 用 itadmin actor（v1.3） | Integration | P0 | 任意 decision 写入 | audit_logs.userEmail = `itadmin@ff.com`（FF 环境）/ AIxC 对应 itadmin email；source=`OUTLOOK_AUTO_SYNC` |
| 自动入组 - AZURE_ENTRA_SYNC_GROUP_ID 未配置（v1.3） | Integration | P1 | env 缺失（仅 dev 场景） | 跳过自动入组逻辑（早 return），不报错；syncAttendees 主流程不变 |
| Optional 不进应到/实到（v1.3） | Integration | P0 | 单会议 2 regular（1 ON_SITE / 1 NOT_CHECKED_IN）+ 1 optional（ON_SITE） | `overallStats.totalRequired=2`、`totalAttended=1`、`attendanceRate=50`、`optionalTotal=1`、`optionalAttended=1` |
| Optional 不进部门统计（v1.3） | Integration | P0 | 同上 | `departmentStats[*].total` 累加 = 2（仅 regular） |
| Optional 不进状态分布（v1.3） | Integration | P0 | 同上 | `statusDistribution.ON_SITE.count=1`（不含 optional 的 ON_SITE） |
| Series 报表 optional 单独累加（v1.3） | Integration | P0 | 系列 1 场 + 1 regular ON_SITE + 1 optional ON_SITE | `overallStats.totalRequired=1`、`totalAttended=1`、`attendanceRate=100`、`optionalTotal=1`、`optionalAttended=1` |

---

## 5. 议程能力 L1 集成测试场景（v1.0）

> **覆盖范围**：议程段 / 议程项 / 上传任务 CRUD + 资料上传（议程项级 + 会议级）+ 下载 + 删除 + 边界/权限。
> **测试文件路径建议**：`testing/backend/integration/meeting-attendance/agenda-*.integration.test.ts`
> **数据隔离**：测试创建的资源统一使用 `t_` 前缀（如 `t_section_001`），`beforeEach` 创建 + `afterEach` cleanup（前缀过滤），种子（角色 / 权限 / 测试用户）不删。
> **公共 setup**：每个 describe 顶部 `beforeEach` 创建 `t_meeting + t_manager + t_employee + t_assignee`；employee/assignee 默认为参会人但不是 manager。

### 5.A 议程段 CRUD（5 用例）

- 5.A.1 `it('should create agenda section as manager and return 201 + persist to DB')`
  - 入口：`POST /meetings/:id/agenda/sections`，actor = manager
  - 断言：HTTP 201；resp.data 含 `{ id, title, order }`；DB `MeetingAgendaSection` 行存在；`createdById = manager.id`
  - cleanup：依赖前缀过滤批量清理
- 5.A.2 `it('should update section title as manager and return 200')`
  - 入口：`PATCH /meetings/:id/agenda/sections/:sectionId`，body `{ title: 't_section_updated' }`
  - 断言：HTTP 200；DB 中该段 `title=t_section_updated`、`updatedAt > createdAt`
- 5.A.3 `it('should soft-delete section as manager and cascade soft-delete items/tasks/attachments')`
  - 入口：`DELETE /meetings/:id/agenda/sections/:sectionId`
  - 前置：段下有 2 个 item，item 下各有 1 task + 1 attachment
  - 断言：HTTP 204；DB 中段 / item / task / attachment 行**仍存在但 `deletedAt` 非空**；`GET /meetings/:id/agenda` 不返回（默认过滤）
- 5.A.4 `it('should reject non-manager non-creator with 403 AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR')`
  - 入口：`PATCH /meetings/:id/agenda/sections/:sectionId`，actor = 普通员工
  - 断言：HTTP 403；resp.code = `AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR`；DB 中该段 `title` 未变
- 5.A.5 `it('should reorder sections in batch and persist new order')`
  - 入口：`PATCH /meetings/:id/agenda/sections/reorder`，body `{ ids: ['s1','s2','s3'] }`
  - 前置：3 个 section（当前 order 任意）
  - 断言：HTTP 200；DB 中各 section 的 `order` 字段按请求数组 index 落库（s1.order=0, s2.order=1, s3.order=2）

### 5.B 议程项 CRUD（5 用例）

- 5.B.1 `it('should create agenda item with presenter, categoryTag, and code')`
  - 入口：`POST /agenda-sections/:sectionId/items`，body `{ title: 't_item_001', presenterUserId, categoryTag: 'OTHER', code: 'A-1' }`
  - 断言：HTTP 201；DB 行字段全部回写
- 5.B.2 `it('should update agenda item fields')`
  - 入口：`PATCH /agenda-sections/:sectionId/items/:itemId`
  - 断言：HTTP 200；DB 字段更新
- 5.B.3 `it('should soft-delete item and cascade soft-delete attachments/tasks')`
  - 入口：`DELETE /agenda-sections/:sectionId/items/:itemId`
  - 前置：item 下 1 task + 1 attachment
  - 断言：HTTP 204；DB 中 item / task / attachment **仍存在但 `deletedAt` 非空**；`GET /meetings/:id/agenda` 不返回该 item
- 5.B.4 `it('should reorder items within a section')`
  - 入口：`PATCH /agenda-sections/:sectionId/items/reorder`，body `{ ids: ['i1','i2','i3'] }`
  - 断言：HTTP 200；DB 中各 item 的 `order` 按请求数组 index 落库
- 5.B.5 `it('should reject invalid presenterUserId with 404 User')`
  - 入口：`POST /agenda-sections/:sectionId/items`，body 含不存在的 `presenterUserId`
  - 断言：HTTP 404；item 未落库

### 5.C 上传任务 CRUD（6 用例）

- 5.C.1 `it('should batch-assign upload tasks to N assignees as manager')`
  - 入口：`POST /agenda-items/:itemId/upload-tasks`，body `{ assigneeUserIds: [u1, u2, u3] }`
  - 断言：HTTP 201；DB `MeetingAgendaItemUploadTask` 表对应 item 下 3 条 `status=PENDING`；resp `skippedExistingUserIds = []`
- 5.C.1a `it('should silently skip duplicate assignees and return skippedExistingUserIds')`
  - 前置：u1 已有 1 条 `status=PENDING` 的 task（未软删）
  - 入口：`POST /agenda-items/:itemId/upload-tasks`，body `{ assigneeUserIds: [u1, u2] }`
  - 断言：HTTP 201（**不抛 409**）；resp `created.length === 1`（仅 u2 新建）；resp `skippedExistingUserIds === [u1]`；u1 的 DB 行数仍为 1（未重复创建）
- 5.C.2 `it('should list upload tasks for an agenda item')`
  - 入口：`GET /agenda-items/:itemId/upload-tasks`
  - 断言：HTTP 200；返回 3 条 task；含 `assigneeUser` 子对象 + `status`
- 5.C.3 `it('should cancel an upload task as manager (status=CANCELLED)')`
  - 入口：`PATCH /agenda-items/:itemId/upload-tasks/:taskId`，body `{ status: 'CANCELLED' }`
  - 断言：HTTP 200；DB `status=CANCELLED`
- 5.C.4 `it('should delete an upload task as manager')`
  - 入口：`DELETE /agenda-items/:itemId/upload-tasks/:taskId`
  - 断言：HTTP 204；DB 行消失
- 5.C.5 `it('should reject non-manager assigning tasks with 403')`
  - 入口：`POST /agenda-items/:itemId/upload-tasks`，actor = 普通员工
  - 断言：HTTP 403；code = `AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR`
- 5.C.6 `it('should return only tasks assigned to current user on GET /my-upload-tasks')`
  - 前置：assignee 被分配 1 task；同 item 另一人也有 1 task
  - 入口：`GET /my-upload-tasks`，actor = assignee
  - 断言：HTTP 200；items.length=1；只含 assignee 自己的 task；不含他人任务

### 5.D 资料上传（议程项级，8 用例）

- 5.D.1 `it('should upload PDF as manager via any-path and persist attachment')`
  - 入口：`POST /agenda-items/:itemId/attachments`（multipart，文件 = `t_doc.pdf` 1MB），actor = manager
  - 断言：HTTP 201；resp.data.attachment 含 `{ id, filename, mimeType, size, storagePath }`；存储路径文件存在；DB 行存在
- 5.D.2 `it('should auto-complete upload task when assignee uploads via assigned-path')`
  - 前置：assignee 有 PENDING task
  - 入口：`POST /agenda-items/:itemId/attachments`（multipart），actor = assignee
  - 断言：HTTP 201；resp.data.taskUpdated.status = `UPLOADED`；`taskUpdated.completedAt` 非空；DB 同步
- 5.D.3 `it('should reject non-assignee non-manager upload with 403 UPLOAD_TASK_NOT_OWNED')`
  - 前置：item 下有 task 但不属于当前用户；当前用户也无 manager 权限
  - 入口：`POST /agenda-items/:itemId/attachments`
  - 断言：HTTP 403；code = `UPLOAD_TASK_NOT_OWNED`
- 5.D.4 `it('should reject file > 200MB with 413 ATTACHMENT_TOO_LARGE')`
  - 入口：上传 201MB 文件
  - 断言：HTTP 413；code = `ATTACHMENT_TOO_LARGE`；DB 无新增
- 5.D.5 `it('should reject disallowed mime (.exe) with 415 ATTACHMENT_MIME_NOT_ALLOWED')`
  - 入口：上传 `t_payload.exe`（mimeType=`application/x-msdownload`）
  - 断言：HTTP 415；code = `ATTACHMENT_MIME_NOT_ALLOWED`
- 5.D.5a `it('should reject .exe disguised as PDF via magic bytes')`
  - 入口：上传文件名 `t_evil.pdf`，Content-Type `application/pdf`，但内容是 PE 二进制（首 4 字节 `MZ` 等）
  - 断言：HTTP 415；code = `ATTACHMENT_MIME_MISMATCH`；DB 无新增；文件未落最终目录
- 5.D.6 `it('should accept file at exact 200MB boundary')`
  - 入口：上传精确 `200 * 1024 * 1024` 字节文件
  - 断言：HTTP 201；attachment 落库；`size === 200 * 1024 * 1024`
- 5.D.7 `it('should return 404 AGENDA_ITEM_NOT_FOUND when uploading to a soft-deleted item')`
  - 前置：item 已被软删（`deletedAt` 非空）
  - 入口：`POST /agenda-items/:itemId/attachments`
  - 断言：HTTP 404；code = `AGENDA_ITEM_NOT_FOUND`（默认 `WHERE deletedAt IS NULL` 过滤）
- 5.D.8 `it('should reject second upload for already-UPLOADED task with 409 UPLOAD_TASK_ALREADY_UPLOADED')`
  - 前置：assignee 的 task 已 UPLOADED
  - 入口：assignee 再次 `POST /agenda-items/:itemId/attachments`
  - 断言：HTTP 409；code = `UPLOAD_TASK_ALREADY_UPLOADED`

### 5.E 资料上传（会议级，3 用例）

- 5.E.1 `it('should upload meeting-level attachment with category=MINUTES as manager')`
  - 入口：`POST /meetings/:id/attachments`，body 含 `category=MINUTES`
  - 断言：HTTP 201；`MeetingAttachment` 行存在；`category=MINUTES`
- 5.E.2 `it('should reject non-manager meeting-level upload with 403')`
  - 入口：同上，actor = 普通员工
  - 断言：HTTP 403
- 5.E.3 `it('should list meeting-level attachments via GET')`
  - 入口：`GET /meetings/:id/attachments`
  - 断言：HTTP 200；list.length ≥ 1；含 category 字段

### 5.F 资料下载（4 用例）

- 5.F.1 `it('should download agenda-item attachment as attendee with Content-Disposition')`
  - 入口：`GET /attachments/agenda-item/:id/download`，actor = 参会员工
  - 断言：HTTP 200；`Content-Disposition` 含 `attachment; filename="t_doc.pdf"`；`Content-Type` = attachment.mimeType；`Content-Length` = attachment.size
- 5.F.1a `it('should encode Chinese filename with RFC 5987 Content-Disposition')`
  - 前置：attachment.filename = `t_中文文档_季度报告.pdf`
  - 入口：`GET /attachments/agenda-item/:id/download`
  - 断言：HTTP 200；Header `Content-Disposition` 同时含 `filename="t_..._.pdf"`（ascii-fallback，非 ASCII 字符替换为下划线或省略）和 `filename*=UTF-8''<percent-encoded>`（如 `%E4%B8%AD%E6%96%87%E6%96%87%E6%A1%A3`）；浏览器侧能正确显示中文文件名
- 5.F.2 `it('should reject non-attendee download with 403')`
  - 入口：同上，actor = 非参会人
  - 断言：HTTP 403
- 5.F.3 `it('should return 404 for downloading deleted attachment')`
  - 前置：attachment 已删
  - 入口：`GET /attachments/agenda-item/:id/download`
  - 断言：HTTP 404；code = `ATTACHMENT_NOT_FOUND`
- 5.F.4 `it('should stream 200MB file without exhausting heap')`
  - 前置：上传精确 200MB attachment
  - 入口：`GET /attachments/agenda-item/:id/download`
  - 断言：HTTP 200；下载完成；`jest --logHeapUsage` 中 heapUsed 增量 < 80MB（验证 streaming 非全量加载）

### 5.G 资料删除（3 用例）

- 5.G.1 `it('should soft-delete attachment as uploader')`
  - 入口：`DELETE /agenda-items/:itemId/attachments/:attachmentId`，actor = 上传者本人
  - 断言：HTTP 204；DB 行仍存在但 `deletedAt` 非空；底层文件**仍在**（cron 30 天后才物理删 + unlink）；`GET /agenda-items/:itemId/attachments` 不返回
- 5.G.2 `it('should reject delete by non-uploader non-manager with 403 ATTACHMENT_DELETE_FORBIDDEN')`
  - 入口：同上，actor = 其他参会员工
  - 断言：HTTP 403；code = `ATTACHMENT_DELETE_FORBIDDEN`
- 5.G.3 `it('should allow manager to force-delete others attachment')`
  - 入口：`DELETE /agenda-items/:itemId/attachments/:attachmentId`，actor = manager（非上传者）
  - 断言：HTTP 204；DB 行 `deletedAt` 非空
- 5.G.4 `it('cron should physically delete + unlink attachments older than 30 days')`
  - 前置：直接 DB 写一条 `deletedAt = now() - 31 days` 的 attachment 行 + 文件存在
  - 入口：手动触发 cron 任务 / 调用对应的 GC service 方法
  - 断言：DB 行**真的消失**（COUNT=0）；底层文件已 `unlink`（`fs.existsSync(storagePath) === false`）
- 5.G.5 `it('cron should NOT touch attachments with deletedAt within 30-day window')`
  - 前置：deletedAt = now() - 10 days
  - 入口：手动触发 cron
  - 断言：DB 行仍在 + 文件仍在；表示 30 天窗口内可恢复

### 5.H 边界 / 权限（4 用例）

- 5.H.1 `it('should allow manager to edit agenda even after meeting COMPLETED + write audit with agendaModifiedAfterMeetingEnd=true')`
  - 前置：meeting.status = COMPLETED
  - 入口：`PATCH /meetings/:id/agenda/sections/:sectionId`，actor = manager
  - 断言：HTTP 200；DB 更新成功（UI 层 warning 不影响 backend）；`MeetingAttendanceAuditLog` 新增一条 `action=AGENDA_SECTION_UPDATED` 且 `changes.agendaModifiedAfterMeetingEnd === true`
- 5.H.2 `it('should return trimmed agenda fields for external attendee (only section title + item title)')`
  - 前置：外部访客（`MeetingExternalAttendee`）
  - 入口：`GET /meetings/:id/agenda`，actor = 外部访客
  - 断言：HTTP 200；每个 section 仅含 `{ id, title }`；每个 item 仅含 `{ id, title }`；无 `description / presenter / categoryTag / uploadTasks / attachments`
- 5.H.3 `it('should allow each assignee to see and delete only own attachments on same agenda item')'`
  - 前置：item 下 2 个 assignee 各上传 1 个 attachment
  - 断言：`GET /agenda-items/:itemId/attachments` 两人都看见 2 条；`DELETE` 另一人的 → 403 `ATTACHMENT_DELETE_FORBIDDEN`；删自己的 → 204
- 5.H.4 `it('should write audit log on agenda edit / task assign / attachment upload / delete')`
  - 入口：依次跑议程改 + 分配任务 + 上传 + 删除
  - 断言：`AuditLog` 表对应 4 条记录，含 `action`（如 `AGENDA_SECTION_UPDATED` / `UPLOAD_TASK_ASSIGNED` / `ATTACHMENT_UPLOADED` / `ATTACHMENT_DELETED`）+ `actorId` + `targetId`

> 议程能力 L1 用例小计：约 **44 个 `it()`**（A5 + B5 + C7 + D9 + E3 + F5 + G5 + H4 + 含 cron 物理删 2 个 / RFC 5987 中文文件名 1 个 / magic bytes mismatch 1 个 / skippedExistingUserIds 1 个 / audit agendaModifiedAfterMeetingEnd 1 个）。

---

## 🧭 人类阅读区（可选）

### 用例详情（示例）

- **用例**: 访客免登录签到
- **前置条件**: 会议处于 SCHEDULED 或 IN_PROGRESS
- **步骤**: 访问访客签到页 → 输入姓名/邮箱 → 提交
- **预期结果**: 在参会名单内（系统用户）则签到成功，不在名单返回 403

- **用例**: 候选会议纳管并同步参会角色
- **前置条件**: 源邮箱可用，候选会议包含 required + optional 参会人
- **步骤**: 在候选池执行纳管 -> 触发同步
- **预期结果**: 本地生成普通参会人（required）与可选参会人（optional）

- **用例**: 官方源参会人数变化追溯
- **前置条件**: 某已纳管 Outlook 会议先后出现两版官方源数据，第二版参会人数量发生变化
- **步骤**: 触发一次新同步 -> 打开绑定历史 -> 查看对应 source version 详情
- **预期结果**: 能直接看到 `graphLastModifiedAt`、`fetchedAt`、`attendees before/after`、`internal matched before/after`，并能展开查看新增/删除邮箱摘要
