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"