221 lines
7.4 KiB
Python
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
|