Files
maskanx_cm_backend/tests/test_clear_bypass_and_timeout_e2e.py
T
AFFAANhandClaude Opus 5 19e1e84fb7 Initial commit: MaskanX backend
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>
2026-08-01 10:28:22 +05:30

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")