feat: 重构 Reminder Notification 系统并更新应用包名

This commit is contained in:
qzl
2026-03-30 18:36:57 +08:00
parent 9fb2a6857b
commit 91bf3c3f96
90 changed files with 5133 additions and 3017 deletions
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from typing import cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
@@ -64,10 +64,14 @@ class FakeFriendshipRepo:
inbox.id = uuid4()
inbox.recipient_id = recipient_id
inbox.sender_id = initiator_id
inbox.schedule_item_id = None
inbox.status = InboxMessageStatus.PENDING
inbox.message_type = InboxMessageType.FRIEND_REQUEST
inbox.friendship_id = friendship.id
inbox.content = {"type": "request", "message": content}
inbox.is_read = False
inbox.created_at = datetime.now(timezone.utc)
inbox.updated_at = datetime.now(timezone.utc)
self._inbox_messages.append(inbox)
return friendship, inbox
@@ -91,10 +95,14 @@ class FakeFriendshipRepo:
inbox.id = uuid4()
inbox.recipient_id = recipient_id
inbox.sender_id = initiator_id
inbox.schedule_item_id = None
inbox.status = InboxMessageStatus.PENDING
inbox.message_type = InboxMessageType.FRIEND_REQUEST
inbox.friendship_id = friendship.id
inbox.content = {"type": "request", "message": content}
inbox.is_read = False
inbox.created_at = datetime.now(timezone.utc)
inbox.updated_at = datetime.now(timezone.utc)
self._inbox_messages.append(inbox)
return friendship, inbox
@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from models.inbox_messages import InboxMessage
from schemas.enums import InboxMessageStatus, InboxMessageType
from v1.inbox_messages import realtime
class _FakeRedis:
def __init__(self) -> None:
self.last_stream: str | None = None
self.last_payload: str | None = None
self.last_block: int | None = None
async def xadd(self, stream: str, fields: dict[str, str]) -> str:
self.last_stream = stream
self.last_payload = fields.get("event")
return "1743313300000-0"
async def xread(self, _streams: dict[str, str], count: int, block: int):
del count
self.last_block = block
return [
(
"inbox:events:test",
[
(
"1743313300000-0",
{
"event": '{"event_id":"e1","event_type":"INBOX_MESSAGE_CREATED","op":"created"}',
},
)
],
)
]
@pytest.mark.asyncio
async def test_publish_inbox_message_created_writes_stream(monkeypatch) -> None:
fake_redis = _FakeRedis()
async def _fake_get_redis():
return fake_redis
monkeypatch.setattr(realtime, "get_or_init_redis_client", _fake_get_redis)
message = InboxMessage(
id=uuid4(),
recipient_id=uuid4(),
sender_id=uuid4(),
message_type=InboxMessageType.CALENDAR,
friendship_id=None,
schedule_item_id=uuid4(),
group_id=None,
content={"type": "invite"},
is_read=False,
status=InboxMessageStatus.PENDING,
created_by=uuid4(),
)
message.created_at = datetime(2026, 3, 30, 7, 0, tzinfo=UTC)
message.updated_at = datetime(2026, 3, 30, 7, 0, tzinfo=UTC)
stream_id = await realtime.publish_inbox_message_created(message)
assert stream_id == "1743313300000-0"
assert fake_redis.last_stream == f"inbox:events:{message.recipient_id}"
assert fake_redis.last_payload is not None
assert '"op":"created"' in fake_redis.last_payload
@pytest.mark.asyncio
async def test_read_inbox_events_decodes_rows(monkeypatch) -> None:
fake_redis = _FakeRedis()
async def _fake_get_redis():
return fake_redis
monkeypatch.setattr(realtime, "get_or_init_redis_client", _fake_get_redis)
rows = await realtime.read_inbox_events(
recipient_id=uuid4(),
last_event_id=None,
)
assert len(rows) == 1
assert rows[0]["id"] == "1743313300000-0"
assert rows[0]["event"]["event_type"] == "INBOX_MESSAGE_CREATED"
@pytest.mark.asyncio
async def test_read_inbox_events_handles_redis_timeout(monkeypatch) -> None:
class _TimeoutRedis(_FakeRedis):
async def xread(self, _streams: dict[str, str], count: int, block: int):
del _streams, count, block
raise TimeoutError("read timeout")
fake_redis = _TimeoutRedis()
async def _fake_get_redis():
return fake_redis
monkeypatch.setattr(realtime, "get_or_init_redis_client", _fake_get_redis)
rows = await realtime.read_inbox_events(recipient_id=uuid4(), last_event_id=None)
assert rows == []
@@ -59,6 +59,9 @@ class FakeRepo:
return self._item
return None
async def get_item(self, item_id: UUID) -> ScheduleItem | None:
return await self.get_by_id(item_id)
async def create(self, data: dict) -> ScheduleItem:
return _create_mock_schedule_item(
owner_id=data["owner_id"],
@@ -74,6 +77,23 @@ class FakeRepo:
self._item.title = data["title"]
return self._item
async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None:
if self._item is None:
return None
if "title" in data:
self._item.title = data["title"]
if "description" in data:
self._item.description = data["description"]
if "start_at" in data:
self._item.start_at = data["start_at"]
if "end_at" in data:
self._item.end_at = data["end_at"]
if "timezone" in data:
self._item.timezone = data["timezone"]
if "extra_metadata" in data:
self._item.extra_metadata = data["extra_metadata"]
return self._item
async def delete_by_item_id(
self, item_id: UUID, owner_id: UUID
) -> ScheduleItem | None:
@@ -81,6 +101,9 @@ class FakeRepo:
return None
return self._item
async def delete_item(self, item_id: UUID) -> None:
del item_id
async def list_by_date_range(
self, owner_id: UUID, start_at: datetime, end_at: datetime
) -> list[ScheduleItem]:
@@ -327,12 +350,11 @@ async def test_update_maps_metadata_to_extra_metadata(
captured: dict | None = None
class CaptureRepo(FakeRepo):
async def update_by_item_id(
self, item_id: UUID, owner_id: UUID, data: dict
) -> ScheduleItem | None:
async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None:
nonlocal captured
del item_id
captured = data
return await super().update_by_item_id(item_id, owner_id, data)
return await super().update_item(item.id, data)
service = ScheduleItemService(
repository=CaptureRepo(item),
@@ -370,12 +392,11 @@ async def test_update_maps_null_metadata_to_extra_metadata_null(
captured: dict | None = None
class CaptureRepo(FakeRepo):
async def update_by_item_id(
self, item_id: UUID, owner_id: UUID, data: dict
) -> ScheduleItem | None:
async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None:
nonlocal captured
del item_id
captured = data
return await super().update_by_item_id(item_id, owner_id, data)
return await super().update_item(item.id, data)
service = ScheduleItemService(
repository=CaptureRepo(item),
@@ -157,6 +157,14 @@ class FriendshipRepoStub:
return friendship
class UserRepoStub:
async def get_by_user_id(self, user_id: UUID):
profile = MagicMock()
profile.id = user_id
profile.username = "owner"
return profile
@pytest.mark.asyncio
async def test_share_forbidden_when_not_owner() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
@@ -172,6 +180,7 @@ async def test_share_forbidden_when_not_owner() -> None:
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
user_repository=cast(Any, UserRepoStub()),
)
with pytest.raises(ApiProblemError) as exc_info:
@@ -204,6 +213,7 @@ async def test_share_success_creates_calendar_invitation_message() -> None:
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
user_repository=cast(Any, UserRepoStub()),
)
result = await service.share(
@@ -223,7 +233,17 @@ async def test_share_success_creates_calendar_invitation_message() -> None:
assert message.sender_id == owner_id
assert message.schedule_item_id == item_id
assert message.message_type == InboxMessageType.CALENDAR
assert message.content == {"type": "invite", "permission": 5, "action": "pending"}
assert message.content is not None
assert message.content["type"] == "invite"
assert message.content["schema_version"] == 2
assert message.content["permission"] == 5
assert message.content["item"]["id"] == str(item_id)
assert message.content["item"]["title"] == "test"
assert message.content["item"]["start_at"] == "2026-02-28T16:00:00+00:00"
assert message.content["item"]["end_at"] is None
assert message.content["item"]["timezone"] == "UTC"
assert message.content["actor"]["username"] == "owner"
assert message.content["actor"]["phone"] == "+8613810000000"
session.commit.assert_awaited_once()
@@ -237,6 +257,7 @@ async def test_share_returns_not_found_when_item_missing() -> None:
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
user_repository=cast(Any, UserRepoStub()),
)
with pytest.raises(ApiProblemError) as exc_info:
@@ -268,6 +289,7 @@ async def test_share_invalid_auth_user_id_returns_503() -> None:
auth_gateway=cast(Any, AuthGatewayInvalidIdStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
user_repository=cast(Any, UserRepoStub()),
)
with pytest.raises(ApiProblemError) as exc_info:
@@ -302,6 +324,7 @@ async def test_share_sqlalchemy_error_rolls_back() -> None:
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
user_repository=cast(Any, UserRepoStub()),
)
with pytest.raises(ApiProblemError) as exc_info:
@@ -334,6 +357,7 @@ async def test_share_returns_forbidden_when_target_is_not_friend() -> None:
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub(accepted=False)),
user_repository=cast(Any, UserRepoStub()),
)
with pytest.raises(ApiProblemError) as exc_info: