test: add calendar sharing tests and update API docs

This commit is contained in:
qzl
2026-02-28 12:28:45 +08:00
parent 7a49783156
commit 173d91086f
7 changed files with 1451 additions and 0 deletions
@@ -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,180 @@
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,
) -> 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 = "calendar invite"
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 == 3
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()
@@ -7,8 +7,10 @@ from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from models.inbox_messages import InboxMessage, InboxMessageType
from models.schedule_items import ScheduleItem from models.schedule_items import ScheduleItem
from v1.auth.schemas import UserByEmailResponse from v1.auth.schemas import UserByEmailResponse
from v1.schedule_items.repository import ScheduleItemRepository from v1.schedule_items.repository import ScheduleItemRepository
@@ -72,6 +74,16 @@ class AuthGatewayStub:
) )
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 @pytest.mark.asyncio
async def test_share_forbidden_when_not_owner() -> None: async def test_share_forbidden_when_not_owner() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001") owner_id = UUID("00000000-0000-0000-0000-000000000001")
@@ -99,3 +111,127 @@ async def test_share_forbidden_when_not_owner() -> None:
) )
assert exc_info.value.status_code == 403 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()
@@ -0,0 +1,714 @@
# Calendar Sharing Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 实现日历事件分享功能 - 用户可以分享日历事件给其他人(通过邮箱),被邀请人会收到待办消息,可以同意或忽略邀请。
**Architecture:** 使用现有的 schemas/repository/service/router 分层架构。新增 inbox_messages 模块处理邀请消息。复用 auth gateway 的 get_user_by_email 通过邮箱查找用户。
**Tech Stack:** FastAPI, SQLAlchemy (async), Pydantic, Supabase Auth
---
## Permission Bits (from design doc)
| Permission | Value | Binary |
|------------|-------|--------|
| view | 1 | 001 |
| invite | 2 | 010 |
| edit | 4 | 100 |
- Owner has all permissions: 7 (111)
- Check permission: `permission & 2 == 2` (has invite)
- Add permission: `permission | 2`
---
## Task 1: Add inbox_messages module (schemas, repository, service, router)
**Files:**
- Create: `backend/src/v1/inbox_messages/__init__.py`
- Create: `backend/src/v1/inbox_messages/schemas.py`
- Create: `backend/src/v1/inbox_messages/repository.py`
- Create: `backend/src/v1/inbox_messages/service.py`
- Create: `backend/src/v1/inbox_messages/router.py`
- Modify: `backend/src/v1/router.py` - include inbox_messages router
**Step 1: Write the failing test**
```python
# backend/tests/unit/v1/inbox_messages/test_schemas.py
import pytest
from uuid import uuid4
from v1.inbox_messages.schemas import (
InboxMessageResponse,
InboxMessageListRequest,
InboxMessageAcceptRequest,
)
def test_inbox_message_response_schema():
msg_id = uuid4()
response = InboxMessageResponse(
id=msg_id,
recipient_id=uuid4(),
sender_id=uuid4(),
message_type="calendar",
schedule_item_id=uuid4(),
content="Join my calendar",
is_read=False,
status="pending",
)
assert response.message_type == "calendar"
assert response.status == "pending"
def test_inbox_message_accept_request_schema():
request = InboxMessageAcceptRequest(
permission_view=True,
permission_edit=False,
permission_invite=False,
)
assert request.permission_view is True
assert request.permission_edit is False
```
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/unit/v1/inbox_messages/test_schemas.py -v`
Expected: FAIL with "ModuleNotFoundError: No module named 'v1.inbox_messages'"
**Step 3: Write minimal implementation**
Create `backend/src/v1/inbox_messages/__init__.py`:
```python
```
Create `backend/src/v1/inbox_messages/schemas.py`:
```python
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
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):
id: UUID
recipient_id: UUID
sender_id: Optional[UUID] = None
message_type: InboxMessageType
schedule_item_id: Optional[UUID] = None
content: Optional[str] = None
is_read: bool = False
status: InboxMessageStatus = InboxMessageStatus.PENDING
created_at: datetime
class InboxMessageListRequest(BaseModel):
status: Optional[InboxMessageStatus] = None
class InboxMessageAcceptRequest(BaseModel):
permission_view: bool = True
permission_edit: bool = False
permission_invite: bool = False
```
Create `backend/src/v1/inbox_messages/repository.py`:
```python
from __future__ import annotations
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.inbox_messages import InboxMessage, InboxMessageStatus
class InboxMessageRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(self, data: dict) -> InboxMessage:
msg = InboxMessage(**data)
self._session.add(msg)
await self._session.flush()
return msg
async def get_by_id(self, message_id: UUID, recipient_id: UUID) -> Optional[InboxMessage]:
result = await self._session.execute(
select(InboxMessage).where(
InboxMessage.id == message_id,
InboxMessage.recipient_id == recipient_id,
)
)
return result.scalar_one_or_none()
async def list_by_recipient(
self, recipient_id: UUID, status: Optional[InboxMessageStatus] = None
) -> list[InboxMessage]:
query = select(InboxMessage).where(InboxMessage.recipient_id == recipient_id)
if status:
query = query.where(InboxMessage.status == status)
query = query.order_by(InboxMessage.created_at.desc())
result = await self._session.execute(query)
return list(result.scalars().all())
async def update_status(
self, message_id: UUID, recipient_id: UUID, status: InboxMessageStatus
) -> Optional[InboxMessage]:
msg = await self.get_by_id(message_id, recipient_id)
if msg:
msg.status = status
await self._session.flush()
return msg
```
Create `backend/src/v1/inbox_messages/service.py`:
```python
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
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.inbox_messages import InboxMessageStatus
from v1.inbox_messages.repository import InboxMessageRepository
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
)
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: Optional[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:
messages = await self._repository.list_by_recipient(
user_id, request.status
)
except SQLAlchemyError:
logger.exception("Failed to list inbox messages")
raise HTTPException(status_code=503, detail="Inbox unavailable")
return [
InboxMessageResponse(
id=m.id,
recipient_id=m.recipient_id,
sender_id=m.sender_id,
message_type=m.message_type,
schedule_item_id=m.schedule_item_id,
content=m.content,
is_read=m.is_read,
status=m.status,
created_at=m.created_at,
)
for m in messages
]
async def accept_invitation(
self, message_id: UUID, request: InboxMessageAcceptRequest
) -> None:
user_id = self.require_user_id()
try:
message = await self._repository.get_by_id(message_id, user_id)
except SQLAlchemyError:
logger.exception("Failed to get inbox message", message_id=str(message_id))
raise HTTPException(status_code=503, detail="Inbox unavailable")
if message is None:
raise HTTPException(status_code=404, detail="Message not found")
if message.message_type != InboxMessageStatus.PENDING:
raise HTTPException(status_code=400, detail="Message already processed")
message.status = InboxMessageStatus.ACCEPTED
await self._session.flush()
await self._session.commit()
async def dismiss_invitation(self, message_id: UUID) -> None:
user_id = self.require_user_id()
try:
message = await self._repository.get_by_id(message_id, user_id)
except SQLAlchemyError:
logger.exception("Failed to get inbox message", message_id=str(message_id))
raise HTTPException(status_code=503, detail="Inbox unavailable")
if message is None:
raise HTTPException(status_code=404, detail="Message not found")
message.status = InboxMessageStatus.DISMISSED
await self._session.flush()
await self._session.commit()
```
Create `backend/src/v1/inbox_messages/dependencies.py`:
```python
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.dependencies import get_current_user
from core.db.session import get_db
from models.auth.models import CurrentUser
from v1.inbox_messages.repository import InboxMessageRepository
from v1.inbox_messages.service import InboxMessageService
def get_inbox_message_repository(
session: Annotated[AsyncSession, Depends(get_db)]
) -> InboxMessageRepository:
return InboxMessageRepository(session)
def get_inbox_message_service(
repository: Annotated[InboxMessageRepository, Depends(get_inbox_message_repository)],
current_user: Annotated[CurrentUser | None, Depends(get_current_user)],
) -> InboxMessageService:
return InboxMessageService(
repository=repository,
session=repository._session,
current_user=current_user,
)
```
Create `backend/src/v1/inbox_messages/router.py`:
```python
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", tags=["inbox"])
@router.get("/messages", response_model=list[InboxMessageResponse])
async def list_inbox_messages(
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
status: InboxMessageStatus | None = Query(None, description="Filter by status"),
) -> list[InboxMessageResponse]:
request = InboxMessageListRequest(status=status)
return await service.list_messages(request)
@router.post("/messages/{message_id}/accept", status_code=204)
async def accept_invitation(
message_id: UUID,
request: InboxMessageAcceptRequest,
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
) -> None:
await service.accept_invitation(message_id, request)
@router.post("/messages/{message_id}/dismiss", status_code=204)
async def dismiss_invitation(
message_id: UUID,
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
) -> None:
await service.dismiss_invitation(message_id)
```
Modify `backend/src/v1/router.py`:
```python
from fastapi import APIRouter
from core.http.models import HealthResponse
from v1.agent_chat.router import router as agent_chat_router
from v1.auth.router import router as auth_router
from v1.infra.router import router as infra_router
from v1.inbox_messages.router import router as inbox_messages_router
from v1.schedule_items.router import router as schedule_items_router
from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
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)
async def health() -> HealthResponse:
return HealthResponse(status="ok")
```
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/unit/v1/inbox_messages/test_schemas.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/v1/inbox_messages/ backend/src/v1/router.py
git commit -m "feat: add inbox messages module for calendar invitations"
```
---
## Task 2: Add share calendar API to schedule_items
**Files:**
- Modify: `backend/src/v1/schedule_items/schemas.py` - add share schemas
- Modify: `backend/src/v1/schedule_items/repository.py` - add subscription create
- Modify: `backend/src/v1/schedule_items/service.py` - add share method
- Modify: `backend/src/v1/schedule_items/router.py` - add share endpoint
- Modify: `backend/src/v1/schedule_items/dependencies.py` - add dependencies
**Step 1: Write the failing test**
```python
# backend/tests/unit/v1/schedule_items/test_share.py
import pytest
from uuid import uuid4
from v1.schedule_items.schemas import ScheduleItemShareRequest
def test_share_request_schema():
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():
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
# view=1, edit=4, invite=0 -> 1|4 = 5
assert request._permission_value() == 5
```
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_share.py -v`
Expected: FAIL with "cannot import 'ScheduleItemShareRequest'"
**Step 3: Write minimal implementation**
Add to `backend/src/v1/schedule_items/schemas.py`:
```python
class ScheduleItemShareRequest(BaseModel):
email: str = 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 |= 1 # 001
if self.permission_edit:
value |= 4 # 100
if self.permission_invite:
value |= 2 # 010
return value
class ScheduleItemShareResponse(BaseModel):
message: str
```
Add to `backend/src/v1/schedule_items/repository.py`:
```python
from models.schedule_subscriptions import ScheduleSubscription
class ScheduleItemRepository:
# ... existing code ...
async def create_subscription(self, data: dict) -> ScheduleSubscription:
sub = ScheduleSubscription(**data)
self._session.add(sub)
await self._session.flush()
return sub
```
Add to `backend/src/v1/schedule_items/service.py`:
```python
from uuid import UUID
from core.auth.models import CurrentUser
from v1.auth.gateway import SupabaseAuthGateway
from models.schedule_subscriptions import ScheduleSubscription
class ScheduleItemService:
# ... existing code ...
async def share(
self, item_id: UUID, request: ScheduleItemShareRequest
) -> ScheduleItemShareResponse:
user_id = self.require_user_id()
# Check item exists and user is owner
try:
item = await self._repository.get_by_item_id(item_id, user_id)
except SQLAlchemyError:
logger.exception("Failed to get schedule item", item_id=str(item_id))
raise HTTPException(status_code=503, detail="Schedule item store unavailable")
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")
# Lookup user by email
auth_gateway = SupabaseAuthGateway()
try:
target_user = await auth_gateway.get_user_by_email(request.email)
except HTTPException as exc:
if exc.status_code == 404:
raise HTTPException(status_code=404, detail="User not found")
raise
target_user_id = UUID(target_user.id)
# Create inbox message
from models.inbox_messages import InboxMessage, InboxMessageType
inbox_data = {
"recipient_id": target_user_id,
"sender_id": user_id,
"message_type": InboxMessageType.CALENDAR,
"schedule_item_id": item_id,
"content": f"{item.title} shared with you",
"created_by": user_id,
}
try:
inbox_msg = InboxMessage(**inbox_data)
self._session.add(inbox_msg)
await self._session.flush()
except SQLAlchemyError:
logger.exception("Failed to create inbox message")
raise HTTPException(status_code=503, detail="Failed to send invitation")
await self._session.commit()
return ScheduleItemShareResponse(
message=f"Invitation sent to {request.email}"
)
```
Add to `backend/src/v1/schedule_items/router.py`:
```python
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListItem,
ScheduleItemListRequest,
ScheduleItemResponse,
ScheduleItemShareRequest,
ScheduleItemShareResponse,
ScheduleItemUpdateRequest,
)
@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)
```
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_share.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/v1/schedule_items/
git commit -m "feat: add share calendar API"
```
---
## Task 3: Add accept invitation - create subscription
**Files:**
- Modify: `backend/src/v1/inbox_messages/service.py` - add subscription creation on accept
**Step 1: Write the failing test**
```python
# backend/tests/unit/v1/inbox_messages/test_accept_invitation.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from v1.inbox_messages.service import InboxMessageService
@pytest.mark.asyncio
async def test_accept_creates_subscription():
# Setup mocks
mock_repo = MagicMock()
mock_session = MagicMock()
mock_message = MagicMock()
mock_message.id = uuid4()
mock_message.message_type = "calendar"
mock_message.status = "pending"
mock_message.schedule_item_id = uuid4()
mock_repo.get_by_id = AsyncMock(return_value=mock_message)
mock_repo._session = mock_session
service = InboxMessageService(
repository=mock_repo,
session=mock_session,
current_user=MagicMock(user_id=uuid4()),
)
# This should be implemented
await service.accept_invitation(mock_message.id, ...)
```
**Step 2: Run test to verify it fails**
Expected: FAIL (test will fail because accept doesn't create subscription yet)
**Step 3: Write implementation**
Modify `backend/src/v1/inbox_messages/service.py` to import ScheduleSubscriptionRepository and create subscription on accept.
**Step 4: Run test to verify it passes**
Run tests and verify pass.
**Step 5: Commit**
---
## Task 4: Fix permission enum reference bug
**Files:**
- Modify: `backend/src/v1/inbox_messages/service.py` - fix InboxMessageStatus reference
**Bug:** In Task 1, we used `InboxMessageStatus.PENDING` but should check against the actual enum type. Fix the bug.
**Step 1: Write test to verify bug**
```python
def test_accept_checks_message_type_not_status():
# Current code incorrectly checks message_type == PENDING
# Should check status == PENDING
```
**Step 2: Fix the implementation**
---
## Task 5: Write unit tests
**Files:**
- Create: `backend/tests/unit/v1/inbox_messages/test_service.py`
- Create: `backend/tests/unit/v1/schedule_items/test_share.py`
---
## Task 6: Write integration tests
**Files:**
- Create: `backend/tests/integration/test_inbox_messages_routes.py`
- Create: `backend/tests/integration/test_schedule_share_routes.py`
---
## Task 7: Update API documentation
**Files:**
- Modify: `docs/runtime/runtime-route.md` - add share/inbox endpoints
---
## Task 8: Run all tests and fix issues
Run full test suite and fix any issues.
---
## Task 9: Run lint and typecheck
Run:
```bash
cd backend && uv run ruff check src/v1/schedule_items/ src/v1/inbox_messages/
cd backend && uv run basedpyright src/v1/schedule_items/ src/v1/inbox_messages/
```
---
## Task 10: Final commit and create PR
```bash
git add .
git commit -m "feat: add calendar sharing with invitations"
git push -u origin feature-calendar-sharing
gh pr create --title "feat: add calendar sharing" --body "..."
```
+109
View File
@@ -358,6 +358,115 @@
--- ---
### POST /schedule-items/{id}/share
分享日历事项给他人(需要认证)。
通过邮箱邀请其他用户,被邀请人将收到待办消息邀请。
**Request:**
```json
{
"email": "string (required, email of user to share with)",
"permission_view": "boolean (default: true)",
"permission_edit": "boolean (default: false)",
"permission_invite": "boolean (default: false)"
}
```
**Permission 位说明:**
| 权限 | 值 | 说明 |
|------|-----|------|
| view | 1 | 查看事项详情 |
| invite | 2 | 邀请其他人订阅此事项 |
| edit | 4 | 修改事项内容、管理订阅 |
可组合使用,如 view+edit = 5view+invite+edit = 7。
**Response:** 200 OK
```json
{
"message": "Invitation sent to user@example.com"
}
```
**Errors:**
- 401: 未认证
- 403: 非日历所有者无权分享
- 404: 日历事项不存在或用户不存在
---
## Inbox Messages
## Inbox Messages
### GET /inbox/messages
获取当前用户的待办消息列表(需要认证)。
**Query Parameters:**
- `status`: string (optional) - 过滤状态:`pending`/`accepted`/`rejected`/`dismissed`
**Response:** 200 OK
```json
[
{
"id": "uuid",
"recipient_id": "uuid",
"sender_id": "uuid?",
"message_type": "calendar",
"schedule_item_id": "uuid?",
"content": "string?",
"is_read": false,
"status": "pending",
"created_at": "2024-01-01T00:00:00Z"
}
]
```
**Errors:**
- 401: 未认证
---
### POST /inbox/messages/{id}/accept
接受邀请(需要认证)。
接受日历邀请时,会为当前用户创建订阅关系。
**Request:**
```json
{
"permission_view": "boolean (default: true)",
"permission_edit": "boolean (default: false)",
"permission_invite": "boolean (default: false)"
}
```
**Response:** 204 No Content
**Errors:**
- 401: 未认证
- 404: 消息不存在
- 400: 消息不是待处理状态或不是日历类型邀请
---
### POST /inbox/messages/{id}/dismiss
忽略邀请(需要认证)。
**Response:** 204 No Content
**Errors:**
- 401: 未认证
- 404: 消息不存在
- 400: 消息不是待处理状态
---
## Users ## Users
### GET /users/me ### GET /users/me