Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 12 additions & 4 deletions app/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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]] = []

Expand All @@ -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()
Expand Down
53 changes: 21 additions & 32 deletions tests/use_cases/test_connection_loss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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],
Expand Down
Loading