|
@@ -0,0 +1,649 @@
|
|
|
|
|
+"""第二阶段(N1-N4)异常测试能力守卫。
|
|
|
|
|
+
|
|
|
|
|
+- N2:Mock API 异常注入模式(超时/空响应/非法 JSON/Content-Type/cursor 行为/恢复默认)
|
|
|
|
|
+- N1:模拟库异常能力(drop 表/无唯一键重复装载/增量列不推进/慢查询视图 DDL)
|
|
|
|
|
+- N3:入站客户端异常用例(缺/错 AccessKey、缺字段、类型错误、stale、snapshot 失败)
|
|
|
|
|
+- N4:异常测试报告(字段覆盖任务书 §15 八列、脱敏、导出)
|
|
|
|
|
+
|
|
|
|
|
+全部进程内验证(TestClient/纯函数),不依赖 Ai-DOP 在线与真实 MySQL。
|
|
|
|
|
+"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+fastapi_testclient = pytest.importorskip("fastapi.testclient")
|
|
|
|
|
+TestClient = fastapi_testclient.TestClient
|
|
|
|
|
+
|
|
|
|
|
+from providers import mock_api # noqa: E402
|
|
|
|
|
+from providers import mock_db # noqa: E402
|
|
|
|
|
+from catalog import sample_adapters as sa # noqa: E402
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# N2:Mock API 异常模式
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture()
|
|
|
|
|
+def client():
|
|
|
|
|
+ state = mock_api.MockApiState()
|
|
|
|
|
+ state.settings.auth_mode = "NONE"
|
|
|
|
|
+ return TestClient(mock_api.create_app(state)), state
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _set(client, **kw):
|
|
|
|
|
+ c, state = client
|
|
|
|
|
+ for key, val in kw.items():
|
|
|
|
|
+ setattr(state.settings, key, val)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_force_status_covers_n2_codes(client):
|
|
|
|
|
+ """N2 要求 401/403/404/429/500/503:force_status 须对全部六类生效。"""
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ for code in (401, 403, 404, 429, 500, 503):
|
|
|
|
|
+ _set(client, force_status=code)
|
|
|
|
|
+ assert c.get("/api/item").status_code == code, code
|
|
|
|
|
+ _set(client, force_status=None)
|
|
|
|
|
+ assert c.get("/api/item").status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_empty_response_mode(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ _set(client, empty_response=True)
|
|
|
|
|
+ resp = c.get("/api/item")
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ assert resp.content == b""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_invalid_json_mode(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ _set(client, invalid_json=True)
|
|
|
|
|
+ resp = c.get("/api/item")
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ with pytest.raises(Exception):
|
|
|
|
|
+ resp.json() # 无法解析为 JSON
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_wrong_content_type_mode(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ _set(client, content_type="text/html")
|
|
|
|
|
+ resp = c.get("/api/item")
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ assert resp.headers["content-type"].startswith("text/html")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_timeout_mode_sleeps_configured_seconds(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ _set(client, timeout_seconds=0.3)
|
|
|
|
|
+ import time
|
|
|
|
|
+ started = time.time()
|
|
|
|
|
+ resp = c.get("/api/item")
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ assert time.time() - started >= 0.3
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_ignore_cursor_returns_all_rows(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {"bizKey": "K-1", "v": 1}, {"bizKey": "K-2", "v": 2}, {"bizKey": "K-3", "v": 3},
|
|
|
|
|
+ ]
|
|
|
|
|
+ c.post("/__control/overrides", json={"path": "/api/order-test", "rows": rows})
|
|
|
|
|
+ # 默认 cursor 过滤:只返回更大行
|
|
|
|
|
+ after = c.get("/api/order-test", params={"cursor": "K-1"}).json()["data"]["list"]
|
|
|
|
|
+ assert [r["bizKey"] for r in after] == ["K-2", "K-3"]
|
|
|
|
|
+ # ignore_cursor:无视 cursor 返回全部行(cursor 不推进观察用)
|
|
|
|
|
+ _set(client, ignore_cursor=True)
|
|
|
|
|
+ after = c.get("/api/order-test", params={"cursor": "K-1"}).json()["data"]["list"]
|
|
|
|
|
+ assert [r["bizKey"] for r in after] == ["K-1", "K-2", "K-3"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_no_sort_keeps_original_order(client):
|
|
|
|
|
+ c, state = client
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {"bizKey": "Z-2"}, {"bizKey": "A-1"}, {"bizKey": "M-3"},
|
|
|
|
|
+ ]
|
|
|
|
|
+ c.post("/__control/overrides", json={"path": "/api/order-test", "rows": rows})
|
|
|
|
|
+ # 默认排序
|
|
|
|
|
+ got = [r["bizKey"] for r in c.get("/api/order-test").json()["data"]["list"]]
|
|
|
|
|
+ assert got == ["A-1", "M-3", "Z-2"]
|
|
|
|
|
+ # no_sort 保持挂载原顺序(cursor 乱序观察用)
|
|
|
|
|
+ _set(client, no_sort=True)
|
|
|
|
|
+ got = [r["bizKey"] for r in c.get("/api/order-test").json()["data"]["list"]]
|
|
|
|
|
+ assert got == ["Z-2", "A-1", "M-3"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_reset_faults_clears_all_injections(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ _set(client, force_status=500, empty_response=True, invalid_json=True,
|
|
|
|
|
+ content_type="text/html", ignore_cursor=True, no_sort=True,
|
|
|
|
|
+ timeout_seconds=1.0, delay_seconds=0.5)
|
|
|
|
|
+ resp = c.post("/__control/settings", json={"reset": True}).json()
|
|
|
|
|
+ assert resp["forceStatus"] is None
|
|
|
|
|
+ assert resp["emptyResponse"] is False
|
|
|
|
|
+ assert resp["invalidJson"] is False
|
|
|
|
|
+ assert resp["contentType"] is None
|
|
|
|
|
+ assert resp["ignoreCursor"] is False
|
|
|
|
|
+ assert resp["noSort"] is False
|
|
|
|
|
+ assert resp["timeoutSeconds"] == 0.0
|
|
|
|
|
+ assert resp["delaySeconds"] == 0.0
|
|
|
|
|
+ assert resp["authMode"] == "TOKEN" # 鉴权模式一并恢复默认
|
|
|
|
|
+ # 恢复后正常拉取可用
|
|
|
|
|
+ _set(client, auth_mode="NONE")
|
|
|
|
|
+ assert c.get("/api/item").status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_audit_records_active_faults(client):
|
|
|
|
|
+ c, _ = client
|
|
|
|
|
+ _set(client, empty_response=True, ignore_cursor=True)
|
|
|
|
|
+ c.get("/api/item")
|
|
|
|
|
+ entries = c.get("/__control/audit").json()["entries"]
|
|
|
|
|
+ assert entries, "audit should record the request"
|
|
|
|
|
+ faults = entries[-1]["faults"]
|
|
|
|
|
+ assert "emptyResponse" in faults
|
|
|
|
|
+ assert "ignoreCursor" in faults
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# N1:模拟库异常能力(纯函数层;真实 MySQL 用例沿用 test_mock_db.py 的跳过约定)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+RUN_ID = "20260930120000-abc123"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_ddl_without_unique_key_for_duplicate_bizkey():
|
|
|
|
|
+ rows = sa.adapt_db_rows(sa.find_object("ITEM"), RUN_ID)
|
|
|
|
|
+ ddl = mock_db.generate_ddl("sim_item", rows, ["bizKey"], "sourceUpdatedAt",
|
|
|
|
|
+ unique_key=False)
|
|
|
|
|
+ assert "UNIQUE KEY" not in ddl # 重复业务键场景:无唯一索引,允许重复行共存
|
|
|
|
|
+ assert "CREATE TABLE IF NOT EXISTS `sim_item`" in ddl
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_raw_insert_sql_has_no_duplicate_merge():
|
|
|
|
|
+ """N1 重复业务键:原始 INSERT 不合并(与 upsert 的 ON DUPLICATE KEY 不同)。"""
|
|
|
|
|
+
|
|
|
|
|
+ class FakeCur:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.executed = []
|
|
|
|
|
+
|
|
|
|
|
+ def executemany(self, sql, rows):
|
|
|
|
|
+ self.executed.append(sql)
|
|
|
|
|
+
|
|
|
|
|
+ def __enter__(self):
|
|
|
|
|
+ return self
|
|
|
|
|
+
|
|
|
|
|
+ def __exit__(self, *args):
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ class FakeConn:
|
|
|
|
|
+ def __init__(self, cur):
|
|
|
|
|
+ self._cur = cur
|
|
|
|
|
+
|
|
|
|
|
+ def cursor(self):
|
|
|
|
|
+ return self._cur
|
|
|
|
|
+
|
|
|
|
|
+ def close(self):
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ cur = FakeCur()
|
|
|
|
|
+ monkeypatch = pytest.MonkeyPatch()
|
|
|
|
|
+ monkeypatch.setattr(mock_db, "_use_db", lambda: FakeConn(cur))
|
|
|
|
|
+ try:
|
|
|
|
|
+ mock_db.insert_rows_raw("sim_item", [{"bizKey": "K-1", "v": 1},
|
|
|
|
|
+ {"bizKey": "K-1", "v": 2}])
|
|
|
|
|
+ finally:
|
|
|
|
|
+ monkeypatch.undo()
|
|
|
|
|
+ assert cur.executed and "ON DUPLICATE KEY" not in cur.executed[0]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_update_rows_can_skip_increment_advance():
|
|
|
|
|
+ """N1 增量列不推进:advance_increment=False 时 SET 子句不含增量列。"""
|
|
|
|
|
+
|
|
|
|
|
+ class FakeCur:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.executed = []
|
|
|
|
|
+
|
|
|
|
|
+ def execute(self, sql, args=None):
|
|
|
|
|
+ self.executed.append(sql)
|
|
|
|
|
+
|
|
|
|
|
+ def __enter__(self):
|
|
|
|
|
+ return self
|
|
|
|
|
+
|
|
|
|
|
+ def __exit__(self, *args):
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def rowcount(self):
|
|
|
|
|
+ return 1
|
|
|
|
|
+
|
|
|
|
|
+ class FakeConn:
|
|
|
|
|
+ def __init__(self, cur):
|
|
|
|
|
+ self._cur = cur
|
|
|
|
|
+
|
|
|
|
|
+ def cursor(self):
|
|
|
|
|
+ return self._cur
|
|
|
|
|
+
|
|
|
|
|
+ def close(self):
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ cur = FakeCur()
|
|
|
|
|
+ monkeypatch = pytest.MonkeyPatch()
|
|
|
|
|
+ monkeypatch.setattr(mock_db, "_use_db", lambda: FakeConn(cur))
|
|
|
|
|
+ try:
|
|
|
|
|
+ mock_db.update_rows("sim_item", {"itemName": "改后"}, {"bizKey": "K-1"},
|
|
|
|
|
+ "sourceUpdatedAt", advance_increment=False)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ monkeypatch.undo()
|
|
|
|
|
+ assert cur.executed
|
|
|
|
|
+ sql = cur.executed[0]
|
|
|
|
|
+ assert "`itemName`=%s" in sql
|
|
|
|
|
+ assert "sourceUpdatedAt" not in sql # 增量列未被写入
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_slow_view_ddl_is_controllable():
|
|
|
|
|
+ ddl = mock_db.build_slow_view_ddl("v_slow_sim_item", "sim_item", 0.5)
|
|
|
|
|
+ assert ddl.startswith("CREATE OR REPLACE VIEW `v_slow_sim_item`")
|
|
|
|
|
+ assert "SLEEP(0.5)" in ddl
|
|
|
|
|
+ assert "FROM `sim_item` t" in ddl
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_drop_sql_targets_simulator_table():
|
|
|
|
|
+ """N1 源表不存在:drop 只在模拟库执行(表名反引号包裹,无库名拼接)。"""
|
|
|
|
|
+
|
|
|
|
|
+ class FakeCur:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.executed = []
|
|
|
|
|
+
|
|
|
|
|
+ def execute(self, sql):
|
|
|
|
|
+ self.executed.append(sql)
|
|
|
|
|
+
|
|
|
|
|
+ def __enter__(self):
|
|
|
|
|
+ return self
|
|
|
|
|
+
|
|
|
|
|
+ def __exit__(self, *args):
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ class FakeConn:
|
|
|
|
|
+ def __init__(self, cur):
|
|
|
|
|
+ self._cur = cur
|
|
|
|
|
+
|
|
|
|
|
+ def cursor(self):
|
|
|
|
|
+ return self._cur
|
|
|
|
|
+
|
|
|
|
|
+ def close(self):
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ cur = FakeCur()
|
|
|
|
|
+ monkeypatch = pytest.MonkeyPatch()
|
|
|
|
|
+ monkeypatch.setattr(mock_db, "_use_db", lambda: FakeConn(cur))
|
|
|
|
|
+ try:
|
|
|
|
|
+ mock_db.drop_table("sim_item")
|
|
|
|
|
+ mock_db.drop_view("v_slow_sim_item")
|
|
|
|
|
+ finally:
|
|
|
|
|
+ monkeypatch.undo()
|
|
|
|
|
+ assert cur.executed == ["DROP TABLE IF EXISTS `sim_item`",
|
|
|
|
|
+ "DROP VIEW IF EXISTS `v_slow_sim_item`"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# N3:入站客户端异常用例(本地捕获请求验证,不触达 Ai-DOP)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+from clients import inbound_client as ic # noqa: E402
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fake_urlopen(monkeypatch, captured: list):
|
|
|
|
|
+ class FakeResp:
|
|
|
|
|
+ status = 200
|
|
|
|
|
+
|
|
|
|
|
+ def read(self):
|
|
|
|
|
+ return b'{"code": 0}'
|
|
|
|
|
+
|
|
|
|
|
+ def __enter__(self):
|
|
|
|
|
+ return self
|
|
|
|
|
+
|
|
|
|
|
+ def __exit__(self, *args):
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ def fake(req, timeout=None):
|
|
|
|
|
+ captured.append({
|
|
|
|
|
+ "url": req.full_url,
|
|
|
|
|
+ "headers": dict(req.headers),
|
|
|
|
|
+ "data": req.data,
|
|
|
|
|
+ })
|
|
|
|
|
+ return FakeResp()
|
|
|
|
|
+
|
|
|
|
|
+ monkeypatch.setattr(ic.urllib.request, "urlopen", fake)
|
|
|
|
|
+ return captured
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture()
|
|
|
|
|
+def envelope():
|
|
|
|
|
+ return {"data": {"list": [
|
|
|
|
|
+ {"ItemNum": "SIM-1", "Descr": "甲", "sourceUpdatedAt": "2026-10-01T00:00:00"},
|
|
|
|
|
+ {"ItemNum": "SIM-2", "Descr": "乙", "sourceUpdatedAt": "2026-10-02T00:00:00"},
|
|
|
|
|
+ ]}}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _client():
|
|
|
|
|
+ return ic.InboundClient("http://127.0.0.1:5005", "SIM-ACCESS-KEY", "sim-secret")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _h(headers: dict, name: str):
|
|
|
|
|
+ """urllib 会把 header 名 title-case 化(X-access-key),大小写不敏感取值。"""
|
|
|
|
|
+ for k, v in headers.items():
|
|
|
|
|
+ if k.lower() == name.lower():
|
|
|
|
|
+ return v
|
|
|
|
|
+ raise KeyError(name)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_missing_access_key_omits_header(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_missing_access_key("MDM_ITEM", envelope)
|
|
|
|
|
+ assert not any(k.lower() == "x-access-key" for k in cap[0]["headers"])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_wrong_access_key_is_self_consistent(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_wrong_access_key("MDM_ITEM", envelope)
|
|
|
|
|
+ sent = cap[0]
|
|
|
|
|
+ assert _h(sent["headers"], "X-Access-Key") == "SIM-UNKNOWN-ACCESS-KEY"
|
|
|
|
|
+ # 签名自洽:用发送的 key 重算应等于请求头中的签名
|
|
|
|
|
+ ts = _h(sent["headers"], "X-Timestamp")
|
|
|
|
|
+ nonce = _h(sent["headers"], "X-Nonce")
|
|
|
|
|
+ digest = _h(sent["headers"], "X-Content-SHA256")
|
|
|
|
|
+ idem = _h(sent["headers"], "Idempotency-Key")
|
|
|
|
|
+ msg = ic.build_message("POST", "/api/mdp/inbound/MDM_ITEM",
|
|
|
|
|
+ "SIM-UNKNOWN-ACCESS-KEY", ts, nonce, digest, idem)
|
|
|
|
|
+ assert _h(sent["headers"], "X-Signature") == ic.sign("sim-secret", msg)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_missing_required_field_drops_field(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_missing_required_field("MDM_ITEM", envelope, "Descr")
|
|
|
|
|
+ body = json.loads(cap[0]["data"].decode("utf-8"))
|
|
|
|
|
+ for row in body["data"]["list"]:
|
|
|
|
|
+ assert "Descr" not in row
|
|
|
|
|
+ assert envelope["data"]["list"][0]["Descr"] == "甲" # 原始信封未被修改
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_wrong_field_type_replaces_value(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_wrong_field_type("MDM_ITEM", envelope, "Descr", 12345)
|
|
|
|
|
+ body = json.loads(cap[0]["data"].decode("utf-8"))
|
|
|
|
|
+ assert body["data"]["list"][0]["Descr"] == 12345
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_stale_rows_backdates_timestamp(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_stale_rows("MDM_ITEM", envelope)
|
|
|
|
|
+ body = json.loads(cap[0]["data"].decode("utf-8"))
|
|
|
|
|
+ for row in body["data"]["list"]:
|
|
|
|
|
+ assert row["sourceUpdatedAt"] == "2000-01-01T00:00:00"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_unknown_entity_targets_unknown_code(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_unknown_entity(envelope)
|
|
|
|
|
+ assert cap[0]["url"].endswith("/api/mdp/inbound/SIM_UNKNOWN_ENTITY_XX")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_bulk_partial_failure_drops_one_line_field(monkeypatch, envelope):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ lines = envelope["data"]["list"]
|
|
|
|
|
+ _client().case_bulk_partial_failure("MDM_ITEM", lines, "Descr", bad_idx=1)
|
|
|
|
|
+ assert _h(cap[0]["headers"], "Content-Type") == "application/x-ndjson"
|
|
|
|
|
+ ndjson = cap[0]["data"].decode("utf-8").splitlines()
|
|
|
|
|
+ row0, row1 = json.loads(ndjson[0]), json.loads(ndjson[1])
|
|
|
|
|
+ assert "Descr" in row0 and "Descr" not in row1 # 仅坏行缺字段
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_case_snapshot_commit_failure_targets_missing_snapshot(monkeypatch):
|
|
|
|
|
+ cap = []
|
|
|
|
|
+ _fake_urlopen(monkeypatch, cap)
|
|
|
|
|
+ _client().case_snapshot_commit_failure("MDM_ITEM")
|
|
|
|
|
+ assert "SNAP-NOT-EXIST-0001" in cap[0]["url"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# N4:场景目录、预置/恢复、异常报告(app.py 路由,TestClient 进程内)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+import app # noqa: E402 conftest 已把模拟器根目录加入 sys.path
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture()
|
|
|
|
|
+def app_client():
|
|
|
|
|
+ return TestClient(app.app)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_catalog_matches_taskbook_counts(app_client):
|
|
|
|
|
+ """任务书 §12-§14:甲 10 / 乙 16 / 丙 17(原文清单数),N4 八列。"""
|
|
|
|
|
+ body = app_client.get("/api/fault/catalog").json()
|
|
|
|
|
+ counts = body["counts"]
|
|
|
|
|
+ assert counts == {"DB_SYNC": 10, "API_PULL": 16, "API_INBOUND": 17, "total": 43}
|
|
|
|
|
+ labels = [label for _, label in body["reportFields"]]
|
|
|
|
|
+ assert labels == ["场景", "模拟端行为", "预期结果", "实际响应",
|
|
|
|
|
+ "Ai-DOP 流水状态", "目标数据是否变化", "是否符合预期", "备注"]
|
|
|
|
|
+ # 每个场景都必须带恢复说明(验收:异常可一键或按说明恢复)
|
|
|
|
|
+ for s in body["scenarios"]:
|
|
|
|
|
+ assert s["recover"].strip(), s["id"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_mock_api_and_recover(app_client):
|
|
|
|
|
+ """N2 预置:apply 改内存态,recover 恢复默认。"""
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N2-05"})
|
|
|
|
|
+ assert r.status_code == 200 and r.json()["applied"] is True
|
|
|
|
|
+ assert app.MOCK_STATE.settings.force_status == 500
|
|
|
|
|
+ # 恢复默认
|
|
|
|
|
+ r = app_client.post("/api/fault/recover", json={"channel": "API_PULL"})
|
|
|
|
|
+ assert r.status_code == 200
|
|
|
|
|
+ assert app.MOCK_STATE.settings.force_status is None
|
|
|
|
|
+ assert app.MOCK_STATE.settings.empty_response is False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_guide_scenario_is_readonly(app_client):
|
|
|
|
|
+ """N1 指引类场景:模拟器不修改 Ai-DOP 配置,返回 guide 说明。"""
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N1-01"})
|
|
|
|
|
+ body = r.json()
|
|
|
|
|
+ assert body["applied"] is False and body["guide"] is True
|
|
|
|
|
+ assert "人工" in body["note"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_override_missing_bizkey(app_client):
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N2-13"})
|
|
|
|
|
+ assert r.json()["applied"] is True
|
|
|
|
|
+ path = r.json()["overridePath"]
|
|
|
|
|
+ rows = app.MOCK_STATE.overrides[path]
|
|
|
|
|
+ assert rows and all("bizKey" not in row for row in rows)
|
|
|
|
|
+ app_client.post("/api/fault/recover", json={"channel": "API_PULL"})
|
|
|
|
|
+ assert not app.MOCK_STATE.overrides
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_inbound_without_creds_is_tool_blocked_not_reject(app_client, monkeypatch):
|
|
|
|
|
+ """N3 apply 缺环境变量时:如实透传 400,不得包装成 expectedReject。
|
|
|
|
|
+
|
|
|
|
|
+ 任务书验收要求「正确拒绝与工具失败区分」:
|
|
|
|
|
+ Ai-DOP 预检拦截(409)才算 expectedReject(= 测试通过);
|
|
|
|
|
+ 模拟器自身缺凭据(400)是工具阻塞,请求根本没发出。
|
|
|
|
|
+ """
|
|
|
|
|
+ for name in ("AIDOP_SIM_INBOUND_ACCESS_KEY", "AIDOP_SIM_INBOUND_SECRET"):
|
|
|
|
|
+ monkeypatch.delenv(name, raising=False)
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-01", "objectCode": "ITEM"})
|
|
|
|
|
+ assert r.status_code == 400
|
|
|
|
|
+ body = r.json()
|
|
|
|
|
+ assert "expectedReject" not in body
|
|
|
|
|
+ assert "缺少环境变量" in body["detail"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _FakeInboundClient:
|
|
|
|
|
+ """拦截发往 Ai-DOP 的请求,捕获构造后的信封/块/坏值。"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, *a, **k):
|
|
|
|
|
+ self.calls = []
|
|
|
|
|
+
|
|
|
|
|
+ def op_push(self, entity, envelope, contract_version=None):
|
|
|
|
|
+ self.calls.append(("push", entity, json.loads(json.dumps(envelope))))
|
|
|
|
|
+ return 202, {"accepted": 2, "rejected": 1}, {"X-Test": "1"}
|
|
|
|
|
+
|
|
|
|
|
+ def op_bulk(self, entity, bulk_rows, contract_version=None):
|
|
|
|
|
+ self.calls.append(("bulk", entity, json.loads(json.dumps(bulk_rows))))
|
|
|
|
|
+ return 202, {"accepted": 1, "rejected": 1}, {"X-Test": "1"}
|
|
|
|
|
+
|
|
|
|
|
+ def case_wrong_field_type(self, entity, envelope, field, bad_value):
|
|
|
|
|
+ self.calls.append(("wrong-field-type", entity, field,
|
|
|
|
|
+ json.loads(json.dumps(bad_value))))
|
|
|
|
|
+ return 400, {"detail": "type fault"}, {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _patch_inbound(monkeypatch, fake):
|
|
|
|
|
+ monkeypatch.setenv("AIDOP_SIM_INBOUND_ACCESS_KEY", "TEST-AK")
|
|
|
|
|
+ monkeypatch.setenv("AIDOP_SIM_INBOUND_SECRET", "TEST-SK")
|
|
|
|
|
+ monkeypatch.setattr(app, "InboundClient", lambda *a, **k: fake)
|
|
|
|
|
+ monkeypatch.setattr(app.aidop_probe, "precheck_inbound_entity",
|
|
|
|
|
+ lambda client, entity, allowed_hosts, contract_version=None:
|
|
|
|
|
+ {"configState": "READY", "httpStatus": 200})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_partial_reject_duplicates_row_when_single_sample(app_client, monkeypatch):
|
|
|
|
|
+ """N3-12 回归:样例仅 1 行时复制一行再破坏,保证「1 好 1 坏」部分行拒绝可演示。"""
|
|
|
|
|
+ fake = _FakeInboundClient()
|
|
|
|
|
+ _patch_inbound(monkeypatch, fake)
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-12", "objectCode": "ITEM"})
|
|
|
|
|
+ assert r.status_code == 200, r.text
|
|
|
|
|
+ op, entity, env = fake.calls[0]
|
|
|
|
|
+ assert op == "push" and entity == "MDM_ITEM"
|
|
|
|
|
+ rows = env["data"]["list"]
|
|
|
|
|
+ assert len(rows) == 2
|
|
|
|
|
+ assert "ItemNum" in rows[0]
|
|
|
|
|
+ assert "ItemNum" not in rows[1] # 复制出的坏行缺必填字段
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_bulk_partial_failure_duplicates_chunk_when_single_row(app_client, monkeypatch):
|
|
|
|
|
+ """N3-16 回归:样例仅 1 行时复制一个块(新幂等键)再破坏,避免单 chunk 全失败退化。"""
|
|
|
|
|
+ fake = _FakeInboundClient()
|
|
|
|
|
+ _patch_inbound(monkeypatch, fake)
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-16", "objectCode": "ITEM"})
|
|
|
|
|
+ assert r.status_code == 200, r.text
|
|
|
|
|
+ op, entity, chunks = fake.calls[0]
|
|
|
|
|
+ assert op == "bulk" and entity == "MDM_ITEM"
|
|
|
|
|
+ assert len(chunks) == 2
|
|
|
|
|
+ keys = [c["idempotencyKey"] for c in chunks]
|
|
|
|
|
+ assert len(set(keys)) == 2 # 复制的块带新幂等键
|
|
|
|
|
+ assert "ItemNum" in chunks[0]["data"]["list"][0]
|
|
|
|
|
+ assert "ItemNum" not in chunks[1]["data"]["list"][0]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_wrong_field_type_uses_structural_hostile_value(app_client, monkeypatch):
|
|
|
|
|
+ """N3-11 回归:坏值必须是结构敌对值(嵌套对象),同类标量可能被宽容转换不触发校验。"""
|
|
|
|
|
+ fake = _FakeInboundClient()
|
|
|
|
|
+ _patch_inbound(monkeypatch, fake)
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N3-11", "objectCode": "ITEM"})
|
|
|
|
|
+ assert r.status_code == 200, r.text
|
|
|
|
|
+ op, entity, field, bad = fake.calls[0]
|
|
|
|
|
+ assert op == "wrong-field-type" and entity == "MDM_ITEM" and field == "ItemNum"
|
|
|
|
|
+ assert isinstance(bad, dict) and not isinstance(bad, int)
|
|
|
|
|
+ assert "ItemNum" not in bad
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_apply_n1_duplicate_bizkey_drops_table_first(app_client, monkeypatch):
|
|
|
|
|
+ """N1-07 回归:表已存在时 CREATE IF NOT EXISTS 不重建,必须先 DROP 才能去掉旧唯一键。
|
|
|
|
|
+
|
|
|
|
|
+ 真实 MySQL 实证过:不先 DROP 会残留 uk_ 唯一索引,重复行插入报 1062。
|
|
|
|
|
+ """
|
|
|
|
|
+ monkeypatch.setenv("AIDOP_SIM_MYSQL_PASSWORD", "x")
|
|
|
|
|
+ calls = []
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "drop_table", lambda t: calls.append(("drop", t)))
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "init_schema",
|
|
|
|
|
+ lambda tables: calls.append(("init", tables["sim_item"].get("unique_key"))))
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "insert_rows_raw",
|
|
|
|
|
+ lambda t, rows: calls.append(("insert", len(rows))))
|
|
|
|
|
+ r = app_client.post("/api/fault/apply", json={"scenarioId": "N1-07", "objectCode": "ITEM"})
|
|
|
|
|
+ assert r.status_code == 200, r.text
|
|
|
|
|
+ assert calls[0] == ("drop", "sim_item")
|
|
|
|
|
+ assert calls[1] == ("init", False) # 无唯一键表
|
|
|
|
|
+ assert calls[2] == ("insert", 2) # 两条相同 bizKey
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_recover_db_drops_before_rebuild(app_client, monkeypatch):
|
|
|
|
|
+ """恢复回归:N1-07 把表改成无唯一键后,恢复必须先 DROP 再建
|
|
|
|
|
+ (init_schema 的 CREATE IF NOT EXISTS 不会改已有表,不 DROP 结构会残留)。"""
|
|
|
|
|
+ monkeypatch.setenv("AIDOP_SIM_MYSQL_PASSWORD", "x")
|
|
|
|
|
+ calls = []
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "drop_table", lambda t: calls.append(("drop", t)))
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "init_schema", lambda tables: calls.append(("init", list(tables))))
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "upsert_rows", lambda t, rows: calls.append(("upsert", t)))
|
|
|
|
|
+ monkeypatch.setattr(app.mock_db, "drop_view", lambda v: calls.append(("dropview", v)))
|
|
|
|
|
+ r = app_client.post("/api/fault/recover", json={"channel": "DB_SYNC"})
|
|
|
|
|
+ assert r.status_code == 200, r.text
|
|
|
|
|
+ idx = calls.index(("drop", "sim_item"))
|
|
|
|
|
+ assert calls[idx + 1][0] == "init" # drop 之后才建表
|
|
|
|
|
+ assert "upsert" in [c[0] for c in calls] # 样例行装载
|
|
|
|
|
+ assert ("dropview", "v_slow_sim_item") in calls
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_report_save_list_export_redacted(app_client, monkeypatch):
|
|
|
|
|
+ """N4 报告八列 + 脱敏导出。"""
|
|
|
|
|
+ monkeypatch.setenv("AIDOP_SIM_INBOUND_ACCESS_KEY", "AK-REAL-SECRET-KEY")
|
|
|
|
|
+ r = app_client.post("/api/fault-report/save", json={
|
|
|
|
|
+ "scenarioId": "N3-02", "simBehavior": "AccessKey=AK-REAL-SECRET-KEY",
|
|
|
|
|
+ "expected": "拒绝", "actualResponse": "401", "aidopFlowStatus": "无流水",
|
|
|
|
|
+ "targetChanged": "否", "pass": "是", "note": "正确拒绝"})
|
|
|
|
|
+ assert r.status_code == 200, r.text
|
|
|
|
|
+ report = r.json()["report"]
|
|
|
|
|
+ assert report["channel"] == "API_INBOUND"
|
|
|
|
|
+ assert report["scenarioTitle"] == "错误 AccessKey"
|
|
|
|
|
+ # 脱敏:AccessKey 只保留后四位
|
|
|
|
|
+ assert "AK-REAL-SECRET-KEY" not in report["simBehavior"]
|
|
|
|
|
+ assert report["simBehavior"].endswith("-KEY")
|
|
|
|
|
+ # 列表与导出
|
|
|
|
|
+ lst = app_client.get("/api/fault-report/list").json()["reports"]
|
|
|
|
|
+ assert any(item["id"] == report["id"] for item in lst)
|
|
|
|
|
+ md = app_client.get(f"/api/fault-report/export/{report['id']}")
|
|
|
|
|
+ assert md.status_code == 200
|
|
|
|
|
+ assert "异常测试报告" in md.text
|
|
|
|
|
+ assert "| 场景 |" in md.text and "| 备注 |" in md.text
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_report_rejects_unknown_scenario(app_client):
|
|
|
|
|
+ r = app_client.post("/api/fault-report/save", json={"scenarioId": "N9-99"})
|
|
|
|
|
+ assert r.status_code == 400
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_report_save_same_scenario_replaces_in_place(app_client, monkeypatch):
|
|
|
|
|
+ """N4 回归:同场景重复保存 = 原位更新(保留原 id),列表不产生重复行。"""
|
|
|
|
|
+ monkeypatch.setenv("AIDOP_SIM_INBOUND_ACCESS_KEY", "AK-TEST")
|
|
|
|
|
+ base = {"scenarioId": "N3-13", "simBehavior": "v1", "expected": "拒绝",
|
|
|
|
|
+ "actualResponse": "400", "aidopFlowStatus": "无流水",
|
|
|
|
|
+ "targetChanged": "否", "pass": "是", "note": "第一版"}
|
|
|
|
|
+ r1 = app_client.post("/api/fault-report/save", json=base).json()
|
|
|
|
|
+ base["note"] = "第二版"
|
|
|
|
|
+ r2 = app_client.post("/api/fault-report/save", json=base).json()
|
|
|
|
|
+ rep = r2["report"]
|
|
|
|
|
+ assert rep["replaced"] is True
|
|
|
|
|
+ assert rep["id"] == r1["report"]["id"] # 原位更新:id 不变
|
|
|
|
|
+ rows = [x for x in app_client.get("/api/fault-report/list").json()["reports"]
|
|
|
|
|
+ if x["scenarioId"] == "N3-13"]
|
|
|
|
|
+ assert len(rows) == 1
|
|
|
|
|
+ assert rows[0]["note"] == "第二版"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_fault_report_save_distinct_scenarios_stay_independent(app_client):
|
|
|
|
|
+ """N4 回归:不同场景各自一条,互不覆盖。"""
|
|
|
|
|
+ for sid, note in (("N3-14", "甲"), ("N3-15", "乙")):
|
|
|
|
|
+ r = app_client.post("/api/fault-report/save", json={
|
|
|
|
|
+ "scenarioId": sid, "simBehavior": "x", "expected": "拒绝",
|
|
|
|
|
+ "actualResponse": "403", "aidopFlowStatus": "无流水",
|
|
|
|
|
+ "targetChanged": "否", "pass": "是", "note": note})
|
|
|
|
|
+ assert r.status_code == 200
|
|
|
|
|
+ assert r.json()["report"]["replaced"] is False
|
|
|
|
|
+ rows = app_client.get("/api/fault-report/list").json()["reports"]
|
|
|
|
|
+ assert [x for x in rows if x["scenarioId"] == "N3-14"][0]["note"] == "甲"
|
|
|
|
|
+ assert [x for x in rows if x["scenarioId"] == "N3-15"][0]["note"] == "乙"
|