Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from pydantic_settings import BaseSettings, SettingsConfigDict

# Static application version string (not environment-configurable).
VERSION = "0.1.0"


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
Expand Down
7 changes: 4 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi.middleware.cors import CORSMiddleware

from app.auth import router as auth_router # APIRouter (re-exported)
from app.config import settings
from app.config import VERSION, settings
from app.database import init_db
from app.errors import (
KlarHTTPException,
Expand All @@ -17,7 +17,7 @@
validation_exception_handler,
)
from app.rag.store import init_chroma
from app.routers import actions, deadlines, letters, public, rag # router modules
from app.routers import actions, deadlines, health, letters, public, rag # router modules


def _validate_cookie_pairing() -> None:
Expand Down Expand Up @@ -112,7 +112,7 @@ async def lifespan(_: FastAPI):
app = FastAPI(
title="Klar API",
description="German bureaucratic mail → structured obligations + deadlines.",
version="0.1.0",
version=VERSION,
lifespan=lifespan,
)

Expand All @@ -137,6 +137,7 @@ async def lifespan(_: FastAPI):
app.include_router(actions.router)
app.include_router(deadlines.router)
app.include_router(rag.router)
app.include_router(health.router)

# Frontend-facing root surface (matches docs/06-frontend-integration-contract.md)
# Mounts the same auth router at /auth so the bootstrap flow works without /api,
Expand Down
21 changes: 21 additions & 0 deletions backend/app/routers/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""GET /api/health — liveness + DB connectivity check."""

from fastapi import APIRouter
from sqlalchemy import text
from sqlmodel import Session

from app.config import VERSION
from app.database import engine

router = APIRouter(prefix="/api", tags=["health"])


@router.get("/health")
def health() -> dict[str, object]:
db_status = "ok"
try:
with Session(engine) as session:
session.exec(text("SELECT 1"))
except Exception:
db_status = "fail"
return {"status": "ok", "version": VERSION, "db": db_status}
53 changes: 53 additions & 0 deletions backend/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Tests for GET /api/health — success and DB-failure paths.

Deliberately mounts only the health router on a throwaway FastAPI app
(instead of the full `app.main.app`) so this test doesn't pull in ChromaDB /
LangChain import-time side effects that belong to unrelated routers.
"""

from fastapi import FastAPI
from fastapi.testclient import TestClient

from app.routers import health as health_module


def _client() -> TestClient:
app = FastAPI()
app.include_router(health_module.router)
return TestClient(app)


def test_get_api_health_returns_200_with_expected_shape():
resp = _client().get("/api/health")

assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["version"] == health_module.VERSION
assert body["db"] == "ok"


def test_get_api_health_reports_db_fail_on_exception(monkeypatch):
class _BoomSession:
def __init__(self, *_a, **_k):
pass

def __enter__(self):
return self

def __exit__(self, *_a):
return False

def exec(self, *_a, **_k):
raise RuntimeError("database is unreachable")

monkeypatch.setattr(health_module, "Session", _BoomSession)

resp = _client().get("/api/health")

# Per CRITERIA/PLAN: the endpoint itself still returns 200; only the
# `db` field flips to "fail" — it does not surface as an HTTP error.
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["db"] == "fail"