"""API tests for the FlowPilot Phase 4 Resolve + Escalate writeback flow. Covers: - Local-only path when no PSA ticket is linked (markdown stored, status flipped, no provider call). - PSA post happy path (provider mocked). - Status transition verified by re-fetch (happy path). - Status verification failure surfaces 502 with a clear error body. - 409 when trying to resolve an already-resolved session / escalate an already-escalated one. - Escalation parallel to resolution (same structure). """ from __future__ import annotations import uuid from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests from app.models.account_settings import AccountSettings from app.models.ai_session import AISession from app.models.psa_connection import PsaConnection from app.services.psa.types import NoteType, PSANote, PSATicket @pytest.fixture(autouse=True) def _isolate_preview_cache(): _clear_preview_cache_for_tests() yield _clear_preview_cache_for_tests() async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession: session_kwargs: dict = dict( user_id=user["user_data"]["id"], account_id=user["user_data"]["account_id"], session_type="chat", intake_type="free_text", intake_content={"text": "phase 4 test"}, status="active", confidence_tier="discovery", conversation_messages=[], ) if with_psa: # Fake connection — provider factory is patched in each test so we # never touch a real CW instance. from app.services.psa.encryption import encrypt_credentials conn = PsaConnection( account_id=user["user_data"]["account_id"], provider="connectwise", display_name="Test ConnectWise", site_url="https://fake.cw.local", company_id="TEST", credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}), is_active=True, ) test_db.add(conn) await test_db.flush() session_kwargs["psa_connection_id"] = conn.id session_kwargs["psa_ticket_id"] = "48291" session = AISession(**session_kwargs) test_db.add(session) await test_db.commit() await test_db.refresh(session) return session # ── Resolve: local-only ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_resolve_local_only_when_no_psa_ticket( client: AsyncClient, test_user, auth_headers, test_db ): session = await _make_session(test_db, test_user, with_psa=False) r = await client.post( f"/api/v1/ai-sessions/{session.id}/resolution-note/post", headers=auth_headers, json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, ) assert r.status_code == 200 body = r.json() assert body["outcome"] == "resolved_local" assert body["session_status"] == "resolved" assert body["external_id"] is None await test_db.refresh(session) assert session.status == "resolved" assert session.resolution_note_markdown == "## Problem\nx\n\n## Resolution\nfixed" assert session.resolved_at is not None # ── Resolve: happy path (PSA post + status transition verified) ──────────── @pytest.mark.asyncio async def test_resolve_posts_to_psa_and_verifies_status( client: AsyncClient, test_user, auth_headers, test_db ): session = await _make_session(test_db, test_user, with_psa=True) # Configure the Resolved status ID so the transition is attempted. await AccountSettings.set_setting( test_db, session.account_id, "cw_resolved_status_id", 42, ) await test_db.commit() # Mock provider: post_note returns a fake note, update_ticket_status # returns anything, get_ticket returns the new status_id (matches 42 # → verification passes). fake_provider = AsyncMock() fake_provider.post_note = AsyncMock(return_value=PSANote( id="cw-note-777", text="...", note_type=NoteType.RESOLUTION, created_at=None, )) fake_provider.update_ticket_status = AsyncMock(return_value=None) fake_provider.get_ticket = AsyncMock(return_value=PSATicket( id="48291", summary="t", status_id=42, status_name="Resolved", )) with patch( "app.services.psa_writeback_service.get_provider_for_connection", return_value=fake_provider, ): r = await client.post( f"/api/v1/ai-sessions/{session.id}/resolution-note/post", headers=auth_headers, json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, ) assert r.status_code == 200 body = r.json() assert body["outcome"] == "resolved" assert body["external_id"] == "cw-note-777" assert body["verified_status_id"] == 42 assert body["verified_status_name"] == "Resolved" # post_note must have used the RESOLUTION note type fake_provider.post_note.assert_awaited_once() called_note_type = fake_provider.post_note.await_args.kwargs["note_type"] assert called_note_type == NoteType.RESOLUTION # ── Resolve: status verification failure → 502 ────────────────────────────── @pytest.mark.asyncio async def test_resolve_surfaces_status_verification_failure( client: AsyncClient, test_user, auth_headers, test_db ): """CW silently rejecting a status change must NOT report silent success.""" session = await _make_session(test_db, test_user, with_psa=True) await AccountSettings.set_setting( test_db, session.account_id, "cw_resolved_status_id", 42, ) await test_db.commit() fake_provider = AsyncMock() fake_provider.post_note = AsyncMock(return_value=PSANote( id="cw-note-alpha", text="...", note_type=NoteType.RESOLUTION, created_at=None, )) fake_provider.update_ticket_status = AsyncMock(return_value=None) # get_ticket returns a DIFFERENT status_id — the transition didn't stick. fake_provider.get_ticket = AsyncMock(return_value=PSATicket( id="48291", summary="t", status_id=99, status_name="In Progress", )) with patch( "app.services.psa_writeback_service.get_provider_for_connection", return_value=fake_provider, ): r = await client.post( f"/api/v1/ai-sessions/{session.id}/resolution-note/post", headers=auth_headers, json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, ) assert r.status_code == 502 assert "did not verify" in r.json()["detail"] # ── Resolve: skip status transition when not configured ──────────────────── @pytest.mark.asyncio async def test_resolve_skips_status_transition_when_unconfigured( client: AsyncClient, test_user, auth_headers, test_db ): """No cw_resolved_status_id setting → post the note, don't touch status, not an error.""" session = await _make_session(test_db, test_user, with_psa=True) # Deliberately no AccountSettings row. fake_provider = AsyncMock() fake_provider.post_note = AsyncMock(return_value=PSANote( id="cw-note-beta", text="...", note_type=NoteType.RESOLUTION, created_at=None, )) with patch( "app.services.psa_writeback_service.get_provider_for_connection", return_value=fake_provider, ): r = await client.post( f"/api/v1/ai-sessions/{session.id}/resolution-note/post", headers=auth_headers, json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, ) assert r.status_code == 200 body = r.json() assert body["outcome"] == "resolved" assert body["verified_status_id"] is None assert body["status_transition_skipped_reason"] is not None fake_provider.update_ticket_status.assert_not_called() fake_provider.get_ticket.assert_not_called() # ── Resolve: already-resolved → 409 ───────────────────────────────────────── @pytest.mark.asyncio async def test_resolve_rejects_already_resolved_session( client: AsyncClient, test_user, auth_headers, test_db ): session = await _make_session(test_db, test_user, with_psa=False) session.status = "resolved" await test_db.commit() r = await client.post( f"/api/v1/ai-sessions/{session.id}/resolution-note/post", headers=auth_headers, json={"markdown": "..."}, ) assert r.status_code == 409 # ── Escalate: local-only + PSA parallels ──────────────────────────────────── @pytest.mark.asyncio async def test_escalate_local_only_when_no_psa_ticket( client: AsyncClient, test_user, auth_headers, test_db ): session = await _make_session(test_db, test_user, with_psa=False) r = await client.post( f"/api/v1/ai-sessions/{session.id}/escalation-package/post", headers=auth_headers, json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"}, ) assert r.status_code == 200 assert r.json()["outcome"] == "escalated_local" await test_db.refresh(session) assert session.status == "escalated" assert session.escalation_package_markdown is not None @pytest.mark.asyncio async def test_escalate_posts_internal_note_to_psa( client: AsyncClient, test_user, auth_headers, test_db ): """Escalation handoff posts as INTERNAL_ANALYSIS (not customer-visible).""" session = await _make_session(test_db, test_user, with_psa=True) fake_provider = AsyncMock() fake_provider.post_note = AsyncMock(return_value=PSANote( id="cw-note-esc", text="...", note_type=NoteType.INTERNAL_ANALYSIS, created_at=None, )) with patch( "app.services.psa_writeback_service.get_provider_for_connection", return_value=fake_provider, ): r = await client.post( f"/api/v1/ai-sessions/{session.id}/escalation-package/post", headers=auth_headers, json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"}, ) assert r.status_code == 200 body = r.json() assert body["outcome"] == "escalated" assert body["external_id"] == "cw-note-esc" # Handoff packages are internal — must NOT be posted with RESOLUTION or DESCRIPTION flags. called = fake_provider.post_note.await_args.kwargs assert called["note_type"] == NoteType.INTERNAL_ANALYSIS