Merge branch 'feature-calendar-sharing' into dev

This commit is contained in:
qzl
2026-02-28 13:28:49 +08:00
19 changed files with 2161 additions and 32 deletions
@@ -0,0 +1,30 @@
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.inbox_messages.repository import SQLAlchemyInboxMessageRepository
from v1.inbox_messages.service import InboxMessageService
from v1.users.dependencies import get_current_user
async def get_inbox_message_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SQLAlchemyInboxMessageRepository:
return SQLAlchemyInboxMessageRepository(session)
def get_inbox_message_service(
session: Annotated[AsyncSession, Depends(get_db)],
repository: Annotated[
SQLAlchemyInboxMessageRepository, Depends(get_inbox_message_repository)
],
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> InboxMessageService:
return InboxMessageService(
repository=repository, session=session, current_user=user
)
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
from core.logging import get_logger
from models.inbox_messages import InboxMessage
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.inbox_messages.repository")
class InboxMessageRepository(Protocol):
async def create(self, data: dict[str, object]) -> InboxMessage: ...
async def get_by_id(
self, message_id: UUID, recipient_id: UUID
) -> InboxMessage | None: ...
async def list_by_recipient(
self, recipient_id: UUID, status: str | None = None
) -> list[InboxMessage]: ...
async def update_status(
self,
message_id: UUID,
recipient_id: UUID,
status: str,
) -> InboxMessage | None: ...
class SQLAlchemyInboxMessageRepository:
_session: AsyncSession
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(self, data: dict[str, object]) -> InboxMessage:
try:
message = InboxMessage(**data)
self._session.add(message)
await self._session.flush()
return message
except SQLAlchemyError:
logger.exception("Inbox message creation failed")
raise
async def get_by_id(
self, message_id: UUID, recipient_id: UUID
) -> InboxMessage | None:
try:
stmt = (
select(InboxMessage)
.where(InboxMessage.id == message_id)
.where(InboxMessage.recipient_id == recipient_id)
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
except SQLAlchemyError:
logger.exception(
"Inbox message lookup failed",
message_id=str(message_id),
recipient_id=str(recipient_id),
)
raise
async def list_by_recipient(
self, recipient_id: UUID, status: str | None = None
) -> list[InboxMessage]:
try:
stmt = (
select(InboxMessage)
.where(InboxMessage.recipient_id == recipient_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(
"Inbox message list failed",
recipient_id=str(recipient_id),
status=status,
)
raise
async def update_status(
self,
message_id: UUID,
recipient_id: UUID,
status: str,
) -> InboxMessage | None:
try:
stmt = (
update(InboxMessage)
.where(InboxMessage.id == message_id)
.where(InboxMessage.recipient_id == recipient_id)
.values(status=status, is_read=True)
.returning(InboxMessage)
)
result = await self._session.execute(stmt)
await self._session.flush()
return result.scalar_one_or_none()
except SQLAlchemyError:
logger.exception(
"Inbox message status update failed",
message_id=str(message_id),
recipient_id=str(recipient_id),
status=status,
)
raise
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from v1.inbox_messages.dependencies import get_inbox_message_service
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
InboxMessageStatus,
)
from v1.inbox_messages.service import InboxMessageService
router = APIRouter(prefix="/inbox/messages", tags=["inbox-messages"])
@router.get("", response_model=list[InboxMessageResponse])
async def list_inbox_messages(
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
status: InboxMessageStatus | None = Query(default=None),
) -> list[InboxMessageResponse]:
request = InboxMessageListRequest(status=status)
return await service.list_messages(request)
@router.post("/{message_id}/accept", response_model=InboxMessageResponse)
async def accept_inbox_message(
message_id: UUID,
request: InboxMessageAcceptRequest,
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
) -> InboxMessageResponse:
return await service.accept_invitation(message_id, request)
@router.post("/{message_id}/dismiss", response_model=InboxMessageResponse)
async def dismiss_inbox_message(
message_id: UUID,
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
) -> InboxMessageResponse:
return await service.dismiss_invitation(message_id)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import ClassVar
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class PermissionBits:
VIEW: int = 1 # 001
INVITE: int = 2 # 010
EDIT: int = 4 # 100
@classmethod
def encode(cls, view: bool, edit: bool, invite: bool) -> int:
value = 0
if view:
value |= cls.VIEW
if edit:
value |= cls.EDIT
if invite:
value |= cls.INVITE
return value
@classmethod
def decode(cls, permission: int) -> dict[str, bool]:
return {
"view": bool(permission & cls.VIEW),
"edit": bool(permission & cls.EDIT),
"invite": bool(permission & cls.INVITE),
}
class InboxMessageType(str, Enum):
FRIEND_REQUEST = "friend_request"
CALENDAR = "calendar"
SYSTEM = "system"
GROUP = "group"
class InboxMessageStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
DISMISSED = "dismissed"
class InboxMessageResponse(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
id: UUID
recipient_id: UUID
sender_id: UUID | None = None
message_type: InboxMessageType
schedule_item_id: UUID | None = None
content: str | None = None
is_read: bool = False
status: InboxMessageStatus = InboxMessageStatus.PENDING
created_at: datetime
class InboxMessageListRequest(BaseModel):
status: InboxMessageStatus | None = None
class InboxMessageAcceptRequest(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
permission_view: bool = True
permission_edit: bool = False
permission_invite: bool = False
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
import json
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.inbox_messages import InboxMessage
from models.schedule_subscriptions import (
ScheduleSubscription,
SubscriptionStatus,
)
from v1.inbox_messages.repository import InboxMessageRepository
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
InboxMessageStatus,
InboxMessageType,
PermissionBits,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.inbox_messages.service")
class InboxMessageService(BaseService):
_repository: InboxMessageRepository
_session: AsyncSession
def __init__(
self,
repository: InboxMessageRepository,
session: AsyncSession,
current_user: CurrentUser | None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
async def list_messages(
self, request: InboxMessageListRequest
) -> list[InboxMessageResponse]:
user_id = self.require_user_id()
try:
status = request.status.value if request.status else None
messages = await self._repository.list_by_recipient(user_id, status)
except SQLAlchemyError:
logger.exception("Failed to list inbox messages", user_id=str(user_id))
raise HTTPException(
status_code=503, detail="Inbox message store unavailable"
)
return [self._to_response(message) for message in messages]
async def accept_invitation(
self,
message_id: UUID,
request: InboxMessageAcceptRequest,
) -> InboxMessageResponse:
user_id = self.require_user_id()
try:
message = await self._repository.get_by_id(message_id, user_id)
if message is None:
raise HTTPException(status_code=404, detail="Inbox message not found")
if message.status.value != InboxMessageStatus.PENDING.value:
raise HTTPException(
status_code=400, detail="Inbox message already handled"
)
if (
message.message_type.value != InboxMessageType.CALENDAR.value
or message.schedule_item_id is None
):
raise HTTPException(
status_code=400, detail="Message is not a calendar invitation"
)
invited_permission = self._parse_invited_permission(message.content)
requested_permission = PermissionBits.encode(
request.permission_view,
request.permission_edit,
request.permission_invite,
)
final_permission = requested_permission & invited_permission
if final_permission == 0:
raise HTTPException(
status_code=400,
detail="No valid permissions requested (must be subset of invited permissions)",
)
subscription = ScheduleSubscription(
item_id=message.schedule_item_id,
subscriber_id=user_id,
permission=final_permission,
status=SubscriptionStatus.ACTIVE,
created_by=user_id,
)
self._session.add(subscription)
updated = await self._repository.update_status(
message_id,
user_id,
InboxMessageStatus.ACCEPTED.value,
)
if updated is None:
await self._session.rollback()
raise HTTPException(status_code=404, detail="Inbox message not found")
await self._session.commit()
except HTTPException:
raise
except SQLAlchemyError:
await self._session.rollback()
logger.exception(
"Failed to accept inbox invitation",
message_id=str(message_id),
user_id=str(user_id),
)
raise HTTPException(
status_code=503, detail="Inbox message store unavailable"
)
return self._to_response(updated)
async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse:
user_id = self.require_user_id()
try:
message = await self._repository.get_by_id(message_id, user_id)
if message is None:
raise HTTPException(status_code=404, detail="Inbox message not found")
if message.status.value != InboxMessageStatus.PENDING.value:
raise HTTPException(
status_code=400, detail="Inbox message already handled"
)
updated = await self._repository.update_status(
message_id,
user_id,
InboxMessageStatus.DISMISSED.value,
)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
logger.exception(
"Failed to dismiss inbox invitation",
message_id=str(message_id),
user_id=str(user_id),
)
raise HTTPException(
status_code=503, detail="Inbox message store unavailable"
)
if updated is None:
raise HTTPException(status_code=404, detail="Inbox message not found")
return self._to_response(updated)
def _to_response(self, message: InboxMessage) -> InboxMessageResponse:
return InboxMessageResponse(
id=message.id,
recipient_id=message.recipient_id,
sender_id=message.sender_id,
message_type=InboxMessageType(message.message_type),
schedule_item_id=message.schedule_item_id,
content=message.content,
is_read=bool(message.is_read),
status=InboxMessageStatus(message.status),
created_at=message.created_at,
)
def _parse_invited_permission(self, content: str | None) -> int:
if not content:
return 0
try:
data = json.loads(content)
return int(data.get("permission", 0))
except (json.JSONDecodeError, ValueError, TypeError):
return 0
+2
View File
@@ -6,6 +6,7 @@ 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.inbox_messages.router import router as inbox_messages_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
@@ -18,6 +19,7 @@ router.include_router(infra_router)
router.include_router(users_router)
router.include_router(agent_chat_router)
router.include_router(schedule_items_router)
router.include_router(inbox_messages_router)
@router.get("/health", response_model=HealthResponse)
@@ -10,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
from core.logging import get_logger
from models.schedule_items import ScheduleItem
from models.schedule_subscriptions import ScheduleSubscription
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +19,7 @@ logger = get_logger("v1.schedule_items.repository")
class ScheduleItemRepository(Protocol):
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: ...
async def get_by_item_id(
self, item_id: UUID, owner_id: UUID
) -> ScheduleItem | None: ...
@@ -31,6 +33,7 @@ class ScheduleItemRepository(Protocol):
async def list_by_date_range(
self, owner_id: UUID, start_at: datetime, end_at: datetime
) -> list[ScheduleItem]: ...
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
@@ -127,3 +130,12 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
except SQLAlchemyError:
logger.exception("Schedule item list failed", owner_id=str(owner_id))
raise
async def create_subscription(self, data: dict) -> ScheduleSubscription:
sub = ScheduleSubscription(**data)
self._session.add(sub)
await self._session.flush()
return sub
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None:
return await super().get_by_id(entity_id)
+12 -11
View File
@@ -12,6 +12,8 @@ from v1.schedule_items.schemas import (
ScheduleItemListItem,
ScheduleItemListRequest,
ScheduleItemResponse,
ScheduleItemShareRequest,
ScheduleItemShareResponse,
ScheduleItemUpdateRequest,
)
from v1.schedule_items.service import ScheduleItemService
@@ -36,17 +38,7 @@ async def list_schedule_items(
) -> list[ScheduleItemListItem]:
request = ScheduleItemListRequest(start_at=start_at, end_at=end_at)
items = await service.list_by_date_range(request)
return [
ScheduleItemListItem(
id=item.id,
title=item.title,
start_at=item.start_at,
end_at=item.end_at,
timezone=item.timezone,
status=item.status,
)
for item in items
]
return [ScheduleItemListItem.model_validate(item) for item in items]
@router.get("/{item_id}", response_model=ScheduleItemResponse)
@@ -72,3 +64,12 @@ async def delete_schedule_item(
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> None:
await service.delete(item_id)
@router.post("/{item_id}/share", response_model=ScheduleItemShareResponse)
async def share_schedule_item(
item_id: UUID,
request: ScheduleItemShareRequest,
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> ScheduleItemShareResponse:
return await service.share(item_id, request)
+30 -1
View File
@@ -5,7 +5,7 @@ from enum import Enum
from typing import ClassVar
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AttachmentType(str, Enum):
@@ -96,3 +96,32 @@ class ScheduleItemListItem(BaseModel):
class ScheduleItemListRequest(BaseModel):
start_at: datetime
end_at: datetime
# Permission bit constants (matching PermissionBits in inbox_messages/schemas.py)
_PERMISSION_VIEW = 1 # 001
_PERMISSION_INVITE = 2 # 010
_PERMISSION_EDIT = 4 # 100
class ScheduleItemShareRequest(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
email: EmailStr = Field(..., description="Email of user to share with")
permission_view: bool = Field(True, description="Grant view permission")
permission_edit: bool = Field(False, description="Grant edit permission")
permission_invite: bool = Field(False, description="Grant invite permission")
def _permission_value(self) -> int:
value = 0
if self.permission_view:
value |= _PERMISSION_VIEW
if self.permission_edit:
value |= _PERMISSION_EDIT
if self.permission_invite:
value |= _PERMISSION_INVITE
return value
class ScheduleItemShareResponse(BaseModel):
message: str
+68 -20
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import json
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from fastapi import HTTPException
@@ -9,13 +10,17 @@ 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.inbox_messages import InboxMessage, InboxMessageType
from models.schedule_items import ScheduleItem
from v1.auth.gateway import SupabaseAuthGateway
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListRequest,
ScheduleItemMetadata,
ScheduleItemResponse,
ScheduleItemShareRequest,
ScheduleItemShareResponse,
ScheduleItemUpdateRequest,
ScheduleItemSourceType,
ScheduleItemStatus,
@@ -24,22 +29,31 @@ from v1.schedule_items.schemas import (
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from v1.auth.schemas import UserByEmailResponse
logger = get_logger("v1.schedule_items.service")
class AuthByEmailGateway(Protocol):
async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ...
class ScheduleItemService(BaseService):
_repository: ScheduleItemRepository
_session: AsyncSession
_auth_gateway: AuthByEmailGateway
def __init__(
self,
repository: ScheduleItemRepository,
session: AsyncSession,
current_user: CurrentUser | None,
auth_gateway: AuthByEmailGateway | None = None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
self._auth_gateway = auth_gateway or SupabaseAuthGateway()
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
user_id = self.require_user_id()
@@ -98,26 +112,16 @@ class ScheduleItemService(BaseService):
if existing is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
update_data: dict = {}
if request.title is not None:
update_data["title"] = request.title
if request.description is not None:
update_data["description"] = request.description
if request.start_at is not None:
update_data["start_at"] = request.start_at
if request.end_at is not None:
update_data["end_at"] = request.end_at
if request.timezone is not None:
update_data["timezone"] = request.timezone
if request.status is not None:
update_data["status"] = request.status
if request.metadata is not None:
update_data["metadata"] = request.metadata.model_dump()
# Build update dict from non-null fields
update_data = request.model_dump(exclude_unset=True)
next_start = (
request.start_at if request.start_at is not None else existing.start_at
)
next_end = request.end_at if request.end_at is not None else existing.end_at
# Handle metadata separately (model_dump returns dict)
if "metadata" in update_data and update_data["metadata"] is not None:
update_data["metadata"] = update_data["metadata"].model_dump()
# Validate time range
next_start = update_data.get("start_at", existing.start_at)
next_end = update_data.get("end_at", existing.end_at)
if next_end is not None and next_end <= next_start:
raise HTTPException(
status_code=400, detail="end_at must be after start_at"
@@ -179,6 +183,50 @@ class ScheduleItemService(BaseService):
return [self._to_response(item) for item in items]
async def share(
self, item_id: UUID, request: ScheduleItemShareRequest
) -> ScheduleItemShareResponse:
user_id = self.require_user_id()
try:
item = await self._repository.get_by_id(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
if item.owner_id != user_id:
raise HTTPException(
status_code=403,
detail="Only owner can share this schedule item",
)
target_user = await self._auth_gateway.get_user_by_email(request.email)
recipient_id = UUID(target_user.id)
message = InboxMessage(
recipient_id=recipient_id,
sender_id=user_id,
message_type=InboxMessageType.CALENDAR,
schedule_item_id=item.id,
content=json.dumps({"permission": request._permission_value()}),
created_by=user_id,
)
self._session.add(message)
await self._session.commit()
except HTTPException:
raise
except SQLAlchemyError:
await self._session.rollback()
logger.exception("Failed to share schedule item", item_id=str(item_id))
raise HTTPException(
status_code=503, detail="Schedule item store unavailable"
)
except ValueError:
await self._session.rollback()
logger.exception(
"Auth lookup returned invalid user id", email=request.email
)
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
return ScheduleItemShareResponse(message="Calendar invitation sent")
def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse:
return ScheduleItemResponse(
id=item.id,
@@ -0,0 +1,156 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Callable
from uuid import UUID, uuid4
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import app
from v1.inbox_messages.dependencies import get_inbox_message_service
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
InboxMessageStatus,
InboxMessageType,
)
from v1.inbox_messages.service import InboxMessageService
class FakeInboxMessageService:
def __init__(
self,
messages: list[InboxMessageResponse],
accepted: InboxMessageResponse,
dismissed: InboxMessageResponse,
) -> None:
self._messages = messages
self._accepted = accepted
self._dismissed = dismissed
async def list_messages(
self, request: InboxMessageListRequest
) -> list[InboxMessageResponse]:
if request.status is None:
return self._messages
return [
message for message in self._messages if message.status == request.status
]
async def accept_invitation(
self,
message_id: UUID,
request: InboxMessageAcceptRequest,
) -> InboxMessageResponse:
if message_id != self._accepted.id:
raise HTTPException(status_code=404, detail="Inbox message not found")
if not request.permission_view:
raise HTTPException(status_code=400, detail="permission_view is required")
return self._accepted
async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse:
if message_id != self._dismissed.id:
raise HTTPException(status_code=404, detail="Inbox message not found")
return self._dismissed
def _override_inbox_message_service(
service: FakeInboxMessageService,
) -> Callable[[], InboxMessageService]:
def _get_service() -> InboxMessageService:
return service # type: ignore[return-value]
return _get_service
def _build_message(
message_id: UUID,
status: InboxMessageStatus,
) -> InboxMessageResponse:
return InboxMessageResponse(
id=message_id,
recipient_id=uuid4(),
sender_id=uuid4(),
message_type=InboxMessageType.CALENDAR,
schedule_item_id=uuid4(),
content='{"permission": 1}',
is_read=False,
status=status,
created_at=datetime(2026, 2, 28, 9, 0, 0, tzinfo=timezone.utc),
)
def test_list_inbox_messages_returns_200() -> None:
pending_message = _build_message(uuid4(), InboxMessageStatus.PENDING)
accepted_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED)
service = FakeInboxMessageService(
messages=[pending_message, accepted_message],
accepted=accepted_message,
dismissed=_build_message(uuid4(), InboxMessageStatus.DISMISSED),
)
app.dependency_overrides[get_inbox_message_service] = (
_override_inbox_message_service(service)
)
client = TestClient(app)
try:
response = client.get("/api/v1/inbox/messages", params={"status": "pending"})
assert response.status_code == 200
body = response.json()
assert len(body) == 1
assert body[0]["status"] == "pending"
finally:
app.dependency_overrides = {}
def test_accept_inbox_message_returns_200() -> None:
accepted_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED)
service = FakeInboxMessageService(
messages=[accepted_message],
accepted=accepted_message,
dismissed=_build_message(uuid4(), InboxMessageStatus.DISMISSED),
)
app.dependency_overrides[get_inbox_message_service] = (
_override_inbox_message_service(service)
)
client = TestClient(app)
try:
response = client.post(
f"/api/v1/inbox/messages/{accepted_message.id}/accept",
json={
"permission_view": True,
"permission_edit": True,
"permission_invite": False,
},
)
assert response.status_code == 200
body = response.json()
assert body["id"] == str(accepted_message.id)
assert body["status"] == "accepted"
finally:
app.dependency_overrides = {}
def test_dismiss_inbox_message_returns_200() -> None:
dismissed_message = _build_message(uuid4(), InboxMessageStatus.DISMISSED)
service = FakeInboxMessageService(
messages=[dismissed_message],
accepted=_build_message(uuid4(), InboxMessageStatus.ACCEPTED),
dismissed=dismissed_message,
)
app.dependency_overrides[get_inbox_message_service] = (
_override_inbox_message_service(service)
)
client = TestClient(app)
try:
response = client.post(f"/api/v1/inbox/messages/{dismissed_message.id}/dismiss")
assert response.status_code == 200
body = response.json()
assert body["id"] == str(dismissed_message.id)
assert body["status"] == "dismissed"
finally:
app.dependency_overrides = {}
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Callable
from uuid import UUID, uuid4
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import app
from v1.schedule_items.dependencies import get_schedule_item_service
from v1.schedule_items.schemas import (
ScheduleItemShareRequest,
ScheduleItemShareResponse,
)
from v1.schedule_items.service import ScheduleItemService
class FakeScheduleItemShareService:
def __init__(self, item_id: UUID) -> None:
self._item_id = item_id
self.last_share_request: ScheduleItemShareRequest | None = None
async def share(
self,
item_id: UUID,
request: ScheduleItemShareRequest,
) -> ScheduleItemShareResponse:
if item_id != self._item_id:
raise HTTPException(status_code=404, detail="Schedule item not found")
self.last_share_request = request
return ScheduleItemShareResponse(message="Calendar invitation sent")
def _override_schedule_item_service(
service: FakeScheduleItemShareService,
) -> Callable[[], ScheduleItemService]:
def _get_service() -> ScheduleItemService:
return service # type: ignore[return-value]
return _get_service
def test_share_schedule_item_returns_200() -> None:
item_id = uuid4()
service = FakeScheduleItemShareService(item_id=item_id)
app.dependency_overrides[get_schedule_item_service] = (
_override_schedule_item_service(service)
)
client = TestClient(app)
try:
response = client.post(
f"/api/v1/schedule-items/{item_id}/share",
json={
"email": "friend@example.com",
"permission_view": True,
"permission_edit": False,
"permission_invite": True,
},
)
assert response.status_code == 200
body = response.json()
assert body["message"] == "Calendar invitation sent"
assert service.last_share_request is not None
assert service.last_share_request.email == "friend@example.com"
assert service.last_share_request.permission_invite is True
finally:
app.dependency_overrides = {}
@@ -0,0 +1,88 @@
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from sqlalchemy.exc import SQLAlchemyError
from models.inbox_messages import InboxMessageType
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
@pytest.mark.asyncio
async def test_create_adds_message_and_flushes() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = SQLAlchemyInboxMessageRepository(session)
recipient_id = uuid4()
result = await repository.create(
{
"recipient_id": recipient_id,
"sender_id": uuid4(),
"message_type": InboxMessageType.CALENDAR,
"schedule_item_id": uuid4(),
"content": "invite",
"created_by": uuid4(),
}
)
session.add.assert_called_once_with(result)
session.flush.assert_awaited_once()
assert result.recipient_id == recipient_id
@pytest.mark.asyncio
async def test_get_by_id_returns_message_when_exists() -> None:
session = AsyncMock()
repository = SQLAlchemyInboxMessageRepository(session)
expected = MagicMock()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = expected
session.execute.return_value = execute_result
result = await repository.get_by_id(uuid4(), uuid4())
assert result is expected
session.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_by_recipient_returns_messages() -> None:
session = AsyncMock()
repository = SQLAlchemyInboxMessageRepository(session)
message_one = MagicMock()
message_two = MagicMock()
execute_result = MagicMock()
execute_result.scalars.return_value.all.return_value = [message_one, message_two]
session.execute.return_value = execute_result
result = await repository.list_by_recipient(uuid4(), "pending")
assert result == [message_one, message_two]
session.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_status_returns_updated_message_and_flushes() -> None:
session = AsyncMock()
repository = SQLAlchemyInboxMessageRepository(session)
updated = MagicMock()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = updated
session.execute.return_value = execute_result
result = await repository.update_status(uuid4(), uuid4(), "dismissed")
assert result is updated
session.execute.assert_awaited_once()
session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_by_id_propagates_sqlalchemy_error() -> None:
session = AsyncMock()
repository = SQLAlchemyInboxMessageRepository(session)
session.execute.side_effect = SQLAlchemyError("boom")
with pytest.raises(SQLAlchemyError):
await repository.get_by_id(uuid4(), uuid4())
@@ -0,0 +1,38 @@
from datetime import datetime, timezone
from uuid import uuid4
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageResponse,
InboxMessageStatus,
InboxMessageType,
)
def test_inbox_message_response_schema() -> None:
msg_id = uuid4()
response = InboxMessageResponse(
id=msg_id,
recipient_id=uuid4(),
sender_id=uuid4(),
message_type=InboxMessageType.CALENDAR,
schedule_item_id=uuid4(),
content="Join my calendar",
is_read=False,
status=InboxMessageStatus.PENDING,
created_at=datetime.now(timezone.utc),
)
assert response.message_type.value == "calendar"
assert response.status.value == "pending"
def test_inbox_message_accept_request_schema() -> None:
request = InboxMessageAcceptRequest(
permission_view=True,
permission_edit=False,
permission_invite=False,
)
assert request.permission_view is True
assert request.permission_edit is False
@@ -0,0 +1,181 @@
from datetime import datetime, timezone
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.inbox_messages import (
InboxMessage,
InboxMessageStatus as InboxMessageModelStatus,
InboxMessageType as InboxMessageModelType,
)
from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus
from v1.inbox_messages.schemas import InboxMessageAcceptRequest, InboxMessageListRequest
from v1.inbox_messages.service import InboxMessageService
def _build_message(
*,
message_id: UUID,
recipient_id: UUID,
status: InboxMessageModelStatus = InboxMessageModelStatus.PENDING,
message_type: InboxMessageModelType = InboxMessageModelType.CALENDAR,
schedule_item_id: UUID | None = None,
content: str = '{"permission": 7}',
) -> InboxMessage:
message = MagicMock(spec=InboxMessage)
message.id = message_id
message.recipient_id = recipient_id
message.sender_id = uuid4()
message.message_type = message_type
message.schedule_item_id = schedule_item_id
message.content = content
message.is_read = False
message.status = status
message.created_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc)
return message
@pytest.mark.asyncio
async def test_list_messages_returns_messages() -> None:
user_id = uuid4()
repo = AsyncMock()
repo.list_by_recipient.return_value = [
_build_message(
message_id=uuid4(),
recipient_id=user_id,
schedule_item_id=uuid4(),
)
]
session = AsyncMock()
service = InboxMessageService(
repository=repo,
session=session,
current_user=CurrentUser(id=user_id),
)
result = await service.list_messages(InboxMessageListRequest())
assert len(result) == 1
assert result[0].recipient_id == user_id
assert result[0].status.value == "pending"
repo.list_by_recipient.assert_awaited_once_with(user_id, None)
@pytest.mark.asyncio
async def test_accept_invitation_creates_subscription() -> None:
user_id = uuid4()
message_id = uuid4()
item_id = uuid4()
pending_message = _build_message(
message_id=message_id,
recipient_id=user_id,
schedule_item_id=item_id,
)
accepted_message = _build_message(
message_id=message_id,
recipient_id=user_id,
status=InboxMessageModelStatus.ACCEPTED,
schedule_item_id=item_id,
)
repo = AsyncMock()
repo.get_by_id.return_value = pending_message
repo.update_status.return_value = accepted_message
session = AsyncMock()
session.add = MagicMock()
service = InboxMessageService(
repository=repo,
session=session,
current_user=CurrentUser(id=user_id),
)
result = await service.accept_invitation(
message_id,
InboxMessageAcceptRequest(
permission_view=True,
permission_edit=True,
permission_invite=False,
),
)
session.add.assert_called_once()
subscription = session.add.call_args.args[0]
assert isinstance(subscription, ScheduleSubscription)
assert subscription.item_id == item_id
assert subscription.subscriber_id == user_id
assert subscription.permission == 5 # view(1) + edit(4) = 5
assert subscription.status == SubscriptionStatus.ACTIVE
repo.update_status.assert_awaited_once_with(message_id, user_id, "accepted")
session.commit.assert_awaited_once()
assert result.status.value == "accepted"
@pytest.mark.asyncio
async def test_dismiss_invitation_updates_status() -> None:
user_id = uuid4()
message_id = uuid4()
pending_message = _build_message(
message_id=message_id,
recipient_id=user_id,
schedule_item_id=uuid4(),
)
dismissed_message = _build_message(
message_id=message_id,
recipient_id=user_id,
status=InboxMessageModelStatus.DISMISSED,
schedule_item_id=uuid4(),
)
repo = AsyncMock()
repo.get_by_id.return_value = pending_message
repo.update_status.return_value = dismissed_message
session = AsyncMock()
service = InboxMessageService(
repository=repo,
session=session,
current_user=CurrentUser(id=user_id),
)
result = await service.dismiss_invitation(message_id)
repo.update_status.assert_awaited_once_with(message_id, user_id, "dismissed")
session.commit.assert_awaited_once()
assert result.status.value == "dismissed"
@pytest.mark.asyncio
async def test_accept_noncalendar_message_fails() -> None:
user_id = uuid4()
message_id = uuid4()
non_calendar_message = _build_message(
message_id=message_id,
recipient_id=user_id,
message_type=InboxMessageModelType.FRIEND_REQUEST,
schedule_item_id=None,
)
repo = AsyncMock()
repo.get_by_id.return_value = non_calendar_message
session = AsyncMock()
session.add = MagicMock()
service = InboxMessageService(
repository=repo,
session=session,
current_user=CurrentUser(id=user_id),
)
with pytest.raises(HTTPException) as exc_info:
await service.accept_invitation(message_id, InboxMessageAcceptRequest())
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Message is not a calendar invitation"
session.add.assert_not_called()
session.commit.assert_not_awaited()
@@ -0,0 +1,237 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from models.inbox_messages import InboxMessage, InboxMessageType
from models.schedule_items import ScheduleItem
from v1.auth.schemas import UserByEmailResponse
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemShareRequest
from v1.schedule_items.service import ScheduleItemService
def test_share_request_schema() -> None:
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
assert request.email == "friend@example.com"
assert request.permission_view is True
def test_permission_bits_calculation() -> None:
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
assert request._permission_value() == 5
def _build_item(item_id: UUID, owner_id: UUID) -> ScheduleItem:
item = MagicMock(spec=ScheduleItem)
item.id = item_id
item.owner_id = owner_id
item.title = "test"
item.description = None
item.start_at = datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc)
item.end_at = None
item.timezone = "UTC"
item.extra_metadata = {}
item.created_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc)
item.updated_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc)
return item
class ShareRepo:
def __init__(self, item: ScheduleItem | None) -> None:
self._item = item
async def get_by_id(self, item_id: UUID) -> ScheduleItem | None:
if self._item and self._item.id == item_id:
return self._item
return None
class AuthGatewayStub:
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
return UserByEmailResponse(
id="00000000-0000-0000-0000-000000000222",
email=email,
created_at="2026-02-28T10:00:00Z",
email_confirmed_at=None,
)
class AuthGatewayInvalidIdStub:
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
return UserByEmailResponse(
id="not-a-uuid",
email=email,
created_at="2026-02-28T10:00:00Z",
email_confirmed_at=None,
)
@pytest.mark.asyncio
async def test_share_forbidden_when_not_owner() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
requester_id = UUID("00000000-0000-0000-0000-000000000002")
item_id = uuid4()
service = ScheduleItemService(
repository=cast(
ScheduleItemRepository,
ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)),
),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
)
with pytest.raises(HTTPException) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_share_success_creates_calendar_invitation_message() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
item_id = uuid4()
session = AsyncMock()
session.add = MagicMock()
service = ScheduleItemService(
repository=cast(
ScheduleItemRepository,
ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)),
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayStub(),
)
result = await service.share(
item_id,
ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
),
)
assert result.message == "Calendar invitation sent"
session.add.assert_called_once()
message = session.add.call_args.args[0]
assert isinstance(message, InboxMessage)
assert message.sender_id == owner_id
assert message.schedule_item_id == item_id
assert message.message_type == InboxMessageType.CALENDAR
assert message.content == '{"permission": 5}'
session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_share_returns_not_found_when_item_missing() -> None:
requester_id = UUID("00000000-0000-0000-0000-000000000002")
service = ScheduleItemService(
repository=cast(ScheduleItemRepository, ShareRepo(None)),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
)
with pytest.raises(HTTPException) as exc_info:
await service.share(
uuid4(),
ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_share_invalid_auth_user_id_returns_503() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
item_id = uuid4()
session = AsyncMock()
service = ScheduleItemService(
repository=cast(
ScheduleItemRepository,
ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)),
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayInvalidIdStub(),
)
with pytest.raises(HTTPException) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Auth lookup unavailable"
session.rollback.assert_awaited_once()
@pytest.mark.asyncio
async def test_share_sqlalchemy_error_rolls_back() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
item_id = uuid4()
session = AsyncMock()
session.add = MagicMock(side_effect=SQLAlchemyError("db error"))
service = ScheduleItemService(
repository=cast(
ScheduleItemRepository,
ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)),
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayStub(),
)
with pytest.raises(HTTPException) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Schedule item store unavailable"
session.rollback.assert_awaited_once()