feat(pilot): PATCH /suggested-fixes/:id/outcome endpoint + tests
Records engineer-reported outcome (applied_success|applied_failed|
applied_partial|dismissed). Enforces transition rules (partial → success/
failed allowed; terminal outcomes return 409) and notes requirements
(applied_partial requires notes).
Sets verified_at on success/failure, stamps applied_at if not already
set (handles the case where the AI [FIX_OUTCOME] marker fires before
the engineer clicks Apply).
Also fixes pre-existing test-infrastructure bug: network_diagram.py used
bare string server_default="'[]'" for JSONB columns, which asyncpg
rejects during test schema creation. Changed to text("'[]'::jsonb") to
match the pattern used by script_template.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
199
backend/tests/test_fix_outcome_endpoint.py
Normal file
199
backend/tests/test_fix_outcome_endpoint.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/outcome.
|
||||
|
||||
Fixture style follows test_session_suggested_fixes_api.py:
|
||||
client, test_user, auth_headers, test_db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||
|
||||
|
||||
# ── shared helper ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _make_session_with_fix(test_db, user) -> tuple[str, str]:
|
||||
"""Create an AISession + active proposed SessionSuggestedFix.
|
||||
|
||||
Returns (session_id_str, fix_id_str).
|
||||
"""
|
||||
session = AISession(
|
||||
user_id=user["user_data"]["id"],
|
||||
account_id=user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "outcome test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
fix = SessionSuggestedFix(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
title="Reset credential cache",
|
||||
description="Clear stale credentials from the domain cache.",
|
||||
confidence_pct=82,
|
||||
)
|
||||
test_db.add(fix)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(fix)
|
||||
|
||||
return str(session.id), str(fix.id)
|
||||
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_outcome_marks_success(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "applied_success"
|
||||
assert body["verified_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_outcome_partial_requires_notes(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_partial"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "notes" in r.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_to_success_allowed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["status"] == "applied_success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_outcome_is_locked(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_failed", "notes": "no change"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_notes_can_be_updated(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""partial→partial with new notes updates the stored notes."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["partial_notes"] == "ran cred clear only"
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_partial", "notes": "also finished the rebuild; not verified yet"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["partial_notes"] == "also finished the rebuild; not verified yet"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismissed_sets_no_timestamps(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""dismissed outcome does not stamp applied_at or verified_at."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "dismissed"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "dismissed"
|
||||
assert body["applied_at"] is None
|
||||
assert body["verified_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_applied_at_auto_stamped_on_first_outcome(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""If applied_at is null when the engineer sets outcome, server stamps it."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_success"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["applied_at"] is not None
|
||||
assert body["verified_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_outcome_stores_notes_as_failure_reason(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""applied_failed stores notes under failure_reason (not partial_notes)."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_failed", "notes": "user reports no change"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["failure_reason"] == "user reports no change"
|
||||
assert body["partial_notes"] is None
|
||||
Reference in New Issue
Block a user