wrolpi/modules/ai/test/test_system_api.py
Roland b07672b976
ai: system endpoints — aggregate status, services, logs, disks
Proxy the Controller under /api/ai so the model sees one API: an
aggregate /status to start every diagnosis, lean service list, recent
service logs (lines clamped), and disks + SMART.  One controller_client
helper resolves the Controller base URL (docker service name vs
localhost).  Every proxy degrades to an `errors` entry when the
Controller is unreachable — never a 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00

130 lines
5.6 KiB
Python

"""Tests for the /api/ai system endpoints (System mode)."""
from http import HTTPStatus
from unittest import mock
import pytest
CONTROLLER_STATS = dict(
cpu=dict(percent=12.5),
memory=dict(total=8_000_000_000, used=2_000_000_000),
load=dict(minute_1=0.5),
drives=[dict(mount='/media/wrolpi', percent=41)],
# Keys the lean status must NOT forward (too big for small contexts).
processes=[dict(pid=1, name='huge')],
iostat=dict(huge=True),
)
CONTROLLER_SERVICES = [
dict(name='wrolpi-api', status='running', enabled=True, port=8081, description='API', viewable=True),
dict(name='wrolpi-kiwix', status='failed', enabled=True, port=9085, description='', view_path='/'),
]
def _mock_controller(responses: dict):
"""Patch controller_get to answer from {path: (status, body)}."""
async def fake_controller_get(path, params=None, timeout=10):
if path not in responses:
raise ConnectionError(f'no controller response for {path}')
return responses[path]
return mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get)
@pytest.mark.asyncio
async def test_ai_system_status(async_client, test_session):
"""The aggregate status contains API state, lean Controller stats, and services."""
responses = {
'/api/stats': (200, CONTROLLER_STATS),
'/api/services': (200, CONTROLLER_SERVICES),
}
with _mock_controller(responses):
request, response = await async_client.get('/api/ai/status')
assert response.status_code == HTTPStatus.OK
assert response.json['version']
assert response.json['wrol_mode'] is False
assert isinstance(response.json['flags'], dict)
# Only the lean subset of Controller stats is forwarded.
assert response.json['system']['cpu'] == dict(percent=12.5)
assert 'processes' not in response.json['system']
assert 'iostat' not in response.json['system']
# Services are lean: no view_path/viewable noise, empty description omitted.
services = {i['name']: i for i in response.json['services']}
assert services['wrolpi-kiwix']['status'] == 'failed'
assert 'viewable' not in services['wrolpi-api']
assert 'description' not in services['wrolpi-kiwix']
assert response.json['errors'] == []
@pytest.mark.asyncio
async def test_ai_system_status_controller_down(async_client, test_session):
"""A Controller outage degrades to errors entries, never a 500."""
with _mock_controller({}):
request, response = await async_client.get('/api/ai/status')
assert response.status_code == HTTPStatus.OK
assert response.json['version']
assert any('system stats' in i for i in response.json['errors'])
assert any('service statuses' in i for i in response.json['errors'])
assert 'system' not in response.json
assert 'services' not in response.json
@pytest.mark.asyncio
async def test_ai_list_services(async_client, test_session):
"""Services are listed lean; a Controller failure is a clear 502."""
with _mock_controller({'/api/services': (200, CONTROLLER_SERVICES)}):
request, response = await async_client.get('/api/ai/services')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 2
assert response.json['results'][0]['name'] == 'wrolpi-api'
with _mock_controller({'/api/services': (500, dict(error='boom'))}):
request, response = await async_client.get('/api/ai/services')
assert response.status_code == HTTPStatus.BAD_GATEWAY
@pytest.mark.asyncio
async def test_ai_get_service_logs(async_client, test_session):
"""Logs are proxied with the lines param clamped."""
captured = {}
async def fake_controller_get(path, params=None, timeout=10):
captured['path'] = path
captured['params'] = params
return 200, dict(service='wrolpi-kiwix', lines=params['lines'], logs='line1\nline2')
with mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get):
request, response = await async_client.get('/api/ai/services/wrolpi-kiwix/logs?lines=999999')
assert response.status_code == HTTPStatus.OK
assert captured['path'] == '/api/services/wrolpi-kiwix/logs'
assert captured['params']['lines'] == 1_000 # clamped
assert response.json['logs'] == 'line1\nline2'
# Unknown service: the Controller's error becomes a 502 with its detail.
with _mock_controller({'/api/services/nope/logs': (404, dict(detail='Service not found'))}):
request, response = await async_client.get('/api/ai/services/nope/logs')
assert response.status_code == HTTPStatus.BAD_GATEWAY
@pytest.mark.asyncio
async def test_ai_list_disks(async_client, test_session):
"""Disks and SMART are aggregated; partial failures land in errors."""
disks = [dict(name='sda1', path='/dev/sda1', size='500G', fstype='ext4', mountpoint='/media/wrolpi')]
responses = {
'/api/disks': (200, disks),
'/api/disks/smart': (200, dict(available=True, drives=[dict(name='sda', assessment='PASS')])),
}
with _mock_controller(responses):
request, response = await async_client.get('/api/ai/disks')
assert response.status_code == HTTPStatus.OK
assert response.json['disks'] == disks
assert response.json['smart']['available'] is True
assert response.json['errors'] == []
# SMART unavailable (e.g. docker) is an error entry, not a failure.
with _mock_controller({'/api/disks': (200, disks)}):
request, response = await async_client.get('/api/ai/disks')
assert response.status_code == HTTPStatus.OK
assert response.json['disks'] == disks
assert 'smart' not in response.json
assert len(response.json['errors']) == 1