Files
resolutionflow/backend/tests/test_psa_writeback_phase4.py
Michael Chihlas 8fd2c1bac6
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
feat(pilot): Phase 4 — Resolve + Escalate PSA writebacks with status verification
Wires the preview popover's Confirm & post action to ConnectWise (and,
via the provider pattern, any future PSA). Adds the parallel Escalate
flow with the handoff-oriented five-section markdown. Sessions without a
linked PSA ticket resolve/escalate locally — markdown stored, status
flipped, nothing posted externally.

Backend:
- EscalationPackageGeneratorService: Sonnet, five sections (Problem /
  What we've confirmed / What we've tried / Current hypothesis /
  Suggested next steps). Shares the preview_cache with a separate KIND
  so Resolve and Escalate previews for the same state coexist.
- PSAWritebackService: post_resolution_note (RESOLUTION note type,
  customer-visible), post_escalation_package (INTERNAL_ANALYSIS,
  handoff for the next engineer only), transition_ticket_status with
  mandatory re-fetch verification. PSAStatusVerificationError surfaces
  loudly when CW silently rejects a status change — the
  ConnectWise anti-pattern CLAUDE.md flags.
- Endpoints:
  * POST /ai-sessions/{id}/escalation-package/preview
  * POST /ai-sessions/{id}/resolution-note/post
  * POST /ai-sessions/{id}/escalation-package/post
  Outcomes: "resolved" / "escalated" with external_id + verified status,
  "resolved_local" / "escalated_local" when no PSA linked.
- Target CW status IDs live in account_settings.preferences
  (cw_resolved_status_id, cw_escalated_status_id). When unset, the post
  proceeds without a status transition — response includes a
  status_transition_skipped_reason rather than silently erroring.
- 7 tests: local-only path, PSA happy path with verified transition,
  status verification failure → 502, skipped transition when
  unconfigured, 409 on already-resolved re-post, escalate parallel path,
  internal-analysis note type enforced.

Frontend:
- ResolutionNotePreview now kind-parameterized ('resolve' | 'escalate')
  with inline edit + Confirm & post. Preview loads from the matching
  backend endpoint; posting calls the matching endpoint; outcome toast
  surfaces the verified CW status or the local-only result.
- AssistantChatPage: previewKind state replaces previewOpen; two toggle
  buttons (Preview Resolve note / Escalate instead) in the lane's bottom
  slot. handleConfirmPost dispatches by kind.

Verified 2026-04-22:
- Local-only Resolve + Escalate round-trip against the dev stack.
- Live Sonnet escalation-package preview; cache hit on repeat call
  with no state change (separate cache kind from resolution-note).
- PSA post + status-verification paths covered by mocked-provider pytest
  cases. Live CW round-trip pending a test CW instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:54:54 -04:00

282 lines
10 KiB
Python

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