"""第二阶段异常场景目录(任务书 §12-§15)。 纯数据 + 应用/恢复指令:副作用由 app.py 的 fault_apply / fault_recover 执行, 本模块不连接数据库、不改任何状态,保证场景定义可单测、可审计。 kind 语义: - auto 模拟器一键预置(apply 指令由模拟器执行) - guide 需测试人员在 Ai-DOP 侧人工操作(页面展示指引,模拟器只控制第三方一侧) 被 Ai-DOP 正确拒绝属于测试通过,不属于工具失败。 """ from __future__ import annotations # 场景报告八列(任务书 §15) FAULT_REPORT_FIELDS: list[tuple[str, str]] = [ ("scenarioId", "场景"), ("simBehavior", "模拟端行为"), ("expected", "预期结果"), ("actualResponse", "实际响应"), ("aidopFlowStatus", "Ai-DOP 流水状态"), ("targetChanged", "目标数据是否变化"), ("pass", "是否符合预期"), ("note", "备注"), ] # --------------------------------------------------------------------------- # N1 方式甲 DB_SYNC(10 场景) # --------------------------------------------------------------------------- N1_SCENARIOS: list[dict] = [ {"id": "N1-01", "channel": "DB_SYNC", "kind": "guide", "title": "数据库地址/端口错误", "sim": "测试人员在 Ai-DOP 侧把来源连接串改为未监听端口(如 3307)。模拟器不修改 Ai-DOP 配置。", "expect": "Ai-DOP 测连或同步失败,提示连接被拒绝/超时。", "recover": "把 Ai-DOP 侧连接串改回正确地址。"}, {"id": "N1-02", "channel": "DB_SYNC", "kind": "guide", "title": "数据库用户名/密码错误", "sim": "测试人员在 Ai-DOP 侧填写错误的测试账号/密码(如 sim_user/wrong)。", "expect": "认证失败(Access denied),同步任务失败。", "recover": "改回正确测试账号。"}, {"id": "N1-03", "channel": "DB_SYNC", "kind": "guide", "title": "无表权限", "sim": "测试人员在模拟库上创建受限账号(仅 SELECT 部分表或完全无权限),Ai-DOP 侧配置该账号。", "expect": "查询被拒绝(SELECT command denied)。", "recover": "Ai-DOP 侧换回有权限账号。"}, {"id": "N1-04", "channel": "DB_SYNC", "kind": "auto", "title": "源表不存在", "sim": "模拟器删除源表(DROP TABLE)。测试人员在 Ai-DOP 侧保持配置不变并触发同步。", "expect": "明确报表不存在(Table ... doesn't exist),同步失败不静默。", "recover": "点击恢复:按目录样例重建表并装载样例行。", "apply": {"type": "db_drop_table"}}, {"id": "N1-05", "channel": "DB_SYNC", "kind": "guide", "title": "字段不存在(错误增量列)", "sim": "测试人员在 Ai-DOP 侧把增量列配置改为不存在的列名(如 sourceUpdatedAt2)。", "expect": "明确报列不存在(Unknown column),同步失败。", "recover": "改回正确增量列名。"}, {"id": "N1-06", "channel": "DB_SYNC", "kind": "auto", "title": "空业务键", "sim": "模拟器装载 bizKey 为 NULL 的行(额外插入,不清空原数据)。", "expect": "被拒绝或隔离,不静默覆盖正常数据。", "recover": "点击恢复:删除空键行并重建样例行。", "apply": {"type": "db_load_null_bizkey"}}, {"id": "N1-07", "channel": "DB_SYNC", "kind": "auto", "title": "重复业务键", "sim": "模拟器重建无唯一键的表并装载两条相同 bizKey 的行。", "expect": "行为可解释(Ai-DOP 侧去重/告警),不产生脏数据。", "recover": "点击恢复:重建带唯一键的表并装载样例行。", "apply": {"type": "db_load_duplicate_bizkey"}}, {"id": "N1-08", "channel": "DB_SYNC", "kind": "auto", "title": "增量列不推进", "sim": "模拟器更新一行数据但保持增量列时间不变(advance_increment=false)。", "expect": "该行不被误判为新增量;下一次正常推进后可同步。", "recover": "点击恢复:重建样例行。", "apply": {"type": "db_update_no_advance"}}, {"id": "N1-09", "channel": "DB_SYNC", "kind": "auto", "title": "慢查询", "sim": "模拟器创建受控延迟视图 v_slow_{table}(每行 SLEEP 0.5 秒),并提示测试人员在 Ai-DOP 侧把 source_table_name 改为该视图名。", "expect": "Ai-DOP 读取耗时显著变长,超时行为可解释。", "recover": "Ai-DOP 侧改回原表名;点击恢复删除视图。", "apply": {"type": "db_slow_view", "sleepSeconds": 0.5}}, {"id": "N1-10", "channel": "DB_SYNC", "kind": "guide", "title": "连接中断", "sim": "测试人员在同步过程中停止模拟库服务(或断开网络),Ai-DOP 侧触发同步。", "expect": "失败不记成功,重连后状态可恢复。", "recover": "重新启动模拟库服务。"}, ] # --------------------------------------------------------------------------- # N2 方式乙 API_PULL(16 场景) # --------------------------------------------------------------------------- N2_SCENARIOS: list[dict] = [ {"id": "N2-01", "channel": "API_PULL", "kind": "auto", "title": "401 未授权", "sim": "Mock API 返回 401(force_status=401)。", "expect": "Ai-DOP 拉取失败,流水记录失败状态,不写入数据。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 401}}}, {"id": "N2-02", "channel": "API_PULL", "kind": "auto", "title": "403 禁止", "sim": "Mock API 返回 403(force_status=403)。", "expect": "Ai-DOP 拉取失败,流水记录失败状态。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 403}}}, {"id": "N2-03", "channel": "API_PULL", "kind": "auto", "title": "404 路径不存在", "sim": "Mock API 返回 404(force_status=404)。", "expect": "Ai-DOP 拉取失败,明确提示路径不存在。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 404}}}, {"id": "N2-04", "channel": "API_PULL", "kind": "auto", "title": "429 限流", "sim": "Mock API 返回 429(force_status=429)。", "expect": "Ai-DOP 拉取失败或按限流策略重试。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 429}}}, {"id": "N2-05", "channel": "API_PULL", "kind": "auto", "title": "500 服务器错误", "sim": "Mock API 返回 500(force_status=500)。", "expect": "Ai-DOP 拉取失败,错误信息明确。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 500}}}, {"id": "N2-06", "channel": "API_PULL", "kind": "auto", "title": "503 服务不可用", "sim": "Mock API 返回 503(force_status=503)。", "expect": "Ai-DOP 拉取失败,错误信息明确。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 503}}}, {"id": "N2-07", "channel": "API_PULL", "kind": "auto", "title": "固定延迟", "sim": "Mock API 每次响应前延迟 3 秒(delay_seconds=3)。", "expect": "Ai-DOP 响应耗时约 3 秒,请求成功。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"delay_seconds": 3}}}, {"id": "N2-08", "channel": "API_PULL", "kind": "auto", "title": "超时", "sim": "Mock API 睡眠 120 秒(timeout_seconds=120),超过 Ai-DOP 读取超时。", "expect": "Ai-DOP 侧超时终止,流水记录失败。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"timeout_seconds": 120}}}, {"id": "N2-09", "channel": "API_PULL", "kind": "auto", "title": "空响应", "sim": "Mock API 返回 200 空 body(empty_response=true)。", "expect": "Ai-DOP 解析失败并明确报错,不写入数据。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"empty_response": True}}}, {"id": "N2-10", "channel": "API_PULL", "kind": "auto", "title": "非法 JSON", "sim": "Mock API 返回截断的非法 JSON 文本(invalid_json=true)。", "expect": "Ai-DOP 解析失败并明确报错。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"invalid_json": True}}}, {"id": "N2-11", "channel": "API_PULL", "kind": "auto", "title": "错误 Content-Type", "sim": "Mock API 返回 text/html 的 Content-Type(content_type=text/html)。", "expect": "Ai-DOP 按 Content-Type 校验失败或仍按 JSON 解析,行为可解释。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"content_type": "text/html"}}}, {"id": "N2-12", "channel": "API_PULL", "kind": "auto", "title": "错误 response path", "sim": "Mock API 把数据挂到不存在的路径 data.notExist(response_path=data.notExist)。", "expect": "Ai-DOP 取不到列表,明确报错。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"response_path": "data.notExist"}}}, {"id": "N2-13", "channel": "API_PULL", "kind": "auto", "title": "缺 bizKey", "sim": "Mock API 挂载去掉 bizKey 字段的覆盖行(/api/item)。", "expect": "Ai-DOP 去重键缺失,报错或隔离,不静默写入。", "recover": "恢复默认(清除覆盖行)。", "apply": {"type": "mock_api_override", "mode": "missing_bizkey"}}, {"id": "N2-14", "channel": "API_PULL", "kind": "auto", "title": "重复 bizKey", "sim": "Mock API 挂载两条相同 bizKey 的覆盖行(/api/item)。", "expect": "Ai-DOP 去重行为可解释,不产生脏数据。", "recover": "恢复默认(清除覆盖行)。", "apply": {"type": "mock_api_override", "mode": "duplicate_bizkey"}}, {"id": "N2-15", "channel": "API_PULL", "kind": "auto", "title": "cursor 不推进", "sim": "Mock API 无视 cursor 返回全部行(ignore_cursor=true)。", "expect": "Ai-DOP 每次拿到同样数据,去重后无新增,行为可解释。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"ignore_cursor": True}}}, {"id": "N2-16", "channel": "API_PULL", "kind": "auto", "title": "cursor 乱序", "sim": "Mock API 不按 bizKey 排序返回(no_sort=true)。", "expect": "Ai-DOP 分页去重行为可解释,不产生脏数据。", "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"no_sort": True}}}, ] # --------------------------------------------------------------------------- # N3 方式丙 API_INBOUND(17 场景,任务书 §14 原文) # --------------------------------------------------------------------------- N3_SCENARIOS: list[dict] = [ {"id": "N3-01", "channel": "API_INBOUND", "kind": "auto", "title": "缺 AccessKey", "sim": "推数请求不发送 X-Access-Key 头。", "expect": "Ai-DOP 拒绝(401/403 授权缺失),不产生流水数据。", "recover": "无需恢复(无状态注入)。", "apply": {"type": "inbound_case", "case": "missing-access-key"}}, {"id": "N3-02", "channel": "API_INBOUND", "kind": "auto", "title": "错误 AccessKey", "sim": "发送未授权的 AccessKey(SIM-UNKNOWN-ACCESS-KEY,签名自洽)。", "expect": "Ai-DOP 拒绝(授权缺失/未知 AccessKey)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "wrong-access-key"}}, {"id": "N3-03", "channel": "API_INBOUND", "kind": "auto", "title": "错误 Secret/签名", "sim": "用错误 Secret 计算签名(bad_signature=true)。", "expect": "Ai-DOP 验签失败(401/403)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "bad-signature"}}, {"id": "N3-04", "channel": "API_INBOUND", "kind": "auto", "title": "body 摘要不一致", "sim": "发送后篡改 body(X-Content-SHA256 与实际不符)。", "expect": "Ai-DOP 摘要校验失败,明确拒绝。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "tamper-body"}}, {"id": "N3-05", "channel": "API_INBOUND", "kind": "auto", "title": "缺幂等键", "sim": "请求不发送 Idempotency-Key 头。", "expect": "Ai-DOP 拒绝(幂等键必填)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "missing-idem"}}, {"id": "N3-06", "channel": "API_INBOUND", "kind": "auto", "title": "过期 timestamp", "sim": "X-Timestamp 设为 1 小时前。", "expect": "Ai-DOP 拒绝(时间戳超出允许窗口)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "expired-ts"}}, {"id": "N3-07", "channel": "API_INBOUND", "kind": "auto", "title": "nonce replay", "sim": "相同 nonce 连发两个请求。", "expect": "第二个被拒绝(防重放)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "nonce-replay"}}, {"id": "N3-08", "channel": "API_INBOUND", "kind": "auto", "title": "同幂等键同 body", "sim": "相同 Idempotency-Key 与 body 连发两次。", "expect": "第二次命中幂等返回相同结果,不重复落库。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "same-key-replay"}}, {"id": "N3-09", "channel": "API_INBOUND", "kind": "auto", "title": "同幂等键不同 body", "sim": "相同 Idempotency-Key 但 body 不同。", "expect": "Ai-DOP 拒绝(幂等键冲突)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "same-key-diff-body"}}, {"id": "N3-10", "channel": "API_INBOUND", "kind": "auto", "title": "缺必填字段", "sim": "推数行删除契约必填字段(如 MDM_ITEM 的 Descr)。", "expect": "Ai-DOP 拒绝或隔离该行(映射/校验失败)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "missing-required-field"}}, {"id": "N3-11", "channel": "API_INBOUND", "kind": "auto", "title": "字段类型错误", "sim": "推数行把必填字段替换为结构敌对值(嵌套对象,实测比数字更能逼近类型校验边界)。", "expect": "Ai-DOP 拒绝或隔离该行(类型校验失败);若宽容转换则在报告中记录实证。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "wrong-field-type"}}, {"id": "N3-12", "channel": "API_INBOUND", "kind": "auto", "title": "部分行拒绝", "sim": "push 信封多行中一行缺必填字段。", "expect": "坏行被拒绝,好行正常入站(或整体拒绝),行为可解释。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "partial-reject"}}, {"id": "N3-13", "channel": "API_INBOUND", "kind": "auto", "title": "stale 数据", "sim": "推数行时间字段退到 2000 年(快照场景下模拟旧数据)。", "expect": "Ai-DOP 拒绝过期数据或按 stale 策略处理。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "stale"}}, {"id": "N3-14", "channel": "API_INBOUND", "kind": "auto", "title": "未授权 entityCode", "sim": "推送到不存在/未授权的实体码 SIM_UNKNOWN_ENTITY_XX。", "expect": "Ai-DOP 返回 404 实体未注册(或 403 未授权)。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "unknown-entity"}}, {"id": "N3-15", "channel": "API_INBOUND", "kind": "auto", "title": "未启用 entity", "sim": "测试人员先在 Ai-DOP 侧关闭某实体 inbound 开关,模拟器再向该实体推送(默认 S5_INVENTORY_TXN,可填其他已注册实体码)。", "expect": "Ai-DOP 拒绝(实体未启用)。", "recover": "Ai-DOP 侧重新启用该实体。", "apply": {"type": "inbound_case", "case": "disabled-entity"}}, {"id": "N3-16", "channel": "API_INBOUND", "kind": "auto", "title": "bulk 部分失败", "sim": "bulk 端点 ndjson 多行中一行缺必填字段。", "expect": "Ai-DOP 返回逐行结果,坏行计数准确。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "bulk-partial-failure"}}, {"id": "N3-17", "channel": "API_INBOUND", "kind": "auto", "title": "snapshot commit 失败", "sim": "提交不存在的 snapshotId(SNAP-NOT-EXIST-0001)。", "expect": "Ai-DOP 拒绝 commit,明确报错。", "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "snapshot-commit-failure"}}, ] FAULT_SCENARIOS: list[dict] = N1_SCENARIOS + N2_SCENARIOS + N3_SCENARIOS FAULT_CHANNEL_LABELS = { "DB_SYNC": "方式甲 DB_SYNC(数据库同步)", "API_PULL": "方式乙 API_PULL(接口拉取)", "API_INBOUND": "方式丙 API_INBOUND(接口推送)", } def scenario_counts() -> dict: counts = {"DB_SYNC": 0, "API_PULL": 0, "API_INBOUND": 0, "total": 0} for s in FAULT_SCENARIOS: counts[s["channel"]] += 1 counts["total"] += 1 return counts def find_scenario(scenario_id: str) -> dict | None: sid = scenario_id.upper() return next((s for s in FAULT_SCENARIOS if s["id"].upper() == sid), None)