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
@@ -4,7 +4,7 @@ import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
from typing import Any, cast
from uuid import UUID, uuid4
import pytest
@@ -27,6 +27,7 @@ class _FakeService:
created_request: Any = None
created_id: str = field(default_factory=lambda: str(uuid4()))
list_calls: list[dict[str, Any]] = field(default_factory=list)
range_calls: list[dict[str, Any]] = field(default_factory=list)
deleted_ids: list[str] = field(default_factory=list)
async def list_paginated(
@@ -47,6 +48,29 @@ class _FakeService:
)
return [item], 1
async def list_by_date_range(self, request: Any):
self.range_calls.append(
{
"start_at": request.start_at,
"end_at": request.end_at,
}
)
return [
SimpleNamespace(
id=UUID(self.created_id),
owner_id=uuid4(),
title="会议",
description="今天下午五点的会议",
start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
status="active",
source_type="manual",
metadata=None,
subscribers=[],
)
]
async def create_agent_generated(self, request):
self.created_request = request
return SimpleNamespace(
@@ -235,22 +259,48 @@ async def test_calendar_read_returns_structured_result_with_ids(
)
result = await calendar_module.calendar_read(
query="会议",
page=1,
page_size=20,
start_at="2026-03-17T00:00:00+08:00",
end_at="2026-03-18T00:00:00+08:00",
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
result_data = json.loads(payload["result"])
assert payload["status"] == "success"
assert result_data["total"] == 1
assert result_data["items"][0]["id"] == fake_service.created_id
assert result_data["items"][0]["timezone"] == "Asia/Shanghai"
assert result_data["items"][0]["description"] == "今天下午五点的会议"
assert result_data["items"][0]["status"] == "active"
assert fake_service.range_calls == [
{
"start_at": datetime(2026, 3, 16, 16, 0, tzinfo=timezone.utc),
"end_at": datetime(2026, 3, 17, 16, 0, tzinfo=timezone.utc),
}
]
@pytest.mark.asyncio
async def test_calendar_read_rejects_naive_datetime_string(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_read(
start_at="2026-03-17T00:00:00",
end_at="2026-03-18T00:00:00+08:00",
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success")
assert "total=1" in payload["result"]
assert "timezone=Asia/Shanghai" in payload["result"]
assert "description=今天下午五点的会议" in payload["result"]
assert "status=active" in payload["result"]
assert fake_service.created_id in payload["result"]
assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}]
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "时区" in payload["error"]["message"]
@pytest.mark.asyncio
@@ -312,3 +362,39 @@ async def test_calendar_share_rejects_invalid_phone(
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_share_accepts_json_invitee_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=cast(
Any,
[
{
"phone": "8613900001234",
"permissionView": True,
"permissionEdit": False,
"permissionInvite": False,
}
],
),
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success success=1 failed=0")
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@@ -0,0 +1,96 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from core.runtime import cli
class _FakeScheduler:
def __init__(self) -> None:
self.started = False
self.shutdown_called = False
self.jobs: list[dict[str, Any]] = []
def add_job(self, func: Any, **kwargs: Any) -> None:
self.jobs.append({"func": func, **kwargs})
def start(self) -> None:
self.started = True
def shutdown(self, *, wait: bool) -> None:
self.shutdown_called = True
self.shutdown_wait = wait
class _StopEvent:
async def wait(self) -> None:
raise asyncio.CancelledError
@pytest.mark.asyncio
async def test_run_automation_scheduler_forever_uses_async_scheduler(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_scheduler = _FakeScheduler()
dispatch_limits: list[int] = []
async def _fake_scan(*, limit: int) -> None:
dispatch_limits.append(limit)
monkeypatch.setattr(cli, "AsyncIOScheduler", lambda: fake_scheduler)
monkeypatch.setattr(cli, "run_automation_scheduler_scan", _fake_scan)
monkeypatch.setattr(cli.asyncio, "Event", lambda: _StopEvent())
settings = cli.config.automation_scheduler
old_enabled = settings.enabled
old_interval = settings.interval_seconds
old_limit = settings.batch_limit
settings.enabled = True
settings.interval_seconds = 9
settings.batch_limit = 7
try:
with pytest.raises(asyncio.CancelledError):
await cli.run_automation_scheduler_forever()
finally:
settings.enabled = old_enabled
settings.interval_seconds = old_interval
settings.batch_limit = old_limit
assert fake_scheduler.started is True
assert fake_scheduler.shutdown_called is True
assert len(fake_scheduler.jobs) == 1
assert fake_scheduler.jobs[0]["max_instances"] == 1
assert fake_scheduler.jobs[0]["coalesce"] is True
scan_job = fake_scheduler.jobs[0]["func"]
await scan_job()
assert dispatch_limits == [7]
@pytest.mark.asyncio
async def test_run_automation_scheduler_forever_disabled_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = cli.config.automation_scheduler
old_enabled = settings.enabled
settings.enabled = False
called = False
def _unexpected_scheduler() -> _FakeScheduler:
nonlocal called
called = True
return _FakeScheduler()
monkeypatch.setattr(cli, "AsyncIOScheduler", _unexpected_scheduler)
try:
await cli.run_automation_scheduler_forever()
finally:
settings.enabled = old_enabled
assert called is False
@@ -1,6 +1,6 @@
from __future__ import annotations
from schemas.agent.runtime_models import RouterAgentOutput
from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputRich
def test_router_agent_output_coerces_key_entity_value_to_string() -> None:
@@ -32,3 +32,59 @@ def test_router_agent_output_coerces_key_entity_value_to_string() -> None:
model = RouterAgentOutput.model_validate(payload)
assert model.key_entities[0].value == "8"
def test_router_agent_output_coerces_constraint_value_to_string() -> None:
payload = {
"normalized_task_input": {
"user_text": "test",
"multimodal_summary": [],
"context_summary": "",
},
"key_entities": [],
"constraints": [
{
"key": "strict_mode",
"value": True,
"required": True,
}
],
"task_typing": {
"primary": "planning",
"secondary": [],
},
"execution_mode": "onestep",
"result_typing": {
"primary": "summary",
"secondary": [],
},
}
model = RouterAgentOutput.model_validate(payload)
assert model.constraints[0].value == "True"
def test_worker_agent_output_rich_accepts_list_item_status_object() -> None:
payload = {
"status": "success",
"answer": "done",
"result_type": "summary",
"ui_hints": {
"intent": "status",
"status": "info",
"title": "状态",
"listItems": [
{
"title": "任务A",
"status": {"type": "info", "value": "已归档"},
}
],
},
}
model = WorkerAgentOutputRich.model_validate(payload)
assert model.ui_hints is not None
assert model.ui_hints.list_items[0].status is not None
assert model.ui_hints.list_items[0].status.value == "info"
@@ -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: