195 lines
6.7 KiB
Python
195 lines
6.7 KiB
Python
"""Tests for the /admin/api operational trigger endpoints.
|
|
|
|
refresh-catalog, seed-energy, and apply-feedback run repo maintenance scripts
|
|
via ``asyncio.create_subprocess_exec``; restart-service schedules systemctl
|
|
through FastAPI BackgroundTasks. These tests monkeypatch
|
|
``asyncio.create_subprocess_exec`` (and ``subprocess.run``) so no real process
|
|
or network is ever touched — they assert the endpoint maps query params and
|
|
CLI args onto the spawned command and returns the documented job shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
import admin
|
|
import dispatcher
|
|
from config import load_config
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CFG = load_config(str(ROOT / "config" / "config.yaml"))
|
|
|
|
|
|
class FakeProcess:
|
|
"""A stand-in for ``asyncio.subprocess.Process`` with a known outcome."""
|
|
|
|
def __init__(self, stdout: bytes = b"ok\n", returncode: int = 0):
|
|
self._stdout = stdout
|
|
self.returncode = returncode
|
|
self.killed = False
|
|
|
|
async def communicate(self):
|
|
return self._stdout, None
|
|
|
|
async def wait(self):
|
|
return self.returncode
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
|
|
class _Recorder:
|
|
"""Captures every ``create_subprocess_exec`` call as ``(args, kwargs)``."""
|
|
|
|
def __init__(self):
|
|
self.calls: list[tuple[tuple, dict]] = []
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_spawn(monkeypatch):
|
|
"""Replace ``asyncio.create_subprocess_exec`` with a success-bound fake."""
|
|
recorder = _Recorder()
|
|
|
|
async def _fake(*args, **kwargs):
|
|
recorder.calls.append((list(args), kwargs))
|
|
return FakeProcess(stdout=b"ok\n", returncode=0)
|
|
|
|
monkeypatch.setattr(admin.asyncio, "create_subprocess_exec", _fake)
|
|
return recorder
|
|
|
|
|
|
@pytest.fixture
|
|
def failing_spawn(monkeypatch):
|
|
"""A fake that makes any spawned command fail with returncode 3."""
|
|
recorder = _Recorder()
|
|
|
|
async def _fake(*args, **kwargs):
|
|
recorder.calls.append((list(args), kwargs))
|
|
return FakeProcess(stdout=b"boom\n", returncode=3)
|
|
|
|
monkeypatch.setattr(admin.asyncio, "create_subprocess_exec", _fake)
|
|
return recorder
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_client(tmp_path, monkeypatch):
|
|
"""A TestClient wired to dispatcher.app with a temp DB (mounts /admin)."""
|
|
conn = sqlite3.connect(str(tmp_path / "test.db"))
|
|
conn.executescript((ROOT / "config" / "schema.sql").read_text())
|
|
conn.close()
|
|
monkeypatch.setattr(dispatcher.cfg.database, "path", str(tmp_path / "test.db"))
|
|
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
|
|
with TestClient(dispatcher.app) as client:
|
|
yield client
|
|
|
|
|
|
# --- refresh-catalog --------------------------------------------------------
|
|
|
|
|
|
def test_refresh_catalog_runs_poller_then_tier(seeded_client, fake_spawn):
|
|
"""POST /admin/api/refresh-catalog spawns poller then tier, &&-semantics."""
|
|
resp = seeded_client.post("/admin/api/refresh-catalog")
|
|
assert resp.status_code == 200
|
|
job = resp.json()
|
|
assert job["status"] == "success"
|
|
assert job["returncode"] == 0
|
|
assert "poller" in job["command"]
|
|
assert "tier" in job["command"]
|
|
|
|
# One spawn per step, in order, with the venv python as the interpreter.
|
|
argv = [args for args, _ in fake_spawn.calls]
|
|
assert argv == [
|
|
[sys.executable, "-m", "poller"],
|
|
[sys.executable, "-m", "tier"],
|
|
]
|
|
for _, kwargs in fake_spawn.calls:
|
|
assert kwargs["cwd"] == str(ROOT)
|
|
|
|
|
|
def test_refresh_catalog_job_shape(seeded_client, fake_spawn):
|
|
"""The job object carries id/command/status/returncode/output_tail."""
|
|
job = seeded_client.post("/admin/api/refresh-catalog").json()
|
|
assert isinstance(job["id"], str) and job["id"]
|
|
assert "command" in job
|
|
assert job["status"] == "success"
|
|
assert job["returncode"] == 0
|
|
# Two steps each emit "ok\n", so the merged tail carries both.
|
|
assert job["output_tail"] == "ok\nok\n"
|
|
|
|
|
|
def test_failed_command_reports_failure(seeded_client, failing_spawn):
|
|
"""A non-zero returncode is reported as failed with that returncode."""
|
|
job = seeded_client.post("/admin/api/apply-feedback").json()
|
|
assert job["status"] == "failed"
|
|
assert job["returncode"] == 3
|
|
assert job["output_tail"] == "boom\n"
|
|
|
|
|
|
# --- seed-energy ------------------------------------------------------------
|
|
|
|
|
|
def test_seed_energy_default_samples(seeded_client, fake_spawn):
|
|
"""Without ?samples=, seed-energy defaults to ``--samples 5``."""
|
|
seeded_client.post("/admin/api/seed-energy")
|
|
argv = fake_spawn.calls[0][0]
|
|
assert argv == [sys.executable, "-m", "seed_energy", "--samples", "5"]
|
|
|
|
|
|
def test_seed_energy_accepts_samples_query(seeded_client, fake_spawn):
|
|
"""POST /admin/api/seed-energy?samples=3 forwards ``--samples 3``."""
|
|
resp = seeded_client.post("/admin/api/seed-energy?samples=3")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
argv = fake_spawn.calls[0][0]
|
|
assert argv == [sys.executable, "-m", "seed_energy", "--samples", "3"]
|
|
|
|
|
|
# --- apply-feedback ---------------------------------------------------------
|
|
|
|
|
|
def test_apply_feedback_apply_mode(seeded_client, fake_spawn):
|
|
"""Without dry_run, feedback.py runs with no extra flag."""
|
|
resp = seeded_client.post("/admin/api/apply-feedback")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
argv = fake_spawn.calls[0][0]
|
|
assert argv == [sys.executable, "-m", "feedback"]
|
|
|
|
|
|
def test_apply_feedback_dry_run(seeded_client, fake_spawn):
|
|
"""?dry_run=true appends ``--dry-run`` to the feedback command."""
|
|
seeded_client.post("/admin/api/apply-feedback?dry_run=true")
|
|
argv = fake_spawn.calls[0][0]
|
|
assert argv == [sys.executable, "-m", "feedback", "--dry-run"]
|
|
|
|
|
|
# --- restart-service --------------------------------------------------------
|
|
|
|
|
|
def test_restart_service_returns_immediately(seeded_client, monkeypatch):
|
|
"""POST /admin/api/restart-service returns {"status": "restarting"} 200.
|
|
|
|
The systemctl call is scheduled via BackgroundTasks, never awaited inline,
|
|
so the response body is the immediate "restarting" status and the actual
|
|
restart fires as a post-response background task.
|
|
"""
|
|
calls: list[tuple] = []
|
|
monkeypatch.setattr(
|
|
admin.subprocess, "run", lambda *a, **k: calls.append((a, k))
|
|
)
|
|
|
|
resp = seeded_client.post("/admin/api/restart-service")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "restarting"}
|
|
|
|
# The BackgroundTask ran after the response was produced; it must have
|
|
# scheduled exactly the systemctl restart command.
|
|
assert calls, "BackgroundTask never fired systemctl"
|
|
spawned = calls[0][0][0]
|
|
assert spawned == ["systemctl", "--user", "restart", "llm-router.service"]
|