Server-assigns a uuid4 id to every AI-generated node (Finding 1 showstopper:
nodes had no id but the advance protocol keys on node_id, so ai_build walks
never advanced past question 1). Replaces the hidden {"node_type":"meta"}
walked_path convention with real category/problem_text/pending_node columns on
l1_walk_sessions (migration 61dda4f615c6) — fixes junk proposals + off-by-one
depth cap (Findings 8,9), and pending_node replays the served node on re-mount
(no duplicate paid LLM call). Intake honors explicit flow_id and adhoc=True
(Findings 4,5); flow_proposals.l1_session_id FK -> CASCADE (Finding 6 time
bomb); L1 category GET is owner+admin like PATCH and require_account_owner_or_admin
delegates to User.can_manage_account (Finding 7); escalate falls back to default
recipients + filters deleted_at + warns when empty (Finding 10). Cleanups: dead
ticket_ref removed, IntakeResponse per-outcome validator, unused acknowledged
dropped, escalations partial index, restored a deleted audit assertion.
Full Phase 2A backend set: 110 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
66 lines
2.3 KiB
Python
66 lines
2.3 KiB
Python
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.account import Account
|
|
from app.models.flow_proposal import FlowProposal
|
|
from app.models.l1_walk_session import L1WalkSession
|
|
from app.models.user import User
|
|
|
|
|
|
def test_flow_proposal_accepts_l1_session_id_without_source_session():
|
|
p = FlowProposal(
|
|
account_id=uuid.uuid4(),
|
|
l1_session_id=uuid.uuid4(),
|
|
source_session_id=None,
|
|
proposal_type="new_flow",
|
|
title="AI L1 draft",
|
|
proposed_flow_data={"tree_structure": {"id": "root"}},
|
|
source="ai_realtime_l1",
|
|
status="pending",
|
|
)
|
|
assert p.l1_session_id is not None and p.source_session_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deleting_l1_session_cascades_proposal_not_check_violation(test_db: AsyncSession):
|
|
"""Finding 6: an L1-sourced proposal has source_session_id NULL by the exactly-one
|
|
CHECK. With ondelete=CASCADE the proposal dies with its session; the old SET NULL
|
|
would have NULLed both columns and aborted the DELETE on the CHECK (time bomb)."""
|
|
s = str(uuid.uuid4())[:8]
|
|
account = Account(id=uuid.uuid4(), name=f"Acct {s}", display_code=s.upper())
|
|
test_db.add(account)
|
|
await test_db.flush()
|
|
user = User(
|
|
id=uuid.uuid4(), email=f"u-{uuid.uuid4()}@example.com", name="U",
|
|
account_id=account.id, account_role="l1_tech", role="engineer", is_active=True,
|
|
)
|
|
test_db.add(user)
|
|
await test_db.flush()
|
|
session = L1WalkSession(
|
|
account_id=account.id, created_by_user_id=user.id,
|
|
ticket_id="t-cascade", ticket_kind="internal", session_kind="ai_build",
|
|
)
|
|
test_db.add(session)
|
|
await test_db.flush()
|
|
proposal = FlowProposal(
|
|
account_id=account.id, l1_session_id=session.id, source_session_id=None,
|
|
proposal_type="new_flow", title="AI L1 draft",
|
|
proposed_flow_data={"tree_structure": {"id": "root"}},
|
|
source="ai_realtime_l1", status="pending",
|
|
)
|
|
test_db.add(proposal)
|
|
await test_db.flush()
|
|
pid = proposal.id
|
|
|
|
# Delete the session — must succeed and cascade to the proposal.
|
|
await test_db.delete(session)
|
|
await test_db.flush()
|
|
|
|
remaining = (await test_db.execute(
|
|
select(FlowProposal).where(FlowProposal.id == pid)
|
|
)).scalar_one_or_none()
|
|
assert remaining is None
|