d
This commit is contained in:
93
domain-api/tests/test_cluster_runtime_service.py
Normal file
93
domain-api/tests/test_cluster_runtime_service.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services import cluster_runtime_service
|
||||
|
||||
|
||||
class RuntimeSchemaGuardTests(unittest.TestCase):
|
||||
def test_ensure_runtime_schema_executes_only_once_per_process(self) -> None:
|
||||
conn = MagicMock()
|
||||
cursor_cm = MagicMock()
|
||||
cursor = MagicMock()
|
||||
conn.cursor.return_value = cursor_cm
|
||||
cursor_cm.__enter__.return_value = cursor
|
||||
db_cm = MagicMock()
|
||||
db_cm.__enter__.return_value = conn
|
||||
|
||||
with patch.object(cluster_runtime_service, "_RUNTIME_SCHEMA_READY", False):
|
||||
with patch.object(cluster_runtime_service, "get_db", return_value=db_cm) as mocked_get_db:
|
||||
cluster_runtime_service.ensure_runtime_schema()
|
||||
cluster_runtime_service.ensure_runtime_schema()
|
||||
|
||||
mocked_get_db.assert_called_once()
|
||||
self.assertEqual(cursor.execute.call_count, 2)
|
||||
cursor.execute.assert_any_call(
|
||||
"SELECT pg_advisory_xact_lock(%s)",
|
||||
(cluster_runtime_service._RUNTIME_SCHEMA_ADVISORY_LOCK_ID,),
|
||||
)
|
||||
cursor.execute.assert_any_call(cluster_runtime_service._RUNTIME_SCHEMA_SQL)
|
||||
conn.commit.assert_called_once()
|
||||
|
||||
def test_control_node_supports_worker_only_on_mainland_with_worker_signals(self) -> None:
|
||||
self.assertFalse(
|
||||
cluster_runtime_service._control_node_supports_worker(
|
||||
region="overseas",
|
||||
metadata={
|
||||
"worker_online": False,
|
||||
"detect_participating": True,
|
||||
"active_threads": 0,
|
||||
"max_threads": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
cluster_runtime_service._control_node_supports_worker(
|
||||
region="mainland",
|
||||
metadata={
|
||||
"worker_online": True,
|
||||
"detect_participating": False,
|
||||
"active_threads": 0,
|
||||
"max_threads": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def test_metadata_idle_without_runtime_work_detects_stale_idle_heartbeat(self) -> None:
|
||||
self.assertTrue(
|
||||
cluster_runtime_service._metadata_idle_without_runtime_work(
|
||||
{
|
||||
"phase_label": "idle",
|
||||
"phase_detail": "Worker 已启动,等待检测指令",
|
||||
"active_threads": 323,
|
||||
"max_threads": 4,
|
||||
"active_job_code": "",
|
||||
"job_items_total": 0,
|
||||
"job_items_claimed": 0,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 0,
|
||||
"job_items_failed": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
cluster_runtime_service._metadata_idle_without_runtime_work(
|
||||
{
|
||||
"phase_label": "running",
|
||||
"active_threads": 12,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
cluster_runtime_service._metadata_idle_without_runtime_work(
|
||||
{
|
||||
"phase_label": "idle",
|
||||
"active_job_code": "sync-overseas-1",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
34
domain-api/tests/test_core_db_retry.py
Normal file
34
domain-api/tests/test_core_db_retry.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.db import db_read_retry
|
||||
|
||||
|
||||
def test_db_read_retry_retries_retryable_error_once() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
@db_read_retry(attempts=3, initial_delay_seconds=0)
|
||||
def flaky() -> str:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
raise errors.DeadlockDetected()
|
||||
return "ok"
|
||||
|
||||
assert flaky() == "ok"
|
||||
assert attempts["count"] == 2
|
||||
|
||||
|
||||
def test_db_read_retry_does_not_swallow_non_retryable_error() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
@db_read_retry(attempts=3, initial_delay_seconds=0)
|
||||
def broken() -> str:
|
||||
attempts["count"] += 1
|
||||
raise ValueError("boom")
|
||||
|
||||
try:
|
||||
broken()
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "boom"
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
assert attempts["count"] == 1
|
||||
32
domain-api/tests/test_core_files_runtime_root.py
Normal file
32
domain-api/tests/test_core_files_runtime_root.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core import files
|
||||
|
||||
|
||||
class RuntimeRootTests(unittest.TestCase):
|
||||
def test_runtime_root_prefers_shared_runtime_outside_release_tree(self) -> None:
|
||||
fake_file = "/opt/domaincheck/releases/domaincheck_release_20260420_222047/domain-api/app/core/files.py"
|
||||
with patch.object(files, "__file__", fake_file):
|
||||
with patch.object(Path, "mkdir", autospec=True, return_value=None) as mocked_mkdir:
|
||||
runtime_root = files.runtime_root()
|
||||
|
||||
self.assertEqual(Path("/opt/domaincheck/runtime/domain-api"), runtime_root)
|
||||
mocked_mkdir.assert_called_once()
|
||||
|
||||
def test_runtime_root_respects_env_override(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
override = Path(tempdir) / "custom-runtime"
|
||||
with patch.dict(os.environ, {"DOMAIN_API_RUNTIME_ROOT": str(override)}, clear=False):
|
||||
runtime_root = files.runtime_root()
|
||||
self.assertEqual(override, runtime_root)
|
||||
self.assertTrue(runtime_root.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
237
domain-api/tests/test_dashboard_service.py
Normal file
237
domain-api/tests/test_dashboard_service.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.dashboard import fetch_overview
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self._last_sql = sql
|
||||
self._last_params = params
|
||||
|
||||
def fetchone(self):
|
||||
if self._responses:
|
||||
return self._responses.pop(0)
|
||||
return (0,)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, responses):
|
||||
self._cursor = _FakeCursor(responses)
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class DashboardServiceTests(unittest.TestCase):
|
||||
@patch("app.services.dashboard._fetch_active_jobs_aggregate")
|
||||
@patch("app.services.dashboard.get_detect_capacity_plan")
|
||||
@patch("app.services.dashboard.get_detect_queue_health")
|
||||
@patch("app.services.dashboard.get_runtime_status")
|
||||
@patch("app.services.dashboard.get_active_detect_job_summary")
|
||||
@patch("app.services.dashboard.get_db")
|
||||
def test_fetch_overview_includes_ops_metrics(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_get_active_detect_job_summary,
|
||||
mock_get_runtime_status,
|
||||
mock_get_detect_queue_health,
|
||||
mock_get_detect_capacity_plan,
|
||||
mock_fetch_active_jobs_aggregate,
|
||||
) -> None:
|
||||
mock_get_db.return_value = _FakeConnection(
|
||||
responses=[
|
||||
(1000,),
|
||||
(900,),
|
||||
(10,),
|
||||
(5,),
|
||||
(0,),
|
||||
(1,),
|
||||
(430,),
|
||||
(420,),
|
||||
(17,),
|
||||
]
|
||||
)
|
||||
mock_fetch_active_jobs_aggregate.return_value = {
|
||||
"active_jobs_total": 3,
|
||||
"queue": {
|
||||
"items_total": 5200,
|
||||
"pending": 4700,
|
||||
"claimed": 11,
|
||||
"running": 165,
|
||||
"completed": 300,
|
||||
"blacklisted": 12,
|
||||
"failed": 7,
|
||||
},
|
||||
"throughput": {
|
||||
"processed_recent": 30,
|
||||
"processed_per_minute": 2.0,
|
||||
"completed_recent": 20,
|
||||
"blacklisted_recent": 1,
|
||||
"failed_recent": 2,
|
||||
},
|
||||
"retry_total": 12,
|
||||
"steps": [
|
||||
{
|
||||
"step_code": "detect_register",
|
||||
"step_name": "注册状态检测",
|
||||
"items_pending": 185,
|
||||
"items_running": 3,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 7,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 20,
|
||||
"processed_per_minute": 1.33,
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_running": 120,
|
||||
"items_claimed": 0,
|
||||
"processed_recent": 20,
|
||||
"processed_per_minute": 1.33,
|
||||
"completed_recent": 15,
|
||||
"failed_recent": 1,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
mock_get_active_detect_job_summary.return_value = {
|
||||
"job_id": 76,
|
||||
"job_code": "sync-overseas-3612",
|
||||
"items_pending": 185,
|
||||
"items_running": 8,
|
||||
"items_completed": 7,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
}
|
||||
mock_get_runtime_status.return_value = {
|
||||
"worker": {"running": True, "mode": "linux-systemd", "expected_on_this_node": True},
|
||||
"node": {"region": "overseas", "role": "control"},
|
||||
"cluster": {"summary": {"online_worker_nodes": 2, "dedicated_online_worker_nodes": 1, "online_control_nodes": 1}},
|
||||
"detect": {
|
||||
"backlog": {
|
||||
"pending_total": 9438,
|
||||
"claimed_total": 410,
|
||||
"running_total": 15,
|
||||
"completed_total": 951,
|
||||
"blacklisted_total": 0,
|
||||
"failed_total": 137,
|
||||
"register_pending": 8487,
|
||||
"downstream_pending": 951,
|
||||
}
|
||||
},
|
||||
}
|
||||
mock_get_detect_queue_health.return_value = {
|
||||
"has_active_job": True,
|
||||
"job": {"job_id": 76, "job_code": "sync-overseas-3612", "status": "running", "progress_percent": 3.5},
|
||||
"queue": {
|
||||
"items_total": 200,
|
||||
"pending": 185,
|
||||
"claimed": 0,
|
||||
"display_claimed": 0,
|
||||
"running": 8,
|
||||
"display_running": 165,
|
||||
"completed": 7,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"throughput": {"processed_recent": 0, "processed_per_minute": 0},
|
||||
"steps": [
|
||||
{
|
||||
"step_code": "detect_register",
|
||||
"step_name": "注册状态检测",
|
||||
"items_pending": 185,
|
||||
"items_running": 165,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 7,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0.0,
|
||||
}
|
||||
],
|
||||
"runtime_activity": {
|
||||
"step_stats": {
|
||||
"detect_360_site": {
|
||||
"step_code": "detect_360_site",
|
||||
"started_recent": 269,
|
||||
"processed_recent": 6,
|
||||
"completed_recent": 6,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_running": 165,
|
||||
"items_claimed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0.0,
|
||||
"completed_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
mock_get_detect_capacity_plan.return_value = {
|
||||
"estimated_hours_remaining": 1.5,
|
||||
"remaining_items": 193,
|
||||
"recommended_additional_workers": 1,
|
||||
}
|
||||
|
||||
data = fetch_overview()
|
||||
|
||||
self.assertEqual("sync-overseas-3612", data["active_job"]["job_code"])
|
||||
self.assertEqual(3, data["active_jobs_aggregate"]["active_jobs_total"])
|
||||
self.assertEqual(9438, data["active_jobs_aggregate"]["queue"]["pending"])
|
||||
self.assertEqual(410, data["active_jobs_aggregate"]["queue"]["claimed"])
|
||||
self.assertEqual(15, data["active_jobs_aggregate"]["queue"]["running"])
|
||||
self.assertEqual(951, data["active_jobs_aggregate"]["queue"]["completed"])
|
||||
self.assertEqual(137, data["active_jobs_aggregate"]["queue"]["failed"])
|
||||
self.assertEqual("mainland-controller-01", data["active_jobs_aggregate"]["nodes"][0]["node_code"])
|
||||
self.assertEqual(900, data["pending_total"])
|
||||
self.assertEqual(10, data["completed_total"])
|
||||
self.assertEqual(430, data["registerable_total"])
|
||||
self.assertEqual(420, data["purchasable_total"])
|
||||
self.assertEqual(185, data["queue_pending_total"])
|
||||
self.assertEqual(8, data["queue_running_total"])
|
||||
self.assertEqual(165, data["queue_display_running_total"])
|
||||
self.assertEqual(7, data["queue_completed_total"])
|
||||
self.assertEqual(9438, data["backlog_pending_total"])
|
||||
self.assertEqual(8487, data["backlog_register_pending_total"])
|
||||
self.assertEqual(951, data["backlog_downstream_pending_total"])
|
||||
self.assertEqual(12, data["retry_total"])
|
||||
self.assertEqual("注册状态检测", data["bottleneck_step"]["step_name"])
|
||||
self.assertEqual(0.0, data["processed_per_minute"])
|
||||
self.assertEqual(0, data["processed_recent"])
|
||||
self.assertEqual(0.0, data["ops_summary"]["processed_per_minute"])
|
||||
self.assertEqual(1.5, data["ops_summary"]["estimated_hours_remaining"])
|
||||
self.assertEqual(1, data["active_execution_nodes"])
|
||||
self.assertEqual(1, data["ops_summary"]["active_execution_nodes"])
|
||||
self.assertEqual(2, len(data["step_queue"]))
|
||||
self.assertTrue(any(item["step_code"] == "detect_360_site" for item in data["step_queue"]))
|
||||
self.assertEqual(1, len(data["node_throughput"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
75
domain-api/tests/test_detect_api_routes.py
Normal file
75
domain-api/tests/test_detect_api_routes.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import detect as detect_route
|
||||
|
||||
|
||||
class DetectApiRoutesTestCase(unittest.TestCase):
|
||||
@patch("app.api.routes.detect.create_detect_run_snapshot")
|
||||
@patch("app.api.routes.detect._dispatch_remote_detect_start")
|
||||
@patch("app.api.routes.detect.get_settings_payload")
|
||||
@patch("app.api.routes.detect.get_detect_status")
|
||||
@patch("app.api.routes.detect.send_worker_command")
|
||||
@patch("app.api.routes.detect.start_worker")
|
||||
@patch("app.api.routes.detect.append_detect_job_event")
|
||||
@patch("app.api.routes.detect.create_detect_job_if_needed")
|
||||
def test_start_detect_skips_local_worker_on_overseas_control(
|
||||
self,
|
||||
mock_create_job,
|
||||
mock_append_event,
|
||||
mock_start_worker,
|
||||
mock_send_worker_command,
|
||||
mock_get_detect_status,
|
||||
mock_get_settings_payload,
|
||||
mock_dispatch_remote,
|
||||
mock_create_snapshot,
|
||||
) -> None:
|
||||
mock_create_job.return_value = {
|
||||
"job_id": 29,
|
||||
"job_code": "sync-overseas-29",
|
||||
"status": "running",
|
||||
"items_pending": 100,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"task_mode": "single_step",
|
||||
"step_code": "detect_register",
|
||||
}
|
||||
mock_get_detect_status.return_value = {
|
||||
"worker_mode": "linux-systemd",
|
||||
"worker_online": False,
|
||||
"worker_process_count": 0,
|
||||
"worker_latest_start_time": "",
|
||||
"worker_runtime_message": "not-applicable",
|
||||
"progress": {},
|
||||
}
|
||||
mock_get_settings_payload.return_value = {
|
||||
"runtime": {"thread_count": 2000},
|
||||
"proxy_config": {"proxy_enable": True, "allow_direct": False, "proxy_urls": ["a"]},
|
||||
}
|
||||
mock_dispatch_remote.return_value = {
|
||||
"queued_jobs": [{"node_code": "mainland-controller-01"}],
|
||||
"queued_total": 1,
|
||||
"failed_total": 0,
|
||||
"target_summary": {"controller_nodes": ["mainland-controller-01"], "worker_nodes": ["mainland-worker-01"]},
|
||||
}
|
||||
|
||||
with patch.object(detect_route.settings, "node_region", "overseas"), patch.object(
|
||||
detect_route.settings, "node_role", "control"
|
||||
):
|
||||
response = detect_route.start_detect()
|
||||
|
||||
self.assertEqual(0, response.code)
|
||||
self.assertIn("海外控制面", response.message)
|
||||
mock_start_worker.assert_not_called()
|
||||
mock_send_worker_command.assert_not_called()
|
||||
mock_dispatch_remote.assert_called_once()
|
||||
mock_create_snapshot.assert_called_once()
|
||||
event_types = [call.kwargs.get("event_type") for call in mock_append_event.call_args_list]
|
||||
self.assertIn("job_dispatch_requested", event_types)
|
||||
self.assertIn("job_dispatch_skipped_local", event_types)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,121 +1,791 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services.detect_job_service import _build_effective_node_stats, _build_effective_summary
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.services.detect_job_service import (
|
||||
append_detect_job_event,
|
||||
_build_step_payload,
|
||||
_classify_pipeline_item_outcome,
|
||||
_classify_runtime_debug_event,
|
||||
_enrich_active_job_summary_with_runtime,
|
||||
_build_display_summary,
|
||||
_build_runtime_display_bucket,
|
||||
normalize_detect_step_code,
|
||||
get_detect_queue_health,
|
||||
process_detect_pipeline_now,
|
||||
resolve_initial_domain_pipeline_item,
|
||||
resolve_domain_pipeline_step,
|
||||
resolve_detect_job_definition,
|
||||
)
|
||||
|
||||
|
||||
class DetectJobServiceTests(unittest.TestCase):
|
||||
def test_build_effective_node_stats_keeps_runtime_nodes_and_backfills_unassigned(self) -> None:
|
||||
distributed_node_stats = [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 200,
|
||||
"items_pending": 195,
|
||||
"items_claimed": 0,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 70,
|
||||
"items_pending": 56,
|
||||
"items_claimed": 9,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "overseas-control-01",
|
||||
"items_total": 50,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 25,
|
||||
"items_running": 4,
|
||||
"items_completed": 21,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "unassigned",
|
||||
"items_total": 950,
|
||||
"items_pending": 950,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
]
|
||||
def test_append_detect_job_event_skips_missing_job_fk(self) -> None:
|
||||
class FakeCursor:
|
||||
def __init__(self) -> None:
|
||||
self.exec_calls = []
|
||||
self.selects = 0
|
||||
|
||||
node_stats = _build_effective_node_stats(
|
||||
distributed_node_stats=distributed_node_stats,
|
||||
raw_items_total=1000,
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.exec_calls.append((sql, params))
|
||||
if "INSERT INTO detect_run_events" in sql:
|
||||
raise AssertionError("should not insert detect_run_events when job row is missing")
|
||||
if "SELECT 1 FROM detect_jobs" in sql:
|
||||
self.selects += 1
|
||||
|
||||
def fetchone(self):
|
||||
return None
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.cursor_obj = FakeCursor()
|
||||
self.commit_calls = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
def commit(self):
|
||||
self.commit_calls += 1
|
||||
|
||||
fake_conn = FakeConn()
|
||||
|
||||
with patch("app.services.detect_job_service.get_db", return_value=fake_conn):
|
||||
with patch("app.services.detect_job_service.push_debug_event") as mock_push:
|
||||
append_detect_job_event(
|
||||
5,
|
||||
event_type="job_dispatch_requested",
|
||||
message="控制面已发送检测启动请求",
|
||||
)
|
||||
|
||||
self.assertEqual(1, fake_conn.cursor_obj.selects)
|
||||
self.assertEqual(0, fake_conn.commit_calls)
|
||||
mock_push.assert_called_once()
|
||||
|
||||
def test_process_detect_pipeline_now_retries_deadlock_once(self) -> None:
|
||||
calls = {"count": 0}
|
||||
|
||||
def _run_pipeline(*, limit, job_id):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
raise errors.DeadlockDetected()
|
||||
return {
|
||||
"processed_items": 7,
|
||||
"advanced_items": 3,
|
||||
"retried_items": 1,
|
||||
}
|
||||
|
||||
with patch("app.services.detect_job_service.process_detect_pipeline", side_effect=_run_pipeline):
|
||||
with patch("app.services.detect_job_service.time.sleep") as mock_sleep:
|
||||
ok, message, data = process_detect_pipeline_now(limit=123, job_id=45)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("deadlock 自动重试 1 次后成功", message)
|
||||
self.assertEqual(1, data["retry_attempts"])
|
||||
self.assertEqual(7, data["processed_items"])
|
||||
self.assertEqual(2, calls["count"])
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
def test_classify_runtime_debug_event_maps_single_step_finalized_completed(self) -> None:
|
||||
classified = _classify_runtime_debug_event(
|
||||
event_type="worker_log",
|
||||
message="检测步骤跟踪: domain=example.com | step=注册状态检测 | stage=single_step_finalized | elapsed_ms=2700 | ok=1 | detect_key=detect_register | final_status=completed | result_state=passed",
|
||||
payload={"job_code": "sync-overseas-27456", "node_code": "mainland-worker-01"},
|
||||
)
|
||||
|
||||
self.assertEqual(4, len(node_stats))
|
||||
unassigned = next(item for item in node_stats if item["node_code"] == "unassigned")
|
||||
self.assertEqual(680, unassigned["items_total"])
|
||||
self.assertEqual(680, unassigned["items_pending"])
|
||||
self.assertEqual("central_queue", unassigned["metrics_source"])
|
||||
self.assertEqual("sync-overseas-27456", classified["job_code"])
|
||||
self.assertTrue(classified["terminal"])
|
||||
self.assertEqual("completed", classified["terminal_status"])
|
||||
self.assertEqual("detect_register", classified["step_code"])
|
||||
|
||||
def test_build_effective_summary_uses_effective_node_stats_and_raw_blacklisted(self) -> None:
|
||||
node_stats = [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 200,
|
||||
"items_pending": 195,
|
||||
"items_claimed": 0,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 70,
|
||||
"items_pending": 56,
|
||||
"items_claimed": 9,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "overseas-control-01",
|
||||
"items_total": 50,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 25,
|
||||
"items_running": 4,
|
||||
"items_completed": 21,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "unassigned",
|
||||
"items_total": 680,
|
||||
"items_pending": 680,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
]
|
||||
|
||||
summary = _build_effective_summary(
|
||||
node_stats=node_stats,
|
||||
raw_items_total=1000,
|
||||
raw_items_blacklisted=3,
|
||||
def test_build_runtime_display_bucket_prefers_real_active_threads_over_raw_current_load(self) -> None:
|
||||
bucket = _build_runtime_display_bucket(
|
||||
(
|
||||
"mainland-worker-01",
|
||||
"mainland",
|
||||
"worker",
|
||||
"busy",
|
||||
753,
|
||||
{
|
||||
"job_items_total": 1000,
|
||||
"job_items_claimed": 0,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 342,
|
||||
"active_threads": 61,
|
||||
"max_threads": 400,
|
||||
"detect_participating": True,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNotNone(bucket)
|
||||
self.assertEqual(61, bucket["current_load"])
|
||||
self.assertEqual(61, bucket["display_running"])
|
||||
self.assertEqual(61, bucket["active_threads"])
|
||||
|
||||
def test_build_runtime_display_bucket_skips_local_overseas_control_plane_load(self) -> None:
|
||||
with patch("app.services.detect_job_service.settings.node_region", "overseas"):
|
||||
with patch("app.services.detect_job_service.settings.node_role", "control"):
|
||||
with patch("app.services.detect_job_service.settings.node_code", "overseas-control-01"):
|
||||
bucket = _build_runtime_display_bucket(
|
||||
(
|
||||
"overseas-control-01",
|
||||
"overseas",
|
||||
"control",
|
||||
"busy",
|
||||
371,
|
||||
{
|
||||
"active_threads": 371,
|
||||
"max_threads": 3200,
|
||||
"detect_participating": False,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(bucket)
|
||||
|
||||
def test_build_runtime_display_bucket_skips_any_control_node_without_worker_support(self) -> None:
|
||||
bucket = _build_runtime_display_bucket(
|
||||
(
|
||||
"overseas-control-01",
|
||||
"overseas",
|
||||
"control",
|
||||
"busy",
|
||||
378,
|
||||
{
|
||||
"active_threads": 378,
|
||||
"max_threads": 3200,
|
||||
"detect_participating": False,
|
||||
"worker_online": False,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(bucket)
|
||||
|
||||
def test_build_display_summary_ignores_inflated_raw_current_load(self) -> None:
|
||||
summary = _build_display_summary(
|
||||
[
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"display_running": 729,
|
||||
"current_load": 729,
|
||||
"active_threads": 729,
|
||||
"max_threads": 800,
|
||||
"items_completed": 196,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"display_running": 753,
|
||||
"current_load": 753,
|
||||
"active_threads": 61,
|
||||
"max_threads": 400,
|
||||
"items_completed": 146,
|
||||
"items_failed": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(790, summary["display_running"])
|
||||
self.assertEqual(790, summary["current_load"])
|
||||
self.assertEqual(790, summary["active_threads"])
|
||||
|
||||
def test_enrich_active_job_summary_with_runtime_adds_runtime_job_code_and_recent_events(self) -> None:
|
||||
summary = _enrich_active_job_summary_with_runtime(
|
||||
{
|
||||
"job_id": 275,
|
||||
"job_code": "sync-overseas-5297",
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"recent_domain_events": [],
|
||||
},
|
||||
event_limit=20,
|
||||
window_minutes=15,
|
||||
runtime_activity={
|
||||
"focus_job_code": "sync-overseas-9506",
|
||||
"job_codes": ["sync-overseas-9506", "sync-overseas-27456"],
|
||||
"processed_recent": 120,
|
||||
"completed_recent": 118,
|
||||
"failed_recent": 1,
|
||||
"blacklisted_recent": 1,
|
||||
},
|
||||
runtime_snapshot={
|
||||
"job": {
|
||||
"job_id": 1835,
|
||||
"job_code": "sync-overseas-9506",
|
||||
"progress_percent": 44.2,
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 1000,
|
||||
"pending": 176,
|
||||
"claimed": 223,
|
||||
"running": 159,
|
||||
"completed": 442,
|
||||
"display_running": 220,
|
||||
}
|
||||
},
|
||||
},
|
||||
recent_domain_events=[
|
||||
{
|
||||
"event_type": "domain_completed",
|
||||
"message": "域名检测完成: example.com",
|
||||
"created_at": "2026-04-21 04:10:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual("sync-overseas-9506", summary["runtime_job_code"])
|
||||
self.assertEqual(["sync-overseas-9506", "sync-overseas-27456"], summary["runtime_job_codes"])
|
||||
self.assertEqual(120, summary["processed_recent"])
|
||||
self.assertEqual(8.0, summary["processed_per_minute"])
|
||||
self.assertEqual(118, summary["completed_recent"])
|
||||
self.assertEqual(1, summary["failed_recent"])
|
||||
self.assertEqual(1, summary["blacklisted_recent"])
|
||||
self.assertEqual(1, len(summary["recent_domain_events"]))
|
||||
self.assertEqual("sync-overseas-9506", summary["runtime_snapshot_job_code"])
|
||||
self.assertEqual(1000, summary["runtime_snapshot_queue"]["items_total"])
|
||||
self.assertEqual(1000, summary["items_total"])
|
||||
self.assertEqual(931, summary["items_pending"])
|
||||
self.assertEqual(34, summary["items_claimed"])
|
||||
self.assertEqual(14, summary["items_running"])
|
||||
self.assertEqual(21, summary["items_completed"])
|
||||
self.assertEqual(3, summary["items_blacklisted"])
|
||||
self.assertEqual(24, summary["items_terminal"])
|
||||
self.assertEqual(176, summary["items_pending"])
|
||||
self.assertEqual(223, summary["items_claimed"])
|
||||
self.assertEqual(159, summary["items_running"])
|
||||
self.assertEqual(442, summary["items_completed"])
|
||||
self.assertEqual(220, summary["display_items_running"])
|
||||
|
||||
def test_enrich_active_job_summary_with_runtime_prefers_snapshot_identity_and_events(self) -> None:
|
||||
summary = _enrich_active_job_summary_with_runtime(
|
||||
{
|
||||
"job_id": 275,
|
||||
"job_code": "sync-overseas-5297",
|
||||
"status": "running",
|
||||
"recent_events": [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"event_type": "domain_started",
|
||||
"message": "开始检测域名: stale.com",
|
||||
"payload": {"job_code": "sync-overseas-5297"},
|
||||
"created_at": "2026-04-21 11:58:00",
|
||||
}
|
||||
],
|
||||
"current_cycle_events": [],
|
||||
"latest_event": None,
|
||||
"display_items_running": 0,
|
||||
"display_current_load": 0,
|
||||
"display_active_threads": 0,
|
||||
"display_max_threads": 0,
|
||||
"display_active_node_codes": [],
|
||||
},
|
||||
event_limit=10,
|
||||
window_minutes=15,
|
||||
runtime_activity={
|
||||
"focus_job_code": "sync-overseas-31437",
|
||||
"job_codes": ["sync-overseas-31437"],
|
||||
"processed_recent": 10,
|
||||
"completed_recent": 10,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
runtime_snapshot={
|
||||
"job": {
|
||||
"job_id": 1902,
|
||||
"job_code": "sync-overseas-31437",
|
||||
"status": "running",
|
||||
"progress_percent": 34.3,
|
||||
"node_stats": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_claimed": 230,
|
||||
"items_running": 67,
|
||||
"items_completed": 343,
|
||||
"items_failed": 0,
|
||||
"active_threads": 170,
|
||||
"max_threads": 2000,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_claimed": 75,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_failed": 0,
|
||||
"active_threads": 19,
|
||||
"max_threads": 1200,
|
||||
},
|
||||
],
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 1000,
|
||||
"pending": 285,
|
||||
"claimed": 305,
|
||||
"running": 67,
|
||||
"completed": 343,
|
||||
"display_running": 189,
|
||||
}
|
||||
},
|
||||
"recent_events": [
|
||||
{
|
||||
"job_id": 2137,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "job_created",
|
||||
"message": "同步拉取待检测批次 sync-overseas-35461,共 1000 个任务项",
|
||||
"payload": {"source_record_id": 35461},
|
||||
"created_at": "2026-04-22 01:21:29",
|
||||
},
|
||||
{
|
||||
"job_id": 1902,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "worker_log",
|
||||
"message": "从任务队列获取到 125 个需要检测的域名",
|
||||
"payload": {"job_code": "sync-overseas-31437"},
|
||||
"created_at": "2026-04-22 01:19:25",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(1902, summary["job_id"])
|
||||
self.assertEqual("sync-overseas-31437", summary["job_code"])
|
||||
self.assertEqual("sync-overseas-31437", summary["runtime_job_code"])
|
||||
self.assertEqual(189, summary["display_items_running"])
|
||||
self.assertEqual(189, summary["display_current_load"])
|
||||
self.assertEqual(189, summary["display_active_threads"])
|
||||
self.assertEqual(3200, summary["display_max_threads"])
|
||||
self.assertEqual(["mainland-controller-01", "mainland-worker-01"], summary["display_active_node_codes"])
|
||||
self.assertEqual(1, len(summary["recent_events"]))
|
||||
self.assertEqual("worker_log", summary["latest_event"]["event_type"])
|
||||
self.assertEqual("sync-overseas-31437", summary["recent_events"][0]["payload"]["job_code"])
|
||||
|
||||
def test_get_detect_queue_health_preserves_runtime_node_throughput_after_snapshot_override(self) -> None:
|
||||
lease_row = (None, None, 0, 0)
|
||||
throughput_rows = []
|
||||
step_throughput_rows = []
|
||||
runtime_display_rows = [
|
||||
(
|
||||
"mainland-controller-01",
|
||||
"mainland",
|
||||
"control",
|
||||
"busy",
|
||||
170,
|
||||
{
|
||||
"job_items_total": 265,
|
||||
"job_items_claimed": 85,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 105,
|
||||
"active_threads": 170,
|
||||
"max_threads": 2000,
|
||||
"detect_participating": True,
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
"mainland-worker-01",
|
||||
"mainland",
|
||||
"worker",
|
||||
"busy",
|
||||
19,
|
||||
{
|
||||
"job_items_total": 139,
|
||||
"job_items_claimed": 64,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 4,
|
||||
"active_threads": 19,
|
||||
"max_threads": 1200,
|
||||
"detect_participating": True,
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
"overseas-control-01",
|
||||
"overseas",
|
||||
"control",
|
||||
"busy",
|
||||
371,
|
||||
{
|
||||
"active_threads": 371,
|
||||
"max_threads": 3200,
|
||||
"detect_participating": False,
|
||||
},
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
conn = MagicMock()
|
||||
cursor_cm = MagicMock()
|
||||
cursor = MagicMock()
|
||||
conn.cursor.return_value = cursor_cm
|
||||
cursor_cm.__enter__.return_value = cursor
|
||||
db_cm = MagicMock()
|
||||
db_cm.__enter__.return_value = conn
|
||||
cursor.fetchone.return_value = lease_row
|
||||
cursor.fetchall.side_effect = [throughput_rows, step_throughput_rows, runtime_display_rows]
|
||||
|
||||
active_job = {
|
||||
"job_id": 1937,
|
||||
"job_code": "sync-overseas-31987",
|
||||
"status": "running",
|
||||
"items_total": 1000,
|
||||
"items_pending": 596,
|
||||
"items_claimed": 149,
|
||||
"items_running": 146,
|
||||
"items_completed": 109,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"progress_percent": 10.9,
|
||||
"node_stats": [],
|
||||
"distributed_node_stats": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 265,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 85,
|
||||
"items_running": 75,
|
||||
"items_completed": 105,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 139,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 64,
|
||||
"items_running": 71,
|
||||
"items_completed": 4,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
],
|
||||
"step_stats": [
|
||||
{
|
||||
"step_code": "detect_register",
|
||||
"items_total": 1000,
|
||||
"items_pending": 449,
|
||||
"items_claimed": 131,
|
||||
"items_running": 273,
|
||||
"items_completed": 147,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
runtime_activity = {
|
||||
"processed_recent": 133,
|
||||
"completed_recent": 113,
|
||||
"failed_recent": 20,
|
||||
"blacklisted_recent": 0,
|
||||
"focus_job_code": "sync-overseas-31563",
|
||||
"job_codes": ["sync-overseas-31563"],
|
||||
"step_code": "detect_register",
|
||||
"nodes": {
|
||||
"mainland-controller-01": {
|
||||
"node_code": "mainland-controller-01",
|
||||
"processed_recent": 74,
|
||||
"completed_recent": 54,
|
||||
"failed_recent": 20,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
"mainland-worker-01": {
|
||||
"node_code": "mainland-worker-01",
|
||||
"processed_recent": 59,
|
||||
"completed_recent": 59,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
runtime_snapshot = {
|
||||
"job": {
|
||||
"job_id": 1937,
|
||||
"job_code": "sync-overseas-31987",
|
||||
"progress_percent": 10.9,
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 1000,
|
||||
"pending": 596,
|
||||
"claimed": 149,
|
||||
"running": 146,
|
||||
"completed": 109,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
"display_claimed": 149,
|
||||
"display_running": 403,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 265,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 85,
|
||||
"items_running": 75,
|
||||
"items_completed": 105,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 139,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 64,
|
||||
"items_running": 71,
|
||||
"items_completed": 4,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
],
|
||||
"steps": [],
|
||||
},
|
||||
}
|
||||
|
||||
with patch("app.services.detect_job_service.settings.node_region", "overseas"):
|
||||
with patch("app.services.detect_job_service.settings.node_role", "control"):
|
||||
with patch("app.services.detect_job_service.settings.node_code", "overseas-control-01"):
|
||||
with patch("app.services.detect_job_service.get_active_detect_job_summary", return_value=active_job):
|
||||
with patch("app.services.detect_job_service._load_runtime_activity_snapshot", return_value=runtime_activity):
|
||||
with patch("app.services.detect_job_service._load_latest_runtime_active_job_snapshot", return_value=runtime_snapshot):
|
||||
with patch("app.services.detect_job_service.get_db", return_value=db_cm):
|
||||
health = get_detect_queue_health(window_minutes=15)
|
||||
|
||||
node_map = {item["node_code"]: item for item in health["nodes"]}
|
||||
self.assertEqual(74, node_map["mainland-controller-01"]["processed_recent"])
|
||||
self.assertEqual(54, node_map["mainland-controller-01"]["completed_recent"])
|
||||
self.assertEqual(20, node_map["mainland-controller-01"]["failed_recent"])
|
||||
self.assertEqual(170, node_map["mainland-controller-01"]["items_running"])
|
||||
self.assertEqual(59, node_map["mainland-worker-01"]["processed_recent"])
|
||||
self.assertEqual(59, node_map["mainland-worker-01"]["completed_recent"])
|
||||
self.assertEqual(19, node_map["mainland-worker-01"]["items_running"])
|
||||
self.assertEqual(113, health["throughput"]["completed_recent"])
|
||||
self.assertEqual(189, health["queue"]["display_running"])
|
||||
self.assertNotIn("overseas-control-01", node_map)
|
||||
|
||||
def test_normalize_detect_step_code_accepts_supported_single_step(self) -> None:
|
||||
self.assertEqual("detect_baidu_site", normalize_detect_step_code("detect_baidu_site"))
|
||||
self.assertEqual("detect_wayback", normalize_detect_step_code("detect_wayback"))
|
||||
|
||||
def test_normalize_detect_step_code_rejects_unknown_step(self) -> None:
|
||||
self.assertEqual("", normalize_detect_step_code("detect_unknown"))
|
||||
|
||||
def test_resolve_detect_job_definition_builds_single_step_job(self) -> None:
|
||||
definition = resolve_detect_job_definition("detect_baidu_site")
|
||||
|
||||
self.assertTrue(definition["is_single_step"])
|
||||
self.assertEqual("single_step", definition["task_mode"])
|
||||
self.assertEqual("detect_baidu_site", definition["step_code"])
|
||||
|
||||
def test_resolve_detect_job_definition_builds_wayback_single_step_job(self) -> None:
|
||||
definition = resolve_detect_job_definition("detect_wayback")
|
||||
|
||||
self.assertTrue(definition["is_single_step"])
|
||||
self.assertEqual("single_step", definition["task_mode"])
|
||||
self.assertEqual("detect_wayback", definition["step_code"])
|
||||
|
||||
def test_build_step_payload_adds_wayback_recent_years_strategy(self) -> None:
|
||||
payload = _build_step_payload(
|
||||
step_code="detect_wayback",
|
||||
domain_snapshot={"domain": "example.com", "source_type": 2},
|
||||
settings_payload={"detect_options": {"detect_wayback": True}},
|
||||
)
|
||||
|
||||
self.assertEqual("detect_wayback", payload["step_code"])
|
||||
self.assertEqual("recent_years", payload["wayback_strategy"])
|
||||
self.assertEqual(5, payload["wayback_recent_years"])
|
||||
self.assertTrue(payload["wayback_stop_on_first_hit"])
|
||||
|
||||
def test_resolve_detect_job_definition_defaults_to_domain_pipeline(self) -> None:
|
||||
definition = resolve_detect_job_definition(None)
|
||||
|
||||
self.assertFalse(definition["is_single_step"])
|
||||
self.assertEqual("domain_pipeline", definition["task_mode"])
|
||||
self.assertEqual("", definition["step_code"])
|
||||
|
||||
def test_resolve_domain_pipeline_step_skips_yikoujia_register(self) -> None:
|
||||
step_code = resolve_domain_pipeline_step(
|
||||
{
|
||||
"source_type": 1,
|
||||
"register_status": 0,
|
||||
"baidu_site": {},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_360_site": False,
|
||||
"detect_chinaz": False,
|
||||
"detect_aizhan": False,
|
||||
"detect_wayback": False,
|
||||
"detect_jucha": False,
|
||||
"detect_juziseo": False,
|
||||
"detect_order": ["detect_register", "detect_baidu_site"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("detect_baidu_site", step_code)
|
||||
|
||||
def test_resolve_domain_pipeline_step_moves_to_next_incomplete_step(self) -> None:
|
||||
step_code = resolve_domain_pipeline_step(
|
||||
{
|
||||
"source_type": 2,
|
||||
"register_status": 3,
|
||||
"baidu_site": {"state": "passed"},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_360_site": True,
|
||||
"detect_chinaz": False,
|
||||
"detect_aizhan": False,
|
||||
"detect_wayback": False,
|
||||
"detect_jucha": False,
|
||||
"detect_juziseo": False,
|
||||
"detect_order": ["detect_register", "detect_baidu_site", "detect_360_site"],
|
||||
}
|
||||
},
|
||||
after_step_code="detect_baidu_site",
|
||||
)
|
||||
|
||||
self.assertEqual("detect_360_site", step_code)
|
||||
|
||||
def test_resolve_initial_domain_pipeline_item_builds_first_step_payload(self) -> None:
|
||||
step_code, payload = resolve_initial_domain_pipeline_item(
|
||||
{
|
||||
"id": 10,
|
||||
"domain": "example.com",
|
||||
"source_type": 2,
|
||||
"register_status": 0,
|
||||
"baidu_site": {},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_order": ["detect_register", "detect_baidu_site"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("detect_register", step_code)
|
||||
self.assertIsNotNone(payload)
|
||||
self.assertEqual("detect_register", payload["step_code"])
|
||||
self.assertEqual("example.com", payload["domain"])
|
||||
|
||||
def test_resolve_initial_domain_pipeline_item_returns_empty_when_pipeline_already_done(self) -> None:
|
||||
step_code, payload = resolve_initial_domain_pipeline_item(
|
||||
{
|
||||
"id": 11,
|
||||
"domain": "done.com",
|
||||
"source_type": 2,
|
||||
"register_status": 3,
|
||||
"baidu_site": {"state": "passed"},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_order": ["detect_register", "detect_baidu_site"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("", step_code)
|
||||
self.assertIsNone(payload)
|
||||
|
||||
def test_classify_pipeline_item_outcome_retries_external_failure(self) -> None:
|
||||
outcome = _classify_pipeline_item_outcome(
|
||||
item_status="failed",
|
||||
result_payload={"state": "degraded", "message": "timeout", "retry_recommended": True},
|
||||
step_code="detect_baidu_site",
|
||||
attempt_count=0,
|
||||
)
|
||||
|
||||
self.assertEqual("retry", outcome["action"])
|
||||
self.assertTrue(outcome["should_retry"])
|
||||
self.assertEqual("external_retry", outcome["reason_code"])
|
||||
|
||||
def test_classify_pipeline_item_outcome_rejects_business_failure(self) -> None:
|
||||
outcome = _classify_pipeline_item_outcome(
|
||||
item_status="failed",
|
||||
result_payload={"state": "rejected", "message": "title contains forbidden keyword"},
|
||||
step_code="detect_chinaz",
|
||||
attempt_count=0,
|
||||
)
|
||||
|
||||
self.assertEqual("reject", outcome["action"])
|
||||
self.assertFalse(outcome["should_retry"])
|
||||
self.assertEqual("business_reject", outcome["reason_code"])
|
||||
|
||||
def test_classify_pipeline_item_outcome_marks_blacklisted_terminal(self) -> None:
|
||||
outcome = _classify_pipeline_item_outcome(
|
||||
item_status="blacklisted",
|
||||
result_payload={"state": "blacklisted", "message": "risk hit"},
|
||||
step_code="detect_baidu_site",
|
||||
attempt_count=0,
|
||||
)
|
||||
|
||||
self.assertEqual("black_hit", outcome["action"])
|
||||
self.assertFalse(outcome["should_retry"])
|
||||
self.assertEqual("blacklisted", outcome["reason_code"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
69
domain-api/tests/test_detect_service_status_fallback.py
Normal file
69
domain-api/tests/test_detect_service_status_fallback.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services import detect_service
|
||||
|
||||
|
||||
class DetectServiceStatusFallbackTests(unittest.TestCase):
|
||||
def test_get_detect_status_keeps_runtime_snapshot_when_db_is_unreachable(self) -> None:
|
||||
runtime_state = {
|
||||
"service_running": True,
|
||||
"detecting": True,
|
||||
"active_threads": 7,
|
||||
"max_threads": 120,
|
||||
"phase": "running",
|
||||
"detail": "Worker 正在处理 7 个检测任务",
|
||||
"updated_at": "2026-04-20 23:59:00",
|
||||
"available_proxy_count": 18,
|
||||
}
|
||||
|
||||
with patch("app.services.detect_service.ensure_runtime_schema"), \
|
||||
patch("app.services.detect_service.get_db", side_effect=RuntimeError("db down")), \
|
||||
patch("app.services.detect_service.get_settings_payload", return_value={"proxy_config": {"proxy_enable": True, "allow_direct": False, "proxy_urls": ["a"]}}), \
|
||||
patch("app.services.detect_service.get_runtime_settings", return_value={"worker_log_sync_enabled": False, "worker_log_sync_mode": "full"}), \
|
||||
patch("app.services.detect_service._load_recent_worker_lines", return_value=(True, "", [])), \
|
||||
patch("app.services.detect_service.detect_worker_runtime", return_value={"mode": "linux-systemd", "running": True, "process_count": 1, "latest_start_time": "2026-04-20 23:58:00", "message": "active/running"}), \
|
||||
patch("app.services.detect_service._load_runtime_state", return_value=runtime_state), \
|
||||
patch("app.services.detect_service._load_runtime_state_from_cluster_node", return_value={}), \
|
||||
patch("app.services.detect_service._extract_available_proxy_count", return_value=0), \
|
||||
patch("app.services.detect_service._extract_active_thread_snapshot", return_value={"active": 0, "max": 0}), \
|
||||
patch("app.services.detect_service._normalize_recent_warning", return_value=""), \
|
||||
patch("app.services.detect_service._build_proxy_runtime_snapshot", return_value={"state": "healthy", "label": "代理正常", "detail": "healthy", "direct_fallback_active": False, "reason": "healthy", "last_refresh_status": "ok", "last_refresh_time": "", "source_count": 2, "raw_items": 18, "validated_count": 18, "available_count": 18, "source_stats": [], "supplier_empty": False}), \
|
||||
patch("app.services.detect_service.resolve_thread_count", return_value={"effective_thread_count": 120, "default_thread_count": 5, "source": "node_override", "override_thread_count": 120, "node_code": "mainland-worker-01"}), \
|
||||
patch("app.services.detect_service.get_active_detect_job_summary", side_effect=RuntimeError("db down")), \
|
||||
patch("app.services.detect_service.sync_detect_runs", return_value=[]), \
|
||||
patch("app.services.detect_service._resolve_remote_log_snapshot", return_value={}), \
|
||||
patch("app.services.detect_service._extract_dependency_alerts", return_value=[]), \
|
||||
patch("app.services.detect_service.append_detect_result_projection_if_changed"):
|
||||
payload = detect_service.get_detect_status()
|
||||
|
||||
self.assertTrue(payload["worker_online"])
|
||||
self.assertTrue(payload["detecting"])
|
||||
self.assertEqual(7, payload["active_thread_count"])
|
||||
self.assertEqual(120, payload["max_thread_count"])
|
||||
self.assertEqual(0, payload["progress"]["pending"])
|
||||
self.assertEqual(0, payload["progress"]["completed"])
|
||||
|
||||
def test_filter_lines_since_supports_journalctl_syslog_timestamps(self) -> None:
|
||||
lines = [
|
||||
"Apr 21 20:12:42 mainland-controller python[1]: 当前实际线程数量: 323/4",
|
||||
"Apr 21 20:17:20 mainland-controller python[2]: Worker 已启动,等待检测指令",
|
||||
]
|
||||
|
||||
filtered = detect_service._filter_lines_since(lines, "2026-04-21 20:17:00")
|
||||
|
||||
self.assertEqual(
|
||||
["Apr 21 20:17:20 mainland-controller python[2]: Worker 已启动,等待检测指令"],
|
||||
filtered,
|
||||
)
|
||||
|
||||
def test_filter_lines_since_falls_back_when_no_timestamp_is_parseable(self) -> None:
|
||||
lines = ["no timestamp line 1", "no timestamp line 2"]
|
||||
|
||||
filtered = detect_service._filter_lines_since(lines, "2026-04-21 20:17:00")
|
||||
|
||||
self.assertEqual(lines, filtered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
205
domain-api/tests/test_juming_services.py
Normal file
205
domain-api/tests/test_juming_services.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from requests.cookies import RequestsCookieJar
|
||||
|
||||
from app.services import juming_service, juming_task_service
|
||||
|
||||
|
||||
class JumingServiceTests(unittest.TestCase):
|
||||
def test_crawl_juming_rejects_invalid_cookie(self) -> None:
|
||||
cookie_jar = RequestsCookieJar()
|
||||
cookie_jar.set("sid", "expired")
|
||||
|
||||
with patch.object(juming_service, "_load_juming_cookie", return_value=(cookie_jar, "local")):
|
||||
with patch.object(juming_service, "_validate_juming_cookie", return_value=(False, "聚名登录态已失效,请重新登录")):
|
||||
with self.assertRaisesRegex(ValueError, "已失效"):
|
||||
juming_service.crawl_juming({"mode": "delete_list"})
|
||||
|
||||
def test_get_juming_status_exposes_remote_validation(self) -> None:
|
||||
cookie_jar = RequestsCookieJar()
|
||||
cookie_jar.set("sid", "alive")
|
||||
|
||||
with patch.object(juming_service, "_load_juming_cookie", return_value=(cookie_jar, "local")):
|
||||
with patch.object(juming_service, "_validate_juming_cookie", return_value=(False, "聚名登录态已失效,请重新登录")):
|
||||
payload = juming_service.get_juming_status()
|
||||
|
||||
self.assertTrue(payload["cookie_present"])
|
||||
self.assertFalse(payload["cookie_valid"])
|
||||
self.assertFalse(payload["cookie_ready"])
|
||||
self.assertIn("已失效", payload["cookie_message"])
|
||||
|
||||
def test_insert_domains_uses_copy_stage_import_path(self) -> None:
|
||||
class FakeCursor:
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[tuple[str, object]] = []
|
||||
self.copy_calls: list[tuple[str, tuple[str, ...], str]] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql: str, params: object = None) -> None:
|
||||
self.executed.append((sql, params))
|
||||
|
||||
def copy_from(self, file_obj, table: str, columns: tuple[str, ...]) -> None:
|
||||
self.copy_calls.append((table, columns, file_obj.read()))
|
||||
|
||||
def fetchone(self):
|
||||
return (2, 2, 0)
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.cursor_obj = FakeCursor()
|
||||
self.commit_calls = 0
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_calls += 1
|
||||
|
||||
fake_conn = FakeConn()
|
||||
|
||||
@contextmanager
|
||||
def fake_get_db():
|
||||
yield fake_conn
|
||||
|
||||
with patch.object(juming_service, "get_db", fake_get_db):
|
||||
with patch.object(juming_service, "IMPORT_BATCH_SIZE", 2):
|
||||
stats = juming_service._insert_domains(
|
||||
["alpha.com", "beta.net"],
|
||||
juming_service.DELETE_LIST_SOURCE_TYPE,
|
||||
)
|
||||
|
||||
self.assertEqual(2, stats["added"])
|
||||
self.assertEqual(1, fake_conn.commit_calls)
|
||||
self.assertTrue(fake_conn.cursor_obj.copy_calls)
|
||||
table, columns, payload = fake_conn.cursor_obj.copy_calls[0]
|
||||
self.assertEqual("juming_import_stage", table)
|
||||
self.assertEqual(("domain", "tld"), columns)
|
||||
self.assertIn("alpha.com\tcom", payload)
|
||||
self.assertIn("beta.net\tnet", payload)
|
||||
executed_sql = "\n".join(sql for sql, _params in fake_conn.cursor_obj.executed)
|
||||
self.assertIn("create temporary table if not exists juming_import_stage", executed_sql.lower())
|
||||
self.assertIn("inserted as", executed_sql.lower())
|
||||
self.assertIn("left join domains existing", executed_sql.lower())
|
||||
|
||||
def test_delete_list_import_skips_already_imported_same_signature(self) -> None:
|
||||
cookie_jar = RequestsCookieJar()
|
||||
cookie_jar.set("sid", "alive")
|
||||
|
||||
class FakeJM:
|
||||
cookie = cookie_jar
|
||||
|
||||
def new_cha_del(self, current_date: str):
|
||||
if current_date == "2026-03-21":
|
||||
return ["alpha.com", "beta.net"]
|
||||
return []
|
||||
|
||||
logs: list[str] = []
|
||||
signature = juming_service._compute_domains_signature(["alpha.com", "beta.net"])
|
||||
cached_state = {
|
||||
"2026-03-21": {
|
||||
"signature": signature,
|
||||
"total": 2,
|
||||
"valid": 2,
|
||||
"invalid": 0,
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(juming_service, "_load_juming_cookie", return_value=(cookie_jar, "local")):
|
||||
with patch.object(juming_service, "JM", return_value=FakeJM()):
|
||||
with patch.object(juming_service, "_load_delete_import_state", return_value=cached_state):
|
||||
with patch.object(juming_service, "_insert_domains") as mock_insert:
|
||||
result = juming_service._crawl_delete_list_and_import(
|
||||
"2026-03-21",
|
||||
False,
|
||||
log=logs.append,
|
||||
)
|
||||
|
||||
mock_insert.assert_not_called()
|
||||
self.assertEqual(0, result["stats"]["added"])
|
||||
self.assertEqual(2, result["stats"]["exists"])
|
||||
self.assertTrue(any("跳过重复入库" in line for line in logs))
|
||||
|
||||
|
||||
class JumingTaskServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self._old_runtime_root = os.environ.get("DOMAIN_API_RUNTIME_ROOT")
|
||||
os.environ["DOMAIN_API_RUNTIME_ROOT"] = self._tmpdir.name
|
||||
juming_task_service._ACTIVE_TASK_IDS.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
juming_task_service._ACTIVE_TASK_IDS.clear()
|
||||
if self._old_runtime_root is None:
|
||||
os.environ.pop("DOMAIN_API_RUNTIME_ROOT", None)
|
||||
else:
|
||||
os.environ["DOMAIN_API_RUNTIME_ROOT"] = self._old_runtime_root
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def _write_tasks(self, records: list[dict]) -> None:
|
||||
path = os.path.join(self._tmpdir.name, "juming_tasks.json")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(records, handle, ensure_ascii=False, indent=2)
|
||||
|
||||
def test_cleanup_orphaned_tasks_marks_running_task_failed(self) -> None:
|
||||
self._write_tasks(
|
||||
[
|
||||
{
|
||||
"task_id": "task-1",
|
||||
"status": "running",
|
||||
"phase": "importing",
|
||||
"phase_label": "入库中",
|
||||
"cancel_requested": False,
|
||||
"message": "开始入库处理",
|
||||
"created_at": "2026-04-21 21:00:00",
|
||||
"updated_at": "2026-04-21 21:00:00",
|
||||
"started_at": "2026-04-21 21:00:00",
|
||||
"completed_at": "",
|
||||
"result": None,
|
||||
"logs": [],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
tasks = juming_task_service.list_juming_tasks()
|
||||
self.assertEqual("failed", tasks[0]["status"])
|
||||
self.assertIn("中断", tasks[0]["message"])
|
||||
|
||||
def test_create_task_rejects_parallel_active_task(self) -> None:
|
||||
self._write_tasks(
|
||||
[
|
||||
{
|
||||
"task_id": "task-1",
|
||||
"status": "running",
|
||||
"phase": "fetching",
|
||||
"phase_label": "抓取中",
|
||||
"cancel_requested": False,
|
||||
"message": "正在抓取",
|
||||
"created_at": "2026-04-21 21:00:00",
|
||||
"updated_at": "2026-04-21 21:00:00",
|
||||
"started_at": "2026-04-21 21:00:00",
|
||||
"completed_at": "",
|
||||
"result": None,
|
||||
"logs": [],
|
||||
}
|
||||
]
|
||||
)
|
||||
juming_task_service._ACTIVE_TASK_IDS.add("task-1")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "已有聚名采集任务正在运行"):
|
||||
juming_task_service.create_juming_task({"mode": "delete_list"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,111 @@ class NodeAgentDeliveryQueueTests(unittest.TestCase):
|
||||
self.assertEqual(0, delivery_queue["pending_count"])
|
||||
self.assertEqual(0, delivery_queue["dead_letter_count"])
|
||||
|
||||
def test_base_payload_prefers_non_loopback_identity(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch.object(node_agent, "AGENT_QUEUE_DIR", temp_dir), patch.object(
|
||||
node_agent,
|
||||
"CONTROL_PLANE_BASE_URL",
|
||||
"http://152.53.37.118:8100",
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"gethostname",
|
||||
return_value="localhost",
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"getfqdn",
|
||||
return_value="localhost.localdomain",
|
||||
), patch.object(
|
||||
node_agent.os,
|
||||
"uname",
|
||||
return_value=type("Uname", (), {"nodename": "localhost"})(),
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"NODE_CODE",
|
||||
"mainland-controller-01",
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"getaddrinfo",
|
||||
return_value=[(None, None, None, None, ("127.0.0.1", 0))],
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"gethostbyname",
|
||||
return_value="127.0.0.1",
|
||||
):
|
||||
class FakeSocket:
|
||||
def connect(self, target):
|
||||
self.target = target
|
||||
|
||||
def getsockname(self):
|
||||
return ("121.204.244.188", 12345)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
with patch.object(node_agent.socket, "socket", return_value=FakeSocket()):
|
||||
payload = node_agent._base_payload()
|
||||
|
||||
self.assertNotIn(payload["hostname"], {"", "localhost", "localhost.localdomain"})
|
||||
self.assertEqual("121.204.244.188", payload["ip"])
|
||||
|
||||
def test_detect_runtime_snapshot_degrades_to_worker_runtime_when_detect_status_fails(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
with patch(
|
||||
"app.services.worker_control_service.detect_worker_runtime",
|
||||
return_value={
|
||||
"running": True,
|
||||
"process_count": 1,
|
||||
"latest_start_time": "2026-04-20 18:00:00",
|
||||
"message": "active/running",
|
||||
},
|
||||
), patch(
|
||||
"app.services.detect_service.get_detect_status",
|
||||
side_effect=RuntimeError('connection to server at "127.0.0.1", port 5432 failed'),
|
||||
):
|
||||
snapshot = node_agent._detect_runtime_snapshot()
|
||||
|
||||
self.assertTrue(snapshot["worker_online"])
|
||||
self.assertTrue(snapshot["service_running"])
|
||||
self.assertFalse(snapshot["detecting"])
|
||||
self.assertEqual("active/running", snapshot["phase_detail"])
|
||||
self.assertEqual("2026-04-20 18:00:00", snapshot["updated_at"])
|
||||
self.assertIn("127.0.0.1", snapshot["error"])
|
||||
|
||||
def test_detect_runtime_snapshot_infers_worker_online_from_active_threads(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
with patch(
|
||||
"app.services.worker_control_service.detect_worker_runtime",
|
||||
return_value={
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": "",
|
||||
},
|
||||
), patch(
|
||||
"app.services.detect_service.get_detect_status",
|
||||
return_value={
|
||||
"worker_online": False,
|
||||
"detecting": False,
|
||||
"active_thread_count": 19,
|
||||
"max_thread_count": 1200,
|
||||
"phase_label": "检测中",
|
||||
"phase_detail": "Worker 正在处理 162 个检测任务",
|
||||
"runtime_state": {
|
||||
"service_running": False,
|
||||
"updated_at": "2026-04-21 00:00:06",
|
||||
},
|
||||
"active_job": {"items_running": 0},
|
||||
},
|
||||
):
|
||||
snapshot = node_agent._detect_runtime_snapshot()
|
||||
|
||||
self.assertTrue(snapshot["worker_online"])
|
||||
self.assertTrue(snapshot["service_running"])
|
||||
self.assertTrue(snapshot["detecting"])
|
||||
self.assertTrue(snapshot["detect_participating"])
|
||||
self.assertEqual(19, snapshot["active_threads"])
|
||||
self.assertEqual(19, snapshot["current_load"])
|
||||
|
||||
def test_job_event_network_failure_is_queued_for_retry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch.object(node_agent, "AGENT_QUEUE_DIR", temp_dir), patch.object(
|
||||
@@ -279,6 +384,63 @@ class NodeAgentDeliveryQueueTests(unittest.TestCase):
|
||||
self.assertEqual("failed_local", event_payload["start_delivery_state"])
|
||||
self.assertIn("temporary offline", event_payload["start_delivery_error"])
|
||||
|
||||
def test_register_heartbeat_and_pull_use_configured_timeouts(self) -> None:
|
||||
with patch.object(node_agent, "_post", return_value={"code": 0, "message": "ok", "data": {"jobs": []}}) as mock_post, patch.object(
|
||||
node_agent,
|
||||
"AGENT_REGISTER_TIMEOUT_SECONDS",
|
||||
91,
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"AGENT_HEARTBEAT_TIMEOUT_SECONDS",
|
||||
92,
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"AGENT_PULL_TIMEOUT_SECONDS",
|
||||
93,
|
||||
):
|
||||
node_agent._register()
|
||||
node_agent._heartbeat()
|
||||
jobs = node_agent._pull_jobs()
|
||||
|
||||
self.assertEqual([], jobs)
|
||||
self.assertEqual(3, mock_post.call_count)
|
||||
self.assertEqual(91, mock_post.call_args_list[0].kwargs["timeout"])
|
||||
self.assertEqual(92, mock_post.call_args_list[1].kwargs["timeout"])
|
||||
self.assertEqual(93, mock_post.call_args_list[2].kwargs["timeout"])
|
||||
|
||||
def test_job_delivery_uses_configured_timeouts(self) -> None:
|
||||
with patch.object(
|
||||
node_agent,
|
||||
"_deliver_or_queue",
|
||||
side_effect=lambda **kwargs: {"state": "queued", "timeout": kwargs["timeout"]},
|
||||
) as mock_deliver, patch.object(
|
||||
node_agent,
|
||||
"AGENT_JOB_COMPLETE_TIMEOUT_SECONDS",
|
||||
94,
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"AGENT_JOB_EVENT_TIMEOUT_SECONDS",
|
||||
47,
|
||||
):
|
||||
complete_result = node_agent._job_complete(
|
||||
11,
|
||||
status="success",
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
result={"ok": True},
|
||||
)
|
||||
event_result = node_agent._job_event(
|
||||
11,
|
||||
event_type="executor_received",
|
||||
message="accepted",
|
||||
)
|
||||
|
||||
self.assertEqual("queued", complete_result["state"])
|
||||
self.assertEqual("queued", event_result["state"])
|
||||
self.assertEqual(2, mock_deliver.call_count)
|
||||
self.assertEqual(94, mock_deliver.call_args_list[0].kwargs["timeout"])
|
||||
self.assertEqual(47, mock_deliver.call_args_list[1].kwargs["timeout"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -110,6 +110,34 @@ class OpsActionExecutorCoreTests(unittest.TestCase):
|
||||
self.assertTrue(data["fallback_used"])
|
||||
self.assertIn("detect_worker.log", data["log_paths"][0])
|
||||
|
||||
@patch("app.services.ops_action_executor_core._reset_lab_state")
|
||||
def test_runtime_reset_lab_state_returns_helper_result_with_host_context(self, mock_reset_lab_state) -> None:
|
||||
runner = _FakeRunner({})
|
||||
mock_reset_lab_state.return_value = (
|
||||
True,
|
||||
"lab runtime reset completed",
|
||||
{"database": {"include_domains": True}},
|
||||
)
|
||||
|
||||
ok, message, data = execute_structured_action(
|
||||
"runtime.reset_lab_state",
|
||||
{"clear_database": True, "clear_domains": True},
|
||||
service_names=build_service_name_map(
|
||||
api_service_name="domaincheck-api",
|
||||
worker_service_name="domaincheck-worker",
|
||||
sync_agent_service_name="domaincheck-sync-agent",
|
||||
),
|
||||
runner=runner,
|
||||
host_context={"hostname": "mainland-controller-01", "ip": "121.204.244.188"},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("lab runtime reset completed", message)
|
||||
self.assertEqual("mainland-controller-01", data["hostname"])
|
||||
self.assertEqual("121.204.244.188", data["ip"])
|
||||
self.assertTrue(data["database"]["include_domains"])
|
||||
mock_reset_lab_state.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,8 +4,11 @@ from unittest.mock import patch
|
||||
|
||||
from app.api.routes.ops_agent import _build_agent_response
|
||||
from app.services.ops_agent_service import (
|
||||
_build_agent_runtime_config_bundle,
|
||||
_runtime_config_bundle_hash_payload,
|
||||
agent_append_job_event,
|
||||
agent_complete_job,
|
||||
agent_mark_job_started,
|
||||
agent_pull_jobs,
|
||||
agent_register,
|
||||
build_node_agent_bootstrap_plan,
|
||||
@@ -102,6 +105,64 @@ class _SequenceConnection:
|
||||
|
||||
|
||||
class OpsAgentServiceTests(unittest.TestCase):
|
||||
def test_runtime_config_bundle_hash_ignores_generated_at(self) -> None:
|
||||
bundle_a = {
|
||||
"node_code": "mainland-worker-01",
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {"mainland-worker-01": 100},
|
||||
"runtime_settings": {"worker_log_sync_enabled": True},
|
||||
"generated_at": "2026-04-19 17:30:00",
|
||||
"config_hash": "stale-hash",
|
||||
}
|
||||
bundle_b = {
|
||||
"node_code": "mainland-worker-01",
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {"mainland-worker-01": 100},
|
||||
"runtime_settings": {"worker_log_sync_enabled": True},
|
||||
"generated_at": "2026-04-19 17:35:00",
|
||||
"config_hash": "other-stale-hash",
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
_runtime_config_bundle_hash_payload(bundle_a),
|
||||
_runtime_config_bundle_hash_payload(bundle_b),
|
||||
)
|
||||
|
||||
@patch("app.services.ops_agent_service.get_sensitive_words_payload")
|
||||
@patch("app.services.ops_agent_service.get_runtime_settings")
|
||||
@patch("app.services.ops_agent_service.get_settings_payload")
|
||||
def test_build_agent_runtime_config_bundle_keeps_hash_stable_for_same_config(
|
||||
self,
|
||||
mock_get_settings_payload,
|
||||
mock_get_runtime_settings,
|
||||
mock_get_sensitive_words_payload,
|
||||
) -> None:
|
||||
mock_get_settings_payload.return_value = {
|
||||
"detect_options": {"detect_wayback": True},
|
||||
"proxy_config": {"enabled": True},
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {"mainland-worker-01": 100},
|
||||
}
|
||||
mock_get_runtime_settings.return_value = {
|
||||
"worker_log_sync_enabled": True,
|
||||
"worker_log_sync_mode": "full",
|
||||
}
|
||||
mock_get_sensitive_words_payload.return_value = {
|
||||
"text": "foo\nbar",
|
||||
"total": 2,
|
||||
"items": ["foo", "bar"],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.services.ops_agent_service._format_time",
|
||||
side_effect=["2026-04-19 17:30:00", "2026-04-19 17:35:00"],
|
||||
):
|
||||
first_bundle = _build_agent_runtime_config_bundle("mainland-worker-01")
|
||||
second_bundle = _build_agent_runtime_config_bundle("mainland-worker-01")
|
||||
|
||||
self.assertNotEqual(first_bundle["generated_at"], second_bundle["generated_at"])
|
||||
self.assertEqual(first_bundle["config_hash"], second_bundle["config_hash"])
|
||||
|
||||
def test_build_agent_response_extracts_detail_code(self) -> None:
|
||||
response = _build_agent_response(False, "bad request", {"detail_code": "agent_token_invalid", "foo": "bar"})
|
||||
|
||||
@@ -730,6 +791,76 @@ class OpsAgentServiceTests(unittest.TestCase):
|
||||
self.assertEqual(0, payload["summary"]["agent_ready"])
|
||||
self.assertEqual(1, payload["summary"]["remote_access_ready"])
|
||||
|
||||
@patch("app.services.runtime_status_service.get_runtime_status")
|
||||
@patch("app.services.cluster_runtime_service.get_cluster_snapshot")
|
||||
@patch("app.services.ops_job_service.list_managed_nodes")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
@patch("app.services.ops_agent_service.ensure_ops_agent_schema")
|
||||
def test_list_managed_nodes_with_agent_state_prefers_non_loopback_cluster_identity_and_infers_worker_online(
|
||||
self,
|
||||
mock_ensure_ops_agent_schema,
|
||||
mock_get_db,
|
||||
mock_list_managed_nodes,
|
||||
mock_get_cluster_snapshot,
|
||||
mock_get_runtime_status,
|
||||
) -> None:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
mock_ensure_ops_agent_schema.return_value = None
|
||||
mock_get_db.return_value = _EmptyConnection()
|
||||
mock_list_managed_nodes.return_value = [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"title": "Mainland Worker 01",
|
||||
"is_enabled": True,
|
||||
"metadata": {
|
||||
"hostname": "S244-248",
|
||||
"ip": "121.204.244.248",
|
||||
},
|
||||
"last_seen_at": now,
|
||||
}
|
||||
]
|
||||
mock_get_cluster_snapshot.return_value = {
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"status": "busy",
|
||||
"current_load": 19,
|
||||
"hostname": "localhost",
|
||||
"ip": "127.0.0.1",
|
||||
"last_heartbeat_at": now,
|
||||
"is_effective_worker": True,
|
||||
"detect_participating": False,
|
||||
"metadata": {
|
||||
"worker_online": False,
|
||||
"active_threads": 19,
|
||||
"max_threads": 1200,
|
||||
"phase_label": "检测中",
|
||||
"phase_detail": "Worker 正在处理 162 个检测任务",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_get_runtime_status.return_value = {
|
||||
"detect": {
|
||||
"participating_nodes": [],
|
||||
"non_participating_nodes": [],
|
||||
}
|
||||
}
|
||||
|
||||
payload = list_managed_nodes_with_agent_state()
|
||||
|
||||
self.assertEqual(1, len(payload["nodes"]))
|
||||
row = payload["nodes"][0]
|
||||
self.assertEqual("S244-248", row["cluster_hostname"])
|
||||
self.assertEqual("121.204.244.248", row["cluster_ip"])
|
||||
self.assertTrue(row["detect_runtime"]["worker_online"])
|
||||
self.assertTrue(row["detect_runtime"]["detect_participating"])
|
||||
self.assertEqual(19, row["detect_runtime"]["active_threads"])
|
||||
|
||||
def test_agent_register_requires_node_code_with_detail_code(self) -> None:
|
||||
ok, message, data = agent_register({}, token="ignored")
|
||||
|
||||
@@ -1176,6 +1307,119 @@ class OpsAgentServiceTests(unittest.TestCase):
|
||||
self.assertEqual("当前控制面仅支持对可见头部记录执行单条动作", message)
|
||||
self.assertEqual("evt-hidden", data["record_id"])
|
||||
|
||||
@patch("app.services.ops_agent_service.get_ops_job")
|
||||
@patch("app.services.ops_agent_service.append_ops_job_event")
|
||||
@patch("app.services.ops_agent_service._authenticate_agent_token")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
def test_agent_pull_jobs_commits_before_appending_event(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_authenticate_agent_token,
|
||||
mock_append_ops_job_event,
|
||||
mock_get_ops_job,
|
||||
) -> None:
|
||||
cursor = _SequenceCursor(fetchall_values=[[(36,)]] )
|
||||
connection = _SequenceConnection(cursor)
|
||||
mock_get_db.return_value = connection
|
||||
mock_authenticate_agent_token.return_value = (True, "ok", {"node_code": "mainland-worker-01"})
|
||||
mock_get_ops_job.return_value = {"id": 36, "job_code": "ops-demo", "action": "health.snapshot"}
|
||||
|
||||
def _assert_after_commit(**kwargs):
|
||||
self.assertTrue(connection.committed)
|
||||
self.assertEqual(36, kwargs["job_id"])
|
||||
self.assertEqual("agent_dispatched", kwargs["event_type"])
|
||||
|
||||
mock_append_ops_job_event.side_effect = _assert_after_commit
|
||||
|
||||
ok, message, data = agent_pull_jobs({"node_code": "mainland-worker-01"}, token="agent-token", limit=1)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("ok", message)
|
||||
self.assertEqual(1, data["count"])
|
||||
self.assertTrue(connection.committed)
|
||||
mock_append_ops_job_event.assert_called_once()
|
||||
|
||||
@patch("app.services.ops_agent_service.get_ops_job")
|
||||
@patch("app.services.ops_agent_service.append_ops_job_event")
|
||||
@patch("app.services.ops_agent_service._authenticate_agent_token")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
def test_agent_mark_job_started_commits_before_appending_event(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_authenticate_agent_token,
|
||||
mock_append_ops_job_event,
|
||||
mock_get_ops_job,
|
||||
) -> None:
|
||||
cursor = _SequenceCursor(fetchone_values=[(36,)], fetchall_values=[[(101,), (102,)]])
|
||||
connection = _SequenceConnection(cursor)
|
||||
mock_get_db.return_value = connection
|
||||
mock_authenticate_agent_token.return_value = (True, "ok", {"node_code": "mainland-worker-01"})
|
||||
mock_get_ops_job.return_value = {"id": 36, "job_code": "ops-demo", "action": "health.snapshot"}
|
||||
|
||||
def _assert_after_commit(**kwargs):
|
||||
self.assertTrue(connection.committed)
|
||||
self.assertEqual(36, kwargs["job_id"])
|
||||
self.assertEqual("agent_started", kwargs["event_type"])
|
||||
self.assertEqual({"step_ids": [101, 102]}, kwargs["payload"])
|
||||
|
||||
mock_append_ops_job_event.side_effect = _assert_after_commit
|
||||
|
||||
ok, message, data = agent_mark_job_started(36, {"node_code": "mainland-worker-01"}, token="agent-token")
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("任务已标记为运行中", message)
|
||||
self.assertEqual(36, data["job"]["id"])
|
||||
self.assertTrue(connection.committed)
|
||||
mock_append_ops_job_event.assert_called_once()
|
||||
|
||||
@patch("app.services.ops_release_service.refresh_release_rollout_for_job")
|
||||
@patch("app.services.ops_agent_service.get_ops_job")
|
||||
@patch("app.services.ops_agent_service.append_ops_job_event")
|
||||
@patch("app.services.ops_agent_service._authenticate_agent_token")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
def test_agent_complete_job_commits_before_appending_event(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_authenticate_agent_token,
|
||||
mock_append_ops_job_event,
|
||||
mock_get_ops_job,
|
||||
mock_refresh_release_rollout_for_job,
|
||||
) -> None:
|
||||
cursor = _SequenceCursor(fetchone_values=[(36, "")], fetchall_values=[[(101,)]] )
|
||||
connection = _SequenceConnection(cursor)
|
||||
mock_get_db.return_value = connection
|
||||
mock_authenticate_agent_token.return_value = (True, "ok", {"node_code": "mainland-worker-01"})
|
||||
mock_get_ops_job.return_value = {"id": 36, "job_code": "ops-demo", "status": "success"}
|
||||
|
||||
def _assert_after_commit(**kwargs):
|
||||
self.assertTrue(connection.committed)
|
||||
self.assertEqual(36, kwargs["job_id"])
|
||||
self.assertEqual("agent_completed", kwargs["event_type"])
|
||||
self.assertEqual("info", kwargs["level"])
|
||||
self.assertEqual([101], kwargs["payload"]["step_ids"])
|
||||
|
||||
mock_append_ops_job_event.side_effect = _assert_after_commit
|
||||
|
||||
ok, message, data = agent_complete_job(
|
||||
36,
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"status": "success",
|
||||
"stdout": "ok",
|
||||
"stderr": "",
|
||||
"result": {"summary": "done"},
|
||||
"duration_ms": 120,
|
||||
},
|
||||
token="agent-token",
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("任务结果已回写", message)
|
||||
self.assertEqual(36, data["job"]["id"])
|
||||
self.assertTrue(connection.committed)
|
||||
mock_append_ops_job_event.assert_called_once()
|
||||
mock_refresh_release_rollout_for_job.assert_called_once_with(36)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,11 +4,13 @@ from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.ops_job_service import (
|
||||
_execute_control_plane_job,
|
||||
_serialize_job_row,
|
||||
create_ops_job,
|
||||
dispatch_ops_job,
|
||||
list_managed_nodes,
|
||||
upsert_managed_node,
|
||||
)
|
||||
|
||||
@@ -109,6 +111,39 @@ class OpsJobServiceTests(unittest.TestCase):
|
||||
self.assertTrue(job["steps_loaded"])
|
||||
self.assertIn("mainland-worker-02", job["summary_text"])
|
||||
|
||||
def test_upsert_managed_node_supports_ssh_entry_and_secret_flags(self) -> None:
|
||||
node_code = "test-managed-node-secret"
|
||||
ok, message, data = upsert_managed_node(
|
||||
{
|
||||
"node_code": node_code,
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"ssh_entry": "121.204.244.188 root <demo-password>",
|
||||
"is_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(ok, message)
|
||||
node = data["node"]
|
||||
self.assertEqual("121.204.244.188", node["ssh_host"])
|
||||
self.assertEqual("root", node["ssh_user"])
|
||||
self.assertEqual(22, node["ssh_port"])
|
||||
self.assertEqual("password", node["auth_mode"])
|
||||
self.assertTrue(node["ssh_password_configured"])
|
||||
|
||||
managed = next(item for item in list_managed_nodes() if item["node_code"] == node_code)
|
||||
self.assertTrue(managed["ssh_password_configured"])
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT ssh_password FROM ops_managed_node_secrets WHERE node_code = %s",
|
||||
(node_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual("demo-password", str(row[0] or ""))
|
||||
|
||||
@patch("app.services.ops_job_service.list_managed_nodes")
|
||||
@patch("app.services.ops_agent_service.build_node_agent_bootstrap_plan")
|
||||
def test_execute_control_plane_bootstrap_uses_managed_node_defaults(
|
||||
@@ -237,7 +272,11 @@ class OpsJobServiceTests(unittest.TestCase):
|
||||
self.assertEqual("keep-me", node["metadata"]["operator_note"])
|
||||
self.assertEqual("online", node["metadata"]["cluster_status"])
|
||||
|
||||
insert_call = cursor.execute.call_args_list[-1]
|
||||
insert_call = next(
|
||||
call
|
||||
for call in cursor.execute.call_args_list
|
||||
if "INSERT INTO ops_managed_nodes" in str(call.args[0])
|
||||
)
|
||||
insert_params = insert_call.args[1]
|
||||
self.assertEqual("121.204.244.248", insert_params[4])
|
||||
self.assertEqual(22, insert_params[5])
|
||||
|
||||
@@ -248,6 +248,42 @@ class OpsPolicyServiceTests(unittest.TestCase):
|
||||
self.assertTrue(preview["blocked"])
|
||||
self.assertIn("local-runtime 仅支持当前控制面本机节点", " ".join(preview["blocking_reasons"]))
|
||||
|
||||
@patch("app.services.ops_policy_service.get_cluster_snapshot")
|
||||
def test_runtime_reset_lab_state_is_critical_and_requires_approval(self, mock_cluster_snapshot) -> None:
|
||||
mock_cluster_snapshot.return_value = {
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"region": "mainland",
|
||||
"role": "control",
|
||||
"status": "online",
|
||||
"current_load": 0,
|
||||
"is_effective_worker": True,
|
||||
"detect_participating": False,
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"online_worker_nodes": 2,
|
||||
"dedicated_online_worker_nodes": 1,
|
||||
"online_control_nodes": 2,
|
||||
"busy_nodes": [],
|
||||
},
|
||||
}
|
||||
|
||||
preview = preview_ops_job_policy(
|
||||
{
|
||||
"action": "runtime.reset_lab_state",
|
||||
"target_type": "node",
|
||||
"target_node_code": "mainland-controller-01",
|
||||
"execution_mode": "remote-agent",
|
||||
"payload": {"clear_database": True, "clear_redis": True},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("critical", preview["risk_level"])
|
||||
self.assertTrue(preview["approval_required"])
|
||||
self.assertFalse(preview["blocked"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import io
|
||||
import grp
|
||||
import os
|
||||
import pwd
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.ops_release_executor_core import build_remote_release_action_script, execute_release_action
|
||||
from app.services.ops_release_executor_core import (
|
||||
_pick_release_owner_group,
|
||||
build_remote_release_action_script,
|
||||
execute_release_action,
|
||||
)
|
||||
|
||||
|
||||
class _BytesResponse:
|
||||
@@ -24,20 +32,45 @@ class _BytesResponse:
|
||||
def _build_release_archive() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
content = b"hello-release"
|
||||
info = tarfile.TarInfo(name="bundle/README.txt")
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
files = {
|
||||
"README.txt": b"hello-release",
|
||||
"domain-api/deploy/systemd/domain-node-agent.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-worker\n",
|
||||
"domain-api/deploy/systemd/domain-worker.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-worker\n",
|
||||
"domain-api/deploy/systemd/domain-api.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-api\n",
|
||||
"domain-api/deploy/systemd/domain-sync-agent.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-worker\n",
|
||||
}
|
||||
for name, content in files.items():
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class OpsReleaseExecutorCoreTests(unittest.TestCase):
|
||||
def test_pick_release_owner_group_prefers_service_identity_over_path_owner(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch(
|
||||
"app.services.ops_release_executor_core._resolve_path_owner_group",
|
||||
return_value=("root", "root"),
|
||||
):
|
||||
owner_user, owner_group = _pick_release_owner_group(
|
||||
Path(tmpdir),
|
||||
[{"service_name": "domaincheck-worker", "user": "www", "group": "www"}],
|
||||
)
|
||||
self.assertEqual(("www", "www"), (owner_user, owner_group))
|
||||
|
||||
def test_execute_release_action_deploys_archive_and_switches_current(self) -> None:
|
||||
archive_bytes = _build_release_archive()
|
||||
commands: list[tuple[str, ...]] = []
|
||||
|
||||
def run_command(command: list[str], *, timeout: int = 60):
|
||||
commands.append(tuple(command))
|
||||
if command[:3] == ["systemctl", "show", "domaincheck-worker"] and "-p" in command:
|
||||
current_user = pwd.getpwuid(os.getuid()).pw_name
|
||||
current_group = grp.getgrgid(os.getgid()).gr_name
|
||||
return 0, f"{current_user}\n{current_group}\n", ""
|
||||
if command[:2] == ["chown", "-R"]:
|
||||
return 0, "", ""
|
||||
if command[:2] == ["systemctl", "restart"]:
|
||||
return 0, "", ""
|
||||
if command[:2] == ["systemctl", "is-active"]:
|
||||
@@ -45,11 +78,13 @@ class OpsReleaseExecutorCoreTests(unittest.TestCase):
|
||||
return 0, "", ""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
systemd_root = Path(tmpdir) / "systemd"
|
||||
ok, message, data = execute_release_action(
|
||||
{
|
||||
"release_version": "2026.04.18-rc1",
|
||||
"artifact_url": "https://example.com/domaincheck.tar.gz",
|
||||
"install_root": tmpdir,
|
||||
"systemd_unit_root": str(systemd_root),
|
||||
"restart_services": ["domaincheck-worker"],
|
||||
"health_check_services": ["domaincheck-worker"],
|
||||
"health_check_urls": [],
|
||||
@@ -70,19 +105,29 @@ class OpsReleaseExecutorCoreTests(unittest.TestCase):
|
||||
release_dir = Path(data["release_dir"])
|
||||
self.assertTrue(release_dir.exists())
|
||||
self.assertTrue((release_dir / ".release-meta.json").exists())
|
||||
self.assertTrue(any(cmd[:2] == ("chown", "-R") for cmd in commands))
|
||||
self.assertIn(("systemctl", "daemon-reload"), commands)
|
||||
self.assertIn(("systemctl", "restart", "domaincheck-worker"), commands)
|
||||
self.assertIn(("systemctl", "is-active", "domaincheck-worker"), commands)
|
||||
self.assertTrue((systemd_root / "domaincheck-node-agent.service").exists())
|
||||
self.assertTrue((systemd_root / "domaincheck-node-agent.service.d" / "current-path.conf").exists())
|
||||
|
||||
def test_build_remote_release_action_script_is_valid_python(self) -> None:
|
||||
script = build_remote_release_action_script(
|
||||
{
|
||||
"release_version": "2026.04.18-rc1",
|
||||
"artifact_url": "https://example.com/domaincheck.tar.gz",
|
||||
"switch_current": True,
|
||||
"rollback_on_failure": False,
|
||||
}
|
||||
)
|
||||
|
||||
compile(script, "<remote-release-script>", "exec")
|
||||
self.assertIn("execute_release_action(", script)
|
||||
self.assertIn("True", script)
|
||||
self.assertIn("False", script)
|
||||
self.assertIn("def collect_service_identity(", script)
|
||||
self.assertIn("def apply_release_permissions(", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,14 +2,17 @@ import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services import ops_runtime_executor_service
|
||||
from app.services.ops_runtime_executor_service import execute_local_support_action, execute_ssh_action
|
||||
|
||||
|
||||
class OpsRuntimeExecutorServiceTests(unittest.TestCase):
|
||||
@patch("app.services.ops_runtime_executor_service.subprocess.run")
|
||||
def test_execute_ssh_action_parses_structured_json_result(self, mock_run) -> None:
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
def test_execute_ssh_action_parses_structured_json_result(self, mock_load_secret, mock_run) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "", "ssh_private_key": ""}
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["ssh"],
|
||||
returncode=0,
|
||||
@@ -113,6 +116,105 @@ class OpsRuntimeExecutorServiceTests(unittest.TestCase):
|
||||
issued_command = mock_run.call_args.args[0]
|
||||
self.assertEqual(["systemctl", "restart", "domaincheck-worker"], issued_command)
|
||||
|
||||
@patch("app.services.ops_runtime_executor_service.subprocess.run")
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
@patch("app.services.ops_runtime_executor_service.paramiko.SSHClient")
|
||||
def test_execute_ssh_action_uses_paramiko_for_password_auth(self, mock_ssh_client_cls, mock_load_secret, mock_run) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "demo-pass", "ssh_private_key": ""}
|
||||
mock_client = MagicMock()
|
||||
stdout = MagicMock()
|
||||
stderr = MagicMock()
|
||||
stdout.channel.recv_exit_status.return_value = 0
|
||||
stdout.read.return_value = (
|
||||
b'{"ok": true, "message": "health snapshot collected", "result": {"checks": {"api": {"state": "active"}}}}\n'
|
||||
)
|
||||
stderr.read.return_value = b""
|
||||
mock_client.exec_command.return_value = (None, stdout, stderr)
|
||||
mock_ssh_client_cls.return_value = mock_client
|
||||
|
||||
ok, message, data = execute_ssh_action(
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"ssh_host": "121.204.244.248",
|
||||
"ssh_port": 22,
|
||||
"ssh_user": "root",
|
||||
"auth_mode": "password",
|
||||
},
|
||||
"health.snapshot",
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("health snapshot collected", message)
|
||||
self.assertEqual("active", data["checks"]["api"]["state"])
|
||||
self.assertEqual("password", data["transport"]["auth_mode"])
|
||||
mock_client.connect.assert_called_once()
|
||||
self.assertFalse(mock_run.called)
|
||||
|
||||
@patch("app.services.ops_runtime_executor_service.subprocess.run")
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
@patch("app.services.ops_runtime_executor_service.paramiko.SSHClient")
|
||||
def test_execute_ssh_action_falls_back_to_password_when_node_auth_mode_is_key_but_only_password_exists(
|
||||
self,
|
||||
mock_ssh_client_cls,
|
||||
mock_load_secret,
|
||||
mock_run,
|
||||
) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "demo-pass", "ssh_private_key": ""}
|
||||
mock_client = MagicMock()
|
||||
stdout = MagicMock()
|
||||
stderr = MagicMock()
|
||||
stdout.channel.recv_exit_status.return_value = 0
|
||||
stdout.read.return_value = (
|
||||
b'{"ok": true, "message": "health snapshot collected", "result": {"checks": {"api": {"state": "active"}}}}\n'
|
||||
)
|
||||
stderr.read.return_value = b""
|
||||
mock_client.exec_command.return_value = (None, stdout, stderr)
|
||||
mock_ssh_client_cls.return_value = mock_client
|
||||
|
||||
ok, message, data = execute_ssh_action(
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"ssh_host": "121.204.244.188",
|
||||
"ssh_port": 22,
|
||||
"ssh_user": "root",
|
||||
"auth_mode": "key",
|
||||
},
|
||||
"health.snapshot",
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("health snapshot collected", message)
|
||||
self.assertEqual("password", data["transport"]["auth_mode"])
|
||||
mock_client.connect.assert_called_once()
|
||||
self.assertFalse(mock_run.called)
|
||||
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
def test_execute_ssh_action_returns_graceful_error_when_paramiko_missing_for_password_auth(
|
||||
self,
|
||||
mock_load_secret,
|
||||
) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "demo-pass", "ssh_private_key": ""}
|
||||
|
||||
with patch.object(ops_runtime_executor_service, "paramiko", None):
|
||||
ok, message, data = execute_ssh_action(
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"ssh_host": "121.204.244.188",
|
||||
"ssh_port": 22,
|
||||
"ssh_user": "root",
|
||||
"auth_mode": "password",
|
||||
},
|
||||
"health.snapshot",
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("paramiko", message)
|
||||
self.assertEqual("password", data["transport"]["auth_mode"])
|
||||
self.assertEqual("121.204.244.188", data["transport"]["ssh_host"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
131
domain-api/tests/test_runtime_status_service.py
Normal file
131
domain-api/tests/test_runtime_status_service.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import unittest
|
||||
|
||||
from app.services.detect_service import _slice_remote_log_lines_fairly
|
||||
from app.services.runtime_status_service import _align_queue_health_with_backlog, _build_detect_node_row
|
||||
|
||||
|
||||
class RuntimeStatusServiceTests(unittest.TestCase):
|
||||
def test_build_detect_node_row_merges_queue_running_metrics(self) -> None:
|
||||
row = _build_detect_node_row(
|
||||
node_code="mainland-worker-01",
|
||||
cluster_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"status": "busy",
|
||||
"is_effective_worker": True,
|
||||
"current_load": 61,
|
||||
"metadata": {
|
||||
"job_items_total": 1000,
|
||||
"job_items_claimed": 0,
|
||||
"job_items_running": 61,
|
||||
},
|
||||
},
|
||||
job_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 0,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
queue_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 1000,
|
||||
"items_pending": 739,
|
||||
"items_claimed": 200,
|
||||
"items_running": 61,
|
||||
"items_completed": 191,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 632,
|
||||
"processed_per_minute": 42.13,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(1000, row["items_total"])
|
||||
self.assertEqual(200, row["items_claimed"])
|
||||
self.assertEqual(61, row["items_running"])
|
||||
self.assertEqual("running", row["participation_state"])
|
||||
self.assertTrue(row["is_current_participant"])
|
||||
self.assertTrue(row["is_dispatch_active"])
|
||||
|
||||
def test_build_detect_node_row_uses_active_threads_as_current_execution_signal(self) -> None:
|
||||
row = _build_detect_node_row(
|
||||
node_code="mainland-worker-01",
|
||||
cluster_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"status": "busy",
|
||||
"is_effective_worker": True,
|
||||
"current_load": 61,
|
||||
"metadata": {
|
||||
"active_threads": 61,
|
||||
"max_threads": 400,
|
||||
},
|
||||
},
|
||||
job_node={},
|
||||
queue_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"processed_recent": 632,
|
||||
"processed_per_minute": 42.13,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(61, row["active_threads"])
|
||||
self.assertEqual(400, row["max_threads"])
|
||||
self.assertEqual("runtime_active", row["participation_state"])
|
||||
self.assertEqual("执行中", row["participation_label"])
|
||||
self.assertTrue(row["is_dispatch_active"])
|
||||
|
||||
def test_slice_remote_log_lines_fairly_keeps_secondary_node_visible(self) -> None:
|
||||
lines = [f"[2026-04-19 17:30:{i:02d}] [mainland-controller-01] controller-{i}" for i in range(20)]
|
||||
lines.extend(
|
||||
[f"[2026-04-19 17:31:{i:02d}] [mainland-worker-01] worker-{i}" for i in range(2)]
|
||||
)
|
||||
|
||||
sliced = _slice_remote_log_lines_fairly(lines, limit=6, min_per_node=2)
|
||||
|
||||
self.assertEqual(6, len(sliced))
|
||||
self.assertTrue(any("[mainland-controller-01]" in line for line in sliced))
|
||||
self.assertTrue(any("[mainland-worker-01]" in line for line in sliced))
|
||||
|
||||
def test_align_queue_health_with_backlog_prefers_larger_runtime_snapshot(self) -> None:
|
||||
aligned = _align_queue_health_with_backlog(
|
||||
{
|
||||
"has_active_job": True,
|
||||
"queue": {
|
||||
"items_total": 120,
|
||||
"pending": 100,
|
||||
"claimed": 10,
|
||||
"running": 5,
|
||||
"completed": 5,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
"terminal": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
"pending_total": 1200,
|
||||
"claimed_total": 230,
|
||||
"running_total": 40,
|
||||
"completed_total": 300,
|
||||
"blacklisted_total": 12,
|
||||
"failed_total": 8,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(aligned["has_active_job"])
|
||||
self.assertEqual(1200, aligned["queue"]["pending"])
|
||||
self.assertEqual(230, aligned["queue"]["claimed"])
|
||||
self.assertEqual(40, aligned["queue"]["running"])
|
||||
self.assertEqual(300, aligned["queue"]["completed"])
|
||||
self.assertEqual(12, aligned["queue"]["blacklisted"])
|
||||
self.assertEqual(8, aligned["queue"]["failed"])
|
||||
self.assertEqual(1790, aligned["queue"]["items_total"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
31
domain-api/tests/test_settings_service.py
Normal file
31
domain-api/tests/test_settings_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import unittest
|
||||
|
||||
from app.services.settings_service import _normalize_thread_count, resolve_thread_count
|
||||
|
||||
|
||||
class SettingsServiceTests(unittest.TestCase):
|
||||
def test_normalize_thread_count_allows_values_above_256(self) -> None:
|
||||
self.assertEqual(512, _normalize_thread_count(512))
|
||||
|
||||
def test_normalize_thread_count_rejects_non_positive_values(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
_normalize_thread_count(0)
|
||||
|
||||
def test_resolve_thread_count_uses_large_node_override(self) -> None:
|
||||
payload = {
|
||||
"thread_count": 8,
|
||||
"node_thread_counts": {
|
||||
"mainland-controller-01": 512,
|
||||
},
|
||||
}
|
||||
|
||||
resolved = resolve_thread_count(node_code="mainland-controller-01", settings_payload=payload)
|
||||
|
||||
self.assertEqual(8, resolved["default_thread_count"])
|
||||
self.assertEqual(512, resolved["override_thread_count"])
|
||||
self.assertEqual(512, resolved["effective_thread_count"])
|
||||
self.assertEqual("node_override", resolved["source"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,15 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.sync_agent import _append_detect_result_projection_snapshot
|
||||
from app.sync_agent import (
|
||||
_append_detect_result_projection_snapshot,
|
||||
_build_aligned_queue_health_snapshot,
|
||||
_emit_structured_tick,
|
||||
_emit_sync_result_breakdown,
|
||||
_filter_runtime_events_for_job,
|
||||
_run_pipeline_stage_processor,
|
||||
_select_projection_job_snapshots,
|
||||
)
|
||||
|
||||
|
||||
class SyncAgentTests(unittest.TestCase):
|
||||
@@ -30,6 +38,155 @@ class SyncAgentTests(unittest.TestCase):
|
||||
self.assertEqual(1, payload["progress"]["blacklisted"])
|
||||
self.assertEqual(2, payload["progress"]["failed"])
|
||||
|
||||
@patch("app.sync_agent.get_latest_unprojected_detect_job_summary")
|
||||
@patch("app.sync_agent.get_active_detect_job_summary")
|
||||
def test_select_projection_job_snapshots_returns_active_and_latest_finished(
|
||||
self,
|
||||
mock_get_active_detect_job_summary,
|
||||
mock_get_latest_unprojected_detect_job_summary,
|
||||
) -> None:
|
||||
mock_get_active_detect_job_summary.return_value = {"job_id": 101, "job_code": "running-job"}
|
||||
mock_get_latest_unprojected_detect_job_summary.return_value = {"job_id": 102, "job_code": "finished-job"}
|
||||
|
||||
snapshots = _select_projection_job_snapshots()
|
||||
|
||||
self.assertEqual(["running-job", "finished-job"], [item["job_code"] for item in snapshots])
|
||||
|
||||
@patch("app.sync_agent.get_latest_unprojected_detect_job_summary")
|
||||
@patch("app.sync_agent.get_active_detect_job_summary")
|
||||
def test_select_projection_job_snapshots_deduplicates_same_job(
|
||||
self,
|
||||
mock_get_active_detect_job_summary,
|
||||
mock_get_latest_unprojected_detect_job_summary,
|
||||
) -> None:
|
||||
mock_get_active_detect_job_summary.return_value = {"job_id": 101, "job_code": "same-job"}
|
||||
mock_get_latest_unprojected_detect_job_summary.return_value = {"job_id": 101, "job_code": "same-job"}
|
||||
|
||||
snapshots = _select_projection_job_snapshots()
|
||||
|
||||
self.assertEqual(1, len(snapshots))
|
||||
self.assertEqual("same-job", snapshots[0]["job_code"])
|
||||
|
||||
def test_build_aligned_queue_health_snapshot_overrides_mismatched_job(self) -> None:
|
||||
active_job = {
|
||||
"job_id": 1891,
|
||||
"job_code": "sync-overseas-31230",
|
||||
"status": "running",
|
||||
"progress_percent": 75.9,
|
||||
"items_total": 1000,
|
||||
"items_pending": 155,
|
||||
"items_claimed": 45,
|
||||
"items_running": 41,
|
||||
"items_completed": 759,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"node_stats": [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 710,
|
||||
"items_claimed": 45,
|
||||
"items_running": 41,
|
||||
"items_completed": 624,
|
||||
}
|
||||
],
|
||||
}
|
||||
queue_health = {
|
||||
"job": {"job_code": "sync-overseas-31273"},
|
||||
"queue": {"items_total": 1000, "pending": 838, "claimed": 162, "running": 0, "completed": 0},
|
||||
"nodes": [{"node_code": "mainland-controller-01", "items_total": 162}],
|
||||
}
|
||||
|
||||
data = _build_aligned_queue_health_snapshot(active_job, queue_health)
|
||||
|
||||
self.assertEqual("sync-overseas-31230", data["job"]["job_code"])
|
||||
self.assertEqual(155, data["queue"]["pending"])
|
||||
self.assertEqual(45, data["queue"]["claimed"])
|
||||
self.assertEqual(41, data["queue"]["running"])
|
||||
self.assertEqual(759, data["queue"]["completed"])
|
||||
self.assertEqual("mainland-worker-01", data["nodes"][0]["node_code"])
|
||||
self.assertEqual("unassigned", data["nodes"][-1]["node_code"])
|
||||
|
||||
@patch("app.sync_agent.push_debug_event")
|
||||
def test_emit_sync_result_breakdown_marks_idle_projection_as_idle(self, mock_push_debug_event) -> None:
|
||||
_emit_sync_result_breakdown(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"sync_type": "detect_result_projection",
|
||||
"ok": False,
|
||||
"message": "当前没有可推送的detect_result_projection",
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
mock_push_debug_event.assert_called_once()
|
||||
self.assertEqual("detect_result_projection_sync_idle", mock_push_debug_event.call_args.kwargs["event_type"])
|
||||
self.assertEqual("info", mock_push_debug_event.call_args.kwargs["level"])
|
||||
|
||||
@patch("app.sync_agent.push_debug_event")
|
||||
def test_emit_structured_tick_marks_idle_sync_push_as_idle(self, mock_push_debug_event) -> None:
|
||||
_emit_structured_tick(
|
||||
base_event_type="sync_push",
|
||||
ok=False,
|
||||
message="当前没有需要立即推送的同步投影",
|
||||
data={"results": []},
|
||||
)
|
||||
|
||||
mock_push_debug_event.assert_called_once()
|
||||
self.assertEqual("sync_push_idle", mock_push_debug_event.call_args.kwargs["event_type"])
|
||||
self.assertEqual("info", mock_push_debug_event.call_args.kwargs["level"])
|
||||
|
||||
def test_filter_runtime_events_for_job_discards_mismatched_jobs(self) -> None:
|
||||
events = _filter_runtime_events_for_job(
|
||||
[
|
||||
{
|
||||
"job_id": 2137,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "job_created",
|
||||
"message": "new job",
|
||||
"payload": {"source_record_id": 35461},
|
||||
"created_at": "2026-04-22 01:21:29",
|
||||
},
|
||||
{
|
||||
"job_id": 1902,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "worker_log",
|
||||
"message": "claim batch",
|
||||
"payload": {"job_code": "sync-overseas-31437"},
|
||||
"created_at": "2026-04-22 01:19:25",
|
||||
},
|
||||
],
|
||||
job_code="sync-overseas-31437",
|
||||
job_id=1902,
|
||||
limit=8,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual("worker_log", events[0]["event_type"])
|
||||
|
||||
@patch("app.sync_agent.push_debug_event")
|
||||
@patch("app.sync_agent.process_detect_pipeline_now")
|
||||
def test_run_pipeline_stage_processor_uses_high_default_limit(
|
||||
self,
|
||||
mock_process_detect_pipeline_now,
|
||||
mock_push_debug_event,
|
||||
) -> None:
|
||||
mock_process_detect_pipeline_now.return_value = (
|
||||
True,
|
||||
"pipeline ok",
|
||||
{"processed_items": 512},
|
||||
)
|
||||
|
||||
ok, message, data = _run_pipeline_stage_processor()
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("pipeline ok", message)
|
||||
self.assertEqual({"processed_items": 512}, data)
|
||||
mock_process_detect_pipeline_now.assert_called_once_with(limit=5000)
|
||||
self.assertEqual("pipeline_tick_success", mock_push_debug_event.call_args.kwargs["event_type"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,7 +2,10 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.sync_push_service import (
|
||||
_build_task_pull_backlog_limits,
|
||||
_extract_detect_result_projection_events,
|
||||
_resolve_detect_result_target_job_id,
|
||||
_should_throttle_task_pull,
|
||||
ingest_runtime_projection,
|
||||
)
|
||||
|
||||
@@ -46,6 +49,61 @@ class _FakeConnection:
|
||||
|
||||
|
||||
class SyncPushServiceTests(unittest.TestCase):
|
||||
def test_build_task_pull_backlog_limits_scales_with_thread_configuration(self) -> None:
|
||||
limits = _build_task_pull_backlog_limits(
|
||||
5000,
|
||||
settings_payload={
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {
|
||||
"mainland-controller-01": 2000,
|
||||
"mainland-worker-01": 1200,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(3200, limits["estimated_total_threads"])
|
||||
self.assertEqual(6400, limits["max_pending_total"])
|
||||
self.assertEqual(3200, limits["max_register_pending"])
|
||||
self.assertEqual(800, limits["max_downstream_pending"])
|
||||
|
||||
def test_should_throttle_task_pull_when_register_backlog_overwhelms_downstream(self) -> None:
|
||||
should_throttle, reason = _should_throttle_task_pull(
|
||||
{
|
||||
"pending_total": 9438,
|
||||
"claimed_total": 410,
|
||||
"running_total": 15,
|
||||
"register_pending": 8487,
|
||||
"downstream_pending": 951,
|
||||
},
|
||||
{
|
||||
"max_pending_total": 12000,
|
||||
"max_register_pending": 3200,
|
||||
"max_downstream_pending": 800,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(should_throttle)
|
||||
self.assertEqual("register_pending", reason)
|
||||
|
||||
def test_should_not_throttle_task_pull_when_backlog_is_within_limits(self) -> None:
|
||||
should_throttle, reason = _should_throttle_task_pull(
|
||||
{
|
||||
"pending_total": 1800,
|
||||
"claimed_total": 100,
|
||||
"running_total": 300,
|
||||
"register_pending": 600,
|
||||
"downstream_pending": 120,
|
||||
},
|
||||
{
|
||||
"max_pending_total": 6400,
|
||||
"max_register_pending": 3200,
|
||||
"max_downstream_pending": 800,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(should_throttle)
|
||||
self.assertEqual("", reason)
|
||||
|
||||
def test_extract_detect_result_projection_events_adds_import_metadata(self) -> None:
|
||||
projection = {
|
||||
"job": {
|
||||
@@ -79,6 +137,43 @@ class SyncPushServiceTests(unittest.TestCase):
|
||||
self.assertTrue(event["payload"]["imported_from_projection"])
|
||||
self.assertTrue(event["payload"]["import_fingerprint"])
|
||||
|
||||
@patch("app.services.sync_push_service.get_db")
|
||||
def test_resolve_detect_result_target_job_id_prefers_matching_job_code(self, mock_get_db) -> None:
|
||||
fake_conn = _FakeConnection(rows=[(456,)])
|
||||
mock_get_db.return_value = fake_conn
|
||||
|
||||
target_job_id = _resolve_detect_result_target_job_id(
|
||||
projection={
|
||||
"job": {
|
||||
"job_code": "sync-mainland-123",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(456, target_job_id)
|
||||
self.assertIn("WHERE job_code = %s", fake_conn.cursor_obj.executed[0][0])
|
||||
|
||||
@patch("app.services.detect_job_service.get_active_detect_job_summary")
|
||||
@patch("app.services.sync_push_service.get_db")
|
||||
def test_resolve_detect_result_target_job_id_falls_back_to_active_job(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_get_active_detect_job_summary,
|
||||
) -> None:
|
||||
fake_conn = _FakeConnection(rows=[None])
|
||||
mock_get_db.return_value = fake_conn
|
||||
mock_get_active_detect_job_summary.return_value = {"job_id": 789}
|
||||
|
||||
target_job_id = _resolve_detect_result_target_job_id(
|
||||
projection={
|
||||
"job": {
|
||||
"job_code": "sync-mainland-999",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(789, target_job_id)
|
||||
|
||||
@patch("app.services.sync_push_service._import_detect_result_projection_events")
|
||||
@patch("app.services.sync_push_service.get_db")
|
||||
def test_ingest_runtime_projection_imports_detect_result_events_on_new_record(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.sync_record_service import _collect_recent_domain_events
|
||||
from app.services.sync_record_service import (
|
||||
_build_runtime_projection_payload,
|
||||
_collect_recent_domain_events,
|
||||
)
|
||||
|
||||
|
||||
class SyncRecordServiceTests(unittest.TestCase):
|
||||
@@ -50,6 +54,82 @@ class SyncRecordServiceTests(unittest.TestCase):
|
||||
self.assertEqual("domain_completed", events[1]["event_type"])
|
||||
self.assertEqual("a.com", events[1]["payload"]["domain"])
|
||||
|
||||
@patch("app.services.sync_record_service._resolve_local_ip", return_value="152.53.37.118")
|
||||
@patch("app.services.sync_record_service.socket.gethostname", return_value="overseas-control-01")
|
||||
@patch("app.services.sync_record_service.settings")
|
||||
def test_build_runtime_projection_payload_zeros_execution_for_overseas_control(
|
||||
self,
|
||||
mock_settings,
|
||||
_mock_hostname,
|
||||
_mock_resolve_ip,
|
||||
) -> None:
|
||||
mock_settings.node_code = "overseas-control-01"
|
||||
mock_settings.node_region = "overseas"
|
||||
mock_settings.node_role = "control"
|
||||
mock_settings.sync_source_region = "overseas"
|
||||
mock_settings.sync_target_region = "mainland"
|
||||
|
||||
payload = _build_runtime_projection_payload(
|
||||
detect={
|
||||
"worker_online": False,
|
||||
"worker_mode": "linux-systemd",
|
||||
"active_thread_count": 0,
|
||||
"max_thread_count": 0,
|
||||
"phase_label": "当前节点不承载",
|
||||
"phase_detail": "当前节点为海外控制面,仅承载 API 控制与同步接收,不执行本机检测任务。",
|
||||
"proxy_runtime_label": "不适用",
|
||||
"proxy_runtime_reason": "not_applicable",
|
||||
"progress": {
|
||||
"pending": 125,
|
||||
"running": 293,
|
||||
"completed": 71,
|
||||
"blacklisted": 0,
|
||||
"failed": 1,
|
||||
},
|
||||
"active_job": {
|
||||
"job_id": 2027,
|
||||
"job_code": "sync-overseas-33501",
|
||||
"status": "running",
|
||||
"progress_percent": 7.2,
|
||||
"items_total": 1000,
|
||||
"items_terminal": 72,
|
||||
"items_pending": 125,
|
||||
"items_running": 293,
|
||||
"items_failed": 1,
|
||||
"node_stats": [{"node_code": "mainland-controller-01", "items_running": 293}],
|
||||
},
|
||||
},
|
||||
cluster={
|
||||
"nodes_total": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "overseas-control-01",
|
||||
"current_load": 293,
|
||||
"detect_participating": True,
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"online_worker_nodes": 3,
|
||||
"dedicated_online_worker_nodes": 1,
|
||||
"online_control_nodes": 2,
|
||||
"busy_nodes": ["overseas-control-01"],
|
||||
"stale_nodes": [],
|
||||
"offline_nodes": [],
|
||||
},
|
||||
},
|
||||
source_region="overseas",
|
||||
target_region="mainland",
|
||||
)
|
||||
|
||||
projection = payload["projection"]
|
||||
self.assertFalse(projection["worker_online"])
|
||||
self.assertFalse(projection["detect_participating"])
|
||||
self.assertEqual(0, projection["active_thread_count"])
|
||||
self.assertEqual(0, projection["max_thread_count"])
|
||||
self.assertEqual(0, projection["progress"]["running"])
|
||||
self.assertEqual("", projection["active_job"]["job_code"])
|
||||
self.assertEqual([], projection["active_job"]["node_stats"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user