ForcePilot/backend/test/unit/channels/adapters/test_conversation_adapter.py
Kris 1022121bee test: add batch of unit tests for channels module
添加了channels限界上下文的大量单元测试文件,包括:
1. 各层级通用与专用的conftest夹具
2. 核心领域模型、事件、服务测试
3. 应用层流水线、扩展处理器测试
4. 适配器与插件层测试
5. 修复并补充了pool manager测试用例
2026-07-02 03:28:19 +08:00

1213 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""yuxi.channels.adapters.conversation_adapter 单元测试。
覆盖 ``ConversationAdapter`` 的 ``saveMessage`` /
``updateMessageChannelStatus`` / ``getMessages`` /
``resolveConversation`` / ``associateConversation`` /
``mergeConversations`` / ``getSessionOwner`` /
``transferSessionOwner`` / ``appendOperationHistory`` /
``searchMessages`` / ``markMessageRecalled`` 方法patch
``ChannelConversationRepository`` / ``ChannelMessageRepository`` /
``ChannelSessionRepository`` 返回 mock 桩,使用 ``MagicMock`` 模拟
``AsyncSession``,不连接真实 DB。
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from yuxi.channels.adapters.conversation_adapter import ConversationAdapter
from yuxi.channels.contract.dtos.channel import (
ChannelType,
Message,
MessageSearchItem,
)
from yuxi.channels.contract.dtos.common import Operator, OperatorRole
from yuxi.channels.contract.dtos.conversation import (
AppendOperationHistoryCmd,
AssociateConversationCmd,
MergeConversationCmd,
MergeResult,
ResolveConversationCmd,
SaveMessageCmd,
UpdateMessageChannelStatusCmd,
)
from yuxi.channels.contract.dtos.session import (
ChatType,
OwnerTransferCmd,
SessionOwner,
)
from yuxi.channels.contract.errors import (
ConflictError,
DependencyError,
NotFoundError,
ValidationError,
)
pytestmark = pytest.mark.unit
def _make_db() -> MagicMock:
"""构造 AsyncSession 桩。"""
db = MagicMock()
db.add = MagicMock()
db.flush = AsyncMock()
db.commit = AsyncMock()
db.rollback = AsyncMock()
db.refresh = AsyncMock()
db.execute = AsyncMock()
db.get = AsyncMock()
return db
def _make_operator() -> Operator:
"""构造操作人 DTO。"""
return Operator(
user_id="admin-1",
role=OperatorRole.SUPERADMIN_USER,
ip="127.0.0.1",
request_id="req-1",
)
def _make_save_cmd(*, conversation_id: str = "100") -> SaveMessageCmd:
"""构造保存消息命令。"""
return SaveMessageCmd(
conversation_id=conversation_id,
role="assistant",
content="hello",
channel_status="sent",
channel_msg_id="cmid-1",
)
def _make_update_status_cmd(
*, message_id: str = "200"
) -> UpdateMessageChannelStatusCmd:
"""构造状态回写命令。"""
return UpdateMessageChannelStatusCmd(
message_id=message_id,
channel_status="read",
event={"status": "read", "at": datetime(2026, 1, 1).isoformat()},
read_at=datetime(2026, 1, 1),
)
def _make_resolve_cmd(
*,
create_if_not_found: bool = False,
unified_identity_id: str | None = None,
) -> ResolveConversationCmd:
"""构造解析会话命令。"""
return ResolveConversationCmd(
peer_id="peer-1",
channel_type=ChannelType.FEISHU,
account_id="acc-1",
chat_type=ChatType.P2P,
unified_identity_id=unified_identity_id,
create_if_not_found=create_if_not_found,
)
def _make_associate_cmd(
*, channel_session_id: str | None = "sess-1"
) -> AssociateConversationCmd:
"""构造关联会话命令。"""
return AssociateConversationCmd(
unified_identity_id="uid-1",
route_binding={},
channel_session_id=channel_session_id,
)
def _make_merge_cmd() -> MergeConversationCmd:
"""构造合并会话命令。"""
return MergeConversationCmd(
source_conversation_id="100",
target_conversation_id="200",
operator=_make_operator(),
reason="duplicate",
)
def _make_owner_transfer_cmd() -> OwnerTransferCmd:
"""构造所有者转移命令。"""
return OwnerTransferCmd(
conversation_id="100",
new_owner_id="peer-2",
operator=_make_operator(),
)
def _make_append_op_cmd(
*, message_id: str = "200"
) -> AppendOperationHistoryCmd:
"""构造追加操作历史命令。"""
return AppendOperationHistoryCmd(
message_id=message_id,
entry={
"operation": "recall",
"at": datetime(2026, 1, 1).isoformat(),
"success": True,
},
)
def _make_message_orm(
*, message_id: int = 200, conversation_id: int = 100
) -> MagicMock:
"""构造 Message ORM 桩。"""
orm = MagicMock()
orm.id = message_id
orm.conversation_id = conversation_id
orm.role = "assistant"
orm.content = "hello"
orm.channel_status = "sent"
orm.channel_msg_id = "cmid-1"
orm.ref_channel_msg_id = None
orm.channel_status_history = []
orm.operations_history = []
orm.channel_read_at = None
orm.channel_recalled_at = None
orm.channel_edited_at = None
orm.created_at = datetime(2026, 1, 1)
orm.conversation = MagicMock()
orm.conversation.channel_type = "feishu"
return orm
def _make_session_orm(
*,
session_id: str = "sess-1",
conversation_id: int | None = 100,
owner_peer_id: str | None = None,
is_temporary: bool = False,
) -> MagicMock:
"""构造 ChannelSession ORM 桩。
默认 ``owner_peer_id`` / ``deleted_at`` / ``closed_at`` 均为 None
使 ``_orm_to_session_aggregate`` 重构的聚合根可通过 ``canBeMerged()```
校验,供合并测试使用。
"""
orm = MagicMock()
orm.id = 1
orm.session_id = session_id
orm.channel_type = "feishu"
orm.account_id = 1
orm.peer_id = "peer-1"
orm.chat_type = "p2p"
orm.conversation_id = conversation_id
orm.unified_identity_id = None
orm.owner_peer_id = owner_peer_id
orm.is_temporary = is_temporary
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
orm.deleted_at = None
orm.closed_at = None
orm.version = 1
return orm
def _make_conversation_orm(
*, conv_id: int = 100, status: str = "active"
) -> MagicMock:
"""构造 Conversation ORM 桩。"""
orm = MagicMock()
orm.id = conv_id
orm.thread_id = "thread-1"
orm.uid = "peer-1"
orm.agent_id = "agent-1"
orm.title = "title"
orm.status = status
orm.extra_metadata = {}
orm.unified_identity_id = None
orm.channel_type = "feishu"
orm.channel_account_id = "acc-1"
orm.channel_session_id = ""
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
return orm
def _build_adapter(
db: MagicMock,
) -> tuple[ConversationAdapter, MagicMock, MagicMock, MagicMock]:
"""构造 adapterpatch 三个 repo 类返回桩。
返回 ``(adapter, message_repo, conversation_repo, session_repo)``
调用方可按需在各 repo 桩上挂载 ``AsyncMock`` 方法。
"""
message_repo = MagicMock()
conversation_repo = MagicMock()
session_repo = MagicMock()
with patch(
"yuxi.channels.adapters.conversation_adapter.ChannelMessageRepository",
return_value=message_repo,
), patch(
"yuxi.channels.adapters.conversation_adapter.ChannelConversationRepository",
return_value=conversation_repo,
), patch(
"yuxi.channels.adapters.conversation_adapter.ChannelSessionRepository",
return_value=session_repo,
):
adapter = ConversationAdapter(db, MagicMock())
return adapter, message_repo, conversation_repo, session_repo
def _make_scalar_result(value):
"""构造 ``scalar_one_or_none`` 返回 ``value`` 的结果桩。"""
result = MagicMock()
result.scalar_one_or_none.return_value = value
return result
def _make_scalars_result(values):
"""构造 ``scalars().all()`` 返回 ``values`` 的结果桩。"""
result = MagicMock()
result.scalars.return_value.all.return_value = values
return result
@pytest.mark.unit
class TestConversationSaveMessage:
@pytest.mark.asyncio
async def test_save_message_returns_message_when_found(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalar_result(_make_message_orm())
)
adapter, message_repo, _, _ = _build_adapter(db)
message_orm = _make_message_orm()
message_repo.init_channel_fields = AsyncMock(return_value=message_orm)
# Act
message = await adapter.saveMessage(_make_save_cmd())
# Assert
assert isinstance(message, Message)
assert message.message_id == "200"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_save_message_skips_commit_when_tx_provided(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalar_result(_make_message_orm())
)
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.init_channel_fields = AsyncMock(
return_value=_make_message_orm()
)
tx = MagicMock()
# Act
await adapter.saveMessage(_make_save_cmd(), tx=tx)
# Assert
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_save_message_raises_not_found_when_no_latest_message(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=_make_scalar_result(None))
adapter, message_repo, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.saveMessage(_make_save_cmd())
@pytest.mark.asyncio
async def test_save_message_raises_not_found_when_invalid_conversation_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.saveMessage(_make_save_cmd(conversation_id="abc"))
@pytest.mark.asyncio
async def test_save_message_translates_integrity_error_to_conflict(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalar_result(_make_message_orm())
)
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.init_channel_fields = AsyncMock(
side_effect=IntegrityError("stmt", {}, Exception("orig"))
)
# Act / Assert
with pytest.raises(ConflictError):
await adapter.saveMessage(_make_save_cmd())
@pytest.mark.asyncio
async def test_save_message_translates_sqlalchemy_error_to_dependency(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(side_effect=SQLAlchemyError("db failure"))
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.saveMessage(_make_save_cmd())
@pytest.mark.unit
class TestConversationUpdateMessageChannelStatus:
@pytest.mark.asyncio
async def test_update_status_returns_message_when_found(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
return_value=_make_message_orm()
)
# Act
message = await adapter.updateMessageChannelStatus(
_make_update_status_cmd()
)
# Assert
assert isinstance(message, Message)
assert message.message_id == "200"
message_repo.update_channel_status.assert_awaited_once()
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_status_skips_commit_when_tx_provided(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
return_value=_make_message_orm()
)
tx = MagicMock()
# Act
await adapter.updateMessageChannelStatus(
_make_update_status_cmd(), tx=tx
)
# Assert
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_status_raises_not_found_when_invalid_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.updateMessageChannelStatus(
_make_update_status_cmd(message_id="abc")
)
@pytest.mark.asyncio
async def test_update_status_raises_not_found_when_message_missing(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(return_value=None)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.updateMessageChannelStatus(
_make_update_status_cmd()
)
@pytest.mark.asyncio
async def test_update_status_translates_integrity_error_to_conflict(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
side_effect=IntegrityError("stmt", {}, Exception("orig"))
)
# Act / Assert
with pytest.raises(ConflictError):
await adapter.updateMessageChannelStatus(
_make_update_status_cmd()
)
@pytest.mark.asyncio
async def test_update_status_translates_sqlalchemy_error_to_dependency(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
side_effect=SQLAlchemyError("db failure")
)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.updateMessageChannelStatus(
_make_update_status_cmd()
)
@pytest.mark.unit
class TestConversationGetMessages:
@pytest.mark.asyncio
async def test_get_messages_returns_tuple(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalars_result(
[_make_message_orm(200), _make_message_orm(201)]
)
)
adapter, _, _, _ = _build_adapter(db)
# Act
messages = await adapter.getMessages("100")
# Assert
assert isinstance(messages, tuple)
assert len(messages) == 2
assert all(isinstance(m, Message) for m in messages)
@pytest.mark.asyncio
async def test_get_messages_returns_empty_tuple_when_no_records(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=_make_scalars_result([]))
adapter, _, _, _ = _build_adapter(db)
# Act
messages = await adapter.getMessages("100")
# Assert
assert messages == ()
@pytest.mark.asyncio
async def test_get_messages_raises_validation_error_when_invalid_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(ValidationError):
await adapter.getMessages("abc")
@pytest.mark.asyncio
async def test_get_messages_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(side_effect=SQLAlchemyError("db failure"))
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.getMessages("100")
@pytest.mark.unit
class TestConversationResolveConversation:
@pytest.mark.asyncio
async def test_resolve_returns_existing_session_conversation_id(self):
# Arrange
db = _make_db()
session_orm = _make_session_orm(conversation_id=100)
db.execute = AsyncMock(
side_effect=[
_make_scalar_result(1),
_make_scalar_result(session_orm),
]
)
adapter, _, _, _ = _build_adapter(db)
# Act
cid = await adapter.resolveConversation(_make_resolve_cmd())
# Assert
assert cid == "100"
@pytest.mark.asyncio
async def test_resolve_raises_not_found_when_account_missing(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalar_result(None)
)
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.resolveConversation(_make_resolve_cmd())
@pytest.mark.asyncio
async def test_resolve_raises_not_found_when_create_false_and_no_session(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
side_effect=[
_make_scalar_result(1),
_make_scalar_result(None),
]
)
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.resolveConversation(
_make_resolve_cmd(create_if_not_found=False)
)
@pytest.mark.asyncio
async def test_resolve_creates_new_conversation_when_create_true(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
side_effect=[
_make_scalar_result(1),
_make_scalar_result(None),
]
)
adapter, _, _, _ = _build_adapter(db)
new_conv = _make_conversation_orm(conv_id=999)
# Act
with patch(
"yuxi.channels.adapters.conversation_adapter.ConversationORM",
return_value=new_conv,
), patch(
"yuxi.channels.adapters.conversation_adapter.ConversationStatsORM"
):
cid = await adapter.resolveConversation(
_make_resolve_cmd(create_if_not_found=True)
)
# Assert
assert cid == "999"
db.add.assert_called()
db.flush.assert_awaited_once()
db.commit.assert_awaited_once()
db.refresh.assert_awaited_once()
@pytest.mark.asyncio
async def test_resolve_returns_existing_conversation_by_identity(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
side_effect=[
_make_scalar_result(1),
_make_scalar_result(None),
MagicMock(),
]
)
adapter, _, conversation_repo, _ = _build_adapter(db)
existing_conv = _make_conversation_orm(conv_id=888)
conversation_repo.find_by_unified_identity = AsyncMock(
return_value=[existing_conv]
)
# Act
cid = await adapter.resolveConversation(
_make_resolve_cmd(unified_identity_id="uid-1")
)
# Assert
assert cid == "888"
@pytest.mark.asyncio
async def test_resolve_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(side_effect=SQLAlchemyError("db failure"))
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.resolveConversation(_make_resolve_cmd())
@pytest.mark.unit
class TestConversationAssociateConversation:
@pytest.mark.asyncio
async def test_associate_links_conversation(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=MagicMock())
adapter, _, conversation_repo, session_repo = _build_adapter(db)
conv_orm = _make_conversation_orm()
conversation_repo.find_by_unified_identity = AsyncMock(
return_value=[conv_orm]
)
session_orm = _make_session_orm()
session_repo.get_by_session_id = AsyncMock(return_value=session_orm)
conversation_repo.link_channel_session = AsyncMock(
return_value=conv_orm
)
# Act
await adapter.associateConversation(_make_associate_cmd())
# Assert
conversation_repo.link_channel_session.assert_awaited_once()
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_associate_raises_not_found_when_no_conversation(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=MagicMock())
adapter, _, conversation_repo, _ = _build_adapter(db)
conversation_repo.find_by_unified_identity = AsyncMock(
return_value=[]
)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.associateConversation(_make_associate_cmd())
@pytest.mark.asyncio
async def test_associate_raises_not_found_when_session_missing(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=MagicMock())
adapter, _, conversation_repo, session_repo = _build_adapter(db)
conv_orm = _make_conversation_orm()
conversation_repo.find_by_unified_identity = AsyncMock(
return_value=[conv_orm]
)
session_repo.get_by_session_id = AsyncMock(return_value=None)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.associateConversation(_make_associate_cmd())
@pytest.mark.asyncio
async def test_associate_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(side_effect=SQLAlchemyError("db failure"))
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.associateConversation(_make_associate_cmd())
@pytest.mark.unit
class TestConversationMergeConversations:
@pytest.mark.asyncio
async def test_merge_returns_result_when_successful(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=MagicMock(rowcount=3))
db.get = AsyncMock(
side_effect=[
_make_conversation_orm(conv_id=100, status="active"),
_make_conversation_orm(conv_id=200, status="active"),
]
)
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(
return_value=[_make_session_orm()]
)
session_repo.soft_delete_by_id = AsyncMock()
# Act
result = await adapter.mergeConversations(_make_merge_cmd())
# Assert
assert isinstance(result, MergeResult)
assert result.migrated_message_count == 3
assert result.source_soft_deleted is True
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_merge_returns_idempotent_when_source_deleted(self):
# Arrange
db = _make_db()
db.get = AsyncMock(
side_effect=[
_make_conversation_orm(conv_id=100, status="deleted"),
_make_conversation_orm(conv_id=200, status="active"),
]
)
adapter, _, _, _ = _build_adapter(db)
# Act
result = await adapter.mergeConversations(_make_merge_cmd())
# Assert
assert result.migrated_message_count == 0
assert result.source_soft_deleted is True
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_merge_raises_not_found_when_source_missing(self):
# Arrange
db = _make_db()
db.get = AsyncMock(
side_effect=[
None,
_make_conversation_orm(conv_id=200),
]
)
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.mergeConversations(_make_merge_cmd())
@pytest.mark.asyncio
async def test_merge_raises_not_found_when_target_missing(self):
# Arrange
db = _make_db()
db.get = AsyncMock(
side_effect=[
_make_conversation_orm(conv_id=100),
None,
]
)
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.mergeConversations(_make_merge_cmd())
@pytest.mark.asyncio
async def test_merge_raises_not_found_when_invalid_source_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
cmd = MergeConversationCmd(
source_conversation_id="abc",
target_conversation_id="200",
operator=_make_operator(),
reason="duplicate",
)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.mergeConversations(cmd)
@pytest.mark.asyncio
async def test_merge_skips_commit_when_tx_provided(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=MagicMock(rowcount=1))
db.get = AsyncMock(
side_effect=[
_make_conversation_orm(conv_id=100, status="active"),
_make_conversation_orm(conv_id=200, status="active"),
]
)
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(
return_value=[_make_session_orm()]
)
session_repo.soft_delete_by_id = AsyncMock()
tx = MagicMock()
# Act
await adapter.mergeConversations(_make_merge_cmd(), tx=tx)
# Assert
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_merge_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(side_effect=SQLAlchemyError("db failure"))
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.mergeConversations(_make_merge_cmd())
@pytest.mark.unit
class TestConversationGetSessionOwner:
@pytest.mark.asyncio
async def test_get_session_owner_returns_owner(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(
return_value=[_make_session_orm(owner_peer_id="peer-1")]
)
# Act
owner = await adapter.getSessionOwner("100")
# Assert
assert isinstance(owner, SessionOwner)
assert owner.owner_peer_id == "peer-1"
assert owner.conversation_id == "100"
@pytest.mark.asyncio
async def test_get_session_owner_returns_none_when_no_sessions(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(return_value=[])
# Act
owner = await adapter.getSessionOwner("100")
# Assert
assert owner is None
@pytest.mark.asyncio
async def test_get_session_owner_returns_none_when_no_owner(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(
return_value=[_make_session_orm(owner_peer_id=None)]
)
# Act
owner = await adapter.getSessionOwner("100")
# Assert
assert owner is None
@pytest.mark.asyncio
async def test_get_session_owner_raises_not_found_when_invalid_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.getSessionOwner("abc")
@pytest.mark.asyncio
async def test_get_session_owner_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(
side_effect=SQLAlchemyError("db failure")
)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.getSessionOwner("100")
@pytest.mark.unit
class TestConversationTransferSessionOwner:
@pytest.mark.asyncio
async def test_transfer_returns_owner(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_orm = _make_session_orm(owner_peer_id="peer-1")
updated_orm = _make_session_orm(owner_peer_id="peer-2")
session_repo.list_by_conversation = AsyncMock(return_value=[session_orm])
session_repo.update = AsyncMock(return_value=updated_orm)
# Act
owner = await adapter.transferSessionOwner(_make_owner_transfer_cmd())
# Assert
assert isinstance(owner, SessionOwner)
assert owner.owner_peer_id == "peer-2"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_transfer_raises_not_found_when_no_sessions(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(return_value=[])
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.transferSessionOwner(_make_owner_transfer_cmd())
@pytest.mark.asyncio
async def test_transfer_raises_not_found_when_invalid_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
cmd = OwnerTransferCmd(
conversation_id="abc",
new_owner_id="peer-2",
operator=_make_operator(),
)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.transferSessionOwner(cmd)
@pytest.mark.asyncio
async def test_transfer_skips_commit_when_tx_provided(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_orm = _make_session_orm()
session_repo.list_by_conversation = AsyncMock(return_value=[session_orm])
session_repo.update = AsyncMock(return_value=session_orm)
tx = MagicMock()
# Act
await adapter.transferSessionOwner(_make_owner_transfer_cmd(), tx=tx)
# Assert
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_transfer_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
adapter, _, _, session_repo = _build_adapter(db)
session_repo.list_by_conversation = AsyncMock(
side_effect=SQLAlchemyError("db failure")
)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.transferSessionOwner(_make_owner_transfer_cmd())
@pytest.mark.unit
class TestConversationAppendOperationHistory:
@pytest.mark.asyncio
async def test_append_op_returns_message(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.append_operation_history = AsyncMock(
return_value=_make_message_orm()
)
# Act
message = await adapter.appendOperationHistory(_make_append_op_cmd())
# Assert
assert isinstance(message, Message)
assert message.message_id == "200"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_append_op_skips_commit_when_tx_provided(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.append_operation_history = AsyncMock(
return_value=_make_message_orm()
)
tx = MagicMock()
# Act
await adapter.appendOperationHistory(_make_append_op_cmd(), tx=tx)
# Assert
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_append_op_raises_not_found_when_invalid_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.appendOperationHistory(
_make_append_op_cmd(message_id="abc")
)
@pytest.mark.asyncio
async def test_append_op_raises_not_found_when_message_missing(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.append_operation_history = AsyncMock(return_value=None)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.appendOperationHistory(_make_append_op_cmd())
@pytest.mark.asyncio
async def test_append_op_translates_integrity_error_to_conflict(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.append_operation_history = AsyncMock(
side_effect=IntegrityError("stmt", {}, Exception("orig"))
)
# Act / Assert
with pytest.raises(ConflictError):
await adapter.appendOperationHistory(_make_append_op_cmd())
@pytest.mark.asyncio
async def test_append_op_translates_sqlalchemy_error_to_dependency(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.append_operation_history = AsyncMock(
side_effect=SQLAlchemyError("db failure")
)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.appendOperationHistory(_make_append_op_cmd())
@pytest.mark.unit
class TestConversationSearchMessages:
@pytest.mark.asyncio
async def test_search_returns_items(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalars_result(
[_make_message_orm(200), _make_message_orm(201)]
)
)
adapter, _, _, _ = _build_adapter(db)
# Act
items = await adapter.searchMessages("hello")
# Assert
assert len(items) == 2
assert all(isinstance(i, MessageSearchItem) for i in items)
assert items[0].message_id == "200"
@pytest.mark.asyncio
async def test_search_returns_empty_list_when_no_matches(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(return_value=_make_scalars_result([]))
adapter, _, _, _ = _build_adapter(db)
# Act
items = await adapter.searchMessages("nomatch")
# Assert
assert items == []
@pytest.mark.asyncio
async def test_search_with_channel_session_id_fills_session_id(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(
return_value=_make_scalars_result([_make_message_orm(200)])
)
adapter, _, _, _ = _build_adapter(db)
# Act
items = await adapter.searchMessages(
"hello", channel_session_id="sess-1"
)
# Assert
assert len(items) == 1
assert items[0].channel_session_id == "sess-1"
@pytest.mark.asyncio
async def test_search_translates_sqlalchemy_error(self):
# Arrange
db = _make_db()
db.execute = AsyncMock(side_effect=SQLAlchemyError("db failure"))
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.searchMessages("hello")
@pytest.mark.unit
class TestConversationMarkMessageRecalled:
@pytest.mark.asyncio
async def test_mark_recalled_succeeds(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
return_value=_make_message_orm()
)
# Act
await adapter.markMessageRecalled("200", datetime(2026, 1, 1))
# Assert
message_repo.update_channel_status.assert_awaited_once()
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_mark_recalled_skips_commit_when_tx_provided(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
return_value=_make_message_orm()
)
tx = MagicMock()
# Act
await adapter.markMessageRecalled(
"200", datetime(2026, 1, 1), tx=tx
)
# Assert
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_mark_recalled_raises_not_found_when_invalid_id(self):
# Arrange
db = _make_db()
adapter, _, _, _ = _build_adapter(db)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.markMessageRecalled("abc", datetime(2026, 1, 1))
@pytest.mark.asyncio
async def test_mark_recalled_raises_not_found_when_message_missing(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(return_value=None)
# Act / Assert
with pytest.raises(NotFoundError):
await adapter.markMessageRecalled("200", datetime(2026, 1, 1))
@pytest.mark.asyncio
async def test_mark_recalled_translates_integrity_error_to_conflict(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
side_effect=IntegrityError("stmt", {}, Exception("orig"))
)
# Act / Assert
with pytest.raises(ConflictError):
await adapter.markMessageRecalled("200", datetime(2026, 1, 1))
@pytest.mark.asyncio
async def test_mark_recalled_translates_sqlalchemy_error_to_dependency(self):
# Arrange
db = _make_db()
adapter, message_repo, _, _ = _build_adapter(db)
message_repo.update_channel_status = AsyncMock(
side_effect=SQLAlchemyError("db failure")
)
# Act / Assert
with pytest.raises(DependencyError):
await adapter.markMessageRecalled("200", datetime(2026, 1, 1))