283 lines
10 KiB
Python
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
|