Files
mals/tests/test_query.py

221 lines
7.4 KiB
Python

"""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