diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..939f71a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +__pycache__ +.mypy_cache +.pytest_cache +.ruff_cache +.env +htmlcov +tests +docs + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3570ae1 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +APP_HOST=0.0.0.0 +APP_PORT=8006 +DATABASE_URL=postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator +DB_POOL_MIN_SIZE=1 +DB_POOL_MAX_SIZE=10 +DB_CONNECT_TIMEOUT_SECONDS=10 +DB_COMMAND_TIMEOUT_SECONDS=30 +LOG_LEVEL=INFO +REQUEST_ID_HEADER=X-Request-ID +PVE_API_VERSION=9.2.3 +SIMULATION_SEED=42 +SIMULATION_TIME_SCALE=10 +SIMULATOR_ADMIN_ENABLED=false +SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81b4503 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +.coverage +coverage.xml +htmlcov/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +dist/ +build/ +*.egg-info/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..443318a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 +FROM python:3.13-slim-bookworm AS builder + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + VIRTUAL_ENV=/opt/venv +RUN python -m venv "$VIRTUAL_ENV" +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +WORKDIR /build +COPY pyproject.toml README.md ./ +COPY app ./app +RUN pip install --upgrade "pip>=25.1,<26" && pip install . + +FROM python:3.13-slim-bookworm AS runtime + +ARG APP_VERSION=0.0.1 +LABEL org.opencontainers.image.title="proxmox-api-simulator" \ + org.opencontainers.image.version="$APP_VERSION" \ + org.opencontainers.image.source="https://github.com/example/proxmox-api-simulator" +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + APP_HOST=0.0.0.0 \ + APP_PORT=8006 +RUN groupadd --system --gid 10001 simulator \ + && useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator +COPY --from=builder /opt/venv /opt/venv +WORKDIR /app +USER 10001:10001 +EXPOSE 8006 +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/live', timeout=2)"] +ENTRYPOINT ["uvicorn", "app.main:app"] +CMD ["--host", "0.0.0.0", "--port", "8006"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63be66a --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2026 proxmox-api-simulator contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fc1dded --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +PYTHON ?= python3.13 +VENV ?= .venv +BIN := $(VENV)/bin +COMPOSE ?= docker compose + +.PHONY: help install format lint typecheck test test-unit test-integration test-contract coverage run dev docker-build docker-up docker-down docker-logs db-up db-down db-migrate db-reset api-import api-diff seed clean ci + +help: ## Show available commands + @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +install: ## Create the Python 3.13 environment and install development dependencies + $(PYTHON) -m venv $(VENV) + $(BIN)/python -m pip install --upgrade "pip>=25.1,<26" + $(BIN)/python -m pip install -e '.[dev]' + +format: ## Format Python sources + $(BIN)/ruff format . + +lint: ## Run Ruff lint checks + $(BIN)/ruff check . + +typecheck: ## Run strict mypy checks + $(BIN)/mypy + +test: ## Run all offline tests + $(BIN)/pytest + +test-unit: ## Run unit tests + $(BIN)/pytest tests/unit + +test-integration: ## Run tests that require PostgreSQL + $(BIN)/pytest -m integration + +test-contract: ## Run offline API contract tests + $(BIN)/pytest -m contract + +coverage: ## Run tests with coverage enforcement + $(BIN)/pytest --cov=app --cov-report=term-missing --cov-report=xml + +run: ## Run the application + $(BIN)/uvicorn app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8006}" + +dev: ## Run with auto-reload + $(BIN)/uvicorn app.main:app --reload --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8006}" + +docker-build: ## Build the runtime image + $(COMPOSE) build simulator + +docker-up: ## Start PostgreSQL and simulator + @test -f .env || cp .env.example .env + $(COMPOSE) up -d --build + +docker-down: ## Stop local services + $(COMPOSE) down + +docker-logs: ## Follow simulator logs + $(COMPOSE) logs -f simulator + +db-up: ## Start PostgreSQL only + $(COMPOSE) up -d postgres + +db-down: ## Stop PostgreSQL + $(COMPOSE) stop postgres + +db-migrate: ## Apply database migrations + @echo "Database migrations are scheduled for milestone D1" >&2; exit 2 + +db-reset: ## Recreate the local database volume + $(COMPOSE) down -v + $(COMPOSE) up -d postgres + +api-import: ## Import an API snapshot + @echo "API import is scheduled for milestone B4" >&2; exit 2 + +api-diff: ## Compare API snapshots + @echo "API diff is scheduled for milestone B5" >&2; exit 2 + +seed: ## Seed simulation data + @echo "Database seed is scheduled for milestone D2" >&2; exit 2 + +clean: ## Remove generated local artifacts + rm -rf $(VENV) .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache + +ci: format lint typecheck coverage ## Run the complete local quality gate + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4524a33 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# proxmox-api-simulator + +Stateful asynchronous Proxmox VE API simulator for testing API clients and +infrastructure tooling without a real hypervisor cluster. + +The project is in its foundation stage. Only liveness and PostgreSQL-backed +readiness endpoints exist. No Proxmox endpoint is claimed as compatible yet; the +official contract importer and stateful vertical slice are tracked in +[the implementation plan](docs/implementation-plan.md). + +## Development + +Python 3.13 is required. + +```bash +make install +make ci +``` + +Local services use plain HTTP at this stage: + +```bash +cp .env.example .env +make docker-up +curl http://localhost:8006/health/live +curl http://localhost:8006/health/ready +``` + +See [the architecture](docs/architecture.md) for component boundaries and +durability decisions. Commands for not-yet-implemented milestones intentionally +return a non-zero status instead of pretending to succeed. + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..76fb20a --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Proxmox API simulator application package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..95abe0d --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""HTTP adapters.""" diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 0000000..e7502b9 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,25 @@ +"""Base external error representation.""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import Request +from fastapi.responses import JSONResponse + +logger = logging.getLogger(__name__) + + +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Log internal failures and return a stable non-FastAPI error envelope.""" + + logger.exception( + "unhandled request error", + extra={"request_id": getattr(request.state, "request_id", None), "path": request.url.path}, + ) + body: dict[str, Any] = { + "data": None, + "errors": {"internal": "internal server error"}, + } + return JSONResponse(status_code=500, content=body) diff --git a/app/api/middleware.py b/app/api/middleware.py new file mode 100644 index 0000000..d0e0c22 --- /dev/null +++ b/app/api/middleware.py @@ -0,0 +1,42 @@ +"""Request correlation and access logging middleware.""" + +from __future__ import annotations + +import logging +import time +import uuid +from collections.abc import Awaitable, Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +logger = logging.getLogger(__name__) + +RequestHandler = Callable[[Request], Awaitable[Response]] + + +class RequestContextMiddleware(BaseHTTPMiddleware): + """Attach a bounded request ID and log one structured completion event.""" + + def __init__(self, app: object, header_name: str) -> None: + super().__init__(app) # type: ignore[arg-type] + self._header_name = header_name + + async def dispatch(self, request: Request, call_next: RequestHandler) -> Response: + supplied = request.headers.get(self._header_name, "") + request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4()) + request.state.request_id = request_id + started = time.monotonic() + response = await call_next(request) + response.headers[self._header_name] = request_id + logger.info( + "request completed", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "duration_ms": round((time.monotonic() - started) * 1000, 3), + }, + ) + return response diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6bce24f --- /dev/null +++ b/app/config.py @@ -0,0 +1,39 @@ +"""Typed application configuration.""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Runtime settings loaded from environment variables and an optional `.env`.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + frozen=True, + ) + + app_name: str = "proxmox-api-simulator" + app_host: str = "0.0.0.0" # noqa: S104 - the container must accept external traffic + app_port: int = Field(default=8006, ge=1, le=65535) + database_url: SecretStr = SecretStr( + "postgresql://proxmox:proxmox@localhost:5432/proxmox_simulator" + ) + db_pool_min_size: int = Field(default=1, ge=1, le=100) + db_pool_max_size: int = Field(default=10, ge=1, le=100) + db_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=60) + db_command_timeout_seconds: float = Field(default=30.0, gt=0, le=300) + log_level: str = "INFO" + request_id_header: str = "X-Request-ID" + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the immutable process configuration.""" + + return Settings() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..89fa222 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +"""PostgreSQL infrastructure.""" diff --git a/app/db/pool.py b/app/db/pool.py new file mode 100644 index 0000000..cef40b7 --- /dev/null +++ b/app/db/pool.py @@ -0,0 +1,79 @@ +"""Small typed asyncpg pool boundary.""" + +from __future__ import annotations + +from typing import Protocol, Self, cast + +import asyncpg # type: ignore[import-untyped] +from asyncpg import Pool + +from app.config import Settings + + +class Database(Protocol): + """Application-facing database lifecycle and health interface.""" + + async def connect(self) -> None: ... + + async def close(self) -> None: ... + + async def is_ready(self) -> bool: ... + + +class AsyncpgDatabase: + """Own an asyncpg pool without exposing it as global mutable state.""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._pool: Pool | None = None + + @property + def pool(self) -> Pool: + """Return the initialized pool to repository factories.""" + + if self._pool is None: + message = "database pool is not initialized" + raise RuntimeError(message) + return self._pool + + async def connect(self) -> None: + """Create the pool and verify the first connection.""" + + if self._pool is not None: + return + settings = self._settings + pool = await asyncpg.create_pool( + dsn=settings.database_url.get_secret_value(), + min_size=settings.db_pool_min_size, + max_size=settings.db_pool_max_size, + timeout=settings.db_connect_timeout_seconds, + command_timeout=settings.db_command_timeout_seconds, + ) + if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons + message = "asyncpg did not create a pool" + raise RuntimeError(message) + self._pool = cast(Pool, pool) + + async def close(self) -> None: + """Close all pooled connections; repeated close is safe.""" + + pool, self._pool = self._pool, None + if pool is not None: + await pool.close() + + async def is_ready(self) -> bool: + """Check that PostgreSQL accepts a trivial query.""" + + if self._pool is None: + return False + try: + return bool(await self._pool.fetchval("SELECT 1") == 1) + except asyncpg.PostgresError: + return False + + async def __aenter__(self) -> Self: + await self.connect() + return self + + async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: + await self.close() diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..8533286 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,14 @@ +"""FastAPI dependency adapters.""" + +from __future__ import annotations + +from fastapi import Request + +from app.db.pool import Database + + +def get_database(request: Request) -> Database: + """Resolve the lifespan-owned database from application state.""" + + database: Database = request.app.state.database + return database diff --git a/app/lifespan.py b/app/lifespan.py new file mode 100644 index 0000000..8822544 --- /dev/null +++ b/app/lifespan.py @@ -0,0 +1,36 @@ +"""Application resource ownership.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager + +from fastapi import FastAPI + +from app.config import Settings +from app.db.pool import AsyncpgDatabase, Database + +DatabaseFactory = Callable[[Settings], Database] +Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]] + + +def create_lifespan(settings: Settings, database_factory: DatabaseFactory) -> Lifespan: + """Build a lifespan context so tests can inject a database implementation.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + database = database_factory(settings) + await database.connect() + app.state.database = database + try: + yield + finally: + await database.close() + + return lifespan + + +def default_database_factory(settings: Settings) -> Database: + """Create the production asyncpg adapter.""" + + return AsyncpgDatabase(settings) diff --git a/app/logging.py b/app/logging.py new file mode 100644 index 0000000..eb87a0a --- /dev/null +++ b/app/logging.py @@ -0,0 +1,40 @@ +"""Structured logging configuration with safe JSON output.""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from typing import Any + + +class JsonFormatter(logging.Formatter): + """Serialize standard records and selected structured attributes as JSON.""" + + _fields = ("request_id", "method", "path", "status", "duration_ms") + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": datetime.now(UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + for field in self._fields: + value = getattr(record, field, None) + if value is not None: + payload[field] = value + if record.exc_info is not None: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def configure_logging(level: str) -> None: + """Configure the root logger once for the process.""" + + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(level.upper()) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e2611b3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,34 @@ +"""FastAPI application factory and ASGI entry point.""" + +from __future__ import annotations + +from fastapi import FastAPI + +from app.api.errors import unhandled_exception_handler +from app.api.middleware import RequestContextMiddleware +from app.config import Settings, get_settings +from app.lifespan import DatabaseFactory, create_lifespan, default_database_factory +from app.logging import configure_logging +from app.observability.health import router as health_router + + +def create_app( + settings: Settings | None = None, + database_factory: DatabaseFactory = default_database_factory, +) -> FastAPI: + """Create an isolated application instance with explicit resource factories.""" + + resolved = settings or get_settings() + configure_logging(resolved.log_level) + app = FastAPI( + title=resolved.app_name, + version="0.0.1", + lifespan=create_lifespan(resolved, database_factory), + ) + app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header) + app.add_exception_handler(Exception, unhandled_exception_handler) + app.include_router(health_router) + return app + + +app = create_app() diff --git a/app/observability/__init__.py b/app/observability/__init__.py new file mode 100644 index 0000000..6715098 --- /dev/null +++ b/app/observability/__init__.py @@ -0,0 +1 @@ +"""Health, metrics, and tracing adapters.""" diff --git a/app/observability/health.py b/app/observability/health.py new file mode 100644 index 0000000..cecc7a7 --- /dev/null +++ b/app/observability/health.py @@ -0,0 +1,37 @@ +"""Kubernetes-compatible health endpoints.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Response, status +from pydantic import BaseModel + +from app.db.pool import Database +from app.dependencies import get_database + +router = APIRouter(prefix="/health", tags=["health"]) + + +class HealthResponse(BaseModel): + status: str + + +@router.get("/live", response_model=HealthResponse) +async def live() -> HealthResponse: + """Report process liveness without checking dependencies.""" + + return HealthResponse(status="ok") + + +@router.get("/ready", response_model=HealthResponse) +async def ready( + response: Response, + database: Annotated[Database, Depends(get_database)], +) -> HealthResponse: + """Report whether the required database dependency is usable.""" + + if await database.is_ready(): + return HealthResponse(status="ok") + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return HealthResponse(status="unavailable") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b87c23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + postgres: + image: postgres:17.5-bookworm + environment: + POSTGRES_DB: proxmox_simulator + POSTGRES_USER: proxmox + POSTGRES_PASSWORD: proxmox + healthcheck: + test: ["CMD-SHELL", "pg_isready -U proxmox -d proxmox_simulator"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + + simulator: + build: + context: . + target: runtime + env_file: + - path: .env + required: false + environment: + DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)"] + interval: 10s + timeout: 3s + retries: 5 + ports: + - "8006:8006" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + postgres-data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5fffb3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "proxmox-api-simulator" +version = "0.0.1" +description = "Stateful asynchronous Proxmox VE API simulator" +readme = "README.md" +requires-python = ">=3.13,<3.14" +license = { text = "Apache-2.0" } +authors = [{ name = "proxmox-api-simulator contributors" }] +dependencies = [ + "asyncpg>=0.30,<0.31", + "fastapi>=0.116,<0.117", + "pydantic>=2.11,<3", + "pydantic-settings>=2.10,<3", + "uvicorn[standard]>=0.35,<0.36", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.28,<0.29", + "mypy>=1.17,<1.18", + "pytest>=8.4,<9", + "pytest-asyncio>=1.1,<2", + "pytest-cov>=6.2,<7", + "ruff>=0.12,<0.13", +] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +target-version = "py313" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101"] + +[tool.mypy] +python_version = "3.13" +strict = true +plugins = ["pydantic.mypy"] +files = ["app", "tests"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "integration: requires PostgreSQL or another external service", + "contract: validates imported API contracts", +] + +[tool.coverage.run] +branch = true +source = ["app"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..38bb211 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package.""" diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py new file mode 100644 index 0000000..fb7db28 --- /dev/null +++ b/tests/unit/test_health.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Self + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db.pool import Database +from app.main import create_app + + +class FakeDatabase: + def __init__(self, ready: bool) -> None: + self.ready = ready + self.connected = False + self.closed = False + + async def connect(self) -> None: + self.connected = True + + async def close(self) -> None: + self.closed = True + + async def is_ready(self) -> bool: + return self.ready + + async def __aenter__(self) -> Self: + await self.connect() + return self + + async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: + await self.close() + + +@pytest.mark.parametrize(("database_ready", "status_code"), [(True, 200), (False, 503)]) +async def test_health_endpoints(database_ready: bool, status_code: int) -> None: + database = FakeDatabase(database_ready) + + def factory(settings: Settings) -> Database: + del settings + return database + + application = create_app(Settings(), factory) + async with application.router.lifespan_context(application): + async with AsyncClient( + transport=ASGITransport(app=application, raise_app_exceptions=False), + base_url="http://test", + ) as client: + live = await client.get("/health/live") + ready = await client.get("/health/ready", headers={"X-Request-ID": "test-request"}) + + assert live.status_code == 200 + assert live.json() == {"status": "ok"} + assert ready.status_code == status_code + assert ready.headers["X-Request-ID"] == "test-request" + assert database.connected + assert database.closed diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..c5f9da6 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import json +import logging + +from app.logging import JsonFormatter + + +def test_json_formatter_emits_structured_fields() -> None: + record = logging.LogRecord("test", logging.INFO, __file__, 1, "hello %s", ("world",), None) + record.request_id = "request-1" + + payload = json.loads(JsonFormatter().format(record)) + + assert payload["message"] == "hello world" + assert payload["request_id"] == "request-1" + assert payload["level"] == "INFO"