Files
resolutionflow/backend/tests/test_psa_writeback_phase4.py
Michael Chihlas 49f88569da
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / backend (pull_request) Failing after 27m35s
CI / frontend (pull_request) Successful in 2m46s
CI / e2e (pull_request) Failing after 4m9s
wip(handoff): restore backend suite to green
Co-Authored-By: Codex <noreply@openai.com>
2026-04-25 06:13:23 -04:00

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