refactor: engineering improvements -- API versioning, structured logging, Alembic, error standardization, test coverage

- 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
This commit is contained in:
Yaojia Wang
2026-04-06 23:19:29 +02:00
parent af53111928
commit f0699436c5
59 changed files with 2846 additions and 149 deletions

View File

@@ -0,0 +1,183 @@
"""Integration tests for the /api/v1/analytics endpoint.
Tests the full API layer (routing, parameter validation, serialization,
error handling) with a mocked database pool.
"""
from __future__ import annotations
from dataclasses import asdict
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from app.analytics.models import AnalyticsResult, InterruptStats
pytestmark = pytest.mark.integration
_SAMPLE_RESULT = AnalyticsResult(
range="7d",
total_conversations=42,
resolution_rate=0.85,
escalation_rate=0.05,
avg_turns_per_conversation=3.2,
avg_cost_per_conversation_usd=0.012,
agent_usage=(),
interrupt_stats=InterruptStats(total=10, approved=7, rejected=2, expired=1),
)
def _build_app():
"""Build a minimal FastAPI app with the analytics router and mocked deps."""
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.analytics.api import router as analytics_router
from app.api_utils import envelope
test_app = FastAPI()
test_app.include_router(analytics_router)
@test_app.exception_handler(Exception)
async def _catch_all(request, exc):
return JSONResponse(
status_code=500,
content=envelope(None, success=False, error="Internal server error"),
)
from fastapi import HTTPException
@test_app.exception_handler(HTTPException)
async def _http_exc(request, exc):
return JSONResponse(
status_code=exc.status_code,
content=envelope(None, success=False, error=exc.detail),
)
@test_app.exception_handler(RequestValidationError)
async def _validation_exc(request, exc):
return JSONResponse(
status_code=422,
content=envelope(None, success=False, error=str(exc)),
)
# No admin_api_key set -> auth is skipped
test_app.state.settings = MagicMock(admin_api_key="")
test_app.state.pool = MagicMock()
return test_app
class TestAnalyticsValidRange:
"""Test analytics endpoint with valid range parameters."""
async def test_valid_range_7d_returns_envelope(self) -> None:
"""GET /api/v1/analytics?range=7d returns success envelope with data."""
test_app = _build_app()
with patch(
"app.analytics.api.get_analytics",
new_callable=AsyncMock,
return_value=_SAMPLE_RESULT,
):
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "7d"})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["error"] is None
assert body["data"]["total_conversations"] == 42
assert body["data"]["resolution_rate"] == 0.85
async def test_default_range_returns_success(self) -> None:
"""GET /api/v1/analytics with no range param defaults to 7d."""
test_app = _build_app()
with patch(
"app.analytics.api.get_analytics",
new_callable=AsyncMock,
return_value=_SAMPLE_RESULT,
) as mock_get:
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics")
assert resp.status_code == 200
# Verify default range of 7 days was passed
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args[1].get("range_days", call_args[0][1] if len(call_args[0]) > 1 else None) in (7, None) or call_args[0][1] == 7
async def test_large_range_365d_works(self) -> None:
"""GET /api/v1/analytics?range=365d is accepted (max boundary)."""
test_app = _build_app()
result = AnalyticsResult(
range="365d",
total_conversations=1000,
resolution_rate=0.9,
escalation_rate=0.02,
avg_turns_per_conversation=4.0,
avg_cost_per_conversation_usd=0.01,
agent_usage=(),
interrupt_stats=InterruptStats(),
)
with patch(
"app.analytics.api.get_analytics",
new_callable=AsyncMock,
return_value=result,
):
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "365d"})
assert resp.status_code == 200
assert resp.json()["success"] is True
class TestAnalyticsInvalidRange:
"""Test analytics endpoint with invalid range parameters."""
async def test_invalid_range_format_returns_400(self) -> None:
"""GET /api/v1/analytics?range=abc returns 400 error envelope."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "abc"})
assert resp.status_code == 400
body = resp.json()
assert body["success"] is False
assert body["data"] is None
assert "Invalid range format" in body["error"]
async def test_zero_day_range_returns_400(self) -> None:
"""GET /api/v1/analytics?range=0d returns 400 because 0 is below minimum."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "0d"})
assert resp.status_code == 400
body = resp.json()
assert body["success"] is False
assert "between 1 and 365" in body["error"]
async def test_range_exceeding_max_returns_400(self) -> None:
"""GET /api/v1/analytics?range=999d returns 400 because it exceeds 365."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "999d"})
assert resp.status_code == 400
body = resp.json()
assert body["success"] is False
assert "between 1 and 365" in body["error"]

View File

@@ -0,0 +1,128 @@
"""Integration tests for global error handling and envelope format consistency.
Tests that all error responses from the FastAPI app conform to the
standard envelope: {"success": false, "data": null, "error": "..."}.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.integration
def _build_app():
"""Build the actual FastAPI app with exception handlers but mocked state."""
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.analytics.api import router as analytics_router
from app.api_utils import envelope
from app.replay.api import router as replay_router
test_app = FastAPI()
test_app.include_router(analytics_router)
test_app.include_router(replay_router)
@test_app.exception_handler(HTTPException)
async def _http_exc(request, exc):
return JSONResponse(
status_code=exc.status_code,
content=envelope(None, success=False, error=exc.detail),
)
@test_app.exception_handler(RequestValidationError)
async def _validation_exc(request, exc):
return JSONResponse(
status_code=422,
content=envelope(None, success=False, error=str(exc)),
)
@test_app.exception_handler(Exception)
async def _catch_all(request, exc):
return JSONResponse(
status_code=500,
content=envelope(None, success=False, error="Internal server error"),
)
@test_app.get("/api/v1/health")
def health_check():
return {"status": "ok", "version": "0.6.0"}
test_app.state.settings = MagicMock(admin_api_key="")
test_app.state.pool = MagicMock()
return test_app
class TestEnvelopeFormat:
"""Tests that error responses consistently follow envelope format."""
async def test_http_400_produces_envelope(self) -> None:
"""A 400 error returns standard envelope with success=false."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "invalid"})
assert resp.status_code == 400
body = resp.json()
assert body["success"] is False
assert body["data"] is None
assert isinstance(body["error"], str)
assert len(body["error"]) > 0
async def test_validation_error_produces_422_envelope(self) -> None:
"""Invalid query param type returns 422 with envelope format."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
# page must be >= 1; passing 0 triggers validation error
resp = await client.get("/api/v1/conversations", params={"page": 0})
assert resp.status_code == 422
body = resp.json()
assert body["success"] is False
assert body["data"] is None
assert isinstance(body["error"], str)
async def test_all_error_fields_present(self) -> None:
"""Error envelope contains exactly success, data, and error keys."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/analytics", params={"range": "bad"})
body = resp.json()
assert set(body.keys()) == {"success", "data", "error"}
async def test_health_endpoint_returns_200(self) -> None:
"""Health check returns 200 with status ok."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert "version" in body
async def test_unknown_endpoint_returns_404(self) -> None:
"""Requesting a non-existent path returns 404."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/nonexistent-path")
# FastAPI returns 404 for unknown routes; may or may not be wrapped
assert resp.status_code == 404

View File

@@ -0,0 +1,164 @@
"""Integration tests for /api/v1/openapi/ endpoints.
Tests the full API layer for the OpenAPI import review workflow,
including job creation, status retrieval, classification updates,
and approval triggering.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.integration
def _build_app():
"""Build a minimal FastAPI app with the openapi router and mocked deps."""
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.api_utils import envelope
from app.openapi.review_api import router as openapi_router
test_app = FastAPI()
test_app.include_router(openapi_router)
@test_app.exception_handler(HTTPException)
async def _http_exc(request, exc):
return JSONResponse(
status_code=exc.status_code,
content=envelope(None, success=False, error=exc.detail),
)
@test_app.exception_handler(RequestValidationError)
async def _validation_exc(request, exc):
return JSONResponse(
status_code=422,
content=envelope(None, success=False, error=str(exc)),
)
test_app.state.settings = MagicMock(admin_api_key="")
return test_app
@pytest.fixture(autouse=True)
def _clear_job_store():
"""Clear the in-memory job store between tests."""
from app.openapi.review_api import _job_store
_job_store.clear()
yield
_job_store.clear()
class TestImportEndpoint:
"""Tests for POST /api/v1/openapi/import."""
async def test_import_returns_202_with_job_id(self) -> None:
"""Starting an import returns 202 with a job_id."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.post(
"/api/v1/openapi/import",
json={"url": "https://example.com/api/spec.json"},
)
assert resp.status_code == 202
body = resp.json()
assert "job_id" in body
assert body["status"] == "pending"
assert body["spec_url"] == "https://example.com/api/spec.json"
async def test_import_invalid_url_returns_422(self) -> None:
"""POST with invalid URL (no http/https) returns 422."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.post(
"/api/v1/openapi/import",
json={"url": "ftp://example.com/spec.json"},
)
assert resp.status_code == 422
body = resp.json()
assert body["success"] is False
class TestJobStatusEndpoint:
"""Tests for GET /api/v1/openapi/jobs/{job_id}."""
async def test_get_existing_job_returns_status(self) -> None:
"""Retrieving an existing job returns its status."""
from app.openapi.review_api import _job_store
_job_store["test-job-1"] = {
"job_id": "test-job-1",
"status": "done",
"spec_url": "https://example.com/spec.json",
"total_endpoints": 5,
"classified_count": 5,
"error_message": None,
"classifications": [],
}
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/openapi/jobs/test-job-1")
assert resp.status_code == 200
body = resp.json()
assert body["job_id"] == "test-job-1"
assert body["status"] == "done"
assert body["total_endpoints"] == 5
async def test_get_unknown_job_returns_404(self) -> None:
"""Retrieving a non-existent job returns 404 error envelope."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/openapi/jobs/unknown-id-999")
assert resp.status_code == 404
body = resp.json()
assert body["success"] is False
assert "not found" in body["error"].lower()
class TestApproveEndpoint:
"""Tests for POST /api/v1/openapi/jobs/{job_id}/approve."""
async def test_approve_with_no_classifications_returns_400(self) -> None:
"""Approving a job with no classifications returns 400."""
from app.openapi.review_api import _job_store
_job_store["empty-job"] = {
"job_id": "empty-job",
"status": "done",
"spec_url": "https://example.com/spec.json",
"total_endpoints": 0,
"classified_count": 0,
"error_message": None,
"classifications": [],
}
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.post("/api/v1/openapi/jobs/empty-job/approve")
assert resp.status_code == 400
body = resp.json()
assert body["success"] is False
assert "no classifications" in body["error"].lower()

View File

@@ -0,0 +1,213 @@
"""Integration tests for /api/v1/conversations and /api/v1/replay/{thread_id}.
Tests the full API layer with a mocked database pool, verifying routing,
serialization, pagination, and error handling in envelope format.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.integration
def _make_fake_cursor(rows, *, fetchone_value=None):
"""Build a fake async cursor returning the given rows on fetchall."""
cursor = AsyncMock()
cursor.fetchall = AsyncMock(return_value=rows)
if fetchone_value is not None:
cursor.fetchone = AsyncMock(return_value=fetchone_value)
return cursor
class _FakeConnection:
"""Fake async connection that returns pre-configured cursors in order."""
def __init__(self, cursors: list) -> None:
self._cursors = list(cursors)
self._idx = 0
async def execute(self, sql, params=None):
cursor = self._cursors[self._idx]
self._idx += 1
return cursor
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
class _FakePool:
"""Fake connection pool that yields a fake connection."""
def __init__(self, conn: _FakeConnection) -> None:
self._conn = conn
def connection(self):
return self._conn
def _build_app(pool=None):
"""Build a minimal FastAPI app with the replay router and mocked deps."""
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.api_utils import envelope
from app.replay.api import router as replay_router
test_app = FastAPI()
test_app.include_router(replay_router)
@test_app.exception_handler(HTTPException)
async def _http_exc(request, exc):
return JSONResponse(
status_code=exc.status_code,
content=envelope(None, success=False, error=exc.detail),
)
@test_app.exception_handler(RequestValidationError)
async def _validation_exc(request, exc):
return JSONResponse(
status_code=422,
content=envelope(None, success=False, error=str(exc)),
)
test_app.state.settings = MagicMock(admin_api_key="")
test_app.state.pool = pool or MagicMock()
return test_app
class TestListConversations:
"""Tests for GET /api/v1/conversations endpoint."""
async def test_returns_paginated_envelope(self) -> None:
"""Conversations list returns envelope with pagination metadata."""
count_cursor = _make_fake_cursor([], fetchone_value=(3,))
rows = [
{"thread_id": "t1", "created_at": "2026-01-01", "last_activity": "2026-01-01",
"status": "active", "total_tokens": 100, "total_cost_usd": 0.01},
{"thread_id": "t2", "created_at": "2026-01-02", "last_activity": "2026-01-02",
"status": "resolved", "total_tokens": 200, "total_cost_usd": 0.02},
]
list_cursor = _make_fake_cursor(rows)
conn = _FakeConnection([count_cursor, list_cursor])
pool = _FakePool(conn)
test_app = _build_app(pool)
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/conversations")
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["total"] == 3
assert len(body["data"]["conversations"]) == 2
assert body["data"]["page"] == 1
assert body["data"]["per_page"] == 20
async def test_custom_page_and_per_page(self) -> None:
"""Custom page/per_page params are reflected in the response."""
count_cursor = _make_fake_cursor([], fetchone_value=(50,))
list_cursor = _make_fake_cursor([])
conn = _FakeConnection([count_cursor, list_cursor])
pool = _FakePool(conn)
test_app = _build_app(pool)
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/conversations", params={"page": 3, "per_page": 10})
assert resp.status_code == 200
body = resp.json()
assert body["data"]["page"] == 3
assert body["data"]["per_page"] == 10
async def test_invalid_page_returns_422(self) -> None:
"""page=0 violates ge=1 constraint and returns 422 error envelope."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/conversations", params={"page": 0})
assert resp.status_code == 422
body = resp.json()
assert body["success"] is False
class TestReplayEndpoint:
"""Tests for GET /api/v1/replay/{thread_id} endpoint."""
async def test_valid_thread_returns_timeline(self) -> None:
"""Replay with valid thread_id returns steps in envelope format."""
checkpoint_rows = [
{
"thread_id": "abc123",
"checkpoint_id": "cp1",
"checkpoint": {
"channel_values": {
"messages": [
{"type": "human", "content": "Hello", "created_at": "2026-01-01T00:00:00Z"},
{"type": "ai", "content": "Hi there!", "created_at": "2026-01-01T00:00:01Z"},
]
}
},
"metadata": {},
}
]
cursor = _make_fake_cursor(checkpoint_rows)
conn = _FakeConnection([cursor])
pool = _FakePool(conn)
test_app = _build_app(pool)
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/replay/abc123")
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["thread_id"] == "abc123"
assert body["data"]["total_steps"] == 2
assert len(body["data"]["steps"]) == 2
assert body["data"]["steps"][0]["type"] == "user_message"
assert body["data"]["steps"][1]["type"] == "agent_response"
async def test_invalid_thread_id_format_returns_400(self) -> None:
"""Thread IDs with path traversal characters are rejected with 400."""
test_app = _build_app()
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/replay/../../etc/passwd")
# FastAPI may return 400 from our handler or 404 from routing
assert resp.status_code in (400, 404, 422)
async def test_nonexistent_thread_returns_404(self) -> None:
"""Replay with a thread_id that has no checkpoints returns 404."""
cursor = _make_fake_cursor([])
conn = _FakeConnection([cursor])
pool = _FakePool(conn)
test_app = _build_app(pool)
async with AsyncClient(
transport=ASGITransport(app=test_app), base_url="http://test"
) as client:
resp = await client.get("/api/v1/replay/nonexistent-thread")
assert resp.status_code == 404
body = resp.json()
assert body["success"] is False
assert "not found" in body["error"].lower()

View File

@@ -0,0 +1,159 @@
"""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()