Add backend API, running status and 30-day run history
- FastAPI + SQLite backend: tenants, jobs and runs, with an idempotent /api/v1/ingest endpoint authenticated by per-collector API keys - Runs store started_at and finished_at; "Last Run" is the most recent finish, and in-progress runs show as Running - Job modal shows a 30-day daily strip, success rate, and a filterable run list with per-run messages - nginx: HTTPS plus basic auth, ingest endpoint exempt; systemd unit - manage.py for API keys, job cleanup and demo data; pytest suite Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
from app.auth import generate_key, hash_key
|
||||
from app.main import create_app
|
||||
from app.settings import Settings
|
||||
from app.timeutil import to_db, utcnow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
settings = Settings(db_path=tmp_path / "test.db")
|
||||
with TestClient(create_app(settings)) as c:
|
||||
key = generate_key()
|
||||
conn = db.connect(settings.db_path)
|
||||
conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('test', ?, ?)",
|
||||
(hash_key(key), to_db(utcnow())))
|
||||
conn.close()
|
||||
c.headers["Authorization"] = f"Bearer {key}"
|
||||
yield c
|
||||
|
||||
|
||||
def iso(delta: timedelta) -> str:
|
||||
return (utcnow() + delta).isoformat()
|
||||
|
||||
|
||||
def payload(run=None, **job):
|
||||
body = {"tenant": "Acme Corp", "source_system": "Veeam B&R",
|
||||
"job": {"external_id": "job-1", "name": "File Server", **job}}
|
||||
if run is not None:
|
||||
body["run"] = run
|
||||
return body
|
||||
|
||||
|
||||
def test_ingest_requires_valid_key(client):
|
||||
assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": ""}).status_code == 401
|
||||
assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": "Bearer nope"}).status_code == 401
|
||||
|
||||
|
||||
def test_running_then_finished_merges_into_one_run(client):
|
||||
started = iso(-timedelta(hours=1))
|
||||
r = client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started}))
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job_id"]
|
||||
|
||||
job = client.get("/api/v1/jobs").json()["jobs"][0]
|
||||
assert job["status"] == "running"
|
||||
assert job["running_since"] is not None
|
||||
assert job["last_run_at"] is None # nothing has finished yet
|
||||
|
||||
finished = iso(-timedelta(minutes=10))
|
||||
client.post("/api/v1/ingest", json=payload(
|
||||
{"status": "warning", "started_at": started, "finished_at": finished, "message": "1 file skipped"}))
|
||||
|
||||
detail = client.get(f"/api/v1/jobs/{job_id}").json()
|
||||
assert detail["status"] == "warning"
|
||||
assert detail["running_since"] is None
|
||||
assert detail["last_run_at"] == to_db(utcnow().fromisoformat(finished))
|
||||
assert detail["duration_seconds"] == 50 * 60
|
||||
assert detail["message"] == "1 file skipped"
|
||||
assert len(client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]) == 1
|
||||
|
||||
|
||||
def test_late_running_report_does_not_reopen_finished_run(client):
|
||||
started = iso(-timedelta(hours=1))
|
||||
client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": started, "finished_at": iso(-timedelta(minutes=5))}))
|
||||
client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started}))
|
||||
assert client.get("/api/v1/jobs").json()["jobs"][0]["status"] == "success"
|
||||
|
||||
|
||||
def test_last_run_is_previous_finish_while_new_run_in_progress(client):
|
||||
prev_end = iso(-timedelta(hours=23))
|
||||
client.post("/api/v1/ingest", json=payload({"status": "failed", "started_at": iso(-timedelta(hours=24)), "finished_at": prev_end, "message": "boom"}))
|
||||
client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": iso(-timedelta(minutes=5))}))
|
||||
job = client.get("/api/v1/jobs").json()["jobs"][0]
|
||||
assert job["status"] == "running"
|
||||
assert job["last_run_at"] == to_db(utcnow().fromisoformat(prev_end))
|
||||
detail = client.get(f"/api/v1/jobs/{job['id']}").json()
|
||||
assert detail["last_status"] == "failed"
|
||||
assert detail["message"] == "boom"
|
||||
|
||||
|
||||
def test_next_run_at_only_changes_when_sent(client):
|
||||
nxt = iso(timedelta(hours=5))
|
||||
client.post("/api/v1/ingest", json=payload(next_run_at=nxt))
|
||||
client.post("/api/v1/ingest", json=payload()) # omitted → keep
|
||||
assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] == to_db(utcnow().fromisoformat(nxt))
|
||||
client.post("/api/v1/ingest", json=payload(next_run_at=None)) # explicit null → clear
|
||||
assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] is None
|
||||
|
||||
|
||||
def test_history_window_and_run_detail(client):
|
||||
for days_ago in (40, 20, 1):
|
||||
start = iso(-timedelta(days=days_ago))
|
||||
client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": start,
|
||||
"finished_at": start, "message": f"{days_ago}d"}))
|
||||
job_id = client.get("/api/v1/jobs").json()["jobs"][0]["id"]
|
||||
runs = client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]
|
||||
assert len(runs) == 2 # the 40-day-old run is outside the 30-day window
|
||||
assert "message" not in runs[0]
|
||||
run = client.get(f"/api/v1/runs/{runs[0]['id']}").json()
|
||||
assert run["message"] == "1d"
|
||||
assert client.get(f"/api/v1/jobs/{job_id}/runs?days=31").status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run", [
|
||||
{"status": "running", "started_at": iso(-timedelta(hours=1)), "finished_at": iso(timedelta(0))},
|
||||
{"status": "success", "started_at": iso(timedelta(0)), "finished_at": iso(-timedelta(hours=1))},
|
||||
{"status": "success", "started_at": "2026-01-01T00:00:00"}, # no timezone
|
||||
{"status": "done", "started_at": iso(timedelta(0))},
|
||||
])
|
||||
def test_rejects_bad_runs(client, run):
|
||||
assert client.post("/api/v1/ingest", json=payload(run)).status_code == 422
|
||||
|
||||
|
||||
def test_tenant_name_is_case_insensitive(client):
|
||||
client.post("/api/v1/ingest", json=payload())
|
||||
body = payload()
|
||||
body["tenant"] = "ACME CORP"
|
||||
client.post("/api/v1/ingest", json=body)
|
||||
assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1
|
||||
Reference in New Issue
Block a user