feat: initial MALS implementation — Phase 1

This commit is contained in:
2026-03-20 07:59:43 -05:00
commit dc1258a5cc
28 changed files with 2972 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""MALS test suite."""

50
tests/conftest.py Normal file
View File

@@ -0,0 +1,50 @@
"""Test configuration and fixtures.
Tests run against a real PostgreSQL instance (docker-compose test stack).
The DATABASE_URL and MALS_API_KEY are read from environment / .env.test.
If not set, pytest will skip DB-dependent tests gracefully.
"""
from __future__ import annotations
import asyncio
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
# Set required env vars before importing the app
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://mals:mals@localhost:5434/mals")
os.environ.setdefault("MALS_API_KEY", "test-secret-key")
from mals.main import app # noqa: E402
TEST_API_KEY = os.environ["MALS_API_KEY"]
AUTH_HEADERS = {"Authorization": f"Bearer {TEST_API_KEY}"}
# ---------------------------------------------------------------------------
# Async HTTP client fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def async_client() -> AsyncClient:
"""Async HTTPX client backed by the ASGI app — no real DB needed for unit tests."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# ---------------------------------------------------------------------------
# Mocked DB pool fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_pool():
"""Return a mock asyncpg pool for unit tests that don't need a real DB."""
pool = MagicMock()
conn = AsyncMock()
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False)
return pool, conn

53
tests/test_auth.py Normal file
View File

@@ -0,0 +1,53 @@
"""Authentication tests."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import AUTH_HEADERS
@pytest.mark.asyncio
async def test_auth_valid_token_passes(async_client: AsyncClient):
"""Requests with correct Bearer token are not rejected at the auth layer.
We don't need a real DB for this — a 422 from missing body means auth passed.
"""
# POST /logs without a body — if auth passes we get 422 (validation), not 401
response = await async_client.post("/logs", json={}, headers=AUTH_HEADERS)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_auth_missing_header(async_client: AsyncClient):
"""Missing Authorization header returns 403 (no credentials) or 401."""
response = await async_client.post("/logs", json={})
assert response.status_code in {401, 403}
@pytest.mark.asyncio
async def test_auth_wrong_token(async_client: AsyncClient):
"""Wrong Bearer token returns 401."""
response = await async_client.post(
"/logs",
json={"agent_id": "x", "message": "x"},
headers={"Authorization": "Bearer totally-wrong"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_auth_not_required_for_health(async_client: AsyncClient):
"""GET /health does not require authentication."""
from unittest.mock import AsyncMock, MagicMock, patch
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

144
tests/test_ingest.py Normal file
View File

@@ -0,0 +1,144 @@
"""Tests for ingest routes: POST /logs and POST /logs/batch."""
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
# ---------------------------------------------------------------------------
# POST /logs — happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ingest_single_log_success(async_client: AsyncClient):
"""POST /logs with valid payload returns 201 with id and created_at."""
fake_id = uuid.uuid4()
fake_ts = datetime.now(tz=timezone.utc)
mock_conn = AsyncMock()
mock_conn.fetchrow.return_value = {"id": fake_id, "created_at": fake_ts}
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.ingest.get_pool", AsyncMock(return_value=mock_pool)):
response = await async_client.post(
"/logs",
json={
"agent_id": "crypto",
"level": "error",
"message": "Bot container unhealthy after restart",
"category": "deploy",
"session_key": "agent:crypto:discord:channel:123",
"source": "openclaw",
"metadata": {"container": "jarvis-crypto-bot-1", "exit_code": 1},
},
headers=AUTH_HEADERS,
)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert "created_at" in data
# ---------------------------------------------------------------------------
# POST /logs — invalid level
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ingest_invalid_level_rejected(async_client: AsyncClient):
"""POST /logs with an invalid level returns 422 Unprocessable Entity."""
response = await async_client.post(
"/logs",
json={
"agent_id": "crypto",
"level": "BADLEVEL",
"message": "This should fail validation",
},
headers=AUTH_HEADERS,
)
assert response.status_code == 422
body = response.json()
# Pydantic v2 puts errors in 'detail' list
assert "detail" in body
# ---------------------------------------------------------------------------
# POST /logs — auth rejection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ingest_requires_auth(async_client: AsyncClient):
"""POST /logs without auth header returns 401."""
response = await async_client.post(
"/logs",
json={"agent_id": "crypto", "message": "should fail"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_ingest_wrong_token_rejected(async_client: AsyncClient):
"""POST /logs with wrong token returns 401."""
response = await async_client.post(
"/logs",
json={"agent_id": "crypto", "message": "should fail"},
headers={"Authorization": "Bearer wrong-key"},
)
assert response.status_code == 401
# ---------------------------------------------------------------------------
# POST /logs/batch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ingest_batch_success(async_client: AsyncClient):
"""POST /logs/batch inserts multiple entries and returns count."""
mock_conn = AsyncMock()
mock_conn.executemany.return_value = None
mock_pool = MagicMock()
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=False)
entries = [
{"agent_id": "crypto", "level": "info", "message": f"Log entry {i}"}
for i in range(5)
]
with patch("mals.routes.ingest.get_pool", AsyncMock(return_value=mock_pool)):
response = await async_client.post(
"/logs/batch",
json=entries,
headers=AUTH_HEADERS,
)
assert response.status_code == 201
data = response.json()
assert data["inserted"] == 5
@pytest.mark.asyncio
async def test_ingest_batch_empty(async_client: AsyncClient):
"""POST /logs/batch with empty list returns 0 inserted without DB call."""
mock_pool = MagicMock()
with patch("mals.routes.ingest.get_pool", AsyncMock(return_value=mock_pool)):
response = await async_client.post(
"/logs/batch",
json=[],
headers=AUTH_HEADERS,
)
assert response.status_code == 201
assert response.json()["inserted"] == 0

220
tests/test_query.py Normal file
View 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