237 lines
9.4 KiB
Python
237 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from arr_web.job_trace import build_job_trace
|
|
|
|
|
|
class JobTraceTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.started = datetime(2026, 7, 29, 11, 0, tzinfo=timezone.utc)
|
|
|
|
def test_programmatic_success_exposes_persisted_safe_trace(self) -> None:
|
|
finished = self.started + timedelta(seconds=8)
|
|
trace = build_job_trace(
|
|
{
|
|
"job_id": "arrjob-trace-001",
|
|
"run_status": "accepted",
|
|
"public_status": "succeeded",
|
|
"delivery_mode": "artifact_callback",
|
|
"business_date": "2026-07-29",
|
|
"created_at": self.started,
|
|
"updated_at": finished,
|
|
"validated_at": finished,
|
|
"finished_at": finished,
|
|
"filename": "source.xml",
|
|
"byte_size": 2048,
|
|
"source_sha256": "a" * 64,
|
|
"requested_processor_version": "3.0.0",
|
|
"delivered_processor_version": "3.0.0",
|
|
"object_key": "must/not/escape",
|
|
},
|
|
attempts=[
|
|
{
|
|
"attempt_no": 1,
|
|
"attempt_status": "succeeded",
|
|
"remote_run_id": None,
|
|
"created_at": self.started + timedelta(seconds=1),
|
|
"started_at": self.started + timedelta(seconds=2),
|
|
"finished_at": finished,
|
|
}
|
|
],
|
|
deliveries=[
|
|
{
|
|
"delivery_key": "local-arrjob-trace-001-a1",
|
|
"attempt_no": 1,
|
|
"delivery_status": "committed",
|
|
"result_status": "success",
|
|
"processor_version": "3.0.0",
|
|
"business_date": "2026-07-29",
|
|
"daily_version_id": 41,
|
|
"received_at": self.started + timedelta(seconds=5),
|
|
"validated_at": self.started + timedelta(seconds=6),
|
|
"committed_at": finished,
|
|
}
|
|
],
|
|
versions=[
|
|
{
|
|
"daily_version_id": 41,
|
|
"business_date": "2026-07-29",
|
|
"version_no": 2,
|
|
"version_status": "active",
|
|
"result_delivery_mode": "artifact_callback",
|
|
"source_rows": 20,
|
|
"retained_rows": 17,
|
|
"excluded_rate_code_rows": 1,
|
|
"duplicate_rows": 2,
|
|
"validation_failed_rows": 0,
|
|
"price_unmatched_rows": 0,
|
|
"created_at": self.started + timedelta(seconds=7),
|
|
"validated_at": finished,
|
|
"activated_at": finished,
|
|
}
|
|
],
|
|
outbox_events=[
|
|
{
|
|
"event_key": "processing-run:1:accepted",
|
|
"event_type": "arr.daily_version_committed",
|
|
"publish_status": "pending",
|
|
"publish_attempts": 0,
|
|
"created_at": finished,
|
|
"available_at": finished,
|
|
"payload": {"must": "not escape"},
|
|
}
|
|
],
|
|
refreshed_at=finished + timedelta(seconds=1),
|
|
)
|
|
|
|
self.assertEqual(trace["trace_version"], "arr-job-trace-3")
|
|
self.assertEqual(trace["job"]["status"], "succeeded")
|
|
self.assertEqual(trace["job"]["current_stage"], "downstream")
|
|
self.assertFalse(trace["job"]["active"])
|
|
self.assertIsNone(trace["job"]["remote_run_id"])
|
|
self.assertEqual(trace["job"]["execution_scope"], "arr_runtime")
|
|
self.assertEqual(trace["job"]["processor_mode"], "fixed_processor")
|
|
self.assertEqual(trace["job"]["remote_dispatch"], "none")
|
|
self.assertEqual(trace["job"]["version_no"], 2)
|
|
self.assertEqual(trace["job"]["output_rows"], 17)
|
|
codes = [item["code"] for item in trace["logs"]]
|
|
for expected in (
|
|
"UPLOAD_REGISTERED",
|
|
"ATTEMPT_QUEUED",
|
|
"PROCESSOR_STARTED",
|
|
"ARTIFACT_RESULT_RECEIVED",
|
|
"ARTIFACT_RESULT_VALIDATED",
|
|
"ARTIFACT_RESULT_COMMITTED",
|
|
"FINANCE_VERSION_VALIDATED",
|
|
"FINANCE_VERSION_ACTIVATED",
|
|
"DOWNSTREAM_EVENT_QUEUED",
|
|
"JOB_SUCCEEDED",
|
|
):
|
|
self.assertIn(expected, codes)
|
|
self.assertEqual(trace["evidence"]["artifact_deliveries"], 1)
|
|
|
|
rendered = json.dumps(trace, ensure_ascii=False)
|
|
for private_value in (
|
|
"must/not/escape",
|
|
"must not escape",
|
|
'"payload"',
|
|
'"object_key"',
|
|
"submission_grant",
|
|
"Agent",
|
|
"MCP",
|
|
):
|
|
self.assertNotIn(private_value, rendered)
|
|
self.assertNotIn("本地处理", rendered)
|
|
self.assertIn("ARR 固定处理器输出", rendered)
|
|
self.assertIn("Finance 数据库提交已完成", rendered)
|
|
|
|
def test_runtime_failure_is_located_at_processor_boundary(self) -> None:
|
|
finished = self.started + timedelta(minutes=4)
|
|
trace = build_job_trace(
|
|
{
|
|
"job_id": "arrjob-timeout-001",
|
|
"run_status": "failed",
|
|
"public_status": "failed",
|
|
"delivery_mode": "artifact_callback",
|
|
"failure_code": "PROCESSOR_TIMEOUT",
|
|
"failure_message": "programmatic processing failed",
|
|
"created_at": self.started,
|
|
"updated_at": finished,
|
|
"finished_at": finished,
|
|
"filename": "source.xml",
|
|
"byte_size": 99,
|
|
"source_sha256": "b" * 64,
|
|
},
|
|
attempts=[
|
|
{
|
|
"attempt_no": 1,
|
|
"attempt_status": "failed",
|
|
"remote_run_id": None,
|
|
"failure_code": "PROCESSOR_TIMEOUT",
|
|
"failure_message": "programmatic processing failed",
|
|
"created_at": self.started + timedelta(seconds=1),
|
|
"started_at": self.started + timedelta(seconds=2),
|
|
"finished_at": finished,
|
|
}
|
|
],
|
|
refreshed_at=finished,
|
|
)
|
|
|
|
self.assertEqual(trace["job"]["current_stage"], "processor")
|
|
self.assertEqual(trace["job"]["failure"]["code"], "PROCESSOR_TIMEOUT")
|
|
errors = [item for item in trace["logs"] if item["level"] == "error"]
|
|
self.assertGreaterEqual(len(errors), 2)
|
|
self.assertTrue(all(item["stage"] == "processor" for item in errors))
|
|
|
|
def test_legacy_direct_mcp_trace_exposes_remote_semantics(self) -> None:
|
|
finished = self.started + timedelta(minutes=4)
|
|
trace = build_job_trace(
|
|
{
|
|
"job_id": "arrjob-legacy-001",
|
|
"run_status": "failed",
|
|
"public_status": "failed",
|
|
"delivery_mode": "direct_mcp",
|
|
"failure_code": "REMOTE_FAILED",
|
|
"failure_message": "remote processor failed",
|
|
"created_at": self.started,
|
|
"updated_at": finished,
|
|
"finished_at": finished,
|
|
"filename": "source.xml",
|
|
"byte_size": 99,
|
|
"source_sha256": "d" * 64,
|
|
},
|
|
attempts=[
|
|
{
|
|
"attempt_no": 1,
|
|
"attempt_status": "failed",
|
|
"remote_run_id": "remote-run-123",
|
|
"failure_code": "REMOTE_FAILED",
|
|
"failure_message": "remote processor failed",
|
|
"created_at": self.started + timedelta(seconds=1),
|
|
"started_at": self.started + timedelta(seconds=2),
|
|
"finished_at": finished,
|
|
}
|
|
],
|
|
refreshed_at=finished,
|
|
)
|
|
|
|
self.assertEqual(trace["job"]["execution_scope"], "remote_agent")
|
|
self.assertEqual(trace["job"]["processor_mode"], "legacy_direct_mcp")
|
|
self.assertEqual(trace["job"]["remote_dispatch"], "mcp")
|
|
self.assertEqual(trace["job"]["remote_run_id"], "remote-run-123")
|
|
|
|
def test_active_run_ends_with_current_programmatic_state(self) -> None:
|
|
trace = build_job_trace(
|
|
{
|
|
"job_id": "arrjob-running-001",
|
|
"run_status": "running",
|
|
"public_status": "running",
|
|
"delivery_mode": "artifact_callback",
|
|
"created_at": self.started,
|
|
"updated_at": self.started + timedelta(seconds=4),
|
|
"filename": "source.xml",
|
|
"byte_size": 128,
|
|
"source_sha256": "c" * 64,
|
|
},
|
|
attempts=[
|
|
{
|
|
"attempt_no": 1,
|
|
"attempt_status": "running",
|
|
"remote_run_id": None,
|
|
"created_at": self.started + timedelta(seconds=1),
|
|
"started_at": self.started + timedelta(seconds=2),
|
|
}
|
|
],
|
|
)
|
|
|
|
self.assertTrue(trace["job"]["active"])
|
|
self.assertEqual(trace["job"]["current_stage"], "processor")
|
|
self.assertEqual(trace["logs"][-1]["code"], "CURRENT_RUNNING")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|