feat: 日历分享改为按手机号+好友关系校验
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user