Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
"""Unit tests for HealthCollector state-change pub/sub logic (#3404)."""

import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from slm.agent.health_collector import _STATE_CHANGE_CHANNEL_TEMPLATE, HealthCollector

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -151,19 +155,58 @@ def test_redis_exception_does_not_raise(self):
# NotificationEvent.SERVICE_FAILED round-trip (import smoke test)
# ---------------------------------------------------------------------------

# The consumer of the published event is autobot-backend's NotificationService
# (the OTHER backend) — "services.notification_service" does not exist in the
# slm-backend tree, so the bare import could never resolve here (#11798).
# Spec-load the real module from the repo checkout under a private name, with
# its environment-bound deps stubbed (ssot_config reads /etc/autobot). Skip
# when the sibling checkout is not present (deployment layouts). Resolved by
# upward search so the slm/agent and ansible copies (drift-checked identical)
# both find the repo root from their different depths.


def _find_backend_notif_path():
for _parent in Path(__file__).resolve().parents:
_cand = _parent / "autobot-backend" / "services" / "notification_service.py"
if _cand.exists():
return _cand
return None


_BACKEND_NOTIF_PATH = _find_backend_notif_path()


def _load_backend_notification_service():
import importlib.util

stubs = {
"constants": MagicMock(),
"constants.ttl_constants": MagicMock(TTL_7_DAYS=7 * 24 * 3600),
"autobot_shared.ssot_config": MagicMock(config=MagicMock()),
"autobot_shared.redis_client": MagicMock(),
"autobot_shared.logging_manager": MagicMock(get_logger=MagicMock(return_value=MagicMock())),
}
with patch.dict(sys.modules, stubs):
spec = importlib.util.spec_from_file_location("_backend_notification_service", _BACKEND_NOTIF_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


class TestServiceFailedEvent:
@pytest.mark.skipif(_BACKEND_NOTIF_PATH is None, reason="autobot-backend checkout not present (env-bound)")
def test_service_failed_enum_value(self):
from services.notification_service import NotificationEvent
mod = _load_backend_notification_service()

assert NotificationEvent.SERVICE_FAILED.value == "service_failed"
assert mod.NotificationEvent.SERVICE_FAILED.value == "service_failed"

@pytest.mark.skipif(_BACKEND_NOTIF_PATH is None, reason="autobot-backend checkout not present (env-bound)")
def test_service_failed_template_renders(self):
from services.notification_service import NotificationEvent, NotificationService
mod = _load_backend_notification_service()

svc = NotificationService()
svc = mod.NotificationService()
result = svc.render_template(
NotificationEvent.SERVICE_FAILED.value,
mod.NotificationEvent.SERVICE_FAILED.value,
{
"service": "nginx",
"hostname": "node-01",
Expand Down
39 changes: 31 additions & 8 deletions autobot-slm-backend/api/code_source_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,35 @@ def __init__(self):
spec = __import__("importlib.util").util.spec_from_file_location("code_source_module", code_source_path)
code_source_module = __import__("importlib.util").util.module_from_spec(spec)

# Mock dependencies before loading the module
sys.modules["services.auth"] = MagicMock()
sys.modules["services.database"] = MagicMock()
sys.modules["models.database"] = MagicMock()
# Mock dependencies before loading the module — snapshot/restore (#11798):
# the previous unconditional sys.modules writes permanently replaced the root
# conftest stubs WITHOUT rebinding the parent-package attributes, so
# mock.patch("services.database.…") in later sweep files (tests/api/
# test_fleet_node_update_11511.py) patched the old module object while
# runtime imports resolved the new one (#9780 divergence).
_mock_config = MagicMock()
_mock_config.settings.external_url = "http://10.0.0.9:8080"
sys.modules["config"] = _mock_config

spec.loader.exec_module(code_source_module)
_STUBS = {
"services.auth": MagicMock(),
"services.database": MagicMock(),
"models.database": MagicMock(),
"config": _mock_config,
}
_prev_modules = {name: sys.modules.get(name) for name in _STUBS}
sys.modules.update(_STUBS)
try:
spec.loader.exec_module(code_source_module)
finally:
for _name, _prev in _prev_modules.items():
if _prev is None:
sys.modules.pop(_name, None)
else:
sys.modules[_name] = _prev

# Register under the spec name so patch("code_source_module.…") can resolve the
# target — mock.patch imports the path, and an unregistered exec'd module raises
# ModuleNotFoundError for every test using _LOCAL_NODE_PATCH (#11798).
sys.modules["code_source_module"] = code_source_module

_find_similar_paths = code_source_module._find_similar_paths
_validate_repo_path = code_source_module._validate_repo_path
Expand Down Expand Up @@ -156,7 +176,10 @@ async def test_find_similar_paths_case_mismatch(self, mock_node):

result = await _find_similar_paths(mock_node, "/home/${USER:-autobot}/Desktop/autobot")

assert result == "${AUTOBOT_PROJECT_ROOT:-/opt/autobot/code_source}"
# The helper returns parent-dir + the case-corrected entry from the
# mocked ls output ("AutoBot"), NOT a fallback root (#11798 — a
# mechanical path-literal sweep had rotted this expectation).
assert result == "/home/${USER:-autobot}/Desktop/AutoBot"

@pytest.mark.asyncio
async def test_find_similar_paths_no_match(self, mock_node):
Expand Down
110 changes: 74 additions & 36 deletions autobot-slm-backend/main_ensure_local_node_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,56 @@
full FastAPI application stack.
"""

import contextlib
import importlib
import importlib.util
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Path bootstrap
# ---------------------------------------------------------------------------
_SLM_ROOT = Path(__file__).parent.parent
# This file sits directly in autobot-slm-backend/, so the SLM root is
# .parent — .parent.parent resolved to the REPO root, whose (different)
# main.py has no _ensure_local_node, erroring every test at setup (#11798).
_SLM_ROOT = Path(__file__).parent
if str(_SLM_ROOT) not in sys.path:
sys.path.insert(0, str(_SLM_ROOT))

# ---------------------------------------------------------------------------
# Stub models.database before any module load so that the lazy import inside
# _ensure_local_node() resolves at function-call time. Python looks up
# "models" and "models.database" as separate sys.modules keys; both must be
# present or the submodule lookup raises ImportError.
# models.database stand-ins for the lazy import inside _ensure_local_node().
# #11798: the old MagicMock Node/NodeRole never carried constructor kwargs as
# attributes, so none of the created-row assertions could hold; and the old
# ``sys.modules.setdefault`` never installed anything because the root
# conftest already stubs "models.database". Kwargs-to-attributes factories
# are swapped in per-test via the autouse fixture below (patch.dict restores).
# ---------------------------------------------------------------------------


class _ColumnMeta(type):
"""Class-level attribute access (Node.node_id in select().where()) → mock column."""

def __getattr__(cls, name):
return MagicMock(name=f"{cls.__name__}.{name}")


class _AttrObj(metaclass=_ColumnMeta):
"""Minimal ORM-row stand-in: constructor kwargs become attributes."""

def __init__(self, **kwargs):
self.__dict__.update(kwargs)


_models_db_stub = MagicMock()
_models_db_stub.Node = MagicMock()
_models_db_stub.NodeRole = MagicMock()
_models_db_stub.NodeStatus = MagicMock()
sys.modules.setdefault("models", MagicMock())
sys.modules.setdefault("models.database", _models_db_stub)
_models_db_stub.Node = type("Node", (_AttrObj,), {})
_models_db_stub.NodeRole = type("NodeRole", (_AttrObj,), {})
_models_db_stub.NodeStatus = SimpleNamespace(ONLINE=SimpleNamespace(value="online"))

# ---------------------------------------------------------------------------
# Constants that must match main.py
Expand Down Expand Up @@ -89,19 +110,26 @@ def _load_module():
stubs = {
"fastapi": MagicMock(),
"fastapi.middleware.cors": MagicMock(),
# main.py imports grown since this list was written (#11798):
"fastapi.responses": MagicMock(),
"api": MagicMock(),
"api.code_source": MagicMock(),
"api.performance": MagicMock(),
"api.personality_proxy": MagicMock(),
"api.roles": MagicMock(),
"api.voice_proxy": MagicMock(),
"autobot_shared.integrity_manifest": MagicMock(),
"config": MagicMock(),
"middleware": MagicMock(),
"services.a2a_card_fetcher": MagicMock(),
"services.auth": MagicMock(),
"services.compose_fleet": MagicMock(),
"services.database": MagicMock(),
"services.git_tracker": MagicMock(),
"services.node_seeder": MagicMock(),
"services.reconciler": MagicMock(),
"services.schedule_executor": MagicMock(),
"services.security_posture_auditor": MagicMock(),
}
with patch.dict(sys.modules, stubs):
spec = importlib.util.spec_from_file_location("isolated_main", _SLM_ROOT / "main.py")
Expand All @@ -112,6 +140,31 @@ def _load_module():
return mod


@pytest.fixture(autouse=True)
def _models_db_active():
"""Swap the attribute-bearing models.database stand-in in per test."""
with patch.dict(sys.modules, {"models": MagicMock(), "models.database": _models_db_stub}):
yield


def _patched_seams(mod, mock_db):
"""ExitStack patching the module seams every test needs (#11798).

- ``settings.slm_node_id``: env-driven since #9956 — pin the sentinel.
- ``_compose_nodes_enabled``: the stubbed compose_fleet returns a truthy
MagicMock, which would route every test into the compose static-IP branch.
- ``sync_slm_node_roles``: the stub module attribute is not awaitable.
"""
stack = contextlib.ExitStack()
stack.enter_context(patch.object(mod, "db_service", mock_db))
_settings = MagicMock()
_settings.slm_node_id = _NODE_ID
stack.enter_context(patch.object(mod, "settings", _settings))
stack.enter_context(patch.object(mod, "_compose_nodes_enabled", MagicMock(return_value=False)))
stack.enter_context(patch.object(mod, "sync_slm_node_roles", AsyncMock()))
return stack


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
Expand All @@ -135,10 +188,7 @@ async def test_creates_node_when_absent(self):
mock_db = MagicMock()
mock_db.session = MagicMock(return_value=_ctx(session))

with (
patch.object(self._mod, "db_service", mock_db),
patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://192.168.1.50"}),
):
with _patched_seams(self._mod, mock_db), patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://192.168.1.50"}):
await self._fn()

# 1 Node + 4 NodeRole = 5 objects
Expand Down Expand Up @@ -168,14 +218,14 @@ async def test_idempotent_when_ip_unchanged(self):
mock_db = MagicMock()
mock_db.session = MagicMock(return_value=_ctx(session))

with (
patch.object(self._mod, "db_service", mock_db),
patch.dict(os.environ, {"SLM_EXTERNAL_URL": f"https://{current_ip}"}),
):
with _patched_seams(self._mod, mock_db), patch.dict(os.environ, {"SLM_EXTERNAL_URL": f"https://{current_ip}"}):
await self._fn()

session.add.assert_not_called()
session.commit.assert_not_awaited()
# #11798: the existing-node path now also heals detected_roles and
# syncs role rows, committing unconditionally — "idempotent" means no
# NEW rows, not no commit.
session.commit.assert_awaited_once()

# ------------------------------------------------------------------
# Node present, IP stale — updates IP field only
Expand All @@ -191,10 +241,7 @@ async def test_heals_stale_ip(self):
mock_db = MagicMock()
mock_db.session = MagicMock(return_value=_ctx(session))

with (
patch.object(self._mod, "db_service", mock_db),
patch.dict(os.environ, {"SLM_EXTERNAL_URL": f"https://{new_ip}"}),
):
with _patched_seams(self._mod, mock_db), patch.dict(os.environ, {"SLM_EXTERNAL_URL": f"https://{new_ip}"}):
await self._fn()

assert existing.ip_address == new_ip
Expand All @@ -216,10 +263,7 @@ async def test_fallback_ip_when_no_env(self):

env_without_url = {k: v for k, v in os.environ.items() if k != "SLM_EXTERNAL_URL"}

with (
patch.object(self._mod, "db_service", mock_db),
patch.dict(os.environ, env_without_url, clear=True),
):
with _patched_seams(self._mod, mock_db), patch.dict(os.environ, env_without_url, clear=True):
await self._fn()

node_obj = added_objects[0]
Expand All @@ -238,10 +282,7 @@ async def test_node_id_is_slm_manager_sentinel(self):
mock_db = MagicMock()
mock_db.session = MagicMock(return_value=_ctx(session))

with (
patch.object(self._mod, "db_service", mock_db),
patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://10.10.10.1"}),
):
with _patched_seams(self._mod, mock_db), patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://10.10.10.1"}):
await self._fn()

node_obj = added_objects[0]
Expand All @@ -261,10 +302,7 @@ async def test_node_roles_have_correct_metadata(self):
mock_db = MagicMock()
mock_db.session = MagicMock(return_value=_ctx(session))

with (
patch.object(self._mod, "db_service", mock_db),
patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://10.10.10.1"}),
):
with _patched_seams(self._mod, mock_db), patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://10.10.10.1"}):
await self._fn()

role_objs = added_objects[1:]
Expand All @@ -289,7 +327,7 @@ async def test_parses_url_with_port(self):

# URL includes port — regex must not capture the port as part of IP
with (
patch.object(self._mod, "db_service", mock_db),
_patched_seams(self._mod, mock_db),
patch.dict(os.environ, {"SLM_EXTERNAL_URL": "https://172.16.0.10:8443"}),
):
await self._fn()
Expand Down
27 changes: 23 additions & 4 deletions autobot-slm-backend/services/drift_checker_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,26 @@
_gt_stub.DEFAULT_REPO_PATH = "/opt/autobot/code_source" # type: ignore[attr-defined]
sys.modules.setdefault("services.git_tracker", _gt_stub)

_spec = importlib.util.spec_from_file_location("drift_checker", _MODULE_PATH)
_dc = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
_spec.loader.exec_module(_dc) # type: ignore[union-attr]
# services.deploy_artifacts must be REAL while drift_checker.py executes: the
# root conftest stubs it as a MagicMock, and drift_checker derives
# _SKIP_DIRS / _SKIP_DIR_SUFFIXES from its ARTIFACT_DIRS / ARTIFACT_DIR_SUFFIXES
# at import time — a MagicMock suffix tuple makes every str.endswith() in
# _collect_checksums raise TypeError (#11798). The module is dependency-free,
# so real-load it and restore the previous sys.modules entry afterwards.
_da_spec = importlib.util.spec_from_file_location("services.deploy_artifacts", _SERVICES_DIR / "deploy_artifacts.py")
_da = importlib.util.module_from_spec(_da_spec) # type: ignore[arg-type]
_da_spec.loader.exec_module(_da) # type: ignore[union-attr]
_prev_da = sys.modules.get("services.deploy_artifacts")
sys.modules["services.deploy_artifacts"] = _da
try:
_spec = importlib.util.spec_from_file_location("drift_checker", _MODULE_PATH)
_dc = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
_spec.loader.exec_module(_dc) # type: ignore[union-attr]
finally:
if _prev_da is None:
sys.modules.pop("services.deploy_artifacts", None)
else:
sys.modules["services.deploy_artifacts"] = _prev_da

# Convenience aliases.
_file_checksum = _dc._file_checksum
Expand Down Expand Up @@ -460,12 +477,14 @@ def test_env_var_respected_via_default_repo_path(self, tmp_path, monkeypatch):

class TestAllowedComponents:
def test_allowlist_contains_expected_components(self):
"""The allowlist must contain all four expected component names."""
"""The allowlist must contain exactly the expected component names."""
expected = {
"autobot-slm-backend",
"autobot-slm-frontend",
"autobot-backend",
"autobot-frontend",
# #10248: the shared library is its own syncable component.
"autobot_shared",
}
assert expected == set(ALLOWED_COMPONENTS)

Expand Down
Loading