- 76 new tests covering CLI commands, API routes, auth, runner, load modules — coverage 99% (169 remaining lines are infrastructure) - Enhanced /health endpoint with DuckDB, bib, pipeline service checks - Added `stack health` CLI command with graceful degradation - SnapshotManager for DuckDB data versioning and rollback - .woodpecker.yml CI pipeline: lint, test (99% gate), marimo check - Error handling audit: no bare except clauses, all exceptions logged Closes #31, #32, #33, #34, #35.
170 lines
5.3 KiB
Python
170 lines
5.3 KiB
Python
"""Tests for the FastAPI server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import jwt
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture()
|
|
def api_secret(monkeypatch):
|
|
secret = "test-secret-key-for-jwt"
|
|
monkeypatch.setenv("STACK_API_SECRET", secret)
|
|
return secret
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
from api.server import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture()
|
|
def authed_client(client, api_secret):
|
|
token = jwt.encode({"sub": "test"}, api_secret, algorithm="HS256")
|
|
client.headers["Authorization"] = f"Bearer {token}"
|
|
return client
|
|
|
|
|
|
class TestHealth:
|
|
def test_health(self, client) -> None:
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["status"] in ("ok", "degraded")
|
|
assert "version" in data
|
|
assert "services" in data
|
|
|
|
|
|
class TestAuth:
|
|
def test_issue_token(self, client, api_secret) -> None:
|
|
r = client.post("/auth/token", json={"secret": api_secret})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert "access_token" in data
|
|
assert data["token_type"] == "bearer"
|
|
decoded = jwt.decode(data["access_token"], api_secret, algorithms=["HS256"])
|
|
assert decoded["sub"] == "stack-api"
|
|
|
|
def test_wrong_secret(self, client, api_secret) -> None:
|
|
r = client.post("/auth/token", json={"secret": "wrong"})
|
|
assert r.status_code == 401
|
|
|
|
def test_no_secret_configured(self, client, monkeypatch) -> None:
|
|
monkeypatch.delenv("STACK_API_SECRET", raising=False)
|
|
from conf import cfg
|
|
|
|
cfg._data.setdefault("api", {})["secret"] = ""
|
|
r = client.post("/auth/token", json={"secret": "anything"})
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestPipelines:
|
|
def test_list_pipelines(self, client) -> None:
|
|
r = client.get("/pipelines")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) > 0
|
|
assert "name" in data[0]
|
|
assert "steps" in data[0]
|
|
|
|
def test_get_pipeline(self, client) -> None:
|
|
r = client.get("/pipelines/readmissions")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["name"] == "readmissions"
|
|
assert data["steps"] > 0
|
|
assert "inputs" in data
|
|
assert "outputs" in data
|
|
|
|
def test_get_pipeline_not_found(self, client) -> None:
|
|
r = client.get("/pipelines/nonexistent_pipeline")
|
|
assert r.status_code == 404
|
|
|
|
def test_run_requires_auth(self, client) -> None:
|
|
r = client.post("/pipelines/run/readmissions")
|
|
assert r.status_code in (401, 403)
|
|
|
|
def test_run_with_auth(self, authed_client) -> None:
|
|
r = authed_client.post("/pipelines/run/readmissions")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["status"] == "running"
|
|
assert data["pipeline"] == "readmissions"
|
|
assert data["job_id"]
|
|
|
|
def test_run_unknown_pipeline(self, authed_client) -> None:
|
|
r = authed_client.post("/pipelines/run/nonexistent")
|
|
assert r.status_code == 404
|
|
|
|
def test_run_status_not_found(self, client) -> None:
|
|
r = client.get("/pipelines/run/nonexistent-job/status")
|
|
assert r.status_code == 404
|
|
|
|
def test_run_and_poll_status(self, authed_client) -> None:
|
|
r = authed_client.post("/pipelines/run/readmissions")
|
|
assert r.status_code == 200
|
|
job_id = r.json()["job_id"]
|
|
|
|
# Poll status — job should exist
|
|
r = authed_client.get(f"/pipelines/run/{job_id}/status")
|
|
assert r.status_code == 200
|
|
assert r.json()["job_id"] == job_id
|
|
assert r.json()["status"] in ("running", "completed", "failed")
|
|
|
|
|
|
class TestBib:
|
|
def test_list_items(self, client) -> None:
|
|
r = client.get("/bib/items")
|
|
assert r.status_code == 200
|
|
assert isinstance(r.json(), list)
|
|
|
|
def test_list_tags(self, client) -> None:
|
|
r = client.get("/bib/tags")
|
|
assert r.status_code == 200
|
|
assert isinstance(r.json(), list)
|
|
|
|
|
|
class TestSchema:
|
|
def test_missing_table(self, client) -> None:
|
|
r = client.get("/schema/nonexistent_table")
|
|
assert r.status_code == 404
|
|
|
|
|
|
class TestLineage:
|
|
def test_full_graph(self, client) -> None:
|
|
r = client.get("/lineage")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert "tables" in data
|
|
assert "edges" in data
|
|
assert len(data["tables"]) > 0
|
|
|
|
def test_table_lineage(self, client) -> None:
|
|
r = client.get("/lineage/readmissions._int_encounter")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["table"] == "readmissions._int_encounter"
|
|
assert "inputs" in data
|
|
|
|
def test_table_not_found(self, client) -> None:
|
|
r = client.get("/lineage/nonexistent.table")
|
|
assert r.status_code == 404
|
|
|
|
def test_export_mermaid(self, client) -> None:
|
|
r = client.get("/lineage/export/mermaid")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["format"] == "mermaid"
|
|
assert "graph LR" in data["content"]
|
|
|
|
def test_export_dot(self, client) -> None:
|
|
r = client.get("/lineage/export/dot")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["format"] == "dot"
|
|
assert "digraph" in data["content"]
|