187 lines
4.9 KiB
Python
187 lines
4.9 KiB
Python
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from free_claude_code.messaging.platforms.outbox import PlatformOutbox
|
|
|
|
|
|
def _noop_outbox(*, limiter=None, delete_many=None) -> PlatformOutbox:
|
|
async def send(
|
|
chat_id: str,
|
|
text: str,
|
|
reply_to: str | None,
|
|
parse_mode: str | None,
|
|
message_thread_id: str | None,
|
|
) -> str:
|
|
return f"{chat_id}:{text}:{reply_to}:{parse_mode}:{message_thread_id}"
|
|
|
|
async def edit(
|
|
chat_id: str,
|
|
message_id: str,
|
|
text: str,
|
|
parse_mode: str | None,
|
|
) -> None:
|
|
return None
|
|
|
|
async def default_delete_many(chat_id: str, message_ids: list[str]) -> None:
|
|
return None
|
|
|
|
return PlatformOutbox(
|
|
limiter=limiter or MagicMock(),
|
|
send=send,
|
|
edit=edit,
|
|
delete_many=delete_many or default_delete_many,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_send_awaits_required_limiter() -> None:
|
|
limiter = MagicMock()
|
|
|
|
async def enqueue(operation, dedup_key=None):
|
|
return await operation()
|
|
|
|
limiter.enqueue = AsyncMock(side_effect=enqueue)
|
|
outbox = _noop_outbox(limiter=limiter)
|
|
|
|
result = await outbox.queue_send_message(
|
|
"chat",
|
|
"hello",
|
|
reply_to="reply",
|
|
parse_mode="MarkdownV2",
|
|
fire_and_forget=False,
|
|
message_thread_id="thread",
|
|
)
|
|
|
|
assert result == "chat:hello:reply:MarkdownV2:thread"
|
|
limiter.enqueue.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_edit_awaits_limiter_with_dedup_key() -> None:
|
|
limiter = MagicMock()
|
|
limiter.enqueue = AsyncMock()
|
|
outbox = _noop_outbox(limiter=limiter)
|
|
|
|
await outbox.queue_edit_message(
|
|
"chat",
|
|
"message",
|
|
"updated",
|
|
parse_mode="MarkdownV2",
|
|
fire_and_forget=False,
|
|
)
|
|
|
|
limiter.enqueue.assert_awaited_once()
|
|
operation = limiter.enqueue.call_args.args[0]
|
|
assert limiter.enqueue.call_args.kwargs["dedup_key"] == "edit:chat:message"
|
|
await operation()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_delete_many_skips_empty_batches() -> None:
|
|
limiter = MagicMock()
|
|
outbox = _noop_outbox(limiter=limiter)
|
|
|
|
await outbox.queue_delete_messages("chat", [], fire_and_forget=True)
|
|
|
|
limiter.fire_and_forget.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_delete_many_dedups_by_batch() -> None:
|
|
limiter = MagicMock()
|
|
outbox = _noop_outbox(limiter=limiter)
|
|
|
|
await outbox.queue_delete_messages("chat", ["1", "2"], fire_and_forget=True)
|
|
|
|
limiter.fire_and_forget.assert_called_once()
|
|
assert (
|
|
limiter.fire_and_forget.call_args.kwargs["dedup_key"]
|
|
== "del_bulk:chat:11f0530a8259fffb"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_delete_many_snapshots_ids_before_queueing() -> None:
|
|
limiter = MagicMock()
|
|
deleted: list[list[str]] = []
|
|
|
|
async def delete_many(_chat_id: str, message_ids: list[str]) -> None:
|
|
deleted.append(message_ids)
|
|
|
|
outbox = _noop_outbox(limiter=limiter, delete_many=delete_many)
|
|
message_ids = ["1", "2"]
|
|
|
|
await outbox.queue_delete_messages("chat", message_ids, fire_and_forget=True)
|
|
message_ids.append("3")
|
|
operation = limiter.fire_and_forget.call_args.args[0]
|
|
await operation()
|
|
|
|
assert deleted == [["1", "2"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_cancels_and_settles_owned_background_work() -> None:
|
|
outbox = _noop_outbox()
|
|
started = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def pending() -> None:
|
|
started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cancelled.set()
|
|
|
|
outbox.fire_and_forget(pending())
|
|
await started.wait()
|
|
|
|
await outbox.close()
|
|
|
|
assert cancelled.is_set()
|
|
assert outbox._background_tasks == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completed_background_failure_is_observed_and_released() -> None:
|
|
outbox = _noop_outbox()
|
|
|
|
async def fail() -> None:
|
|
raise RuntimeError("background failed")
|
|
|
|
with patch("free_claude_code.messaging.platforms.outbox.logger.error") as error_log:
|
|
outbox.fire_and_forget(fail())
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
|
|
assert outbox._background_tasks == set()
|
|
error_log.assert_called_once_with(
|
|
"Outbound background task failed: exc_type={}",
|
|
"RuntimeError",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_rejects_all_later_work() -> None:
|
|
limiter = MagicMock()
|
|
outbox = _noop_outbox(limiter=limiter)
|
|
await outbox.close()
|
|
|
|
with pytest.raises(RuntimeError, match="outbox is closed"):
|
|
await outbox.queue_send_message("chat", "message")
|
|
|
|
ran = False
|
|
|
|
async def late_task() -> None:
|
|
nonlocal ran
|
|
ran = True
|
|
|
|
with pytest.raises(RuntimeError, match="outbox is closed"):
|
|
outbox.fire_and_forget(late_task())
|
|
await asyncio.sleep(0)
|
|
|
|
assert ran is False
|
|
assert outbox._background_tasks == set()
|
|
limiter.fire_and_forget.assert_not_called()
|