feat: phase 0 foundation — FastAPI scaffold + /healthz

Scaffold app/ package, pinned requirements.txt, multi-stage Dockerfile,
docker-compose.yml, and .env.example. Add typed pydantic-settings loader
with full env contract and a production validator that refuses the
dev-sentinel SECRET_KEY. Wire structlog with an APP_ENV-driven renderer
(console in dev, JSON in prod). Ship a minimal unauthenticated /healthz
returning {status, version, commit_sha} with commit SHA fed through a
GIT_COMMIT_SHA build arg.

Also mark Phase 0 complete in docs/ROADMAP.md.
This commit is contained in:
2026-04-21 14:44:41 -05:00
parent 22f357f3e8
commit 78dd1ac243
19 changed files with 653 additions and 9 deletions

48
.env.example Normal file
View File

@@ -0,0 +1,48 @@
# ---------------------------------------------------------------------------
# Chicken Babies R Us — environment contract
#
# Copy this file to `.env` and fill in the real values locally. The .env file
# is gitignored. This file (.env.example) is the public contract and MUST
# contain only placeholder / safe-default values — never real secrets.
# ---------------------------------------------------------------------------
# --- Runtime mode -----------------------------------------------------------
# development | production
APP_ENV=development
# --- Signing / sessions -----------------------------------------------------
# itsdangerous signer for cookies / CSRF tokens.
# Generate locally with: python -c "import secrets; print(secrets.token_urlsafe(48))"
# The string below is a DEV-ONLY sentinel; the app refuses to start in
# production if SECRET_KEY still matches this value.
SECRET_KEY=dev-insecure-change-me
# --- Database ---------------------------------------------------------------
DATABASE_URL=sqlite:///data/app.db
# --- Email (Resend) ---------------------------------------------------------
RESEND_API_KEY=
RESEND_FROM=no-reply@chickenbabies.example
# --- Admin allowlist / contact routing --------------------------------------
# Comma-separated list of emails allowed to request admin magic links.
ADMIN_EMAILS=
# Inbox that the public contact form messages get routed to.
ADMIN_CONTACT_EMAIL=
# --- hCaptcha ---------------------------------------------------------------
HCAPTCHA_SITE_KEY=
HCAPTCHA_SECRET=
# --- Reverse proxy / Uvicorn ------------------------------------------------
# Caddy's LAN IP (comma-separated allowed). Only headers from these IPs are
# trusted for X-Forwarded-For / X-Forwarded-Proto.
FORWARDED_ALLOW_IPS=127.0.0.1
# --- Session / auth tuning --------------------------------------------------
SESSION_MAX_DAYS=30
MAGIC_LINK_TTL_MIN=15
# --- Build metadata ---------------------------------------------------------
# Injected at Docker build time. Surfaced by /healthz. Optional in dev.
GIT_COMMIT_SHA=unknown

7
.gitignore vendored
View File

