- API versioning: all REST endpoints prefixed with /api/v1/ - Structured logging: replaced stdlib logging with structlog (console/JSON modes) - Alembic migrations: versioned DB schema with initial migration - Error standardization: global exception handlers for consistent envelope format - Interrupt cleanup: asyncio background task for expired interrupt removal - Integration tests: +30 tests (analytics, replay, openapi, error, session APIs) - Frontend tests: +57 tests (all components, pages, useWebSocket hook) - Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""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()
|