feat: initial MALS implementation — Phase 1
This commit is contained in:
220
tests/test_query.py
Normal file
220
tests/test_query.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user