Merge branch 'feat/phase1-scaffold'
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# SneakySwole Environment Configuration
|
||||||
|
# Copy this file to .env and fill in real values.
|
||||||
|
|
||||||
|
# Admin credentials (used to create admin user on first run)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# App settings
|
||||||
|
APP_ENV=development
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
APP_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite:///data/sneakyswole.db
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# -- Build stage --
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (layer caching)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY config/ ./config/
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
53
app/config.py
Normal file
53
app/config.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Application configuration loader.
|
||||||
|
|
||||||
|
Reads environment variables (with .env file support) and provides
|
||||||
|
a typed, validated Settings object used throughout the application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
|
# Load .env file from project root
|
||||||
|
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
load_dotenv(_env_path)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Typed application settings loaded from environment variables.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
admin_username: Admin login username (from ADMIN_USERNAME env var).
|
||||||
|
admin_password: Admin login password (from ADMIN_PASSWORD env var).
|
||||||
|
app_env: Runtime environment — 'development' or 'production'.
|
||||||
|
app_host: Host address to bind the server to.
|
||||||
|
app_port: Port number for the server.
|
||||||
|
app_log_level: Minimum log level for structlog output.
|
||||||
|
database_url: SQLite connection string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
admin_username: str
|
||||||
|
admin_password: str
|
||||||
|
app_env: str = "development"
|
||||||
|
app_host: str = "0.0.0.0"
|
||||||
|
app_port: int = 8000
|
||||||
|
app_log_level: str = "info"
|
||||||
|
database_url: str = "sqlite:///data/sneakyswole.db"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Return a cached Settings instance (singleton pattern).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The application Settings object.
|
||||||
|
"""
|
||||||
|
return Settings()
|
||||||
48
app/logging_config.py
Normal file
48
app/logging_config.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Structured logging configuration using structlog.
|
||||||
|
|
||||||
|
Call setup_logging() once at application startup to configure
|
||||||
|
structlog with human-readable (dev) or JSON (prod) output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(log_level: str = "info") -> None:
|
||||||
|
"""Configure structlog for the application.
|
||||||
|
|
||||||
|
In development, output is colorized and human-readable.
|
||||||
|
In production, output is JSON for structured log aggregation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_level: Minimum log level as a string (e.g., 'info', 'debug').
|
||||||
|
"""
|
||||||
|
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
|
||||||
|
|
||||||
|
# Configure standard library logging (captured by structlog)
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=numeric_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
structlog.dev.ConsoleRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
59
app/main.py
Normal file
59
app/main.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""FastAPI application factory for SneakySwole.
|
||||||
|
|
||||||
|
Creates and configures the FastAPI app with routes, templates,
|
||||||
|
static files, and structured logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.logging_config import setup_logging
|
||||||
|
from app.routes.health import router as health_router
|
||||||
|
from app.routes.pages import router as pages_router
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Template and static file directories
|
||||||
|
_BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
TEMPLATES_DIR = _BASE_DIR / "templates"
|
||||||
|
STATIC_DIR = _BASE_DIR / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create and configure the FastAPI application.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully configured FastAPI application instance.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
setup_logging(log_level=settings.app_log_level)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="SneakySwole",
|
||||||
|
description="Open-source workout tracking and programming",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
# Jinja2 templates (available to routes via request.state or dependency)
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
app.state.templates = templates
|
||||||
|
|
||||||
|
# Register route modules
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(pages_router)
|
||||||
|
|
||||||
|
logger.info("app_started", environment=settings.app_env)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# Uvicorn entry point
|
||||||
|
app = create_app()
|
||||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
23
app/routes/health.py
Normal file
23
app/routes/health.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Health check route for monitoring and readiness probes.
|
||||||
|
|
||||||
|
Provides a simple GET /health endpoint that returns application
|
||||||
|
status, name, and version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check() -> dict:
|
||||||
|
"""Return application health status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with app name, version, and status.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"app": "sneakyswole",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
23
app/routes/pages.py
Normal file
23
app/routes/pages.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Page routes for serving full HTML pages.
|
||||||
|
|
||||||
|
Renders Jinja2 templates for user-facing pages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def home_page(request: Request) -> HTMLResponse:
|
||||||
|
"""Render the home page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered home page HTML.
|
||||||
|
"""
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse(request, "pages/home.html")
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
36
app/static/css/sneakyswole.css
Normal file
36
app/static/css/sneakyswole.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/* SneakySwole custom CSS overrides
|
||||||
|
Built on top of Pico CSS dark theme.
|
||||||
|
Keep Pico mostly untouched — only override what's necessary. */
|
||||||
|
|
||||||
|
/* Slightly bolder nav styling */
|
||||||
|
nav h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active nav link indicator */
|
||||||
|
nav a[aria-current="page"] {
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing for main content area */
|
||||||
|
main.container {
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash message styling */
|
||||||
|
.flash-success {
|
||||||
|
color: var(--pico-ins-color);
|
||||||
|
border-left: 4px solid var(--pico-ins-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-error {
|
||||||
|
color: var(--pico-del-color);
|
||||||
|
border-left: 4px solid var(--pico-del-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
47
app/templates/base.html
Normal file
47
app/templates/base.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}SneakySwole{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Pico CSS (dark theme via data-theme="dark" on <html>) -->
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
|
||||||
|
<!-- Custom overrides -->
|
||||||
|
<link rel="stylesheet" href="/static/css/sneakyswole.css">
|
||||||
|
|
||||||
|
<!-- HTMX -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"
|
||||||
|
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
{% block head_extra %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="container">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><strong><a href="/">SneakySwole</a></strong></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
{% block nav_items %}
|
||||||
|
<!-- Phase 3 adds: profile switcher, login/logout -->
|
||||||
|
{% endblock %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block flash %}{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container">
|
||||||
|
<small>SneakySwole — Open-source workout tracking</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
app/templates/pages/home.html
Normal file
12
app/templates/pages/home.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SneakySwole — Home{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>SneakySwole</h1>
|
||||||
|
<p>Your open-source workout tracker</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<p>Welcome to SneakySwole. Get started by logging in.</p>
|
||||||
|
{% endblock %}
|
||||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
23
docker-compose.yaml
Normal file
23
docker-compose.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: sneakyswole
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- sneakyswole-data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sneakyswole-data:
|
||||||
|
driver: local
|
||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "sneakyswole"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Open-source workout tracking and programming app"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75.0"]
|
||||||
|
build-backend = "setuptools.backends._legacy:_Backend"
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi>=0.115.0,<1.0.0
|
||||||
|
uvicorn[standard]>=0.34.0,<1.0.0
|
||||||
|
jinja2>=3.1.0,<4.0.0
|
||||||
|
structlog>=24.0.0,<25.0.0
|
||||||
|
python-dotenv>=1.0.0,<2.0.0
|
||||||
|
bcrypt>=4.2.0,<5.0.0
|
||||||
|
pyyaml>=6.0.0,<7.0.0
|
||||||
|
sqlmodel>=0.0.22,<1.0.0
|
||||||
|
alembic>=1.14.0,<2.0.0
|
||||||
|
httpx>=0.28.0,<1.0.0
|
||||||
|
pytest>=8.0.0,<9.0.0
|
||||||
|
pytest-asyncio>=0.24.0,<1.0.0
|
||||||
|
pydantic-settings>=2.7.0,<3.0.0
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Shared test fixtures for the SneakySwole test suite."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _set_test_env() -> None:
|
||||||
|
"""Ensure required env vars are set for all tests."""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"ADMIN_USERNAME": "testadmin",
|
||||||
|
"ADMIN_PASSWORD": "testpass123",
|
||||||
|
"APP_ENV": "development",
|
||||||
|
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
|
||||||
|
}, clear=False):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
"""Create a FastAPI TestClient for integration tests."""
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
return TestClient(app)
|
||||||
12
tests/test_app.py
Normal file
12
tests/test_app.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Tests for the FastAPI application factory."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateApp:
|
||||||
|
"""Tests for the application factory and basic setup."""
|
||||||
|
|
||||||
|
def test_app_starts_without_error(self, client: TestClient) -> None:
|
||||||
|
"""The app should initialize and serve requests."""
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for the application configuration loader."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings:
|
||||||
|
"""Tests for the Settings configuration class."""
|
||||||
|
|
||||||
|
def test_settings_loads_defaults(self) -> None:
|
||||||
|
"""Settings should have sensible defaults for all fields."""
|
||||||
|
env = {
|
||||||
|
"ADMIN_USERNAME": "testadmin",
|
||||||
|
"ADMIN_PASSWORD": "testpass123",
|
||||||
|
}
|
||||||
|
# Remove DATABASE_URL so we test the actual default
|
||||||
|
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
|
||||||
|
env_clean.update(env)
|
||||||
|
with patch.dict(os.environ, env_clean, clear=True):
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.admin_username == "testadmin"
|
||||||
|
assert settings.admin_password == "testpass123"
|
||||||
|
assert settings.app_env == "development"
|
||||||
|
assert settings.app_host == "0.0.0.0"
|
||||||
|
assert settings.app_port == 8000
|
||||||
|
assert settings.database_url == "sqlite:///data/sneakyswole.db"
|
||||||
|
|
||||||
|
def test_settings_requires_admin_username(self) -> None:
|
||||||
|
"""Settings should require ADMIN_USERNAME to be set."""
|
||||||
|
with patch.dict(os.environ, {"ADMIN_PASSWORD": "testpass"}, clear=True):
|
||||||
|
try:
|
||||||
|
Settings()
|
||||||
|
assert False, "Should have raised an error"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_settings_returns_singleton(self) -> None:
|
||||||
|
"""get_settings should return the same instance on repeated calls."""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"ADMIN_USERNAME": "admin",
|
||||||
|
"ADMIN_PASSWORD": "pass",
|
||||||
|
}, clear=False):
|
||||||
|
s1 = get_settings()
|
||||||
|
s2 = get_settings()
|
||||||
|
assert s1 is s2
|
||||||
30
tests/test_health.py
Normal file
30
tests/test_health.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Tests for the health check endpoint."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthCheck:
|
||||||
|
"""Tests for GET /health."""
|
||||||
|
|
||||||
|
def test_health_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""GET /health should return HTTP 200."""
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_health_returns_status_ok(self, client: TestClient) -> None:
|
||||||
|
"""GET /health should return a JSON body with status 'ok'."""
|
||||||
|
response = client.get("/health")
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
|
def test_health_includes_app_name(self, client: TestClient) -> None:
|
||||||
|
"""GET /health should include the application name."""
|
||||||
|
response = client.get("/health")
|
||||||
|
data = response.json()
|
||||||
|
assert data["app"] == "sneakyswole"
|
||||||
|
|
||||||
|
def test_health_includes_version(self, client: TestClient) -> None:
|
||||||
|
"""GET /health should include the application version."""
|
||||||
|
response = client.get("/health")
|
||||||
|
data = response.json()
|
||||||
|
assert "version" in data
|
||||||
22
tests/test_logging.py
Normal file
22
tests/test_logging.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Tests for structured logging configuration."""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.logging_config import setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogging:
|
||||||
|
"""Tests for the structlog configuration."""
|
||||||
|
|
||||||
|
def test_setup_logging_configures_structlog(self) -> None:
|
||||||
|
"""setup_logging should configure structlog without errors."""
|
||||||
|
setup_logging(log_level="info")
|
||||||
|
logger = structlog.get_logger("test")
|
||||||
|
assert logger is not None
|
||||||
|
|
||||||
|
def test_logger_can_log_message(self) -> None:
|
||||||
|
"""A configured logger should be able to emit a log message."""
|
||||||
|
setup_logging(log_level="debug")
|
||||||
|
logger = structlog.get_logger("test_module")
|
||||||
|
# Should not raise
|
||||||
|
logger.info("test message", key="value")
|
||||||
22
tests/test_pages.py
Normal file
22
tests/test_pages.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Tests for HTML page routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestHomePage:
|
||||||
|
"""Tests for GET /."""
|
||||||
|
|
||||||
|
def test_home_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""GET / should return HTTP 200."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_home_contains_app_name(self, client: TestClient) -> None:
|
||||||
|
"""GET / should render HTML containing the app name."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "SneakySwole" in response.text
|
||||||
|
|
||||||
|
def test_home_has_dark_theme(self, client: TestClient) -> None:
|
||||||
|
"""GET / should use the dark theme."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert 'data-theme="dark"' in response.text
|
||||||
Reference in New Issue
Block a user