* feat: flexible intake — deferred variables + prepared sessions
Remove blocking intake form modal. Variables are now filled inline during
flow execution or pre-filled via prepared sessions. Adds PATCH /sessions/{id}/variables
endpoint, POST /sessions/prepare for session pre-staging, inline variable prompts
in StepDetail, editable Session Variables panel, and "Prepared for You" dashboard section.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: pass treeData directly to startSession to avoid stale state
setTree(treeData) hasn't committed when startSession runs immediately
after, so tree is still null and getStepsFromTree returns []. This
caused the step detail area to render empty on new session start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: wire PrepareSessionModal entry point in Flow Library
Add "Prepare session" button (clipboard icon) to grid, list, and table
views for procedural/maintenance flows. Clicking fetches tree intake
fields and account members, then opens PrepareSessionModal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
721 lines
25 KiB
Python
721 lines
25 KiB
Python
"""Tests for procedural flows feature (tree_type='procedural')."""
|
|
import pytest
|
|
from app.core.tree_validation import (
|
|
validate_procedural_structure,
|
|
can_publish_tree,
|
|
)
|
|
from app.schemas.tree import IntakeFormField, TreeCreate
|
|
|
|
|
|
# --- Helper Data ---
|
|
|
|
def make_valid_procedural_steps():
|
|
"""Return a valid procedural step array."""
|
|
return {
|
|
"steps": [
|
|
{
|
|
"id": "step-1",
|
|
"type": "procedure_step",
|
|
"title": "Configure Static IP",
|
|
"description": "Set the IP to [VAR:ip_address]",
|
|
"content_type": "action",
|
|
},
|
|
{
|
|
"id": "step-2",
|
|
"type": "procedure_step",
|
|
"title": "Install AD DS Role",
|
|
"description": "Add the AD DS server role",
|
|
"content_type": "action",
|
|
},
|
|
{
|
|
"id": "step-end",
|
|
"type": "procedure_end",
|
|
"title": "Procedure Complete",
|
|
},
|
|
]
|
|
}
|
|
|
|
|
|
def make_valid_intake_form():
|
|
"""Return valid intake form field dicts."""
|
|
return [
|
|
{
|
|
"variable_name": "server_name",
|
|
"label": "Server Name",
|
|
"field_type": "text",
|
|
"required": True,
|
|
"display_order": 1,
|
|
},
|
|
{
|
|
"variable_name": "ip_address",
|
|
"label": "IP Address",
|
|
"field_type": "ip_address",
|
|
"required": True,
|
|
"display_order": 2,
|
|
},
|
|
{
|
|
"variable_name": "notes",
|
|
"label": "Additional Notes",
|
|
"field_type": "textarea",
|
|
"required": False,
|
|
"display_order": 3,
|
|
},
|
|
]
|
|
|
|
|
|
# --- Procedural Validation Unit Tests ---
|
|
|
|
class TestValidateProceduralStructure:
|
|
"""Unit tests for validate_procedural_structure()."""
|
|
|
|
def test_valid_procedural_tree(self):
|
|
is_valid, errors = validate_procedural_structure(make_valid_procedural_steps())
|
|
assert is_valid
|
|
assert errors == []
|
|
|
|
def test_empty_structure(self):
|
|
is_valid, errors = validate_procedural_structure({})
|
|
assert not is_valid
|
|
assert any("empty" in e["message"].lower() for e in errors)
|
|
|
|
def test_none_structure(self):
|
|
is_valid, errors = validate_procedural_structure(None)
|
|
assert not is_valid
|
|
|
|
def test_missing_steps_array(self):
|
|
is_valid, errors = validate_procedural_structure({"name": "test"})
|
|
assert not is_valid
|
|
assert any("steps" in e["message"] for e in errors)
|
|
|
|
def test_empty_steps_array(self):
|
|
is_valid, errors = validate_procedural_structure({"steps": []})
|
|
assert not is_valid
|
|
|
|
def test_step_missing_id(self):
|
|
structure = {
|
|
"steps": [
|
|
{"type": "procedure_step", "title": "Step 1"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("id" in e["field"] for e in errors)
|
|
|
|
def test_step_missing_type(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "title": "Step 1"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("type" in e["field"] for e in errors)
|
|
|
|
def test_step_missing_title(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("title" in e["field"] for e in errors)
|
|
|
|
def test_invalid_step_type(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "decision", "title": "Bad Type"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("Invalid step type" in e["message"] for e in errors)
|
|
|
|
def test_no_end_step(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1"},
|
|
{"id": "s2", "type": "procedure_step", "title": "Step 2"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("procedure_end" in e["message"] for e in errors)
|
|
|
|
def test_multiple_end_steps(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1"},
|
|
{"id": "end1", "type": "procedure_end", "title": "End 1"},
|
|
{"id": "end2", "type": "procedure_end", "title": "End 2"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
|
|
def test_end_step_not_last(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "end", "type": "procedure_end", "title": "End"},
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("last step" in e["message"] for e in errors)
|
|
|
|
def test_duplicate_step_ids(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1"},
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 2"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("Duplicate" in e["message"] for e in errors)
|
|
|
|
def test_invalid_content_type(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1", "content_type": "bad_type"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert not is_valid
|
|
assert any("content_type" in e["field"] for e in errors)
|
|
|
|
def test_valid_content_types(self):
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1", "content_type": "action"},
|
|
{"id": "s2", "type": "procedure_step", "title": "Step 2", "content_type": "informational"},
|
|
{"id": "s3", "type": "procedure_step", "title": "Step 3", "content_type": "verification"},
|
|
{"id": "s4", "type": "procedure_step", "title": "Step 4", "content_type": "warning"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert is_valid
|
|
|
|
def test_single_step_with_end(self):
|
|
"""Minimal valid procedural tree: one step + end."""
|
|
structure = {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Only Step"},
|
|
{"id": "end", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
is_valid, errors = validate_procedural_structure(structure)
|
|
assert is_valid
|
|
|
|
|
|
# --- can_publish_tree dispatch ---
|
|
|
|
class TestCanPublishTreeDispatch:
|
|
"""Test can_publish_tree dispatches correctly by tree_type."""
|
|
|
|
def test_troubleshooting_uses_tree_validation(self):
|
|
"""Default tree_type uses troubleshooting validation."""
|
|
structure = {
|
|
"id": "root",
|
|
"type": "decision",
|
|
"question": "Test?",
|
|
"children": [
|
|
{"id": "y", "type": "solution", "title": "Yes"},
|
|
{"id": "n", "type": "solution", "title": "No"},
|
|
]
|
|
}
|
|
can, errors = can_publish_tree(structure, "My Tree", tree_type="troubleshooting")
|
|
assert can
|
|
|
|
def test_procedural_uses_procedural_validation(self):
|
|
can, errors = can_publish_tree(
|
|
make_valid_procedural_steps(),
|
|
"DC Build Procedure",
|
|
tree_type="procedural",
|
|
)
|
|
assert can
|
|
|
|
def test_procedural_rejects_troubleshooting_structure(self):
|
|
"""A troubleshooting structure should fail procedural validation."""
|
|
ts_structure = {
|
|
"id": "root",
|
|
"type": "decision",
|
|
"question": "Test?",
|
|
}
|
|
can, errors = can_publish_tree(ts_structure, "My Tree", tree_type="procedural")
|
|
assert not can
|
|
|
|
def test_procedural_validates_intake_form(self):
|
|
can, errors = can_publish_tree(
|
|
make_valid_procedural_steps(),
|
|
"DC Build",
|
|
tree_type="procedural",
|
|
intake_form=make_valid_intake_form(),
|
|
)
|
|
assert can
|
|
|
|
def test_procedural_rejects_duplicate_variable_names(self):
|
|
intake = [
|
|
{"variable_name": "name", "label": "Name", "field_type": "text", "required": True, "display_order": 1},
|
|
{"variable_name": "name", "label": "Name 2", "field_type": "text", "required": False, "display_order": 2},
|
|
]
|
|
can, errors = can_publish_tree(
|
|
make_valid_procedural_steps(),
|
|
"DC Build",
|
|
tree_type="procedural",
|
|
intake_form=intake,
|
|
)
|
|
assert not can
|
|
assert any("Duplicate" in e["message"] for e in errors)
|
|
|
|
def test_procedural_rejects_select_without_options(self):
|
|
intake = [
|
|
{"variable_name": "role", "label": "Server Role", "field_type": "select", "required": True, "display_order": 1},
|
|
]
|
|
can, errors = can_publish_tree(
|
|
make_valid_procedural_steps(),
|
|
"DC Build",
|
|
tree_type="procedural",
|
|
intake_form=intake,
|
|
)
|
|
assert not can
|
|
assert any("option" in e["message"].lower() for e in errors)
|
|
|
|
def test_empty_name_blocks_publish(self):
|
|
can, errors = can_publish_tree(
|
|
make_valid_procedural_steps(),
|
|
"",
|
|
tree_type="procedural",
|
|
)
|
|
assert not can
|
|
assert any("name" in e["field"] for e in errors)
|
|
|
|
|
|
# --- IntakeFormField Pydantic Schema Tests ---
|
|
|
|
class TestIntakeFormFieldSchema:
|
|
"""Test IntakeFormField Pydantic validation."""
|
|
|
|
def test_valid_text_field(self):
|
|
field = IntakeFormField(
|
|
variable_name="server_name",
|
|
label="Server Name",
|
|
field_type="text",
|
|
required=True,
|
|
display_order=1,
|
|
)
|
|
assert field.variable_name == "server_name"
|
|
|
|
def test_invalid_variable_name_uppercase(self):
|
|
with pytest.raises(Exception):
|
|
IntakeFormField(
|
|
variable_name="ServerName",
|
|
label="Server Name",
|
|
field_type="text",
|
|
required=True,
|
|
display_order=1,
|
|
)
|
|
|
|
def test_invalid_variable_name_starts_with_number(self):
|
|
with pytest.raises(Exception):
|
|
IntakeFormField(
|
|
variable_name="1server",
|
|
label="Server Name",
|
|
field_type="text",
|
|
required=True,
|
|
display_order=1,
|
|
)
|
|
|
|
def test_valid_variable_name_with_underscores(self):
|
|
field = IntakeFormField(
|
|
variable_name="ip_address_v4",
|
|
label="IPv4 Address",
|
|
field_type="ip_address",
|
|
required=True,
|
|
display_order=1,
|
|
)
|
|
assert field.variable_name == "ip_address_v4"
|
|
|
|
def test_select_requires_options(self):
|
|
with pytest.raises(Exception):
|
|
IntakeFormField(
|
|
variable_name="role",
|
|
label="Role",
|
|
field_type="select",
|
|
required=True,
|
|
display_order=1,
|
|
)
|
|
|
|
def test_select_with_options_valid(self):
|
|
field = IntakeFormField(
|
|
variable_name="role",
|
|
label="Role",
|
|
field_type="select",
|
|
required=True,
|
|
options=["AD DS", "DNS", "DHCP"],
|
|
display_order=1,
|
|
)
|
|
assert field.options == ["AD DS", "DNS", "DHCP"]
|
|
|
|
def test_multi_select_requires_options(self):
|
|
with pytest.raises(Exception):
|
|
IntakeFormField(
|
|
variable_name="roles",
|
|
label="Roles",
|
|
field_type="multi_select",
|
|
required=True,
|
|
display_order=1,
|
|
)
|
|
|
|
def test_checkbox_field(self):
|
|
field = IntakeFormField(
|
|
variable_name="confirm_backup",
|
|
label="Backup confirmed?",
|
|
field_type="checkbox",
|
|
required=False,
|
|
display_order=1,
|
|
)
|
|
assert field.field_type == "checkbox"
|
|
|
|
|
|
# --- TreeCreate Schema with Procedural Fields ---
|
|
|
|
class TestTreeCreateProceduralSchema:
|
|
"""Test TreeCreate schema with tree_type and intake_form."""
|
|
|
|
def test_defaults_to_troubleshooting(self):
|
|
tree = TreeCreate(
|
|
name="Test",
|
|
tree_structure={"id": "root", "type": "decision", "question": "Test?"},
|
|
)
|
|
assert tree.tree_type == "troubleshooting"
|
|
assert tree.intake_form is None
|
|
|
|
def test_procedural_with_intake_form(self):
|
|
tree = TreeCreate(
|
|
name="DC Build",
|
|
tree_type="procedural",
|
|
tree_structure=make_valid_procedural_steps(),
|
|
intake_form=[
|
|
IntakeFormField(
|
|
variable_name="server_name",
|
|
label="Server Name",
|
|
field_type="text",
|
|
required=True,
|
|
display_order=1,
|
|
),
|
|
],
|
|
)
|
|
assert tree.tree_type == "procedural"
|
|
assert len(tree.intake_form) == 1
|
|
|
|
def test_duplicate_variable_names_rejected(self):
|
|
with pytest.raises(Exception):
|
|
TreeCreate(
|
|
name="DC Build",
|
|
tree_type="procedural",
|
|
tree_structure=make_valid_procedural_steps(),
|
|
intake_form=[
|
|
IntakeFormField(
|
|
variable_name="name",
|
|
label="Name",
|
|
field_type="text",
|
|
required=True,
|
|
display_order=1,
|
|
),
|
|
IntakeFormField(
|
|
variable_name="name",
|
|
label="Name Again",
|
|
field_type="text",
|
|
required=False,
|
|
display_order=2,
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
# --- API Integration Tests ---
|
|
|
|
@pytest.mark.asyncio
|
|
class TestProceduralFlowsAPI:
|
|
"""Integration tests for procedural flow CRUD via API."""
|
|
|
|
async def test_create_procedural_draft(self, client, auth_headers):
|
|
"""Create a procedural flow as draft."""
|
|
tree_data = {
|
|
"name": "DC Build Procedure",
|
|
"description": "Domain Controller setup procedure",
|
|
"tree_type": "procedural",
|
|
"status": "draft",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
"intake_form": make_valid_intake_form(),
|
|
}
|
|
response = await client.post(
|
|
"/api/v1/trees",
|
|
json=tree_data,
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200 or response.status_code == 201
|
|
data = response.json()
|
|
assert data["tree_type"] == "procedural"
|
|
assert data["intake_form"] is not None
|
|
assert len(data["intake_form"]) == 3
|
|
|
|
async def test_create_procedural_published(self, client, auth_headers):
|
|
"""Create a procedural flow and publish it (validation runs)."""
|
|
tree_data = {
|
|
"name": "M365 User Onboarding",
|
|
"tree_type": "procedural",
|
|
"status": "published",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
}
|
|
response = await client.post(
|
|
"/api/v1/trees",
|
|
json=tree_data,
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200 or response.status_code == 201
|
|
|
|
async def test_create_procedural_published_invalid_fails(self, client, auth_headers):
|
|
"""Publish should fail if procedural structure is invalid."""
|
|
tree_data = {
|
|
"name": "Bad Procedure",
|
|
"tree_type": "procedural",
|
|
"status": "published",
|
|
"tree_structure": {
|
|
"steps": [
|
|
{"id": "s1", "type": "procedure_step", "title": "Step 1"},
|
|
# No end step
|
|
]
|
|
},
|
|
}
|
|
response = await client.post(
|
|
"/api/v1/trees",
|
|
json=tree_data,
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
async def test_list_trees_filter_by_type(self, client, auth_headers):
|
|
"""Create both types and filter by tree_type."""
|
|
# Create troubleshooting
|
|
await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "Troubleshooting Tree",
|
|
"status": "draft",
|
|
"tree_structure": {"id": "root", "type": "decision", "question": "Test?"},
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
# Create procedural
|
|
await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "Procedure Flow",
|
|
"tree_type": "procedural",
|
|
"status": "draft",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
|
|
# Filter by procedural
|
|
response = await client.get(
|
|
"/api/v1/trees?tree_type=procedural",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert all(t["tree_type"] == "procedural" for t in data)
|
|
|
|
# Filter by troubleshooting
|
|
response = await client.get(
|
|
"/api/v1/trees?tree_type=troubleshooting",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert all(t["tree_type"] == "troubleshooting" for t in data)
|
|
|
|
async def test_update_procedural_tree(self, client, auth_headers):
|
|
"""Update a procedural tree's intake form."""
|
|
# Create
|
|
create_resp = await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "Procedure",
|
|
"tree_type": "procedural",
|
|
"status": "draft",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
tree_id = create_resp.json()["id"]
|
|
|
|
# Update with intake form
|
|
update_resp = await client.put(
|
|
f"/api/v1/trees/{tree_id}",
|
|
json={
|
|
"intake_form": make_valid_intake_form(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert update_resp.status_code == 200
|
|
data = update_resp.json()
|
|
assert data["intake_form"] is not None
|
|
|
|
async def test_start_session_procedural_with_variables(self, client, auth_headers):
|
|
"""Start a procedural session with intake form variables."""
|
|
# Create published procedural tree with intake form
|
|
create_resp = await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "DC Build",
|
|
"tree_type": "procedural",
|
|
"status": "published",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
"intake_form": make_valid_intake_form(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert create_resp.status_code in (200, 201)
|
|
tree_id = create_resp.json()["id"]
|
|
|
|
# Start session with variables
|
|
session_resp = await client.post(
|
|
"/api/v1/sessions",
|
|
json={
|
|
"tree_id": tree_id,
|
|
"session_variables": {
|
|
"server_name": "DC01",
|
|
"ip_address": "192.168.1.10",
|
|
},
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert session_resp.status_code in (200, 201)
|
|
session_data = session_resp.json()
|
|
assert session_data["session_variables"]["server_name"] == "DC01"
|
|
assert session_data["tree_snapshot"]["tree_type"] == "procedural"
|
|
|
|
async def test_start_session_procedural_deferred_variables(self, client, auth_headers):
|
|
"""Starting a procedural session without required intake fields should succeed (deferred variables)."""
|
|
# Create published procedural tree with required intake form
|
|
create_resp = await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "DC Build",
|
|
"tree_type": "procedural",
|
|
"status": "published",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
"intake_form": make_valid_intake_form(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
tree_id = create_resp.json()["id"]
|
|
|
|
# Start without required fields — should succeed (deferred variables)
|
|
session_resp = await client.post(
|
|
"/api/v1/sessions",
|
|
json={
|
|
"tree_id": tree_id,
|
|
# Missing server_name and ip_address — will be filled inline later
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert session_resp.status_code == 201
|
|
session_id = session_resp.json()["id"]
|
|
|
|
# Fill variables via PATCH endpoint
|
|
patch_resp = await client.patch(
|
|
f"/api/v1/sessions/{session_id}/variables",
|
|
json={"variables": {"server_name": "DC-01", "ip_address": "10.0.0.1"}},
|
|
headers=auth_headers,
|
|
)
|
|
assert patch_resp.status_code == 200
|
|
assert patch_resp.json()["session_variables"]["server_name"] == "DC-01"
|
|
assert patch_resp.json()["session_variables"]["ip_address"] == "10.0.0.1"
|
|
|
|
async def test_start_session_procedural_optional_fields_ok(self, client, auth_headers):
|
|
"""Starting a session with only required fields (optional missing) should work."""
|
|
create_resp = await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "DC Build",
|
|
"tree_type": "procedural",
|
|
"status": "published",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
"intake_form": make_valid_intake_form(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
tree_id = create_resp.json()["id"]
|
|
|
|
# Start with only required fields (notes is optional)
|
|
session_resp = await client.post(
|
|
"/api/v1/sessions",
|
|
json={
|
|
"tree_id": tree_id,
|
|
"session_variables": {
|
|
"server_name": "DC01",
|
|
"ip_address": "192.168.1.10",
|
|
# notes is optional, not provided
|
|
},
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert session_resp.status_code in (200, 201)
|
|
|
|
async def test_fork_preserves_tree_type_and_intake_form(self, client, auth_headers):
|
|
"""Forking a procedural tree should preserve tree_type and intake_form."""
|
|
# Create procedural tree
|
|
create_resp = await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "DC Build Original",
|
|
"tree_type": "procedural",
|
|
"status": "published",
|
|
"tree_structure": make_valid_procedural_steps(),
|
|
"intake_form": make_valid_intake_form(),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
tree_id = create_resp.json()["id"]
|
|
|
|
# Fork it
|
|
fork_resp = await client.post(
|
|
f"/api/v1/trees/{tree_id}/fork",
|
|
json={"fork_reason": "Customized for Client X"},
|
|
headers=auth_headers,
|
|
)
|
|
assert fork_resp.status_code in (200, 201)
|
|
fork_data = fork_resp.json()
|
|
assert fork_data["tree_type"] == "procedural"
|
|
assert fork_data["intake_form"] is not None
|
|
assert len(fork_data["intake_form"]) == 3
|
|
|
|
async def test_existing_trees_default_troubleshooting(self, client, auth_headers):
|
|
"""Trees created without tree_type should default to troubleshooting."""
|
|
response = await client.post(
|
|
"/api/v1/trees",
|
|
json={
|
|
"name": "Legacy Tree",
|
|
"status": "draft",
|
|
"tree_structure": {"id": "root", "type": "decision", "question": "Test?"},
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code in (200, 201)
|
|
data = response.json()
|
|
assert data["tree_type"] == "troubleshooting"
|
|
assert data.get("intake_form") is None
|