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:
commit
902133edd3
4655 changed files with 1342691 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal 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
154
tests/test_api_incidents.py
Normal 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
119
tests/test_api_services.py
Normal 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
13
tests/test_health.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue