Files
resolutionflow/backend/tests/test_phase6_draft_templates.py
Michael Chihlas 4aaf57adb5
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
Closes the loop on the Phase 5 "Run now, templatize after resolve" path.
After a session resolves, drafts queued by the three-option dialog surface
as a modal that lets the engineer review the AI-proposed parameterization
and either save as a reusable team template or skip. A "don't ask again"
toggle writes to account_settings.preferences so the next resolve won't
pop the modal.

Backend:
- /api/v1/draft-templates:
  * GET — list account drafts (pending_only default true; pass false for
    audit view including accepted/rejected)
  * GET /{id} — single draft
  * POST /{id}/accept — promotes to a new script_templates row with
    source_session_id / source_user_id / source_ticket_ref populated
    (drives the Script Library "generated from CW #X · resolved by Y"
    provenance chip). Draft flips to status=accepted,
    promoted_template_id set, resolved_at stamped. 409 on re-accept /
    already-rejected. 400 on unknown category_id.
  * POST /{id}/reject — flips to status=rejected. 409 on re-reject.
- /api/v1/accounts/me/preferences (GET/PATCH) — thin wrapper over
  AccountSettings.get_setting/set_setting. PATCH merges keys into the
  JSONB column, preserving existing keys the client didn't touch.
  Used by the "Don't ask again for this team" checkbox
  (templatize_prompt_enabled=false) and, forward-looking, by
  cw_resolved_status_id / cw_escalated_status_id from Phase 4.
- 13 tests: list filter, accept with/without edited_body, provenance
  copy-through, reject, 409 on re-accept / re-reject, 400 on unknown
  category, prefs round-trip with merge semantics.

Frontend:
- src/components/pilot/script/TemplatizePrompt.tsx — modal showing the
  drafted script with proposed parameters in the Phase 5
  ParameterizationPreview, editable name/category/description, an
  individual-parameter remove button, and the "don't ask again" opt-out.
  Accept posts to /draft-templates/{id}/accept + optionally PATCHes
  preferences. Skip posts /reject.
- src/api/draftTemplates.ts — typed client plus accountPreferencesApi.
- AssistantChatPage: after a successful Resolve (external OR local),
  fetches preferences + pending drafts for the session and queues the
  modal one draft at a time. Escalate does not trigger this flow.
- Sidebar: Scripts nav shows the pending-draft count as a badge. Fetched
  independently of the main sidebar stats so endpoint flakes don't
  break the rest of the sidebar.

