Files
resolutionflow/backend/tests/test_script_builder_inline.py
Michael Chihlas d4fae87236 feat(pilot): inline Script Builder session — idempotent create + auth + filtered list
POST /script-builder/sessions now supports origin='pilot_inline':
- Requires ai_session_id; validates it against current user ownership.
- Get-or-create: returns existing row for (user, ai_session_id) pair.
- Partial unique index on the DB backs the invariant; races resolve to
  the single winner row.

list_sessions + count_user_sessions default-scope to origin='standalone'
so inline scratch sessions don't pollute the /script-builder dashboard
or count against the 5-session cap.

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

177 lines
6.3 KiB
Python

"""Integration tests for inline pilot_inline script_builder_session behavior.
Covers:
- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline'
- Authorization: ai_session_id must belong to current user
- list_sessions + count_user_sessions default-scope to 'standalone'
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select, func
from uuid import uuid4
from app.models.ai_session import AISession
from app.models.script_builder_session import ScriptBuilderSession
async def _make_pilot_session(test_db, user) -> str:
"""Helper: create a minimal pilot session owned by `user`.
Matches the existing pattern used by test_fix_outcome_endpoint.py.
`user` is the dict returned by the test_user fixture:
{"email": ..., "password": ..., "user_data": {"id": ..., "account_id": ..., ...}}
"""
user_id = user["user_data"]["id"]
account_id = user["user_data"]["account_id"]
session = AISession(
id=uuid4(), user_id=user_id, account_id=account_id,
session_type="tshoot", intake_type="psa_ticket",
intake_content={}, title="QA",
status="active", confidence_tier="exploring", confidence_score=0.0,
)
test_db.add(session)
await test_db.commit()
return str(session.id)
@pytest.mark.asyncio
async def test_inline_create_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Second create with same (user, ai_session_id) returns the existing row."""
ai_session_id = await _make_pilot_session(test_db, test_user)
r1 = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r1.status_code in (200, 201), r1.text
first_id = r1.json()["id"]
r2 = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r2.status_code in (200, 201)
assert r2.json()["id"] == first_id
# DB confirms only one row
row_count = await test_db.scalar(
select(func.count()).select_from(ScriptBuilderSession).where(
ScriptBuilderSession.user_id == test_user["user_data"]["id"],
ScriptBuilderSession.origin == "pilot_inline",
)
)
assert row_count == 1
@pytest.mark.asyncio
async def test_inline_requires_ai_session_id(
client: AsyncClient, auth_headers
):
"""origin='pilot_inline' without ai_session_id is rejected."""
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline"},
headers=auth_headers,
)
assert r.status_code == 400
assert "ai_session_id" in r.text.lower()
@pytest.mark.asyncio
async def test_inline_ai_session_must_belong_to_caller(
client: AsyncClient, test_user, auth_headers, test_db
):
"""ai_session_id pointing at another user's session is rejected."""
# Create pilot session owned by a DIFFERENT user
from app.models.user import User
from app.models.account import Account
other_account = Account(id=uuid4(), name="other", display_code="OTH-0001")
test_db.add(other_account)
await test_db.flush()
other_user = User(
id=uuid4(), email="other@example.com",
password_hash="x", name="Other", role="engineer",
is_super_admin=False, is_team_admin=False, is_active=True,
is_service_account=False, must_change_password=False,
account_id=other_account.id, account_role="engineer",
)
test_db.add(other_user)
await test_db.flush()
# Build user dict in the same shape as the test_user fixture
other_user_dict = {
"user_data": {"id": str(other_user.id), "account_id": str(other_account.id)}
}
other_session_id = await _make_pilot_session(test_db, other_user_dict)
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": other_session_id},
headers=auth_headers,
)
assert r.status_code in (403, 404), r.text
@pytest.mark.asyncio
async def test_list_sessions_excludes_inline(
client: AsyncClient, test_user, auth_headers, test_db
):
"""GET /scripts/builder/sessions returns only standalone rows."""
ai_session_id = await _make_pilot_session(test_db, test_user)
# Create one inline session
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
# Create one standalone session
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
r = await client.get("/api/v1/scripts/builder/sessions", headers=auth_headers)
assert r.status_code == 200
body = r.json()
# Depending on response shape, this may be a list or {"sessions": [...]}.
items = body if isinstance(body, list) else body.get("sessions", body.get("items", []))
# Response schema does not surface `origin`; len==1 is the only meaningful guard:
# inline row would push this to 2.
assert len(items) == 1
@pytest.mark.asyncio
async def test_inline_sessions_do_not_count_against_cap(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Creating 5 pilot_inline sessions does not block a subsequent standalone."""
# Create 5 distinct pilot sessions and attach inline builder sessions to each
for _ in range(5):
ai_session_id = await _make_pilot_session(test_db, test_user)
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r.status_code in (200, 201), r.text
# A standalone create should still succeed — inline sessions don't count
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
assert r.status_code in (200, 201), r.text