* feat: maintenance flow UX redesign — batch status hub, context strip, detail page upgrades (#85) - Add BatchStatusPage (/flows/:id/batches/:batchId): per-target Start/Resume/View cards, progress bar, 5s polling while in-progress, completion outcome summary - Add BatchStatusCard: handles not-started/in-progress/complete states with step progress for in-progress targets - Add ActiveBatchBanner: amber banner on detail page when a batch is running, links to BatchStatusPage - Add MaintenanceContextStrip: amber strip in ProceduralNavigationPage for maintenance flows showing target name, batch progress (X/Y complete), and Back to Batch nav - Update MaintenanceFlowDetailPage: active batch banner, clickable run history rows with mini progress dots and outcome summaries, Run button loading state, post-launch navigates to BatchStatusPage - Update ProceduralNavigationPage: renders MaintenanceContextStrip between top bar and content when tree_type === 'maintenance'; fetches batch progress once on mount - Add batch_id filter to GET /sessions backend endpoint and SessionListParams frontend type - Add /flows/:id/batches/:batchId route to router Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: session detail page — completion action + outcome summary card - In-progress sessions: amber banner with "Complete Session" button opens SessionOutcomeModal to set outcome/notes/next-steps and finalize - Completed sessions: colored outcome summary card (icon + outcome label + duration + notes + next steps) replaces dense header metadata; "Copy for Ticket" promoted to primary action inside the card - Export toolbar de-emphasized to secondary row of smaller controls below the summary card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add library-page action props to StepCard (edit/delete/save) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: pass library-page action props through StepLibraryBrowser + refreshKey Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Step Library page — create, edit, delete, save-to-library Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add RuntimeStep union type for procedural custom steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: custom step insertion in procedural flow sessions Engineers can add custom steps inline during execution. Steps are persisted to session.custom_steps and restored on resume. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: suppress StepFeedback on custom steps, fix resume stepState seeding, functional updater for step index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add tree forking UI design doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add tree fork UI implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkInfo type and fork fields to Tree/TreeListItem Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: align ForkInfo type with backend schema, remove redundant fork fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ForkInfo placement, required fork_info field, add JSDoc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkModal component with name and reason fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ForkModal accessibility and UX (escape, click-outside, labels, maxLength) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: open ForkModal on fork action in TreeLibraryPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ForkModal to MyTreesPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show Fork chip badge on forked tree cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add flow-to-library step sync design doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add flow-to-library sync implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add sync tracking columns to step_library Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add sync columns and source_tree relationship to StepLibrary model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add group_label to StepContent, is_flow_synced/source_tree_name to StepLibraryResponse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: include is_flow_synced and source_tree_name in step list/detail responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add is_flow_synced and source_tree_name to step list response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add selectinload and sync fields to search and get_step endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add step_sync module with extraction and upsert logic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: safe NOT IN placeholders for asyncpg, add deactivate docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: trigger step library sync on tree publish and deactivate on delete - Call sync_steps_from_tree in update_tree whenever the tree is published (status transitions to 'published' or is already published and structure changes) - Call deactivate_synced_steps_for_tree in delete_tree before db.commit() so the FK SET NULL does not nullify source_tree_id before the WHERE clause runs - Fix ::jsonb cast syntax in step_sync.py (asyncpg rejects :: operator in text() queries; replaced with CAST(:content AS jsonb)) - Add UniqueConstraint('source_tree_id','source_node_id') to StepLibrary model so Base.metadata.create_all (used by tests) creates the constraint that the ON CONFLICT clause in sync_steps_from_tree depends on Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add is_flow_synced and source_tree_name to Step types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show From Flow badge and lock icon on flow-synced StepCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show source flow name in StepDetailModal for synced steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add Library Visibility select to procedural StepEditor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address code review issues in flow-to-library sync - Fix sync trigger: only fire on publish transition, not every PUT - Add TestSyncOnPublish integration tests (2 tests, 16 total passing) - Add group_label to frontend StepContent interface - Guard Library Visibility select to procedure_step nodes only - Block API edits to flow-synced steps (400 read-only guard) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: handle None author_id in step sync to avoid invalid UUID error When a system/default tree has no author (author_id is None), str(None) produces the literal string 'None' which asyncpg rejects as an invalid UUID for the created_by column. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add ResolutionFlow service account to own default tree steps in library Default/system trees had no author_id (NULL), causing a NOT NULL violation when syncing steps to step_library.created_by on publish. - Add is_service_account flag to users table (migration 4f4137ce) - Add service_account.py: idempotent ensure_service_account() creates noreply@resolutionflow.com with unusable password on startup - Cache service account ID on app.state at lifespan startup - Add get_service_account_id() FastAPI dep (returns None in tests) - sync_steps_from_tree: resolve author_id or service_account_id as created_by - create_tree: set author_id=service_account_id for is_default trees - Migration 1490781700bc: backfill author_id on 31 existing default trees Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
217 lines
9.2 KiB
Python
217 lines
9.2 KiB
Python
"""Tests for flow-to-library step sync."""
|
|
import pytest
|
|
from uuid import uuid4
|
|
from app.core.step_sync import extract_steps_for_sync, resolve_step_visibility
|
|
|
|
|
|
class TestResolveStepVisibility:
|
|
"""Test visibility resolution logic."""
|
|
|
|
def test_public_flow_gives_public_steps(self):
|
|
result = resolve_step_visibility(is_public=True, account_id=None, node_override=None)
|
|
assert result == 'public'
|
|
|
|
def test_team_flow_gives_team_steps(self):
|
|
result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override=None)
|
|
assert result == 'team'
|
|
|
|
def test_private_flow_gives_team_steps(self):
|
|
result = resolve_step_visibility(is_public=False, account_id=None, node_override=None)
|
|
assert result == 'team'
|
|
|
|
def test_node_override_takes_precedence(self):
|
|
result = resolve_step_visibility(is_public=True, account_id=None, node_override='team')
|
|
assert result == 'team'
|
|
|
|
def test_public_override_on_team_flow(self):
|
|
result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override='public')
|
|
assert result == 'public'
|
|
|
|
|
|
class TestExtractStepsForSync:
|
|
"""Test step extraction from tree structures."""
|
|
|
|
def test_extracts_procedure_steps_from_procedural_flow(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "step_1", "type": "procedure_step", "title": "Verify prerequisites",
|
|
"description": "Check all prereqs", "content_type": "action"},
|
|
{"id": "end_1", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
assert len(results) == 1
|
|
assert results[0]['source_node_id'] == 'step_1'
|
|
assert results[0]['title'] == 'Verify prerequisites'
|
|
assert results[0]['step_type'] == 'action'
|
|
assert results[0]['content']['instructions'] == 'Check all prereqs'
|
|
|
|
def test_skips_section_header_nodes(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "sec_1", "type": "section_header", "title": "Phase 1"},
|
|
{"id": "step_1", "type": "procedure_step", "title": "First step",
|
|
"description": "Do this"},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
assert len(results) == 1
|
|
assert results[0]['source_node_id'] == 'step_1'
|
|
|
|
def test_captures_section_header_as_group_label(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "sec_1", "type": "section_header", "title": "Cable Checks"},
|
|
{"id": "step_1", "type": "procedure_step", "title": "Check cable",
|
|
"description": "Verify cable is seated"},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
assert results[0]['content']['group_label'] == 'Cable Checks'
|
|
|
|
def test_normalizes_string_commands(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "step_1", "type": "procedure_step", "title": "Run command",
|
|
"description": "Execute this", "commands": "ping 8.8.8.8"},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
assert results[0]['content']['commands'] == [{"label": "", "command": "ping 8.8.8.8", "command_type": None}]
|
|
|
|
def test_normalizes_commandblock_commands(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "step_1", "type": "procedure_step", "title": "Run PS",
|
|
"description": "Run powershell",
|
|
"commands": [{"code": "Get-Service", "language": "powershell", "label": "Check services"}]},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
cmds = results[0]['content']['commands']
|
|
assert len(cmds) == 1
|
|
assert cmds[0]['command'] == 'Get-Service'
|
|
assert cmds[0]['command_type'] == 'powershell'
|
|
assert cmds[0]['label'] == 'Check services'
|
|
|
|
def test_extracts_action_and_solution_from_troubleshooting(self):
|
|
tree_structure = {
|
|
"id": "root",
|
|
"type": "decision",
|
|
"question": "What is wrong?",
|
|
"options": [{"id": "o1", "label": "Thing A", "next_node_id": "act_1"}],
|
|
"children": [
|
|
{"id": "act_1", "type": "action", "title": "Fix thing A",
|
|
"description": "Do the fix", "next_node_id": "sol_1",
|
|
"children": [{"id": "sol_1", "type": "solution", "title": "All fixed",
|
|
"description": "Problem resolved"}]},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='troubleshooting'))
|
|
node_ids = {r['source_node_id'] for r in results}
|
|
assert 'act_1' in node_ids
|
|
assert 'sol_1' in node_ids
|
|
types = {r['source_node_id']: r['step_type'] for r in results}
|
|
assert types['act_1'] == 'action'
|
|
assert types['sol_1'] == 'solution'
|
|
|
|
def test_uses_title_as_instructions_fallback(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "step_1", "type": "procedure_step", "title": "Do the thing"},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
assert results[0]['content']['instructions'] == 'Do the thing'
|
|
|
|
def test_empty_steps_list(self):
|
|
tree_structure = {"steps": []}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
|
|
assert results == []
|
|
|
|
def test_maintenance_treated_same_as_procedural(self):
|
|
tree_structure = {
|
|
"steps": [
|
|
{"id": "step_1", "type": "procedure_step", "title": "Maintenance step",
|
|
"description": "Do maintenance"},
|
|
]
|
|
}
|
|
results = list(extract_steps_for_sync(tree_structure, tree_type='maintenance'))
|
|
assert len(results) == 1
|
|
|
|
|
|
class TestSyncOnPublish:
|
|
"""Integration tests — sync triggered by publishing a tree."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publishing_procedural_tree_creates_library_steps(
|
|
self, client, auth_headers
|
|
):
|
|
# Create a procedural tree with two steps
|
|
tree_resp = await client.post("/api/v1/trees", json={
|
|
"name": "Test Procedure",
|
|
"tree_type": "procedural",
|
|
"status": "draft",
|
|
"tree_structure": {
|
|
"steps": [
|
|
{"id": "step_1", "type": "procedure_step",
|
|
"title": "First step", "description": "Do this first"},
|
|
{"id": "step_2", "type": "procedure_step",
|
|
"title": "Second step", "description": "Do this second"},
|
|
{"id": "end_1", "type": "procedure_end", "title": "Done"},
|
|
]
|
|
}
|
|
}, headers=auth_headers)
|
|
assert tree_resp.status_code == 201
|
|
tree_id = tree_resp.json()["id"]
|
|
|
|
# Publish the tree
|
|
pub_resp = await client.put(f"/api/v1/trees/{tree_id}", json={"status": "published"}, headers=auth_headers)
|
|
assert pub_resp.status_code == 200
|
|
|
|
# Check library has synced entries
|
|
lib_resp = await client.get("/api/v1/steps", headers=auth_headers)
|
|
assert lib_resp.status_code == 200
|
|
steps = lib_resp.json()
|
|
synced = [s for s in steps if s.get("is_flow_synced")]
|
|
assert len(synced) == 2
|
|
titles = {s["title"] for s in synced}
|
|
assert "First step" in titles
|
|
assert "Second step" in titles
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_republishing_updates_existing_library_steps(
|
|
self, client, auth_headers
|
|
):
|
|
# Create a draft tree first, then publish
|
|
tree_resp = await client.post("/api/v1/trees", json={
|
|
"name": "Update Test",
|
|
"tree_type": "procedural",
|
|
"status": "draft",
|
|
"tree_structure": {"steps": [
|
|
{"id": "step_1", "type": "procedure_step",
|
|
"title": "Original title", "description": "Original desc"},
|
|
{"id": "end_1", "type": "procedure_end", "title": "Done"},
|
|
]}
|
|
}, headers=auth_headers)
|
|
tree_id = tree_resp.json()["id"]
|
|
first_pub = await client.put(f"/api/v1/trees/{tree_id}", json={"status": "published"}, headers=auth_headers)
|
|
assert first_pub.status_code == 200
|
|
|
|
# Republish with updated step title
|
|
second_pub = await client.put(f"/api/v1/trees/{tree_id}", json={
|
|
"tree_structure": {"steps": [
|
|
{"id": "step_1", "type": "procedure_step",
|
|
"title": "Updated title", "description": "Updated desc"},
|
|
{"id": "end_1", "type": "procedure_end", "title": "Done"},
|
|
]},
|
|
"status": "published"
|
|
}, headers=auth_headers)
|
|
assert second_pub.status_code == 200
|
|
|
|
# Check library shows updated title (not a duplicate)
|
|
lib_resp = await client.get("/api/v1/steps", headers=auth_headers)
|
|
synced = [s for s in lib_resp.json() if s.get("is_flow_synced")]
|
|
assert len(synced) == 1
|
|
assert synced[0]["title"] == "Updated title"
|