Files
stack/tests/api/test_server.py
kert e07c8e76e9 add P8 quality: 99% coverage, health checks, snapshots, CI pipeline
- 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.
2026-03-12 18:28:55 -04:00

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"]