385 lines
16 KiB
Python
385 lines
16 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, generate_text_export, generate_markdown_export,
|
|
generate_html_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"
|
|
|
|
|
|
class TestPhaseB:
|
|
"""Tests for Phase B export features: custom markers, detail levels, summary."""
|
|
|
|
def test_custom_step_markers_psa(self):
|
|
"""Custom steps should have [CUSTOM] prefix in PSA export."""
|
|
session = _make_session(decisions=[
|
|
{"node_id": "node-1", "question": "Check DNS", "answer": "OK"},
|
|
{"node_id": "custom-abc123", "question": "Check Additional Logs", "answer": "Found error"},
|
|
])
|
|
options = SessionExport(format="psa")
|
|
result = generate_psa_export(session, options)
|
|
assert "[CUSTOM] Check Additional Logs" in result
|
|
assert "[CUSTOM] Check DNS" not in result
|
|
|
|
def test_custom_step_markers_markdown(self):
|
|
"""Custom steps should have [CUSTOM] prefix and subtitle in markdown."""
|
|
session = _make_session(decisions=[
|
|
{"node_id": "custom-xyz", "question": "Manual Check", "answer": "Done"},
|
|
])
|
|
options = SessionExport(format="markdown")
|
|
result = generate_markdown_export(session, options)
|
|
assert "[CUSTOM] Manual Check" in result
|
|
assert "*Custom step added by engineer*" in result
|
|
|
|
def test_custom_step_markers_html(self):
|
|
"""Custom steps should have purple badge in HTML export."""
|
|
session = _make_session(decisions=[
|
|
{"node_id": "custom-xyz", "question": "Manual Check", "answer": "Done"},
|
|
])
|
|
options = SessionExport(format="html")
|
|
result = generate_html_export(session, options)
|
|
assert "CUSTOM</span>" in result
|
|
|
|
def test_command_output_truncation_standard(self):
|
|
"""Standard detail level truncates long command output."""
|
|
long_output = "\n".join(f"line {i}" for i in range(20))
|
|
session = _make_session(decisions=[
|
|
{"node_id": "node-1", "question": "Run diagnostics", "answer": "See output",
|
|
"command_output": long_output},
|
|
])
|
|
options = SessionExport(format="text", detail_level="standard")
|
|
result = generate_text_export(session, options)
|
|
assert "(full output omitted — 20 lines)" in result
|
|
assert "line 19" not in result
|
|
|
|
def test_command_output_full_detail(self):
|
|
"""Full detail level shows all command output."""
|
|
long_output = "\n".join(f"line {i}" for i in range(20))
|
|
session = _make_session(decisions=[
|
|
{"node_id": "node-1", "question": "Run diagnostics", "answer": "See output",
|
|
"command_output": long_output},
|
|
])
|
|
options = SessionExport(format="text", detail_level="full")
|
|
result = generate_text_export(session, options)
|
|
assert "(full output omitted" not in result
|
|
assert "line 19" in result
|
|
|
|
def test_truncation_short_output_unchanged(self):
|
|
"""Short command output is not truncated even in standard mode."""
|
|
short_output = "line 1\nline 2\nline 3"
|
|
session = _make_session(decisions=[
|
|
{"node_id": "node-1", "question": "Check", "answer": "OK",
|
|
"command_output": short_output},
|
|
])
|
|
options = SessionExport(format="text", detail_level="standard")
|
|
result = generate_text_export(session, options)
|
|
assert "(full output omitted" not in result
|
|
assert "line 3" in result
|
|
|
|
def test_truncation_markdown_format(self):
|
|
"""Markdown format uses italic truncation marker."""
|
|
long_output = "\n".join(f"line {i}" for i in range(20))
|
|
session = _make_session(decisions=[
|
|
{"node_id": "node-1", "question": "Check", "answer": "OK",
|
|
"command_output": long_output},
|
|
])
|
|
options = SessionExport(format="markdown", detail_level="standard")
|
|
result = generate_markdown_export(session, options)
|
|
assert "*(full output omitted — 20 lines)*" in result
|
|
|
|
def test_truncation_html_format(self):
|
|
"""HTML format shows truncation marker (currently escaped in code block)."""
|
|
long_output = "\n".join(f"line {i}" for i in range(20))
|
|
session = _make_session(decisions=[
|
|
{"node_id": "node-1", "question": "Check", "answer": "OK",
|
|
"command_output": long_output},
|
|
])
|
|
options = SessionExport(format="html", detail_level="standard")
|
|
result = generate_html_export(session, options)
|
|
# HTML escaping causes <em> to become <em> in pre/code blocks
|
|
# This is actually correct behavior for code blocks
|
|
assert "full output omitted" in result
|
|
assert "20 lines" in result
|
|
assert "line 19" not in result
|
|
|
|
def test_summary_block_psa(self):
|
|
"""Summary block appears when include_summary is True."""
|
|
session = _make_session()
|
|
options = SessionExport(format="psa", include_summary=True)
|
|
result = generate_psa_export(session, options)
|
|
assert "--- SUMMARY ---" in result
|
|
assert "Issue:" in result
|
|
assert "Status:" in result
|
|
|
|
def test_no_summary_by_default(self):
|
|
"""Summary block should not appear by default."""
|
|
session = _make_session()
|
|
options = SessionExport(format="psa")
|
|
result = generate_psa_export(session, options)
|
|
assert "--- SUMMARY ---" not in result
|
|
|
|
def test_summary_block_markdown(self):
|
|
"""Summary block in markdown uses table format."""
|
|
session = _make_session()
|
|
options = SessionExport(format="markdown", include_summary=True)
|
|
result = generate_markdown_export(session, options)
|
|
assert "## Summary" in result
|
|
assert "| Issue |" in result
|
|
|
|
def test_summary_status_completed(self):
|
|
"""Completed resolved session shows Resolved status in summary."""
|
|
session = _make_session()
|
|
session.outcome = "resolved"
|
|
options = SessionExport(format="psa", include_summary=True)
|
|
result = generate_psa_export(session, options)
|
|
assert "Status: Resolved" in result
|
|
|
|
def test_summary_status_in_progress(self):
|
|
"""In-progress session shows step count in summary status."""
|
|
session = _make_session(
|
|
decisions=[{"node_id": "n1", "question": "Step 1", "answer": "Done"}],
|
|
completed_at=None,
|
|
)
|
|
session.completed_at = None
|
|
options = SessionExport(format="psa", include_summary=True)
|
|
result = generate_psa_export(session, options)
|
|
assert "In Progress" in result
|
|
|
|
def test_summary_empty_fields_no_placeholders(self):
|
|
"""Empty summary fields should be blank, not show placeholders."""
|
|
session = _make_session()
|
|
session.outcome_notes = None
|
|
session.next_steps = None
|
|
options = SessionExport(format="psa", include_summary=True)
|
|
result = generate_psa_export(session, options)
|
|
assert "[Edit in preview]" not in result
|