Skip to content

Commit bbae64f

Browse files
authored
Merge pull request #198 from AAdewunmi/feat/add-readiness-endpoint
Feat/add readiness endpoint
2 parents b35f982 + 6916464 commit bbae64f

7 files changed

Lines changed: 245 additions & 1 deletion

File tree

config/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
),
2424
path("ops/", include(("returns.urls.ops", "ops"), namespace="ops")),
2525
path("console/", include("console.urls")),
26+
path("api/", include("core.api.urls")),
2627
path("api/analytics/", include("analytics.api.urls")),
2728
path("api/returns/", include("returns.api.urls")),
2829
path("admin/", admin.site.urls),

core/api/urls.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""URL routes for core operational API endpoints."""
2+
3+
from __future__ import annotations
4+
5+
from django.urls import path
6+
7+
from core.api.views import HealthCheckView
8+
9+
app_name = "core_api"
10+
11+
urlpatterns = [
12+
path("health/", HealthCheckView.as_view(), name="health"),
13+
]

core/api/views.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Operational API views for ReturnHub core endpoints."""
2+
3+
from __future__ import annotations
4+
5+
from rest_framework import status
6+
from rest_framework.permissions import AllowAny
7+
from rest_framework.response import Response
8+
from rest_framework.views import APIView
9+
10+
from core.health import get_readiness_payload
11+
12+
13+
class HealthCheckView(APIView):
14+
"""Expose a small readiness contract for load balancers and probes."""
15+
16+
authentication_classes: list[type] = []
17+
permission_classes = [AllowAny]
18+
19+
def get(self, request, *args, **kwargs) -> Response:
20+
"""Return the current readiness state for the application."""
21+
payload = get_readiness_payload()
22+
response_status = (
23+
status.HTTP_200_OK if payload["status"] == "ok" else status.HTTP_503_SERVICE_UNAVAILABLE
24+
)
25+
return Response(payload, status=response_status)

core/health.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Health and readiness checks used by deployment and monitoring tooling."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from django.conf import settings
8+
from django.db import connections
9+
from django.db.utils import OperationalError
10+
from django.utils import timezone
11+
12+
13+
def check_database() -> tuple[bool, str]:
14+
"""Return whether the default database is reachable.
15+
16+
The readiness check uses a lightweight ``SELECT 1`` probe so the endpoint
17+
verifies the actual connection path that the application depends on in
18+
production. The text message is intentionally stable because external
19+
tooling may display or log it directly.
20+
"""
21+
try:
22+
with connections["default"].cursor() as cursor:
23+
cursor.execute("SELECT 1")
24+
cursor.fetchone()
25+
except OperationalError:
26+
return False, "unavailable"
27+
28+
return True, "ok"
29+
30+
31+
def get_readiness_payload() -> dict[str, Any]:
32+
"""Build the stable readiness payload returned by ``/api/health/``.
33+
34+
Returns:
35+
A serialisable dictionary containing the service status, a release
36+
identifier, a UTC timestamp, and named dependency checks.
37+
"""
38+
database_ok, database_status = check_database()
39+
overall_status = "ok" if database_ok else "degraded"
40+
41+
return {
42+
"status": overall_status,
43+
"service": "returnhub",
44+
"release": getattr(settings, "RELEASE_VERSION", "dev"),
45+
"timestamp": timezone.now().isoformat(),
46+
"checks": {
47+
"database": database_status,
48+
},
49+
}

docs/sprint-runbook/sprint-6/sprint-6-multi-surface-verification.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
This runbook verifies the current ReturnHub multi-surface experience using the live repository structure. It covers seeded demo access, public entry points, role-specific console routes, paginated surface routes, wrong-role handling, and the smoke tests that back those flows in the test suite.
55

6+
For an executable version of this runbook, use:
7+
8+
```bash
9+
./docs/sprint-runbook/sprint-6/sprint-6-multi-surface-verification.sh
10+
```
11+
12+
The shell runbooks are written to fail with compact `CHECK_FAILED=` or `UNEXPECTED_ERROR=` lines instead of printing full Python tracebacks.
13+
614
## Scope
715

816
This document is aligned to the current project state:
@@ -36,7 +44,9 @@ Expected result:
3644

3745
- `web` is running
3846
- the seed command reports `ReturnHub demo seed complete.`
39-
- the seed command prints the seeded usernames and a total case count of `32`
47+
- the seed command prints the seeded usernames
48+
- the seed command prints `Seeded demo subset count: 32`
49+
- the seed command may print a larger live `Total cases` value in non-pristine local databases
4050

4151
## Seeded Users
4252

