feat: 日历分享改为按手机号+好友关系校验

This commit is contained in:
qzl
2026-03-30 11:37:41 +08:00
parent 60318b7aaa
commit 9fb2a6857b
20 changed files with 624 additions and 230 deletions
@@ -261,21 +261,12 @@ async def test_calendar_share_executes_with_valid_invitee(
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
target_user_id = str(uuid4())
monkeypatch.setattr(
calendar_module,
"resolve_share_target_phone_map",
lambda user_ids: {target_user_id: "+8613900001234"}
if target_user_id in user_ids
else {},
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=[
calendar_module.CalendarShareInvitee(
userId=target_user_id,
phone="13900001234",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
@@ -287,9 +278,37 @@ async def test_calendar_share_executes_with_valid_invitee(
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success invited_count=1")
assert payload["result"].startswith("status=success success=1 failed=0")
assert "+8613900001234" in payload["result"]
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"
@pytest.mark.asyncio
async def test_calendar_share_rejects_invalid_phone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_share(
event_id=str(uuid4()),
invitees=[
calendar_module.CalendarShareInvitee(
phone="12345",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
@@ -0,0 +1,63 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom.user_lookup import user_lookup
import core.agentscope.tools.custom.user_lookup as user_lookup_module
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
@pytest.mark.asyncio
async def test_user_lookup_requires_runtime_context() -> None:
result = await user_lookup()
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_user_lookup_returns_friend_contacts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_list_friend_contacts(**_: Any) -> list[dict[str, str]]:
return [
{
"userId": "00000000-0000-0000-0000-000000000101",
"username": "alice",
"phone": "+8613900000001",
},
{
"userId": "00000000-0000-0000-0000-000000000102",
"username": "bob",
"phone": "+8613900000002",
},
]
monkeypatch.setattr(
user_lookup_module,
"_list_friend_contacts",
_fake_list_friend_contacts,
)
result = await user_lookup(session=SimpleNamespace(), owner_id=uuid4())
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert "friends_count=2" in payload["result"]
assert "username=alice" in payload["result"]
assert "+8613900000001" in payload["result"]
@@ -1,17 +1,18 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from typing import Any, 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.http.errors import ApiProblemError
from core.auth.models import CurrentUser
from models.inbox_messages import InboxMessage, InboxMessageType
from models.schedule_items import ScheduleItem
from schemas.enums import FriendshipStatus
from v1.auth.schemas import UserByPhoneResponse
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemShareRequest
@@ -79,6 +80,14 @@ class AuthGatewayStub:
phone_confirmed_at=None,
)
async def get_user_by_id(self, user_id: str) -> UserByPhoneResponse:
return UserByPhoneResponse(
id=user_id,
phone="+8613810000000",
created_at="2026-02-28T10:00:00Z",
phone_confirmed_at=None,
)
class InboxRepoStub:
async def create(self, data: dict[str, object]) -> InboxMessage:
@@ -127,6 +136,26 @@ class AuthGatewayInvalidIdStub:
phone_confirmed_at=None,
)
async def get_user_by_id(self, user_id: str) -> UserByPhoneResponse:
return UserByPhoneResponse(
id=user_id,
phone="+8613810000000",
created_at="2026-02-28T10:00:00Z",
phone_confirmed_at=None,
)
class FriendshipRepoStub:
def __init__(self, accepted: bool = True) -> None:
self._accepted = accepted
async def get_friendship_between_users(self, user_id_1: UUID, user_id_2: UUID):
if not self._accepted:
return None
friendship = MagicMock()
friendship.status = FriendshipStatus.ACCEPTED
return friendship
@pytest.mark.asyncio
async def test_share_forbidden_when_not_owner() -> None:
@@ -140,11 +169,12 @@ async def test_share_forbidden_when_not_owner() -> None:
),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
@@ -171,8 +201,9 @@ async def test_share_success_creates_calendar_invitation_message() -> None:
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
result = await service.share(
@@ -203,11 +234,12 @@ async def test_share_returns_not_found_when_item_missing() -> None:
repository=cast(ScheduleItemRepository, ShareRepo(None)),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
uuid4(),
ScheduleItemShareRequest(
@@ -233,11 +265,12 @@ async def test_share_invalid_auth_user_id_returns_503() -> None:
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayInvalidIdStub(),
auth_gateway=cast(Any, AuthGatewayInvalidIdStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
@@ -266,11 +299,12 @@ async def test_share_sqlalchemy_error_rolls_back() -> None:
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
@@ -284,3 +318,34 @@ async def test_share_sqlalchemy_error_rolls_back() -> None:
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Schedule item store unavailable"
session.rollback.assert_awaited_once()
@pytest.mark.asyncio
async def test_share_returns_forbidden_when_target_is_not_friend() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
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=owner_id),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub(accepted=False)),
)
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
phone="+8613810000000",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "You can only share calendar with accepted friends"
@@ -0,0 +1,71 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from v1.auth.schemas import UserByIdResponse
from v1.users.contact_resolver import resolve_contacts_by_user_ids
class _AuthGatewayStub:
def __init__(self, responses: dict[str, UserByIdResponse]) -> None:
self._responses = responses
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
response = self._responses.get(user_id)
if response is None:
raise RuntimeError("missing")
return response
@pytest.mark.asyncio
async def test_resolve_contacts_by_user_ids_builds_contact_map() -> None:
user_id = uuid4()
profile = SimpleNamespace(
id=user_id,
username="alice",
avatar_url="https://img.example/a.png",
)
gateway = _AuthGatewayStub(
{
str(user_id): UserByIdResponse(
id=str(user_id),
phone="+8613900001001",
created_at="2026-01-01T00:00:00Z",
phone_confirmed_at=None,
)
}
)
contacts = await resolve_contacts_by_user_ids(
user_ids=[user_id],
profiles_by_id=cast(dict[Any, Any], {user_id: profile}),
auth_gateway=gateway,
)
assert str(contacts[user_id].user_id) == str(user_id)
assert contacts[user_id].username == "alice"
assert contacts[user_id].avatar_url == "https://img.example/a.png"
assert contacts[user_id].phone == "+8613900001001"
@pytest.mark.asyncio
async def test_resolve_contacts_by_user_ids_keeps_profile_on_auth_failure() -> None:
user_id = uuid4()
profile = SimpleNamespace(
id=user_id,
username="bob",
avatar_url=None,
)
gateway = _AuthGatewayStub({})
contacts = await resolve_contacts_by_user_ids(
user_ids=[user_id],
profiles_by_id=cast(dict[Any, Any], {user_id: profile}),
auth_gateway=gateway,
)
assert contacts[user_id].username == "bob"
assert contacts[user_id].phone is None