From e11014ea26d098feebeaf8dd15fbd8387e1d049b Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sun, 12 Jul 2026 23:06:25 +0300 Subject: [PATCH] feat: add runnable simulator foundation --- .dockerignore | 11 +++++ .env.example | 15 +++++++ .gitignore | 14 ++++++ Dockerfile | 34 ++++++++++++++ LICENSE | 13 ++++++ Makefile | 85 +++++++++++++++++++++++++++++++++++ README.md | 32 +++++++++++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/errors.py | 25 +++++++++++ app/api/middleware.py | 42 +++++++++++++++++ app/config.py | 39 ++++++++++++++++ app/db/__init__.py | 1 + app/db/pool.py | 79 ++++++++++++++++++++++++++++++++ app/dependencies.py | 14 ++++++ app/lifespan.py | 36 +++++++++++++++ app/logging.py | 40 +++++++++++++++++ app/main.py | 34 ++++++++++++++ app/observability/__init__.py | 1 + app/observability/health.py | 37 +++++++++++++++ docker-compose.yml | 44 ++++++++++++++++++ pyproject.toml | 65 +++++++++++++++++++++++++++ tests/__init__.py | 1 + tests/unit/test_health.py | 58 ++++++++++++++++++++++++ tests/unit/test_logging.py | 17 +++++++ 25 files changed, 739 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/errors.py create mode 100644 app/api/middleware.py create mode 100644 app/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/pool.py create mode 100644 app/dependencies.py create mode 100644 app/lifespan.py create mode 100644 app/logging.py create mode 100644 app/main.py create mode 100644 app/observability/__init__.py create mode 100644 app/observability/health.py create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/unit/test_health.py create mode 100644 tests/unit/test_logging.py 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"