"""Integration tests for session endpoints."""
import pytest
from httpx import AsyncClient
class TestSessions:
"""Test suite for troubleshooting session endpoints."""
@pytest.mark.asyncio
async def test_create_session(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test starting a new troubleshooting session."""
session_data = {
"tree_id": test_tree["id"],
"ticket_number": "TICKET-123",
"client_name": "Test Client"
}
response = await client.post(
"/api/v1/sessions",
json=session_data,
headers=auth_headers
)
assert response.status_code == 201
data = response.json()
assert data["tree_id"] == test_tree["id"]
assert data["ticket_number"] == session_data["ticket_number"]
assert data["client_name"] == session_data["client_name"]
assert data["path_taken"] == []
assert data["decisions"] == []
assert data["completed_at"] is None
assert "id" in data
assert "started_at" in data
@pytest.mark.asyncio
async def test_get_session(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test retrieving a specific session."""
# Create a session first
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Get the session
response = await client.get(
f"/api/v1/sessions/{session_id}",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == session_id
assert data["tree_id"] == test_tree["id"]
@pytest.mark.asyncio
async def test_list_sessions(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test listing user's sessions."""
# Create a session
await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
# List sessions
response = await client.get("/api/v1/sessions", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
@pytest.mark.asyncio
async def test_update_session_path(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test updating session with path taken."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Update path
update_data = {
"path_taken": ["root", "solution1"]
}
response = await client.put(
f"/api/v1/sessions/{session_id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["path_taken"] == update_data["path_taken"]
@pytest.mark.asyncio
async def test_update_session_ticket(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test updating session metadata."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Update metadata
update_data = {
"ticket_number": "UPDATED-456",
"client_name": "Updated Client"
}
response = await client.put(
f"/api/v1/sessions/{session_id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["ticket_number"] == update_data["ticket_number"]
assert data["client_name"] == update_data["client_name"]
@pytest.mark.asyncio
async def test_complete_session(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test marking a session as complete."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Complete session
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["completed_at"] is not None
assert data["outcome"] == "resolved"
assert data["outcome_notes"] == "Issue fixed after restarting service"
@pytest.mark.asyncio
async def test_complete_session_with_cancelled_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test completing a session with 'cancelled' outcome."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "cancelled", "outcome_notes": "Ticket withdrawn by client"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["outcome"] == "cancelled"
assert data["outcome_notes"] == "Ticket withdrawn by client"
assert data["completed_at"] is not None
@pytest.mark.asyncio
async def test_complete_session_with_resolved_externally_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test completing a session with 'resolved_externally' outcome."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved_externally"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["outcome"] == "resolved_externally"
assert data["completed_at"] is not None
@pytest.mark.asyncio
async def test_complete_session_requires_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that completion requires an outcome payload."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
headers=auth_headers
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_complete_already_completed_session(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that completing an already completed session fails."""
# Create and complete session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
# Try to complete again
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_export_session_markdown(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test exporting session to markdown format."""
# Create session with ticket number
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"], "ticket_number": "EXP-001"},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Export as markdown
export_data = {
"format": "markdown",
"include_timestamps": True,
"include_tree_info": True
}
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json=export_data,
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "EXP-001" in content # Should contain ticket number
assert "#" in content # Markdown headers
@pytest.mark.asyncio
async def test_export_markdown_includes_outcome_and_step_duration(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test markdown export includes session outcome and per-step duration."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"},
headers=auth_headers
)
session_id = create_response.json()["id"]
step_timestamp = "2026-02-11T10:10:00Z"
update_response = await client.put(
f"/api/v1/sessions/{session_id}",
json={
"decisions": [{
"node_id": "root",
"question": "Is this a test?",
"answer": "Yes",
"action_performed": None,
"notes": "Validated quickly",
"automation_used": False,
"timestamp": step_timestamp,
"entered_at": "2026-02-11T10:08:30Z",
"exited_at": step_timestamp,
"duration_seconds": 90,
"attachments": []
}]
},
headers=auth_headers
)
assert update_response.status_code == 200
complete_response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"},
headers=auth_headers
)
assert complete_response.status_code == 200
export_response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "markdown", "include_timestamps": True, "include_tree_info": True},
headers=auth_headers
)
assert export_response.status_code == 200
content = export_response.text
assert "**Outcome:** Workaround Applied" in content
assert "**Duration:**" in content
assert "**Duration:** 1m 30s" in content
@pytest.mark.asyncio
async def test_export_session_text(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test exporting session to text format."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Export as text
export_data = {"format": "text"}
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json=export_data,
headers=auth_headers
)
assert response.status_code == 200
assert response.headers["content-type"] == "text/plain; charset=utf-8"
@pytest.mark.asyncio
async def test_export_session_html(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test exporting session to HTML format."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Export as HTML
export_data = {"format": "html"}
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json=export_data,
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "" in content
assert "" in content
@pytest.mark.asyncio
async def test_filter_sessions_by_completion(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test filtering sessions by completion status."""
# Create two sessions, complete one
create1 = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session1_id = create1.json()["id"]
await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
# Complete first session
await client.post(
f"/api/v1/sessions/{session1_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
# Get completed sessions
response = await client.get(
"/api/v1/sessions?completed=true",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
assert all(s["completed_at"] is not None for s in data)
# Get incomplete sessions
response = await client.get(
"/api/v1/sessions?completed=false",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
assert all(s["completed_at"] is None for s in data)
@pytest.mark.asyncio
async def test_create_session_has_scratchpad(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that new sessions include scratchpad field."""
session_data = {
"tree_id": test_tree["id"],
}
response = await client.post(
"/api/v1/sessions",
json=session_data,
headers=auth_headers
)
assert response.status_code == 201
data = response.json()
assert "scratchpad" in data
assert data["scratchpad"] == ""
@pytest.mark.asyncio
async def test_update_scratchpad_via_put(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test updating scratchpad through the existing PUT endpoint."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Update scratchpad via PUT
update_data = {
"scratchpad": "- Server IP: 192.168.1.50\n- Error: 0x80070005"
}
response = await client.put(
f"/api/v1/sessions/{session_id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["scratchpad"] == update_data["scratchpad"]
@pytest.mark.asyncio
async def test_patch_scratchpad(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test the dedicated PATCH scratchpad endpoint."""
# Create session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Patch scratchpad
response = await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": "- IP: 10.0.0.1\n- User: jsmith"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["scratchpad"] == "- IP: 10.0.0.1\n- User: jsmith"
@pytest.mark.asyncio
async def test_patch_scratchpad_not_found(
self, client: AsyncClient, auth_headers: dict
):
"""Test PATCH scratchpad with invalid session ID."""
import uuid
fake_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/sessions/{fake_id}/scratchpad",
json={"scratchpad": "test"},
headers=auth_headers
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_patch_scratchpad_empty_string(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test PATCH scratchpad with empty string (clear scratchpad)."""
# Create session and set scratchpad
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Set scratchpad
await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": "some notes"},
headers=auth_headers
)
# Clear scratchpad
response = await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": ""},
headers=auth_headers
)
assert response.status_code == 200
assert response.json()["scratchpad"] == ""
@pytest.mark.asyncio
async def test_patch_scratchpad_completed_session(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that scratchpad can still be updated on completed sessions."""
# Create and complete session
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
# Should still be able to update scratchpad on completed sessions
response = await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": "post-resolution notes"},
headers=auth_headers
)
assert response.status_code == 200
assert response.json()["scratchpad"] == "post-resolution notes"
@pytest.mark.asyncio
async def test_export_includes_scratchpad_markdown(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that markdown export includes scratchpad content."""
# Create session with scratchpad
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"], "ticket_number": "SP-001"},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Add scratchpad content
await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": "- Server IP: 192.168.1.50\n- Error: 0x80070005"},
headers=auth_headers
)
# Export as markdown
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "markdown", "include_tree_info": True},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "## Evidence / Reference" in content
assert "192.168.1.50" in content
assert "0x80070005" in content
@pytest.mark.asyncio
async def test_export_includes_scratchpad_text(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that text export includes scratchpad content."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": "Error code: 12345"},
headers=auth_headers
)
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "text"},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "EVIDENCE / REFERENCE" in content
assert "Error code: 12345" in content
@pytest.mark.asyncio
async def test_export_includes_scratchpad_html(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that HTML export includes scratchpad content."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": "DNS server: 10.0.0.5"},
headers=auth_headers
)
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "html"},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "Evidence / Reference" in content
assert "DNS server: 10.0.0.5" in content
@pytest.mark.asyncio
async def test_export_excludes_empty_scratchpad(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that export omits scratchpad section when empty."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
# Export without setting scratchpad
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "markdown"},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "Evidence / Reference" not in content
@pytest.mark.asyncio
async def test_export_excludes_whitespace_only_scratchpad(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that export omits scratchpad section when only whitespace."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
await client.patch(
f"/api/v1/sessions/{session_id}/scratchpad",
json={"scratchpad": " \n \n "},
headers=auth_headers
)
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "markdown"},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert "Evidence / Reference" not in content
@pytest.mark.asyncio
async def test_html_export_escapes_script_tags(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that HTML export escapes script tags in user content (XSS prevention)."""
session_data = {
"tree_id": test_tree["id"],
"ticket_number": '',
"client_name": '
'
}
create_response = await client.post(
"/api/v1/sessions", json=session_data, headers=auth_headers
)
session_id = create_response.json()["id"]
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "html", "include_tree_info": True},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert ''},
headers=auth_headers
)
response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "html"},
headers=auth_headers
)
assert response.status_code == 200
content = response.text
assert '