feat: indie status page MVP -- FastAPI + SQLite

- 8 DB models (services, incidents, monitors, subscribers, etc.)
- Full CRUD API for services, incidents, monitors
- Public status page with live data
- Incident detail page with timeline
- API key authentication
- Uptime monitoring scheduler
- 13 tests passing
- TECHNICAL_DESIGN.md with full spec
This commit is contained in:
IndieStatusBot 2026-04-25 05:00:00 +00:00
commit 902133edd3
4655 changed files with 1342691 additions and 0 deletions

0
tests/__init__.py Normal file
View file

56
tests/conftest.py Normal file
View file

@ -0,0 +1,56 @@
"""Test fixtures for Indie Status Page."""
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
from app.dependencies import get_db
from app.main import app
# Use an in-memory SQLite for tests
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestSessionLocal = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_database():
"""Create all tables once for the test session."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session():
"""Provide a clean database session for each test."""
async with TestSessionLocal() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session: AsyncSession):
"""Provide an HTTP test client with DB dependency override."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def api_key():
"""Return the test API key."""
from app.config import settings
return settings.admin_api_key

154
tests/test_api_incidents.py Normal file
View file

@ -0,0 +1,154 @@
"""Test Incidents API endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_incident(client, api_key):
"""Should create a new incident after creating a service."""
# Create a service first
svc = await client.post(
"/api/v1/services/",
json={"name": "Auth Service", "slug": "auth-service"},
headers={"X-API-Key": api_key},
)
assert svc.status_code == 201
service_id = svc.json()["id"]
# Create an incident
response = await client.post(
"/api/v1/incidents/",
json={
"service_id": service_id,
"title": "Login failures",
"status": "investigating",
"severity": "major",
},
headers={"X-API-Key": api_key},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Login failures"
assert data["status"] == "investigating"
@pytest.mark.asyncio
async def test_list_incidents(client, api_key):
"""Should list incidents."""
response = await client.get("/api/v1/incidents/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_get_incident_with_updates(client, api_key):
"""Should get an incident and add an update."""
# Create a service
svc = await client.post(
"/api/v1/services/",
json={"name": "Storage", "slug": "storage"},
headers={"X-API-Key": api_key},
)
service_id = svc.json()["id"]
# Create an incident
inc = await client.post(
"/api/v1/incidents/",
json={
"service_id": service_id,
"title": "Data loss",
"status": "investigating",
"severity": "outage",
},
headers={"X-API-Key": api_key},
)
incident_id = inc.json()["id"]
# Add an update
upd = await client.post(
f"/api/v1/incidents/{incident_id}/updates",
json={
"status": "identified",
"body": "We found the root cause.",
},
headers={"X-API-Key": api_key},
)
assert upd.status_code == 201
assert upd.json()["status"] == "identified"
# Get the incident - should include updates
get_resp = await client.get(f"/api/v1/incidents/{incident_id}")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["status"] == "identified"
assert len(data["updates"]) == 1
assert data["updates"][0]["body"] == "We found the root cause."
@pytest.mark.asyncio
async def test_update_incident(client, api_key):
"""Should update incident status via PATCH."""
# Create service + incident
svc = await client.post(
"/api/v1/services/",
json={"name": "Cache", "slug": "cache"},
headers={"X-API-Key": api_key},
)
service_id = svc.json()["id"]
inc = await client.post(
"/api/v1/incidents/",
json={
"service_id": service_id,
"title": "Slow responses",
"status": "investigating",
"severity": "minor",
},
headers={"X-API-Key": api_key},
)
incident_id = inc.json()["id"]
# Patch the incident
patch_resp = await client.patch(
f"/api/v1/incidents/{incident_id}",
json={"status": "resolved"},
headers={"X-API-Key": api_key},
)
assert patch_resp.status_code == 200
data = patch_resp.json()
assert data["status"] == "resolved"
assert data["resolved_at"] is not None
@pytest.mark.asyncio
async def test_delete_incident(client, api_key):
"""Should delete an incident."""
svc = await client.post(
"/api/v1/services/",
json={"name": "Email", "slug": "email-svc"},
headers={"X-API-Key": api_key},
)
service_id = svc.json()["id"]
inc = await client.post(
"/api/v1/incidents/",
json={
"service_id": service_id,
"title": "Emails delayed",
"status": "monitoring",
"severity": "minor",
},
headers={"X-API-Key": api_key},
)
incident_id = inc.json()["id"]
del_resp = await client.delete(
f"/api/v1/incidents/{incident_id}",
headers={"X-API-Key": api_key},
)
assert del_resp.status_code == 204
# Verify it's gone
get_resp = await client.get(f"/api/v1/incidents/{incident_id}")
assert get_resp.status_code == 404

119
tests/test_api_services.py Normal file
View file

@ -0,0 +1,119 @@
"""Test Services API endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_service(client, api_key):
"""Should create a new service."""
response = await client.post(
"/api/v1/services/",
json={
"name": "API Server",
"slug": "api-server",
},
headers={"X-API-Key": api_key},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "API Server"
assert data["slug"] == "api-server"
assert data["is_visible"] is True
@pytest.mark.asyncio
async def test_list_services(client, api_key):
"""Should list all services."""
# Create a service first
await client.post(
"/api/v1/services/",
json={"name": "Database", "slug": "database"},
headers={"X-API-Key": api_key},
)
response = await client.get("/api/v1/services/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
@pytest.mark.asyncio
async def test_api_key_required(client):
"""Should reject requests without API key for protected routes."""
response = await client.post(
"/api/v1/services/",
json={"name": "Unauthorized", "slug": "unauth"},
)
assert response.status_code == 422 # Missing required header
@pytest.mark.asyncio
async def test_get_service(client, api_key):
"""Should get a single service by ID."""
create_resp = await client.post(
"/api/v1/services/",
json={"name": "Web App", "slug": "web-app"},
headers={"X-API-Key": api_key},
)
assert create_resp.status_code == 201
service_id = create_resp.json()["id"]
response = await client.get(f"/api/v1/services/{service_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == service_id
assert data["name"] == "Web App"
@pytest.mark.asyncio
async def test_update_service(client, api_key):
"""Should update a service with PATCH."""
create_resp = await client.post(
"/api/v1/services/",
json={"name": "Old Name", "slug": "old-slug"},
headers={"X-API-Key": api_key},
)
service_id = create_resp.json()["id"]
response = await client.patch(
f"/api/v1/services/{service_id}",
json={"name": "New Name"},
headers={"X-API-Key": api_key},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "New Name"
assert data["slug"] == "old-slug" # unchanged
@pytest.mark.asyncio
async def test_delete_service(client, api_key):
"""Should delete a service."""
create_resp = await client.post(
"/api/v1/services/",
json={"name": "Delete Me", "slug": "delete-me"},
headers={"X-API-Key": api_key},
)
service_id = create_resp.json()["id"]
response = await client.delete(
f"/api/v1/services/{service_id}",
headers={"X-API-Key": api_key},
)
assert response.status_code == 204
# Verify it's gone
get_resp = await client.get(f"/api/v1/services/{service_id}")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_update_service_not_found(client, api_key):
"""Should return 404 when updating nonexistent service."""
response = await client.patch(
"/api/v1/services/00000000-0000-0000-0000-000000000000",
json={"name": "Ghost"},
headers={"X-API-Key": api_key},
)
assert response.status_code == 404

13
tests/test_health.py Normal file
View file

@ -0,0 +1,13 @@
"""Test the health check endpoint."""
import pytest
@pytest.mark.asyncio
async def test_health_check(client):
"""Health check should return 200 with status ok."""
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "version" in data