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>
177 lines
6.3 KiB
Python
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
|