from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest from yuxi.channel.security.identity import IdentityLinkResolver @pytest.fixture def mock_repository() -> MagicMock: repo = MagicMock() repo.create = AsyncMock(return_value=None) repo.delete_by_sender = AsyncMock(return_value=True) repo.load_all = AsyncMock(return_value=[]) return repo @pytest.fixture def mock_pg_manager(monkeypatch): session = AsyncMock() ctx = MagicMock() ctx.__aenter__ = AsyncMock(return_value=session) ctx.__aexit__ = AsyncMock(return_value=None) manager = MagicMock() manager.get_async_session_context = MagicMock(return_value=ctx) monkeypatch.setattr("yuxi.channel.security.identity.pg_manager", manager) return session @pytest.mark.unit class TestIdentityLinkResolver: def test_resolve_by_full_key(self) -> None: resolver = IdentityLinkResolver({"canonical_1": ["feishu:ou_xxx"]}) assert resolver.resolve("feishu", "ou_xxx") == "canonical_1" def test_resolve_by_peer_id_without_channel(self) -> None: resolver = IdentityLinkResolver({"canonical_1": ["12345"]}) assert resolver.resolve("dingtalk", "12345") == "canonical_1" def test_full_key_takes_precedence_over_bare_peer_id(self) -> None: resolver = IdentityLinkResolver( { "canonical_feishu": ["feishu:12345"], "canonical_bare": ["12345"], } ) assert resolver.resolve("feishu", "12345") == "canonical_feishu" assert resolver.resolve("dingtalk", "12345") == "canonical_bare" def test_resolve_unknown_returns_peer_id(self) -> None: resolver = IdentityLinkResolver({"canonical_1": ["feishu:ou_xxx"]}) assert resolver.resolve("feishu", "unknown_id") == "unknown_id" def test_empty_links_returns_peer_id(self) -> None: resolver = IdentityLinkResolver({}) assert resolver.resolve("feishu", "ou_xxx") == "ou_xxx" def test_multiple_ids_per_canonical(self) -> None: resolver = IdentityLinkResolver({"canonical_1": ["feishu:ou_xxx", "dingtalk:12345", "telegram:67890"]}) assert resolver.resolve("feishu", "ou_xxx") == "canonical_1" assert resolver.resolve("dingtalk", "12345") == "canonical_1" assert resolver.resolve("telegram", "67890") == "canonical_1" def test_duplicate_linked_id_last_canonical_wins(self) -> None: resolver = IdentityLinkResolver( { "first": ["feishu:ou_xxx"], "second": ["feishu:ou_xxx"], } ) assert resolver.resolve("feishu", "ou_xxx") == "second" @pytest.mark.unit class TestIdentityLinkResolverLink: async def test_link_creates_record_and_updates_index(self, mock_repository: MagicMock) -> None: resolver = IdentityLinkResolver(identity_repository=mock_repository) await resolver.link("feishu", "ou_xxx", "canonical_1", "acc_1") mock_repository.create.assert_awaited_once_with( channel_type="feishu", account_id="acc_1", channel_sender_id="ou_xxx", platform_user_id="canonical_1", paired_by="qr", ) assert resolver.resolve("feishu", "ou_xxx") == "canonical_1" async def test_link_multiple_sender_ids_for_same_platform_user(self, mock_repository: MagicMock) -> None: resolver = IdentityLinkResolver(identity_repository=mock_repository) await resolver.link("feishu", "ou_xxx", "canonical_1", "acc_1") await resolver.link("dingtalk", "12345", "canonical_1", "acc_1") assert resolver.resolve("feishu", "ou_xxx") == "canonical_1" assert resolver.resolve("dingtalk", "12345") == "canonical_1" @pytest.mark.unit class TestIdentityLinkResolverUnlink: async def test_unlink_removes_binding(self, mock_repository: MagicMock) -> None: resolver = IdentityLinkResolver( {"canonical_1": ["feishu:ou_xxx"]}, identity_repository=mock_repository, ) mock_repository.delete_by_sender = AsyncMock(return_value=True) result = await resolver.unlink("feishu", "acc_1", "ou_xxx") assert result is True mock_repository.delete_by_sender.assert_awaited_once_with( channel_type="feishu", account_id="acc_1", channel_sender_id="ou_xxx", ) assert resolver.resolve("feishu", "ou_xxx") == "ou_xxx" async def test_unlink_nonexistent_binding_returns_false(self, mock_repository: MagicMock) -> None: resolver = IdentityLinkResolver( {"canonical_1": ["feishu:ou_xxx"]}, identity_repository=mock_repository, ) mock_repository.delete_by_sender = AsyncMock(return_value=False) result = await resolver.unlink("feishu", "acc_1", "unknown_id") assert result is False assert resolver.resolve("feishu", "ou_xxx") == "canonical_1" @pytest.mark.unit class TestIdentityLinkResolverCreate: async def test_create_builds_index_from_repository(self, mock_repository: MagicMock) -> None: record1 = MagicMock() record1.platform_user_id = "canonical_1" record1.channel_type = "feishu" record1.channel_sender_id = "ou_xxx" record2 = MagicMock() record2.platform_user_id = "canonical_1" record2.channel_type = "dingtalk" record2.channel_sender_id = "12345" mock_repository.load_all = AsyncMock(return_value=[record1, record2]) resolver = await IdentityLinkResolver.create(mock_repository) mock_repository.load_all.assert_awaited_once() assert resolver.resolve("feishu", "ou_xxx") == "canonical_1" assert resolver.resolve("dingtalk", "12345") == "canonical_1" async def test_create_uses_pg_manager_when_repository_is_none( self, mock_pg_manager: AsyncMock, monkeypatch ) -> None: record = MagicMock() record.platform_user_id = "canonical_1" record.channel_type = "feishu" record.channel_sender_id = "ou_xxx" mock_repo = MagicMock() mock_repo.load_all = AsyncMock(return_value=[record]) monkeypatch.setattr( "yuxi.channel.security.identity.ChannelIdentityRepository", MagicMock(return_value=mock_repo), ) resolver = await IdentityLinkResolver.create() assert resolver.resolve("feishu", "ou_xxx") == "canonical_1"