# 固定地点签到（Site Attendance）- 测试场景

> **module**: site-attendance
> **doc_type**: TestScenarios
> **status**: Draft
> **owner**: 待定
> **upstream_docs**: `01-prd.md`, `03-architecture.md`, `06-data-model.md`, `07-api.md`, `08-error-codes.md`
> **last_verified**: 2026-04-17

---

## 范围

本文档覆盖 v1.5 共享签到 + QR 轮换 + SIGNED 访问模式的测试场景。
v1.0 场景（签到/签退交替、定位校验、免登录流程、事件记录、汇总维护）的测试沿用现有集成测试，不重复列出。

---

## 测试矩阵总览

| 维度 | 取值 |
|------|------|
| `accessMode` | PUBLIC / SIGNED |
| `qrRotationSeconds` | null（永久） / 3600（1h 轮换） |
| `sharedCheckinEnabled` | false / true |
| 认证方式 | 已登录 / 免登录 |
| Partner 跳转 | 本地 / 跨域 |

**四种部署形态**（`03-architecture.md` 定义）全部覆盖：
- **A**: PUBLIC + 不轮换 + 不共享（兼容现状）
- **B**: SIGNED + 轮换 + 不共享（独立 + 轮换）
- **C**: SIGNED + 轮换 + 共享（共享分诊）
- **D**: SIGNED + 轮换 + 共享 + 挂 Partner（混合并存）

---

## L1 后端集成测试场景

### SCN-SA-V15-001：PUBLIC 兼容性

**优先级**: P0
**描述**: 存量 PUBLIC checkpoint 不带 token 访问能正常签到

**前置**: checkpoint `accessMode=PUBLIC`, `sharedCheckinEnabled=false`

**步骤**:
1. 直接访问 `/api/v1/site-attendance/checkpoints/code/CP-PUBLIC/public`
2. 期望：200，返回 checkpoint 信息
3. 调用 `POST /checkin` (登录态) → 期望 201 + 事件写入

**通过标准**: 无 token 也能签到，与 v1.0 行为完全一致

---

### SCN-SA-V15-002：SIGNED 无 token 拒绝

**优先级**: P0
**描述**: SIGNED checkpoint 不带 t 和 ticket 访问被拒

**前置**: checkpoint `accessMode=SIGNED`

**步骤**:
1. 访问 `/api/v1/.../public`（不带 t、不带 ticket）
2. 期望：401 `code: QR_TOKEN_MISSING`

---

### SCN-SA-V15-003：SIGNED 有效 QR token 通过

**前置**: checkpoint `accessMode=SIGNED`, `qrRotationSeconds=3600`

**步骤**:
1. 先 `GET /qr-token` 拿 `token`
2. 用 `?t=<token>` 调 `/public`
3. 期望：200，返回 checkpoint 信息
4. 用同 token 调 `/checkin` → 期望 201

---

### SCN-SA-V15-004：QR token 过期被拒

**前置**: checkpoint SIGNED + 轮换 3600s, grace 120s

**步骤**:
1. 拿当前 token（bucket = N）
2. mock 时间前进到 bucket N+1 + 125 秒（超过宽限期）
3. 用旧 token 调 `/public`
4. 期望：401 `code: QR_TOKEN_EXPIRED`

---

### SCN-SA-V15-005：QR token 在宽限期内通过

**前置**: checkpoint SIGNED + 轮换 3600s, grace 120s

**步骤**:
1. 拿 bucket = N 的 token
2. mock 时间前进到 bucket N+1 的前 60 秒（宽限期内）
3. 用旧 token 调 `/public`
4. 期望：200

---

### SCN-SA-V15-006：QR token 签名伪造被拒

**步骤**:
1. 手工构造 `<bucket>.<fake-mac>`
2. 调 `/public?t=fake`
3. 期望：401 `code: QR_TOKEN_INVALID`

---

### SCN-SA-V15-007：永久 QR token

**前置**: checkpoint SIGNED + `qrRotationSeconds=null`

