/report.html shows one company's backups over the last 7, 30 or 90 days, chosen in the page and kept in the URL so reports can be bookmarked or linked. Reached from Reports in the top bar, or a company's name in a job's detail. - Summary (jobs, runs, success rate, warnings, failures, overdue now); per job: run counts, success rate (successful / finished runs), last result and last successful backup; a day-by-day strip; every warning and failure with its message - Print / Save as PDF: a print stylesheet prints light whatever the screen theme, hides the controls, keeps rows and strips whole across pages, and keeps the coloured cells and badges - Days before a job was first known show as "Not monitored yet" rather than as missed backups, so a 90-day report for a newly added client doesn't look like 60 days of failures - CSV of every run (full messages, times in the browser's time zone, UTF-8 BOM for Excel, formula-like values defused with a leading ') Backend: GET /api/v1/tenants, /api/v1/reports/tenant and /api/v1/reports/tenant.csv; periods capped at retention. tzdata added for systems without time zone data. Frontend status and day-strip helpers moved out of app.js into status.js / history.js for reuse. Tested: 9 new backend tests; in the browser against the API with 30 days of demo history (summary, issues, strips, 7/30/90 switching, CSV download with BOM, print styles applied on screen), mock mode and phone width. Co-Authored-By: Claude Opus 5.5 <[email protected]>
134 lines
6.7 KiB
Python
134 lines
6.7 KiB
Python
import csv
|
||
import io
|
||
from datetime import UTC, datetime, 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", retention_days=90)
|
||
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 ('t', ?, ?)", (hash_key(key), to_db(utcnow())))
|
||
conn.close()
|
||
c.headers["Authorization"] = f"Bearer {key}"
|
||
yield c
|
||
|
||
|
||
def report_run(client, tenant, job, status, started, minutes=10, message="", size=None):
|
||
run = {"status": status, "started_at": started.isoformat(), "message": message}
|
||
if status != "running":
|
||
run["finished_at"] = (started + timedelta(minutes=minutes)).isoformat()
|
||
if size is not None:
|
||
run["size_bytes"] = size
|
||
r = client.post("/api/v1/ingest", json={
|
||
"tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": job, "name": job}, "run": run})
|
||
assert r.status_code == 200, r.text
|
||
|
||
|
||
@pytest.fixture
|
||
def alpha(client):
|
||
now = utcnow()
|
||
report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=1), size=1000,
|
||
message="Job completed successfully.")
|
||
report_run(client, "Alpha Construction", "File Server", "failed", now - timedelta(days=2),
|
||
message="=HYPERLINK(\"http://evil\")\nRepository unreachable")
|
||
report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=45)) # outside 30 days
|
||
report_run(client, "Alpha Construction", "SQL01", "warning", now - timedelta(hours=5), message="Log truncation skipped")
|
||
report_run(client, "Alpha Construction", "SQL01", "running", now - timedelta(minutes=5))
|
||
report_run(client, "Bluewater Dental", "Imaging", "success", now - timedelta(days=1))
|
||
return now
|
||
|
||
|
||
def test_lists_companies_with_jobs(client, alpha):
|
||
assert client.get("/api/v1/tenants").json()["tenants"] == [
|
||
{"name": "Alpha Construction", "jobs": 2}, {"name": "Bluewater Dental", "jobs": 1}]
|
||
|
||
|
||
def test_report_covers_one_company_for_the_period(client, alpha):
|
||
r = client.get("/api/v1/reports/tenant", params={"tenant": "alpha construction"}) # any case
|
||
assert r.status_code == 200
|
||
report = r.json()
|
||
assert report["tenant"] == "Alpha Construction"
|
||
assert report["days"] == 30
|
||
jobs = {j["job_name"]: j for j in report["jobs"]}
|
||
assert set(jobs) == {"File Server", "SQL01"} # not Bluewater's
|
||
assert [run["status"] for run in jobs["File Server"]["runs"]] == ["failed", "success"] # oldest first; 45-day-old run left out
|
||
assert [run["status"] for run in jobs["SQL01"]["runs"]] == ["warning", "running"]
|
||
assert jobs["SQL01"]["status"] == "running"
|
||
|
||
|
||
def test_report_says_when_each_job_was_first_tracked(client, alpha):
|
||
jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]}
|
||
# File Server's earliest run (45 days ago, outside the report) predates its first report to the API
|
||
assert jobs["File Server"]["tracked_since"] == to_db(alpha - timedelta(days=45))
|
||
# SQL01's runs are all recent; it was first reported (created) when its first run arrived
|
||
assert jobs["SQL01"]["tracked_since"] <= to_db(alpha - timedelta(hours=5))
|
||
|
||
|
||
def test_report_includes_messages_only_for_warnings_and_failures(client, alpha):
|
||
jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]}
|
||
failed, success = jobs["File Server"]["runs"]
|
||
assert "Repository unreachable" in failed["message"]
|
||
assert success["message"] is None
|
||
assert success["size_bytes"] == 1000
|
||
assert success["duration_seconds"] == 600
|
||
|
||
|
||
def test_report_period_is_limited_to_retention(client, alpha):
|
||
assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 90}).status_code == 200
|
||
r = client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 91})
|
||
assert r.status_code == 422
|
||
assert "90 days" in r.json()["detail"]
|
||
assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 0}).status_code == 422
|
||
|
||
|
||
def test_unknown_company_is_404(client, alpha):
|
||
assert client.get("/api/v1/reports/tenant", params={"tenant": "Nobody Inc"}).status_code == 404
|
||
|
||
|
||
def read_csv(response):
|
||
text = response.content.decode("utf-8")
|
||
assert text.startswith("") # so Excel reads it as UTF-8
|
||
return list(csv.reader(io.StringIO(text[1:])))
|
||
|
||
|
||
def test_csv_has_every_run_with_times_in_the_given_zone(client):
|
||
started = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) # 05:00 in Edmonton (MST, -7)
|
||
# The fixture's timestamps are relative to now; this run needs a fixed winter date in range
|
||
client.app.state.settings = Settings(db_path=client.app.state.settings.db_path, retention_days=10_000)
|
||
report_run(client, "Alpha Construction", "File Server", "success", started, minutes=20, size=5,
|
||
message="Job completed successfully.")
|
||
r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "days": 5000, "tz": "America/Edmonton"})
|
||
assert r.status_code == 200
|
||
assert r.headers["content-type"].startswith("text/csv")
|
||
rows = read_csv(r)
|
||
assert rows[0] == ["Company", "Job", "Source", "Started (America/Edmonton)", "Finished (America/Edmonton)",
|
||
"Duration (seconds)", "Status", "Size (bytes)", "Message"]
|
||
assert rows[1] == ["Alpha Construction", "File Server", "Veeam B&R", "2026-01-15 05:00:00", "2026-01-15 05:20:00",
|
||
"1200", "success", "5", "Job completed successfully."]
|
||
|
||
|
||
def test_csv_defuses_spreadsheet_formulas_and_keeps_full_messages(client, alpha):
|
||
rows = read_csv(client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"}))
|
||
by_status = {row[6]: row for row in rows[1:]}
|
||
assert by_status["failed"][8].startswith("'=HYPERLINK")
|
||
assert by_status["success"][8] == "Job completed successfully." # successes' messages too, unlike the JSON
|
||
assert by_status["running"][4] == "" # not finished
|
||
assert len(rows) == 1 + 4 # header + the 4 runs in the last 30 days
|
||
|
||
|
||
def test_csv_filename_and_bad_time_zone(client, alpha):
|
||
r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"})
|
||
assert 'filename="backup-report-alpha-construction-' in r.headers["content-disposition"]
|
||
assert client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "tz": "Mars/Olympus"}).status_code == 422
|