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:
qzl
2026-02-28 12:01:57 +08:00
parent 0dfc52cbf7
commit 17551d662b
5 changed files with 1621 additions and 1 deletions
+220
View File
@@ -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
+1 -1
View File
@@ -24,7 +24,7 @@ class FriendRequestResponse(BaseModel):
sender: UserBasicInfo
recipient: UserBasicInfo
content: str | None
status: Literal["pending", "accepted", "rejected"]
status: Literal["pending", "accepted", "rejected", "canceled"]
created_at: datetime
+438
View File
@@ -1 +1,439 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from models.friendships import FriendshipStatus
from models.inbox_messages import InboxMessageStatus, InboxMessageType
from v1.friendships.repository import FriendshipRepository
from v1.friendships.schemas import (
FriendRequestCreate,
FriendRequestResponse,
FriendResponse,
)
from v1.users.repository import UserRepository
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from v1.friendships.schemas import UserBasicInfo
logger = get_logger("v1.friendships.service")
class FriendshipService(BaseService):
"""Friendship service handling friend requests and friends list.
Responsibilities:
- Authorization checks
- Validation (not self, not already friends, not blocked)
- Transaction boundary (commit/rollback)
- Converting ORM models to response schemas
"""
_repository: FriendshipRepository
_user_repository: UserRepository
_session: AsyncSession
def __init__(
self,
repository: FriendshipRepository,
user_repository: UserRepository,
session: AsyncSession,
current_user: CurrentUser | None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._user_repository = user_repository
self._session = session
async def send_request(self, request: FriendRequestCreate) -> FriendRequestResponse:
user_id = self.require_user_id()
target_user_id = request.target_user_id
if user_id == target_user_id:
raise HTTPException(
status_code=400, detail="Cannot send friend request to yourself"
)
existing = await self._repository.get_friendship_between_users(
user_id, target_user_id
)
if existing:
if existing.status == FriendshipStatus.ACCEPTED:
raise HTTPException(
status_code=400, detail="Already friends with this user"
)
if existing.status == FriendshipStatus.BLOCKED:
raise HTTPException(
status_code=400, detail="Cannot send friend request to blocked user"
)
try:
friendship, inbox = await self._repository.create_request(
user_id, target_user_id
)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
sender = await self._user_repository.get_by_user_id(user_id)
recipient = await self._user_repository.get_by_user_id(target_user_id)
return FriendRequestResponse(
id=friendship.id,
sender=self._build_user_basic_info(sender),
recipient=self._build_user_basic_info(recipient),
content=inbox.content,
status="pending",
created_at=friendship.created_at,
)
async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse:
user_id = self.require_user_id()
try:
friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
if friendship is None:
raise HTTPException(status_code=404, detail="Friend request not found")
recipient_id = (
friendship.user_low_id
if friendship.initiator_id == friendship.user_high_id
else friendship.user_high_id
)
if recipient_id != user_id:
raise HTTPException(
status_code=403, detail="Not authorized to accept this request"
)
if friendship.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=400, detail="Friend request is not pending")
inbox = await self._repository.get_pending_inbox_for_recipient(
user_id, friendship_id
)
if inbox is None:
raise HTTPException(status_code=404, detail="Inbox message not found")
friendship.status = FriendshipStatus.ACCEPTED
inbox.status = InboxMessageStatus.ACCEPTED
try:
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
sender_id = friendship.initiator_id
if sender_id is None:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
sender = await self._user_repository.get_by_user_id(sender_id)
recipient = await self._user_repository.get_by_user_id(user_id)
return FriendRequestResponse(
id=friendship.id,
sender=self._build_user_basic_info(sender),
recipient=self._build_user_basic_info(recipient),
content=inbox.content,
status="accepted",
created_at=friendship.created_at,
)
async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse:
user_id = self.require_user_id()
try:
friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
if friendship is None:
raise HTTPException(status_code=404, detail="Friend request not found")
recipient_id = (
friendship.user_low_id
if friendship.initiator_id == friendship.user_high_id
else friendship.user_high_id
)
if recipient_id != user_id:
raise HTTPException(
status_code=403, detail="Not authorized to decline this request"
)
if friendship.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=400, detail="Friend request is not pending")
inbox = await self._repository.get_pending_inbox_for_recipient(
user_id, friendship_id
)
friendship.status = FriendshipStatus.DECLINED
if inbox:
inbox.status = InboxMessageStatus.REJECTED
try:
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
sender_id = friendship.initiator_id
if sender_id is None:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
sender = await self._user_repository.get_by_user_id(sender_id)
recipient = await self._user_repository.get_by_user_id(user_id)
return FriendRequestResponse(
id=friendship.id,
sender=self._build_user_basic_info(sender),
recipient=self._build_user_basic_info(recipient),
content=inbox.content if inbox else None,
status="rejected",
created_at=friendship.created_at,
)
async def cancel_request(self, friendship_id: UUID) -> FriendRequestResponse:
user_id = self.require_user_id()
try:
friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
if friendship is None:
raise HTTPException(status_code=404, detail="Friend request not found")
if friendship.initiator_id != user_id:
raise HTTPException(
status_code=403, detail="Not authorized to cancel this request"
)
if friendship.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=400, detail="Friend request is not pending")
inbox = await self._repository.get_pending_inbox_for_recipient(
friendship.user_high_id, friendship_id
)
friendship.status = FriendshipStatus.CANCELED
if inbox:
inbox.status = InboxMessageStatus.DISMISSED
try:
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
sender = await self._user_repository.get_by_user_id(user_id)
recipient_id = friendship.user_high_id
if recipient_id is None:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
recipient = await self._user_repository.get_by_user_id(recipient_id)
return FriendRequestResponse(
id=friendship.id,
sender=self._build_user_basic_info(sender),
recipient=self._build_user_basic_info(recipient),
content=inbox.content if inbox else None,
status="canceled",
created_at=friendship.created_at,
)
async def get_inbox(self) -> list[FriendRequestResponse]:
user_id = self.require_user_id()
try:
inbox_messages = await self._repository.get_inbox_messages_for_user(
user_id, InboxMessageStatus.PENDING
)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
result: list[FriendRequestResponse] = []
for inbox in inbox_messages:
if inbox.message_type != InboxMessageType.FRIEND_REQUEST:
continue
friendship_id = inbox.friendship_id
if friendship_id is None:
continue
friendship = await self._repository.get_friendship_by_id(friendship_id)
if friendship is None or friendship.status != FriendshipStatus.PENDING:
continue
sender_id = inbox.sender_id
if sender_id is None:
continue
sender = await self._user_repository.get_by_user_id(sender_id)
recipient = await self._user_repository.get_by_user_id(user_id)
result.append(
FriendRequestResponse(
id=friendship.id,
sender=self._build_user_basic_info(sender),
recipient=self._build_user_basic_info(recipient),
content=inbox.content,
status="pending",
created_at=friendship.created_at,
)
)
return result
async def get_outgoing_requests(self) -> list[FriendRequestResponse]:
user_id = self.require_user_id()
try:
outgoing = await self._repository.get_outgoing_requests(user_id)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
result: list[FriendRequestResponse] = []
for friendship in outgoing:
recipient_id = (
friendship.user_low_id
if friendship.initiator_id == friendship.user_high_id
else friendship.user_high_id
)
sender = await self._user_repository.get_by_user_id(user_id)
recipient = await self._user_repository.get_by_user_id(recipient_id)
result.append(
FriendRequestResponse(
id=friendship.id,
sender=self._build_user_basic_info(sender),
recipient=self._build_user_basic_info(recipient),
content=None,
status="pending",
created_at=friendship.created_at,
)
)
return result
async def get_friends_list(self) -> list[FriendResponse]:
user_id = self.require_user_id()
try:
friendships = await self._repository.get_friends_list(user_id)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
result: list[FriendResponse] = []
for friendship in friendships:
friend_id = (
friendship.user_high_id
if friendship.user_low_id == user_id
else friendship.user_low_id
)
friend = await self._user_repository.get_by_user_id(friend_id)
result.append(
FriendResponse(
id=friendship.id,
friend=self._build_user_basic_info(friend),
status="active",
created_at=friendship.created_at,
accepted_at=friendship.updated_at,
)
)
return result
async def remove_friend(self, friend_id: UUID) -> FriendResponse:
user_id = self.require_user_id()
try:
friendship = await self._repository.get_friendship_between_users(
user_id, friend_id
)
except SQLAlchemyError:
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
if friendship is None:
raise HTTPException(status_code=404, detail="Friendship not found")
if friendship.status != FriendshipStatus.ACCEPTED:
raise HTTPException(
status_code=400, detail="Can only remove accepted friendships"
)
friendship.deleted_at = datetime.now(timezone.utc)
try:
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(
status_code=503, detail="Friendship service unavailable"
)
friend = await self._user_repository.get_by_user_id(friend_id)
return FriendResponse(
id=friendship.id,
friend=self._build_user_basic_info(friend),
status="active",
created_at=friendship.created_at,
accepted_at=friendship.updated_at,
)
def _build_user_basic_info(self, profile: Any) -> "UserBasicInfo":
from v1.friendships.schemas import UserBasicInfo
if profile is None:
return UserBasicInfo(id="", username="")
p = profile # type: ignore[assignment]
return UserBasicInfo(
id=str(p.id),
username=p.username,
avatar_url=p.avatar_url if hasattr(p, "avatar_url") else None,
)