from datetime import UTC, datetime from pathlib import Path import pytest from app.health import dependency_status, effective_status, validate_dependencies from app.incidents import incident_overview from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate from app.store import Store def _probe(service_id: str, status: HealthStatus) -> ProbeResult: return ProbeResult( id=f"probe-{service_id}-{status.value}", service_id=service_id, status=status, latency_ms=1.0, detail=status.value, checked_at=datetime.now(UTC), ) def test_down_dependency_degrades_healthy_service(tmp_path: Path) -> None: store = Store(str(tmp_path / "servicemap.db")) database = store.create_service( ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) ) api = store.create_service( ServiceCreate( name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id], ) ) store.save_probe(_probe(database.id, HealthStatus.DOWN)) store.save_probe(_probe(api.id, HealthStatus.HEALTHY)) assert dependency_status(api, store) == HealthStatus.DOWN assert effective_status(api, store) == HealthStatus.DEGRADED def test_service_without_dependencies_has_healthy_dependency_state(tmp_path: Path) -> None: store = Store(str(tmp_path / "servicemap.db")) service = store.create_service( ServiceCreate(name="Standalone", kind=ServiceKind.HTTP, url="https://example.test") ) assert dependency_status(service, store) == HealthStatus.HEALTHY assert effective_status(service, store) == HealthStatus.UNKNOWN def test_cycle_validation_rejects_recursive_dependencies(tmp_path: Path) -> None: store = Store(str(tmp_path / "servicemap.db")) database = store.create_service( ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) ) api = store.create_service( ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]) ) with pytest.raises(ValueError, match="cycle"): validate_dependencies([api.id], store, service_id=database.id) validate_dependencies([database.id], store, service_id=api.id) updated = store.update_service( api.id, ServiceUpdate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]), ) assert updated is not None def test_incident_overview_finds_root_cause_and_multihop_impact(tmp_path: Path) -> None: store = Store(str(tmp_path / "servicemap.db")) database = store.create_service( ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) ) api = store.create_service( ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]) ) web = store.create_service( ServiceCreate(name="Web", kind=ServiceKind.HTTP, url="https://web.test", depends_on=[api.id]) ) store.save_probe(_probe(database.id, HealthStatus.DOWN)) store.save_probe(_probe(api.id, HealthStatus.HEALTHY)) store.save_probe(_probe(web.id, HealthStatus.HEALTHY)) assert effective_status(api, store) == HealthStatus.DEGRADED assert dependency_status(web, store) == HealthStatus.DEGRADED assert effective_status(web, store) == HealthStatus.DEGRADED overview = incident_overview(store) assert overview.active is True assert overview.down_service_ids == [database.id] assert set(overview.degraded_service_ids) == {api.id, web.id} assert overview.candidates[0].service_id == database.id assert api.id in overview.candidates[0].affected_service_ids assert web.id in overview.candidates[0].affected_service_ids