Verified live 2026-04-22: seed two drafts → GET sees both pending →
accept draft A (template created, provenance CW #99123 populated) →
reject draft B → pending count drops → PATCH opt-out → GET confirms
persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:37:49 -04:00

296 lines
10 KiB
Python

"""API tests for the FlowPilot Phase 6 post-resolve templatization flow.
Covers:
- GET /api/v1/draft-templates list with pending_only filter.
- POST /{id}/accept → creates script_templates row with provenance fields,
marks draft accepted + promoted_template_id set.
- POST /{id}/reject → marks rejected.
- 409 when accepting or rejecting a non-pending draft.
- Category validation (400 on unknown category_id).
- GET/PATCH /accounts/me/preferences round-trip.
"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.models.account_settings import AccountSettings
from app.models.ai_session import AISession
from app.models.draft_template import DraftTemplate
from app.models.script_template import ScriptCategory, ScriptTemplate
async def _make_draft(
test_db,
user,
*,
proposed_name: str = "Test draft",
status_: str = "pending",
with_psa_ticket: bool = False,
) -> tuple[AISession, DraftTemplate]:
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": "phase 6 test"},
status="resolved",
confidence_tier="discovery",
conversation_messages=[],
psa_ticket_id="48307" if with_psa_ticket else None,
)
test_db.add(session)
await test_db.flush()
draft = DraftTemplate(
account_id=user["user_data"]["account_id"],
source_session_id=session.id,
source_user_id=user["user_data"]["id"],
script_body='Do-Something -Target {{ target_name }}\n',
proposed_parameters={
"parameters": [
{"key": "target_name", "label": "Target Name", "type": "text"},
],
},
proposed_name=proposed_name,
status=status_,
)
test_db.add(draft)
await test_db.commit()
await test_db.refresh(draft)
return session, draft
async def _make_category(test_db) -> ScriptCategory:
cat = ScriptCategory(
name="Phase 6 Test Category",
slug=f"phase-6-test-{datetime.now(timezone.utc).timestamp()}",
description="test",
is_active=True,
)
test_db.add(cat)
await test_db.commit()
await test_db.refresh(cat)
return cat
# ── List ─────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_pending_only_default(
client: AsyncClient, test_user, auth_headers, test_db
):
await _make_draft(test_db, test_user, proposed_name="pending-a", status_="pending")
await _make_draft(test_db, test_user, proposed_name="accepted-b", status_="accepted")
r = await client.get("/api/v1/draft-templates", headers=auth_headers)
assert r.status_code == 200
drafts = r.json()["drafts"]
names = {d["proposed_name"] for d in drafts}
assert "pending-a" in names
assert "accepted-b" not in names
@pytest.mark.asyncio
async def test_list_with_pending_only_false_includes_all(
client: AsyncClient, test_user, auth_headers, test_db
):
await _make_draft(test_db, test_user, proposed_name="pending-c", status_="pending")
await _make_draft(test_db, test_user, proposed_name="rejected-d", status_="rejected")
r = await client.get(
"/api/v1/draft-templates?pending_only=false", headers=auth_headers,
)
assert r.status_code == 200
names = {d["proposed_name"] for d in r.json()["drafts"]}
assert {"pending-c", "rejected-d"}.issubset(names)
# ── Accept ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_accept_creates_template_with_provenance(
client: AsyncClient, test_user, auth_headers, test_db
):
session, draft = await _make_draft(test_db, test_user, with_psa_ticket=True)
cat = await _make_category(test_db)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "Do Something On Target",
"category_id": str(cat.id),
"description": "promoted from phase 6 test",
"parameters_schema": {
"parameters": [
{"key": "target_name", "label": "Target", "field_type": "text"},
],
},
},
)
assert r.status_code == 201
body = r.json()
assert body["draft_id"] == str(draft.id)
assert body["promoted_template_id"] is not None
assert body["template_slug"] == "do-something-on-target"
# Draft row is now accepted with the promoted template ID set.
await test_db.refresh(draft)
assert draft.status == "accepted"
assert draft.promoted_template_id == UUID(body["promoted_template_id"])
assert draft.resolved_at is not None
# New template row exists with provenance fields populated.
tpl_result = await test_db.execute(
select(ScriptTemplate).where(ScriptTemplate.id == UUID(body["promoted_template_id"]))
)
tpl = tpl_result.scalar_one()
assert tpl.source_session_id == session.id
assert tpl.source_user_id == UUID(test_user["user_data"]["id"])
assert tpl.source_ticket_ref == "CW #48307"
assert tpl.script_body == draft.script_body # edited_body was not supplied
@pytest.mark.asyncio
async def test_accept_with_edited_body_overrides_draft(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user)
cat = await _make_category(test_db)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "Edited Body Test",
"category_id": str(cat.id),
"parameters_schema": {"parameters": []},
"edited_body": 'Write-Host "edited final version"\n',
},
)
assert r.status_code == 201
tpl = (
await test_db.execute(
select(ScriptTemplate).where(
ScriptTemplate.id == UUID(r.json()["promoted_template_id"])
)
)
).scalar_one()
assert tpl.script_body == 'Write-Host "edited final version"\n'
@pytest.mark.asyncio
async def test_accept_rejects_unknown_category(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user)
bogus_cat = "00000000-0000-0000-0000-000000000000"
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "x",
"category_id": bogus_cat,
"parameters_schema": {"parameters": []},
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_accept_already_accepted_returns_409(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user, status_="accepted")
cat = await _make_category(test_db)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "x",
"category_id": str(cat.id),
"parameters_schema": {"parameters": []},
},
)
assert r.status_code == 409
# ── Reject ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_reject_marks_draft_rejected(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
)
assert r.status_code == 200
assert r.json()["status"] == "rejected"
await test_db.refresh(draft)
assert draft.status == "rejected"
assert draft.resolved_at is not None
@pytest.mark.asyncio
async def test_reject_already_accepted_returns_409(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user, status_="accepted")
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
)
assert r.status_code == 409
# ── Preferences ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_preferences_empty_by_default(
client: AsyncClient, auth_headers,
):
r = await client.get("/api/v1/accounts/me/preferences", headers=auth_headers)
assert r.status_code == 200
assert r.json()["preferences"] == {}
@pytest.mark.asyncio
async def test_patch_preferences_merges_keys(
client: AsyncClient, test_user, auth_headers, test_db
):
# First write: one key.
r = await client.patch(
"/api/v1/accounts/me/preferences",
headers=auth_headers,
json={"preferences": {"templatize_prompt_enabled": False}},
)
assert r.status_code == 200
assert r.json()["preferences"]["templatize_prompt_enabled"] is False
# Second write: different key — first must be preserved (merge semantics).
r2 = await client.patch(
"/api/v1/accounts/me/preferences",
headers=auth_headers,
json={"preferences": {"cw_resolved_status_id": 42}},
)
assert r2.status_code == 200
prefs = r2.json()["preferences"]
assert prefs["templatize_prompt_enabled"] is False
assert prefs["cw_resolved_status_id"] == 42
# Stored on the account_settings row.
stored = (
await test_db.execute(
select(AccountSettings.preferences).where(
AccountSettings.account_id == UUID(test_user["user_data"]["account_id"])
)
)
).scalar_one()
assert stored["templatize_prompt_enabled"] is False
assert stored["cw_resolved_status_id"] == 42