diff --git a/AGENTS.md b/AGENTS.md index 1494c77..3ca94e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,8 +109,11 @@ a session, a transaction or a repository directly. Provider scopes: - `Database.database_session` — request-scoped, finalizer closes the session. - `Database.transaction` — request-scoped `db_retry.Transaction`, the object every use case wraps its work in. -- `Database.autocommit_transaction` — the same, opened at `AUTOCOMMIT` so the - token revocation check sends no BEGIN or COMMIT. `rolled_back_db_session` +- `Database.autocommit_transaction` — the same, opened at `AUTOCOMMIT` so a + use case that sends exactly one read statement (login, `GET /auth/me/`, the + chat list and detail, the token revocation check) sends no BEGIN or COMMIT. + Reads of two or more statements keep `Database.transaction`, so they see + one snapshot. `rolled_back_db_session` overrides `Database.autocommit_isolation_level` to `None`, because switching the shared test connection would roll back its outer transaction. - `Repositories.*` — request-scoped, `auto_commit=False` (see above). diff --git a/app/ioc.py b/app/ioc.py index 08892aa..7153a4c 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -75,15 +75,23 @@ class Repositories(Group, scope=Scope.REQUEST): class UseCases(Group, scope=Scope.REQUEST): register_user_use_case = providers.Factory(creator=RegisterUserUseCase) - authenticate_user_use_case = providers.Factory(creator=AuthenticateUserUseCase) + authenticate_user_use_case = providers.Factory( + creator=AuthenticateUserUseCase, kwargs={"transaction": Database.autocommit_transaction} + ) create_chat_use_case = providers.Factory(creator=CreateChatUseCase) - fetch_chat_use_case = providers.Factory(creator=FetchChatUseCase) + fetch_chat_use_case = providers.Factory( + creator=FetchChatUseCase, kwargs={"transaction": Database.autocommit_transaction} + ) create_message_use_case = providers.Factory(creator=CreateMessageUseCase) fetch_messages_use_case = providers.Factory(creator=FetchMessagesUseCase) edit_message_use_case = providers.Factory(creator=EditMessageUseCase) delete_message_use_case = providers.Factory(creator=DeleteMessageUseCase) - fetch_chats_use_case = providers.Factory(creator=FetchChatsUseCase) - fetch_user_use_case = providers.Factory(creator=FetchUserUseCase) + fetch_chats_use_case = providers.Factory( + creator=FetchChatsUseCase, kwargs={"transaction": Database.autocommit_transaction} + ) + fetch_user_use_case = providers.Factory( + creator=FetchUserUseCase, kwargs={"transaction": Database.autocommit_transaction} + ) mark_read_use_case = providers.Factory(creator=MarkReadUseCase) revoke_token_use_case = providers.Factory(creator=RevokeTokenUseCase) check_token_revoked_use_case = providers.Factory( diff --git a/tests/use_cases/test_check_token_revoked.py b/tests/use_cases/test_autocommit_reads.py similarity index 50% rename from tests/use_cases/test_check_token_revoked.py rename to tests/use_cases/test_autocommit_reads.py index 8cbad15..538d56b 100644 --- a/tests/use_cases/test_check_token_revoked.py +++ b/tests/use_cases/test_autocommit_reads.py @@ -1,14 +1,35 @@ +import contextlib import typing import modern_di +import pytest import sqlalchemy as sa from sqlalchemy import event from app import ioc +from app.actor import Actor from app.database.resources import create_database_engine +from app.exceptions import PermissionDeniedError -async def test_revocation_check_sends_no_begin_or_commit(di_container: modern_di.Container) -> None: +UNKNOWN_ACTOR: typing.Final = Actor(id=-1) + + +@pytest.mark.parametrize( + ("provider", "kwargs"), + [ + (ioc.UseCases.check_token_revoked_use_case, {"jti": "unknown"}), + (ioc.UseCases.authenticate_user_use_case, {"username": "nobody", "password": "hunter2hunter2"}), + (ioc.UseCases.fetch_user_use_case, {"actor": UNKNOWN_ACTOR}), + (ioc.UseCases.fetch_chats_use_case, {"actor": UNKNOWN_ACTOR}), + (ioc.UseCases.fetch_chat_use_case, {"actor": UNKNOWN_ACTOR, "chat_id": -1}), + ], +) +async def test_single_statement_read_sends_no_begin_or_commit( + di_container: modern_di.Container, + provider: modern_di.providers.Factory[typing.Any], + kwargs: dict[str, typing.Any], +) -> None: engine: typing.Final = create_database_engine() in_transaction: typing.Final[list[bool]] = [] @@ -21,8 +42,9 @@ def record(connection: sa.Connection, *_: object) -> None: di_container.override(ioc.Database.database_engine, engine) try: async with di_container.build_child_container(scope=modern_di.Scope.REQUEST) as container: - use_case: typing.Final = container.resolve_provider(ioc.UseCases.check_token_revoked_use_case) - assert await use_case(jti="unknown") is False + use_case: typing.Final = container.resolve_provider(provider) + with contextlib.suppress(PermissionDeniedError): + await use_case(**kwargs) finally: di_container.reset_override(ioc.Database.database_engine) await engine.dispose() diff --git a/tests/use_cases/test_connection_loss.py b/tests/use_cases/test_connection_loss.py index b493431..31c0e9a 100644 --- a/tests/use_cases/test_connection_loss.py +++ b/tests/use_cases/test_connection_loss.py @@ -71,32 +71,19 @@ async def lost_pooled_connection(request_container: modern_di.Container) -> None await _terminate_backend(engine, *watched) -_READ_CASES: typing.Final = [ - (ioc.UseCases.authenticate_user_use_case, {"username": "nobody", "password": "hunter2hunter2"}, None), - (ioc.UseCases.fetch_chats_use_case, {"actor": UNKNOWN_ACTOR}, []), - (ioc.UseCases.fetch_user_use_case, {"actor": UNKNOWN_ACTOR}, None), -] -READ_USE_CASES: typing.Final = pytest.mark.parametrize(("provider", "kwargs", "expected"), _READ_CASES) -READ_USE_CASES_WITH_REVOCATION_CHECK: typing.Final = pytest.mark.parametrize( +AUTOCOMMIT_READ_USE_CASES: typing.Final = pytest.mark.parametrize( ("provider", "kwargs", "expected"), - [*_READ_CASES, (ioc.UseCases.check_token_revoked_use_case, {"jti": "unknown"}, False)], + [ + (ioc.UseCases.authenticate_user_use_case, {"username": "nobody", "password": "hunter2hunter2"}, None), + (ioc.UseCases.fetch_chats_use_case, {"actor": UNKNOWN_ACTOR}, []), + (ioc.UseCases.fetch_user_use_case, {"actor": UNKNOWN_ACTOR}, None), + (ioc.UseCases.check_token_revoked_use_case, {"jti": "unknown"}, False), + ], ) -@pytest.mark.usefixtures("lost_connection") -@READ_USE_CASES -async def test_read_use_case_recovers_from_a_lost_connection( - request_container: modern_di.Container, - provider: modern_di.providers.Factory[typing.Any], - kwargs: dict[str, typing.Any], - expected: object, -) -> None: - use_case: typing.Final = request_container.resolve_provider(provider) - assert await use_case(**kwargs) == expected - - @pytest.mark.usefixtures("lost_pooled_connection") -@READ_USE_CASES_WITH_REVOCATION_CHECK +@AUTOCOMMIT_READ_USE_CASES async def test_read_use_case_recovers_from_a_connection_lost_while_pooled( request_container: modern_di.Container, provider: modern_di.providers.Factory[typing.Any], @@ -107,22 +94,24 @@ async def test_read_use_case_recovers_from_a_connection_lost_while_pooled( assert await use_case(**kwargs) == expected +_TRANSACTIONAL_MEMBERSHIP_GATED_CASES: typing.Final = [ + (ioc.UseCases.fetch_messages_use_case, {}), + ( + ioc.UseCases.create_message_use_case, + {"data": SendMessageRequest(idempotency_key=uuid.uuid4(), text="hello")}, + ), + (ioc.UseCases.mark_read_use_case, {"data": MarkReadRequest(last_read_message_id=1)}), +] +TRANSACTIONAL_MEMBERSHIP_GATED_USE_CASES: typing.Final = pytest.mark.parametrize( + ("provider", "kwargs"), _TRANSACTIONAL_MEMBERSHIP_GATED_CASES +) MEMBERSHIP_GATED_USE_CASES: typing.Final = pytest.mark.parametrize( - ("provider", "kwargs"), - [ - (ioc.UseCases.fetch_chat_use_case, {}), - (ioc.UseCases.fetch_messages_use_case, {}), - ( - ioc.UseCases.create_message_use_case, - {"data": SendMessageRequest(idempotency_key=uuid.uuid4(), text="hello")}, - ), - (ioc.UseCases.mark_read_use_case, {"data": MarkReadRequest(last_read_message_id=1)}), - ], + ("provider", "kwargs"), [(ioc.UseCases.fetch_chat_use_case, {}), *_TRANSACTIONAL_MEMBERSHIP_GATED_CASES] ) @pytest.mark.usefixtures("lost_connection") -@MEMBERSHIP_GATED_USE_CASES +@TRANSACTIONAL_MEMBERSHIP_GATED_USE_CASES async def test_membership_gated_use_case_recovers_from_a_lost_connection( request_container: modern_di.Container, provider: modern_di.providers.Factory[typing.Any],