feat: 增强日历功能并集成 AgentScope 代理服务
This commit is contained in:
@@ -10,45 +10,6 @@ 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."""
|
||||
|
||||
@@ -112,12 +73,18 @@ class TestFriendshipRepository:
|
||||
|
||||
mock_session.execute = AsyncMock(side_effect=mock_execute_func)
|
||||
|
||||
friendship, inbox = await repository.create_request(initiator_id, recipient_id)
|
||||
content = "你好,我是测试用户"
|
||||
friendship, inbox = await repository.create_request(
|
||||
initiator_id,
|
||||
recipient_id,
|
||||
content,
|
||||
)
|
||||
|
||||
assert friendship is not None
|
||||
assert inbox is not None
|
||||
assert friendship.initiator_id == initiator_id
|
||||
assert inbox.recipient_id == recipient_id
|
||||
assert inbox.content == content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_friendship_between_users_returns_friendship(
|
||||
|
||||
@@ -44,7 +44,10 @@ class FakeFriendshipRepo:
|
||||
self._inbox_messages = inbox_messages or []
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
self,
|
||||
initiator_id: UUID,
|
||||
recipient_id: UUID,
|
||||
content: str | None = None,
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
friendship = MagicMock(spec=Friendship)
|
||||
friendship.id = uuid4()
|
||||
@@ -62,7 +65,34 @@ class FakeFriendshipRepo:
|
||||
inbox.status = InboxMessageStatus.PENDING
|
||||
inbox.message_type = InboxMessageType.FRIEND_REQUEST
|
||||
inbox.friendship_id = friendship.id
|
||||
inbox.content = None
|
||||
inbox.content = content
|
||||
self._inbox_messages.append(inbox)
|
||||
|
||||
return friendship, inbox
|
||||
|
||||
async def reactivate_request(
|
||||
self,
|
||||
friendship: Friendship,
|
||||
initiator_id: UUID,
|
||||
content: str | None = None,
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
friendship.status = FriendshipStatus.PENDING
|
||||
friendship.initiator_id = initiator_id
|
||||
|
||||
recipient_id = (
|
||||
friendship.user_low_id
|
||||
if initiator_id == friendship.user_high_id
|
||||
else friendship.user_high_id
|
||||
)
|
||||
|
||||
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 = content
|
||||
self._inbox_messages.append(inbox)
|
||||
|
||||
return friendship, inbox
|
||||
@@ -124,12 +154,6 @@ class FakeUserRepo:
|
||||
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()
|
||||
@@ -189,6 +213,28 @@ class TestSendRequest:
|
||||
assert result is not None
|
||||
mock_session.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_request_persists_content_to_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,
|
||||
)
|
||||
|
||||
content = "你好,我是张三"
|
||||
result = await service.send_request(
|
||||
FriendRequestCreate(target_user_id=USER_B, content=content)
|
||||
)
|
||||
|
||||
assert result.content == content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_request_to_self_raises_400(
|
||||
self,
|
||||
|
||||
@@ -56,14 +56,14 @@ async def test_list_by_recipient_returns_messages() -> None:
|
||||
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")
|
||||
result = await repository.list_by_recipient(uuid4(), False)
|
||||
|
||||
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:
|
||||
async def test_mark_as_read_returns_updated_message_and_flushes() -> None:
|
||||
session = AsyncMock()
|
||||
repository = SQLAlchemyInboxMessageRepository(session)
|
||||
updated = MagicMock()
|
||||
@@ -71,7 +71,7 @@ async def test_update_status_returns_updated_message_and_flushes() -> None:
|
||||
execute_result.scalar_one_or_none.return_value = updated
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
result = await repository.update_status(uuid4(), uuid4(), "dismissed")
|
||||
result = await repository.mark_as_read(uuid4(), uuid4())
|
||||
|
||||
assert result is updated
|
||||
session.execute.assert_awaited_once()
|
||||
|
||||
@@ -2,7 +2,6 @@ from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from v1.inbox_messages.schemas import (
|
||||
InboxMessageAcceptRequest,
|
||||
InboxMessageResponse,
|
||||
InboxMessageStatus,
|
||||
InboxMessageType,
|
||||
@@ -25,14 +24,3 @@ def test_inbox_message_response_schema() -> None:
|
||||
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 (
|
||||
@@ -11,8 +12,6 @@ from models.inbox_messages import (
|
||||
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
|
||||
|
||||
|
||||
@@ -31,6 +30,7 @@ def _build_message(
|
||||
message.sender_id = uuid4()
|
||||
message.message_type = message_type
|
||||
message.schedule_item_id = schedule_item_id
|
||||
message.friendship_id = None
|
||||
message.content = content
|
||||
message.is_read = False
|
||||
message.status = status
|
||||
@@ -56,7 +56,7 @@ async def test_list_messages_returns_messages() -> None:
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
result = await service.list_messages(InboxMessageListRequest())
|
||||
result = await service.list_messages()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].recipient_id == user_id
|
||||
@@ -65,28 +65,21 @@ async def test_list_messages_returns_messages() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_invitation_creates_subscription() -> None:
|
||||
async def test_mark_as_read_updates_message() -> None:
|
||||
user_id = uuid4()
|
||||
message_id = uuid4()
|
||||
item_id = uuid4()
|
||||
pending_message = _build_message(
|
||||
updated_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,
|
||||
status=InboxMessageModelStatus.PENDING,
|
||||
schedule_item_id=uuid4(),
|
||||
)
|
||||
updated_message.is_read = True
|
||||
|
||||
repo = AsyncMock()
|
||||
repo.get_by_id.return_value = pending_message
|
||||
repo.update_status.return_value = accepted_message
|
||||
repo.mark_as_read.return_value = updated_message
|
||||
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
|
||||
service = InboxMessageService(
|
||||
repository=repo,
|
||||
@@ -94,46 +87,20 @@ async def test_accept_invitation_creates_subscription() -> None:
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
result = await service.accept_invitation(
|
||||
message_id,
|
||||
InboxMessageAcceptRequest(
|
||||
permission_view=True,
|
||||
permission_edit=True,
|
||||
permission_invite=False,
|
||||
),
|
||||
)
|
||||
result = await service.mark_as_read(message_id)
|
||||
|
||||
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")
|
||||
repo.mark_as_read.assert_awaited_once_with(message_id, user_id)
|
||||
session.commit.assert_awaited_once()
|
||||
assert result.status.value == "accepted"
|
||||
assert result.is_read is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismiss_invitation_updates_status() -> None:
|
||||
async def test_mark_as_read_raises_404_when_message_missing() -> 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
|
||||
repo.mark_as_read.return_value = None
|
||||
|
||||
session = AsyncMock()
|
||||
service = InboxMessageService(
|
||||
@@ -142,29 +109,23 @@ async def test_dismiss_invitation_updates_status() -> None:
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
result = await service.dismiss_invitation(message_id)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.mark_as_read(message_id)
|
||||
|
||||
repo.update_status.assert_awaited_once_with(message_id, user_id, "dismissed")
|
||||
session.commit.assert_awaited_once()
|
||||
assert result.status.value == "dismissed"
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Inbox message not found"
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_noncalendar_message_fails() -> None:
|
||||
async def test_mark_as_read_store_error_returns_503() -> 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
|
||||
repo.mark_as_read.side_effect = SQLAlchemyError("boom")
|
||||
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
|
||||
service = InboxMessageService(
|
||||
repository=repo,
|
||||
@@ -173,9 +134,8 @@ async def test_accept_noncalendar_message_fails() -> None:
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.accept_invitation(message_id, InboxMessageAcceptRequest())
|
||||
await service.mark_as_read(message_id)
|
||||
|
||||
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()
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Inbox message store unavailable"
|
||||
session.rollback.assert_awaited_once()
|
||||
|
||||
@@ -86,3 +86,30 @@ def test_metadata_attachment_reminder() -> None:
|
||||
)
|
||||
assert attachment.type == AttachmentType.REMINDER
|
||||
assert attachment.content == "Don't forget!"
|
||||
|
||||
|
||||
def test_metadata_rejects_invalid_color() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleItemMetadata(color="blue")
|
||||
|
||||
|
||||
def test_metadata_rejects_invalid_version() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleItemMetadata(version=2)
|
||||
|
||||
|
||||
def test_metadata_rejects_unknown_field() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleItemMetadata.model_validate({"color": "#FF6B6B", "unknown": True})
|
||||
|
||||
|
||||
def test_metadata_attachment_rejects_unknown_field() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleItemMetadataAttachment.model_validate(
|
||||
{
|
||||
"name": "memo",
|
||||
"type": "document",
|
||||
"url": "https://example.com",
|
||||
"unexpected": "x",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from models.schedule_items import (
|
||||
)
|
||||
from v1.schedule_items.schemas import (
|
||||
ScheduleItemCreateRequest,
|
||||
ScheduleItemMetadata,
|
||||
ScheduleItemUpdateRequest,
|
||||
)
|
||||
from v1.schedule_items.service import ScheduleItemService
|
||||
@@ -50,6 +51,11 @@ class FakeRepo:
|
||||
return self._item
|
||||
return None
|
||||
|
||||
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None:
|
||||
if self._item and entity_id == self._item.id:
|
||||
return self._item
|
||||
return None
|
||||
|
||||
async def create(self, data: dict) -> ScheduleItem:
|
||||
return _create_mock_schedule_item(
|
||||
owner_id=data["owner_id"],
|
||||
@@ -77,6 +83,20 @@ class FakeRepo:
|
||||
) -> list[ScheduleItem]:
|
||||
return [self._item] if self._item else []
|
||||
|
||||
async def list_paginated(
|
||||
self,
|
||||
owner_id: UUID,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[ScheduleItem], int]:
|
||||
del owner_id, page, page_size
|
||||
return ([self._item] if self._item else [], 1 if self._item else 0)
|
||||
|
||||
async def create_subscription(self, data: dict):
|
||||
del data
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session() -> AsyncMock:
|
||||
@@ -183,3 +203,70 @@ async def test_delete_success(mock_session: AsyncMock) -> None:
|
||||
await service.delete(item.id)
|
||||
|
||||
mock_session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_maps_metadata_to_extra_metadata(mock_session: AsyncMock) -> None:
|
||||
user_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
captured: dict | None = None
|
||||
|
||||
class CaptureRepo(FakeRepo):
|
||||
async def create(self, data: dict) -> ScheduleItem:
|
||||
nonlocal captured
|
||||
captured = data
|
||||
return _create_mock_schedule_item(
|
||||
owner_id=data["owner_id"], title=data["title"]
|
||||
)
|
||||
|
||||
request = ScheduleItemCreateRequest(
|
||||
title="Roadmap",
|
||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||
metadata=ScheduleItemMetadata(location="会议室A", color="#4F46E5", version=1),
|
||||
)
|
||||
service = ScheduleItemService(
|
||||
repository=CaptureRepo(None),
|
||||
session=mock_session,
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
await service.create(request)
|
||||
|
||||
assert captured is not None
|
||||
assert "extra_metadata" in captured
|
||||
assert captured["extra_metadata"]["location"] == "会议室A"
|
||||
assert "metadata" not in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_maps_metadata_to_extra_metadata(mock_session: AsyncMock) -> None:
|
||||
user_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
item = _create_mock_schedule_item()
|
||||
captured: dict | None = None
|
||||
|
||||
class CaptureRepo(FakeRepo):
|
||||
async def update_by_item_id(
|
||||
self, item_id: UUID, owner_id: UUID, data: dict
|
||||
) -> ScheduleItem | None:
|
||||
nonlocal captured
|
||||
captured = data
|
||||
return await super().update_by_item_id(item_id, owner_id, data)
|
||||
|
||||
service = ScheduleItemService(
|
||||
repository=CaptureRepo(item),
|
||||
session=mock_session,
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
await service.update(
|
||||
item.id,
|
||||
ScheduleItemUpdateRequest(
|
||||
metadata=ScheduleItemMetadata(
|
||||
location="线上会议", color="#3B82F6", version=1
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert captured is not None
|
||||
assert "extra_metadata" in captured
|
||||
assert captured["extra_metadata"]["location"] == "线上会议"
|
||||
assert "metadata" not in captured
|
||||
|
||||
Reference in New Issue
Block a user