From 207460e9417d231732b6340c1a23e0200c1b5dcb Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 2 Nov 2025 00:07:55 -0600 Subject: [PATCH] Fix agent listing and add scheduler timing tests --- Data/Engine/Unit_Tests/conftest.py | 27 ++++++ Data/Engine/Unit_Tests/test_devices_api.py | 12 +++ .../Unit_Tests/test_scheduler_timing.py | 83 +++++++++++++++++++ .../Engine/services/API/devices/management.py | 6 +- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 Data/Engine/Unit_Tests/test_scheduler_timing.py diff --git a/Data/Engine/Unit_Tests/conftest.py b/Data/Engine/Unit_Tests/conftest.py index 2e0e0939..6d2084e2 100644 --- a/Data/Engine/Unit_Tests/conftest.py +++ b/Data/Engine/Unit_Tests/conftest.py @@ -16,6 +16,33 @@ from typing import Iterator import pytest from flask import Flask +import sys +import types + +import importlib.machinery + + +def _ensure_eventlet_stub() -> None: + if "eventlet" in sys.modules: + return + eventlet_module = types.ModuleType("eventlet") + eventlet_module.monkey_patch = lambda **_kwargs: None # type: ignore[attr-defined] + eventlet_module.sleep = lambda _seconds: None # type: ignore[attr-defined] + + wsgi_module = types.ModuleType("eventlet.wsgi") + wsgi_module.HttpProtocol = object() # type: ignore[attr-defined] + + eventlet_module.wsgi = wsgi_module # type: ignore[attr-defined] + + eventlet_module.__spec__ = importlib.machinery.ModuleSpec("eventlet", loader=None) + wsgi_module.__spec__ = importlib.machinery.ModuleSpec("eventlet.wsgi", loader=None) + + sys.modules["eventlet"] = eventlet_module + sys.modules["eventlet.wsgi"] = wsgi_module + + +_ensure_eventlet_stub() + from Data.Engine.server import create_app diff --git a/Data/Engine/Unit_Tests/test_devices_api.py b/Data/Engine/Unit_Tests/test_devices_api.py index 9b18ff97..c77cb1b0 100644 --- a/Data/Engine/Unit_Tests/test_devices_api.py +++ b/Data/Engine/Unit_Tests/test_devices_api.py @@ -37,6 +37,18 @@ def test_list_devices(engine_harness: EngineTestHarness) -> None: assert "summary" in device and isinstance(device["summary"], dict) +def test_list_agents(engine_harness: EngineTestHarness) -> None: + client = engine_harness.app.test_client() + response = client.get("/api/agents") + assert response.status_code == 200 + payload = response.get_json() + assert isinstance(payload, dict) + assert payload, "expected at least one agent in the response" + first_agent = next(iter(payload.values())) + assert first_agent["hostname"] == "test-device" + assert first_agent["agent_id"] == "test-device-agent" + + def test_device_details(engine_harness: EngineTestHarness) -> None: client = engine_harness.app.test_client() response = client.get("/api/device/details/test-device") diff --git a/Data/Engine/Unit_Tests/test_scheduler_timing.py b/Data/Engine/Unit_Tests/test_scheduler_timing.py new file mode 100644 index 00000000..780ed5b6 --- /dev/null +++ b/Data/Engine/Unit_Tests/test_scheduler_timing.py @@ -0,0 +1,83 @@ +# ====================================================== +# Data\Engine\Unit_Tests\test_scheduler_timing.py +# Description: Validates the Engine job scheduler's interval calculations to +# ensure jobs are queued on the expected cadence. +# +# API Endpoints (if applicable): None +# ====================================================== + +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from typing import Callable, List, Tuple + +from flask import Flask + +from Data.Engine.services.API.scheduled_jobs import job_scheduler + + +class _DummySocketIO: + def __init__(self) -> None: + self.started_tasks: List[Tuple[Callable, tuple, dict]] = [] + + def start_background_task(self, target: Callable, *args, **kwargs): + # The scheduler calls into Socket.IO to spawn the background loop. + # Tests only verify the scheduling math, so we capture the request + # without launching a greenlet/thread. + self.started_tasks.append((target, args, kwargs)) + return None + + def emit(self, *_args, **_kwargs): + return None + + +def _make_scheduler(tmp_path: Path) -> job_scheduler.JobScheduler: + app = Flask(__name__) + db_path = tmp_path / "scheduler.sqlite3" + return job_scheduler.JobScheduler(app, _DummySocketIO(), str(db_path)) + + +def _ts(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> int: + return int(dt.datetime(year, month, day, hour, minute, tzinfo=dt.timezone.utc).timestamp()) + + +def test_immediate_schedule_runs_once(tmp_path): + scheduler = _make_scheduler(tmp_path) + now = _ts(2024, 3, 1, 12, 0) + assert scheduler._compute_next_run("immediately", None, None, now) == now + assert scheduler._compute_next_run("immediately", None, now, now) is None + + +def test_hourly_schedule_advances_increments(tmp_path): + scheduler = _make_scheduler(tmp_path) + start = _ts(2024, 3, 1, 9, 0) + now = _ts(2024, 3, 1, 9, 30) + assert scheduler._compute_next_run("every_hour", start, None, now) == start + next_candidate = scheduler._compute_next_run("every_hour", start, start, now) + assert next_candidate == _ts(2024, 3, 1, 10, 0) + + +def test_daily_schedule_rolls_forward(tmp_path): + scheduler = _make_scheduler(tmp_path) + start = _ts(2024, 3, 1, 6, 15) + after_two_days = _ts(2024, 3, 3, 5, 0) + next_run = scheduler._compute_next_run("daily", start, start, after_two_days) + assert next_run == _ts(2024, 3, 2, 6, 15) + + +def test_monthly_schedule_handles_late_month(tmp_path): + scheduler = _make_scheduler(tmp_path) + start = _ts(2024, 1, 31, 8, 0) + after_two_months = _ts(2024, 3, 5, 8, 0) + next_run = scheduler._compute_next_run("monthly", start, start, after_two_months) + # February 2024 has 29 days, so the scheduler should clamp to Feb 29th. + assert next_run == _ts(2024, 2, 29, 8, 0) + + +def test_yearly_schedule_rolls_forward(tmp_path): + scheduler = _make_scheduler(tmp_path) + start = _ts(2023, 2, 28, 0, 0) + after_two_years = _ts(2025, 3, 1, 0, 0) + next_run = scheduler._compute_next_run("yearly", start, start, after_two_years) + assert next_run == _ts(2024, 2, 28, 0, 0) diff --git a/Data/Engine/services/API/devices/management.py b/Data/Engine/services/API/devices/management.py index a332d87e..2b213d9e 100644 --- a/Data/Engine/services/API/devices/management.py +++ b/Data/Engine/services/API/devices/management.py @@ -615,7 +615,11 @@ class DeviceManagementService: payload["agent_id"] = agent_key agents[agent_key] = payload - return {"agents": agents}, 200 + # The legacy server exposed /api/agents as a mapping keyed by + # agent identifier. The Engine WebUI expects the same structure, + # so we return the flattened dictionary directly instead of + # wrapping it in another object. + return agents, 200 except Exception as exc: self.logger.debug("Failed to list agents", exc_info=True) return {"error": str(exc)}, 500