"""Tests for query routes: GET /logs, GET /logs/summary, GET /logs/agents.""" from __future__ import annotations import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import AsyncClient from tests.conftest import AUTH_HEADERS # Shared test data _FAKE_LOG = { "id": uuid.uuid4(), "created_at": datetime.now(tz=timezone.utc), "agent_id": "crypto", "session_key": None, "source": "api", "level": "error", "category": "deploy", "message": "Bot container unhealthy", "metadata": {}, "trace_id": None, "parent_id": None, "resolved": False, "resolved_at": None, "resolved_by": None, } def _make_pool_returning(rows, count=None): """Create a mock pool whose conn returns *rows* from fetch() and *count* from fetchrow().""" mock_conn = AsyncMock() mock_conn.fetch.return_value = rows mock_conn.fetchrow.return_value = {"n": count if count is not None else len(rows)} mock_pool = MagicMock() mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False) return mock_pool # --------------------------------------------------------------------------- # GET /logs — filter by agent_id # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_list_logs_filter_by_agent(async_client: AsyncClient): """GET /logs?agent_id=crypto returns paginated results.""" mock_pool = _make_pool_returning([_FAKE_LOG], count=1) with patch("mals.routes.query.get_pool", AsyncMock(return_value=mock_pool)): response = await async_client.get( "/logs", params={"agent_id": "crypto"}, headers=AUTH_HEADERS, ) assert response.status_code == 200 data = response.json() assert data["total"] == 1 assert len(data["items"]) == 1 assert data["items"][0]["agent_id"] == "crypto" assert data["items"][0]["level"] == "error" @pytest.mark.asyncio async def test_list_logs_no_auth(async_client: AsyncClient): """GET /logs without auth returns 401.""" response = await async_client.get("/logs") assert response.status_code == 401 @pytest.mark.asyncio async def test_list_logs_limit_enforced(async_client: AsyncClient): """GET /logs with limit > 1000 returns 422.""" response = await async_client.get( "/logs", params={"limit": 9999}, headers=AUTH_HEADERS, ) assert response.status_code == 422 # --------------------------------------------------------------------------- # GET /logs/summary # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_summary_returns_structure(async_client: AsyncClient): """GET /logs/summary returns the expected keys.""" mock_conn = AsyncMock() # agent_rows: level breakdown by agent mock_conn.fetch.side_effect = [ [{"agent_id": "crypto", "level": "error", "cnt": 2}], # agent_rows [], # unresolved_rows [], # recurring_rows [], # prev_rows (for new_errors calculation) [], # new_error_rows ] mock_pool = MagicMock() mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False) with patch("mals.routes.query.get_pool", AsyncMock(return_value=mock_pool)): response = await async_client.get("/logs/summary", headers=AUTH_HEADERS) assert response.status_code == 200 data = response.json() assert "period" in data assert "by_agent" in data assert "unresolved_errors" in data assert "new_errors" in data assert "recurring_errors" in data assert "crypto" in data["by_agent"] assert data["by_agent"]["crypto"]["error"] == 2 # --------------------------------------------------------------------------- # GET /logs/agents # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_list_agents_returns_activity(async_client: AsyncClient): """GET /logs/agents returns agent activity list.""" mock_conn = AsyncMock() mock_conn.fetch.return_value = [ { "agent_id": "crypto", "last_seen": datetime.now(tz=timezone.utc), "total_24h": 42, "error_count_24h": 3, } ] mock_pool = MagicMock() mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False) with patch("mals.routes.query.get_pool", AsyncMock(return_value=mock_pool)): response = await async_client.get("/logs/agents", headers=AUTH_HEADERS) assert response.status_code == 200 data = response.json() assert isinstance(data, list) assert data[0]["agent_id"] == "crypto" assert data[0]["error_count_24h"] == 3 # --------------------------------------------------------------------------- # PATCH /logs/{id}/resolve # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_resolve_log_success(async_client: AsyncClient): """PATCH /logs/{id}/resolve marks entry resolved.""" mock_conn = AsyncMock() mock_conn.execute.return_value = "UPDATE 1" mock_pool = MagicMock() mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False) log_id = uuid.uuid4() with patch("mals.routes.query.get_pool", AsyncMock(return_value=mock_pool)): response = await async_client.patch( f"/logs/{log_id}/resolve", json={"resolved_by": "jarvis"}, headers=AUTH_HEADERS, ) assert response.status_code == 200 assert response.json()["status"] == "resolved" @pytest.mark.asyncio async def test_resolve_log_not_found(async_client: AsyncClient): """PATCH /logs/{id}/resolve returns 404 when entry not found or already resolved.""" mock_conn = AsyncMock() mock_conn.execute.return_value = "UPDATE 0" mock_pool = MagicMock() mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False) log_id = uuid.uuid4() with patch("mals.routes.query.get_pool", AsyncMock(return_value=mock_pool)): response = await async_client.patch( f"/logs/{log_id}/resolve", json={"resolved_by": "jarvis"}, headers=AUTH_HEADERS, ) assert response.status_code == 404 # --------------------------------------------------------------------------- # GET /health — no auth required # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_health_no_auth(async_client: AsyncClient): """GET /health is accessible without auth.""" mock_conn = AsyncMock() mock_conn.fetchval.return_value = 1 mock_pool = MagicMock() mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False) with patch("mals.main.get_pool", AsyncMock(return_value=mock_pool)): response = await async_client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" assert "version" in data