AutoRpa/state_center/tests/test_web_api.py

1227 lines
39 KiB
Python

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"
def test_advance_from_receipt_notice_auto_archives_job():
_install_fake_pymysql()
main = importlib.import_module("state_center.app.main")
state = {
"job": {
"job_id": "JOB-1",
"supplier_name": "重庆亿昊药业",
"current_node_code": "M5_RECEIPT_NOTICE",
"status": "WAITING_USER",
"finished_at": None,
},
"job_nodes": [],
"events": [],
}
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("UPDATE job SET status='SUCCESS'"):
state["job"]["status"] = "SUCCESS"
state["job"]["finished_at"] = params[0]
state["job"]["current_node_code"] = "ARCHIVE_JOB"
elif sql.startswith("INSERT INTO job_event"):
state["events"].append({"event_type": params[1], "node_code": params[2]})
else:
raise AssertionError(sql)
nxt = main._advance(FakeCursor(), state["job"], "M5_RECEIPT_NOTICE")
assert nxt is None
assert state["job"]["status"] == "SUCCESS"
assert state["job"]["current_node_code"] == "ARCHIVE_JOB"
assert state["job"]["finished_at"] is not None
assert state["job_nodes"] == [{
"node_code": "ARCHIVE_JOB",
"executor_type": "STATE_CENTER",
"attempt_no": 1,
"status": "SUCCESS",
}]
assert {"event_type": "JOB_FINISHED", "node_code": None} in state["events"]
def test_retry_failed_retryable_node_opens_next_attempt(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": "ORDER_FETCH",
"status": "FAILED_RETRYABLE",
"source_type": "YYC",
},
"attempts": [
{"attempt_no": 1, "status": "FAILED"},
],
"events": [],
}
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 = [state["job"]]
elif "SELECT MAX(attempt_no)" in sql or "SELECT COALESCE(MAX(attempt_no)" in sql:
self._result = [{"max_attempt": state["attempts"][-1]["attempt_no"]}]
elif sql.startswith("UPDATE job SET status='PENDING'"):
state["job"]["status"] = "PENDING"
elif sql.startswith("INSERT INTO job_event"):
state["events"].append({"event_type": params[1], "node_code": params[2]})
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/jobs/JOB-1/retry",
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["node_code"] == "ORDER_FETCH"
assert body["attempt_no"] == 2
assert state["job"]["status"] == "PENDING"
assert state["job"]["current_node_code"] == "ORDER_FETCH"
assert state["attempts"] == [{"attempt_no": 1, "status": "FAILED"}]
def test_contract_approve_timeout_creates_ops_task(monkeypatch):
_install_fake_pymysql()
main = importlib.import_module("state_center.app.main")
client = TestClient(main.app)
state = {
"job": {
"job_id": "JOB-1",
"supplier_name": "重庆亿昊药业",
"current_node_code": "CONTRACT_APPROVE_WAIT",
"status": "WAITING_EXTERNAL",
"wait_check_count": main.settings.CONTRACT_WAIT_MAX_CHECKS - 1,
"source_type": "YYC",
},
"job_nodes": [],
"tasks": [],
}
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 = [state["job"]]
elif sql.startswith("INSERT INTO job_node"):
state["job_nodes"].append({
"node_code": params[1],
"attempt_no": params[2],
"status": params[3],
})
elif sql.startswith("UPDATE job SET status='WAITING_USER'"):
state["job"]["status"] = "WAITING_USER"
state["job"]["current_role"] = "OPS"
state["job"]["wait_check_count"] = params[0]
elif sql.startswith("DELETE FROM user_task"):
pass
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:
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/jobs/JOB-1/nodes/CONTRACT_APPROVE_WAIT/check-result",
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
json={"approved": False, "operator": "agent", "detail": "仍未审批"},
)
assert response.status_code == 200
body = response.json()
assert body["escalated"] is True
assert body["job_status"] == "WAITING_USER"
assert state["job"]["current_role"] == "OPS"
assert state["tasks"] == [{
"job_id": "JOB-1",
"node_code": "CONTRACT_APPROVE_WAIT",
"role_code": "OPS",
"title": "合同审批超时:重庆亿昊药业",
}]
def test_contract_approve_timeout_task_confirm_advances_to_rebate(monkeypatch):
_install_fake_pymysql()
main = importlib.import_module("state_center.app.main")
client = TestClient(main.app)
state = {
"task": {
"task_id": "TASK-OPS-1",
"job_id": "JOB-1",
"node_code": "CONTRACT_APPROVE_WAIT",
"role_code": "OPS",
"status": "OPEN",
"submit_token": None,
"submitted_result_json": None,
},
"job": {
"job_id": "JOB-1",
"supplier_name": "重庆亿昊药业",
"current_node_code": "CONTRACT_APPROVE_WAIT",
"status": "WAITING_USER",
"current_role": "OPS",
"source_type": "YYC",
},
"job_nodes": [
{"node_code": "CONTRACT_APPROVE_WAIT", "attempt_no": 8, "status": "FAILED"},
],
}
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 "SELECT * FROM job WHERE job_id=%s" in sql:
self._result = [state["job"]]
elif "SELECT attempt_no,status FROM job_node" in sql:
self._result = [state["job_nodes"][-1]]
elif sql.startswith("INSERT INTO job_node"):
state["job_nodes"].append({
"node_code": params[1],
"executor_type": params[2],
"attempt_no": params[3],
"status": "SUCCESS",
})
elif sql.startswith("UPDATE job_node SET status='SUCCESS'"):
raise AssertionError("timeout confirm must not overwrite check history")
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-OPS-1/submit",
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
json={
"action": "CONFIRM_AND_CONTINUE",
"payload": {},
"submitToken": "ops-confirm-1",
"operator": "ops",
},
)
assert response.status_code == 200
assert response.json()["next_node"] == "REBATE_HEADER"
assert state["job"]["current_node_code"] == "REBATE_HEADER"
assert state["job_nodes"][-1]["node_code"] == "CONTRACT_APPROVE_WAIT"
assert state["job_nodes"][-1]["status"] == "SUCCESS"
assert state["job_nodes"][-1]["attempt_no"] == 9
assert state["job_nodes"][0]["status"] == "FAILED"
def test_human_node_rejects_rpa_complete(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": "SOURCE_ADD",
"status": "WAITING_USER",
"source_type": "YYC",
}]
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/jobs/JOB-1/nodes/SOURCE_ADD/complete",
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
json={"attemptNo": 1, "summary": {"sourceAdded": True}},
)
assert response.status_code == 409
assert "人工节点" in response.json()["detail"]
def test_task_submit_rejects_oversized_submit_token(monkeypatch):
_install_fake_pymysql()
main = importlib.import_module("state_center.app.main")
client = TestClient(main.app)
advanced = {"moved": False}
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": "SOURCE_ADD",
"status": "OPEN",
"submit_token": None,
}]
elif "SELECT * FROM job WHERE job_id=%s" in sql:
self._result = [{
"job_id": "JOB-1",
"current_node_code": "SOURCE_ADD",
"status": "WAITING_USER",
"source_type": "YYC",
}]
elif sql.startswith("UPDATE job SET"):
advanced["moved"] = True
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()
def begin(self):
return None
def commit(self):
return None
def rollback(self):
return None
def close(self):
return None
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": "x" * 65,
"operator": "alice",
},
)
assert response.status_code == 400
assert "submitToken" in response.json()["detail"]
assert advanced["moved"] is False