feat: add PSA ticket export format and Quick-Start landing page
PSA Export: - New "PSA / Ticket Note" export format optimized for ConnectWise - Structured output: Problem, Steps Taken, Resolution, Time Spent, Notes - Prominent "Copy for Ticket" button on session detail page - 24 unit tests for PSA export generator Quick-Start Landing: - New default landing page with search-first UX - Auto-focused search bar with debounced tree search - "Continue Session" cards for active sessions - "Recent Trees" section from session history - Home nav item and logo links updated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
233
backend/tests/test_psa_export.py
Normal file
233
backend/tests/test_psa_export.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Tests for PSA/ticket note export format.
|
||||
|
||||
Covers: all fields present, missing optional fields, duration calculation,
|
||||
incomplete sessions, and edge cases.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.session import SessionExport
|
||||
from app.services.export_service import generate_psa_export, _format_duration
|
||||
|
||||
|
||||
def _make_session(
|
||||
tree_name="Test Tree",
|
||||
tree_description="A test problem description",
|
||||
ticket_number=None,
|
||||
client_name=None,
|
||||
decisions=None,
|
||||
scratchpad="",
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
):
|
||||
"""Create a mock session object for PSA export testing."""
|
||||
session = MagicMock()
|
||||
session.tree_snapshot = {"name": tree_name, "description": tree_description}
|
||||
session.ticket_number = ticket_number
|
||||
session.client_name = client_name
|
||||
session.decisions = decisions or []
|
||||
session.scratchpad = scratchpad
|
||||
session.started_at = started_at or datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
session.completed_at = completed_at or datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc)
|
||||
return session
|
||||
|
||||
|
||||
def _default_options():
|
||||
return SessionExport(format="psa", include_timestamps=True, include_tree_info=True)
|
||||
|
||||
|
||||
class TestPsaExportAllFields:
|
||||
"""Test PSA export with all fields populated."""
|
||||
|
||||
def test_includes_header(self):
|
||||
session = _make_session(
|
||||
ticket_number="TK-5001",
|
||||
client_name="Contoso Corp",
|
||||
)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "=== TROUBLESHOOTING NOTES ===" in result
|
||||
assert "Ticket: TK-5001" in result
|
||||
assert "Client: Contoso Corp" in result
|
||||
|
||||
def test_includes_tree_name_and_date(self):
|
||||
session = _make_session(tree_name="DNS Troubleshooting")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Tree: DNS Troubleshooting" in result
|
||||
assert "Date: 2026-01-15 10:00" in result
|
||||
|
||||
def test_includes_problem_section(self):
|
||||
session = _make_session(tree_description="Client cannot resolve DNS names")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- PROBLEM ---" in result
|
||||
assert "Client cannot resolve DNS names" in result
|
||||
|
||||
def test_includes_steps_taken(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Is the DNS service running?", "answer": "Yes", "notes": "Checked services"},
|
||||
{"question": "Can you ping the DNS server?", "answer": "No"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- STEPS TAKEN ---" in result
|
||||
assert "1. Is the DNS service running? -> Yes" in result
|
||||
assert " Notes: Checked services" in result
|
||||
assert "2. Can you ping the DNS server? -> No" in result
|
||||
|
||||
def test_includes_resolution(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Check cable", "answer": "Connected"},
|
||||
{"question": "Restart service", "answer": "Service restarted successfully"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- RESOLUTION ---" in result
|
||||
assert "Service restarted successfully" in result
|
||||
|
||||
def test_includes_duration(self):
|
||||
session = _make_session(
|
||||
started_at=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc),
|
||||
completed_at=datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- TIME SPENT ---" in result
|
||||
assert "Duration: 1h 30m" in result
|
||||
|
||||
def test_includes_engineer_notes(self):
|
||||
session = _make_session(scratchpad="Checked firewall rules, port 53 was blocked")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- ENGINEER NOTES ---" in result
|
||||
assert "Checked firewall rules, port 53 was blocked" in result
|
||||
|
||||
|
||||
class TestPsaExportMissingFields:
|
||||
"""Test PSA export gracefully handles missing optional fields."""
|
||||
|
||||
def test_missing_ticket_number(self):
|
||||
session = _make_session(ticket_number=None)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Ticket: N/A" in result
|
||||
|
||||
def test_missing_client_name(self):
|
||||
session = _make_session(client_name=None)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Client: N/A" in result
|
||||
|
||||
def test_missing_description(self):
|
||||
session = _make_session(tree_description="")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "No description provided." in result
|
||||
|
||||
def test_empty_scratchpad(self):
|
||||
session = _make_session(scratchpad="")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- ENGINEER NOTES ---" in result
|
||||
lines = result.split("\n")
|
||||
notes_idx = lines.index("--- ENGINEER NOTES ---")
|
||||
assert lines[notes_idx + 1] == "None"
|
||||
|
||||
def test_whitespace_only_scratchpad(self):
|
||||
session = _make_session(scratchpad=" \n \n ")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
lines = result.split("\n")
|
||||
notes_idx = lines.index("--- ENGINEER NOTES ---")
|
||||
assert lines[notes_idx + 1] == "None"
|
||||
|
||||
def test_no_decisions(self):
|
||||
session = _make_session(decisions=[])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "No steps recorded." in result
|
||||
assert "No resolution recorded." in result
|
||||
|
||||
def test_decision_with_action_performed_fallback(self):
|
||||
session = _make_session(decisions=[
|
||||
{"action_performed": "Restarted the server", "answer": "Done"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "1. Restarted the server -> Done" in result
|
||||
|
||||
def test_decision_without_answer(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Check logs"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "1. Check logs" in result
|
||||
# No arrow when answer is missing
|
||||
assert "->" not in result
|
||||
|
||||
|
||||
class TestDurationCalculation:
|
||||
"""Test the _format_duration helper."""
|
||||
|
||||
def test_hours_and_minutes(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 12, 45, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "2h 45m"
|
||||
|
||||
def test_minutes_only(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 10, 25, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "25 minutes"
|
||||
|
||||
def test_zero_minutes(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 10, 0, 30, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "0 minutes"
|
||||
|
||||
def test_incomplete_session(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, None) == "In progress"
|
||||
|
||||
def test_exact_hour(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "1h 0m"
|
||||
|
||||
|
||||
class TestPsaExportIncompleteSession:
|
||||
"""Test PSA export for sessions that are not yet completed."""
|
||||
|
||||
def test_incomplete_session_shows_in_progress_duration(self):
|
||||
session = _make_session(completed_at=None)
|
||||
# Override completed_at to None explicitly
|
||||
session.completed_at = None
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Duration: In progress" in result
|
||||
|
||||
def test_incomplete_session_with_steps(self):
|
||||
session = _make_session(
|
||||
decisions=[{"question": "First step", "answer": "Done"}],
|
||||
completed_at=None,
|
||||
)
|
||||
session.completed_at = None
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "1. First step -> Done" in result
|
||||
assert "Duration: In progress" in result
|
||||
|
||||
|
||||
class TestPsaExportFormat:
|
||||
"""Test the overall format structure of PSA export."""
|
||||
|
||||
def test_section_order(self):
|
||||
session = _make_session(
|
||||
ticket_number="TK-100",
|
||||
client_name="Acme",
|
||||
decisions=[{"question": "Step 1", "answer": "Yes"}],
|
||||
scratchpad="Some notes",
|
||||
)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
sections = [
|
||||
"=== TROUBLESHOOTING NOTES ===",
|
||||
"--- PROBLEM ---",
|
||||
"--- STEPS TAKEN ---",
|
||||
"--- RESOLUTION ---",
|
||||
"--- TIME SPENT ---",
|
||||
"--- ENGINEER NOTES ---",
|
||||
]
|
||||
positions = [result.index(s) for s in sections]
|
||||
assert positions == sorted(positions), "Sections should appear in the expected order"
|
||||
|
||||
def test_format_validation_accepts_psa(self):
|
||||
"""Verify the schema accepts 'psa' as a valid format."""
|
||||
export = SessionExport(format="psa")
|
||||
assert export.format == "psa"
|
||||
Reference in New Issue
Block a user