f0af44d840
- Update agent router/service/repository with new endpoints - Update auth routes with phone-based authentication - Update users service with new phone lookup - Update schedule_items with new schemas - Update message schemas with visibility support - Update settings with new automation scheduler config - Update CLI with new commands - Update tests to match new API contracts
196 lines
5.8 KiB
Python
196 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from core.auth.models import CurrentUser
|
|
from v1.users.schemas import UserSearchRequest, UserUpdateRequest
|
|
from v1.users.service import UserService
|
|
|
|
|
|
@dataclass
|
|
class _FakeProfile:
|
|
id: object
|
|
username: str
|
|
avatar_url: str | None
|
|
bio: str | None
|
|
settings: dict | None = None
|
|
|
|
|
|
class _FakeRepository:
|
|
def __init__(self, profile: _FakeProfile | None) -> None:
|
|
self._profile = profile
|
|
self.update_calls: list[tuple[object, dict[str, str | None]]] = []
|
|
|
|
async def update_by_user_id(
|
|
self, user_id: object, update_data: dict[str, str | None]
|
|
):
|
|
self.update_calls.append((user_id, update_data))
|
|
if self._profile is None:
|
|
return None
|
|
return _FakeProfile(
|
|
id=self._profile.id,
|
|
username=update_data.get("username") or self._profile.username,
|
|
avatar_url=update_data.get("avatar_url")
|
|
if "avatar_url" in update_data
|
|
else self._profile.avatar_url,
|
|
bio=update_data.get("bio") if "bio" in update_data else self._profile.bio,
|
|
)
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self) -> None:
|
|
self.commit_called = 0
|
|
self.rollback_called = 0
|
|
|
|
async def commit(self) -> None:
|
|
self.commit_called += 1
|
|
|
|
async def rollback(self) -> None:
|
|
self.rollback_called += 1
|
|
|
|
|
|
class _FakeSearchRepository:
|
|
def __init__(self, profiles: list[_FakeProfile]) -> None:
|
|
self._profiles_by_id = {profile.id: profile for profile in profiles}
|
|
|
|
async def get_by_user_ids(
|
|
self, user_ids: list[object]
|
|
) -> dict[object, _FakeProfile]:
|
|
return {
|
|
user_id: self._profiles_by_id[user_id]
|
|
for user_id in user_ids
|
|
if user_id in self._profiles_by_id
|
|
}
|
|
|
|
async def search_users(self, query: str, limit: int = 20) -> list[_FakeProfile]:
|
|
_ = limit
|
|
return [
|
|
profile
|
|
for profile in self._profiles_by_id.values()
|
|
if query.lower() in profile.username.lower()
|
|
]
|
|
|
|
|
|
class _FakeAuthLookup:
|
|
def __init__(self, mapping: dict[str, list[str]]) -> None:
|
|
self.mapping = mapping
|
|
|
|
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
|
_ = limit
|
|
return self.mapping.get(query, [])
|
|
|
|
|
|
class _FakeUserContextCache:
|
|
def __init__(self, *, should_fail: bool = False) -> None:
|
|
self.should_fail = should_fail
|
|
self.invalidated_user_ids: list[object] = []
|
|
|
|
async def invalidate_user(self, *, user_id: object) -> int:
|
|
self.invalidated_user_ids.append(user_id)
|
|
if self.should_fail:
|
|
raise RuntimeError("cache down")
|
|
return 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_me_invalidates_user_context_cache() -> None:
|
|
user_id = uuid4()
|
|
repo = _FakeRepository(
|
|
_FakeProfile(id=user_id, username="old", avatar_url=None, bio=None)
|
|
)
|
|
session = _FakeSession()
|
|
cache = _FakeUserContextCache()
|
|
service = UserService(
|
|
repository=repo, # type: ignore[arg-type]
|
|
session=session, # type: ignore[arg-type]
|
|
current_user=CurrentUser(id=user_id),
|
|
user_context_cache=cache, # type: ignore[arg-type]
|
|
)
|
|
|
|
result = await service.update_me(UserUpdateRequest(username="new-name"))
|
|
|
|
assert result.username == "new-name"
|
|
assert session.commit_called == 1
|
|
assert cache.invalidated_user_ids == [user_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_me_succeeds_when_cache_invalidation_fails() -> None:
|
|
user_id = uuid4()
|
|
repo = _FakeRepository(
|
|
_FakeProfile(id=user_id, username="old", avatar_url=None, bio=None)
|
|
)
|
|
session = _FakeSession()
|
|
cache = _FakeUserContextCache(should_fail=True)
|
|
service = UserService(
|
|
repository=repo, # type: ignore[arg-type]
|
|
session=session, # type: ignore[arg-type]
|
|
current_user=CurrentUser(id=user_id),
|
|
user_context_cache=cache, # type: ignore[arg-type]
|
|
)
|
|
|
|
result = await service.update_me(UserUpdateRequest(username="new-name"))
|
|
|
|
assert result.username == "new-name"
|
|
assert session.commit_called == 1
|
|
assert cache.invalidated_user_ids == [user_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_users_supports_phone_without_country_code() -> None:
|
|
user_id = uuid4()
|
|
repo = _FakeSearchRepository(
|
|
[
|
|
_FakeProfile(
|
|
id=user_id,
|
|
username="alice",
|
|
avatar_url=None,
|
|
bio=None,
|
|
)
|
|
]
|
|
)
|
|
session = _FakeSession()
|
|
auth_lookup = _FakeAuthLookup({"13812345678": [str(user_id)]})
|
|
service = UserService(
|
|
repository=repo, # type: ignore[arg-type]
|
|
session=session, # type: ignore[arg-type]
|
|
current_user=CurrentUser(id=user_id),
|
|
auth_gateway=auth_lookup, # type: ignore[arg-type]
|
|
)
|
|
|
|
results = await service.search_users(UserSearchRequest(query="13812345678"))
|
|
|
|
assert len(results) == 1
|
|
assert results[0].id == str(user_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_users_preserves_numeric_username_lookup() -> None:
|
|
user_id = uuid4()
|
|
repo = _FakeSearchRepository(
|
|
[
|
|
_FakeProfile(
|
|
id=user_id,
|
|
username="20260319",
|
|
avatar_url=None,
|
|
bio=None,
|
|
)
|
|
]
|
|
)
|
|
session = _FakeSession()
|
|
auth_lookup = _FakeAuthLookup({})
|
|
service = UserService(
|
|
repository=repo, # type: ignore[arg-type]
|
|
session=session, # type: ignore[arg-type]
|
|
current_user=CurrentUser(id=user_id),
|
|
auth_gateway=auth_lookup, # type: ignore[arg-type]
|
|
)
|
|
|
|
results = await service.search_users(UserSearchRequest(query="20260319"))
|
|
|
|
assert len(results) == 1
|
|
assert results[0].username == "20260319"
|