feat: initial MALS implementation — Phase 1
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""MALS test suite."""
|
||||
50
tests/conftest.py
Normal file
50
tests/conftest.py
Normal 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
53
tests/test_auth.py
Normal 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
144
tests/test_ingest.py
Normal 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
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