from __future__ import annotations from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiops.infra.config.services import NestedInsightSetting, ServicesSetting from aiops.models.guardian import nested_insight as ni_models from aiops.service.guardian.nested_insight.service import ( NestedInsightService, WindowSnapshot, ) class _FakeScalarsResult: def __init__(self, items: list[object]) -> None: self._items = items def all(self) -> list[object]: return list(self._items) def first(self) -> object | None: return self._items[0] if self._items else None class _FakeSession: def __init__(self, items: list[object] | None = None) -> None: self._result = _FakeScalarsResult(items or []) async def scalars(self, stmt: object) -> _FakeScalarsResult: # noqa: ARG002 return self._result class _FakeDatabase: def __init__(self, session: _FakeSession) -> None: self._session = session @asynccontextmanager async def atransaction(self): # type: ignore[no-untyped-def] yield self._session @pytest.fixture def base_service() -> NestedInsightService: logger = MagicMock() db = _FakeDatabase(_FakeSession()) sched = MagicMock() asset = MagicMock() service_llm = MagicMock() config = NestedInsightSetting() services_config = ServicesSetting() return NestedInsightService( logger=logger, db=db, sched=sched, asset=asset, service_llm=service_llm, config=config, analysis_storage_dir=services_config.analysis_storage_dir, ) @pytest.mark.asyncio async def test_prepare_window_updates_completed( base_service: NestedInsightService, ) -> None: window = SimpleNamespace( id=1, tenant_id="t1", cluster_id="c1", start_at=datetime.now(timezone.utc), end_at=datetime.now(timezone.utc) + timedelta(minutes=10), status=ni_models.NestedInsightStatus.WATCHING, final_report_s3_key="s3://report", ) repo = MagicMock() repo.get_window = AsyncMock(return_value=window) repo.update_window_status = AsyncMock() with patch( "aiops.service.guardian.nested_insight.service.AsyncNestedInsightRepo", return_value=repo, ): result = await base_service._prepare_window_for_analysis(window.id) assert result is None repo.update_window_status.assert_awaited_once_with( window, ni_models.NestedInsightStatus.COMPLETED ) @pytest.mark.asyncio async def test_prepare_window_collects_linked_ids() -> None: window = SimpleNamespace( id=7, tenant_id="t2", cluster_id="c2", start_at=datetime.now(timezone.utc) - timedelta(minutes=5), end_at=datetime.now(timezone.utc) + timedelta(minutes=5), status=ni_models.NestedInsightStatus.WATCHING, final_report_s3_key=None, ) analysis_models = [SimpleNamespace(id=11), SimpleNamespace(id=12)] session = _FakeSession(analysis_models) db = _FakeDatabase(session) repo = MagicMock() repo.get_window = AsyncMock(return_value=window) repo.get_linked_analysis_ids = AsyncMock(return_value=[]) repo.update_window_status = AsyncMock() repo.transition_window_status = AsyncMock(return_value=True) service = NestedInsightService( logger=MagicMock(), db=db, sched=MagicMock(), asset=MagicMock(), service_llm=MagicMock(), config=NestedInsightSetting(), analysis_storage_dir=ServicesSetting().analysis_storage_dir, ) with patch( "aiops.service.guardian.nested_insight.service.AsyncNestedInsightRepo", return_value=repo, ): snapshot, linked_ids = await service._prepare_window_for_analysis(window.id) assert isinstance(snapshot, WindowSnapshot) assert snapshot.id == window.id assert linked_ids == [11, 12] repo.transition_window_status.assert_awaited_once_with( window.id, ni_models.NestedInsightStatus.WATCHING, ni_models.NestedInsightStatus.ANALYZING, ) @pytest.mark.asyncio async def test_prepare_window_skips_non_watching( base_service: NestedInsightService, ) -> None: window = SimpleNamespace( id=9, tenant_id="t3", cluster_id="c3", start_at=datetime.now(timezone.utc), end_at=datetime.now(timezone.utc) + timedelta(minutes=10), status=ni_models.NestedInsightStatus.ANALYZING, final_report_s3_key=None, ) repo = MagicMock() repo.get_window = AsyncMock(return_value=window) with patch( "aiops.service.guardian.nested_insight.service.AsyncNestedInsightRepo", return_value=repo, ): result = await base_service._prepare_window_for_analysis(window.id) assert result is None assert not repo.update_window_status.called @pytest.mark.asyncio async def test_reset_stuck_windows(base_service: NestedInsightService) -> None: window1 = SimpleNamespace( id=1, updated_at=datetime.now(timezone.utc) - timedelta(hours=1), ) window2 = SimpleNamespace( id=2, updated_at=datetime.now(timezone.utc) - timedelta(hours=2), ) repo = MagicMock() repo.list_stuck_analyzing_windows = AsyncMock(return_value=[window1, window2]) repo.update_window_status = AsyncMock() with patch( "aiops.service.guardian.nested_insight.service.AsyncNestedInsightRepo", return_value=repo, ): await base_service._reset_stuck_windows(datetime.now(timezone.utc)) assert repo.update_window_status.await_count == 2 repo.update_window_status.assert_any_await( window1, ni_models.NestedInsightStatus.WATCHING ) repo.update_window_status.assert_any_await( window2, ni_models.NestedInsightStatus.WATCHING ) @pytest.mark.asyncio async def test_list_windows_to_close(base_service: NestedInsightService) -> None: window1 = SimpleNamespace(id=101) window2 = SimpleNamespace(id=102) repo = MagicMock() repo.list_windows_to_close = AsyncMock(return_value=[window1, window2]) with patch( "aiops.service.guardian.nested_insight.service.AsyncNestedInsightRepo", return_value=repo, ): ids = await base_service._list_windows_to_close( datetime.now(timezone.utc) - timedelta(minutes=10), datetime.now(timezone.utc), ) assert ids == [101, 102] @pytest.mark.asyncio async def test_mark_window_completed_sets_key( base_service: NestedInsightService, ) -> None: window = SimpleNamespace( id=55, final_report_s3_key=None, ) repo = MagicMock() repo.get_window = AsyncMock(return_value=window) repo.mark_window_completed = AsyncMock() with patch( "aiops.service.guardian.nested_insight.service.AsyncNestedInsightRepo", return_value=repo, ): await base_service._mark_window_completed(window.id, "s3://report") assert window.final_report_s3_key == "s3://report" repo.mark_window_completed.assert_awaited_once_with(window)