All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
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>
296 lines
10 KiB
Python
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
|