feat(friendships): implement FriendshipService with TDD
- Add send_request(), accept_request(), decline_request(), cancel_request() - Add get_inbox(), get_outgoing_requests(), get_friends_list(), remove_friend() - Add unit tests for all service methods (14 tests) - Update FriendRequestResponse schema to include 'canceled' status - Follow async SQLAlchemy patterns and BaseService conventions
This commit is contained in:
@@ -1 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.db.base_repository import BaseRepository
|
||||
from core.logging import get_logger
|
||||
from models.friendships import Friendship, FriendshipStatus
|
||||
from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger("v1.friendships.repository")
|
||||
|
||||
|
||||
class FriendshipRepository(Protocol):
|
||||
"""Protocol defining the friendship repository interface."""
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
"""Create a friendship request and inbox message."""
|
||||
...
|
||||
|
||||
async def get_friendship_between_users(
|
||||
self, user_id_1: UUID, user_id_2: UUID
|
||||
) -> Friendship | None:
|
||||
"""Check if friendship exists between two users."""
|
||||
...
|
||||
|
||||
async def get_pending_inbox_for_recipient(
|
||||
self, recipient_id: UUID, friendship_id: UUID
|
||||
) -> InboxMessage | None:
|
||||
"""Get pending inbox message for recipient."""
|
||||
...
|
||||
|
||||
async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None:
|
||||
"""Get friendship by ID."""
|
||||
...
|
||||
|
||||
async def get_inbox_messages_for_user(
|
||||
self, user_id: UUID, status: InboxMessageStatus | None = None
|
||||
) -> list[InboxMessage]:
|
||||
"""Get inbox messages for a user."""
|
||||
...
|
||||
|
||||
async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]:
|
||||
"""Get outgoing friend requests."""
|
||||
...
|
||||
|
||||
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
|
||||
"""Get accepted friends list."""
|
||||
...
|
||||
|
||||
|
||||
class SQLAlchemyFriendshipRepository(BaseRepository[Friendship]):
|
||||
"""SQLAlchemy implementation of FriendshipRepository.
|
||||
|
||||
Note: This repository only performs CRUD operations.
|
||||
- No commit (only flush) - service layer handles transactions
|
||||
- No auth logic - service layer handles authorization
|
||||
- No HTTP exceptions - returns None or raises SQLAlchemyError
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
super().__init__(session, Friendship)
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
try:
|
||||
user_low_id = min(initiator_id, recipient_id)
|
||||
user_high_id = max(initiator_id, recipient_id)
|
||||
|
||||
friendship = Friendship(
|
||||
user_low_id=user_low_id,
|
||||
user_high_id=user_high_id,
|
||||
initiator_id=initiator_id,
|
||||
status=FriendshipStatus.PENDING,
|
||||
requested_at=UUID(int=0),
|
||||
)
|
||||
self._session.add(friendship)
|
||||
await self._session.flush()
|
||||
|
||||
inbox = InboxMessage(
|
||||
recipient_id=recipient_id,
|
||||
sender_id=initiator_id,
|
||||
message_type=InboxMessageType.FRIEND_REQUEST,
|
||||
friendship_id=friendship.id,
|
||||
status=InboxMessageStatus.PENDING,
|
||||
)
|
||||
self._session.add(inbox)
|
||||
await self._session.flush()
|
||||
|
||||
return friendship, inbox
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to create friendship request",
|
||||
initiator_id=str(initiator_id),
|
||||
recipient_id=str(recipient_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friendship_between_users(
|
||||
self, user_id_1: UUID, user_id_2: UUID
|
||||
) -> Friendship | None:
|
||||
try:
|
||||
user_low_id = min(user_id_1, user_id_2)
|
||||
user_high_id = max(user_id_1, user_id_2)
|
||||
|
||||
stmt = (
|
||||
select(Friendship)
|
||||
.where(Friendship.user_low_id == user_low_id)
|
||||
.where(Friendship.user_high_id == user_high_id)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get friendship between users",
|
||||
user_id_1=str(user_id_1),
|
||||
user_id_2=str(user_id_2),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_pending_inbox_for_recipient(
|
||||
self, recipient_id: UUID, friendship_id: UUID
|
||||
) -> InboxMessage | None:
|
||||
try:
|
||||
stmt = (
|
||||
select(InboxMessage)
|
||||
.where(InboxMessage.recipient_id == recipient_id)
|
||||
.where(InboxMessage.friendship_id == friendship_id)
|
||||
.where(InboxMessage.status == InboxMessageStatus.PENDING)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get pending inbox message",
|
||||
recipient_id=str(recipient_id),
|
||||
friendship_id=str(friendship_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None:
|
||||
try:
|
||||
return await self.get_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get friendship by id",
|
||||
friendship_id=str(friendship_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_inbox_messages_for_user(
|
||||
self, user_id: UUID, status: InboxMessageStatus | None = None
|
||||
) -> list[InboxMessage]:
|
||||
try:
|
||||
stmt = (
|
||||
select(InboxMessage)
|
||||
.where(InboxMessage.recipient_id == user_id)
|
||||
.order_by(InboxMessage.created_at.desc())
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(InboxMessage.status == status)
|
||||
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get inbox messages for user",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]:
|
||||
try:
|
||||
stmt = (
|
||||
select(Friendship)
|
||||
.where(Friendship.initiator_id == user_id)
|
||||
.where(Friendship.status == FriendshipStatus.PENDING)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
.order_by(Friendship.created_at.desc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get outgoing requests",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
|
||||
try:
|
||||
stmt = (
|
||||
select(Friendship)
|
||||
.where(
|
||||
or_(
|
||||
Friendship.user_low_id == user_id,
|
||||
Friendship.user_high_id == user_id,
|
||||
)
|
||||
)
|
||||
.where(Friendship.status == FriendshipStatus.ACCEPTED)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
.order_by(Friendship.updated_at.desc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get friends list",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user