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