test_fault.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. """第二阶段(N1-N4)异常测试能力守卫。
  2. - N2:Mock API 异常注入模式(超时/空响应/非法 JSON/Content-Type/cursor 行为/恢复默认)
  3. - N1:模拟库异常能力(drop 表/无唯一键重复装载/增量列不推进/慢查询视图 DDL)
  4. - N3:入站客户端异常用例(缺/错 AccessKey、缺字段、类型错误、stale、snapshot 失败)
  5. - N4:异常测试报告(字段覆盖任务书 §15 八列、脱敏、导出)
  6. 全部进程内验证(TestClient/纯函数),不依赖 Ai-DOP 在线与真实 MySQL。
  7. """
  8. from __future__ import annotations
  9. import json
  10. import pytest
  11. fastapi_testclient = pytest.importorskip("fastapi.testclient")
  12. TestClient = fastapi_testclient.TestClient
  13. from providers import mock_api # noqa: E402
  14. from providers import mock_db # noqa: E402
  15. from catalog import sample_adapters as sa # noqa: E402
  16. # ---------------------------------------------------------------------------
  17. # N2:Mock API 异常模式
  18. # ---------------------------------------------------------------------------
  19. @pytest.fixture()
  20. def client():
  21. state = mock_api.MockApiState()
  22. state.settings.auth_mode = "NONE"
  23. return TestClient(mock_api.create_app(state)), state
  24. def _set(client, **kw):
  25. c, state = client
  26. for key, val in kw.items():
  27. setattr(state.settings, key, val)
  28. def test_force_status_covers_n2_codes(client):
  29. """N2 要求 401/403/404/429/500/503:force_status 须对全部六类生效。"""
  30. c, _ = client
  31. for code in (401, 403, 404, 429, 500, 503):
  32. _set(client, force_status=code)
  33. assert c.get("/api/item").status_code == code, code
  34. _set(client, force_status=None)
  35. assert c.get("/api/item").status_code == 200
  36. def test_empty_response_mode(client):
  37. c, _ = client
  38. _set(client, empty_response=True)
  39. resp = c.get("/api/item")
  40. assert resp.status_code == 200
  41. assert resp.content == b""
  42. def test_invalid_json_mode(client):
  43. c, _ = client
  44. _set(client, invalid_json=True)
  45. resp = c.get("/api/item")
  46. assert resp.status_code == 200
  47. with pytest.raises(Exception):
  48. resp.json() # 无法解析为 JSON
  49. def test_wrong_content_type_mode(client):
  50. c, _ = client
  51. _set(client, content_type="text/html")
  52. resp = c.get("/api/item")
  53. assert resp.status_code == 200
  54. assert resp.headers["content-type"].startswith("text/html")
  55. def test_timeout_mode_sleeps_configured_seconds(client):
  56. c, _ = client
  57. _set(client, timeout_seconds=0.3)
  58. import time
  59. started = time.time()
  60. resp = c.get("/api/item")
  61. assert resp.status_code == 200
  62. assert time.time() - started >= 0.3
  63. def test_ignore_cursor_returns_all_rows(client):
  64. c, _ = client
  65. rows = [
  66. {"bizKey": "K-1", "v": 1}, {"bizKey": "K-2", "v": 2}, {"bizKey": "K-3", "v": 3},
  67. ]
  68. c.post("/__control/overrides", json={"path": "/api/order-test", "rows": rows})
  69. # 默认 cursor 过滤:只返回更大行
  70. after = c.get("/api/order-test", params={"cursor": "K-1"}).json()["data"]["list"]
  71. assert [r["bizKey"] for r in after] == ["K-2", "K-3"]
  72. # ignore_cursor:无视 cursor 返回全部行(cursor 不推进观察用)
  73. _set(client, ignore_cursor=True)
  74. after = c.get("/api/order-test", params={"cursor": "K-1"}).json()["data"]["list"]
  75. assert [r["bizKey"] for r in after] == ["K-1", "K-2", "K-3"]
  76. def test_no_sort_keeps_original_order(client):
  77. c, state = client
  78. rows = [
  79. {"bizKey": "Z-2"}, {"bizKey": "A-1"}, {"bizKey": "M-3"},
  80. ]
  81. c.post("/__control/overrides", json={"path": "/api/order-test", "rows": rows})
  82. # 默认排序
  83. got = [r["bizKey"] for r in c.get("/api/order-test").json()["data"]["list"]]
  84. assert got == ["A-1", "M-3", "Z-2"]
  85. # no_sort 保持挂载原顺序(cursor 乱序观察用)
  86. _set(client, no_sort=True)
  87. got = [r["bizKey"] for r in c.get("/api/order-test").json()["data"]["list"]]
  88. assert got == ["Z-2", "A-1", "M-3"]
  89. def test_reset_faults_clears_all_injections(client):
  90. c, _ = client
  91. _set(client, force_status=500, empty_response=True, invalid_json=True,
  92. content_type="text/html", ignore_cursor=True, no_sort=True,
  93. timeout_seconds=1.0, delay_seconds=0.5)
  94. resp = c.post("/__control/settings", json={"reset": True}).json()
  95. assert resp["forceStatus"] is None
  96. assert resp["emptyResponse"] is False
  97. assert resp["invalidJson"] is False
  98. assert resp["contentType"] is None
  99. assert resp["ignoreCursor"] is False
  100. assert resp["noSort"] is False
  101. assert resp["timeoutSeconds"] == 0.0
  102. assert resp["delaySeconds"] == 0.0
  103. assert resp["authMode"] == "TOKEN" # 鉴权模式一并恢复默认
  104. # 恢复后正常拉取可用
  105. _set(client, auth_mode="NONE")
  106. assert c.get("/api/item").status_code == 200
  107. def test_audit_records_active_faults(client):
  108. c, _ = client
  109. _set(client, empty_response=True, ignore_cursor=True)
  110. c.get("/api/item")
  111. entries = c.get("/__control/audit").json()["entries"]
  112. assert entries, "audit should record the request"
  113. faults = entries[-1]["faults"]
  114. assert "emptyResponse" in faults
  115. assert "ignoreCursor" in faults
  116. # ---------------------------------------------------------------------------
  117. # N1:模拟库异常能力(纯函数层;真实 MySQL 用例沿用 test_mock_db.py 的跳过约定)
  118. # ---------------------------------------------------------------------------
  119. RUN_ID = "20260930120000-abc123"
  120. def test_ddl_without_unique_key_for_duplicate_bizkey():
  121. rows = sa.adapt_db_rows(sa.find_object("ITEM"), RUN_ID)
  122. ddl = mock_db.generate_ddl("sim_item", rows, ["bizKey"], "sourceUpdatedAt",
  123. unique_key=False)
  124. assert "UNIQUE KEY" not in ddl # 重复业务键场景:无唯一索引,允许重复行共存
  125. assert "CREATE TABLE IF NOT EXISTS `sim_item`" in ddl
  126. def test_raw_insert_sql_has_no_duplicate_merge():
  127. """N1 重复业务键:原始 INSERT 不合并(与 upsert 的 ON DUPLICATE KEY 不同)。"""
  128. class FakeCur:
  129. def __init__(self):
  130. self.executed = []
  131. def executemany(self, sql, rows):
  132. self.executed.append(sql)
  133. def __enter__(self):
  134. return self
  135. def __exit__(self, *args):
  136. return False
  137. class FakeConn:
  138. def __init__(self, cur):
  139. self._cur = cur
  140. def cursor(self):
  141. return self._cur
  142. def close(self):
  143. pass
  144. cur = FakeCur()
  145. monkeypatch = pytest.MonkeyPatch()
  146. monkeypatch.setattr(mock_db, "_use_db", lambda: FakeConn(cur))
  147. try:
  148. mock_db.insert_rows_raw("sim_item", [{"bizKey": "K-1", "v": 1},
  149. {"bizKey": "K-1", "v": 2}])
  150. finally:
  151. monkeypatch.undo()
  152. assert cur.executed and "ON DUPLICATE KEY" not in cur.executed[0]
  153. def test_update_rows_can_skip_increment_advance():
  154. """N1 增量列不推进:advance_increment=False 时 SET 子句不含增量列。"""
  155. class FakeCur:
  156. def __init__(self):
  157. self.executed = []
  158. def execute(self, sql, args=None):
  159. self.executed.append(sql)
  160. def __enter__(self):
  161. return self
  162. def __exit__(self, *args):
  163. return False
  164. @property
  165. def rowcount(self):
  166. return 1
  167. class FakeConn:
  168. def __init__(self, cur):
  169. self._cur = cur
  170. def cursor(self):
  171. return self._cur
  172. def close(self):
  173. pass
  174. cur = FakeCur()
  175. monkeypatch = pytest.MonkeyPatch()
  176. monkeypatch.setattr(mock_db, "_use_db", lambda: FakeConn(cur))
  177. try:
  178. mock_db.update_rows("sim_item", {"itemName": "改后"}, {"bizKey": "K-1"},
  179. "sourceUpdatedAt", advance_increment=False)
  180. finally:
  181. monkeypatch.undo()
  182. assert cur.executed
  183. sql = cur.executed[0]
  184. assert "`itemName`=%s" in sql
  185. assert "sourceUpdatedAt" not in sql # 增量列未被写入
  186. def test_slow_view_ddl_is_controllable():
  187. ddl = mock_db.build_slow_view_ddl("v_slow_sim_item", "sim_item", 0.5)
  188. assert ddl.startswith("CREATE OR REPLACE VIEW `v_slow_sim_item`")
  189. assert "SLEEP(0.5)" in ddl
  190. assert "FROM `sim_item` t" in ddl
  191. def test_drop_sql_targets_simulator_table():
  192. """N1 源表不存在:drop 只在模拟库执行(表名反引号包裹,无库名拼接)。"""
  193. class FakeCur:
  194. def __init__(self):
  195. self.executed = []
  196. def execute(self, sql):
  197. self.executed.append(sql)
  198. def __enter__(self):
  199. return self
  200. def __exit__(self, *args):
  201. return False
  202. class FakeConn:
  203. def __init__(self, cur):
  204. self._cur = cur
  205. def cursor(self):
  206. return self._cur
  207. def close(self):
  208. pass
  209. cur = FakeCur()
  210. monkeypatch = pytest.MonkeyPatch()
  211. monkeypatch.setattr(mock_db, "_use_db", lambda: FakeConn(cur))
  212. try:
  213. mock_db.drop_table("sim_item")
  214. mock_db.drop_view("v_slow_sim_item")
  215. finally:
  216. monkeypatch.undo()
  217. assert cur.executed == ["DROP TABLE IF EXISTS `sim_item`",
  218. "DROP VIEW IF EXISTS `v_slow_sim_item`"]
  219. # ---------------------------------------------------------------------------
  220. # N3:入站客户端异常用例(本地捕获请求验证,不触达 Ai-DOP)
  221. # ---------------------------------------------------------------------------
  222. from clients import inbound_client as ic # noqa: E402
  223. def _fake_urlopen(monkeypatch, captured: list):
  224. class FakeResp:
  225. status = 200
  226. def read(self):
  227. return b'{"code": 0}'
  228. def __enter__(self):
  229. return self
  230. def __exit__(self, *args):
  231. return False
  232. def fake(req, timeout=None):
  233. captured.append({
  234. "url": req.full_url,
  235. "headers": dict(req.headers),
  236. "data": req.data,
  237. })
  238. return FakeResp()
  239. monkeypatch.setattr(ic.urllib.request, "urlopen", fake)
  240. return captured
  241. @pytest.fixture()
  242. def envelope():
  243. return {"data": {"list": [
  244. {"ItemNum": "SIM-1", "Descr": "甲", "sourceUpdatedAt": "2026-10-01T00:00:00"},
  245. {"ItemNum": "SIM-2", "Descr": "乙", "sourceUpdatedAt": "2026-10-02T00:00:00"},
  246. ]}}
  247. def _client():
  248. return ic.InboundClient("http://127.0.0.1:5005", "SIM-ACCESS-KEY", "sim-secret")
  249. def _h(headers: dict, name: str):
  250. """urllib 会把 header 名 title-case 化(X-access-key),大小写不敏感取值。"""
  251. for k, v in headers.items():
  252. if k.lower() == name.lower():
  253. return v
  254. raise KeyError(name)
  255. def test_case_missing_access_key_omits_header(monkeypatch, envelope):
  256. cap = []
  257. _fake_urlopen(monkeypatch, cap)
  258. _client().case_missing_access_key("MDM_ITEM", envelope)
  259. assert not any(k.lower() == "x-access-key" for k in cap[0]["headers"])
  260. def test_case_wrong_access_key_is_self_consistent(monkeypatch, envelope):
  261. cap = []
  262. _fake_urlopen(monkeypatch, cap)
  263. _client().case_wrong_access_key("MDM_ITEM", envelope)
  264. sent = cap[0]
  265. assert _h(sent["headers"], "X-Access-Key") == "SIM-UNKNOWN-ACCESS-KEY"
  266. # 签名自洽:用发送的 key 重算应等于请求头中的签名
  267. ts = _h(sent["headers"], "X-Timestamp")
  268. nonce = _h(sent["headers"], "X-Nonce")
  269. digest = _h(sent["headers"], "X-Content-SHA256")
  270. idem = _h(sent["headers"], "Idempotency-Key")
  271. msg = ic.build_message("POST", "/api/mdp/inbound/MDM_ITEM",
  272. "SIM-UNKNOWN-ACCESS-KEY", ts, nonce, digest, idem)
  273. assert _h(sent["headers"], "X-Signature") == ic.sign("sim-secret", msg)
  274. def test_case_missing_required_field_drops_field(monkeypatch, envelope):
  275. cap = []
  276. _fake_urlopen(monkeypatch, cap)
  277. _client().case_missing_required_field("MDM_ITEM", envelope, "Descr")
  278. body = json.loads(cap[0]["data"].decode("utf-8"))
  279. for row in body["data"]["list"]:
  280. assert "Descr" not in row
  281. assert envelope["data"]["list"][0]["Descr"] == "甲" # 原始信封未被修改
  282. def test_case_wrong_field_type_replaces_value(monkeypatch, envelope):
  283. cap = []
  284. _fake_urlopen(monkeypatch, cap)
  285. _client().case_wrong_field_type("MDM_ITEM", envelope, "Descr", 12345)
  286. body = json.loads(cap[0]["data"].decode("utf-8"))
  287. assert body["data"]["list"][0]["Descr"] == 12345
  288. def test_case_stale_rows_backdates_timestamp(monkeypatch, envelope):
  289. cap = []
  290. _fake_urlopen(monkeypatch, cap)
  291. _client().case_stale_rows("MDM_ITEM", envelope)
  292. body = json.loads(cap[0]["data"].decode("utf-8"))
  293. for row in body["data"]["list"]:
  294. assert row["sourceUpdatedAt"] == "2000-01-01T00:00:00"
  295. def test_case_unknown_entity_targets_unknown_code(monkeypatch, envelope):
  296. cap = []
  297. _fake_urlopen(monkeypatch, cap)
  298. _client().case_unknown_entity(envelope)
  299. assert cap[0]["url"].endswith("/api/mdp/inbound/SIM_UNKNOWN_ENTITY_XX")
  300. def test_case_bulk_partial_failure_drops_one_line_field(monkeypatch, envelope):
  301. cap = []
  302. _fake_urlopen(monkeypatch, cap)
  303. lines = envelope["data"]["list"]
  304. _client().case_bulk_partial_failure("MDM_ITEM", lines, "Descr", bad_idx=1)
  305. assert _h(cap[0]["headers"], "Content-Type") == "application/x-ndjson"
  306. ndjson = cap[0]["data"].decode("utf-8").splitlines()
  307. row0, row1 = json.loads(ndjson[0]), json.loads(ndjson[1])
  308. assert "Descr" in row0 and "Descr" not in row1 # 仅坏行缺字段
  309. def test_case_snapshot_commit_failure_targets_missing_snapshot(monkeypatch):
  310. cap = []
  311. _fake_urlopen(monkeypatch, cap)
  312. _client().case_snapshot_commit_failure("MDM_ITEM")
  313. assert "SNAP-NOT-EXIST-0001" in cap[0]["url"]
  314. # ---------------------------------------------------------------------------
  315. # N4:场景目录、预置/恢复、异常报告(app.py 路由,TestClient 进程内)
  316. # ---------------------------------------------------------------------------
  317. import app # noqa: E402 conftest 已把模拟器根目录加入 sys.path
  318. @pytest.fixture()
  319. def app_client():
  320. return TestClient(app.app)
  321. def test_fault_catalog_matches_taskbook_counts(app_client):
  322. """任务书 §12-§14:甲 10 / 乙 16 / 丙 17(原文清单数),N4 八列。"""
  323. body = app_client.get("/api/fault/catalog").json()
  324. counts = body["counts"]
  325. assert counts == {"DB_SYNC": 10, "API_PULL": 16, "API_INBOUND": 17, "total": 43}
  326. labels = [label for _, label in body["reportFields"]]
  327. assert labels == ["场景", "模拟端行为", "预期结果", "实际响应",
  328. "Ai-DOP 流水状态", "目标数据是否变化", "是否符合预期", "备注"]
  329. # 每个场景都必须带恢复说明(验收:异常可一键或按说明恢复)
  330. for s in body["scenarios"]:
  331. assert s["recover"].strip(), s["id"]
  332. def test_fault_apply_mock_api_and_recover(app_client):
  333. """N2 预置:apply 改内存态,recover 恢复默认。"""
  334. r = app_client.post("/api/fault/apply", json={"scenarioId": "N2-05"})
  335. assert r.status_code == 200 and r.json()["applied"] is True
  336. assert app.MOCK_STATE.settings.force_status == 500
  337. # 恢复默认
  338. r = app_client.post("/api/fault/recover", json={"channel": "API_PULL"})
  339. assert r.status_code == 200
  340. assert app.MOCK_STATE.settings.force_status is None
  341. assert app.MOCK_STATE.settings.empty_response is False
  342. def test_fault_apply_guide_scenario_is_readonly(app_client):
  343. """N1 指引类场景:模拟器不修改 Ai-DOP 配置,返回 guide 说明。"""
  344. r = app_client.post("/api/fault/apply", json={"scenarioId": "N1-01"})
  345. body = r.json()
  346. assert body["applied"] is False and body["guide"] is True
  347. assert "人工" in body["note"]
  348. def test_fault_apply_override_missing_bizkey(app_client):
  349. r = app_client.post("/api/fault/apply", json={"scenarioId": "N2-13"})
  350. assert r.json()["applied"] is True
  351. path = r.json()["overridePath"]
  352. rows = app.MOCK_STATE.overrides[path]
  353. assert rows and all("bizKey" not in row for row in rows)
  354. app_client.post("/api/fault/recover", json={"channel": "API_PULL"})
  355. assert not app.MOCK_STATE.overrides
  356. def test_fault_apply_inbound_without_creds_is_tool_blocked_not_reject(app_client, monkeypatch):
  357. """N3 apply 缺环境变量时:如实透传 400,不得包装成 expectedReject。
  358. 任务书验收要求「正确拒绝与工具失败区分」:
  359. Ai-DOP 预检拦截(409)才算 expectedReject(= 测试通过);
  360. 模拟器自身缺凭据(400)是工具阻塞,请求根本没发出。
  361. """
  362. for name in ("AIDOP_SIM_INBOUND_ACCESS_KEY", "AIDOP_SIM_INBOUND_SECRET"):
  363. monkeypatch.delenv(name, raising=False)
  364. r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-01", "objectCode": "ITEM"})
  365. assert r.status_code == 400
  366. body = r.json()
  367. assert "expectedReject" not in body
  368. assert "缺少环境变量" in body["detail"]
  369. class _FakeInboundClient:
  370. """拦截发往 Ai-DOP 的请求,捕获构造后的信封/块/坏值。"""
  371. def __init__(self, *a, **k):
  372. self.calls = []
  373. def op_push(self, entity, envelope, contract_version=None):
  374. self.calls.append(("push", entity, json.loads(json.dumps(envelope))))
  375. return 202, {"accepted": 2, "rejected": 1}, {"X-Test": "1"}
  376. def op_bulk(self, entity, bulk_rows, contract_version=None):
  377. self.calls.append(("bulk", entity, json.loads(json.dumps(bulk_rows))))
  378. return 202, {"accepted": 1, "rejected": 1}, {"X-Test": "1"}
  379. def case_wrong_field_type(self, entity, envelope, field, bad_value):
  380. self.calls.append(("wrong-field-type", entity, field,
  381. json.loads(json.dumps(bad_value))))
  382. return 400, {"detail": "type fault"}, {}
  383. def _patch_inbound(monkeypatch, fake):
  384. monkeypatch.setenv("AIDOP_SIM_INBOUND_ACCESS_KEY", "TEST-AK")
  385. monkeypatch.setenv("AIDOP_SIM_INBOUND_SECRET", "TEST-SK")
  386. monkeypatch.setattr(app, "InboundClient", lambda *a, **k: fake)
  387. monkeypatch.setattr(app.aidop_probe, "precheck_inbound_entity",
  388. lambda client, entity, allowed_hosts, contract_version=None:
  389. {"configState": "READY", "httpStatus": 200})
  390. def test_fault_apply_partial_reject_duplicates_row_when_single_sample(app_client, monkeypatch):
  391. """N3-12 回归:样例仅 1 行时复制一行再破坏,保证「1 好 1 坏」部分行拒绝可演示。"""
  392. fake = _FakeInboundClient()
  393. _patch_inbound(monkeypatch, fake)
  394. r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-12", "objectCode": "ITEM"})
  395. assert r.status_code == 200, r.text
  396. op, entity, env = fake.calls[0]
  397. assert op == "push" and entity == "MDM_ITEM"
  398. rows = env["data"]["list"]
  399. assert len(rows) == 2
  400. assert "ItemNum" in rows[0]
  401. assert "ItemNum" not in rows[1] # 复制出的坏行缺必填字段
  402. def test_fault_apply_bulk_partial_failure_duplicates_chunk_when_single_row(app_client, monkeypatch):
  403. """N3-16 回归:样例仅 1 行时复制一个块(新幂等键)再破坏,避免单 chunk 全失败退化。"""
  404. fake = _FakeInboundClient()
  405. _patch_inbound(monkeypatch, fake)
  406. r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-16", "objectCode": "ITEM"})
  407. assert r.status_code == 200, r.text
  408. op, entity, chunks = fake.calls[0]
  409. assert op == "bulk" and entity == "MDM_ITEM"
  410. assert len(chunks) == 2
  411. keys = [c["idempotencyKey"] for c in chunks]
  412. assert len(set(keys)) == 2 # 复制的块带新幂等键
  413. assert "ItemNum" in chunks[0]["data"]["list"][0]
  414. assert "ItemNum" not in chunks[1]["data"]["list"][0]
  415. def test_fault_apply_wrong_field_type_uses_structural_hostile_value(app_client, monkeypatch):
  416. """N3-11 回归:坏值必须是结构敌对值(嵌套对象),同类标量可能被宽容转换不触发校验。"""
  417. fake = _FakeInboundClient()
  418. _patch_inbound(monkeypatch, fake)
  419. r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-11", "objectCode": "ITEM"})
  420. assert r.status_code == 200, r.text
  421. op, entity, field, bad = fake.calls[0]
  422. assert op == "wrong-field-type" and entity == "MDM_ITEM" and field == "ItemNum"
  423. assert isinstance(bad, dict) and not isinstance(bad, int)
  424. assert "ItemNum" not in bad
  425. def test_fault_apply_n1_duplicate_bizkey_drops_table_first(app_client, monkeypatch):
  426. """N1-07 回归:表已存在时 CREATE IF NOT EXISTS 不重建,必须先 DROP 才能去掉旧唯一键。
  427. 真实 MySQL 实证过:不先 DROP 会残留 uk_ 唯一索引,重复行插入报 1062。
  428. """
  429. monkeypatch.setenv("AIDOP_SIM_MYSQL_PASSWORD", "x")
  430. calls = []
  431. monkeypatch.setattr(app.mock_db, "drop_table", lambda t: calls.append(("drop", t)))
  432. monkeypatch.setattr(app.mock_db, "init_schema",
  433. lambda tables: calls.append(("init", tables["sim_item"].get("unique_key"))))
  434. monkeypatch.setattr(app.mock_db, "insert_rows_raw",
  435. lambda t, rows: calls.append(("insert", len(rows))))
  436. r = app_client.post("/api/fault/apply", json={"scenarioId": "N1-07", "objectCode": "ITEM"})
  437. assert r.status_code == 200, r.text
  438. assert calls[0] == ("drop", "sim_item")
  439. assert calls[1] == ("init", False) # 无唯一键表
  440. assert calls[2] == ("insert", 2) # 两条相同 bizKey
  441. def test_fault_recover_db_drops_before_rebuild(app_client, monkeypatch):
  442. """恢复回归:N1-07 把表改成无唯一键后,恢复必须先 DROP 再建
  443. (init_schema 的 CREATE IF NOT EXISTS 不会改已有表,不 DROP 结构会残留)。"""
  444. monkeypatch.setenv("AIDOP_SIM_MYSQL_PASSWORD", "x")
  445. calls = []
  446. monkeypatch.setattr(app.mock_db, "drop_table", lambda t: calls.append(("drop", t)))
  447. monkeypatch.setattr(app.mock_db, "init_schema", lambda tables: calls.append(("init", list(tables))))
  448. monkeypatch.setattr(app.mock_db, "upsert_rows", lambda t, rows: calls.append(("upsert", t)))
  449. monkeypatch.setattr(app.mock_db, "drop_view", lambda v: calls.append(("dropview", v)))
  450. r = app_client.post("/api/fault/recover", json={"channel": "DB_SYNC"})
  451. assert r.status_code == 200, r.text
  452. idx = calls.index(("drop", "sim_item"))
  453. assert calls[idx + 1][0] == "init" # drop 之后才建表
  454. assert "upsert" in [c[0] for c in calls] # 样例行装载
  455. assert ("dropview", "v_slow_sim_item") in calls
  456. def test_fault_report_save_list_export_redacted(app_client, monkeypatch):
  457. """N4 报告八列 + 脱敏导出。"""
  458. monkeypatch.setenv("AIDOP_SIM_INBOUND_ACCESS_KEY", "AK-REAL-SECRET-KEY")
  459. r = app_client.post("/api/fault-report/save", json={
  460. "scenarioId": "N3-02", "simBehavior": "AccessKey=AK-REAL-SECRET-KEY",
  461. "expected": "拒绝", "actualResponse": "401", "aidopFlowStatus": "无流水",
  462. "targetChanged": "否", "pass": "是", "note": "正确拒绝"})
  463. assert r.status_code == 200, r.text
  464. report = r.json()["report"]
  465. assert report["channel"] == "API_INBOUND"
  466. assert report["scenarioTitle"] == "错误 AccessKey"
  467. # 脱敏:AccessKey 只保留后四位
  468. assert "AK-REAL-SECRET-KEY" not in report["simBehavior"]
  469. assert report["simBehavior"].endswith("-KEY")
  470. # 列表与导出
  471. lst = app_client.get("/api/fault-report/list").json()["reports"]
  472. assert any(item["id"] == report["id"] for item in lst)
  473. md = app_client.get(f"/api/fault-report/export/{report['id']}")
  474. assert md.status_code == 200
  475. assert "异常测试报告" in md.text
  476. assert "| 场景 |" in md.text and "| 备注 |" in md.text
  477. def test_fault_report_rejects_unknown_scenario(app_client):
  478. r = app_client.post("/api/fault-report/save", json={"scenarioId": "N9-99"})
  479. assert r.status_code == 400
  480. def test_fault_report_save_same_scenario_replaces_in_place(app_client, monkeypatch):
  481. """N4 回归:同场景重复保存 = 原位更新(保留原 id),列表不产生重复行。"""
  482. monkeypatch.setenv("AIDOP_SIM_INBOUND_ACCESS_KEY", "AK-TEST")
  483. base = {"scenarioId": "N3-13", "simBehavior": "v1", "expected": "拒绝",
  484. "actualResponse": "400", "aidopFlowStatus": "无流水",
  485. "targetChanged": "否", "pass": "是", "note": "第一版"}
  486. r1 = app_client.post("/api/fault-report/save", json=base).json()
  487. base["note"] = "第二版"
  488. r2 = app_client.post("/api/fault-report/save", json=base).json()
  489. rep = r2["report"]
  490. assert rep["replaced"] is True
  491. assert rep["id"] == r1["report"]["id"] # 原位更新:id 不变
  492. rows = [x for x in app_client.get("/api/fault-report/list").json()["reports"]
  493. if x["scenarioId"] == "N3-13"]
  494. assert len(rows) == 1
  495. assert rows[0]["note"] == "第二版"
  496. def test_fault_report_save_distinct_scenarios_stay_independent(app_client):
  497. """N4 回归:不同场景各自一条,互不覆盖。"""
  498. for sid, note in (("N3-14", "甲"), ("N3-15", "乙")):
  499. r = app_client.post("/api/fault-report/save", json={
  500. "scenarioId": sid, "simBehavior": "x", "expected": "拒绝",
  501. "actualResponse": "403", "aidopFlowStatus": "无流水",
  502. "targetChanged": "否", "pass": "是", "note": note})
  503. assert r.status_code == 200
  504. assert r.json()["report"]["replaced"] is False
  505. rows = app_client.get("/api/fault-report/list").json()["reports"]
  506. assert [x for x in rows if x["scenarioId"] == "N3-14"][0]["note"] == "甲"
  507. assert [x for x in rows if x["scenarioId"] == "N3-15"][0]["note"] == "乙"