**步骤**:
1. 拿 token：期望 `"permanent.<mac>"`
2. mock 时间前进 1 年
3. 用同 token 调 `/public` → 期望 200

---

### SCN-SA-V15-008：分诊选项列表（本地 + Partner）

**前置**: checkpoint C `sharedCheckinEnabled=true, sharedCompanyId=ff`，挂 2 个 Partner（aixc 上海、aixc 北京）

**步骤**:
1. 调 `GET /dispatch-options?t=<token>`
2. 期望：`self = { companyId: 'ff', ... }`, `partners = [aixc 上海, aixc 北京]`

---

### SCN-SA-V15-009：分诊未启用

**前置**: checkpoint `sharedCheckinEnabled=false`

**步骤**:
1. 调 `/dispatch-options`
2. 期望：404 `code: DISPATCH_NOT_ENABLED`

---

### SCN-SA-V15-010：Dispatch 签发 ticket（本地跳）

**步骤**:
1. `POST /dispatch` with `choice: { companyId: 'ff' }` (self)
2. 期望：201，`redirectUrl` 以 `/c/FF-LOBBY?ticket=...` 开头，`targetMode='local'`
3. 解析 ticket 载荷：`targetCode=FF-LOBBY`, `dispatchCheckpointId` 匹配

---

### SCN-SA-V15-011：Dispatch 签发 ticket（跨域跳）

**步骤**:
1. `POST /dispatch` with `choice: { companyId: 'aixc', partnerId: '...' }`
2. 期望：201，`redirectUrl` 以 Partner.targetUrl 开头，含 `?ticket=...&dispatchOrigin=...`
3. `targetMode='external'`

---

### SCN-SA-V15-012：Ticket 校验通过

**前置**: 已通过 dispatch 拿到 ticket

**步骤**:
1. `POST /validate-ticket` with `{ ticket, targetCheckpointCode }`
2. 期望：200，`valid=true`，payload 返回
3. 查 DB：`SharedCheckinTicketUsage` 有对应 nonce

---

### SCN-SA-V15-013：Ticket 重放被拒

**步骤**:
1. 用同一 ticket 再次调 `/validate-ticket`
2. 期望：409 `code: TICKET_ALREADY_USED`

---

### SCN-SA-V15-014：Ticket 过期被拒

**步骤**:
1. 签发 ticket
2. mock 时间前进 301 秒（TTL=300）
3. 调 `/validate-ticket`
4. 期望：401 `code: TICKET_EXPIRED`

---

### SCN-SA-V15-015：Ticket targetCode 不匹配

**步骤**:
1. 用为 FF-LOBBY 签发的 ticket 去调 AIXC-HQ 的 `/validate-ticket`
2. 期望：400 `code: TICKET_TARGET_MISMATCH`

---

### SCN-SA-V15-016：Ticket dispatchOrigin 不在白名单

**前置**: env `SHARED_CHECKIN_ALLOWED_HOSTS=ff.example.com,aixc.example.com`

**步骤**:
1. 手工构造 ticket，dispatchOrigin = `https://evil.com/...`，签名用合法 secret
2. `/validate-ticket` → 期望 403 `code: TICKET_ORIGIN_NOT_ALLOWED`

---

### SCN-SA-V15-017：Partner 保存 hostname 白名单

**前置**: env `SHARED_CHECKIN_ALLOWED_HOSTS=ff.example.com,aixc.example.com`

**步骤**:
1. `POST /checkpoints/:id/partners` with `targetUrl=https://ff.example.com/c/FOO` → 期望 201
2. `POST /checkpoints/:id/partners` with `targetUrl=https://evil.com/c/FOO` → 期望 400 `code: PARTNER_TARGETURL_HOST_NOT_ALLOWED`

---

### SCN-SA-V15-018：Partner CRUD 完整链路

**步骤**:
1. POST → 201 + id
2. GET list → 返回新 partner
3. PATCH 改 displayLabel → 200 + updatedBy 刷新
4. DELETE → 204
5. GET list → 不含该 partner

---

### SCN-SA-V15-019：Partner 级联删除

