Independent FastAPI backend for the MaskanX agentic growth platform. Includes the agent runtime, MCP client integrations (Meta Ads, LinkedIn, HubSpot, Tavily, Exa, xAI, Citedy, image generation), PostgreSQL storage for chats and cron jobs, provider and secret management, and the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
10 KiB
Python
295 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""E2E tests for /clear bypass and LLM timeout features.
|
|
|
|
Tests the actual ChannelManager class with mock channels to verify:
|
|
1. force_clear_session cancels in-progress tasks
|
|
2. force_clear_session deletes session files
|
|
3. LLM timeout fires and cleans up
|
|
4. Normal processing still works (no regression)
|
|
5. Telegram /clear calls force_clear on the manager
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, AsyncIterator, List, Optional
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from adclaw.app.channels.manager import ChannelManager, _process_batch
|
|
from adclaw.app.channels.base import BaseChannel, ProcessHandler
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers / Mocks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MockChannel(BaseChannel):
|
|
"""Minimal concrete channel for testing."""
|
|
|
|
channel = "mock"
|
|
uses_manager_queue = True
|
|
|
|
def __init__(self, process: ProcessHandler, slow_seconds: float = 0.0):
|
|
super().__init__(process)
|
|
self.slow_seconds = slow_seconds
|
|
self._started = False
|
|
self._stopped = False
|
|
self.processed_payloads: List[Any] = []
|
|
self.system_messages: List[tuple] = []
|
|
|
|
@classmethod
|
|
def from_env(cls, process, **kw):
|
|
return cls(process)
|
|
|
|
@classmethod
|
|
def from_config(cls, process, config, **kw):
|
|
return cls(process)
|
|
|
|
async def start(self):
|
|
self._started = True
|
|
|
|
async def stop(self):
|
|
self._stopped = True
|
|
|
|
def get_debounce_key(self, payload: Any) -> str:
|
|
if isinstance(payload, dict):
|
|
return payload.get("session_id", "default")
|
|
return "default"
|
|
|
|
def _is_native_payload(self, payload: Any) -> bool:
|
|
return isinstance(payload, dict) and "content_parts" in payload
|
|
|
|
async def _consume_one_request(self, payload: Any) -> None:
|
|
"""Simulate processing - optionally slow."""
|
|
self.processed_payloads.append(payload)
|
|
if self.slow_seconds > 0:
|
|
await asyncio.sleep(self.slow_seconds)
|
|
|
|
async def consume_one(self, payload: Any) -> None:
|
|
await self._consume_one_request(payload)
|
|
|
|
async def send_system_message(self, to: str, text: str) -> None:
|
|
self.system_messages.append((to, text))
|
|
|
|
async def send_content_parts(self, to, parts, meta):
|
|
pass
|
|
|
|
def to_handle_from_target(self, user_id, session_id):
|
|
return user_id
|
|
|
|
|
|
async def _dummy_process(request: Any) -> AsyncIterator:
|
|
yield # pragma: no cover
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 1: force_clear_session cancels in-progress task
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_force_clear_cancels_in_progress_task():
|
|
"""Start a slow processing task, call force_clear_session while it's
|
|
processing, verify task was cancelled and state cleaned up."""
|
|
ch = MockChannel(_dummy_process, slow_seconds=60.0)
|
|
mgr = ChannelManager([ch])
|
|
|
|
await mgr.start_all()
|
|
try:
|
|
# Enqueue a payload that will take 60s to process
|
|
payload = {"content_parts": [{"type": "text", "text": "hello"}],
|
|
"session_id": "sess1"}
|
|
mgr._enqueue_one("mock", payload)
|
|
|
|
# Also enqueue a pending message for the same key
|
|
# (will be queued once first is picked up and in_progress is set)
|
|
await asyncio.sleep(0.3) # let consumer pick up first payload
|
|
|
|
# Verify it's in progress
|
|
assert ("mock", "sess1") in mgr._in_progress, \
|
|
"Session should be in_progress"
|
|
|
|
# Enqueue another payload that should go to pending
|
|
payload2 = {"content_parts": [{"type": "text", "text": "world"}],
|
|
"session_id": "sess1"}
|
|
mgr._enqueue_one("mock", payload2)
|
|
assert ("mock", "sess1") in mgr._pending, \
|
|
"Second payload should be in pending"
|
|
|
|
# Now force clear
|
|
was_active = await mgr.force_clear_session("mock", "sess1")
|
|
|
|
assert was_active is True, "Should report task was active"
|
|
assert ("mock", "sess1") not in mgr._in_progress, \
|
|
"in_progress should be cleared"
|
|
assert ("mock", "sess1") not in mgr._pending, \
|
|
"pending should be cleared"
|
|
assert ("mock", "sess1") not in mgr._processing_tasks, \
|
|
"processing_tasks should be cleared"
|
|
finally:
|
|
await mgr.stop_all()
|
|
|
|
print("PASS: Scenario 1 - force_clear_session cancels in-progress task")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 2: force_clear_session deletes session files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_force_clear_deletes_session_files():
|
|
"""Create temp dir with session files, call _delete_session_files,
|
|
verify files are deleted."""
|
|
ch = MockChannel(_dummy_process)
|
|
mgr = ChannelManager([ch])
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create session files matching a key pattern
|
|
key = "chat123"
|
|
files_to_delete = [
|
|
f"session_chat123_2026-03-15.json",
|
|
f"backup_chat123.json",
|
|
]
|
|
files_to_keep = [
|
|
"session_other456.json",
|
|
"unrelated.txt",
|
|
]
|
|
for f in files_to_delete + files_to_keep:
|
|
Path(tmpdir, f).write_text("{}")
|
|
|
|
mgr.set_session_dir(tmpdir)
|
|
mgr._delete_session_files(key)
|
|
|
|
remaining = set(os.listdir(tmpdir))
|
|
for f in files_to_delete:
|
|
assert f not in remaining, f"File {f} should have been deleted"
|
|
for f in files_to_keep:
|
|
assert f in remaining, f"File {f} should have been kept"
|
|
|
|
print("PASS: Scenario 2 - force_clear_session deletes session files")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 3: LLM timeout fires and cleans up
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_timeout_fires_and_cleans_up():
|
|
"""Set _processing_timeout to 2s, start a 30s task, verify timeout."""
|
|
ch = MockChannel(_dummy_process, slow_seconds=30.0)
|
|
mgr = ChannelManager([ch])
|
|
mgr._processing_timeout = 2.0
|
|
|
|
await mgr.start_all()
|
|
try:
|
|
payload = {"content_parts": [{"type": "text", "text": "slow"}],
|
|
"session_id": "sess_timeout",
|
|
"meta": {"chat_id": "chat42"}}
|
|
mgr._enqueue_one("mock", payload)
|
|
|
|
# Wait for timeout to fire (2s) + some buffer
|
|
await asyncio.sleep(3.5)
|
|
|
|
# After timeout, state should be cleaned up
|
|
assert ("mock", "sess_timeout") not in mgr._in_progress, \
|
|
"in_progress should be cleared after timeout"
|
|
assert ("mock", "sess_timeout") not in mgr._processing_tasks, \
|
|
"processing_tasks should be cleared after timeout"
|
|
|
|
# Channel should have received a timeout notification
|
|
assert len(ch.system_messages) >= 1, \
|
|
"Should have sent timeout notification"
|
|
assert "timed out" in ch.system_messages[0][1].lower(), \
|
|
"Timeout message should mention 'timed out'"
|
|
finally:
|
|
await mgr.stop_all()
|
|
|
|
print("PASS: Scenario 3 - LLM timeout fires and cleans up")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 4: Normal processing still works (no regression)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normal_processing_completes():
|
|
"""Process a fast batch and verify it completes normally."""
|
|
ch = MockChannel(_dummy_process, slow_seconds=0.1)
|
|
mgr = ChannelManager([ch])
|
|
mgr._processing_timeout = 60.0 # generous timeout
|
|
|
|
await mgr.start_all()
|
|
try:
|
|
payload = {"content_parts": [{"type": "text", "text": "fast"}],
|
|
"session_id": "sess_fast"}
|
|
mgr._enqueue_one("mock", payload)
|
|
|
|
# Wait for processing to complete
|
|
await asyncio.sleep(0.5)
|
|
|
|
# Payload should have been processed
|
|
assert len(ch.processed_payloads) == 1, \
|
|
f"Expected 1 processed payload, got {len(ch.processed_payloads)}"
|
|
assert ch.processed_payloads[0]["session_id"] == "sess_fast"
|
|
|
|
# State should be clean
|
|
assert ("mock", "sess_fast") not in mgr._in_progress, \
|
|
"in_progress should be cleared after normal completion"
|
|
assert ("mock", "sess_fast") not in mgr._processing_tasks, \
|
|
"processing_tasks should be cleared after normal completion"
|
|
|
|
# Pending should be flushed (empty)
|
|
assert ("mock", "sess_fast") not in mgr._pending, \
|
|
"pending should be empty after normal completion"
|
|
finally:
|
|
await mgr.stop_all()
|
|
|
|
print("PASS: Scenario 4 - Normal processing completes correctly")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 5: /clear in Telegram channel calls force_clear
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telegram_clear_calls_force_clear():
|
|
"""Verify TelegramChannel._force_clear_session calls
|
|
manager.force_clear_session with correct args."""
|
|
# We mock the TelegramChannel minimally to test the wiring
|
|
from adclaw.app.channels.telegram.channel import TelegramChannel
|
|
|
|
# Create a mock manager
|
|
mock_manager = AsyncMock()
|
|
mock_manager.force_clear_session = AsyncMock(return_value=True)
|
|
|
|
# Create a mock bot
|
|
mock_bot = AsyncMock()
|
|
|
|
# Create a mock application with bot
|
|
mock_app = MagicMock()
|
|
mock_app.bot = mock_bot
|
|
|
|
# Instantiate TelegramChannel with mocks
|
|
tg = TelegramChannel.__new__(TelegramChannel)
|
|
tg._channel_manager = mock_manager
|
|
tg._application = mock_app
|
|
tg.channel = "telegram"
|
|
|
|
# Call _force_clear_session
|
|
await tg._force_clear_session("12345")
|
|
|
|
# Verify manager was called
|
|
mock_manager.force_clear_session.assert_called_once_with("telegram", "12345")
|
|
|
|
# Verify bot sent a message
|
|
mock_bot.send_message.assert_called_once()
|
|
call_kwargs = mock_bot.send_message.call_args
|
|
assert call_kwargs.kwargs.get("chat_id") == "12345" or \
|
|
(call_kwargs.args and call_kwargs.args[0] == "12345") or \
|
|
call_kwargs[1].get("chat_id") == "12345", \
|
|
"Bot should send message to the correct chat_id"
|
|
|
|
print("PASS: Scenario 5 - Telegram /clear calls force_clear on manager")
|