Merge remote-tracking branch 'origin/feature/friendship' into dev

This commit is contained in:
qzl
2026-02-28 12:22:18 +08:00
12 changed files with 2339 additions and 1 deletions
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db import get_db
from v1.friendships.repository import SQLAlchemyFriendshipRepository
from v1.friendships.service import FriendshipService
from v1.users.dependencies import get_current_user
from v1.users.repository import SQLAlchemyUserRepository
async def get_friendship_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SQLAlchemyFriendshipRepository:
return SQLAlchemyFriendshipRepository(session)
async def get_friendship_service(
session: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> FriendshipService:
friendship_repository = SQLAlchemyFriendshipRepository(session)
user_repository = SQLAlchemyUserRepository(session)
return FriendshipService(
repository=friendship_repository,
user_repository=user_repository,
session=session,
current_user=current_user,
)
+221
View File
@@ -0,0 +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
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, status
from v1.friendships.dependencies import get_friendship_service
from v1.friendships.schemas import (
FriendRequestAction,
FriendRequestCreate,
FriendRequestResponse,
FriendResponse,
)
from v1.friendships.service import FriendshipService
router = APIRouter(prefix="/friends", tags=["friends"])
@router.post(
"/requests",
response_model=FriendRequestResponse,
status_code=status.HTTP_201_CREATED,
)
async def send_friend_request(
payload: FriendRequestCreate,
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
return await service.send_request(payload)
@router.get("/requests/inbox", response_model=list[FriendRequestResponse])
async def get_inbox(
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendRequestResponse]:
return await service.get_inbox()
@router.get("/requests/outgoing", response_model=list[FriendRequestResponse])
async def get_outgoing_requests(
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendRequestResponse]:
return await service.get_outgoing_requests()
@router.post(
"/requests/{friendship_id}/accept",
response_model=FriendRequestResponse,
)
async def accept_friend_request(
friendship_id: UUID,
_: FriendRequestAction,
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
return await service.accept_request(friendship_id)
@router.post(
"/requests/{friendship_id}/decline",
response_model=FriendRequestResponse,
)
async def decline_friend_request(
friendship_id: UUID,
_: FriendRequestAction,
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
return await service.decline_request(friendship_id)
@router.delete(
"/requests/{friendship_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def cancel_friend_request(
friendship_id: UUID,
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> None:
await service.cancel_request(friendship_id)
@router.get("", response_model=list[FriendResponse])
async def get_friends_list(
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendResponse]:
return await service.get_friends_list()
@router.delete(
"/{friendship_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_friend(
friendship_id: UUID,
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> None:
await service.remove_friend(friendship_id)
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from typing import ClassVar, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class UserBasicInfo(BaseModel):
id: str
username: str
avatar_url: str | None = None
class FriendRequestCreate(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
target_user_id: UUID
content: str | None = Field(None, max_length=200)
class FriendRequestResponse(BaseModel):
id: UUID
sender: UserBasicInfo
recipient: UserBasicInfo
content: str | None
status: Literal["pending", "accepted", "rejected", "canceled"]
created_at: datetime
class FriendResponse(BaseModel):
id: UUID
friend: UserBasicInfo
status: Literal["active"]
created_at: datetime
accepted_at: datetime | None
class FriendRequestAction(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
pass
+501
View File
@@ -0,0 +1,501 @@
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"
)
logger.info(
"friend_request_sent",
extra={"initiator_id": str(user_id), "target_id": str(target_user_id)},
)
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:
logger.warning(
"friend_request_accept_unauthorized",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship_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"
)
logger.info(
"friend_request_accepted",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship_id),
"initiator_id": str(sender_id),
},
)
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:
logger.warning(
"friend_request_decline_unauthorized",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship_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"
)
logger.info(
"friend_request_declined",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship_id),
"initiator_id": str(sender_id),
},
)
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:
logger.warning(
"friend_request_cancel_unauthorized",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship_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)
logger.info(
"friend_request_canceled",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship_id),
"target_id": str(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"
)
logger.info(
"friend_removed",
extra={
"actor_id": str(user_id),
"friendship_id": str(friendship.id),
"removed_friend_id": str(friend_id),
},
)
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,
)
+2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from core.http.models import HealthResponse
from v1.agent_chat.router import router as agent_chat_router
from v1.auth.router import router as auth_router
from v1.friendships.router import router as friendships_router
from v1.infra.router import router as infra_router
from v1.schedule_items.router import router as schedule_items_router
from v1.users.router import router as users_router
@@ -12,6 +13,7 @@ from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(friendships_router)
router.include_router(infra_router)
router.include_router(users_router)
router.include_router(agent_chat_router)
@@ -0,0 +1,364 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Callable
from uuid import UUID
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import app
from core.auth.models import CurrentUser
from v1.friendships.dependencies import get_friendship_service
from v1.friendships.schemas import (
FriendRequestCreate,
FriendRequestResponse,
FriendResponse,
UserBasicInfo,
)
from v1.friendships.service import FriendshipService
from v1.users.dependencies import get_current_user
def _raise_unauthorized() -> CurrentUser:
raise HTTPException(status_code=401, detail="Unauthorized")
class FakeFriendshipService(FriendshipService):
def __init__(self) -> None:
pass
async def send_request(self, request: FriendRequestCreate) -> FriendRequestResponse:
return FriendRequestResponse(
id=UUID("11111111-1111-1111-1111-111111111111"),
sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None),
recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None),
content=request.content,
status="pending",
created_at=datetime.now(timezone.utc),
)
async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse:
return FriendRequestResponse(
id=friendship_id,
sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None),
recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None),
content="Hello!",
status="accepted",
created_at=datetime.now(timezone.utc),
)
async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse:
return FriendRequestResponse(
id=friendship_id,
sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None),
recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None),
content="Hello!",
status="rejected",
created_at=datetime.now(timezone.utc),
)
async def cancel_request(self, friendship_id: UUID) -> FriendRequestResponse:
return FriendRequestResponse(
id=friendship_id,
sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None),
recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None),
content="Hello!",
status="canceled",
created_at=datetime.now(timezone.utc),
)
async def get_inbox(self) -> list[FriendRequestResponse]:
return [
FriendRequestResponse(
id=UUID("11111111-1111-1111-1111-111111111111"),
sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None),
recipient=UserBasicInfo(
id="user-2", username="recipient", avatar_url=None
),
content="Hello!",
status="pending",
created_at=datetime.now(timezone.utc),
)
]
async def get_outgoing_requests(self) -> list[FriendRequestResponse]:
return [
FriendRequestResponse(
id=UUID("22222222-2222-2222-2222-222222222222"),
sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None),
recipient=UserBasicInfo(
id="user-3", username="target", avatar_url=None
),
content=None,
status="pending",
created_at=datetime.now(timezone.utc),
)
]
async def get_friends_list(self) -> list[FriendResponse]:
return [
FriendResponse(
id=UUID("33333333-3333-3333-3333-333333333333"),
friend=UserBasicInfo(id="user-2", username="friend", avatar_url=None),
status="active",
created_at=datetime.now(timezone.utc),
accepted_at=datetime.now(timezone.utc),
)
]
async def remove_friend(self, friend_id: UUID) -> FriendResponse:
return FriendResponse(
id=UUID("33333333-3333-3333-3333-333333333333"),
friend=UserBasicInfo(id=str(friend_id), username="friend", avatar_url=None),
status="active",
created_at=datetime.now(timezone.utc),
accepted_at=datetime.now(timezone.utc),
)
def _override_friendship_service(
service: FriendshipService,
) -> Callable[[], FriendshipService]:
def _get_service() -> FriendshipService:
return service
return _get_service
def _get_fake_current_user() -> CurrentUser:
return CurrentUser(
id=UUID("00000000-0000-0000-0000-000000000001"),
email="test@example.com",
)
class TestSendFriendRequest:
def test_send_friend_request_returns_201(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.post(
"/api/v1/friends/requests",
json={
"target_user_id": "22222222-2222-2222-2222-222222222222",
"content": "Hello!",
},
)
assert response.status_code == 201
body = response.json()
assert body["status"] == "pending"
assert body["sender"]["username"] == "sender"
assert body["recipient"]["username"] == "recipient"
finally:
app.dependency_overrides = {}
def test_send_friend_request_missing_target_returns_422(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.post("/api/v1/friends/requests", json={})
assert response.status_code == 422
assert response.headers["content-type"].startswith(
"application/problem+json"
)
finally:
app.dependency_overrides = {}
def test_send_friend_request_invalid_uuid_returns_422(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.post(
"/api/v1/friends/requests",
json={"target_user_id": "invalid-uuid"},
)
assert response.status_code == 422
assert response.headers["content-type"].startswith(
"application/problem+json"
)
finally:
app.dependency_overrides = {}
class TestGetInbox:
def test_get_inbox_returns_200(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.get("/api/v1/friends/requests/inbox")
assert response.status_code == 200
body = response.json()
assert isinstance(body, list)
assert len(body) == 1
assert body[0]["status"] == "pending"
finally:
app.dependency_overrides = {}
class TestGetOutgoingRequests:
def test_get_outgoing_requests_returns_200(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.get("/api/v1/friends/requests/outgoing")
assert response.status_code == 200
body = response.json()
assert isinstance(body, list)
assert len(body) == 1
assert body[0]["status"] == "pending"
finally:
app.dependency_overrides = {}
class TestAcceptFriendRequest:
def test_accept_friend_request_returns_200(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.post(
"/api/v1/friends/requests/11111111-1111-1111-1111-111111111111/accept",
json={},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "accepted"
finally:
app.dependency_overrides = {}
class TestDeclineFriendRequest:
def test_decline_friend_request_returns_200(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.post(
"/api/v1/friends/requests/11111111-1111-1111-1111-111111111111/decline",
json={},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "rejected"
finally:
app.dependency_overrides = {}
class TestCancelFriendRequest:
def test_cancel_friend_request_returns_204(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.delete(
"/api/v1/friends/requests/11111111-1111-1111-1111-111111111111"
)
assert response.status_code == 204
assert response.content == b""
finally:
app.dependency_overrides = {}
class TestGetFriendsList:
def test_get_friends_list_returns_200(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.get("/api/v1/friends")
assert response.status_code == 200
body = response.json()
assert isinstance(body, list)
assert len(body) == 1
assert body[0]["status"] == "active"
assert body[0]["friend"]["username"] == "friend"
finally:
app.dependency_overrides = {}
class TestRemoveFriend:
def test_remove_friend_returns_204(self) -> None:
app.dependency_overrides[get_friendship_service] = _override_friendship_service(
FakeFriendshipService()
)
app.dependency_overrides[get_current_user] = _get_fake_current_user
client = TestClient(app)
try:
response = client.delete(
"/api/v1/friends/33333333-3333-3333-3333-333333333333"
)
assert response.status_code == 204
assert response.content == b""
finally:
app.dependency_overrides = {}
class TestFriendshipRequiresAuth:
def test_send_request_without_auth_returns_401(self) -> None:
app.dependency_overrides[get_current_user] = _raise_unauthorized
client = TestClient(app)
try:
response = client.post(
"/api/v1/friends/requests",
json={
"target_user_id": "22222222-2222-2222-2222-222222222222",
},
)
assert response.status_code == 401
finally:
app.dependency_overrides = {}
def test_get_inbox_without_auth_returns_401(self) -> None:
app.dependency_overrides[get_current_user] = _raise_unauthorized
client = TestClient(app)
try:
response = client.get("/api/v1/friends/requests/inbox")
assert response.status_code == 401
finally:
app.dependency_overrides = {}
def test_get_friends_without_auth_returns_401(self) -> None:
app.dependency_overrides[get_current_user] = _raise_unauthorized
client = TestClient(app)
try:
response = client.get("/api/v1/friends")
assert response.status_code == 401
finally:
app.dependency_overrides = {}
@@ -0,0 +1,380 @@
from __future__ import annotations
import uuid
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType
class FakeFriendshipRepository:
"""Fake implementation for testing."""
def __init__(self) -> None:
self.friendships: dict[uuid.UUID, Friendship] = {}
self.inbox_messages: dict[uuid.UUID, InboxMessage] = {}
async def create_request(
self,
initiator_id: uuid.UUID,
recipient_id: uuid.UUID,
) -> tuple[Friendship, InboxMessage]:
raise NotImplementedError
async def get_friendship_between_users(
self, user_id_1: uuid.UUID, user_id_2: uuid.UUID
) -> Friendship | None:
raise NotImplementedError
async def get_pending_inbox_for_recipient(
self, recipient_id: uuid.UUID, friendship_id: uuid.UUID
) -> InboxMessage | None:
raise NotImplementedError
async def get_friendship_by_id(self, friendship_id: uuid.UUID) -> Friendship | None:
raise NotImplementedError
async def get_inbox_messages_for_user(
self, user_id: uuid.UUID, status: InboxMessageStatus | None = None
) -> list[InboxMessage]:
raise NotImplementedError
async def get_outgoing_requests(self, user_id: uuid.UUID) -> list[Friendship]:
raise NotImplementedError
async def get_friends_list(self, user_id: uuid.UUID) -> list[Friendship]:
raise NotImplementedError
class TestFriendshipRepository:
"""Tests for FriendshipRepository."""
@pytest.fixture
def mock_session(self) -> MagicMock:
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
session.add = MagicMock()
session.commit = AsyncMock()
return session
@pytest.fixture
def repository(self, mock_session: MagicMock) -> Any:
from v1.friendships.repository import SQLAlchemyFriendshipRepository
return SQLAlchemyFriendshipRepository(mock_session)
@pytest.mark.asyncio
async def test_create_request_creates_friendship_and_inbox(
self, repository: Any, mock_session: MagicMock
) -> None:
initiator_id = uuid.uuid4()
recipient_id = uuid.uuid4()
result_friendship = Friendship(
id=uuid.uuid4(),
user_low_id=min(initiator_id, recipient_id),
user_high_id=max(initiator_id, recipient_id),
initiator_id=initiator_id,
status=FriendshipStatus.PENDING,
requested_at=uuid.uuid4(),
)
result_inbox = InboxMessage(
id=uuid.uuid4(),
recipient_id=recipient_id,
sender_id=initiator_id,
message_type=InboxMessageType.FRIEND_REQUEST,
status=InboxMessageStatus.PENDING,
)
mock_execute = AsyncMock(
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None))
)
mock_session.execute = mock_execute
mock_session.add = MagicMock()
class MockExecuteResult:
def __init__(self, returning):
self._returning = returning
def scalar_one_or_none(self):
return self._returning
async def mock_execute_func(stmt):
if "INSERT INTO friendships" in str(stmt):
return MockExecuteResult(result_friendship)
elif "INSERT INTO inbox_messages" in str(stmt):
return MockExecuteResult(result_inbox)
return MockExecuteResult(None)
mock_session.execute = AsyncMock(side_effect=mock_execute_func)
friendship, inbox = await repository.create_request(initiator_id, recipient_id)
assert friendship is not None
assert inbox is not None
assert friendship.initiator_id == initiator_id
assert inbox.recipient_id == recipient_id
@pytest.mark.asyncio
async def test_get_friendship_between_users_returns_friendship(
self, repository: Any, mock_session: MagicMock
) -> None:
user_id_1 = uuid.uuid4()
user_id_2 = uuid.uuid4()
expected_friendship = Friendship(
id=uuid.uuid4(),
user_low_id=min(user_id_1, user_id_2),
user_high_id=max(user_id_1, user_id_2),
initiator_id=user_id_1,
status=FriendshipStatus.ACCEPTED,
)
class MockExecuteResult:
def scalar_one_or_none(self):
return expected_friendship
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_friendship_between_users(user_id_1, user_id_2)
assert result is not None
assert result.id == expected_friendship.id
@pytest.mark.asyncio
async def test_get_friendship_between_users_returns_none(
self, repository: Any, mock_session: MagicMock
) -> None:
user_id_1 = uuid.uuid4()
user_id_2 = uuid.uuid4()
class MockExecuteResult:
def scalar_one_or_none(self):
return None
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_friendship_between_users(user_id_1, user_id_2)
assert result is None
@pytest.mark.asyncio
async def test_get_pending_inbox_for_recipient_returns_inbox(
self, repository: Any, mock_session: MagicMock
) -> None:
recipient_id = uuid.uuid4()
friendship_id = uuid.uuid4()
expected_inbox = InboxMessage(
id=uuid.uuid4(),
recipient_id=recipient_id,
sender_id=uuid.uuid4(),
message_type=InboxMessageType.FRIEND_REQUEST,
friendship_id=friendship_id,
status=InboxMessageStatus.PENDING,
)
class MockExecuteResult:
def scalar_one_or_none(self):
return expected_inbox
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_pending_inbox_for_recipient(
recipient_id, friendship_id
)
assert result is not None
assert result.id == expected_inbox.id
@pytest.mark.asyncio
async def test_get_pending_inbox_for_recipient_returns_none(
self, repository: Any, mock_session: MagicMock
) -> None:
recipient_id = uuid.uuid4()
friendship_id = uuid.uuid4()
class MockExecuteResult:
def scalar_one_or_none(self):
return None
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_pending_inbox_for_recipient(
recipient_id, friendship_id
)
assert result is None
@pytest.mark.asyncio
async def test_get_friendship_by_id_returns_friendship(
self, repository: Any, mock_session: MagicMock
) -> None:
friendship_id = uuid.uuid4()
expected_friendship = Friendship(
id=friendship_id,
user_low_id=uuid.uuid4(),
user_high_id=uuid.uuid4(),
initiator_id=uuid.uuid4(),
status=FriendshipStatus.ACCEPTED,
)
class MockExecuteResult:
def scalar_one_or_none(self):
return expected_friendship
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_friendship_by_id(friendship_id)
assert result is not None
assert result.id == friendship_id
@pytest.mark.asyncio
async def test_get_friendship_by_id_returns_none(
self, repository: Any, mock_session: MagicMock
) -> None:
friendship_id = uuid.uuid4()
class MockExecuteResult:
def scalar_one_or_none(self):
return None
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_friendship_by_id(friendship_id)
assert result is None
@pytest.mark.asyncio
async def test_get_inbox_messages_for_user_returns_messages(
self, repository: Any, mock_session: MagicMock
) -> None:
user_id = uuid.uuid4()
expected_messages = [
InboxMessage(
id=uuid.uuid4(),
recipient_id=user_id,
sender_id=uuid.uuid4(),
message_type=InboxMessageType.FRIEND_REQUEST,
status=InboxMessageStatus.PENDING,
),
InboxMessage(
id=uuid.uuid4(),
recipient_id=user_id,
sender_id=uuid.uuid4(),
message_type=InboxMessageType.FRIEND_REQUEST,
status=InboxMessageStatus.ACCEPTED,
),
]
class MockScalars:
def all(self):
return expected_messages
class MockExecuteResult:
def scalars(self):
return MockScalars()
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_inbox_messages_for_user(user_id)
assert len(result) == 2
@pytest.mark.asyncio
async def test_get_inbox_messages_for_user_filters_by_status(
self, repository: Any, mock_session: MagicMock
) -> None:
user_id = uuid.uuid4()
expected_messages = [
InboxMessage(
id=uuid.uuid4(),
recipient_id=user_id,
sender_id=uuid.uuid4(),
message_type=InboxMessageType.FRIEND_REQUEST,
status=InboxMessageStatus.PENDING,
),
]
class MockScalars:
def all(self):
return expected_messages
class MockExecuteResult:
def scalars(self):
return MockScalars()
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_inbox_messages_for_user(
user_id, status=InboxMessageStatus.PENDING
)
assert len(result) == 1
assert result[0].status == InboxMessageStatus.PENDING
@pytest.mark.asyncio
async def test_get_outgoing_requests_returns_pending_requests(
self, repository: Any, mock_session: MagicMock
) -> None:
user_id = uuid.uuid4()
expected_requests = [
Friendship(
id=uuid.uuid4(),
user_low_id=user_id,
user_high_id=uuid.uuid4(),
initiator_id=user_id,
status=FriendshipStatus.PENDING,
),
]
class MockScalars:
def all(self):
return expected_requests
class MockExecuteResult:
def scalars(self):
return MockScalars()
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_outgoing_requests(user_id)
assert len(result) == 1
assert result[0].initiator_id == user_id
assert result[0].status == FriendshipStatus.PENDING
@pytest.mark.asyncio
async def test_get_friends_list_returns_accepted_friends(
self, repository: Any, mock_session: MagicMock
) -> None:
user_id = uuid.uuid4()
friend_id = uuid.uuid4()
expected_friends = [
Friendship(
id=uuid.uuid4(),
user_low_id=min(user_id, friend_id),
user_high_id=max(user_id, friend_id),
initiator_id=user_id,
status=FriendshipStatus.ACCEPTED,
accepted_at=uuid.uuid4(),
),
]
class MockScalars:
def all(self):
return expected_friends
class MockExecuteResult:
def scalars(self):
return MockScalars()
mock_session.execute = AsyncMock(return_value=MockExecuteResult())
result = await repository.get_friends_list(user_id)
assert len(result) == 1
assert result[0].status == FriendshipStatus.ACCEPTED
@@ -0,0 +1,582 @@
from __future__ import annotations
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from core.auth.models import CurrentUser
from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType
from v1.friendships.repository import FriendshipRepository
from v1.friendships.schemas import (
FriendRequestCreate,
)
from v1.friendships.service import FriendshipService
from v1.users.repository import UserRepository
def _create_mock_profile(
user_id: UUID = UUID("00000000-0000-0000-0000-000000000001"),
username: str = "testuser",
avatar_url: str | None = None,
) -> MagicMock:
"""Create a mock Profile ORM object."""
profile = MagicMock()
profile.id = user_id
profile.username = username
profile.avatar_url = avatar_url
profile.bio = None
return profile
class FakeFriendshipRepo:
"""Fake repository for testing that conforms to FriendshipRepository protocol."""
def __init__(
self,
friendships: list[Friendship] | None = None,
inbox_messages: list[InboxMessage] | None = None,
) -> None:
self._friendships = friendships or []
self._inbox_messages = inbox_messages or []
async def create_request(
self, initiator_id: UUID, recipient_id: UUID
) -> tuple[Friendship, InboxMessage]:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = initiator_id
friendship.user_low_id = min(initiator_id, recipient_id)
friendship.user_high_id = max(initiator_id, recipient_id)
friendship.status = FriendshipStatus.PENDING
friendship.created_at = datetime.utcnow()
self._friendships.append(friendship)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = recipient_id
inbox.sender_id = initiator_id
inbox.status = InboxMessageStatus.PENDING
inbox.message_type = InboxMessageType.FRIEND_REQUEST
inbox.friendship_id = friendship.id
inbox.content = None
self._inbox_messages.append(inbox)
return friendship, inbox
async def get_friendship_between_users(
self, user_id_1: UUID, user_id_2: UUID
) -> Friendship | None:
for f in self._friendships:
user_low = min(user_id_1, user_id_2)
user_high = max(user_id_1, user_id_2)
if f.user_low_id == user_low and f.user_high_id == user_high:
return f
return None
async def get_pending_inbox_for_recipient(
self, recipient_id: UUID, friendship_id: UUID
) -> InboxMessage | None:
for msg in self._inbox_messages:
if msg.recipient_id == recipient_id and msg.friendship_id == friendship_id:
return msg
return None
async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None:
for f in self._friendships:
if f.id == friendship_id:
return f
return None
async def get_inbox_messages_for_user(
self, user_id: UUID, status: InboxMessageStatus | None = None
) -> list[InboxMessage]:
result = [msg for msg in self._inbox_messages if msg.recipient_id == user_id]
if status:
result = [msg for msg in result if msg.status == status]
return result
async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]:
return [
f
for f in self._friendships
if f.initiator_id == user_id and f.status == FriendshipStatus.PENDING
]
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
return [
f
for f in self._friendships
if f.status == FriendshipStatus.ACCEPTED
and (f.user_low_id == user_id or f.user_high_id == user_id)
]
class FakeUserRepo:
"""Fake user repository for testing."""
def __init__(self, profiles: dict[UUID, MagicMock] | None = None) -> None:
self._profiles = profiles or {}
async def get_by_user_id(self, user_id: UUID) -> MagicMock | None:
return self._profiles.get(user_id)
async def get_by_username(self, username: str) -> MagicMock | None:
for profile in self._profiles.values():
if profile.username == username:
return profile
return None
_repo_check: FriendshipRepository = FakeFriendshipRepo()
_user_repo_check: UserRepository = FakeUserRepo()
USER_A = UUID("00000000-0000-0000-0000-000000000001")
USER_B = UUID("00000000-0000-0000-0000-000000000002")
USER_C = UUID("00000000-0000-0000-0000-000000000003")
@pytest.fixture
def mock_session() -> AsyncMock:
session = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
return session
@pytest.fixture
def current_user() -> CurrentUser:
return CurrentUser(id=USER_A)
@pytest.fixture
def mock_friendship_repo() -> FakeFriendshipRepo:
return FakeFriendshipRepo()
@pytest.fixture
def mock_user_repo() -> FakeUserRepo:
return FakeUserRepo(
{
USER_A: _create_mock_profile(USER_A, "user_a"),
USER_B: _create_mock_profile(USER_B, "user_b"),
USER_C: _create_mock_profile(USER_C, "user_c"),
}
)
class TestSendRequest:
@pytest.mark.asyncio
async def test_send_request_creates_friendship_and_inbox(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.send_request(FriendRequestCreate(target_user_id=USER_B))
assert result is not None
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_request_to_self_raises_400(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
with pytest.raises(HTTPException) as exc_info:
await service.send_request(
FriendRequestCreate(target_user_id=current_user.id)
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_send_request_to_existing_friend_raises_400(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
existing_friendship = MagicMock(spec=Friendship)
existing_friendship.id = uuid4()
existing_friendship.user_low_id = min(USER_A, USER_B)
existing_friendship.user_high_id = max(USER_A, USER_B)
existing_friendship.status = FriendshipStatus.ACCEPTED
mock_friendship_repo._friendships.append(existing_friendship)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
with pytest.raises(HTTPException) as exc_info:
await service.send_request(FriendRequestCreate(target_user_id=USER_B))
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_send_request_when_blocked_raises_400(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
blocked_friendship = MagicMock(spec=Friendship)
blocked_friendship.id = uuid4()
blocked_friendship.user_low_id = min(USER_A, USER_B)
blocked_friendship.user_high_id = max(USER_A, USER_B)
blocked_friendship.status = FriendshipStatus.BLOCKED
mock_friendship_repo._friendships.append(blocked_friendship)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
with pytest.raises(HTTPException) as exc_info:
await service.send_request(FriendRequestCreate(target_user_id=USER_B))
assert exc_info.value.status_code == 400
class TestAcceptRequest:
@pytest.mark.asyncio
async def test_accept_request_updates_status(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = USER_B
friendship.user_low_id = min(USER_A, USER_B)
friendship.user_high_id = max(USER_A, USER_B)
friendship.status = FriendshipStatus.PENDING
mock_friendship_repo._friendships.append(friendship)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = USER_A # current_user is the recipient
inbox.content = None
inbox.status = InboxMessageStatus.PENDING
inbox.friendship_id = friendship.id
mock_friendship_repo._inbox_messages.append(inbox)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.accept_request(friendship.id)
assert result.status == "accepted"
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_accept_nonexistent_request_raises_404(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
with pytest.raises(HTTPException) as exc_info:
await service.accept_request(uuid4())
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_accept_request_not_recipient_raises_403(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = USER_A # current user is the initiator
friendship.user_low_id = min(USER_A, USER_B)
friendship.user_high_id = max(USER_A, USER_B)
friendship.status = FriendshipStatus.PENDING
mock_friendship_repo._friendships.append(friendship)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = USER_B # the other user is the recipient
inbox.content = None
inbox.status = InboxMessageStatus.PENDING
inbox.friendship_id = friendship.id
mock_friendship_repo._inbox_messages.append(inbox)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
with pytest.raises(HTTPException) as exc_info:
await service.accept_request(friendship.id)
assert exc_info.value.status_code == 403
class TestDeclineRequest:
@pytest.mark.asyncio
async def test_decline_request_updates_status(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = USER_B
friendship.user_low_id = min(USER_A, USER_B)
friendship.user_high_id = max(USER_A, USER_B)
friendship.status = FriendshipStatus.PENDING
mock_friendship_repo._friendships.append(friendship)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = USER_A
inbox.content = None
inbox.status = InboxMessageStatus.PENDING
inbox.friendship_id = friendship.id
mock_friendship_repo._inbox_messages.append(inbox)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.decline_request(friendship.id)
assert result.status == "rejected"
mock_session.commit.assert_awaited_once()
class TestCancelRequest:
@pytest.mark.asyncio
async def test_cancel_request_removes_request(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = current_user.id
friendship.user_low_id = min(USER_A, USER_B)
friendship.user_high_id = max(USER_A, USER_B)
friendship.status = FriendshipStatus.PENDING
mock_friendship_repo._friendships.append(friendship)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = USER_B # The other user
inbox.content = None
inbox.status = InboxMessageStatus.PENDING
inbox.friendship_id = friendship.id
mock_friendship_repo._inbox_messages.append(inbox)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.cancel_request(friendship.id)
assert result.status == "canceled"
mock_session.commit.assert_awaited_once()
class TestGetInbox:
@pytest.mark.asyncio
async def test_get_inbox_returns_pending_messages(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = USER_B
friendship.status = FriendshipStatus.PENDING
mock_friendship_repo._friendships.append(friendship)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = current_user.id
inbox.sender_id = USER_B
inbox.content = None
inbox.status = InboxMessageStatus.PENDING
inbox.message_type = InboxMessageType.FRIEND_REQUEST
inbox.friendship_id = friendship.id
inbox.created_at = datetime.utcnow()
mock_friendship_repo._inbox_messages.append(inbox)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.get_inbox()
assert len(result) == 1
class TestGetOutgoingRequests:
@pytest.mark.asyncio
async def test_get_outgoing_requests_returns_sent_requests(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.initiator_id = current_user.id
friendship.status = FriendshipStatus.PENDING
mock_friendship_repo._friendships.append(friendship)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.get_outgoing_requests()
assert len(result) == 1
class TestGetFriendsList:
@pytest.mark.asyncio
async def test_get_friends_list_returns_accepted_friends(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.user_low_id = current_user.id
friendship.user_high_id = USER_B
friendship.status = FriendshipStatus.ACCEPTED
friendship.created_at = datetime.utcnow()
friendship.updated_at = datetime.utcnow()
mock_friendship_repo._friendships.append(friendship)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.get_friends_list()
assert len(result) == 1
class TestRemoveFriend:
@pytest.mark.asyncio
async def test_remove_friend_soft_deletes(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
friendship.user_low_id = min(current_user.id, USER_B)
friendship.user_high_id = max(current_user.id, USER_B)
friendship.status = FriendshipStatus.ACCEPTED
friendship.created_at = datetime.utcnow()
friendship.updated_at = datetime.utcnow()
mock_friendship_repo._friendships.append(friendship)
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
result = await service.remove_friend(USER_B)
assert result.status == "active"
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_remove_nonexistent_friend_raises_404(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
with pytest.raises(HTTPException) as exc_info:
await service.remove_friend(uuid4())
assert exc_info.value.status_code == 404
@@ -0,0 +1,116 @@
from __future__ import annotations
import pytest
from datetime import datetime
from uuid import uuid4
from v1.friendships.schemas import (
UserBasicInfo,
FriendRequestCreate,
FriendRequestResponse,
FriendResponse,
FriendRequestAction,
)
def test_user_basic_info_maps_fields() -> None:
user = UserBasicInfo(id="user-1", username="alice", avatar_url=None)
assert user.id == "user-1"
assert user.username == "alice"
assert user.avatar_url is None
def test_user_basic_info_with_avatar() -> None:
user = UserBasicInfo(
id="user-2", username="bob", avatar_url="https://example.com/avatar.png"
)
assert user.avatar_url == "https://example.com/avatar.png"
def test_friend_request_create_valid() -> None:
target_id = uuid4()
request = FriendRequestCreate(
target_user_id=target_id, content="Hi, let's be friends!"
)
assert request.target_user_id == target_id
assert request.content == "Hi, let's be friends!"
def test_friend_request_create_without_content() -> None:
target_id = uuid4()
request = FriendRequestCreate(target_user_id=target_id, content=None)
assert request.target_user_id == target_id
assert request.content is None
def test_friend_request_create_content_max_length() -> None:
target_id = uuid4()
with pytest.raises(Exception):
FriendRequestCreate(target_user_id=target_id, content="x" * 201)
def test_friend_request_response_maps_fields() -> None:
sender = UserBasicInfo(id="user-1", username="alice", avatar_url=None)
recipient = UserBasicInfo(id="user-2", username="bob", avatar_url=None)
request_id = uuid4()
created = datetime(2026, 1, 15, 10, 30, 0)
response = FriendRequestResponse(
id=request_id,
sender=sender,
recipient=recipient,
content="Hello!",
status="pending",
created_at=created,
)
assert response.id == request_id
assert response.sender.username == "alice"
assert response.recipient.username == "bob"
assert response.status == "pending"
assert response.created_at == created
def test_friend_response_maps_fields() -> None:
friend_user = UserBasicInfo(id="user-2", username="bob", avatar_url=None)
request_id = uuid4()
created = datetime(2026, 1, 15, 10, 30, 0)
accepted = datetime(2026, 1, 16, 12, 0, 0)
response = FriendResponse(
id=request_id,
friend=friend_user,
status="active",
created_at=created,
accepted_at=accepted,
)
assert response.id == request_id
assert response.friend.username == "bob"
assert response.status == "active"
assert response.accepted_at == accepted
def test_friend_response_accepted_at_optional() -> None:
friend_user = UserBasicInfo(id="user-2", username="bob", avatar_url=None)
request_id = uuid4()
created = datetime(2026, 1, 15, 10, 30, 0)
response = FriendResponse(
id=request_id,
friend=friend_user,
status="active",
created_at=created,
accepted_at=None,
)
assert response.accepted_at is None
def test_friend_request_action_no_fields() -> None:
action = FriendRequestAction()
assert action.model_dump() == {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"include": ["backend"],
"exclude": ["**/__pycache__", "**/node_modules", "**/.git"],
"exclude": ["**/__pycache__", "**/node_modules", "**/.git", "backend/tests"],
"typeCheckingMode": "standard",
"pythonVersion": "3.12",
"pythonPlatform": "Linux",