feat: overhaul with modular API, agent endpoints, migrations, backup and tests
- Split monolithic app.py into server/api/ routers - Add Pydantic schemas for input validation - Add /agent/* API with separate AGENT_TOKEN auth - Replace static schema.sql with versioned migrations (datenerhaltend) - Add tools/backup_db.py for SQLite backups - Add Dockerfile HEALTHCHECK and compose.yml AGENT_TOKEN env - Add pytest tests for user and agent API - Update README with API docs and deployment safety notes - Bump version to 1.2.0
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Set environment variables BEFORE importing the app or settings module.
|
||||
# Use a fixed test workspace so every module that imports settings sees the
|
||||
# same paths. The client fixture wipes the DB before each test.
|
||||
TEST_DIR = Path(__file__).resolve().parent
|
||||
TEST_DB = TEST_DIR / "test.sqlite"
|
||||
TEST_MEDS = TEST_DIR / "fixtures" / "meds.yaml"
|
||||
TEST_MESSAGES = TEST_DIR / "fixtures" / "messages.yaml"
|
||||
|
||||
os.environ["APP_PIN"] = "1234"
|
||||
os.environ["JWT_SECRET"] = "test-secret-must-be-at-least-32-bytes-long-ok"
|
||||
os.environ["AGENT_TOKEN"] = "agent-test-token-must-be-long-enough-for-secrets"
|
||||
os.environ["VAPID_PRIVATE_KEY"] = ""
|
||||
os.environ["VAPID_PUBLIC_KEY"] = ""
|
||||
os.environ["OPENROUTER_API_KEY"] = ""
|
||||
os.environ["DB_PATH"] = str(TEST_DB)
|
||||
os.environ["MEDS_PATH"] = str(TEST_MEDS)
|
||||
os.environ["MESSAGES_PATH"] = str(TEST_MESSAGES)
|
||||
|
||||
# Neutralize the background scheduler for tests — it binds to the asyncio loop
|
||||
# and survives poorly across multiple TestClient lifecycles.
|
||||
import server.scheduler
|
||||
|
||||
server.scheduler.start_scheduler = lambda: None # type: ignore[assignment]
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from server.app import app
|
||||
from server.auth import create_token
|
||||
from server.config_loader import reload_meds_config
|
||||
|
||||
|
||||
def _write_fixtures() -> None:
|
||||
TEST_MEDS.parent.mkdir(parents=True, exist_ok=True)
|
||||
TEST_MEDS.write_text(
|
||||
"timezone: Europe/Berlin\n"
|
||||
"slots:\n"
|
||||
" - id: morning\n"
|
||||
" time: \"08:00\"\n"
|
||||
" label: Morgens\n"
|
||||
" meds:\n"
|
||||
" - name: Elvanse\n"
|
||||
" dose: 30mg\n"
|
||||
" reminder_window_minutes: 90\n"
|
||||
)
|
||||
TEST_MESSAGES.write_text(
|
||||
"reminder: [\"Zeit für Medis.\"]\n"
|
||||
"success: [\"Genommen.\"]\n"
|
||||
"missed: [\"Verpasst.\"]\n"
|
||||
"snooze: [\"Snooze.\"]\n"
|
||||
"motivation_fallback: [\"Motivation.\"]\n"
|
||||
"roast_fallback: [\"Roast.\"]\n"
|
||||
)
|
||||
|
||||
|
||||
_write_fixtures()
|
||||
reload_meds_config()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client():
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_token() -> str:
|
||||
return create_token()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(user_token: str):
|
||||
return {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_headers():
|
||||
return {"Authorization": "Bearer agent-test-token-must-be-long-enough-for-secrets"}
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health(client: TestClient) -> None:
|
||||
resp = client.get("/api/health/")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_login_ok(client: TestClient) -> None:
|
||||
resp = client.post("/api/auth/pin", json={"pin": "1234"})
|
||||
assert resp.status_code == 200
|
||||
assert "token" in resp.json()
|
||||
|
||||
|
||||
def test_login_wrong(client: TestClient) -> None:
|
||||
resp = client.post("/api/auth/pin", json={"pin": "0000"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_config_requires_auth(client: TestClient) -> None:
|
||||
resp = client.get("/api/config/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_config_authenticated(client: TestClient, auth_headers: dict) -> None:
|
||||
resp = client.get("/api/config/", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["timezone"] == "Europe/Berlin"
|
||||
assert any(s["id"] == "morning" for s in data["slots"])
|
||||
|
||||
|
||||
def test_today_and_log(client: TestClient, auth_headers: dict) -> None:
|
||||
resp = client.get("/api/today", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
today = resp.json()
|
||||
assert today["slots"][0]["id"] == "morning"
|
||||
|
||||
resp = client.post(
|
||||
"/api/log",
|
||||
headers=auth_headers,
|
||||
json={"slot_id": "morning", "status": "taken"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["stats"]["total_taken"] == 1
|
||||
|
||||
|
||||
def test_log_unknown_slot(client: TestClient, auth_headers: dict) -> None:
|
||||
resp = client.post(
|
||||
"/api/log",
|
||||
headers=auth_headers,
|
||||
json={"slot_id": "nope", "status": "taken"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_snooze(client: TestClient, auth_headers: dict) -> None:
|
||||
resp = client.post(
|
||||
"/api/snooze",
|
||||
headers=auth_headers,
|
||||
json={"slot_id": "morning", "minutes": 15},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "snooze_until" in resp.json()
|
||||
|
||||
|
||||
def test_snooze_invalid_minutes(client: TestClient, auth_headers: dict) -> None:
|
||||
resp = client.post(
|
||||
"/api/snooze",
|
||||
headers=auth_headers,
|
||||
json={"slot_id": "morning", "minutes": 42},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_agent_api_disabled_without_token(client: TestClient) -> None:
|
||||
import server.settings as settings_module
|
||||
|
||||
original = settings_module.settings.agent_token
|
||||
try:
|
||||
settings_module.settings.agent_token = ""
|
||||
resp = client.get("/agent/health", headers={"Authorization": "Bearer foo"})
|
||||
assert resp.status_code == 503
|
||||
finally:
|
||||
settings_module.settings.agent_token = original
|
||||
|
||||
|
||||
def test_agent_health(client: TestClient, agent_headers: dict) -> None:
|
||||
resp = client.get("/agent/health", headers=agent_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "db_version" in data
|
||||
|
||||
|
||||
def test_agent_status(client: TestClient, agent_headers: dict) -> None:
|
||||
resp = client.get("/agent/status", headers=agent_headers)
|
||||
assert resp.status_code == 200
|
||||
assert "today" in resp.json()
|
||||
assert "stats" in resp.json()
|
||||
|
||||
|
||||
def test_agent_log_and_history(client: TestClient, agent_headers: dict) -> None:
|
||||
resp = client.post(
|
||||
"/agent/log",
|
||||
headers=agent_headers,
|
||||
json={"slot_id": "morning", "status": "taken", "source": "agent"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "taken"
|
||||
|
||||
resp = client.get("/agent/history?days=7", headers=agent_headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["days"]) == 7
|
||||
|
||||
|
||||
def test_agent_config(client: TestClient, agent_headers: dict) -> None:
|
||||
resp = client.get("/agent/config", headers=agent_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["timezone"] == "Europe/Berlin"
|
||||
|
||||
|
||||
def test_vapid_legacy_path(client: TestClient) -> None:
|
||||
resp = client.get("/api/vapid-public-key")
|
||||
assert resp.status_code == 200
|
||||
assert "key" in resp.json()
|
||||
Reference in New Issue
Block a user