2026-02-28 12:28:45 +08:00
|
|
|
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 (
|
|
|
|
|
InboxMessageResponse,
|
|
|
|
|
InboxMessageStatus,
|
|
|
|
|
InboxMessageType,
|
|
|
|
|
)
|
|
|
|
|
from v1.inbox_messages.service import InboxMessageService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeInboxMessageService:
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
messages: list[InboxMessageResponse],
|
2026-03-11 15:28:29 +08:00
|
|
|
read_message: InboxMessageResponse,
|
2026-02-28 12:28:45 +08:00
|
|
|
) -> None:
|
|
|
|
|
self._messages = messages
|
2026-03-11 15:28:29 +08:00
|
|
|
self._read_message = read_message
|
2026-03-30 18:36:57 +08:00
|
|
|
self._stream_rows: list[dict[str, object]] = []
|
|
|
|
|
|
|
|
|
|
def set_stream_rows(self, rows: list[dict[str, object]]) -> None:
|
|
|
|
|
self._stream_rows = rows
|
|
|
|
|
|
|
|
|
|
def require_user_id(self) -> UUID:
|
|
|
|
|
return self._read_message.recipient_id
|
2026-02-28 12:28:45 +08:00
|
|
|
|
|
|
|
|
async def list_messages(
|
2026-03-11 15:28:29 +08:00
|
|
|
self, is_read: bool | None = None
|
2026-02-28 12:28:45 +08:00
|
|
|
) -> list[InboxMessageResponse]:
|
2026-03-11 15:28:29 +08:00
|
|
|
if is_read is None:
|
2026-02-28 12:28:45 +08:00
|
|
|
return self._messages
|
2026-03-11 15:28:29 +08:00
|
|
|
return [message for message in self._messages if message.is_read is is_read]
|
2026-02-28 12:28:45 +08:00
|
|
|
|
2026-03-11 15:28:29 +08:00
|
|
|
async def mark_as_read(self, message_id: UUID) -> InboxMessageResponse:
|
|
|
|
|
if message_id != self._read_message.id:
|
2026-02-28 12:28:45 +08:00
|
|
|
raise HTTPException(status_code=404, detail="Inbox message not found")
|
2026-03-11 15:28:29 +08:00
|
|
|
return self._read_message
|
2026-02-28 12:28:45 +08:00
|
|
|
|
2026-03-30 18:36:57 +08:00
|
|
|
async def stream_events(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
last_event_id: str | None,
|
|
|
|
|
) -> list[dict[str, object]]:
|
|
|
|
|
del last_event_id
|
|
|
|
|
rows = self._stream_rows
|
|
|
|
|
self._stream_rows = []
|
|
|
|
|
return rows
|
|
|
|
|
|
2026-02-28 12:28:45 +08:00
|
|
|
|
|
|
|
|
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(),
|
2026-03-30 18:36:57 +08:00
|
|
|
content={"permission": 1},
|
2026-02-28 12:28:45 +08:00
|
|
|
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)
|
2026-03-11 15:28:29 +08:00
|
|
|
read_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED)
|
|
|
|
|
read_message = read_message.model_copy(update={"is_read": True})
|
2026-02-28 12:28:45 +08:00
|
|
|
service = FakeInboxMessageService(
|
2026-03-11 15:28:29 +08:00
|
|
|
messages=[pending_message, read_message],
|
|
|
|
|
read_message=read_message,
|
2026-02-28 12:28:45 +08:00
|
|
|
)
|
|
|
|
|
app.dependency_overrides[get_inbox_message_service] = (
|
|
|
|
|
_override_inbox_message_service(service)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
2026-03-11 15:28:29 +08:00
|
|
|
response = client.get("/api/v1/inbox/messages", params={"is_read": "false"})
|
2026-02-28 12:28:45 +08:00
|
|
|
assert response.status_code == 200
|
|
|
|
|
body = response.json()
|
|
|
|
|
assert len(body) == 1
|
2026-03-11 15:28:29 +08:00
|
|
|
assert body[0]["is_read"] is False
|
2026-02-28 12:28:45 +08:00
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 15:28:29 +08:00
|
|
|
def test_mark_as_read_returns_200() -> None:
|
|
|
|
|
read_message = _build_message(uuid4(), InboxMessageStatus.PENDING)
|
|
|
|
|
read_message = read_message.model_copy(update={"is_read": True})
|
2026-02-28 12:28:45 +08:00
|
|
|
service = FakeInboxMessageService(
|
2026-03-11 15:28:29 +08:00
|
|
|
messages=[read_message],
|
|
|
|
|
read_message=read_message,
|
2026-02-28 12:28:45 +08:00
|
|
|
)
|
|
|
|
|
app.dependency_overrides[get_inbox_message_service] = (
|
|
|
|
|
_override_inbox_message_service(service)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
2026-03-11 15:28:29 +08:00
|
|
|
response = client.patch(f"/api/v1/inbox/messages/{read_message.id}/read")
|
2026-02-28 12:28:45 +08:00
|
|
|
assert response.status_code == 200
|
|
|
|
|
body = response.json()
|
2026-03-11 15:28:29 +08:00
|
|
|
assert body["id"] == str(read_message.id)
|
|
|
|
|
assert body["is_read"] is True
|
2026-02-28 12:28:45 +08:00
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
2026-03-30 18:36:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stream_inbox_events_returns_sse_payload() -> None:
|
|
|
|
|
read_message = _build_message(uuid4(), InboxMessageStatus.PENDING)
|
|
|
|
|
service = FakeInboxMessageService(
|
|
|
|
|
messages=[read_message], read_message=read_message
|
|
|
|
|
)
|
|
|
|
|
service.set_stream_rows(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"id": "1743313300000-0",
|
|
|
|
|
"event": {
|
|
|
|
|
"event_id": str(uuid4()),
|
|
|
|
|
"occurred_at": "2026-03-30T07:00:00+00:00",
|
|
|
|
|
"user_id": str(read_message.recipient_id),
|
|
|
|
|
"message_id": str(read_message.id),
|
|
|
|
|
"event_type": "INBOX_MESSAGE_CREATED",
|
|
|
|
|
"op": "created",
|
|
|
|
|
"version": 1743313300000,
|
|
|
|
|
"data": {"message": {"id": str(read_message.id)}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
app.dependency_overrides[get_inbox_message_service] = (
|
|
|
|
|
_override_inbox_message_service(service)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
|
|
|
|
response = client.get("/api/v1/inbox/messages/stream?idle_limit=1")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.headers["content-type"].startswith("text/event-stream")
|
|
|
|
|
payload = response.text
|
|
|
|
|
assert "event: INBOX_MESSAGE_CREATED" in payload
|
|
|
|
|
assert '"op":"created"' in payload
|
|
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stream_inbox_events_rejects_invalid_last_event_id() -> None:
|
|
|
|
|
read_message = _build_message(uuid4(), InboxMessageStatus.PENDING)
|
|
|
|
|
service = FakeInboxMessageService(
|
|
|
|
|
messages=[read_message], read_message=read_message
|
|
|
|
|
)
|
|
|
|
|
app.dependency_overrides[get_inbox_message_service] = (
|
|
|
|
|
_override_inbox_message_service(service)
|
|
|
|
|
)
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
|
|
|
|
response = client.get(
|
|
|
|
|
"/api/v1/inbox/messages/stream",
|
|
|
|
|
headers={"Last-Event-ID": "not-a-stream-id"},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
body = response.json()
|
|
|
|
|
assert body.get("code") == "INBOX_INVALID_LAST_EVENT_ID"
|
|
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|