diff --git a/CONTEXT.md b/CONTEXT.md index d613601..07d6d91 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,6 +22,12 @@ A chat between exactly two users, identified by `direct_key`, the canonical makes opening one twice an upsert instead of a read-then-race. _Avoid_: DM, 1:1 +**Actor**: +The authenticated caller of a request: a user id proved by the JWT, carrying no +other user data and not implying the row still exists. Distinct from **Member**, +which is per-chat authorization for that actor. +_Avoid_: principal, current user, authenticated user + **Member**: The `(chat_id, user_id)` row granting access to a chat, plus that user's read marker. Necessary for every read or write on a chat; not sufficient for editing diff --git a/app/actor.py b/app/actor.py new file mode 100644 index 0000000..a52e747 --- /dev/null +++ b/app/actor.py @@ -0,0 +1,6 @@ +import dataclasses + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class Actor: + id: int diff --git a/app/api/auth.py b/app/api/auth.py index 31631ab..f13e3c3 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -2,37 +2,26 @@ import typing import litestar -import modern_di_litestar from litestar.config.app import AppConfig from litestar.connection import ASGIConnection from litestar.plugins import InitPlugin from litestar.security.jwt import JWTCookieAuth, Token -from app import ioc -from app.database import resources as database_resources -from app.database import tables +from app.actor import Actor from app.settings import settings -type AuthedRequest = litestar.Request[tables.UsersTable, Token, typing.Any] +type AuthedRequest = litestar.Request[Actor, Token, typing.Any] -async def retrieve_user_handler(token: Token, connection: ASGIConnection) -> tables.UsersTable | None: - # Auth middleware runs before request-scoped DI exists, so this opens its own session. +async def retrieve_user_handler(token: Token, _connection: ASGIConnection) -> Actor | None: try: - user_id = int(token.sub) + return Actor(id=int(token.sub)) except ValueError: return None - di_container: typing.Final = modern_di_litestar.fetch_di_container(connection.app) - engine: typing.Final = di_container.resolve_provider(ioc.Database.database_engine) - session: typing.Final = database_resources.create_session(engine) - try: - return await session.get(tables.UsersTable, user_id) - finally: - await database_resources.close_session(session) -jwt_cookie_auth: typing.Final = JWTCookieAuth[tables.UsersTable]( +jwt_cookie_auth: typing.Final = JWTCookieAuth[Actor]( retrieve_user_handler=retrieve_user_handler, token_secret=settings.jwt_secret, default_token_expiration=datetime.timedelta(seconds=settings.jwt_lifetime_seconds), diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 08541eb..2131a9a 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -9,6 +9,7 @@ from app.api.auth import AuthedRequest, jwt_cookie_auth from app.schemas import api as schemas from app.use_cases.authenticate_user import AuthenticateUserUseCase +from app.use_cases.fetch_user import FetchUserUseCase from app.use_cases.register_user import RegisterUserUseCase @@ -48,8 +49,11 @@ async def logout() -> Response[None]: @litestar.get("/auth/me/") -async def me(request: AuthedRequest) -> schemas.User: - return schemas.User.model_validate(request.user) +async def me(request: AuthedRequest, fetch_user_use_case: NamedDependency[FetchUserUseCase]) -> schemas.User: + user: typing.Final = await fetch_user_use_case(actor=request.user) + if user is None: + raise NotAuthorizedException(detail="Invalid authentication credentials") + return schemas.User.model_validate(user) ROUTER: typing.Final = litestar.Router( diff --git a/app/ioc.py b/app/ioc.py index d46b4c1..41df3a1 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -16,6 +16,7 @@ from app.use_cases.fetch_chat import FetchChatUseCase from app.use_cases.fetch_chats import FetchChatsUseCase from app.use_cases.fetch_messages import FetchMessagesUseCase +from app.use_cases.fetch_user import FetchUserUseCase from app.use_cases.mark_read import MarkReadUseCase from app.use_cases.register_user import RegisterUserUseCase @@ -66,6 +67,7 @@ class UseCases(Group, scope=Scope.REQUEST): 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) mark_read_use_case = providers.Factory(creator=MarkReadUseCase) diff --git a/app/use_cases/create_chat.py b/app/use_cases/create_chat.py index 2435ac9..583f076 100644 --- a/app/use_cases/create_chat.py +++ b/app/use_cases/create_chat.py @@ -4,6 +4,7 @@ from advanced_alchemy.exceptions import DuplicateKeyError from db_retry import Transaction, postgres_retry +from app.actor import Actor from app.database import tables from app.exceptions import ValidationError from app.repositories.chat_members_repository import ChatMembersRepository @@ -21,7 +22,7 @@ class CreateChatUseCase: chat_members_repository: ChatMembersRepository @postgres_retry - async def __call__(self, *, actor: tables.UsersTable, data: CreateChatRequest) -> tuple[tables.ChatsTable, bool]: + async def __call__(self, *, actor: Actor, data: CreateChatRequest) -> tuple[tables.ChatsTable, bool]: member_ids: typing.Final = {actor.id, *data.member_ids} direct_key: str | None = None diff --git a/app/use_cases/create_message.py b/app/use_cases/create_message.py index 27acfc5..e58c489 100644 --- a/app/use_cases/create_message.py +++ b/app/use_cases/create_message.py @@ -4,6 +4,7 @@ from advanced_alchemy.exceptions import DuplicateKeyError from db_retry import Transaction, postgres_retry +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError from app.repositories.chat_members_repository import ChatMembersRepository @@ -21,7 +22,7 @@ class CreateMessageUseCase: @postgres_retry async def __call__( - self, *, actor: tables.UsersTable, chat_id: int, data: SendMessageRequest + self, *, actor: Actor, chat_id: int, data: SendMessageRequest ) -> tuple[tables.MessagesTable, bool]: if not await self.chat_members_repository.is_member(chat_id, actor.id): msg = "Not a member of this chat" diff --git a/app/use_cases/delete_message.py b/app/use_cases/delete_message.py index 32b98eb..782de58 100644 --- a/app/use_cases/delete_message.py +++ b/app/use_cases/delete_message.py @@ -3,6 +3,7 @@ from db_retry import Transaction, postgres_retry +from app.actor import Actor from app.database import tables from app.repositories.chat_members_repository import ChatMembersRepository from app.repositories.chats_repository import ChatsRepository @@ -18,7 +19,7 @@ class DeleteMessageUseCase: chats_repository: ChatsRepository @postgres_retry - async def __call__(self, *, actor: tables.UsersTable, message_id: int) -> None: + async def __call__(self, *, actor: Actor, message_id: int) -> None: async with self.transaction: message = await fetch_message_for_author( messages_repository=self.messages_repository, diff --git a/app/use_cases/edit_message.py b/app/use_cases/edit_message.py index 21bf332..b4ff357 100644 --- a/app/use_cases/edit_message.py +++ b/app/use_cases/edit_message.py @@ -3,6 +3,7 @@ from db_retry import Transaction, postgres_retry +from app.actor import Actor from app.database import tables from app.exceptions import ConflictError from app.repositories.chat_members_repository import ChatMembersRepository @@ -18,9 +19,7 @@ class EditMessageUseCase: chat_members_repository: ChatMembersRepository @postgres_retry - async def __call__( - self, *, actor: tables.UsersTable, message_id: int, data: EditMessageRequest - ) -> tables.MessagesTable: + async def __call__(self, *, actor: Actor, message_id: int, data: EditMessageRequest) -> tables.MessagesTable: async with self.transaction: message = await fetch_message_for_author( messages_repository=self.messages_repository, diff --git a/app/use_cases/fetch_chat.py b/app/use_cases/fetch_chat.py index db5c170..6e3c049 100644 --- a/app/use_cases/fetch_chat.py +++ b/app/use_cases/fetch_chat.py @@ -2,6 +2,7 @@ from db_retry import postgres_retry +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError from app.repositories.chat_members_repository import ChatMembersRepository @@ -14,7 +15,7 @@ class FetchChatUseCase: chat_members_repository: ChatMembersRepository @postgres_retry - async def __call__(self, *, actor: tables.UsersTable, chat_id: int) -> tables.ChatsTable: + async def __call__(self, *, actor: Actor, chat_id: int) -> tables.ChatsTable: if not await self.chat_members_repository.is_member(chat_id, actor.id): msg = "Not a member of this chat" raise PermissionDeniedError(msg) diff --git a/app/use_cases/fetch_chats.py b/app/use_cases/fetch_chats.py index e8ae31e..913d8c2 100644 --- a/app/use_cases/fetch_chats.py +++ b/app/use_cases/fetch_chats.py @@ -3,6 +3,7 @@ from db_retry import postgres_retry +from app.actor import Actor from app.database import tables from app.repositories.chats_repository import ChatsRepository @@ -12,5 +13,5 @@ class FetchChatsUseCase: chats_repository: ChatsRepository @postgres_retry - async def __call__(self, *, actor: tables.UsersTable) -> Sequence[tables.ChatsTable]: + async def __call__(self, *, actor: Actor) -> Sequence[tables.ChatsTable]: return await self.chats_repository.list_for_user(actor.id) diff --git a/app/use_cases/fetch_messages.py b/app/use_cases/fetch_messages.py index e5dfedc..750912e 100644 --- a/app/use_cases/fetch_messages.py +++ b/app/use_cases/fetch_messages.py @@ -4,6 +4,7 @@ from db_retry import postgres_retry +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError, ValidationError from app.repositories.chat_members_repository import ChatMembersRepository @@ -22,7 +23,7 @@ class FetchMessagesUseCase: async def __call__( self, *, - actor: tables.UsersTable, + actor: Actor, chat_id: int, before_id: int | None = None, after_id: int | None = None, diff --git a/app/use_cases/fetch_user.py b/app/use_cases/fetch_user.py new file mode 100644 index 0000000..6be2ee9 --- /dev/null +++ b/app/use_cases/fetch_user.py @@ -0,0 +1,16 @@ +import dataclasses + +from db_retry import postgres_retry + +from app.actor import Actor +from app.database import tables +from app.repositories.users_repository import UsersRepository + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class FetchUserUseCase: + users_repository: UsersRepository + + @postgres_retry + async def __call__(self, *, actor: Actor) -> tables.UsersTable | None: + return await self.users_repository.get_one_or_none(id=actor.id) diff --git a/app/use_cases/mark_read.py b/app/use_cases/mark_read.py index d7d87f1..12bdb90 100644 --- a/app/use_cases/mark_read.py +++ b/app/use_cases/mark_read.py @@ -3,6 +3,7 @@ from db_retry import Transaction, postgres_retry +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError, ValidationError from app.repositories.chat_members_repository import ChatMembersRepository @@ -17,9 +18,7 @@ class MarkReadUseCase: messages_repository: MessagesRepository @postgres_retry - async def __call__( - self, *, actor: tables.UsersTable, chat_id: int, data: MarkReadRequest - ) -> tables.ChatMembersTable: + async def __call__(self, *, actor: Actor, chat_id: int, data: MarkReadRequest) -> tables.ChatMembersTable: member: typing.Final = await self.chat_members_repository.fetch_member(chat_id, actor.id) if member is None: msg = "Not a member of this chat" diff --git a/app/use_cases/message_authorization.py b/app/use_cases/message_authorization.py index 5039d45..7a0244e 100644 --- a/app/use_cases/message_authorization.py +++ b/app/use_cases/message_authorization.py @@ -1,5 +1,6 @@ import typing +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError from app.repositories.chat_members_repository import ChatMembersRepository @@ -10,7 +11,7 @@ async def fetch_message_for_author( *, messages_repository: MessagesRepository, chat_members_repository: ChatMembersRepository, - actor: tables.UsersTable, + actor: Actor, message_id: int, action: str, ) -> tables.MessagesTable: diff --git a/docs/adr/0002-cookie-auth-not-bearer.md b/docs/adr/0002-cookie-auth-not-bearer.md index e0ad5d5..5a231f4 100644 --- a/docs/adr/0002-cookie-auth-not-bearer.md +++ b/docs/adr/0002-cookie-auth-not-bearer.md @@ -1,6 +1,6 @@ # Cookie auth, not a bearer header -**Decision:** The JWT travels in a cookie (`JWTCookieAuth[UsersTable]`), not in +**Decision:** The JWT travels in a cookie (`JWTCookieAuth[Actor]`), not in an `Authorization: Bearer` header. ## Context diff --git a/docs/adr/0005-domain-error-vocabulary.md b/docs/adr/0005-domain-error-vocabulary.md index 258ac0a..70d7cf9 100644 --- a/docs/adr/0005-domain-error-vocabulary.md +++ b/docs/adr/0005-domain-error-vocabulary.md @@ -28,8 +28,9 @@ Login failure is the mirror of that mistake, and it is why the login handler raises Litestar's own `NotAuthorizedException` instead of `PermissionDeniedError`: a bad credential is an *identification* failure, not an authorization decision about an already-identified actor — at that point -there is no actor yet to authorize. It is the one place `litestar.exceptions` -is used deliberately. +there is no actor yet to authorize. `GET /api/auth/me/` raises it for the same +reason, on a token whose user row is gone. Those two are the only deliberate +uses of `litestar.exceptions`. ## Consequence diff --git a/docs/adr/0014-auth-carries-an-actor-id.md b/docs/adr/0014-auth-carries-an-actor-id.md new file mode 100644 index 0000000..0f410e5 --- /dev/null +++ b/docs/adr/0014-auth-carries-an-actor-id.md @@ -0,0 +1,57 @@ +# Authentication carries an actor id, not a user row + +**Decision:** `retrieve_user_handler` resolves an `Actor` — a frozen dataclass +holding the `id` proved by the JWT — from `token.sub` alone, reading no row. + +Auth middleware runs before request-scoped DI exists, so anything it loads must +come from a session it opens and closes itself. Loading the user row there cost +a second DB session per authenticated request against `db_pool_size=5` / +`db_max_overflow=0`, and handed every use case a detached ORM instance from a +closed session — safe only because every column happened to be loaded and +nobody mutated it. Against that, all ten reads of `actor` across +`app/use_cases/` were `actor.id`. `Actor` lives in `app/actor.py`, a top-level +module, so `app/use_cases/` never imports `app.api`. + +## Rejected: loading the user row in middleware + +The shape this replaces. Its one real benefit was that authentication proved +the user still existed; see Consequence for why that is not worth a session. +`GET /api/auth/me/` is the only consumer of the full row and now fetches it +through `FetchUserUseCase` on the request-scoped session, like every other +read. Reintroducing the lookup is invisible in API responses either way, which +is why the claim is pinned by +`test_retrieve_user_handler_resolves_an_actor_without_reading_the_database` +rather than by an endpoint test. + +## Rejected: UUID or uuid7 user ids + +Raised as the alternative to passing a bare integer around. There is one +writer, so there is no id-coordination problem to solve. `direct_key` +(`String(64)`) no longer fits two ids; three FK columns widen 8→16 bytes, one +of them indexed (`chat_members.user_id`); and `schemas.User.id` and +`member_ids` become a breaking API change. uuid7 is also the wrong tool for the +benefit usually wanted here — it publishes registration time in its first 48 +bits. [`0001-sequence-ids-not-snowflakes.md`](0001-sequence-ids-not-snowflakes.md) +already carries the ordering argument for message ids. + +## Rejected: an opaque public id + +Deferred rather than refused. If opacity is ever wanted, the shape is the +two-id pattern — the BigInt PK stays internal, a separate opaque public id +faces outward — which is strictly additive on top of this decision. Adopting it +now would buy nothing and cost a second identifier to keep in agreement. + +## Consequence + +Authentication no longer proves the user exists. A token whose row is gone +authenticates: reads come back empty, writes hit the `messages.user_id` foreign +key. Nothing can reach that state today — there is no delete-user or +disable-user path — and the accepted cost is recorded in the invariant test's +docstring, not as a deferred item. + +## Revisit trigger + +A delete-user or disable-user path being added. It meets the same problem as +[`../../planning/deferred/2026-08-21-logout-does-not-revoke-jwt.md`](../../planning/deferred/2026-08-21-logout-does-not-revoke-jwt.md) +— a credential outliving what it names — and both should be solved once, +together. diff --git a/planning/deferred/2026-08-21-two-sessions-per-authenticated-request.md b/planning/deferred/2026-08-21-two-sessions-per-authenticated-request.md deleted file mode 100644 index 0a49abd..0000000 --- a/planning/deferred/2026-08-21-two-sessions-per-authenticated-request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -summary: Auth middleware runs before request-scoped DI is available, so `retrieve_user_handler` opens its own session for the user lookup, separate from the request-scoped session used later. ---- - -# Every authenticated request opens two DB sessions - -## Why it is open - -Auth middleware runs before request-scoped DI is available, so -`retrieve_user_handler` (`app/api/auth.py`) opens its own short-lived session -for the user lookup, separate from the request-scoped session the resolved -use case's repositories use. That's two sessions per authenticated request -against `db_pool_size=5` / `db_max_overflow=0`. - -## Revisit trigger - -Before deploying this anywhere with real concurrent traffic — pool -exhaustion under load is the first thing to check if requests start timing -out waiting for a connection. diff --git a/tests/api/test_auth_api.py b/tests/api/test_auth_api.py index b341bd2..51b86d2 100644 --- a/tests/api/test_auth_api.py +++ b/tests/api/test_auth_api.py @@ -1,9 +1,13 @@ +import datetime + import pytest import sqlalchemy as sa from httpx import AsyncClient +from litestar.security.jwt import Token from sqlalchemy.ext.asyncio import AsyncSession -from app.api.auth import jwt_cookie_auth +from app.actor import Actor +from app.api.auth import jwt_cookie_auth, retrieve_user_handler from app.database import tables @@ -122,3 +126,20 @@ async def test_me_rejects_token_for_a_user_that_no_longer_exists(client: AsyncCl client.cookies.set(jwt_cookie_auth.key, token) response = await client.get("/api/auth/me/") assert response.status_code == 401 + + +async def test_retrieve_user_handler_resolves_an_actor_without_reading_the_database() -> None: + """INVARIANT: authentication resolves an Actor from the token alone, taking no database read. + + Broken by fetching the DI container off the connection to load the user row again: that is a + second session per authenticated request against a pool of five, and it hands every use case a + detached ORM instance. Responses are identical either way, so nothing else would catch it; the + connection passed here is None precisely so any such access fails. + + The cost this accepts: authentication no longer proves the user row exists. A token that + outlives its user still authenticates - reads come back empty, writes hit the messages.user_id + foreign key. Nothing can reach that state today; a delete-user path would have to solve it + alongside logout not revoking the JWT. + """ + token = Token(sub="42", exp=datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(minutes=5)) + assert await retrieve_user_handler(token, None) == Actor(id=42) # ty: ignore[invalid-argument-type] diff --git a/tests/use_cases/conftest.py b/tests/use_cases/conftest.py index 3b53b26..9ce6d4c 100644 --- a/tests/use_cases/conftest.py +++ b/tests/use_cases/conftest.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app import ioc, security +from app.actor import Actor from app.database import tables from app.schemas import api as schemas from app.use_cases.create_chat import CreateChatUseCase @@ -27,7 +28,7 @@ async def request_container( expose(ioc.Repositories, ioc.UseCases, container_fixture="request_container") -async def _make_user(session: AsyncSession, username: str) -> tables.UsersTable: +async def _make_actor(session: AsyncSession, username: str) -> Actor: user: typing.Final = UserFactory.build( username=username, password_hash=security.hash_password("hunter2hunter2"), @@ -35,28 +36,26 @@ async def _make_user(session: AsyncSession, username: str) -> tables.UsersTable: ) session.add(user) await session.flush() - return user + return Actor(id=user.id) @pytest.fixture -async def alice(db_session: AsyncSession) -> tables.UsersTable: - return await _make_user(db_session, "alice") +async def alice(db_session: AsyncSession) -> Actor: + return await _make_actor(db_session, "alice") @pytest.fixture -async def bob(db_session: AsyncSession) -> tables.UsersTable: - return await _make_user(db_session, "bob") +async def bob(db_session: AsyncSession) -> Actor: + return await _make_actor(db_session, "bob") @pytest.fixture -async def carol(db_session: AsyncSession) -> tables.UsersTable: - return await _make_user(db_session, "carol") +async def carol(db_session: AsyncSession) -> Actor: + return await _make_actor(db_session, "carol") @pytest.fixture -async def direct_chat( - create_chat_use_case: CreateChatUseCase, alice: tables.UsersTable, bob: tables.UsersTable -) -> tables.ChatsTable: +async def direct_chat(create_chat_use_case: CreateChatUseCase, alice: Actor, bob: Actor) -> tables.ChatsTable: chat, _ = await create_chat_use_case( actor=alice, data=schemas.CreateChatRequest(chat_type=tables.ChatType.DIRECT, member_ids=[bob.id]) ) @@ -65,7 +64,7 @@ async def direct_chat( @pytest.fixture async def alice_message( - create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> tables.MessagesTable: message, _ = await create_message_use_case( actor=alice, chat_id=direct_chat.id, data=schemas.SendMessageRequest(idempotency_key=uuid.uuid4(), text="hello") @@ -76,10 +75,10 @@ async def alice_message( @pytest.fixture def send( create_message_use_case: CreateMessageUseCase, -) -> typing.Callable[[tables.UsersTable, int, str], typing.Awaitable[tuple[tables.MessagesTable, bool]]]: +) -> typing.Callable[[Actor, int, str], typing.Awaitable[tuple[tables.MessagesTable, bool]]]: """Send a message with a fresh idempotency key per call, so callers never collide on retries.""" - async def _send(actor: tables.UsersTable, chat_id: int, text: str) -> tuple[tables.MessagesTable, bool]: + async def _send(actor: Actor, chat_id: int, text: str) -> tuple[tables.MessagesTable, bool]: return await create_message_use_case( actor=actor, chat_id=chat_id, data=schemas.SendMessageRequest(idempotency_key=uuid.uuid4(), text=text) ) diff --git a/tests/use_cases/test_create_chat.py b/tests/use_cases/test_create_chat.py index c26cd07..0f15412 100644 --- a/tests/use_cases/test_create_chat.py +++ b/tests/use_cases/test_create_chat.py @@ -1,6 +1,7 @@ import pytest from advanced_alchemy.exceptions import DuplicateKeyError +from app.actor import Actor from app.database import tables from app.exceptions import ValidationError from app.repositories.chats_repository import ChatsRepository @@ -59,7 +60,7 @@ async def create(self, *_args: object, **_kwargs: object) -> tables.ChatsTable: async def test_direct_chat_is_created_with_both_members( - create_chat_use_case: CreateChatUseCase, alice: tables.UsersTable, bob: tables.UsersTable + create_chat_use_case: CreateChatUseCase, alice: Actor, bob: Actor ) -> None: chat, created = await create_chat_use_case( actor=alice, data=schemas.CreateChatRequest(chat_type=tables.ChatType.DIRECT, member_ids=[bob.id]) @@ -71,7 +72,7 @@ async def test_direct_chat_is_created_with_both_members( async def test_direct_chat_is_idempotent_for_the_same_pair( - create_chat_use_case: CreateChatUseCase, alice: tables.UsersTable, bob: tables.UsersTable + create_chat_use_case: CreateChatUseCase, alice: Actor, bob: Actor ) -> None: first, first_created = await create_chat_use_case( actor=alice, data=schemas.CreateChatRequest(chat_type=tables.ChatType.DIRECT, member_ids=[bob.id]) @@ -86,7 +87,7 @@ async def test_direct_chat_is_idempotent_for_the_same_pair( async def test_direct_chat_creation_recovers_from_a_concurrent_duplicate_key( - create_chat_use_case: CreateChatUseCase, alice: tables.UsersTable, bob: tables.UsersTable + create_chat_use_case: CreateChatUseCase, alice: Actor, bob: Actor ) -> None: """INVARIANT: a direct-chat insert losing the uq_chats_direct_key race recovers the winner's row. @@ -117,7 +118,7 @@ async def test_direct_chat_creation_recovers_from_a_concurrent_duplicate_key( async def test_direct_chat_recovery_raises_if_the_winners_row_is_unreadable( - create_chat_use_case: CreateChatUseCase, alice: tables.UsersTable, bob: tables.UsersTable + create_chat_use_case: CreateChatUseCase, alice: Actor, bob: Actor ) -> None: broken = CreateChatUseCase( transaction=create_chat_use_case.transaction, @@ -131,7 +132,7 @@ async def test_direct_chat_recovery_raises_if_the_winners_row_is_unreadable( async def test_group_chat_reraises_an_unexpected_duplicate_key( - create_chat_use_case: CreateChatUseCase, alice: tables.UsersTable, bob: tables.UsersTable + create_chat_use_case: CreateChatUseCase, alice: Actor, bob: Actor ) -> None: broken = CreateChatUseCase( transaction=create_chat_use_case.transaction, @@ -146,9 +147,9 @@ async def test_group_chat_reraises_an_unexpected_duplicate_key( async def test_direct_chat_rejects_more_than_two_members( create_chat_use_case: CreateChatUseCase, - alice: tables.UsersTable, - bob: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + bob: Actor, + carol: Actor, ) -> None: with pytest.raises(ValidationError): await create_chat_use_case( @@ -158,9 +159,9 @@ async def test_direct_chat_rejects_more_than_two_members( async def test_group_chat_includes_the_creator( create_chat_use_case: CreateChatUseCase, - alice: tables.UsersTable, - bob: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + bob: Actor, + carol: Actor, ) -> None: chat, created = await create_chat_use_case( actor=alice, diff --git a/tests/use_cases/test_create_message.py b/tests/use_cases/test_create_message.py index 68ef8d3..d52bb85 100644 --- a/tests/use_cases/test_create_message.py +++ b/tests/use_cases/test_create_message.py @@ -3,6 +3,7 @@ import pytest from advanced_alchemy.exceptions import DuplicateKeyError +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError from app.repositories.chats_repository import ChatsRepository @@ -56,7 +57,7 @@ async def create(self, *_args: object, **_kwargs: object) -> tables.MessagesTabl async def test_send_returns_created_true_on_first_call( - create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> None: message, created = await create_message_use_case( actor=alice, chat_id=direct_chat.id, data=schemas.SendMessageRequest(idempotency_key=uuid.uuid4(), text="hi") @@ -66,7 +67,7 @@ async def test_send_returns_created_true_on_first_call( async def test_repeated_idempotency_key_returns_the_same_message( - create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> None: key = uuid.uuid4() first, first_created = await create_message_use_case( @@ -85,7 +86,7 @@ async def test_send_updates_chat_last_message_id( create_message_use_case: CreateMessageUseCase, chats_repository: ChatsRepository, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: message, _ = await create_message_use_case( actor=alice, chat_id=direct_chat.id, data=schemas.SendMessageRequest(idempotency_key=uuid.uuid4(), text="hi") @@ -95,7 +96,7 @@ async def test_send_updates_chat_last_message_id( async def test_non_member_cannot_send( - create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, carol: tables.UsersTable + create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, carol: Actor ) -> None: with pytest.raises(PermissionDeniedError): await create_message_use_case( @@ -106,7 +107,7 @@ async def test_non_member_cannot_send( async def test_concurrent_duplicate_key_recovers_the_winners_message( - create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> None: """INVARIANT: a message insert losing the (chat_id, idempotency_key) race returns the winner's row. @@ -138,7 +139,7 @@ async def test_concurrent_duplicate_key_recovers_the_winners_message( async def test_send_recovery_raises_if_the_winners_row_is_unreadable( - create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + create_message_use_case: CreateMessageUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> None: broken = CreateMessageUseCase( transaction=create_message_use_case.transaction, @@ -160,8 +161,8 @@ async def test_same_idempotency_key_in_two_different_chats_creates_two_messages( create_message_use_case: CreateMessageUseCase, create_chat_use_case: CreateChatUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + carol: Actor, ) -> None: other_chat, _ = await create_chat_use_case( actor=alice, data=schemas.CreateChatRequest(chat_type=tables.ChatType.DIRECT, member_ids=[carol.id]) diff --git a/tests/use_cases/test_edit_message.py b/tests/use_cases/test_edit_message.py index e74fa55..09ddae1 100644 --- a/tests/use_cases/test_edit_message.py +++ b/tests/use_cases/test_edit_message.py @@ -2,6 +2,7 @@ import pytest +from app.actor import Actor from app.database import tables from app.exceptions import ConflictError, PermissionDeniedError from app.repositories.chat_members_repository import ChatMembersRepository @@ -14,7 +15,7 @@ async def test_author_can_edit( - edit_message_use_case: EditMessageUseCase, alice_message: tables.MessagesTable, alice: tables.UsersTable + edit_message_use_case: EditMessageUseCase, alice_message: tables.MessagesTable, alice: Actor ) -> None: edited = await edit_message_use_case( actor=alice, message_id=alice_message.id, data=schemas.EditMessageRequest(text="fixed") @@ -24,7 +25,7 @@ async def test_author_can_edit( async def test_other_member_cannot_edit( - edit_message_use_case: EditMessageUseCase, alice_message: tables.MessagesTable, bob: tables.UsersTable + edit_message_use_case: EditMessageUseCase, alice_message: tables.MessagesTable, bob: Actor ) -> None: with pytest.raises(PermissionDeniedError): await edit_message_use_case( @@ -33,7 +34,7 @@ async def test_other_member_cannot_edit( async def _remove_alice_from_chat( - chat_members_repository: ChatMembersRepository, alice_message: tables.MessagesTable, alice: tables.UsersTable + chat_members_repository: ChatMembersRepository, alice_message: tables.MessagesTable, alice: Actor ) -> None: membership = await chat_members_repository.get_one(chat_id=alice_message.chat_id, user_id=alice.id) await chat_members_repository.delete(item_id=membership.id) @@ -43,7 +44,7 @@ async def test_author_without_membership_cannot_edit( edit_message_use_case: EditMessageUseCase, chat_members_repository: ChatMembersRepository, alice_message: tables.MessagesTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: await _remove_alice_from_chat(chat_members_repository, alice_message, alice) with pytest.raises(PermissionDeniedError): @@ -53,7 +54,7 @@ async def test_author_without_membership_cannot_edit( async def test_non_member_cannot_edit( - edit_message_use_case: EditMessageUseCase, alice_message: tables.MessagesTable, carol: tables.UsersTable + edit_message_use_case: EditMessageUseCase, alice_message: tables.MessagesTable, carol: Actor ) -> None: with pytest.raises(PermissionDeniedError): await edit_message_use_case( @@ -65,7 +66,7 @@ async def test_editing_a_deleted_message_raises_conflict( edit_message_use_case: EditMessageUseCase, delete_message_use_case: DeleteMessageUseCase, alice_message: tables.MessagesTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: await delete_message_use_case(actor=alice, message_id=alice_message.id) with pytest.raises(ConflictError): @@ -78,7 +79,7 @@ async def test_author_can_delete( delete_message_use_case: DeleteMessageUseCase, messages_repository: MessagesRepository, alice_message: tables.MessagesTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: await delete_message_use_case(actor=alice, message_id=alice_message.id) stored = await messages_repository.get_one(id=alice_message.id) @@ -86,7 +87,7 @@ async def test_author_can_delete( async def test_other_member_cannot_delete( - delete_message_use_case: DeleteMessageUseCase, alice_message: tables.MessagesTable, bob: tables.UsersTable + delete_message_use_case: DeleteMessageUseCase, alice_message: tables.MessagesTable, bob: Actor ) -> None: with pytest.raises(PermissionDeniedError): await delete_message_use_case(actor=bob, message_id=alice_message.id) @@ -96,7 +97,7 @@ async def test_author_without_membership_cannot_delete( delete_message_use_case: DeleteMessageUseCase, chat_members_repository: ChatMembersRepository, alice_message: tables.MessagesTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: await _remove_alice_from_chat(chat_members_repository, alice_message, alice) with pytest.raises(PermissionDeniedError): @@ -104,7 +105,7 @@ async def test_author_without_membership_cannot_delete( async def test_non_member_cannot_delete( - delete_message_use_case: DeleteMessageUseCase, alice_message: tables.MessagesTable, carol: tables.UsersTable + delete_message_use_case: DeleteMessageUseCase, alice_message: tables.MessagesTable, carol: Actor ) -> None: with pytest.raises(PermissionDeniedError): await delete_message_use_case(actor=carol, message_id=alice_message.id) @@ -114,7 +115,7 @@ async def test_deleting_an_already_deleted_message_is_idempotent( delete_message_use_case: DeleteMessageUseCase, messages_repository: MessagesRepository, alice_message: tables.MessagesTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: await delete_message_use_case(actor=alice, message_id=alice_message.id) first_deleted_at = (await messages_repository.get_one(id=alice_message.id)).deleted_at @@ -130,7 +131,7 @@ async def test_deleted_message_disappears_from_listing( fetch_messages_use_case: FetchMessagesUseCase, create_message_use_case: CreateMessageUseCase, alice_message: tables.MessagesTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: other, _ = await create_message_use_case( actor=alice, diff --git a/tests/use_cases/test_unread_counts.py b/tests/use_cases/test_unread_counts.py index 702679c..05f8590 100644 --- a/tests/use_cases/test_unread_counts.py +++ b/tests/use_cases/test_unread_counts.py @@ -3,6 +3,7 @@ import pytest +from app.actor import Actor from app.database import tables from app.exceptions import PermissionDeniedError, ValidationError from app.repositories.messages_repository import MessagesRepository @@ -13,14 +14,14 @@ from app.use_cases.mark_read import MarkReadUseCase -SendFixture = typing.Callable[[tables.UsersTable, int, str], typing.Awaitable[tuple[tables.MessagesTable, bool]]] +SendFixture = typing.Callable[[Actor, int, str], typing.Awaitable[tuple[tables.MessagesTable, bool]]] async def test_unread_counts_messages_from_others( fetch_chats_use_case: FetchChatsUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - bob: tables.UsersTable, + alice: Actor, + bob: Actor, send: SendFixture, ) -> None: await send(bob, direct_chat.id, "one") @@ -32,7 +33,7 @@ async def test_unread_counts_messages_from_others( async def test_own_messages_are_never_unread( fetch_chats_use_case: FetchChatsUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, + alice: Actor, send: SendFixture, ) -> None: await send(alice, direct_chat.id, "mine") @@ -44,7 +45,7 @@ async def test_system_messages_count_as_unread( fetch_chats_use_case: FetchChatsUseCase, messages_repository: MessagesRepository, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, + alice: Actor, ) -> None: await messages_repository.create( tables.MessagesTable(chat_id=direct_chat.id, user_id=None, idempotency_key=uuid.uuid4(), text="Bob joined") @@ -57,8 +58,8 @@ async def test_marking_read_clears_the_count( fetch_chats_use_case: FetchChatsUseCase, mark_read_use_case: MarkReadUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - bob: tables.UsersTable, + alice: Actor, + bob: Actor, send: SendFixture, ) -> None: message, _ = await send(bob, direct_chat.id, "one") @@ -73,8 +74,8 @@ async def test_deleted_messages_are_not_unread( fetch_chats_use_case: FetchChatsUseCase, delete_message_use_case: DeleteMessageUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - bob: tables.UsersTable, + alice: Actor, + bob: Actor, send: SendFixture, ) -> None: message, _ = await send(bob, direct_chat.id, "one") @@ -84,7 +85,7 @@ async def test_deleted_messages_are_not_unread( async def test_chat_with_no_messages_has_no_last_message( - fetch_chats_use_case: FetchChatsUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + fetch_chats_use_case: FetchChatsUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> None: chats = await fetch_chats_use_case(actor=alice) assert chats[0].id == direct_chat.id @@ -96,8 +97,8 @@ async def test_listing_orders_most_recently_active_chat_first( fetch_chats_use_case: FetchChatsUseCase, create_chat_use_case: CreateChatUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + carol: Actor, send: SendFixture, ) -> None: other_chat, _ = await create_chat_use_case( @@ -112,9 +113,9 @@ async def test_unread_counts_differ_per_chat( fetch_chats_use_case: FetchChatsUseCase, create_chat_use_case: CreateChatUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - bob: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + bob: Actor, + carol: Actor, send: SendFixture, ) -> None: other_chat, _ = await create_chat_use_case( @@ -131,7 +132,7 @@ async def test_unread_counts_differ_per_chat( async def test_non_member_cannot_mark_read( - mark_read_use_case: MarkReadUseCase, direct_chat: tables.ChatsTable, carol: tables.UsersTable + mark_read_use_case: MarkReadUseCase, direct_chat: tables.ChatsTable, carol: Actor ) -> None: with pytest.raises(PermissionDeniedError): await mark_read_use_case( @@ -143,8 +144,8 @@ async def test_marking_read_with_a_message_from_another_chat_is_rejected( mark_read_use_case: MarkReadUseCase, create_chat_use_case: CreateChatUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + carol: Actor, send: SendFixture, ) -> None: other_chat, _ = await create_chat_use_case( @@ -158,7 +159,7 @@ async def test_marking_read_with_a_message_from_another_chat_is_rejected( async def test_marking_read_rejects_an_unknown_message_id( - mark_read_use_case: MarkReadUseCase, direct_chat: tables.ChatsTable, alice: tables.UsersTable + mark_read_use_case: MarkReadUseCase, direct_chat: tables.ChatsTable, alice: Actor ) -> None: with pytest.raises(ValidationError): await mark_read_use_case( @@ -170,8 +171,8 @@ async def test_marking_read_is_monotonic( fetch_chats_use_case: FetchChatsUseCase, mark_read_use_case: MarkReadUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - bob: tables.UsersTable, + alice: Actor, + bob: Actor, send: SendFixture, ) -> None: first, _ = await send(bob, direct_chat.id, "one") @@ -194,8 +195,8 @@ async def test_deleting_the_newest_message_updates_preview_and_ordering( delete_message_use_case: DeleteMessageUseCase, create_chat_use_case: CreateChatUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, - carol: tables.UsersTable, + alice: Actor, + carol: Actor, send: SendFixture, ) -> None: other_chat, _ = await create_chat_use_case( @@ -220,7 +221,7 @@ async def test_deleting_a_non_newest_message_leaves_preview_and_ordering_unchang fetch_chats_use_case: FetchChatsUseCase, delete_message_use_case: DeleteMessageUseCase, direct_chat: tables.ChatsTable, - alice: tables.UsersTable, + alice: Actor, send: SendFixture, ) -> None: first, _ = await send(alice, direct_chat.id, "first")