"""Integration tests for SessionManager + InterruptManager lifecycle. These tests exercise the in-memory managers together, verifying the full lifecycle of sessions and interrupts: creation, TTL sliding, interrupt registration/resolution, and expired-interrupt cleanup. No database required -- both managers are in-memory. """ from __future__ import annotations import time from unittest.mock import patch import pytest from app.interrupt_manager import InterruptManager from app.session_manager import SessionManager pytestmark = pytest.mark.integration class TestSessionInterruptLifecycle: """Tests for the combined session + interrupt lifecycle.""" def test_create_session_register_interrupt_check_status(self) -> None: """Full lifecycle: create session, register interrupt, verify both states.""" sm = SessionManager(session_ttl_seconds=3600) im = InterruptManager(ttl_seconds=300) # Create a session state = sm.touch("thread-1") assert state.thread_id == "thread-1" assert not state.has_pending_interrupt assert not sm.is_expired("thread-1") # Register an interrupt record = im.register("thread-1", "cancel_order", {"order_id": "1042"}) sm.extend_for_interrupt("thread-1") assert im.has_pending("thread-1") session_state = sm.get_state("thread-1") assert session_state is not None assert session_state.has_pending_interrupt # Session should not expire while interrupt is pending assert not sm.is_expired("thread-1") def test_interrupt_expiry_after_ttl(self) -> None: """Interrupt expires when TTL elapses, even if session is alive.""" im = InterruptManager(ttl_seconds=5) record = im.register("thread-2", "refund", {"amount": 50}) assert im.has_pending("thread-2") # Simulate time passing beyond TTL with patch("app.interrupt_manager.time") as mock_time: mock_time.time.return_value = record.created_at + 10 assert not im.has_pending("thread-2") status = im.check_status("thread-2") assert status is not None assert status.is_expired assert status.remaining_seconds == 0.0 def test_interrupt_resolve_flow(self) -> None: """Resolving an interrupt removes it from pending and resets session.""" sm = SessionManager(session_ttl_seconds=3600) im = InterruptManager(ttl_seconds=300) sm.touch("thread-3") im.register("thread-3", "delete_account", {"user_id": "u1"}) sm.extend_for_interrupt("thread-3") # Verify pending state assert im.has_pending("thread-3") assert sm.get_state("thread-3").has_pending_interrupt # Resolve im.resolve("thread-3") sm.resolve_interrupt("thread-3") assert not im.has_pending("thread-3") session_state = sm.get_state("thread-3") assert session_state is not None assert not session_state.has_pending_interrupt def test_cleanup_expired_removes_old_interrupts(self) -> None: """cleanup_expired removes only expired interrupts, keeping active ones.""" im = InterruptManager(ttl_seconds=10) # Register two interrupts at different times old_record = im.register("thread-old", "action_old", {}) new_record = im.register("thread-new", "action_new", {}) # Simulate time where only old one expired with patch("app.interrupt_manager.time") as mock_time: # Move old record's creation to the past im._interrupts["thread-old"] = old_record.__class__( interrupt_id=old_record.interrupt_id, thread_id=old_record.thread_id, action=old_record.action, params=old_record.params, created_at=time.time() - 20, ttl_seconds=old_record.ttl_seconds, ) mock_time.time.return_value = time.time() expired = im.cleanup_expired() assert len(expired) == 1 assert expired[0].thread_id == "thread-old" # New one should still be pending assert im.has_pending("thread-new") assert not im.has_pending("thread-old") def test_session_ttl_sliding_window(self) -> None: """Touching a session resets the sliding window TTL.""" sm = SessionManager(session_ttl_seconds=3600) state1 = sm.touch("thread-5") first_activity = state1.last_activity time.sleep(0.01) state2 = sm.touch("thread-5") second_activity = state2.last_activity assert second_activity > first_activity assert not sm.is_expired("thread-5") def test_session_expires_after_ttl_without_activity(self) -> None: """Session expires when TTL passes without a touch or interrupt.""" sm = SessionManager(session_ttl_seconds=0) sm.touch("thread-6") # TTL is 0 so session is immediately expired assert sm.is_expired("thread-6") def test_pending_interrupt_prevents_session_expiry(self) -> None: """A session with pending interrupt does not expire even with TTL=0.""" sm = SessionManager(session_ttl_seconds=0) sm.touch("thread-7") sm.extend_for_interrupt("thread-7") # Even with TTL=0, session should not expire because of pending interrupt assert not sm.is_expired("thread-7") def test_retry_prompt_for_expired_interrupt(self) -> None: """InterruptManager generates a retry prompt for expired interrupts.""" im = InterruptManager(ttl_seconds=300) record = im.register("thread-8", "cancel_order", {"order_id": "1042"}) prompt = im.generate_retry_prompt(record) assert prompt["type"] == "interrupt_expired" assert prompt["thread_id"] == "thread-8" assert "cancel_order" in prompt["action"] assert "cancel_order" in prompt["message"] assert "expired" in prompt["message"].lower()