**步骤**:
1. 为 checkpoint 建 2 个 partners
2. DELETE checkpoint
3. 查 DB：2 个 partners 都被删除

---

### SCN-SA-V15-020：启用共享签到时缺 companyId 校验

**步骤**:
1. `PATCH /checkpoints/:id` with `sharedCheckinEnabled=true` but 无 `sharedCompanyId`
2. 期望：400 `code: CHECKPOINT_SHARED_COMPANY_MISSING`

---

### SCN-SA-V15-021：QR 轮换周期小于 60 秒被拒

**步骤**:
1. `POST /checkpoints` with `qrRotationSeconds=30`
2. 期望：400 `code: CHECKPOINT_QR_ROTATION_INVALID`

---

### SCN-SA-V15-022：accessMode 切换后 QR 行为

**前置**: checkpoint PUBLIC

**步骤**:
1. `PATCH` 改 `accessMode=SIGNED, qrRotationSeconds=3600`
2. 再次访问不带 token 的 `/public` → 期望 401 `QR_TOKEN_MISSING`
3. 拿 token 后访问 → 期望 200

---

### SCN-SA-V15-023：签到（已登录 + ticket）完整链路

**步骤**:
1. 分诊 → 拿 ticket
2. `POST /checkin` 带 `ticket` 作为 query / header，含 JWT
3. 期望：201，事件写入，`authMethod=AUTHENTICATED`
4. 查 `DailySummary`：`firstCheckInAt` 更新

---

### SCN-SA-V15-024：签到（guest + ticket）完整链路

**步骤**:
1. 分诊 → 拿 ticket
2. `POST /guest-checkin` 带 ticket + `userId`
3. 期望：201，事件写入，`authMethod=UNAUTHENTICATED`

---

### SCN-SA-V15-025：Ticket usage 定时清理

**步骤**:
1. 插入 3 条过期 `SharedCheckinTicketUsage`（expiresAt < now）
2. 插入 2 条未过期
3. 调 `POST /internal/shared-checkin/cleanup-ticket-usage`
4. 期望：`deletedCount=3`，剩 2 条

---

## L1c 数据质量校验

### SCN-SA-V15-DQ-001：存量 checkpoint 迁移默认值

**前置**: 运行 v1.5 迁移

**步骤**:
1. 查 DB 所有 v1.0 已存在的 checkpoint
2. 断言：`accessMode=PUBLIC`, `qrRotationSeconds=null`, `qrGraceSeconds=120`, `sharedCheckinEnabled=false`

---

### SCN-SA-V15-DQ-002：Partner 唯一性 / 索引

**步骤**:
1. 查询 `SharedCheckinPartner` 的 `(checkpointId, isActive, sortOrder)` 索引存在
2. Prisma `@@index` 与 DB `\d` 输出一致

---

### SCN-SA-V15-DQ-003：枚举值一致性

**步骤**:
1. DB 查询 `SiteCheckpointAccessMode` 枚举值
2. 断言只有 `PUBLIC, SIGNED`，与 Prisma 和 08-error-codes 一致

---

## L0 契约校验重点

- `/qr-token` 响应字段 vs 前端 `QrTokenResponse` interface
- `/dispatch-options` 响应字段 vs 前端 `DispatchOptions` interface
- `/dispatch` 响应 `redirectUrl` / `targetMode` 字段名完全匹配
- `/validate-ticket` 响应 `payload.companyId` / `companyLabel` / `dispatchOrigin` 匹配前端
- `/partners` CRUD 响应的 Partner 结构与 6-data-model 字段表一致
- `/public` 响应新增 4 个 v1.5 字段（accessMode / sharedCheckinEnabled / sharedCompanyId / sharedCompanyLabel）

---

## 跳过的场景（已在其他层覆盖）

| 场景 | 理由 |
|------|------|
| HMAC 签名算法本身正确性 | 由单测覆盖 `crypto.createHmac('sha256', ...)` |
| 时区换算 | v1.0 已测 |
| GPS 距离计算 | v1.0 已测 |
| 前端 QR 渲染视觉 | L2/L3 人工验收 |
