import importlib import sys import types from fastapi.testclient import TestClient def _install_fake_pymysql(): pymysql = types.ModuleType("pymysql") cursors = types.ModuleType("pymysql.cursors") class DictCursor: # noqa: D401 - test double pass def connect(*args, **kwargs): # noqa: D401 - test double raise AssertionError("connect should be monkeypatched in tests") cursors.DictCursor = DictCursor pymysql.cursors = cursors pymysql.connect = connect sys.modules["pymysql"] = pymysql sys.modules["pymysql.cursors"] = cursors def test_timeline_api_returns_backend_computed_nodes(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) class FakeCursor: def __init__(self): self._result = [] def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "FROM job WHERE job_id" in sql: self._result = [{ "job_id": "JOB-1", "source_type": "YYC", "current_node_code": "SOURCE_ADD", "status": "WAITING_USER", "needs_source_add": 1, }] elif "FROM job_node" in sql: self._result = [ {"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"}, {"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"}, ] else: self._result = [] def fetchone(self): return self._result[0] if self._result else None def fetchall(self): return list(self._result) class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) resp = client.get("/api/jobs/JOB-1/timeline") assert resp.status_code == 200 body = resp.json() by_code = {item["node_code"]: item for item in body["timeline"]} assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS" assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED" assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER" def test_state_machine_api_returns_chinese_labels(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) resp = client.get("/api/state-machine") assert resp.status_code == 200 body = resp.json() assert body["node_labels"]["ORDER_FETCH"] == "订单获取" assert body["executor_labels"]["RPA"] == "艺赛旗执行" assert body["node_executor"]["PRODUCT_MATCH"] == "STATE_CENTER" def test_health_reports_database_status(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): assert sql == "SELECT 1" def fetchone(self): return {"ok": 1} class FakeConn: def cursor(self): return FakeCursor() def close(self): pass monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) response = client.get("/health") assert response.status_code == 200 assert response.json() == {"ok": True, "database": "ok"} def test_task_submit_confirms_source_add_and_is_idempotent(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) state = { "task": { "task_id": "TASK-1", "job_id": "JOB-1", "node_code": "SOURCE_ADD", "role_code": "PURCHASER", "status": "OPEN", "submit_token": None, "submitted_action": None, "submitted_payload_json": None, "submitted_result_json": None, }, "job": { "job_id": "JOB-1", "current_node_code": "SOURCE_ADD", "status": "WAITING_USER", "needs_source_add": 1, "current_role": "PURCHASER", "source_type": "YYC", }, "job_nodes": [ {"node_code": "SOURCE_ADD", "attempt_no": 1, "status": "WAITING_USER"}, ], } class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): self.last_sql = sql self.last_params = params if "FROM user_task WHERE task_id=%s" in sql: self._result = [state["task"]] elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql: self._result = [state["job"]] elif "submitted_result_json" in sql and "FROM user_task" in sql: self._result = [state["task"]] elif "SELECT node_code,attempt_no,status" in sql: self._result = list(state["job_nodes"]) elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): state["task"]["status"] = "SUBMITTED" state["task"]["submit_token"] = params[0] state["task"]["submitted_action"] = params[1] state["task"]["submitted_payload_json"] = params[2] state["task"]["submitted_result_json"] = params[3] state["task"]["submitted_by"] = params[4] state["task"]["submitted_at"] = params[5] elif sql.startswith("UPDATE job SET needs_source_add=0"): state["job"]["needs_source_add"] = 0 elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): state["job"]["current_node_code"] = params[0] state["job"]["status"] = "PENDING" elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): state["job_nodes"][0]["status"] = "SUCCESS" elif sql.startswith("INSERT INTO job_event"): pass else: self._result = [] def fetchone(self): return self._result[0] if self._result else None def fetchall(self): return list(self._result) @property def _result(self): return getattr(self, "_result_data", []) @_result.setter def _result(self, value): self._result_data = value class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) headers = {"X-Agent-Token": main.settings.AGENT_TOKEN} first = client.post( "/api/tasks/TASK-1/submit", headers=headers, json={ "action": "CONFIRM_AND_CONTINUE", "payload": {"sourceAdd": True}, "submitToken": "token-1", "operator": "alice", }, ) assert first.status_code == 200 first_body = first.json() assert first_body["next_node"] == "CONTRACT_CREATE" assert state["task"]["status"] == "SUBMITTED" assert state["task"]["submitted_result_json"] is not None assert state["job"]["current_node_code"] == "CONTRACT_CREATE" assert state["job"]["needs_source_add"] == 0 second = client.post( "/api/tasks/TASK-1/submit", headers=headers, json={ "action": "CONFIRM_AND_CONTINUE", "payload": {"sourceAdd": True}, "submitToken": "token-1", "operator": "alice", }, ) assert second.status_code == 200 assert second.json()["idempotent"] is True third = client.post( "/api/tasks/TASK-1/submit", headers=headers, json={ "action": "CONFIRM_AND_CONTINUE", "payload": {"sourceAdd": True}, "submitToken": "token-2", "operator": "alice", }, ) assert third.status_code == 409 def test_task_submit_source_add_creates_missing_node_history(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) state = { "task": { "task_id": "TASK-1", "job_id": "JOB-1", "node_code": "SOURCE_ADD", "role_code": "PURCHASER", "status": "OPEN", "submit_token": None, "submitted_result_json": None, }, "job": { "job_id": "JOB-1", "current_node_code": "SOURCE_ADD", "status": "WAITING_USER", "needs_source_add": 1, "current_role": "PURCHASER", "source_type": "YYC", }, "job_nodes": [], } class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "FROM user_task WHERE task_id=%s" in sql: self._result = [state["task"]] elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql: self._result = [state["job"]] elif sql.startswith("UPDATE job SET needs_source_add=0"): state["job"]["needs_source_add"] = 0 elif sql.startswith("INSERT INTO job_node"): state["job_nodes"].append({ "node_code": params[1], "attempt_no": params[3], "status": "SUCCESS", "executor_type": params[2], }) elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): state["job"]["current_node_code"] = params[0] state["job"]["status"] = "PENDING" elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): state["task"]["status"] = "SUBMITTED" state["task"]["submit_token"] = params[0] state["task"]["submitted_result_json"] = params[3] elif sql.startswith("INSERT INTO job_event"): pass else: self._result = [] def fetchone(self): return self._result[0] if self._result else None @property def _result(self): return getattr(self, "_result_data", []) @_result.setter def _result(self, value): self._result_data = value class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) response = client.post( "/api/tasks/TASK-1/submit", headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, json={ "action": "CONFIRM_AND_CONTINUE", "payload": {}, "submitToken": "source-submit-1", "operator": "采购张三", }, ) assert response.status_code == 200 assert state["job_nodes"] == [{ "node_code": "SOURCE_ADD", "attempt_no": 1, "status": "SUCCESS", "executor_type": "HUMAN", }] def test_advance_to_m5_human_node_creates_task(): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") state = { "job": { "job_id": "JOB-1", "supplier_name": "重庆亿昊药业", "current_node_code": "M5_CREATE_ORDER", "status": "RUNNING", }, "job_nodes": [], "tasks": [], } class FakeCursor: def execute(self, sql, params=None): if sql.startswith("INSERT INTO job_node"): state["job_nodes"].append({ "node_code": params[1], "executor_type": params[2], "attempt_no": params[3], "status": params[4], }) elif sql.startswith("DELETE FROM user_task"): pass elif sql.startswith("UPDATE job SET current_node_code=%s,status='WAITING_USER'"): state["job"]["current_node_code"] = params[0] state["job"]["status"] = "WAITING_USER" state["job"]["current_role"] = params[1] elif sql.startswith("INSERT INTO user_task"): state["tasks"].append({ "job_id": params[1], "node_code": params[2], "role_code": params[3], "title": params[4], }) elif sql.startswith("INSERT INTO job_event"): pass else: raise AssertionError(sql) nxt = main._advance(FakeCursor(), state["job"], "M5_CREATE_ORDER") assert nxt == "M5_MANUAL_CONFIRM" assert state["job"]["status"] == "WAITING_USER" assert state["job"]["current_role"] == "PURCHASER" assert state["job_nodes"] == [{ "node_code": "M5_MANUAL_CONFIRM", "executor_type": "HUMAN", "attempt_no": 1, "status": "WAITING_USER", }] assert state["tasks"] == [{ "job_id": "JOB-1", "node_code": "M5_MANUAL_CONFIRM", "role_code": "PURCHASER", "title": "M5人工确认:重庆亿昊药业", }] def test_node_complete_exports_artifacts(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) state = { "job": { "job_id": "JOB-1", "current_node_code": "CONTRACT_CREATE", "status": "RUNNING", "source_type": "YYC", }, "job_node": { "node_code": "CONTRACT_CREATE", "attempt_no": 1, "status": "RUNNING", }, "artifacts": [], } class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "FROM job WHERE job_id=%s" in sql and "ORDER BY" not in sql: self._result = [state["job"]] elif "FROM job_node WHERE job_id=%s" in sql: self._result = [state["job_node"]] elif "FROM user_task WHERE job_id=%s" in sql: self._result = [] elif "FROM job_artifact WHERE job_id=%s" in sql: self._result = list(state["artifacts"]) elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): state["job_node"]["status"] = "SUCCESS" state["job_node"]["ended_at"] = params[1] if len(params) > 2: state["job_node"]["callback_idempotency_key"] = params[2] elif sql.startswith("INSERT INTO job_artifact"): state["artifacts"].append({ "job_id": params[0], "node_code": params[1], "attempt_no": params[2], "artifact_type": params[3], "storage_path": params[4], "file_hash": params[5], "visible_roles": params[6], "is_masked": params[7], }) elif sql.startswith("INSERT INTO job_event"): pass else: self._result = [] def fetchone(self): return self._result[0] if self._result else None def fetchall(self): if self._result: return list(self._result) return [] @property def _result(self): return getattr(self, "_result_data", []) @_result.setter def _result(self, value): self._result_data = value class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) headers = {"X-Agent-Token": main.settings.AGENT_TOKEN} done = client.post( "/api/jobs/JOB-1/nodes/CONTRACT_CREATE/complete", headers=headers, json={ "attemptNo": 1, "executorRunId": "RUN-1", "idempotencyKey": "idem-1", "businessRefs": {"contractSystemNo": "HT-001"}, "summary": {"done": True}, "artifacts": [ { "artifactType": "CONTRACT_PDF", "storagePath": "/data/jobs/JOB-1/contract.pdf", "fileHash": "sha256:abc", "visibleRoles": ["PURCHASER", "PLANNER"], "isMasked": False, } ], }, ) assert done.status_code == 200 package = client.get("/api/jobs/JOB-1/evidence-package") assert package.status_code == 200 body = package.json() assert body["artifacts"][0]["artifact_type"] == "CONTRACT_PDF" assert body["artifacts"][0]["storage_path"] == "/data/jobs/JOB-1/contract.pdf" def test_job_list_api_returns_compact_rows(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "FROM job" in sql: self._result = [{ "job_id": "JOB-1", "supplier_name": "重庆亿昊药业", "order_date": "2026-08-04", "source_type": "YYC", "status": "RUNNING", "current_node_code": "PRODUCT_MATCH", "current_role": "PLANNER", "created_at": "2026-08-10 10:00:00", "updated_at": "2026-08-10 10:05:00", "finished_at": None, }] else: self._result = [] def fetchall(self): return list(self._result) @property def _result(self): return getattr(self, "_result_data", []) @_result.setter def _result(self, value): self._result_data = value class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) resp = client.get("/api/jobs", params={"status": "RUNNING"}) assert resp.status_code == 200 body = resp.json() assert body["items"][0]["job_id"] == "JOB-1" assert body["items"][0]["supplier_name"] == "重庆亿昊药业" assert body["items"][0]["current_node_code"] == "PRODUCT_MATCH" def test_task_list_api_filters_by_role(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "FROM user_task" in sql: self._result = [{ "task_id": "TASK-1", "job_id": "JOB-1", "node_code": "SOURCE_ADD", "role_code": "PURCHASER", "status": "OPEN", "title": "新增货源:重庆亿昊药业", "reason_type": "SOURCE_ADD", "submitted_action": None, "submitted_at": None, "created_at": "2026-08-10 10:00:00", }] else: self._result = [] def fetchall(self): return list(self._result) @property def _result(self): return getattr(self, "_result_data", []) @_result.setter def _result(self, value): self._result_data = value class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) resp = client.get("/api/tasks/my", params={"roleCode": "PURCHASER", "status": "open"}) assert resp.status_code == 200 body = resp.json() assert body["items"][0]["task_id"] == "TASK-1" assert body["items"][0]["role_code"] == "PURCHASER" def test_ingest_order_items_before_match(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) executed = [] class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): executed.append((sql, params)) if "SELECT * FROM job WHERE job_id=%s" in sql: self._result = [{ "job_id": "JOB-1", "current_node_code": "ORDER_FETCH", "status": "RUNNING", }] elif "FROM product_match" in sql: self._result = [{"c": 0}] else: self._result = [] def fetchone(self): return self._result[0] if self._result else None class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) response = client.post( "/api/jobs/JOB-1/items", headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, json={ "replace": True, "rows": [{ "orderNo": "DD-001", "rawProductName": "阿卡波糖片", "platformProductCode": "PT-001", "spec": "50mg*30片", "manufacturer": "示例制药", "approvalNo": "国药准字H00000001", "qty": 12, "unitPrice": 9.8, "sourceRowNo": 2, }], }, ) assert response.status_code == 200 assert response.json() == {"ok": True, "inserted": 1, "order_nos": ["DD-001"]} assert any(sql.startswith("INSERT INTO order_item") for sql, _ in executed) assert any("order_no_list_json" in sql for sql, _ in executed) def test_ingest_order_items_rejects_replace_after_match(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "SELECT * FROM job WHERE job_id=%s" in sql: self._result = [{"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH"}] elif "FROM product_match" in sql: self._result = [{"c": 1}] else: self._result = [] def fetchone(self): return self._result[0] if self._result else None class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) response = client.post( "/api/jobs/JOB-1/items", headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, json={"replace": True, "rows": []}, ) assert response.status_code == 409 assert "对码结果" in response.json()["detail"] def test_load_fixture_goods_for_local_demo(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) monkeypatch.setattr(main.settings, "DEV_MODE", True, raising=False) response = client.post( "/api/dev/match/load-fixture-goods", headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, ) assert response.status_code == 200 assert response.json()["loaded"] >= 3 assert main._ENGINE_LOADED is True def test_select_candidate_requires_candidate_payload(monkeypatch): _install_fake_pymysql() main = importlib.import_module("state_center.app.main") client = TestClient(main.app) class FakeCursor: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params=None): if "FROM user_task WHERE task_id=%s" in sql: self._result = [{ "task_id": "TASK-1", "job_id": "JOB-1", "node_code": "PRODUCT_MATCH", "role_code": "PLANNER", "status": "OPEN", "submit_token": None, "product_match_id": 7, }] elif "FROM job WHERE job_id=%s" in sql: self._result = [{ "job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH", "status": "WAITING_USER", }] else: self._result = [] def fetchone(self): return self._result[0] if self._result else None class FakeConn: def cursor(self): return FakeCursor() monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) response = client.post( "/api/tasks/TASK-1/submit", headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, json={ "action": "SELECT_CANDIDATE", "payload": {}, "submitToken": "submit-1", "operator": "计划员张三", }, ) assert response.status_code == 400 assert response.json()["detail"] == "选择候选货品时必须提供 productMatchId 和 goodsId"