Merge branch 'feature-calendar-sharing' into dev
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user