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>
234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
"""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"
|