From a7c3169606175b6a746b3b4f8b294432451131e2 Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 5 Oct 2026 04:01:49 +0000 Subject: [PATCH] Upload files to "backend" --- backend/__init__.py | Bin 0 -> 1024 bytes backend/test_api.py | 124 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 backend/__init__.py create mode 100644 backend/test_api.py diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..06d7405020018ddf3cacee90fd4af10487da3d20 GIT binary patch literal 1024 ScmZQz7zLvtFd70QH3R?z00031 literal 0 HcmV?d00001 diff --git a/backend/test_api.py b/backend/test_api.py new file mode 100644 index 0000000..30fe42a --- /dev/null +++ b/backend/test_api.py @@ -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