tests/test_core_health_api.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for core health helpers and readiness API views."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, datetime
6+
7+
from django.db.utils import OperationalError
8+
from rest_framework import status
9+
from rest_framework.test import APIRequestFactory
10+
11+
from core.api.views import HealthCheckView
12+
from core.health import check_database, get_readiness_payload
13+
14+
15+
class _HealthyCursor:
16+
"""Minimal cursor stub for the successful database probe path."""
17+
18+
def __init__(self) -> None:
19+
self.executed_sql: list[str] = []
20+
self.fetchone_calls = 0
21+
22+
def __enter__(self) -> _HealthyCursor:
23+
return self
24+
25+
def __exit__(self, exc_type, exc, tb) -> None:
26+
return None
27+
28+
def execute(self, sql: str) -> None:
29+
self.executed_sql.append(sql)
30+
31+
def fetchone(self) -> tuple[int]:
32+
self.fetchone_calls += 1
33+
return (1,)
34+
35+
36+
class _HealthyConnection:
37+
"""Connection stub that returns the supplied cursor."""
38+
39+
def __init__(self, cursor: _HealthyCursor) -> None:
40+
self._cursor = cursor
41+
42+
def cursor(self) -> _HealthyCursor:
43+
return self._cursor
44+
45+
46+
class _UnavailableConnection:
47+
"""Connection stub that raises the expected operational error."""
48+
49+
def cursor(self):
50+
raise OperationalError("database unavailable")
51+
52+
53+
def test_check_database_returns_ok_when_default_database_is_reachable(monkeypatch) -> None:
54+
"""The probe should execute a lightweight query and report success."""
55+
56+
cursor = _HealthyCursor()
57+
monkeypatch.setattr(
58+
"core.health.connections",
59+
{"default": _HealthyConnection(cursor)},
60+
)
61+
62+
assert check_database() == (True, "ok")
63+
assert cursor.executed_sql == ["SELECT 1"]
64+
assert cursor.fetchone_calls == 1
65+
66+
67+
def test_check_database_returns_unavailable_on_operational_error(monkeypatch) -> None:
68+
"""Operational errors should degrade the readiness dependency check."""
69+
70+
monkeypatch.setattr(
71+
"core.health.connections",
72+
{"default": _UnavailableConnection()},
73+
)
74+
75+
assert check_database() == (False, "unavailable")
76+
77+
78+
def test_get_readiness_payload_includes_release_timestamp_and_checks(monkeypatch, settings) -> None:
79+
"""The readiness payload should expose the stable response contract."""
80+
81+
fixed_now = datetime(2026, 4, 21, 9, 3, 36, tzinfo=UTC)
82+
monkeypatch.setattr("core.health.check_database", lambda: (True, "ok"))
83+
monkeypatch.setattr("core.health.timezone.now", lambda: fixed_now)
84+
settings.RELEASE_VERSION = "2026.04.21"
85+
86+
assert get_readiness_payload() == {
87+
"status": "ok",
88+
"service": "returnhub",
89+
"release": "2026.04.21",
90+
"timestamp": fixed_now.isoformat(),
91+
"checks": {"database": "ok"},
92+
}
93+
94+
95+
def test_get_readiness_payload_falls_back_to_degraded_and_dev_release(
96+
monkeypatch, settings
97+
) -> None:
98+
"""The helper should preserve safe defaults for degraded environments."""
99+
100+
fixed_now = datetime(2026, 4, 21, 9, 3, 36, tzinfo=UTC)
101+
monkeypatch.setattr("core.health.check_database", lambda: (False, "unavailable"))
102+
monkeypatch.setattr("core.health.timezone.now", lambda: fixed_now)
103+
del settings.RELEASE_VERSION
104+
105+
assert get_readiness_payload() == {
106+
"status": "degraded",
107+
"service": "returnhub",
108+
"release": "dev",
109+
"timestamp": fixed_now.isoformat(),
110+
"checks": {"database": "unavailable"},
111+
}
112+
113+
114+
def test_health_check_view_returns_200_for_healthy_payload(monkeypatch) -> None:
115+
"""The API view should return 200 when the app is ready."""
116+
117+
monkeypatch.setattr(
118+
"core.api.views.get_readiness_payload",
119+
lambda: {"status": "ok", "checks": {"database": "ok"}},
120+
)
121+
122+
response = HealthCheckView.as_view()(APIRequestFactory().get("/api/health/"))
123+
124+
assert response.status_code == status.HTTP_200_OK
125+
assert response.data == {"status": "ok", "checks": {"database": "ok"}}
126+
127+
128+
def test_health_check_view_returns_503_for_degraded_payload(monkeypatch) -> None:
129+
"""The API view should signal unready status to probes and load balancers."""
130+
131+
monkeypatch.setattr(
132+
"core.api.views.get_readiness_payload",
133+
lambda: {"status": "degraded", "checks": {"database": "unavailable"}},
134+
)
135+
136+
response = HealthCheckView.as_view()(APIRequestFactory().get("/api/health/"))
137+
138+
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
139+
assert response.data == {"status": "degraded", "checks": {"database": "unavailable"}}

tests/test_core_urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ def test_ops_namespace_routes_to_real_case_detail_page() -> None:
3030
assert resolve("/ops/42/").view_name == "ops:case-detail"
3131

3232

33+
def test_api_health_route_resolves_to_core_health_check() -> None:
34+
"""The root router should expose the operational health endpoint."""
35+
36+
assert reverse("core_api:health") == "/api/health/"
37+
assert resolve("/api/health/").view_name == "core_api:health"
38+
39+
3340
def test_config_urls_appends_media_patterns_when_debug(monkeypatch) -> None:
3441
"""Root URLs should append media-serving patterns in debug mode."""
3542

0 commit comments

Comments
 (0)