"""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"