Merge remote-tracking branch 'origin/feature/friendship' into dev
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
|||||||
from core.http.models import HealthResponse
|
from core.http.models import HealthResponse
|
||||||
from v1.agent_chat.router import router as agent_chat_router
|
from v1.agent_chat.router import router as agent_chat_router
|
||||||
from v1.auth.router import router as auth_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.infra.router import router as infra_router
|
||||||
from v1.schedule_items.router import router as schedule_items_router
|
from v1.schedule_items.router import router as schedule_items_router
|
||||||
from v1.users.router import router as users_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 = APIRouter(prefix="/api/v1")
|
||||||
router.include_router(auth_router)
|
router.include_router(auth_router)
|
||||||
|
router.include_router(friendships_router)
|
||||||
router.include_router(infra_router)
|
router.include_router(infra_router)
|
||||||
router.include_router(users_router)
|
router.include_router(users_router)
|
||||||
router.include_router(agent_chat_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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"include": ["backend"],
|
"include": ["backend"],
|
||||||
"exclude": ["**/__pycache__", "**/node_modules", "**/.git"],
|
"exclude": ["**/__pycache__", "**/node_modules", "**/.git", "backend/tests"],
|
||||||
"typeCheckingMode": "standard",
|
"typeCheckingMode": "standard",
|
||||||
"pythonVersion": "3.12",
|
"pythonVersion": "3.12",
|
||||||
"pythonPlatform": "Linux",
|
"pythonPlatform": "Linux",
|
||||||
|
|||||||
Reference in New Issue
Block a user