From dd87daf68e750ae02fb96cc3038a94235bc21a1e Mon Sep 17 00:00:00 2001 From: manavgup Date: Mon, 30 Mar 2026 14:08:45 -0400 Subject: [PATCH] =?UTF-8?q?Phase=201:=20Runtime=20core=20=E2=80=94=20entit?= =?UTF-8?q?ies,=20repository,=20service,=20exceptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime core for FastStack v1: - base/entity.py: Entity hierarchy (Entity, AuditedEntity, SoftDeleteEntity, FullAuditedEntity) with UUID PKs, audit fields, soft-delete support - base/repository.py: Repository Protocol (async, runtime_checkable) + SqlAlchemyRepository implementation with soft-delete awareness - base/service.py: CrudService with 6 async lifecycle hooks (before/after create, update, delete) - base/permissions.py: @require_permission, @require_role decorators - exceptions/domain.py: DomainError hierarchy (8 exception classes) - exceptions/handlers.py: RFC 7807 global exception handlers - database/session.py: Async DatabaseConfig, engine, session factory, get_db 69 tests covering: - Entity base class hierarchy and MRO - Repository Protocol conformance (structural typing) - SqlAlchemyRepository CRUD, soft-delete, hard-delete - CrudService lifecycle hooks (ordering, transformation, guarding) - RFC 7807 error responses for all 8 exception types Part of Phase 1 in #1 Co-Authored-By: Claude Opus 4.6 (1M context) --- faststack_core/base/entity.py | 90 ++++++++++ faststack_core/base/permissions.py | 65 +++++++ faststack_core/base/repository.py | 131 ++++++++++++++ faststack_core/base/service.py | 95 +++++++++++ faststack_core/database/session.py | 84 +++++++++ faststack_core/exceptions/domain.py | 51 ++++++ faststack_core/exceptions/handlers.py | 30 ++++ pyproject.toml | 6 +- tests/test_core/test_base_entity.py | 224 ++++++++++++++++++++++++ tests/test_core/test_crud_service.py | 236 ++++++++++++++++++++++++++ tests/test_core/test_exceptions.py | 221 ++++++++++++++++++++++++ tests/test_core/test_repository.py | 215 +++++++++++++++++++++++ 12 files changed, 1447 insertions(+), 1 deletion(-) create mode 100644 faststack_core/base/entity.py create mode 100644 faststack_core/base/permissions.py create mode 100644 faststack_core/base/repository.py create mode 100644 faststack_core/base/service.py create mode 100644 faststack_core/database/session.py create mode 100644 faststack_core/exceptions/domain.py create mode 100644 faststack_core/exceptions/handlers.py create mode 100644 tests/test_core/test_base_entity.py create mode 100644 tests/test_core/test_crud_service.py create mode 100644 tests/test_core/test_exceptions.py create mode 100644 tests/test_core/test_repository.py diff --git a/faststack_core/base/entity.py b/faststack_core/base/entity.py new file mode 100644 index 0000000..552ec45 --- /dev/null +++ b/faststack_core/base/entity.py @@ -0,0 +1,90 @@ +"""Base entity classes for SQLAlchemy 2.0 declarative models. + +Provides an abstract entity hierarchy with UUID primary keys, audit fields, +and soft-delete support. All classes are abstract — concrete models inherit +from the appropriate level. + +Hierarchy: + Base (DeclarativeBase) + └── Entity — UUID primary key + ├── AuditedEntity — created_at/by, updated_at/by + ├── SoftDeleteEntity — is_deleted, deleted_at/by + └── FullAuditedEntity(AuditedEntity, SoftDeleteEntity) — all fields +""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """SQLAlchemy 2.0 declarative base for all FastStack models.""" + + pass + + +class Entity(Base): + """Abstract entity with a UUID primary key. + + Every persistent domain object inherits from this class (directly or + through one of the audited variants). + """ + + __abstract__ = True + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + + +class AuditedEntity(Entity): + """Abstract entity that tracks creation and last-update metadata. + + Fields: + created_at: UTC timestamp set automatically on insert. + updated_at: UTC timestamp set automatically on insert and update. + created_by: Optional identifier of the user who created the record. + updated_by: Optional identifier of the user who last updated the record. + """ + + __abstract__ = True + + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) + ) + created_by: Mapped[str | None] = mapped_column(String(255), default=None) + updated_by: Mapped[str | None] = mapped_column(String(255), default=None) + + +class SoftDeleteEntity(Entity): + """Abstract entity that supports soft deletion. + + Instead of removing rows, soft-deleted records are flagged so they can + be excluded from normal queries while remaining recoverable. + + Fields: + is_deleted: Whether the record has been soft-deleted. + deleted_at: UTC timestamp of when the record was soft-deleted. + deleted_by: Optional identifier of the user who deleted the record. + """ + + __abstract__ = True + + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + deleted_by: Mapped[str | None] = mapped_column(String(255), default=None) + + +class FullAuditedEntity(AuditedEntity, SoftDeleteEntity): + """Abstract entity combining audit tracking and soft-delete support. + + Diamond inheritance is resolved cleanly because every intermediate class + sets ``__abstract__ = True``, so SQLAlchemy never tries to map more than + one table for the shared ``Entity`` ancestor. + + Includes all fields from both :class:`AuditedEntity` and + :class:`SoftDeleteEntity`. + """ + + __abstract__ = True diff --git a/faststack_core/base/permissions.py b/faststack_core/base/permissions.py new file mode 100644 index 0000000..04d9cce --- /dev/null +++ b/faststack_core/base/permissions.py @@ -0,0 +1,65 @@ +"""Permission dependency factories for FastAPI routes. + +These are v1 stubs that inspect ``request.state.user`` (populated by the +application's own auth middleware) and raise +:class:`~faststack_core.exceptions.domain.InsufficientPermissionsError` +when the required permission or role is missing. +""" + +from collections.abc import Callable + +from fastapi import Request + +from faststack_core.exceptions.domain import InsufficientPermissionsError + + +def require_permission(permission: str) -> Callable: + """Return a FastAPI dependency that checks *permission* on the current user. + + The dependency reads ``request.state.user`` and expects it to expose a + ``permissions`` attribute (any iterable supporting ``in``). + + Usage:: + + @router.get( + "/admin", + dependencies=[Depends(require_permission("admin:read"))], + ) + async def admin_endpoint(): ... + """ + + async def dependency(request: Request) -> None: + user = getattr(request.state, "user", None) + if user is None: + raise InsufficientPermissionsError("Authentication required") + user_permissions = getattr(user, "permissions", []) + if permission not in user_permissions: + raise InsufficientPermissionsError(f"Missing permission: {permission}") + + return dependency + + +def require_role(role: str) -> Callable: + """Return a FastAPI dependency that checks *role* on the current user. + + The dependency reads ``request.state.user`` and expects it to expose a + ``roles`` attribute (any iterable supporting ``in``). + + Usage:: + + @router.get( + "/admin", + dependencies=[Depends(require_role("admin"))], + ) + async def admin_endpoint(): ... + """ + + async def dependency(request: Request) -> None: + user = getattr(request.state, "user", None) + if user is None: + raise InsufficientPermissionsError("Authentication required") + user_roles = getattr(user, "roles", []) + if role not in user_roles: + raise InsufficientPermissionsError(f"Missing role: {role}") + + return dependency diff --git a/faststack_core/base/repository.py b/faststack_core/base/repository.py new file mode 100644 index 0000000..1e9ca52 --- /dev/null +++ b/faststack_core/base/repository.py @@ -0,0 +1,131 @@ +"""Repository Protocol and async SQLAlchemy implementation. + +Defines the contract (``Repository`` Protocol) that all repository +implementations — including in-memory fakes — must satisfy via structural +typing. Also provides ``SqlAlchemyRepository``, the production +implementation backed by an ``AsyncSession``. + +See ADR-002 for the design rationale: Protocol over ABC, fakes over mocks. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Generic, Protocol, TypeVar, runtime_checkable +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from faststack_core.base.entity import Entity, SoftDeleteEntity +from faststack_core.exceptions.domain import NotFoundError + +T = TypeVar("T", bound=Entity) + + +# --------------------------------------------------------------------------- +# Protocol — the contract +# --------------------------------------------------------------------------- + + +@runtime_checkable +class Repository(Protocol[T]): + """Base repository contract. All methods are async. + + Any class that implements these method signatures satisfies the + Protocol via structural typing — no inheritance required. + """ + + async def get_by_id(self, id: UUID) -> T | None: ... + + async def list(self, skip: int = 0, limit: int = 100) -> list[T]: ... + + async def create(self, data: dict) -> T: ... + + async def update(self, id: UUID, data: dict) -> T: ... + + async def delete(self, id: UUID) -> None: ... + + async def count(self) -> int: ... + + +@runtime_checkable +class SearchableRepository(Repository[T], Protocol): + """Extended contract with full-text search and sorting.""" + + async def search( + self, query: str, fields: list[str], skip: int = 0, limit: int = 100 + ) -> list[T]: ... + + +# --------------------------------------------------------------------------- +# SQLAlchemy implementation +# --------------------------------------------------------------------------- + + +class SqlAlchemyRepository(Generic[T]): + """Async SQLAlchemy implementation satisfying the ``Repository`` Protocol. + + Parameters + ---------- + db: + An active ``AsyncSession``. + model: + The SQLAlchemy model class (a concrete subclass of ``Entity``). + """ + + def __init__(self, db: AsyncSession, model: type[T]) -> None: + self.db = db + self.model = model + + async def get_by_id(self, id: UUID) -> T | None: + result = await self.db.execute( + select(self.model).where(self.model.id == id) # type: ignore[attr-defined] + ) + return result.scalar_one_or_none() + + async def list(self, skip: int = 0, limit: int = 100) -> list[T]: + stmt = select(self.model).offset(skip).limit(limit) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def create(self, data: dict) -> T: + entity = self.model(**data) + self.db.add(entity) + await self.db.flush() + await self.db.refresh(entity) + return entity + + async def update(self, id: UUID, data: dict) -> T: + entity = await self.get_by_id(id) + if not entity: + raise NotFoundError(f"{self.model.__name__} with id {id} not found") + for key, value in data.items(): + setattr(entity, key, value) + await self.db.flush() + await self.db.refresh(entity) + return entity + + async def delete(self, id: UUID) -> None: + entity = await self.get_by_id(id) + if not entity: + raise NotFoundError(f"{self.model.__name__} with id {id} not found") + if isinstance(entity, SoftDeleteEntity): + entity.is_deleted = True # type: ignore[attr-defined] + entity.deleted_at = datetime.now(UTC) # type: ignore[attr-defined] + await self.db.flush() + else: + await self.db.delete(entity) + await self.db.flush() + + async def hard_delete(self, id: UUID) -> None: + """Permanently remove the entity, ignoring soft-delete.""" + entity = await self.get_by_id(id) + if not entity: + raise NotFoundError(f"{self.model.__name__} with id {id} not found") + await self.db.delete(entity) + await self.db.flush() + + async def count(self) -> int: + result = await self.db.execute(select(func.count()).select_from(self.model)) + return result.scalar_one() diff --git a/faststack_core/base/service.py b/faststack_core/base/service.py new file mode 100644 index 0000000..2eb95b8 --- /dev/null +++ b/faststack_core/base/service.py @@ -0,0 +1,95 @@ +"""CRUD service with async lifecycle hooks. + +Provides ``CrudService`` — a generic service that wraps a ``Repository`` +and exposes create/get/list/update/delete operations with before/after +hooks at each step. Users override only the hooks they need in their +generated service files. + +See ADR-005 for the design rationale: hooks over method overrides. +""" + +from __future__ import annotations + +from typing import Any, Generic, TypeVar +from uuid import UUID + +from faststack_core.base.entity import Entity +from faststack_core.base.repository import Repository +from faststack_core.exceptions.domain import NotFoundError + +T = TypeVar("T", bound=Entity) + + +class CrudService(Generic[T]): + """Generic async CRUD service with lifecycle hooks. + + Parameters + ---------- + repository: + Any object satisfying the ``Repository[T]`` Protocol. + + Hooks + ----- + Override these ``async`` methods in your generated service to inject + custom logic. Each hook has a default no-op implementation. + + - ``before_create(data)`` → transform / validate before persisting + - ``after_create(entity)`` → side-effects after creation + - ``before_update(id, data)`` → transform / validate before update + - ``after_update(entity)`` → side-effects after update + - ``before_delete(id)`` → guard / validate before deletion + - ``after_delete(id)`` → cleanup after deletion + """ + + def __init__(self, repository: Repository[T]) -> None: + self.repository = repository + + # ------------------------------------------------------------------ + # Lifecycle hooks — override in subclasses + # ------------------------------------------------------------------ + + async def before_create(self, data: dict[str, Any]) -> dict[str, Any]: + return data + + async def after_create(self, entity: T) -> T: + return entity + + async def before_update(self, id: UUID, data: dict[str, Any]) -> dict[str, Any]: + return data + + async def after_update(self, entity: T) -> T: + return entity + + async def before_delete(self, id: UUID) -> None: + pass + + async def after_delete(self, id: UUID) -> None: + pass + + # ------------------------------------------------------------------ + # CRUD operations + # ------------------------------------------------------------------ + + async def create(self, data: dict[str, Any]) -> T: + data = await self.before_create(data) + entity = await self.repository.create(data) + return await self.after_create(entity) + + async def get(self, id: UUID) -> T: + entity = await self.repository.get_by_id(id) + if not entity: + raise NotFoundError(f"Entity with id {id} not found") + return entity + + async def list(self, skip: int = 0, limit: int = 100) -> list[T]: + return await self.repository.list(skip=skip, limit=limit) + + async def update(self, id: UUID, data: dict[str, Any]) -> T: + data = await self.before_update(id, data) + entity = await self.repository.update(id, data) + return await self.after_update(entity) + + async def delete(self, id: UUID) -> None: + await self.before_delete(id) + await self.repository.delete(id) + await self.after_delete(id) diff --git a/faststack_core/database/session.py b/faststack_core/database/session.py new file mode 100644 index 0000000..87d02c1 --- /dev/null +++ b/faststack_core/database/session.py @@ -0,0 +1,84 @@ +"""Async database session configuration for FastStack. + +Provides helpers to create an async SQLAlchemy engine and session factory, +plus a FastAPI-compatible dependency that yields a transactional session. +""" + +from collections.abc import AsyncGenerator +from dataclasses import dataclass + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + + +@dataclass +class DatabaseConfig: + """Connection parameters for an async SQLAlchemy engine. + + ``pool_size``, ``max_overflow``, and ``pool_timeout`` are silently + ignored for SQLite URLs because aiosqlite uses a + :class:`~sqlalchemy.pool.StaticPool` that does not support them. + """ + + url: str + echo: bool = False + pool_size: int = 5 + max_overflow: int = 10 + pool_timeout: int = 30 + + +def _is_sqlite(url: str) -> bool: + """Return True when *url* targets an SQLite backend.""" + return url.startswith("sqlite") + + +def create_engine(config: DatabaseConfig) -> AsyncEngine: + """Create an async SQLAlchemy engine from *config*. + + Pool-related parameters are only forwarded for connection-pooling + backends (e.g. PostgreSQL via asyncpg). SQLite/aiosqlite does not + support them, so they are omitted automatically. + """ + kwargs: dict = { + "echo": config.echo, + } + + if not _is_sqlite(config.url): + kwargs["pool_size"] = config.pool_size + kwargs["max_overflow"] = config.max_overflow + kwargs["pool_timeout"] = config.pool_timeout + + return create_async_engine(config.url, **kwargs) + + +def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + """Create an async session factory bound to *engine*.""" + return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db( + session_factory: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[AsyncSession, None]: + """FastAPI dependency that yields a transactional async database session. + + The session is committed on successful exit and rolled back if an + exception propagates. + + Usage in generated projects:: + + # In dependencies.py + async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + yield session + """ + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/faststack_core/exceptions/domain.py b/faststack_core/exceptions/domain.py new file mode 100644 index 0000000..6480d55 --- /dev/null +++ b/faststack_core/exceptions/domain.py @@ -0,0 +1,51 @@ +"""Domain exception hierarchy for FastStack. + +All domain-level errors inherit from DomainError and carry a human-readable +message plus an optional details dict. EXCEPTION_STATUS_MAP provides the +canonical mapping from exception type to HTTP status code. +""" + + +class DomainError(Exception): + """Base class for all domain-level errors.""" + + def __init__(self, message: str, details: dict | None = None): + self.message = message + self.details = details or {} + super().__init__(message) + + +class NotFoundError(DomainError): ... + + +class AlreadyExistsError(DomainError): ... + + +class ValidationError(DomainError): ... + + +class OperationNotAllowedError(DomainError): ... + + +class ResourceConflictError(DomainError): ... + + +class InsufficientPermissionsError(DomainError): ... + + +class ExternalServiceError(DomainError): ... + + +class ConfigurationError(DomainError): ... + + +EXCEPTION_STATUS_MAP: dict[type[DomainError], int] = { + NotFoundError: 404, + AlreadyExistsError: 409, + ValidationError: 422, + OperationNotAllowedError: 403, + ResourceConflictError: 409, + InsufficientPermissionsError: 403, + ExternalServiceError: 502, + ConfigurationError: 500, +} diff --git a/faststack_core/exceptions/handlers.py b/faststack_core/exceptions/handlers.py new file mode 100644 index 0000000..e986c09 --- /dev/null +++ b/faststack_core/exceptions/handlers.py @@ -0,0 +1,30 @@ +"""RFC 7807 Problem Details exception handlers for FastAPI. + +Call ``register_exception_handlers(app)`` during application startup to +install a single handler that converts any ``DomainError`` (or subclass) +into a JSON response that conforms to RFC 7807. +""" + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from .domain import EXCEPTION_STATUS_MAP, DomainError + + +def register_exception_handlers(app: FastAPI) -> None: + """Register domain-error handlers that produce RFC 7807 responses.""" + + @app.exception_handler(DomainError) + async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse: + status = EXCEPTION_STATUS_MAP.get(type(exc), 500) + return JSONResponse( + status_code=status, + content={ + "type": f"/errors/{type(exc).__name__}", + "title": type(exc).__name__, + "status": status, + "detail": exc.message, + "instance": str(request.url), + **({"details": exc.details} if exc.details else {}), + }, + ) diff --git a/pyproject.toml b/pyproject.toml index 6e487de..251c210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "faststack" version = "0.1.0" description = "Hybrid FastAPI framework — runtime core + CLI generator" -authors = ["Manav Gup "] +authors = ["Manav Gupta "] license = "MIT" readme = "README.md" packages = [ @@ -62,8 +62,12 @@ select = [ ignore = [ "E501", # line too long (handled by black) "B008", # do not perform function calls in argument defaults + "UP046", # Generic[T] subclass — we use this pattern for Protocol/SQLAlchemy compat ] +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["C901"] # allow complex test helper functions + [tool.black] line-length = 100 target-version = ["py312"] diff --git a/tests/test_core/test_base_entity.py b/tests/test_core/test_base_entity.py new file mode 100644 index 0000000..e30bf77 --- /dev/null +++ b/tests/test_core/test_base_entity.py @@ -0,0 +1,224 @@ +"""Tests for faststack_core.base.entity — the abstract entity hierarchy. + +Uses aiosqlite for async in-memory SQLAlchemy testing. A concrete model +(Item) inherits from FullAuditedEntity so every field in the hierarchy is +exercised end-to-end. +""" + +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy import String +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import Mapped, mapped_column + +from faststack_core.base.entity import ( + AuditedEntity, + Base, + Entity, + FullAuditedEntity, + SoftDeleteEntity, +) + +# --------------------------------------------------------------------------- +# Concrete test model +# --------------------------------------------------------------------------- + + +class Item(FullAuditedEntity): + """Concrete model used exclusively for testing.""" + + __tablename__ = "items" + + name: Mapped[str] = mapped_column(String(100)) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def async_engine(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +async def session(async_engine): + async_session = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + yield session + + +# --------------------------------------------------------------------------- +# Entity — UUID primary key +# --------------------------------------------------------------------------- + + +async def test_entity_has_uuid_pk(): + """Entity declares a UUID primary key column.""" + # Entity is abstract, so inspect via the concrete Item table + pk_cols = [c for c in Item.__table__.columns if c.primary_key] + assert len(pk_cols) == 1 + assert pk_cols[0].name == "id" + + +async def test_uuid_auto_generated_on_create(session: AsyncSession): + """UUID primary key is automatically populated when no id is provided.""" + item = Item(name="auto-uuid") + session.add(item) + await session.flush() + + assert item.id is not None + assert isinstance(item.id, uuid.UUID) + + +# --------------------------------------------------------------------------- +# AuditedEntity fields +# --------------------------------------------------------------------------- + + +async def test_audited_entity_fields_exist(): + """AuditedEntity contributes created_at, updated_at, created_by, updated_by.""" + col_names = {c.name for c in Item.__table__.columns} + for field in ("created_at", "updated_at", "created_by", "updated_by"): + assert field in col_names, f"Missing audited field: {field}" + + +async def test_created_at_auto_set(session: AsyncSession): + """created_at is automatically set on insert.""" + before = datetime.now(UTC) + item = Item(name="audit-test") + session.add(item) + await session.flush() + after = datetime.now(UTC) + + assert item.created_at is not None + assert before <= item.created_at <= after + + +async def test_updated_at_auto_set(session: AsyncSession): + """updated_at is automatically set on insert.""" + item = Item(name="update-test") + session.add(item) + await session.flush() + + assert item.updated_at is not None + assert isinstance(item.updated_at, datetime) + + +async def test_created_by_defaults_to_none(session: AsyncSession): + """created_by defaults to None when not explicitly provided.""" + item = Item(name="no-author") + session.add(item) + await session.flush() + + assert item.created_by is None + + +async def test_updated_by_defaults_to_none(session: AsyncSession): + """updated_by defaults to None when not explicitly provided.""" + item = Item(name="no-updater") + session.add(item) + await session.flush() + + assert item.updated_by is None + + +# --------------------------------------------------------------------------- +# SoftDeleteEntity fields +# --------------------------------------------------------------------------- + + +async def test_soft_delete_entity_fields_exist(): + """SoftDeleteEntity contributes is_deleted, deleted_at, deleted_by.""" + col_names = {c.name for c in Item.__table__.columns} + for field in ("is_deleted", "deleted_at", "deleted_by"): + assert field in col_names, f"Missing soft-delete field: {field}" + + +async def test_is_deleted_defaults_false(session: AsyncSession): + """is_deleted defaults to False on new records.""" + item = Item(name="alive") + session.add(item) + await session.flush() + + assert item.is_deleted is False + + +async def test_deleted_at_defaults_to_none(session: AsyncSession): + """deleted_at defaults to None on new records.""" + item = Item(name="not-deleted") + session.add(item) + await session.flush() + + assert item.deleted_at is None + + +async def test_deleted_by_defaults_to_none(session: AsyncSession): + """deleted_by defaults to None on new records.""" + item = Item(name="no-deleter") + session.add(item) + await session.flush() + + assert item.deleted_by is None + + +# --------------------------------------------------------------------------- +# FullAuditedEntity — MRO / diamond inheritance +# --------------------------------------------------------------------------- + + +async def test_full_audited_entity_has_all_fields(): + """FullAuditedEntity combines every field from both parents.""" + col_names = {c.name for c in Item.__table__.columns} + expected = { + "id", + "name", + "created_at", + "updated_at", + "created_by", + "updated_by", + "is_deleted", + "deleted_at", + "deleted_by", + } + assert expected.issubset(col_names), f"Missing columns: {expected - col_names}" + + +async def test_full_audited_entity_mro(): + """FullAuditedEntity's MRO includes both AuditedEntity and SoftDeleteEntity.""" + mro = FullAuditedEntity.__mro__ + assert AuditedEntity in mro + assert SoftDeleteEntity in mro + assert Entity in mro + + +# --------------------------------------------------------------------------- +# Persistence round-trip +# --------------------------------------------------------------------------- + + +async def test_persist_and_retrieve(session: AsyncSession): + """A concrete FullAuditedEntity model can be persisted and retrieved.""" + item = Item(name="round-trip", created_by="tester", updated_by="tester") + session.add(item) + await session.flush() + + item_id = item.id + + # Expire and re-fetch from the DB to prove persistence + await session.commit() + loaded = await session.get(Item, item_id) + + assert loaded is not None + assert loaded.name == "round-trip" + assert loaded.created_by == "tester" + assert loaded.is_deleted is False + assert isinstance(loaded.id, uuid.UUID) + assert loaded.id == item_id diff --git a/tests/test_core/test_crud_service.py b/tests/test_core/test_crud_service.py new file mode 100644 index 0000000..eb5faa7 --- /dev/null +++ b/tests/test_core/test_crud_service.py @@ -0,0 +1,236 @@ +"""Tests for CrudService with lifecycle hooks. + +Uses an in-memory fake repository (not SqlAlchemyRepository) to test +pure business logic without any database. This is the pattern generated +projects will use for unit tests. +""" + +import uuid +from typing import Any +from uuid import UUID + +import pytest + +from faststack_core.base.service import CrudService +from faststack_core.exceptions.domain import NotFoundError + +# --------------------------------------------------------------------------- +# Fake entity + repository +# --------------------------------------------------------------------------- + + +class FakeEntity: + """Minimal entity stand-in for testing.""" + + def __init__(self, **kwargs: Any) -> None: + self.id: UUID = kwargs.get("id", uuid.uuid4()) + for key, value in kwargs.items(): + setattr(self, key, value) + + +class FakeRepository: + """In-memory repository satisfying the Repository Protocol.""" + + def __init__(self) -> None: + self._store: dict[UUID, FakeEntity] = {} + + async def get_by_id(self, id: UUID) -> FakeEntity | None: + return self._store.get(id) + + async def list(self, skip: int = 0, limit: int = 100) -> list[FakeEntity]: + items = list(self._store.values()) + return items[skip : skip + limit] + + async def create(self, data: dict) -> FakeEntity: + entity = FakeEntity(**data) + self._store[entity.id] = entity + return entity + + async def update(self, id: UUID, data: dict) -> FakeEntity: + entity = self._store.get(id) + if not entity: + raise NotFoundError(f"Entity {id} not found") + for key, value in data.items(): + setattr(entity, key, value) + return entity + + async def delete(self, id: UUID) -> None: + if id not in self._store: + raise NotFoundError(f"Entity {id} not found") + del self._store[id] + + async def count(self) -> int: + return len(self._store) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(): + return FakeRepository() + + +@pytest.fixture +def service(repo): + return CrudService(repo) + + +# --------------------------------------------------------------------------- +# Basic CRUD +# --------------------------------------------------------------------------- + + +async def test_create(service): + entity = await service.create({"name": "test"}) + assert entity.name == "test" + assert entity.id is not None + + +async def test_get(service): + entity = await service.create({"name": "findable"}) + found = await service.get(entity.id) + assert found.id == entity.id + + +async def test_get_not_found(service): + with pytest.raises(NotFoundError): + await service.get(uuid.uuid4()) + + +async def test_list_empty(service): + result = await service.list() + assert result == [] + + +async def test_list_with_items(service): + await service.create({"name": "a"}) + await service.create({"name": "b"}) + result = await service.list() + assert len(result) == 2 + + +async def test_update(service): + entity = await service.create({"name": "before"}) + updated = await service.update(entity.id, {"name": "after"}) + assert updated.name == "after" + + +async def test_delete(service): + entity = await service.create({"name": "doomed"}) + await service.delete(entity.id) + with pytest.raises(NotFoundError): + await service.get(entity.id) + + +# --------------------------------------------------------------------------- +# Lifecycle hooks fire in correct order +# --------------------------------------------------------------------------- + + +async def test_before_create_transforms_data(repo): + """before_create can modify data before it reaches the repository.""" + + class TransformService(CrudService): + async def before_create(self, data): + data["name"] = data["name"].upper() + return data + + svc = TransformService(repo) + entity = await svc.create({"name": "lowercase"}) + assert entity.name == "LOWERCASE" + + +async def test_after_create_receives_entity(repo): + """after_create receives the created entity and can transform it.""" + called_with = {} + + class TrackingService(CrudService): + async def after_create(self, entity): + called_with["id"] = entity.id + called_with["name"] = entity.name + return entity + + svc = TrackingService(repo) + entity = await svc.create({"name": "tracked"}) + assert called_with["id"] == entity.id + assert called_with["name"] == "tracked" + + +async def test_before_update_transforms_data(repo): + """before_update can modify data before it reaches the repository.""" + + class TransformService(CrudService): + async def before_update(self, id, data): + data["name"] = data["name"].strip() + return data + + svc = TransformService(repo) + entity = await svc.create({"name": "original"}) + updated = await svc.update(entity.id, {"name": " padded "}) + assert updated.name == "padded" + + +async def test_after_update_receives_entity(repo): + """after_update receives the updated entity.""" + called_with = {} + + class TrackingService(CrudService): + async def after_update(self, entity): + called_with["name"] = entity.name + return entity + + svc = TrackingService(repo) + entity = await svc.create({"name": "before"}) + await svc.update(entity.id, {"name": "after"}) + assert called_with["name"] == "after" + + +async def test_before_delete_can_guard(repo): + """before_delete can raise to prevent deletion.""" + + class GuardedService(CrudService): + async def before_delete(self, id): + raise NotFoundError("Deletion blocked by guard") + + svc = GuardedService(repo) + entity = await svc.create({"name": "protected"}) + with pytest.raises(NotFoundError, match="Deletion blocked"): + await svc.delete(entity.id) + + # Entity should still exist + assert await repo.get_by_id(entity.id) is not None + + +async def test_after_delete_fires(repo): + """after_delete is called after successful deletion.""" + deleted_ids = [] + + class TrackingService(CrudService): + async def after_delete(self, id): + deleted_ids.append(id) + + svc = TrackingService(repo) + entity = await svc.create({"name": "tracked-delete"}) + await svc.delete(entity.id) + assert entity.id in deleted_ids + + +async def test_hook_execution_order(repo): + """Hooks execute in the correct order: before → operation → after.""" + call_log = [] + + class OrderedService(CrudService): + async def before_create(self, data): + call_log.append("before_create") + return data + + async def after_create(self, entity): + call_log.append("after_create") + return entity + + svc = OrderedService(repo) + await svc.create({"name": "ordered"}) + assert call_log == ["before_create", "after_create"] diff --git a/tests/test_core/test_exceptions.py b/tests/test_core/test_exceptions.py new file mode 100644 index 0000000..bf7513b --- /dev/null +++ b/tests/test_core/test_exceptions.py @@ -0,0 +1,221 @@ +"""Tests for the domain exception hierarchy and RFC 7807 handlers.""" + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from faststack_core.exceptions.domain import ( + EXCEPTION_STATUS_MAP, + AlreadyExistsError, + ConfigurationError, + DomainError, + ExternalServiceError, + InsufficientPermissionsError, + NotFoundError, + OperationNotAllowedError, + ResourceConflictError, + ValidationError, +) +from faststack_core.exceptions.handlers import register_exception_handlers + +# --------------------------------------------------------------------------- +# Test app fixture +# --------------------------------------------------------------------------- + + +def _build_app() -> FastAPI: + """Return a minimal FastAPI app wired with domain exception handlers and + test routes that raise each exception type.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/not-found") + async def _not_found(): + raise NotFoundError("Thing not found") + + @app.get("/already-exists") + async def _already_exists(): + raise AlreadyExistsError("Already exists") + + @app.get("/validation") + async def _validation(): + raise ValidationError("Bad input", details={"field": "email", "reason": "invalid format"}) + + @app.get("/operation-not-allowed") + async def _operation_not_allowed(): + raise OperationNotAllowedError("Nope") + + @app.get("/resource-conflict") + async def _resource_conflict(): + raise ResourceConflictError("Conflict detected") + + @app.get("/insufficient-permissions") + async def _insufficient_permissions(): + raise InsufficientPermissionsError("Forbidden") + + @app.get("/external-service") + async def _external_service(): + raise ExternalServiceError("Upstream broke") + + @app.get("/configuration") + async def _configuration(): + raise ConfigurationError("Bad config") + + @app.get("/not-found-with-details") + async def _not_found_with_details(): + raise NotFoundError("Missing", details={"id": "abc-123"}) + + @app.get("/not-found-no-details") + async def _not_found_no_details(): + raise NotFoundError("Missing") + + # An unmapped DomainError subclass -- should default to 500. + class _UnknownDomainError(DomainError): ... + + @app.get("/unknown-domain-error") + async def _unknown(): + raise _UnknownDomainError("Something unexpected") + + return app + + +@pytest.fixture +def app() -> FastAPI: + return _build_app() + + +@pytest.fixture +async def client(app: FastAPI) -> AsyncClient: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +# --------------------------------------------------------------------------- +# 1. Each exception class maps to the correct HTTP status code +# --------------------------------------------------------------------------- + +_STATUS_CASES = [ + ("/not-found", 404, "NotFoundError"), + ("/already-exists", 409, "AlreadyExistsError"), + ("/validation", 422, "ValidationError"), + ("/operation-not-allowed", 403, "OperationNotAllowedError"), + ("/resource-conflict", 409, "ResourceConflictError"), + ("/insufficient-permissions", 403, "InsufficientPermissionsError"), + ("/external-service", 502, "ExternalServiceError"), + ("/configuration", 500, "ConfigurationError"), +] + + +@pytest.mark.parametrize("path, expected_status, expected_title", _STATUS_CASES) +async def test_exception_status_codes(client, path, expected_status, expected_title): + resp = await client.get(path) + assert resp.status_code == expected_status + body = resp.json() + assert body["title"] == expected_title + + +# --------------------------------------------------------------------------- +# 2. RFC 7807 response has all required fields +# --------------------------------------------------------------------------- + +_RFC7807_REQUIRED_FIELDS = {"type", "title", "status", "detail", "instance"} + + +@pytest.mark.parametrize("path, expected_status, _title", _STATUS_CASES) +async def test_rfc7807_required_fields(client, path, expected_status, _title): + resp = await client.get(path) + body = resp.json() + assert _RFC7807_REQUIRED_FIELDS <= set(body.keys()) + assert body["status"] == expected_status + assert body["instance"].startswith("http://test/") + assert body["type"].startswith("/errors/") + + +# --------------------------------------------------------------------------- +# 3. details dict is included when provided, omitted when empty +# --------------------------------------------------------------------------- + + +async def test_details_included_when_provided(client): + resp = await client.get("/not-found-with-details") + body = resp.json() + assert "details" in body + assert body["details"] == {"id": "abc-123"} + + +async def test_details_omitted_when_empty(client): + resp = await client.get("/not-found-no-details") + body = resp.json() + assert "details" not in body + + +async def test_validation_error_carries_details(client): + resp = await client.get("/validation") + body = resp.json() + assert body["details"] == {"field": "email", "reason": "invalid format"} + + +# --------------------------------------------------------------------------- +# 4. Unknown DomainError subclass defaults to 500 +# --------------------------------------------------------------------------- + + +async def test_unknown_domain_error_defaults_to_500(client): + resp = await client.get("/unknown-domain-error") + assert resp.status_code == 500 + body = resp.json() + assert body["detail"] == "Something unexpected" + assert body["status"] == 500 + + +# --------------------------------------------------------------------------- +# 5. Explicit checks for NotFoundError (404), AlreadyExistsError (409), +# and ValidationError (422) +# --------------------------------------------------------------------------- + + +async def test_not_found_error(client): + resp = await client.get("/not-found") + assert resp.status_code == 404 + body = resp.json() + assert body["type"] == "/errors/NotFoundError" + assert body["title"] == "NotFoundError" + assert body["detail"] == "Thing not found" + + +async def test_already_exists_error(client): + resp = await client.get("/already-exists") + assert resp.status_code == 409 + body = resp.json() + assert body["type"] == "/errors/AlreadyExistsError" + assert body["title"] == "AlreadyExistsError" + assert body["detail"] == "Already exists" + + +async def test_validation_error(client): + resp = await client.get("/validation") + assert resp.status_code == 422 + body = resp.json() + assert body["type"] == "/errors/ValidationError" + assert body["title"] == "ValidationError" + assert body["detail"] == "Bad input" + + +# --------------------------------------------------------------------------- +# 6. EXCEPTION_STATUS_MAP completeness sanity check +# --------------------------------------------------------------------------- + + +def test_exception_status_map_covers_all_subclasses(): + expected = { + NotFoundError, + AlreadyExistsError, + ValidationError, + OperationNotAllowedError, + ResourceConflictError, + InsufficientPermissionsError, + ExternalServiceError, + ConfigurationError, + } + assert set(EXCEPTION_STATUS_MAP.keys()) == expected diff --git a/tests/test_core/test_repository.py b/tests/test_core/test_repository.py new file mode 100644 index 0000000..608e7e6 --- /dev/null +++ b/tests/test_core/test_repository.py @@ -0,0 +1,215 @@ +"""Tests for Repository Protocol conformance and SqlAlchemyRepository. + +Uses aiosqlite for async in-memory SQLAlchemy testing. +""" + +import uuid + +import pytest +from sqlalchemy import String +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import Mapped, mapped_column + +from faststack_core.base.entity import Base, Entity, FullAuditedEntity +from faststack_core.base.repository import Repository, SqlAlchemyRepository +from faststack_core.exceptions.domain import NotFoundError + +# --------------------------------------------------------------------------- +# Test models +# --------------------------------------------------------------------------- + + +class SimpleItem(Entity): + """Non-soft-delete entity for testing hard delete behavior.""" + + __tablename__ = "simple_items" + name: Mapped[str] = mapped_column(String(100)) + + +class AuditedItem(FullAuditedEntity): + """Soft-delete entity for testing soft delete behavior.""" + + __tablename__ = "audited_items" + name: Mapped[str] = mapped_column(String(100)) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def engine(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield eng + await eng.dispose() + + +@pytest.fixture +async def session(engine): + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as sess: + yield sess + + +@pytest.fixture +def simple_repo(session): + return SqlAlchemyRepository(session, SimpleItem) + + +@pytest.fixture +def audited_repo(session): + return SqlAlchemyRepository(session, AuditedItem) + + +# --------------------------------------------------------------------------- +# Protocol conformance +# --------------------------------------------------------------------------- + + +class FakeRepo: + """Minimal fake that satisfies Repository Protocol via structural typing.""" + + async def get_by_id(self, id: uuid.UUID) -> None: + return None + + async def list(self, skip: int = 0, limit: int = 100) -> list: + return [] + + async def create(self, data: dict) -> None: + return None + + async def update(self, id: uuid.UUID, data: dict) -> None: + return None + + async def delete(self, id: uuid.UUID) -> None: + pass + + async def count(self) -> int: + return 0 + + +async def test_sqlalchemy_repo_satisfies_protocol(): + """SqlAlchemyRepository is recognized as a Repository at runtime.""" + assert isinstance(SqlAlchemyRepository, type) + # We can't isinstance-check a generic, but we can verify method signatures exist + for method in ("get_by_id", "list", "create", "update", "delete", "count"): + assert hasattr(SqlAlchemyRepository, method) + + +async def test_fake_repo_satisfies_protocol(): + """A plain class with matching methods satisfies the Protocol.""" + fake = FakeRepo() + assert isinstance(fake, Repository) + + +# --------------------------------------------------------------------------- +# CRUD operations +# --------------------------------------------------------------------------- + + +async def test_create(simple_repo): + item = await simple_repo.create({"name": "test-item"}) + assert item.name == "test-item" + assert item.id is not None + assert isinstance(item.id, uuid.UUID) + + +async def test_get_by_id(simple_repo): + item = await simple_repo.create({"name": "findable"}) + found = await simple_repo.get_by_id(item.id) + assert found is not None + assert found.id == item.id + assert found.name == "findable" + + +async def test_get_by_id_not_found(simple_repo): + result = await simple_repo.get_by_id(uuid.uuid4()) + assert result is None + + +async def test_list_empty(simple_repo): + items = await simple_repo.list() + assert items == [] + + +async def test_list_with_items(simple_repo): + await simple_repo.create({"name": "a"}) + await simple_repo.create({"name": "b"}) + await simple_repo.create({"name": "c"}) + items = await simple_repo.list() + assert len(items) == 3 + + +async def test_list_with_skip_and_limit(simple_repo): + for i in range(5): + await simple_repo.create({"name": f"item-{i}"}) + items = await simple_repo.list(skip=1, limit=2) + assert len(items) == 2 + + +async def test_update(simple_repo): + item = await simple_repo.create({"name": "before"}) + updated = await simple_repo.update(item.id, {"name": "after"}) + assert updated.name == "after" + assert updated.id == item.id + + +async def test_update_not_found(simple_repo): + with pytest.raises(NotFoundError): + await simple_repo.update(uuid.uuid4(), {"name": "nope"}) + + +async def test_count_empty(simple_repo): + assert await simple_repo.count() == 0 + + +async def test_count_with_items(simple_repo): + await simple_repo.create({"name": "one"}) + await simple_repo.create({"name": "two"}) + assert await simple_repo.count() == 2 + + +# --------------------------------------------------------------------------- +# Hard delete (non-soft-delete entity) +# --------------------------------------------------------------------------- + + +async def test_delete_hard_deletes_simple_entity(simple_repo): + item = await simple_repo.create({"name": "to-delete"}) + await simple_repo.delete(item.id) + assert await simple_repo.get_by_id(item.id) is None + + +async def test_delete_not_found(simple_repo): + with pytest.raises(NotFoundError): + await simple_repo.delete(uuid.uuid4()) + + +# --------------------------------------------------------------------------- +# Soft delete (FullAuditedEntity) +# --------------------------------------------------------------------------- + + +async def test_delete_soft_deletes_audited_entity(audited_repo): + item = await audited_repo.create({"name": "soft-target"}) + await audited_repo.delete(item.id) + + # Entity still exists in DB but is flagged + found = await audited_repo.get_by_id(item.id) + assert found is not None + assert found.is_deleted is True + assert found.deleted_at is not None + + +async def test_hard_delete_removes_audited_entity(audited_repo): + item = await audited_repo.create({"name": "hard-target"}) + await audited_repo.hard_delete(item.id) + assert await audited_repo.get_by_id(item.id) is None + + +async def test_hard_delete_not_found(audited_repo): + with pytest.raises(NotFoundError): + await audited_repo.hard_delete(uuid.uuid4())