From aa855b17ba02b1664a90ff15fecc5e0923bcfee9 Mon Sep 17 00:00:00 2001 From: Tomas Sanchez Date: Fri, 12 Jun 2026 18:58:22 -0300 Subject: [PATCH] feat(template): make the User example slice optional Wire the include_user_example flag: conditional _exclude drops the User slice (commands, events, model, adapters, routes, reader, handlers, migration, and user tests) when off, and bootstrap/router/migrations/unit-of-work are Jinja-guarded so the off path yields a coherent monitor-only service. Add generic core tests (unit-of-work lifecycle, self-contained messagebus and messages) so both states stay at 100% coverage, and extend the bake-test into a true/false matrix that runs ruff check, ruff format --check, pyrefly, and pytest --cov-fail-under=100 for each. Bakes proven green both ways: User-on 56 tests / User-off 34 tests, 100% coverage each. Closes #21 Closes #22 Refs #24 Co-Authored-By: Claude Fable 5 --- .github/workflows/template-ci.yml | 7 +- README.md | 4 +- copier.yml | 21 ++++ template/migrations/env.py.jinja | 2 + .../adapters/unit_of_work.py.jinja | 6 + .../src/{{ package_name }}/bootstrap.py.jinja | 24 +++- .../src/{{ package_name }}/router.py.jinja | 6 + .../service_layer/unit_of_work.py.jinja | 13 ++ ...migrations.py => test_migrations.py.jinja} | 6 + .../integration/test_unit_of_work.py.jinja | 118 ++++++++++++++++++ .../tests/unit/domain/test_messages.py.jinja | 69 +++++----- .../service_layer/test_messagebus.py.jinja | 105 ++++++++++------ tests/test_bake.py | 60 +++++++-- 13 files changed, 354 insertions(+), 87 deletions(-) rename template/tests/integration/{test_migrations.py => test_migrations.py.jinja} (84%) create mode 100644 template/tests/integration/test_unit_of_work.py.jinja diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index ea03dbf..c93a7c3 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -1,6 +1,9 @@ # Template repository CI: bake the Copier template and run the generated -# project's full quality gate (Ruff, Pyrefly, pytest at 100% coverage). -# The generated project ships its own workflow at template/.github/workflows/build.yml. +# project's full quality gate (Ruff, Pyrefly, pytest at 100% coverage) across +# the feature-flag matrix. The parametrized bake-test in tests/test_bake.py +# renders the template with include_user_example both on and off, so a single +# pytest run validates every variant. The generated project ships its own +# workflow at template/.github/workflows/build.yml. name: Template CI diff --git a/README.md b/README.md index e3f31d2..41eb8f6 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ uv run pytest The bake-test ([`tests/test_bake.py`](tests/test_bake.py)) snapshots the tracked working tree, renders it with a sample answer set, then runs `uv sync`, `ruff check`, `pyrefly check`, and `pytest --cov src` inside the -baked project, asserting 100% coverage. The same bake runs in CI via +baked project, asserting 100% coverage. It is parametrized over the `include_user_example` feature flag, so +both the default (User example on) and the monitor-only baseline (User example off) are validated on every +run. The same bake matrix runs in CI via [`.github/workflows/template-ci.yml`](.github/workflows/template-ci.yml). ## Acknowledgements diff --git a/copier.yml b/copier.yml index 8660bf3..adcc705 100644 --- a/copier.yml +++ b/copier.yml @@ -11,6 +11,27 @@ _exclude: - ".ruff_cache" - ".pytest_cache" - ".mypy_cache" + # When the User example slice is disabled, drop its source, adapters, + # entrypoints, migration, and dedicated tests. Copier matches _exclude against + # the *rendered* destination path, so patterns use the final names (no .jinja + # suffix). Each pattern renders to an empty string when the flag is on, which + # Copier ignores. + - "{% if not include_user_example %}**/domain/commands/user.py{% endif %}" + - "{% if not include_user_example %}**/domain/events/user.py{% endif %}" + - "{% if not include_user_example %}**/domain/models/user.py{% endif %}" + - "{% if not include_user_example %}**/adapters/models/user.py{% endif %}" + - "{% if not include_user_example %}**/adapters/repository.py{% endif %}" + - "{% if not include_user_example %}**/adapters/queries.py{% endif %}" + - "{% if not include_user_example %}**/entrypoint/users.py{% endif %}" + - "{% if not include_user_example %}**/service_layer/handlers.py{% endif %}" + - "{% if not include_user_example %}**/service_layer/queries.py{% endif %}" + - "{% if not include_user_example %}**/service_layer/read_models.py{% endif %}" + - "{% if not include_user_example %}**/service_layer/repository.py{% endif %}" + - "{% if not include_user_example %}**/versions/*create_users.py{% endif %}" + - "{% if not include_user_example %}**/unit/domain/models/test_user.py{% endif %}" + - "{% if not include_user_example %}**/service_layer/test_handlers.py{% endif %}" + - "{% if not include_user_example %}**/integration/test_persistence.py{% endif %}" + - "{% if not include_user_example %}**/e2e/entrypoint/test_users.py{% endif %}" _message_after_copy: | Your project "{{ project_name }}" is ready. diff --git a/template/migrations/env.py.jinja b/template/migrations/env.py.jinja index 252ba2e..00c8b79 100644 --- a/template/migrations/env.py.jinja +++ b/template/migrations/env.py.jinja @@ -6,7 +6,9 @@ from alembic import context from sqlalchemy import engine_from_config, pool from {{ package_name }}.adapters.models.base import Base +{%- if include_user_example %} from {{ package_name }}.adapters.models.user import UserRecord # noqa: F401 +{%- endif %} from {{ package_name }}.settings.database_settings import DatabaseSettings config = context.config diff --git a/template/src/{{ package_name }}/adapters/unit_of_work.py.jinja b/template/src/{{ package_name }}/adapters/unit_of_work.py.jinja index 9b0f46f..bde4de9 100644 --- a/template/src/{{ package_name }}/adapters/unit_of_work.py.jinja +++ b/template/src/{{ package_name }}/adapters/unit_of_work.py.jinja @@ -7,7 +7,9 @@ from types import TracebackType from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker +{% if include_user_example -%} from {{ package_name }}.adapters.repository import SqlAlchemyUserRepository +{% endif -%} from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork, IntegrityConflict @@ -25,7 +27,9 @@ class SqlAlchemyUnitOfWork(AbstractUnitOfWork): def __enter__(self) -> SqlAlchemyUnitOfWork: """Open a session and repositories.""" self.session = self.session_factory() +{%- if include_user_example %} self.users = SqlAlchemyUserRepository(self.session) +{%- endif %} return self def __exit__( @@ -41,7 +45,9 @@ class SqlAlchemyUnitOfWork(AbstractUnitOfWork): def commit(self) -> None: """Persist tracked aggregate changes and commit the transaction.""" try: +{%- if include_user_example %} self.users.persist_changes() +{%- endif %} self.session.commit() except IntegrityError as error: raise IntegrityConflict from error diff --git a/template/src/{{ package_name }}/bootstrap.py.jinja b/template/src/{{ package_name }}/bootstrap.py.jinja index 6b4a45f..cf0eb74 100644 --- a/template/src/{{ package_name }}/bootstrap.py.jinja +++ b/template/src/{{ package_name }}/bootstrap.py.jinja @@ -12,15 +12,22 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from {{ package_name }}.adapters.models.base import Base +{%- if include_user_example %} from {{ package_name }}.adapters.queries import SqlAlchemyUserReader +{%- endif %} from {{ package_name }}.adapters.unit_of_work import SqlAlchemyUnitOfWork +{%- if include_user_example %} from {{ package_name }}.domain.commands.user import DeactivateUser, RegisterUser from {{ package_name }}.domain.events.user import UserDeactivated, UserRegistered from {{ package_name }}.service_layer import handlers +{%- endif %} from {{ package_name }}.service_layer.messagebus import MessageBus +{%- if include_user_example %} from {{ package_name }}.service_layer.queries import UserReader +{%- endif %} from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork from {{ package_name }}.settings.database_settings import DatabaseSettings +{%- if include_user_example %} def _ignore_user_registered(event: UserRegistered) -> None: @@ -29,6 +36,7 @@ def _ignore_user_registered(event: UserRegistered) -> None: def _ignore_user_deactivated(event: UserDeactivated) -> None: """Provide a default no-op external event publisher.""" +{%- endif %} @dataclass @@ -38,9 +46,11 @@ class ApplicationContainer: engine: Engine session_factory: sessionmaker[Session] uow_factory: Callable[[], AbstractUnitOfWork] - user_reader: UserReader bus: MessageBus auto_create_schema: bool +{%- if include_user_example %} + user_reader: UserReader +{%- endif %} def startup(self) -> None: """Initialize resources required by the running application.""" @@ -54,13 +64,17 @@ class ApplicationContainer: def bootstrap( database_settings: DatabaseSettings | None = None, +{%- if include_user_example %} publish: Callable[[UserRegistered], None] = _ignore_user_registered, +{%- endif %} ) -> ApplicationContainer: """Build application dependencies. Args: database_settings: Optional database configuration override. +{%- if include_user_example %} publish: External user-registration event publisher. +{%- endif %} Returns: A configured application container. @@ -76,6 +90,7 @@ def bootstrap( engine = create_engine(settings.URL, **engine_options) session_factory = sessionmaker(bind=engine, expire_on_commit=False) uow_factory = partial(SqlAlchemyUnitOfWork, session_factory) +{%- if include_user_example %} user_reader = SqlAlchemyUserReader(session_factory) bus = MessageBus( uow_factory=uow_factory, @@ -88,11 +103,16 @@ def bootstrap( UserDeactivated: [partial(handlers.publish_user_deactivated, publish=_ignore_user_deactivated)], }, ) +{%- else %} + bus = MessageBus(uow_factory=uow_factory, command_handlers={}, event_handlers={}) +{%- endif %} return ApplicationContainer( engine=engine, session_factory=session_factory, uow_factory=uow_factory, - user_reader=user_reader, bus=bus, auto_create_schema=settings.AUTO_CREATE_SCHEMA, +{%- if include_user_example %} + user_reader=user_reader, +{%- endif %} ) diff --git a/template/src/{{ package_name }}/router.py.jinja b/template/src/{{ package_name }}/router.py.jinja index 3fc4b05..218a5fb 100644 --- a/template/src/{{ package_name }}/router.py.jinja +++ b/template/src/{{ package_name }}/router.py.jinja @@ -8,7 +8,11 @@ Resources: from fastapi import APIRouter +{% if include_user_example -%} from {{ package_name }}.entrypoint import monitor, users +{%- else -%} +from {{ package_name }}.entrypoint import monitor +{%- endif %} api_v1_prefix: str = "/api/v1" @@ -17,6 +21,8 @@ api_router_v1: APIRouter = APIRouter(prefix=api_v1_prefix) # Base routers root_router.include_router(monitor.router) +{%- if include_user_example %} # API routers api_router_v1.include_router(users.router) +{%- endif %} diff --git a/template/src/{{ package_name }}/service_layer/unit_of_work.py.jinja b/template/src/{{ package_name }}/service_layer/unit_of_work.py.jinja index 2f54f3c..e38c5df 100644 --- a/template/src/{{ package_name }}/service_layer/unit_of_work.py.jinja +++ b/template/src/{{ package_name }}/service_layer/unit_of_work.py.jinja @@ -7,7 +7,9 @@ from collections.abc import Iterator from types import TracebackType from {{ package_name }}.domain.messages import Event +{%- if include_user_example %} from {{ package_name }}.service_layer.repository import UserRepository +{%- endif %} class IntegrityConflict(RuntimeError): @@ -16,8 +18,10 @@ class IntegrityConflict(RuntimeError): class AbstractUnitOfWork(ABC): """Provide atomic persistence and event collection.""" +{%- if include_user_example %} users: UserRepository +{%- endif %} def __enter__(self) -> AbstractUnitOfWork: """Enter the transaction boundary.""" @@ -42,6 +46,7 @@ class AbstractUnitOfWork(ABC): def collect_new_events(self) -> Iterator[Event]: """Yield pending events from aggregates seen in this transaction. +{%- if include_user_example %} A unit of work that was never entered has no repository; in that case no aggregate was seen and the iterator yields nothing. @@ -52,3 +57,11 @@ class AbstractUnitOfWork(ABC): for user in users.seen.values(): while user.events: yield user.events.pop(0) +{%- else %} + + Without the example aggregate slice this monitor-only baseline tracks no + aggregates, so the iterator yields nothing. + """ + return + yield # pragma: no cover - unreachable generator sentinel +{%- endif %} diff --git a/template/tests/integration/test_migrations.py b/template/tests/integration/test_migrations.py.jinja similarity index 84% rename from template/tests/integration/test_migrations.py rename to template/tests/integration/test_migrations.py.jinja index f58e083..9facbe3 100644 --- a/template/tests/integration/test_migrations.py +++ b/template/tests/integration/test_migrations.py.jinja @@ -14,7 +14,11 @@ def test_upgrades_a_blank_database_to_head(self, tmp_path: Path): """ GIVEN a blank SQLite database WHEN Alembic upgrades the database to head +{%- if include_user_example %} THEN the user schema and Alembic revision table exist +{%- else %} + THEN Alembic records its revision table even with no migrations yet +{%- endif %} """ # GIVEN database_path = tmp_path / "migration-test.db" @@ -28,4 +32,6 @@ def test_upgrades_a_blank_database_to_head(self, tmp_path: Path): # THEN tables = inspect(create_engine(database_url)).get_table_names() assert "alembic_version" in tables +{%- if include_user_example %} assert "users" in tables +{%- endif %} diff --git a/template/tests/integration/test_unit_of_work.py.jinja b/template/tests/integration/test_unit_of_work.py.jinja new file mode 100644 index 0000000..aa93ef0 --- /dev/null +++ b/template/tests/integration/test_unit_of_work.py.jinja @@ -0,0 +1,118 @@ +"""Integration tests for the SQLAlchemy unit of work. + +These tests exercise the transaction adapter directly, independent of any +domain aggregate slice, so the monitor-only baseline keeps the unit of work +covered. A throwaway SQLAlchemy record defined against the shared declarative +base stands in for a persisted row without depending on example domain models. +""" + +from collections.abc import Iterator + +import pytest +from sqlalchemy import String, create_engine, select +from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker +from sqlalchemy.pool import StaticPool + +from {{ package_name }}.adapters.models.base import Base +from {{ package_name }}.adapters.unit_of_work import SqlAlchemyUnitOfWork +from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork, IntegrityConflict + + +class _Widget(Base): + """A throwaway record used to drive the unit of work in tests.""" + + __tablename__ = "uow_test_widgets" + + id: Mapped[str] = mapped_column(String(), primary_key=True) + + +@pytest.fixture(name="session_factory") +def fixture_session_factory() -> Iterator[sessionmaker[Session]]: + """Create an isolated in-memory SQLAlchemy session factory.""" + engine = create_engine("sqlite+pysqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + yield sessionmaker(bind=engine, expire_on_commit=False) + engine.dispose() + + +class TestSqlAlchemyUnitOfWork: + """Test transaction semantics without a domain aggregate slice.""" + + def test_commits_a_row(self, session_factory: sessionmaker[Session]): + """ + GIVEN a SQLAlchemy unit of work + WHEN a row is added and explicitly committed + THEN a later session observes the persisted row + """ + # WHEN + with SqlAlchemyUnitOfWork(session_factory) as uow: + uow.session.add(_Widget(id="alpha")) + uow.commit() + + # THEN + with session_factory() as session: + assert session.get(_Widget, "alpha") is not None + + def test_rolls_back_uncommitted_work(self, session_factory: sessionmaker[Session]): + """ + GIVEN a SQLAlchemy unit of work + WHEN a row is added without an explicit commit + THEN the implicit rollback on exit discards the row + """ + # WHEN + with SqlAlchemyUnitOfWork(session_factory) as uow: + uow.session.add(_Widget(id="beta")) + + # THEN + with session_factory() as session: + assert session.scalar(select(_Widget).where(_Widget.id == "beta")) is None + + def test_translates_integrity_errors(self, session_factory: sessionmaker[Session]): + """ + GIVEN a row that already exists + WHEN a conflicting primary key is committed + THEN the adapter raises an application persistence conflict + """ + # GIVEN + with SqlAlchemyUnitOfWork(session_factory) as uow: + uow.session.add(_Widget(id="gamma")) + uow.commit() + + # WHEN / THEN + with pytest.raises(IntegrityConflict), SqlAlchemyUnitOfWork(session_factory) as uow: + uow.session.add(_Widget(id="gamma")) + uow.commit() + + +class TestAbstractUnitOfWork: + """Test the default transaction-boundary behavior.""" + + def test_enter_returns_self_and_exit_rolls_back(self): + """ + GIVEN a unit of work using the default boundary behavior + WHEN it is used as a context manager without committing + THEN entering returns the instance and exiting rolls work back + """ + + # GIVEN + class _RecordingUnitOfWork(AbstractUnitOfWork): + """A unit of work that records its commit and rollback calls.""" + + def __init__(self) -> None: + self.rolled_back = False + + def commit(self) -> None: + """Do nothing.""" + + def rollback(self) -> None: + """Record a rollback.""" + self.rolled_back = True + + uow = _RecordingUnitOfWork() + + # WHEN + with uow as entered: + assert entered is uow + + # THEN + assert uow.rolled_back is True diff --git a/template/tests/unit/domain/test_messages.py.jinja b/template/tests/unit/domain/test_messages.py.jinja index 77dafc7..f421320 100644 --- a/template/tests/unit/domain/test_messages.py.jinja +++ b/template/tests/unit/domain/test_messages.py.jinja @@ -1,13 +1,24 @@ """Test suite for Pydantic application messages.""" from typing import Any, cast -from uuid import UUID import pytest -from pydantic import ValidationError +from pydantic import Field, ValidationError -from {{ package_name }}.domain.commands.user import RegisterUser -from {{ package_name }}.domain.events.user import UserRegistered +from {{ package_name }}.domain.messages import Command, Event + + +class _SampleCommand(Command): + """A sample command used to exercise shared message behavior.""" + + display_name: str = Field(min_length=1) + retry_count: int = 0 + + +class _SampleEvent(Event): + """A sample event used to exercise shared message behavior.""" + + display_name: str class TestMessages: @@ -17,15 +28,15 @@ class TestMessages: """ GIVEN a Pydantic command and event WHEN the messages are serialized - THEN adapters receive stable dictionary representations + THEN adapters receive stable camel-case dictionary representations """ # GIVEN - command = RegisterUser(name="Ada Lovelace", email="ada@example.com") - event = UserRegistered(user_id=command.user_id, email=command.email) + command = _SampleCommand(display_name="Ada Lovelace", retry_count=2) + event = _SampleEvent(display_name="Ada Lovelace") # WHEN / THEN - assert UUID(command.model_dump(mode="json")["userId"]) == command.user_id - assert event.model_dump(mode="json") == {"userId": str(command.user_id), "email": "ada@example.com"} + assert command.model_dump(mode="json") == {"displayName": "Ada Lovelace", "retryCount": 2} + assert event.model_dump(mode="json") == {"displayName": "Ada Lovelace"} def test_parses_camel_case_commands(self): """ @@ -33,44 +44,22 @@ class TestMessages: WHEN the command schema validates it THEN application code receives the typed command """ - # GIVEN - user_id = "00000000-0000-0000-0000-000000000001" - # WHEN - command = RegisterUser.model_validate( - { - "name": "Ada Lovelace", - "email": "ada@example.com", - "settings": {"marketingEnabled": True, "backupEmail": "backup@example.com"}, - "userId": user_id, - } - ) + command = _SampleCommand.model_validate({"displayName": "Ada Lovelace", "retryCount": 3}) # THEN - assert str(command.user_id) == user_id - assert command.model_dump(mode="json")["settings"] == { - "theme": "light", - "language": "en", - "marketingEnabled": True, - "backupEmail": "backup@example.com", - } - - @pytest.mark.parametrize( - ("name", "email"), - [ - ("", "ada@example.com"), - ("Ada Lovelace", "not-an-email"), - ], - ) - def test_rejects_invalid_commands(self, name: str, email: str): + assert command.display_name == "Ada Lovelace" + assert command.retry_count == 3 + + def test_rejects_invalid_commands(self): """ - GIVEN invalid registration data from an external adapter + GIVEN invalid command data from an external adapter WHEN the command schema validates it THEN Pydantic rejects the command before dispatch """ # WHEN / THEN with pytest.raises(ValidationError): - RegisterUser(name=name, email=email) + _SampleCommand(display_name="") def test_rejects_message_mutation(self): """ @@ -79,8 +68,8 @@ class TestMessages: THEN Pydantic rejects the mutation """ # GIVEN - command = RegisterUser(name="Ada Lovelace", email="ada@example.com") + command = _SampleCommand(display_name="Ada Lovelace") # WHEN / THEN with pytest.raises(ValidationError): - cast(Any, command).email = "other@example.com" + cast(Any, command).display_name = "Other" diff --git a/template/tests/unit/service_layer/test_messagebus.py.jinja b/template/tests/unit/service_layer/test_messagebus.py.jinja index 43d025b..53c46cb 100644 --- a/template/tests/unit/service_layer/test_messagebus.py.jinja +++ b/template/tests/unit/service_layer/test_messagebus.py.jinja @@ -1,18 +1,47 @@ """Test suite for internal message dispatch.""" -from functools import partial +from collections.abc import Iterator from unittest.mock import patch -from uuid import uuid4 import pytest -from {{ package_name }}.domain.commands.user import DeactivateUser, RegisterUser -from {{ package_name }}.domain.events.user import UserRegistered -from {{ package_name }}.domain.messages import Message -from {{ package_name }}.service_layer.handlers import publish_user_registered, register_user +from {{ package_name }}.domain.messages import Command, Event, Message from {{ package_name }}.service_layer.messagebus import MessageBus, UnhandledCommand from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork -from tests.unit.service_layer.test_handlers import FakeUnitOfWork + + +class _SampleCommand(Command): + """A sample command used to drive the message bus under test.""" + + label: str + + +class _SampleEvent(Event): + """A sample event raised while handling the sample command.""" + + label: str + + +class _StubUnitOfWork(AbstractUnitOfWork): + """A unit of work that surfaces a fixed set of collected events.""" + + def __init__(self, events: list[Event] | None = None): + """Initialize the stub with the events to drain after a command.""" + self._events = events or [] + self.committed = False + self.rolled_back = False + + def commit(self) -> None: + """Record a commit.""" + self.committed = True + + def rollback(self) -> None: + """Record a rollback.""" + self.rolled_back = True + + def collect_new_events(self) -> Iterator[Event]: + """Yield the events queued for this transaction.""" + yield from self._events class TestMessageBus: @@ -20,26 +49,29 @@ class TestMessageBus: def test_dispatches_events_raised_by_command_handlers(self): """ - GIVEN a message bus with registration and publication handlers - WHEN a registration command is dispatched - THEN the resulting domain event is published + GIVEN a message bus whose command handler raises a domain event + WHEN the command is dispatched + THEN the resulting domain event reaches its registered handler """ # GIVEN - published: list[UserRegistered] = [] - uow = FakeUnitOfWork() + event = _SampleEvent(label="raised") + published: list[_SampleEvent] = [] + + def handle_command(command: _SampleCommand, uow: AbstractUnitOfWork) -> str: + return command.label + bus = MessageBus( - uow_factory=lambda: uow, - command_handlers={RegisterUser: register_user}, - event_handlers={UserRegistered: [partial(publish_user_registered, publish=published.append)]}, + uow_factory=lambda: _StubUnitOfWork([event]), + command_handlers={_SampleCommand: handle_command}, + event_handlers={_SampleEvent: [published.append]}, ) # WHEN - command = RegisterUser(name="Ada Lovelace", email="ada@example.com") - user_id = bus.handle(command) + result = bus.handle(_SampleCommand(label="ok")) # THEN - assert user_id == command.user_id - assert published == [UserRegistered(user_id=command.user_id, email="ada@example.com")] + assert result == "ok" + assert published == [event] def test_logs_event_handler_failures_and_continues(self): """ @@ -49,21 +81,24 @@ class TestMessageBus: """ # GIVEN - def fail_to_publish(event: UserRegistered) -> None: - raise RuntimeError(event.email) + def handle_command(command: _SampleCommand, uow: AbstractUnitOfWork) -> str: + return command.label + + def fail_to_publish(event: _SampleEvent) -> None: + raise RuntimeError(event.label) bus = MessageBus( - uow_factory=FakeUnitOfWork, - command_handlers={RegisterUser: register_user}, - event_handlers={UserRegistered: [fail_to_publish]}, + uow_factory=lambda: _StubUnitOfWork([_SampleEvent(label="boom")]), + command_handlers={_SampleCommand: handle_command}, + event_handlers={_SampleEvent: [fail_to_publish]}, ) # WHEN with patch("{{ package_name }}.service_layer.messagebus.log.exception") as log_exception: - user_id = bus.handle(RegisterUser(name="Ada Lovelace", email="ada@example.com")) + result = bus.handle(_SampleCommand(label="ok")) # THEN - assert user_id is not None + assert result == "ok" log_exception.assert_called_once() def test_rejects_messages_without_command_or_event_semantics(self): @@ -73,7 +108,7 @@ class TestMessageBus: THEN the unsupported message is rejected """ # GIVEN - bus = MessageBus(uow_factory=FakeUnitOfWork, command_handlers={}, event_handlers={}) + bus = MessageBus(uow_factory=_StubUnitOfWork, command_handlers={}, event_handlers={}) # WHEN / THEN with pytest.raises(TypeError, match="Unsupported message type"): @@ -86,26 +121,26 @@ class TestMessageBus: THEN the bus raises an explicit unhandled-command error """ # GIVEN - bus = MessageBus(uow_factory=FakeUnitOfWork, command_handlers={}, event_handlers={}) + bus = MessageBus(uow_factory=_StubUnitOfWork, command_handlers={}, event_handlers={}) # WHEN / THEN - with pytest.raises(UnhandledCommand, match="DeactivateUser"): - bus.handle(DeactivateUser(user_id=uuid4())) + with pytest.raises(UnhandledCommand, match="_SampleCommand"): + bus.handle(_SampleCommand(label="ok")) class TestUnitOfWorkEventCollection: """Test event collection on the abstract unit of work.""" - def test_yields_nothing_when_never_entered(self): + def test_yields_nothing_for_a_transaction_without_aggregates(self): """ - GIVEN a unit of work that was never entered and has no repository + GIVEN a unit of work that tracked no aggregates WHEN events are collected THEN the collection yields nothing instead of raising """ # GIVEN - class NeverEnteredUnitOfWork(AbstractUnitOfWork): - """A unit of work whose repository is only created on __enter__.""" + class _EmptyUnitOfWork(AbstractUnitOfWork): + """A unit of work that tracks no aggregates.""" def commit(self) -> None: """Do nothing.""" @@ -113,7 +148,7 @@ class TestUnitOfWorkEventCollection: def rollback(self) -> None: """Do nothing.""" - uow = NeverEnteredUnitOfWork() + uow = _EmptyUnitOfWork() # WHEN events = list(uow.collect_new_events()) diff --git a/tests/test_bake.py b/tests/test_bake.py index a99cbf0..782d21f 100644 --- a/tests/test_bake.py +++ b/tests/test_bake.py @@ -4,6 +4,9 @@ path contains Jinja. Instead we *bake* the template: render it to a temporary directory with a sample answer set and run the generated project's full quality gate (Ruff, Pyrefly, pytest at 100% coverage). + +The bake runs across the feature-flag matrix so both the default +``include_user_example`` slice and the monitor-only baseline are validated. """ from __future__ import annotations @@ -18,7 +21,7 @@ TEMPLATE_ROOT = Path(__file__).resolve().parent.parent -SAMPLE_ANSWERS: dict[str, object] = { +BASE_ANSWERS: dict[str, object] = { "project_name": "Demo Service", "project_slug": "demo-service", "package_name": "demo_service", @@ -30,9 +33,14 @@ "license": "MIT", "python_version": "3.13", "database_url": "sqlite+pysqlite:///./demo-service.db", - "include_user_example": True, } +# The feature-flag matrix: each entry is a (test id, include_user_example) pair. +FEATURE_FLAG_MATRIX = [ + pytest.param(True, id="user-example-on"), + pytest.param(False, id="user-example-off"), +] + def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: """Run a command inside the baked project and capture its output.""" @@ -68,17 +76,27 @@ def _snapshot_working_tree(dst: Path) -> Path: @pytest.fixture(scope="module") -def baked_project(tmp_path_factory: pytest.TempPathFactory) -> Path: - """GIVEN the template, render it once with the sample answers. +def template_source(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Snapshot the tracked working tree once for the whole module.""" + return _snapshot_working_tree(tmp_path_factory.mktemp("template-src")) + + +@pytest.fixture(params=FEATURE_FLAG_MATRIX) +def baked_project( + request: pytest.FixtureRequest, + template_source: Path, + tmp_path_factory: pytest.TempPathFactory, +) -> Path: + """GIVEN the template, render it for each feature-flag combination. `unsafe=True` is required so Copier executes the `_tasks` entry (`uv lock`). """ - src = _snapshot_working_tree(tmp_path_factory.mktemp("template-src")) + include_user_example: bool = request.param dst = tmp_path_factory.mktemp("baked") copier.run_copy( - str(src), + str(template_source), str(dst), - data=SAMPLE_ANSWERS, + data={**BASE_ANSWERS, "include_user_example": include_user_example}, defaults=True, unsafe=True, quiet=True, @@ -104,6 +122,30 @@ def test_no_template_package_references(baked_project: Path) -> None: assert not offenders, f"stale template imports: {offenders}" +@pytest.mark.parametrize("include_user_example", FEATURE_FLAG_MATRIX) +def test_user_slice_presence_matches_flag( + template_source: Path, + tmp_path_factory: pytest.TempPathFactory, + include_user_example: bool, +) -> None: + """WHEN baked THEN the user example slice is present only when the flag is on.""" + dst = tmp_path_factory.mktemp("baked-flag") + copier.run_copy( + str(template_source), + str(dst), + data={**BASE_ANSWERS, "include_user_example": include_user_example}, + defaults=True, + unsafe=True, + quiet=True, + ) + user_model = dst / "src" / "demo_service" / "domain" / "models" / "user.py" + user_router = dst / "src" / "demo_service" / "entrypoint" / "users.py" + user_migration = dst / "migrations" / "versions" / "20260531_0001_create_users.py" + assert user_model.exists() is include_user_example + assert user_router.exists() is include_user_example + assert user_migration.exists() is include_user_example + + def test_baked_project_passes_quality_gate(baked_project: Path) -> None: """WHEN the baked project is synced THEN ruff, pyrefly and pytest all pass at 100%.""" sync = _run(["uv", "sync"], baked_project) @@ -112,6 +154,10 @@ def test_baked_project_passes_quality_gate(baked_project: Path) -> None: ruff = _run(["uv", "run", "ruff", "check", "."], baked_project) assert ruff.returncode == 0, f"ruff failed:\n{ruff.stdout}\n{ruff.stderr}" + # Mirror the generated project's `make lint`, which also enforces formatting. + ruff_format = _run(["uv", "run", "ruff", "format", "--check", "."], baked_project) + assert ruff_format.returncode == 0, f"ruff format failed:\n{ruff_format.stdout}\n{ruff_format.stderr}" + pyrefly = _run(["uv", "run", "pyrefly", "check"], baked_project) assert pyrefly.returncode == 0, f"pyrefly failed:\n{pyrefly.stdout}\n{pyrefly.stderr}"