feat(family): deploy Single-Writer DuckDB backend, Mila parent cockpit & feeding analytics, and Moritz macro tracker
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import DATA_DIR
|
||||
from app.engine import DuckDBEngine
|
||||
from app.git_sync import GitSyncManager
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_data_dir():
|
||||
# Use real wiki data dir if exists, else temporary copy
|
||||
if DATA_DIR.is_dir():
|
||||
yield DATA_DIR
|
||||
else:
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(test_data_dir):
|
||||
return DuckDBEngine(data_dir=test_data_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(engine):
|
||||
app.state.engine = engine
|
||||
app.state.git_sync = GitSyncManager(wiki_dir=engine.data_dir.parent)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
@@ -0,0 +1,179 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_mila_dashboard_endpoint(client: TestClient):
|
||||
resp = client.get("/duckdb-api/v1/mila/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["metadata"]["subject"] == "mila"
|
||||
assert "live_status" in data
|
||||
assert "today" in data
|
||||
assert "growth" in data
|
||||
|
||||
|
||||
def test_moritz_dashboard_endpoint(client: TestClient):
|
||||
resp = client.get("/duckdb-api/v1/moritz/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["metadata"]["subject"] == "moritz"
|
||||
assert "today" in data
|
||||
assert "yesterday" in data
|
||||
assert "seven_days" in data
|
||||
assert "thirty_days" in data
|
||||
|
||||
|
||||
def test_healthz_and_status_endpoints(client: TestClient):
|
||||
resp_h = client.get("/healthz")
|
||||
assert resp_h.status_code == 200
|
||||
assert resp_h.json()["engine_ready"] is True
|
||||
|
||||
resp_s = client.get("/duckdb-api/v1/status")
|
||||
assert resp_s.status_code == 200
|
||||
data_s = resp_s.json()
|
||||
assert "status" in data_s
|
||||
assert "table_counts" in data_s
|
||||
assert data_s["table_counts"]["sleep"] > 0
|
||||
|
||||
|
||||
def test_mila_history_includes_diaper_only_days(client: TestClient):
|
||||
# Log a diaper change for a specific day without sleep
|
||||
client.post("/duckdb-api/v1/events", json={
|
||||
"kind": "diaper.change",
|
||||
"subject": "mila",
|
||||
"start": "2026-10-12T12:00:00+02:00",
|
||||
"payload": {"pee": True, "poo": True},
|
||||
})
|
||||
resp = client.get("/duckdb-api/v1/mila/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
history = data["history"]
|
||||
dates = [h["date"] for h in history]
|
||||
assert "2026-10-12" in dates
|
||||
|
||||
|
||||
def test_moritz_seven_days_includes_garmin_days(client: TestClient):
|
||||
resp = client.get("/duckdb-api/v1/moritz/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
days = data["seven_days"]["days"]
|
||||
assert len(days) > 0
|
||||
# Every returned day has a valid date and steps or expenditure
|
||||
for d in days:
|
||||
assert d["date"] is not None
|
||||
assert "expenditure_kcal" in d
|
||||
|
||||
|
||||
def test_mila_dashboard_parent_cockpit_and_norms(client: TestClient):
|
||||
resp = client.get("/duckdb-api/v1/mila/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# Parent cockpit live fields
|
||||
live = data["live_status"]
|
||||
assert "is_sleeping" in live
|
||||
assert "active_sleep_duration_str" in live
|
||||
assert "wake_window_minutes" in live
|
||||
assert "last_feed_since_str" in live
|
||||
assert "last_diaper_since_str" in live
|
||||
assert "last_stool_str" in live
|
||||
|
||||
# Science age norms
|
||||
assert "age_norms" in data
|
||||
norms = data["age_norms"]
|
||||
assert norms["age_months"] >= 6.0
|
||||
assert norms["wake_window_min_minutes"] == 135
|
||||
assert norms["wake_window_max_minutes"] == 180
|
||||
assert norms["feed_interval_min_minutes"] == 180
|
||||
|
||||
# Ensure IDs on items
|
||||
today = data["today"]
|
||||
for f in today["feedings"]:
|
||||
assert "id" in f
|
||||
for d in today["diapers"]:
|
||||
assert "id" in d
|
||||
|
||||
|
||||
def test_day_detail_and_summary_endpoints(client: TestClient):
|
||||
# Test mila day detail
|
||||
resp_mila = client.get("/duckdb-api/v1/mila/day/2026-10-12")
|
||||
assert resp_mila.status_code == 200
|
||||
assert resp_mila.json()["metadata"]["today_date"] == "2026-10-12"
|
||||
|
||||
# Test moritz day detail
|
||||
resp_moritz = client.get("/duckdb-api/v1/moritz/day/2026-10-12")
|
||||
assert resp_moritz.status_code == 200
|
||||
assert "today" in resp_moritz.json()
|
||||
|
||||
# Test summary
|
||||
resp_sum = client.get("/duckdb-api/v1/summary/mila?date=2026-10-12")
|
||||
assert resp_sum.status_code == 200
|
||||
sum_data = resp_sum.json()
|
||||
assert sum_data["subject"] == "mila"
|
||||
assert sum_data["date"] == "2026-10-12"
|
||||
assert "nutrition" in sum_data
|
||||
|
||||
|
||||
def test_entities_and_places_endpoints(client: TestClient):
|
||||
# Put and list entity
|
||||
client.post("/duckdb-api/v1/entities", json={
|
||||
"collection": "places",
|
||||
"record": {
|
||||
"id": "test-biergarten",
|
||||
"name": "Test Biergarten",
|
||||
"category": "biergarten",
|
||||
"lat": 49.02,
|
||||
"lon": 12.09,
|
||||
}
|
||||
})
|
||||
|
||||
resp_places = client.get("/duckdb-api/v1/entities/places")
|
||||
assert resp_places.status_code == 200
|
||||
places = resp_places.json()
|
||||
assert any(p["id"] == "test-biergarten" for p in places)
|
||||
|
||||
# Places near search
|
||||
resp_near = client.get("/duckdb-api/v1/places/near?lat=49.02&lon=12.09&radius_m=500")
|
||||
assert resp_near.status_code == 200
|
||||
near = resp_near.json()
|
||||
assert len(near) > 0
|
||||
assert near[0]["id"] == "test-biergarten"
|
||||
|
||||
|
||||
def test_garmin_sync_and_duplicate_guard(client: TestClient):
|
||||
# Duplicate guard test
|
||||
post_data = {
|
||||
"kind": "feeding.bottle",
|
||||
"subject": "mila",
|
||||
"start": "2026-10-12T10:00:00+02:00",
|
||||
"payload": {"consumed_ml": 180, "milk_type": "pre"},
|
||||
}
|
||||
r1 = client.post("/duckdb-api/v1/events", json=post_data)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["status"] == "success"
|
||||
assert "feedback" in r1.json()
|
||||
|
||||
# Second post within 15min should be duplicate_skipped
|
||||
r2 = client.post("/duckdb-api/v1/events", json=post_data)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["status"] == "duplicate_skipped"
|
||||
|
||||
# Garmin sync test
|
||||
garmin_payload = {
|
||||
"daily": [{
|
||||
"calendarDate": "2026-10-15",
|
||||
"totalKilocalories": 2400,
|
||||
"activeKilocalories": 600,
|
||||
"bmrKilocalories": 1800,
|
||||
"totalSteps": 11000,
|
||||
"restingHeartRate": 52,
|
||||
}],
|
||||
"sleep": [],
|
||||
"activities": [],
|
||||
}
|
||||
r_sync = client.post("/duckdb-api/v1/garmin/sync", json=garmin_payload)
|
||||
assert r_sync.status_code == 200
|
||||
assert r_sync.json()["status"] == "success"
|
||||
assert r_sync.json()["synced_daily"] == 1
|
||||
@@ -0,0 +1,129 @@
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.engine import DuckDBEngine
|
||||
from app.models import EventCreateRequest, EventStopRequest
|
||||
|
||||
|
||||
def test_add_and_stop_sleep_event(tmp_path: Path):
|
||||
# Use isolated temp directory for write tests so we do not pollute wiki git repo
|
||||
engine = DuckDBEngine(data_dir=tmp_path)
|
||||
|
||||
# 1. Add active sleep event (end=None)
|
||||
add_req = EventCreateRequest(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
payload={"type": "nap", "location": "kinderwagen"},
|
||||
actor="test-runner",
|
||||
note="Mittagsschlaf Test",
|
||||
)
|
||||
res_add = engine.add_event(add_req)
|
||||
assert res_add["status"] == "success"
|
||||
event_id = res_add["id"]
|
||||
assert event_id is not None
|
||||
|
||||
# Check that it appears in open sleep
|
||||
open_rows = engine.query(f"SELECT * FROM sleep WHERE id = '{event_id}' AND (isOpen = true OR endIso IS NULL)")
|
||||
assert len(open_rows) == 1
|
||||
|
||||
# 2. Stop the active sleep event
|
||||
stop_req = EventStopRequest(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
event_id=event_id,
|
||||
actor="test-runner",
|
||||
)
|
||||
res_stop = engine.stop_event(stop_req)
|
||||
assert res_stop["status"] == "success"
|
||||
assert res_stop["id"] == event_id
|
||||
|
||||
# Check that it is now closed
|
||||
closed_rows = engine.query(f"SELECT isOpen, endIso FROM sleep WHERE id = '{event_id}'")
|
||||
assert len(closed_rows) == 1
|
||||
assert closed_rows[0]["isOpen"] is False
|
||||
assert closed_rows[0]["endIso"] is not None
|
||||
|
||||
|
||||
def test_add_nutrition_and_diaper_event(tmp_path: Path):
|
||||
engine = DuckDBEngine(data_dir=tmp_path)
|
||||
|
||||
# Add nutrition event
|
||||
nut_req = EventCreateRequest(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
payload={"item": "Apfel", "qty": 150.0, "kcal": 78.0, "protein_g": 0.5},
|
||||
meal="snack",
|
||||
actor="test-runner",
|
||||
)
|
||||
res_nut = engine.add_event(nut_req)
|
||||
assert res_nut["status"] == "success"
|
||||
nut_id = res_nut["id"]
|
||||
|
||||
nut_rows = engine.query(f"SELECT * FROM nutrition WHERE id = '{nut_id}'")
|
||||
assert len(nut_rows) == 1
|
||||
|
||||
# Add diaper event
|
||||
diaper_req = EventCreateRequest(
|
||||
kind="diaper.change",
|
||||
subject="mila",
|
||||
payload={"pee": True, "poo": False},
|
||||
actor="test-runner",
|
||||
)
|
||||
res_diaper = engine.add_event(diaper_req)
|
||||
assert res_diaper["status"] == "success"
|
||||
diaper_id = res_diaper["id"]
|
||||
|
||||
diaper_rows = engine.query(f"SELECT * FROM mila WHERE id = '{diaper_id}'")
|
||||
assert len(diaper_rows) == 1
|
||||
|
||||
|
||||
def test_duplicate_open_sleep_rejected(tmp_path: Path):
|
||||
engine = DuckDBEngine(data_dir=tmp_path)
|
||||
|
||||
# 1. Start first open sleep session
|
||||
req1 = EventCreateRequest(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
actor="parent",
|
||||
)
|
||||
res1 = engine.add_event(req1)
|
||||
assert res1["status"] == "success"
|
||||
|
||||
# 2. Attempt to start second open sleep session without stopping the first
|
||||
req2 = EventCreateRequest(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
payload={"type": "nap", "location": "kinderwagen"},
|
||||
actor="parent",
|
||||
)
|
||||
with pytest.raises(ValueError, match="already active for subject 'mila'"):
|
||||
engine.add_event(req2)
|
||||
|
||||
|
||||
def test_duplicate_open_sleep_api_endpoint(client: TestClient):
|
||||
# 1. Start first session via API
|
||||
res1 = client.post("/duckdb-api/v1/events", json={
|
||||
"kind": "sleep.session",
|
||||
"subject": "test_baby",
|
||||
"payload": {"type": "nap"},
|
||||
})
|
||||
assert res1.status_code == 200
|
||||
|
||||
# 2. Start second session via API -> must return 400 Bad Request
|
||||
res2 = client.post("/duckdb-api/v1/events", json={
|
||||
"kind": "sleep.session",
|
||||
"subject": "test_baby",
|
||||
"payload": {"type": "nap"},
|
||||
})
|
||||
assert res2.status_code == 400
|
||||
assert "already active for subject 'test_baby'" in res2.json()["detail"]
|
||||
|
||||
# 3. Clean up by stopping
|
||||
stop_res = client.post("/duckdb-api/v1/events/stop", json={
|
||||
"kind": "sleep.session",
|
||||
"subject": "test_baby",
|
||||
})
|
||||
assert stop_res.status_code == 200
|
||||
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_generic_query_select(client: TestClient):
|
||||
payload = {
|
||||
"query": "SELECT count(*) AS cnt FROM v_sleep WHERE subject = 'mila'"
|
||||
}
|
||||
resp = client.post("/duckdb-api/v1/query", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "rows" in data
|
||||
assert len(data["rows"]) == 1
|
||||
assert data["rows"][0]["cnt"] > 0
|
||||
assert data["execution_time_ms"] < 50.0 # verifies sub-50ms execution
|
||||
|
||||
|
||||
def test_generic_query_disallowed_drop(client: TestClient):
|
||||
payload = {"query": "DROP TABLE sleep;"}
|
||||
resp = client.post("/duckdb-api/v1/query", json=payload)
|
||||
assert resp.status_code == 400
|
||||
assert "not permitted" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_generic_query_disallowed_insert(client: TestClient):
|
||||
payload = {"query": "INSERT INTO sleep VALUES ('x', 'y')"}
|
||||
resp = client.post("/duckdb-api/v1/query", json=payload)
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
from app.engine import DuckDBEngine
|
||||
|
||||
|
||||
def test_engine_init_and_counts(engine: DuckDBEngine):
|
||||
counts = engine.get_table_counts()
|
||||
assert "sleep" in counts
|
||||
assert "nutrition" in counts
|
||||
assert "mila" in counts
|
||||
assert "garmin_daily" in counts
|
||||
assert counts["sleep"] > 0
|
||||
assert counts["nutrition"] > 0
|
||||
|
||||
|
||||
def test_mila_dashboard_payload(engine: DuckDBEngine):
|
||||
data = engine.get_mila_dashboard()
|
||||
assert data["status"] == "ok"
|
||||
assert data["metadata"]["subject"] == "mila"
|
||||
assert "live_status" in data
|
||||
assert "today" in data
|
||||
assert "averages" in data
|
||||
assert "history" in data
|
||||
assert "growth" in data
|
||||
assert len(data["history"]) > 0
|
||||
|
||||
|
||||
def test_moritz_dashboard_payload(engine: DuckDBEngine):
|
||||
data = engine.get_moritz_dashboard()
|
||||
assert data["status"] == "ok"
|
||||
assert data["metadata"]["subject"] == "moritz"
|
||||
assert "today" in data
|
||||
assert "yesterday" in data
|
||||
assert "garmin_recovery" in data
|
||||
assert "seven_days" in data
|
||||
assert "thirty_days" in data
|
||||
assert len(data["thirty_days"]["days"]) > 0
|
||||
@@ -0,0 +1,68 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from app.git_sync import GitSyncManager
|
||||
|
||||
|
||||
def test_git_sync_conflict_handling(tmp_path: Path):
|
||||
repo_dir = tmp_path / "fake_repo"
|
||||
repo_dir.mkdir()
|
||||
subprocess.run(["git", "init", "-b", "main", str(repo_dir)], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_dir), "config", "user.email", "test@test.local"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_dir), "config", "user.name", "Test User"], check=True)
|
||||
|
||||
test_file = repo_dir / "test.txt"
|
||||
test_file.write_text("initial")
|
||||
subprocess.run(["git", "-C", str(repo_dir), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_dir), "commit", "-m", "initial commit"], check=True)
|
||||
|
||||
mgr = GitSyncManager(wiki_dir=repo_dir)
|
||||
assert mgr.ensure_repo() is True
|
||||
assert mgr.status == "ok"
|
||||
assert mgr.is_conflict is False
|
||||
|
||||
|
||||
def test_git_url_encoding(monkeypatch):
|
||||
monkeypatch.setattr("app.git_sync.GIT_USERNAME", "moritz@graf")
|
||||
monkeypatch.setattr("app.git_sync.GIT_PASSWORD", "p@ss:w/ord%123")
|
||||
monkeypatch.setattr("app.git_sync.GIT_REPO_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git")
|
||||
|
||||
mgr = GitSyncManager()
|
||||
auth_url = mgr.get_auth_repo_url()
|
||||
assert auth_url == "https://moritz%40graf:p%40ss%3Aw%2Ford%25123@git.moritzgraf.de/moritz/knowledge-wiki.git"
|
||||
|
||||
|
||||
def test_ensure_repo_handles_non_empty_dir(tmp_path: Path):
|
||||
# Simulate a PVC containing lost+found before repo initialization
|
||||
wiki_pvc = tmp_path / "wiki_pvc"
|
||||
wiki_pvc.mkdir()
|
||||
(wiki_pvc / "lost+found").mkdir()
|
||||
|
||||
# Create upstream origin to fetch from
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(["git", "init", "--bare", str(origin)], check=True)
|
||||
|
||||
# Push initial commit to bare origin
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
subprocess.run(["git", "init", "-b", "main", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "Admin"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.email", "admin@test.local"], check=True)
|
||||
(work / "README.md").write_text("# Knowledge Wiki")
|
||||
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-m", "init"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(origin)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)
|
||||
|
||||
# Now verify GitSyncManager initializes cleanly into non-empty directory
|
||||
mgr = GitSyncManager(wiki_dir=wiki_pvc)
|
||||
# Point URL to file repo
|
||||
import app.git_sync
|
||||
app.git_sync.GIT_REPO_URL = str(origin)
|
||||
app.git_sync.GIT_USERNAME = ""
|
||||
app.git_sync.GIT_PASSWORD = ""
|
||||
|
||||
success = mgr.ensure_repo()
|
||||
assert success is True
|
||||
assert (wiki_pvc / ".git").is_dir()
|
||||
assert (wiki_pvc / "README.md").is_file()
|
||||
assert (wiki_pvc / "lost+found").is_dir()
|
||||
Reference in New Issue
Block a user