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

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