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