@@ -25,8 +25,11 @@ env/
*.db-wal *.db-wal
*.db-shm *.db-shm
# Runtime data (DB + media uploads live here in prod) # Runtime data (DB + media uploads live here in prod).
data/ # Ignore everything inside data/ except the .gitkeep marker; negating a
# path whose *directory* is ignored does not work, so we ignore the
# contents with a trailing glob instead.
data/*
!data/.gitkeep !data/.gitkeep
# Editors / IDE # Editors / IDE

90
Dockerfile Normal file
View File

@@ -0,0 +1,90 @@
# syntax=docker/dockerfile:1.7
# ---------------------------------------------------------------------------
# Chicken Babies R Us — container image
#
# Multi-stage build:
# builder - install build deps, create a venv, resolve Python requirements
# runtime - slim image with only runtime libs + the copied venv + app code
#
# Phase 0 scope: runs as root and has no HEALTHCHECK directive. Phase 6
# (per docs/ROADMAP.md) introduces a non-root user and a HEALTHCHECK.
# ---------------------------------------------------------------------------
ARG PYTHON_IMAGE=python:3.12-slim-bookworm
# ==== Stage 1: builder =====================================================
FROM ${PYTHON_IMAGE} AS builder
# Avoid interactive apt prompts and keep pip quiet + deterministic.
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
# Build dependencies: `build-essential` covers any wheel that needs to
# compile from source; libmagic headers are required by python-magic.
# Keep this list minimal to reduce attack surface of the builder stage.
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
build-essential \
libmagic1 \
libmagic-dev \
&& rm -rf /var/lib/apt/lists/*
# Dedicated virtualenv at a stable path so the runtime stage can copy it
# verbatim. This keeps the runtime image free of build tooling.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
WORKDIR /build
# Install Python dependencies. Copying only requirements.txt first lets
# Docker cache the dependency layer when application code changes.
COPY requirements.txt /build/requirements.txt
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# ==== Stage 2: runtime =====================================================
FROM ${PYTHON_IMAGE} AS runtime
# Runtime-only libs: python-magic needs libmagic1 at import time. Build
# tooling is intentionally NOT installed here.
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# Copy the pre-built virtualenv from the builder stage and put it first
# on PATH so `python` and installed console scripts (uvicorn) resolve.
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
WORKDIR /app
# Application source. Only the `app/` package is needed at runtime; tests
# and docs stay out of the image.
COPY app /app/app
# Git commit SHA wired in at build time. Declared as an ARG with a safe
# default so local builds without --build-arg still succeed; surfaced to
# runtime via an ENV so the Settings loader can pick it up.
ARG GIT_COMMIT_SHA=unknown
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
EXPOSE 8000
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
# listed peer IPs (Caddy on the host). No --reload: this is a prod-shape
# image; local hot-reload is a dev concern and runs outside Docker.
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--proxy-headers", \
"--forwarded-allow-ips", "127.0.0.1"]

14
app/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""Chicken Babies R Us FastAPI application package.
This module intentionally keeps only the package version constant. All
runtime wiring lives in :mod:`app.main`.
"""
from __future__ import annotations
# Semantic version of the application. Bump per release. Surfaced via
# FastAPI's OpenAPI metadata and the /healthz endpoint so ops can verify
# which build is live in a given environment.
__version__: str = "0.1.0"
__all__ = ["__version__"]

149
app/config.py Normal file
View File

@@ -0,0 +1,149 @@
"""Typed application configuration loader.
Loads environment variables (with `.env` support for local development)
into a strongly-typed :class:`Settings` instance via `pydantic-settings`.
The full environment contract lives here so every consumer of configuration
can import :func:`get_settings` and get a validated object. Values that
aren't needed until later roadmap phases are declared as :data:`Optional`
so the app boots cleanly in Phase 0 without mandating yet-unused secrets.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal, Optional
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Sentinel value for the SECRET_KEY in .env.example. The production
# validator below refuses to start the app if this sentinel survives into
# a production deployment. Keeping it as a named constant makes the
# security control explicit and searchable.
_DEV_SECRET_KEY_SENTINEL: str = "dev-insecure-change-me"
class Settings(BaseSettings):
"""Strongly-typed runtime configuration.
Populated from process environment variables and (for local dev) an
optional `.env` file. All fields correspond 1:1 with the env-var
contract documented in ``docs/ROADMAP.md`` and ``.env.example``.
"""
# Pydantic-settings config: read from .env when present, ignore unknown
# keys (so adding new vars to .env ahead of code changes is non-fatal),
# and treat env var names case-insensitively.
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# --- Runtime mode ------------------------------------------------------
app_env: Literal["development", "production"] = Field(
default="development",
description="Runtime mode; gates debug behavior and logging renderer.",
)
# --- Signing / sessions -----------------------------------------------
# Used by itsdangerous for signed cookies and CSRF tokens. The default
# is intentionally a well-known sentinel so local dev just works; the
# model validator below blocks it from reaching production.
secret_key: str = Field(
default=_DEV_SECRET_KEY_SENTINEL,
description="itsdangerous signer key; MUST be overridden in production.",
)
# --- Database ----------------------------------------------------------
database_url: str = Field(
default="sqlite:///data/app.db",
description="SQLAlchemy URL for the application database.",
)
# --- Email (Resend) ----------------------------------------------------
# Optional at Phase 0: contact form and magic-link delivery come online
# in Phases 3 and 5 respectively.
resend_api_key: Optional[str] = Field(default=None)
resend_from: Optional[str] = Field(default=None)
# --- Admin allowlist / contact routing ---------------------------------
# Comma-separated list; stored raw so we preserve exact input for audit,
# and split on demand via the `admin_emails_list` property.
admin_emails: str = Field(
default="",
description="Comma-separated allowlist of admin email addresses.",
)
admin_contact_email: Optional[str] = Field(default=None)
# --- hCaptcha ----------------------------------------------------------
hcaptcha_site_key: Optional[str] = Field(default=None)
hcaptcha_secret: Optional[str] = Field(default=None)
# --- Reverse proxy -----------------------------------------------------
# Only requests whose peer IP is in this list will have their
# X-Forwarded-* headers trusted by Uvicorn's proxy-headers support.
forwarded_allow_ips: str = Field(default="127.0.0.1")
# --- Session / auth tuning --------------------------------------------
session_max_days: int = Field(default=30, ge=1, le=365)
magic_link_ttl_min: int = Field(default=15, ge=1, le=60)
# --- Build metadata ----------------------------------------------------
# Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so
# operators can confirm which build is live.
git_commit_sha: str = Field(default="unknown")
# ----------------------------------------------------------------------
# Derived helpers
# ----------------------------------------------------------------------
@property
def admin_emails_list(self) -> list[str]:
"""Return the admin allowlist as a clean list of lowercase strings.
Splits ``admin_emails`` on commas, strips whitespace, drops empty
tokens, and lowercases — matching typical email canonicalization
for allowlist comparisons. The raw string is preserved on the
instance for audit / diagnostic purposes.
"""
return [
part.strip().lower()
for part in self.admin_emails.split(",")
if part.strip()
]
# ----------------------------------------------------------------------
# Validators
# ----------------------------------------------------------------------
@model_validator(mode="after")
def _refuse_dev_secret_in_production(self) -> "Settings":
"""Fail fast if the dev sentinel SECRET_KEY reaches production.
Security control: prevents accidental deployment with the
publicly-documented placeholder secret, which would make every
signed cookie / CSRF token forgeable by anyone with the source.
"""
if (
self.app_env == "production"
and self.secret_key == _DEV_SECRET_KEY_SENTINEL
):
raise ValueError(
"SECRET_KEY must be overridden in production; "
"the dev sentinel value is not permitted."
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return a process-wide cached :class:`Settings` instance.
Using :func:`functools.lru_cache` ensures the environment is parsed
exactly once per process, which is both cheaper and — more importantly
— guarantees every caller sees the same validated values. Tests that
need a fresh parse can call ``get_settings.cache_clear()``.
"""
return Settings()

72
app/logging_config.py Normal file
View File

@@ -0,0 +1,72 @@
"""Structlog initialization.
Single entry point :func:`configure_logging` sets up structlog with an
``APP_ENV``-driven renderer: a pretty console renderer during development
and JSON-lines output in production so logs plug straight into any log
aggregator. Must be called exactly once at app startup, before any
module obtains a logger via ``structlog.get_logger()``.
"""
from __future__ import annotations
import logging
import sys
from typing import Any
import structlog
def configure_logging(app_env: str) -> None:
"""Configure structlog + stdlib logging for the application.
Parameters
----------
app_env:
The resolved ``APP_ENV`` value. ``"development"`` selects a
colorized console renderer; anything else (including
``"production"``) selects the JSON renderer so logs are
machine-parseable.
Notes
-----
This function is idempotent within a process: structlog accepts
repeated calls to :func:`structlog.configure`. It deliberately keeps
the setup small for Phase 0 — a proper ``ProcessorFormatter`` bridge
between stdlib and structlog can be layered in later without touching
call sites.
"""
# Route stdlib logging through a root handler writing to stdout. Our
# own loggers go through structlog's pipeline; third-party libraries
# that use the stdlib logger will at least surface at INFO.
logging.basicConfig(
level=logging.INFO,
stream=sys.stdout,
format="%(message)s",
)
# Processors shared across environments. Order matters: contextvars
# first so bound context is present for every later step; exception
# info extraction before rendering so tracebacks serialize cleanly.
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
# Environment-specific final renderer. Dev gets human-friendly
# colorized output; everywhere else emits JSON lines.
if app_env == "development":
final_renderer: Any = structlog.dev.ConsoleRenderer(colors=True)
else:
final_renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=[*shared_processors, final_renderer],
# PrintLoggerFactory writes to stdout without requiring a stdlib
# logger bridge; sufficient for Phase 0.
logger_factory=structlog.PrintLoggerFactory(),
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
cache_logger_on_first_use=True,
)

66
app/main.py Normal file
View File

@@ -0,0 +1,66 @@
"""FastAPI application factory and module-level app instance.
The factory pattern (``create_app``) keeps test setup straightforward and
lets us swap in alternate configurations without module-level side
effects. ``app = create_app()`` at import time is what Uvicorn references
via ``app.main:app``.
"""
from __future__ import annotations
import structlog
from fastapi import FastAPI
from app import __version__
from app.config import get_settings
from app.logging_config import configure_logging
from app.routes.health import router as health_router
def create_app() -> FastAPI:
"""Build and return the FastAPI application.
Responsibilities:
- Load validated configuration via :func:`get_settings`.
- Initialize structured logging *before* any logger is used.
- Instantiate FastAPI with canonical title + version.
- Register routers (Phase 0: only ``/healthz``).
- Emit a single ``app_started`` structured log event.
"""
# Parse + validate configuration first so a bad environment fails fast
# with a clear pydantic error before we touch logging / FastAPI.
settings = get_settings()
# Configure structlog *before* acquiring any logger in the app so the
# very first log line already flows through our processor chain.
configure_logging(settings.app_env)
application = FastAPI(
title="Chicken Babies R Us",
version=__version__,
# Docs stay on for Phase 0; a later phase can gate these behind
# admin auth or disable them entirely in production.
docs_url="/docs",
redoc_url="/redoc",
)
# Register routers. Kept explicit (no dynamic discovery) so the set of
# mounted endpoints is trivially auditable.
application.include_router(health_router)
# Single structured startup event. Do NOT include secret material.
logger = structlog.get_logger(__name__)
logger.info(
"app_started",
app_env=settings.app_env,
version=__version__,
commit_sha=settings.git_commit_sha,
)
return application
# Module-level ASGI handle. Uvicorn / gunicorn import this as
# ``app.main:app``. Building it at import time is intentional: it fails
# loudly at container start if configuration is invalid.
app: FastAPI = create_app()

7
app/models/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Domain models and persistence mappers.
Populated in Phase 2 with dataclasses + SQL schema per
``docs/ROADMAP.md``. Intentionally empty in Phase 0.
"""
from __future__ import annotations

8
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""HTTP route packages.
Routers live as sibling modules and are wired into the app in
:mod:`app.main`. Phase 0 only exposes ``health``; public, admin, and auth
routers arrive in later phases.
"""
from __future__ import annotations

69
app/routes/health.py Normal file
View File

@@ -0,0 +1,69 @@
"""Liveness / version endpoint.
The ``/healthz`` endpoint is intentionally minimal: it must be safe to
expose unauthenticated (Caddy + uptime checks will hit it), so it leaks
only the app version and the build's git commit SHA — no hostnames,
paths, config values, or environment details.
"""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app import __version__
from app.config import Settings, get_settings
class HealthResponse(BaseModel):
"""Response schema for ``GET /healthz``.
Kept as an explicit :class:`pydantic.BaseModel` so FastAPI publishes
the shape in the OpenAPI schema and so any drift is caught at typing
time rather than via a loose ``dict``.
"""
status: Literal["ok"] = Field(
default="ok",
description="Liveness indicator; this endpoint only returns 'ok'.",
)
version: str = Field(description="Application semantic version.")
commit_sha: str = Field(
description="Git commit SHA of the running build ('unknown' in dev).",
)
# Module-level router; mounted by `app.main.create_app`. No prefix and no
# auth dependencies — /healthz is intentionally public.
router: APIRouter = APIRouter(tags=["health"])
@router.get(
"/healthz",
response_model=HealthResponse,
summary="Liveness + version probe",
)
def healthz(settings: Settings = Depends(get_settings)) -> HealthResponse:
"""Return a minimal liveness envelope.
Parameters
----------
settings:
Injected via FastAPI's dependency system so tests can override
configuration cleanly.
Returns
-------
HealthResponse
``status`` is always ``"ok"``; ``version`` and ``commit_sha``
identify the running build.
"""
# Intentionally does not touch the DB, filesystem, or any external
# service. This is liveness, not readiness — a readiness probe with
# deeper checks can be added in a later phase behind a different path.
return HealthResponse(
version=__version__,
commit_sha=settings.git_commit_sha,
)

7
app/services/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Application services (auth, email, cache, markdown, media, hcaptcha).
Populated phase-by-phase per ``docs/ROADMAP.md``. Intentionally empty in
Phase 0.
"""
from __future__ import annotations

0
app/static/.gitkeep Normal file
View File

0
app/templates/.gitkeep Normal file
View File

0
data/.gitkeep Normal file
View File

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
# ---------------------------------------------------------------------------
# Chicken Babies R Us — local / on-host compose file.
#
# Modern Docker Compose does not require a top-level `version:` key. The
# `web` service builds the multi-stage Dockerfile, loads secrets from
# `.env`, and mounts the runtime `data/` directory so the SQLite DB and
# media uploads survive container restarts.
# ---------------------------------------------------------------------------
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
# Pass the host's current commit SHA through at build time so
# /healthz can report which build is live. Falls back to
# "unknown" when the env var is unset, matching the Dockerfile
# default.
GIT_COMMIT_SHA: ${GIT_COMMIT_SHA:-unknown}
env_file:
- .env
ports:
# Uvicorn listens on 8000 inside the container. In the production
# topology Caddy fronts this; for local runs it's directly on the
# host loopback via the mapped port.
- "8000:8000"
volumes:
# SQLite DB + media uploads live under data/. Mounting it keeps
# state on the host so container rebuilds don't wipe content.
- ./data:/app/data
restart: unless-stopped

View File

@@ -4,14 +4,38 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
--- ---
## Phase 0 — Foundation ## Phase 0 — Foundation
- Scaffold `app/` package, `requirements.txt`, `.env.example`, `Dockerfile`, `docker-compose.yml`. **Completed:** 2026-04-21
- Pinned deps (upper bounds):
`fastapi`, `uvicorn[standard]`, `jinja2`, `pydantic`, `pydantic-settings`, `sqlalchemy` (Core only), `markdown-it-py`, `bleach`, `Pillow`, `python-magic`, `resend`, `slowapi`, `structlog`, `itsdangerous`, `python-multipart`, `pytest`, `httpx`. **Summary:** Scaffolded the FastAPI skeleton — package layout, pinned deps, multi-stage Dockerfile, compose file, typed config loader, structlog init, and a `/healthz` liveness endpoint surfacing app version + git commit SHA.
- `structlog` init at app startup.
- Health endpoint `/healthz` (returns app version + commit SHA from env). **Key files:**
- Typed config loader reading env via `pydantic-settings`. - `app/__init__.py` — package `__version__ = "0.1.0"`
- `app/main.py``create_app()` factory + module-level `app`; configures logging then mounts routers
- `app/config.py``Settings(BaseSettings)` with the full env contract (Optional where unused in Phase 0) + model validator refusing the dev-sentinel `SECRET_KEY` in production; `get_settings()` is `lru_cache`-d
- `app/logging_config.py``configure_logging(app_env)`; `ConsoleRenderer` in dev, `JSONRenderer` otherwise
- `app/routes/health.py``APIRouter` mounting `GET /healthz`, typed `HealthResponse` pydantic model
- `app/models/`, `app/services/`, `app/templates/`, `app/static/` — placeholder dirs with `.gitkeep`
- `tests/test_healthz.py` — FastAPI `TestClient` smoke test
- `requirements.txt` — 17 pinned packages, `>=X,<next-major` ranges
- `.env.example` — public env contract; `.env` stays gitignored
- `Dockerfile` — multi-stage (`builder` + `runtime`), `python:3.12-slim-bookworm`, `libmagic1` runtime; root user and no HEALTHCHECK (Phase 6 hardening)
- `docker-compose.yml``web` service with `env_file: .env`, `./data:/app/data` bind mount, `GIT_COMMIT_SHA` build arg
- `.gitignore` — adjusted `data/` rule to `data/*` so `!data/.gitkeep` works
**Endpoints created:**
- `GET /healthz` — public, unauthenticated. Returns minimal flat JSON `{"status":"ok","version":"0.1.0","commit_sha":"<sha-or-unknown>"}`.
**Key details:**
- **Healthz shape:** minimal flat JSON (not the full code_guidelines envelope). Future phases that add JSON APIs will use the envelope; the healthcheck stays small on purpose.
- **Version source:** `app.__version__` string constant. **Commit SHA source:** `GIT_COMMIT_SHA` env var, baked into the image via `ARG GIT_COMMIT_SHA=unknown` + `ENV GIT_COMMIT_SHA`. Surfaces as `"unknown"` when unset (dev).
- **Config loader:** single `Settings` class covers every ROADMAP env-var row. Fields not yet used at Phase 0 (`RESEND_*`, `HCAPTCHA_*`, `ADMIN_CONTACT_EMAIL`) are `Optional[str] = None`. `SECRET_KEY` defaults to the sentinel `"dev-insecure-change-me"`; a `@model_validator(mode="after")` refuses to boot if that sentinel survives into `APP_ENV=production`.
- **Admin emails:** stored as raw comma-separated string; `admin_emails_list` property returns stripped+lowercased list for allowlist comparisons (used by Phase 3 auth).
- **Logging:** `configure_logging` runs inside `create_app()` before any `structlog.get_logger` call; `app_started` structured event fires once at startup with `app_env`, `version`, `commit_sha` (no secrets).
- **Docker CMD:** uvicorn runs with `--proxy-headers --forwarded-allow-ips=127.0.0.1` (Phase 6 will swap the IP for Caddy's LAN address).
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 1 passed ✓ · `curl /healthz` returned both the default `"unknown"` payload and the real commit SHA when `GIT_COMMIT_SHA=$(git rev-parse HEAD)` was set ✓ · `docker compose config` exit 0 ✓.
- **Branch:** built on `chore/phase-0-foundation` off `dev`; merged `--no-ff` into `dev` on completion. Not pushed.
## Phase 1 — Public Site Skeleton ## Phase 1 — Public Site Skeleton

17
requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi>=0.115,<0.120
uvicorn[standard]>=0.32,<0.35
jinja2>=3.1,<4.0
pydantic>=2.9,<3.0
pydantic-settings>=2.6,<3.0
sqlalchemy>=2.0,<2.1
markdown-it-py>=3.0,<4.0
bleach>=6.2,<7.0
Pillow>=11.0,<12.0
python-magic>=0.4.27,<0.5
resend>=2.4,<3.0
slowapi>=0.1.9,<0.2
structlog>=24.4,<26.0
itsdangerous>=2.2,<3.0
python-multipart>=0.0.12,<0.1
pytest>=8.3,<9.0
httpx>=0.28,<0.29

3
tests/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Pytest suite for the Chicken Babies R Us app."""
from __future__ import annotations

35
tests/test_healthz.py Normal file
View File

@@ -0,0 +1,35 @@
"""Tests for the ``/healthz`` endpoint.
Kept deliberately small in Phase 0: we verify contract and shape, not
content beyond what the response model guarantees.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import __version__
from app.main import app
def test_healthz_returns_ok_and_version() -> None:
"""``GET /healthz`` returns 200 with status=ok and the app version.
Also asserts ``commit_sha`` is present as a non-empty string — it
defaults to ``"unknown"`` in dev, which still satisfies the contract
surfaced to uptime probes.
"""
# TestClient uses the module-level app, which has already been wired
# by create_app() at import time — exactly the path uvicorn takes in
# production, so the test exercises real startup behavior.
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200, response.text
payload = response.json()
assert payload["status"] == "ok"
assert payload["version"] == __version__
assert isinstance(payload["commit_sha"], str)
assert payload["commit_sha"], "commit_sha must be a non-empty string"