From b97596d286d8e4fa1ad24714bb255c9ca46d5756 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 8 Feb 2026 08:14:22 -0500 Subject: [PATCH 1/2] refactor: tech debt reduction - extract hooks, deduplicate helpers, update deps, add CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract useCustomStepFlow hook from TreeNavigationPage (1040 → 759 lines) - Create core/filters.py with shared tree/step visibility filters - Create services/export_service.py from session export logic - Add GitHub Actions CI/CD pipeline (pytest + lint + build) - Add GIN index migration for full-text search on trees - Update FastAPI 0.128.5, Pydantic 2.12.5, SQLAlchemy 2.0.46, +5 more - Fix regex → pattern deprecation in Query() params Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 73 +++ CLAUDE.md | 7 + .../versions/027_add_trees_fts_index.py | 32 ++ backend/app/api/endpoints/sessions.py | 160 +------ backend/app/api/endpoints/steps.py | 29 +- backend/app/api/endpoints/trees.py | 25 +- backend/app/core/filters.py | 60 +++ backend/app/services/__init__.py | 0 backend/app/services/export_service.py | 162 +++++++ backend/requirements.txt | 16 +- frontend/src/hooks/useCustomStepFlow.ts | 362 +++++++++++++++ frontend/src/pages/TreeNavigationPage.tsx | 417 +++--------------- 12 files changed, 786 insertions(+), 557 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/alembic/versions/027_add_trees_fts_index.py create mode 100644 backend/app/core/filters.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/export_service.py create mode 100644 frontend/src/hooks/useCustomStepFlow.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..980d2ae6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: patherly_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test + DATABASE_URL_SYNC: postgresql://postgres:postgres@localhost:5432/patherly_test + SECRET_KEY: ci-test-secret-key-not-for-production + DEBUG: "true" + APP_NAME: ResolutionFlow + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + backend/requirements.txt + backend/requirements-dev.txt + + - name: Install dependencies + run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt + + - name: Run tests + run: cd backend && python -m pytest --override-ini="addopts=" + + frontend: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: cd frontend && npm ci + + - name: Lint + run: cd frontend && npm run lint + + - name: Build + run: cd frontend && npm run build diff --git a/CLAUDE.md b/CLAUDE.md index 0ae5ac94..4046edbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,13 @@ When adding new frontend pages or components, use "ResolutionFlow" for any user- - **Global Thin Scrollbar Styling:** - 6px thin scrollbars site-wide (Firefox `scrollbar-width: thin` + WebKit pseudo-elements) - Theme-aware colors using CSS variables (`--border`, `--muted-foreground`) +- **Admin Panel (Feb 2026):** + - Full admin panel at `/admin/*` with 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories) + - Super admin access: requires `is_super_admin=true` on User model + - Admin API endpoints: `/api/v1/admin/*` (all require `require_admin` dependency) + - Utility script: `backend/make_superadmin_simple.py list|` - promote users to super admin + - ActionMenu component: uses React Portal to avoid overflow clipping in tables + - **API Path Gotcha:** Frontend `apiClient` baseURL is `http://localhost:8000/api/v1` — all API calls should use relative paths WITHOUT `/api/v1/` prefix (e.g., `/admin/users` not `/api/v1/admin/users`) ### What's In Progress diff --git a/backend/alembic/versions/027_add_trees_fts_index.py b/backend/alembic/versions/027_add_trees_fts_index.py new file mode 100644 index 00000000..af552fd1 --- /dev/null +++ b/backend/alembic/versions/027_add_trees_fts_index.py @@ -0,0 +1,32 @@ +"""add full-text search GIN index on trees + +Revision ID: 027 +Revises: 026 +Create Date: 2026-02-08 + +Adds a GIN index for full-text search on the trees table, +indexing the name and description columns using English text search. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '027' +down_revision: Union[str, None] = '026' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "CREATE INDEX idx_trees_fts ON trees USING GIN (" + "to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, ''))" + ")" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_trees_fts") diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 10d47d96..aa770398 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -1,4 +1,3 @@ -import html from datetime import datetime, timezone from typing import Annotated, Optional from uuid import UUID @@ -14,6 +13,7 @@ from app.models.user import User from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse from app.api.deps import get_current_active_user from app.core.permissions import can_access_tree +from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export router = APIRouter(prefix="/sessions", tags=["sessions"]) @@ -283,13 +283,13 @@ async def export_session( # Generate export based on format if export_options.format == "markdown": - content = _generate_markdown_export(session, export_options) + content = generate_markdown_export(session, export_options) media_type = "text/markdown" elif export_options.format == "html": - content = _generate_html_export(session, export_options) + content = generate_html_export(session, export_options) media_type = "text/html" else: # text - content = _generate_text_export(session, export_options) + content = generate_text_export(session, export_options) media_type = "text/plain" # Mark as exported @@ -299,158 +299,6 @@ async def export_session( return PlainTextResponse(content=content, media_type=media_type) -def _generate_markdown_export(session: Session, options: SessionExport) -> str: - """Generate markdown export.""" - lines = [] - - if options.include_tree_info: - tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") - lines.append(f"# {tree_name}") - lines.append("") - if session.ticket_number: - lines.append(f"**Ticket:** {session.ticket_number}") - if session.client_name: - lines.append(f"**Client:** {session.client_name}") - if options.include_timestamps: - lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}") - if session.completed_at: - lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}") - lines.append("") - lines.append("---") - lines.append("") - - # Scratchpad / Evidence section - scratchpad = getattr(session, 'scratchpad', '') or '' - if scratchpad.strip(): - lines.append("## Evidence / Reference") - lines.append("") - lines.append(scratchpad) - lines.append("") - lines.append("---") - lines.append("") - - lines.append("## Troubleshooting Steps") - lines.append("") - - for i, decision in enumerate(session.decisions, 1): - question = decision.get("question") or decision.get("action_performed", "Step") - answer = decision.get("answer", "") - notes = decision.get("notes", "") - - lines.append(f"### Step {i}: {question}") - if answer: - lines.append(f"**Answer:** {answer}") - if notes: - lines.append(f"**Notes:** {notes}") - if options.include_timestamps and decision.get("timestamp"): - lines.append(f"*{decision['timestamp']}*") - lines.append("") - - return "\n".join(lines) - - -def _generate_text_export(session: Session, options: SessionExport) -> str: - """Generate plain text export.""" - lines = [] - - if options.include_tree_info: - tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") - lines.append(tree_name) - lines.append("=" * len(tree_name)) - if session.ticket_number: - lines.append(f"Ticket: {session.ticket_number}") - if session.client_name: - lines.append(f"Client: {session.client_name}") - if options.include_timestamps: - lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}") - if session.completed_at: - lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}") - lines.append("") - - # Scratchpad / Evidence section - scratchpad = getattr(session, 'scratchpad', '') or '' - if scratchpad.strip(): - lines.append("EVIDENCE / REFERENCE") - lines.append("-" * 20) - lines.append(scratchpad) - lines.append("") - - lines.append("TROUBLESHOOTING STEPS") - lines.append("-" * 20) - - for i, decision in enumerate(session.decisions, 1): - question = decision.get("question") or decision.get("action_performed", "Step") - answer = decision.get("answer", "") - notes = decision.get("notes", "") - - lines.append(f"\n{i}. {question}") - if answer: - lines.append(f" Answer: {answer}") - if notes: - lines.append(f" Notes: {notes}") - - return "\n".join(lines) - - -def _generate_html_export(session: Session, options: SessionExport) -> str: - """Generate HTML export.""" - tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) - - html_parts = ['', '', '', - '', - f'{tree_name}', - '', - '', ''] - - if options.include_tree_info: - html_parts.append(f'

{tree_name}

') - html_parts.append('
') - if session.ticket_number: - html_parts.append(f'

Ticket: {html.escape(session.ticket_number)}

') - if session.client_name: - html_parts.append(f'

Client: {html.escape(session.client_name)}

') - if options.include_timestamps: - html_parts.append(f'

Started: {session.started_at.strftime("%Y-%m-%d %H:%M")}

') - if session.completed_at: - html_parts.append(f'

Completed: {session.completed_at.strftime("%Y-%m-%d %H:%M")}

') - html_parts.append('
') - - # Scratchpad / Evidence section - scratchpad = getattr(session, 'scratchpad', '') or '' - if scratchpad.strip(): - html_parts.append('

Evidence / Reference

') - html_parts.append(f'
{html.escape(scratchpad)}
') - - html_parts.append('

Troubleshooting Steps

') - - for i, decision in enumerate(session.decisions, 1): - question = html.escape(decision.get("question") or decision.get("action_performed", "Step")) - answer = html.escape(decision.get("answer", "")) - notes = html.escape(decision.get("notes", "")) - - html_parts.append('
') - html_parts.append(f'

Step {i}: {question}

') - if answer: - html_parts.append(f'

Answer: {answer}

') - if notes: - html_parts.append(f'

Notes: {notes}

') - if options.include_timestamps and decision.get("timestamp"): - html_parts.append(f'

{html.escape(str(decision["timestamp"]))}

') - html_parts.append('
') - - html_parts.extend(['', '']) - return "\n".join(html_parts) - - # --- Save Session as Tree --- diff --git a/backend/app/api/endpoints/steps.py b/backend/app/api/endpoints/steps.py index d1e5a160..2e5d1c61 100644 --- a/backend/app/api/endpoints/steps.py +++ b/backend/app/api/endpoints/steps.py @@ -3,12 +3,13 @@ from typing import Optional from datetime import datetime, timezone from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select, or_, and_, func, desc, Integer, case +from sqlalchemy import select, func, desc, Integer, case from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.api.deps import get_current_active_user, require_engineer_or_admin from app.core.permissions import can_view_step, can_edit_step +from app.core.filters import build_step_visibility_filter from app.models.user import User from app.models.step_library import StepLibrary, StepRating from app.models.step_category import StepCategory @@ -53,29 +54,15 @@ async def get_step_or_404( return step -def build_visibility_filter(user: User): - """Build SQLAlchemy filter for step visibility based on user.""" - if user.account_id: - return or_( - StepLibrary.visibility == 'public', - and_(StepLibrary.visibility == 'team', StepLibrary.account_id == user.account_id), - StepLibrary.created_by == user.id # Own private steps - ) - else: - return or_( - StepLibrary.visibility == 'public', - StepLibrary.created_by == user.id - ) - @router.get("", response_model=list[StepLibraryListResponse]) async def list_steps( - visibility: Optional[str] = Query(None, regex="^(private|team|public)$"), + visibility: Optional[str] = Query(None, pattern="^(private|team|public)$"), category_id: Optional[UUID] = None, tags: Optional[list[str]] = Query(None), min_rating: Optional[float] = Query(None, ge=0, le=5), - step_type: Optional[str] = Query(None, regex="^(decision|action|solution)$"), - sort_by: str = Query("recent", regex="^(recent|popular|highest_rated|most_used)$"), + step_type: Optional[str] = Query(None, pattern="^(decision|action|solution)$"), + sort_by: str = Query("recent", pattern="^(recent|popular|highest_rated|most_used)$"), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), @@ -84,7 +71,7 @@ async def list_steps( """List steps with filters and pagination.""" query = select(StepLibrary).where( StepLibrary.is_active == True, - build_visibility_filter(current_user) + build_step_visibility_filter(current_user) ) # Apply filters @@ -165,7 +152,7 @@ async def search_steps( query = select(StepLibrary).where( StepLibrary.is_active == True, - build_visibility_filter(current_user), + build_step_visibility_filter(current_user), func.to_tsvector('english', StepLibrary.title).match(search_query) ).order_by(desc(StepLibrary.rating_average)).limit(limit) @@ -218,7 +205,7 @@ async def get_popular_tags( func.count().label('count') ).where( StepLibrary.is_active == True, - build_visibility_filter(current_user) + build_step_visibility_filter(current_user) ).group_by( func.unnest(StepLibrary.tags) ).order_by( diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 2d2d8d66..2313ed80 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -4,7 +4,7 @@ from uuid import UUID import secrets from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_, true as sa_true, update +from sqlalchemy import select, func, or_, update from sqlalchemy.orm import selectinload from app.core.database import get_db @@ -21,6 +21,7 @@ from app.schemas.tree import ( ) from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin from app.core.permissions import can_edit_tree, can_access_tree +from app.core.filters import build_tree_access_filter from app.core.subscriptions import check_tree_limit from app.core.audit import log_audit from app.core.config import settings @@ -29,28 +30,6 @@ from app.core.tree_validation import can_publish_tree router = APIRouter(prefix="/trees", tags=["trees"]) -def build_tree_access_filter(current_user: User): - """Build the access filter for trees based on user permissions. - - Returns trees that are: - - All trees (for super admins) - - Default/system trees (visible to all) - - Public trees - - User's own trees - - Trees from user's team - """ - if current_user.is_super_admin: - return sa_true() - conditions = [ - Tree.is_default == True, - Tree.is_public == True, - Tree.author_id == current_user.id, - ] - if current_user.account_id: - conditions.append(Tree.account_id == current_user.account_id) - return or_(*conditions) - - def build_tree_response(tree: Tree) -> TreeListResponse: """Build TreeListResponse with category_info and tags.""" category_info = None diff --git a/backend/app/core/filters.py b/backend/app/core/filters.py new file mode 100644 index 00000000..6e9b587d --- /dev/null +++ b/backend/app/core/filters.py @@ -0,0 +1,60 @@ +""" +Centralized query filters for ResolutionFlow. + +Provides reusable SQLAlchemy filter builders for tree access control +and step visibility, used across multiple endpoint modules. +""" +from __future__ import annotations +from typing import TYPE_CHECKING + +from sqlalchemy import or_, and_, true as sa_true + +if TYPE_CHECKING: + from app.models.user import User + + +def build_tree_access_filter(current_user: User): + """Build the access filter for trees based on user permissions. + + Returns trees that are: + - All trees (for super admins) + - Default/system trees (visible to all) + - Public trees + - User's own trees + - Trees from user's account + """ + from app.models.tree import Tree + + if current_user.is_super_admin: + return sa_true() + conditions = [ + Tree.is_default == True, + Tree.is_public == True, + Tree.author_id == current_user.id, + ] + if current_user.account_id: + conditions.append(Tree.account_id == current_user.account_id) + return or_(*conditions) + + +def build_step_visibility_filter(current_user: User): + """Build SQLAlchemy filter for step visibility based on user. + + Returns steps that are: + - Public steps (visible to all) + - Team steps (visible to same account members) + - User's own private steps + """ + from app.models.step_library import StepLibrary + + if current_user.account_id: + return or_( + StepLibrary.visibility == 'public', + and_(StepLibrary.visibility == 'team', StepLibrary.account_id == current_user.account_id), + StepLibrary.created_by == current_user.id # Own private steps + ) + else: + return or_( + StepLibrary.visibility == 'public', + StepLibrary.created_by == current_user.id + ) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py new file mode 100644 index 00000000..4bd4b755 --- /dev/null +++ b/backend/app/services/export_service.py @@ -0,0 +1,162 @@ +""" +Session export generators for ResolutionFlow. + +Provides markdown, plain text, and HTML export formatters +for troubleshooting sessions. +""" +import html + +from app.models.session import Session +from app.schemas.session import SessionExport + + +def generate_markdown_export(session: Session, options: SessionExport) -> str: + """Generate markdown export.""" + lines = [] + + if options.include_tree_info: + tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") + lines.append(f"# {tree_name}") + lines.append("") + if session.ticket_number: + lines.append(f"**Ticket:** {session.ticket_number}") + if session.client_name: + lines.append(f"**Client:** {session.client_name}") + if options.include_timestamps: + lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}") + if session.completed_at: + lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}") + lines.append("") + lines.append("---") + lines.append("") + + # Scratchpad / Evidence section + scratchpad = getattr(session, 'scratchpad', '') or '' + if scratchpad.strip(): + lines.append("## Evidence / Reference") + lines.append("") + lines.append(scratchpad) + lines.append("") + lines.append("---") + lines.append("") + + lines.append("## Troubleshooting Steps") + lines.append("") + + for i, decision in enumerate(session.decisions, 1): + question = decision.get("question") or decision.get("action_performed", "Step") + answer = decision.get("answer", "") + notes = decision.get("notes", "") + + lines.append(f"### Step {i}: {question}") + if answer: + lines.append(f"**Answer:** {answer}") + if notes: + lines.append(f"**Notes:** {notes}") + if options.include_timestamps and decision.get("timestamp"): + lines.append(f"*{decision['timestamp']}*") + lines.append("") + + return "\n".join(lines) + + +def generate_text_export(session: Session, options: SessionExport) -> str: + """Generate plain text export.""" + lines = [] + + if options.include_tree_info: + tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") + lines.append(tree_name) + lines.append("=" * len(tree_name)) + if session.ticket_number: + lines.append(f"Ticket: {session.ticket_number}") + if session.client_name: + lines.append(f"Client: {session.client_name}") + if options.include_timestamps: + lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}") + if session.completed_at: + lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}") + lines.append("") + + # Scratchpad / Evidence section + scratchpad = getattr(session, 'scratchpad', '') or '' + if scratchpad.strip(): + lines.append("EVIDENCE / REFERENCE") + lines.append("-" * 20) + lines.append(scratchpad) + lines.append("") + + lines.append("TROUBLESHOOTING STEPS") + lines.append("-" * 20) + + for i, decision in enumerate(session.decisions, 1): + question = decision.get("question") or decision.get("action_performed", "Step") + answer = decision.get("answer", "") + notes = decision.get("notes", "") + + lines.append(f"\n{i}. {question}") + if answer: + lines.append(f" Answer: {answer}") + if notes: + lines.append(f" Notes: {notes}") + + return "\n".join(lines) + + +def generate_html_export(session: Session, options: SessionExport) -> str: + """Generate HTML export.""" + tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) + + html_parts = ['', '', '', + '', + f'{tree_name}', + '', + '', ''] + + if options.include_tree_info: + html_parts.append(f'

{tree_name}

') + html_parts.append('
') + if session.ticket_number: + html_parts.append(f'

Ticket: {html.escape(session.ticket_number)}

') + if session.client_name: + html_parts.append(f'

Client: {html.escape(session.client_name)}

') + if options.include_timestamps: + html_parts.append(f'

Started: {session.started_at.strftime("%Y-%m-%d %H:%M")}

') + if session.completed_at: + html_parts.append(f'

Completed: {session.completed_at.strftime("%Y-%m-%d %H:%M")}

') + html_parts.append('
') + + # Scratchpad / Evidence section + scratchpad = getattr(session, 'scratchpad', '') or '' + if scratchpad.strip(): + html_parts.append('

Evidence / Reference

') + html_parts.append(f'
{html.escape(scratchpad)}
') + + html_parts.append('

Troubleshooting Steps

') + + for i, decision in enumerate(session.decisions, 1): + question = html.escape(decision.get("question") or decision.get("action_performed", "Step")) + answer = html.escape(decision.get("answer", "")) + notes = html.escape(decision.get("notes", "")) + + html_parts.append('
') + html_parts.append(f'

Step {i}: {question}

') + if answer: + html_parts.append(f'

Answer: {answer}

') + if notes: + html_parts.append(f'

Notes: {notes}

') + if options.include_timestamps and decision.get("timestamp"): + html_parts.append(f'

{html.escape(str(decision["timestamp"]))}

') + html_parts.append('
') + + html_parts.extend(['', '']) + return "\n".join(html_parts) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5bfbf210..35081e29 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,22 +1,22 @@ # FastAPI and server -fastapi==0.109.2 -uvicorn[standard]==0.27.1 +fastapi==0.128.5 +uvicorn[standard]==0.40.0 # Database -sqlalchemy==2.0.25 -asyncpg==0.29.0 +sqlalchemy==2.0.46 +asyncpg==0.31.0 psycopg2-binary==2.9.9 -alembic==1.13.1 +alembic==1.18.3 # Authentication python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 bcrypt==4.1.2 -python-multipart==0.0.9 +python-multipart==0.0.22 # Validation and settings -pydantic==2.6.1 -pydantic-settings==2.1.0 +pydantic==2.12.5 +pydantic-settings==2.12.0 email-validator==2.1.0 # Rate Limiting diff --git a/frontend/src/hooks/useCustomStepFlow.ts b/frontend/src/hooks/useCustomStepFlow.ts new file mode 100644 index 00000000..5c8e0906 --- /dev/null +++ b/frontend/src/hooks/useCustomStepFlow.ts @@ -0,0 +1,362 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { sessionsApi, stepsApi } from '@/api' +import { treesApi } from '@/api' +import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types' +import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' +import type { DescendantNode } from '@/components/session' + +interface UseCustomStepFlowParams { + tree: Tree | null + session: Session | null + currentNodeId: string + pathTaken: string[] + decisions: DecisionRecord[] + notes: string + findNode: (nodeId: string, structure?: TreeStructure) => TreeStructure | null + setCurrentNodeId: (id: string) => void + setPathTaken: (path: string[]) => void + setDecisions: (decisions: DecisionRecord[]) => void + setNotes: (notes: string) => void + setIsCompleting: (completing: boolean) => void + setError: (error: string | null) => void + isCompleting: boolean +} + +export function useCustomStepFlow({ + tree, + session, + currentNodeId, + pathTaken, + decisions, + notes, + findNode, + setCurrentNodeId, + setPathTaken, + setDecisions, + setNotes, + setIsCompleting, + setError, + isCompleting, +}: UseCustomStepFlowParams) { + const navigate = useNavigate() + + // Custom steps + const [customSteps, setCustomSteps] = useState([]) + const [showCustomStepModal, setShowCustomStepModal] = useState(false) + + // Post-step action flow + const [showPostStepModal, setShowPostStepModal] = useState(false) + const [pendingStep, setPendingStep] = useState(null) + const [pendingStepIsFromLibrary, setPendingStepIsFromLibrary] = useState(false) + const [isSavingStep, setIsSavingStep] = useState(false) + + // Continuation flow + const [showContinuationModal, setShowContinuationModal] = useState(false) + const [branchOriginNodeId, setBranchOriginNodeId] = useState(null) + const [pendingContinuationNodeId, setPendingContinuationNodeId] = useState(null) + + // Custom branch mode + const [customBranchMode, setCustomBranchMode] = useState(false) + + // Fork flow + const [showForkModal, setShowForkModal] = useState(false) + + const findCustomStep = (nodeId: string): CustomStep | null => { + return customSteps.find(cs => cs.id === nodeId) || null + } + + // Get descendant nodes two levels deep (grandchildren) from a decision node's options. + const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => { + const decisionNode = findNode(decisionNodeId, tree?.tree_structure) + if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) { + return [] + } + + const descendants: DescendantNode[] = [] + + for (const option of decisionNode.options) { + if (!option.next_node_id) continue + const childNode = findNode(option.next_node_id, tree?.tree_structure) + if (!childNode) continue + + if (childNode.type === 'decision' && childNode.options) { + for (const childOption of childNode.options) { + if (!childOption.next_node_id) continue + const grandchild = findNode(childOption.next_node_id, tree?.tree_structure) + if (!grandchild) continue + + descendants.push({ + id: grandchild.id, + label: grandchild.question || grandchild.title || 'Untitled', + type: grandchild.type, + parentOptionLabel: `${option.label} \u2192 ${childOption.label}` + }) + } + } else if (childNode.type === 'action' && childNode.next_node_id) { + const grandchild = findNode(childNode.next_node_id, tree?.tree_structure) + if (grandchild) { + descendants.push({ + id: grandchild.id, + label: grandchild.question || grandchild.title || 'Untitled', + type: grandchild.type, + parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}` + }) + } + } + } + + return descendants + } + + // Navigate back to a previously-created custom step from the decision node + const handleNavigateToCustomStep = (customStep: CustomStep) => { + const newPath = [...pathTaken, customStep.id] + setPathTaken(newPath) + setCurrentNodeId(customStep.id) + } + + // Called when CustomStepModal submits - show action modal instead of inserting directly + const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => { + setPendingStep(step) + setPendingStepIsFromLibrary(isFromLibrary) + setShowCustomStepModal(false) + setShowPostStepModal(true) + } + + const resetPendingStep = () => { + setShowPostStepModal(false) + setPendingStep(null) + setPendingStepIsFromLibrary(false) + setIsSavingStep(false) + } + + // Save to library only, don't insert into session + const handleSaveForLater = async () => { + if (!pendingStep) return + + setIsSavingStep(true) + try { + if (!pendingStepIsFromLibrary) { + await stepsApi.create({ + title: pendingStep.title, + step_type: pendingStep.step_type, + content: pendingStep.content, + visibility: 'private', + tags: pendingStep.tags || [] + }) + } + resetPendingStep() + } catch (err) { + console.error('Failed to save step to library:', err) + setIsSavingStep(false) + } + } + + // Insert into session and show continuation options + const handleUseNow = async () => { + if (!pendingStep || !session) return + + setIsSavingStep(true) + try { + setBranchOriginNodeId(currentNodeId) + + const customStep: CustomStep = { + id: crypto.randomUUID(), + inserted_after_node_id: currentNodeId, + step_data: pendingStep, + timestamp: new Date().toISOString() + } + + const newDecision: DecisionRecord = { + node_id: customStep.id, + question: null, + answer: null, + action_performed: `Custom Step: ${pendingStep.title}`, + notes: pendingStep.content.instructions || null, + automation_used: false, + timestamp: new Date().toISOString(), + attachments: [] + } + + const newCustomSteps = [...customSteps, customStep] + const newDecisions = [...decisions, newDecision] + const newPath = [...pathTaken, customStep.id] + + setCustomSteps(newCustomSteps) + setDecisions(newDecisions) + setPathTaken(newPath) + setCurrentNodeId(customStep.id) + + await sessionsApi.update(session.id, { + path_taken: newPath, + decisions: newDecisions, + custom_steps: newCustomSteps + }) + + resetPendingStep() + setShowContinuationModal(true) + } catch (err) { + console.error('Failed to insert custom step:', err) + setIsSavingStep(false) + } + } + + // Save to library AND insert into session + const handleBoth = async () => { + if (!pendingStep) return + + setIsSavingStep(true) + try { + if (!pendingStepIsFromLibrary) { + await stepsApi.create({ + title: pendingStep.title, + step_type: pendingStep.step_type, + content: pendingStep.content, + visibility: 'private', + tags: pendingStep.tags || [] + }) + } + await handleUseNow() + } catch (err) { + console.error('Failed to save and insert step:', err) + setIsSavingStep(false) + } + } + + // Handle selecting a descendant node from continuation modal + const handleSelectDescendant = (nodeId: string) => { + setShowContinuationModal(false) + setBranchOriginNodeId(null) + setPendingContinuationNodeId(nodeId) + } + + // Navigate to the previously-selected descendant node + const handleContinueToDescendant = async () => { + if (!pendingContinuationNodeId || !session) return + + const newPath = [...pathTaken, pendingContinuationNodeId] + setPathTaken(newPath) + setCurrentNodeId(pendingContinuationNodeId) + setNotes('') + setPendingContinuationNodeId(null) + + try { + await sessionsApi.update(session.id, { path_taken: newPath }) + } catch (err) { + console.error('Failed to update session path:', err) + } + } + + // Enter custom branch building mode + const handleBuildCustomBranch = () => { + setShowContinuationModal(false) + setCustomBranchMode(true) + } + + // Complete session from custom branch mode + const handleCustomBranchComplete = async () => { + if (!session) return + + setIsCompleting(true) + setError(null) + + try { + const completionDecision: DecisionRecord = { + node_id: currentNodeId, + question: null, + answer: null, + action_performed: 'Custom Branch Completed', + notes: notes || 'Issue resolved via custom troubleshooting steps', + automation_used: false, + timestamp: new Date().toISOString(), + attachments: [] + } + + await sessionsApi.update(session.id, { + decisions: [...decisions, completionDecision] + }) + + await sessionsApi.complete(session.id) + + if (customSteps.length > 0) { + setShowForkModal(true) + } else { + navigate(`/sessions/${session.id}`) + } + } catch (err) { + console.error('Failed to complete session:', err) + setError('Failed to complete session. Please try again.') + } finally { + setIsCompleting(false) + } + } + + // Fork tree with custom branch + const handleForkTree = async (name: string, description: string) => { + if (!tree) return + + try { + const forkedTree = await treesApi.create({ + name, + description, + tree_structure: tree.tree_structure, + is_public: false + }) + + navigate(`/trees/${forkedTree.id}/edit`) + } catch (err) { + console.error('Failed to fork tree:', err) + throw err + } + } + + // Skip forking and go to session detail + const handleSkipFork = () => { + setShowForkModal(false) + navigate(`/sessions/${session!.id}`) + } + + // Initialize custom steps when resuming a session + const initCustomSteps = (steps: CustomStep[]) => { + setCustomSteps(steps) + } + + return { + // State + customSteps, + showCustomStepModal, + showPostStepModal, + pendingStep, + pendingStepIsFromLibrary, + isSavingStep, + showContinuationModal, + branchOriginNodeId, + pendingContinuationNodeId, + customBranchMode, + showForkModal, + + // Derived + findCustomStep, + getDescendantNodes, + + // Actions + setShowCustomStepModal, + setShowContinuationModal, + setShowForkModal, + initCustomSteps, + handleNavigateToCustomStep, + handleStepCreated, + resetPendingStep, + handleSaveForLater, + handleUseNow, + handleBoth, + handleSelectDescendant, + handleContinueToDescendant, + handleBuildCustomBranch, + handleCustomBranchComplete, + handleForkTree, + handleSkipFork, + isCompleting, + } +} diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 4773fdac..3915a903 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' -import { treesApi, sessionsApi, stepsApi } from '@/api' +import { treesApi, sessionsApi } from '@/api' import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts' -import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types' -import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' +import { useCustomStepFlow } from '@/hooks/useCustomStepFlow' +import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types' import { cn } from '@/lib/utils' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { CustomStepModal } from '@/components/step-library/CustomStepModal' -import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, type DescendantNode } from '@/components/session' +import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session' import { Plus, CheckCircle, ArrowRight } from 'lucide-react' interface LocationState { @@ -35,33 +35,41 @@ export function TreeNavigationPage() { const [clientName, setClientName] = useState('') const [showMetadataForm, setShowMetadataForm] = useState(true) - // Custom steps - const [customSteps, setCustomSteps] = useState([]) - const [showCustomStepModal, setShowCustomStepModal] = useState(false) - - // Post-step action flow - const [showPostStepModal, setShowPostStepModal] = useState(false) - const [pendingStep, setPendingStep] = useState(null) - const [pendingStepIsFromLibrary, setPendingStepIsFromLibrary] = useState(false) - const [isSavingStep, setIsSavingStep] = useState(false) - - // Continuation flow - const [showContinuationModal, setShowContinuationModal] = useState(false) - const [branchOriginNodeId, setBranchOriginNodeId] = useState(null) - const [pendingContinuationNodeId, setPendingContinuationNodeId] = useState(null) - - // Custom branch mode - const [customBranchMode, setCustomBranchMode] = useState(false) - - // Fork flow - const [showForkModal, setShowForkModal] = useState(false) - // Scratchpad state const [scratchpadOpen, setScratchpadOpen] = useState(() => { return localStorage.getItem('scratchpad-collapsed') === 'false' }) - // Scratchpad save handler + const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => { + if (!structure) return null + if (structure.id === nodeId) return structure + if (structure.children) { + for (const child of structure.children) { + const found = findNode(nodeId, child) + if (found) return found + } + } + return null + } + + // Custom step flow (creation, post-step actions, continuation, branching, forking) + const customStepFlow = useCustomStepFlow({ + tree, + session, + currentNodeId, + pathTaken, + decisions, + notes, + findNode, + setCurrentNodeId, + setPathTaken, + setDecisions, + setNotes, + setIsCompleting, + setError, + isCompleting, + }) + const handleScratchpadSave = async (content: string) => { if (!session) return await sessionsApi.updateScratchpad(session.id, content) @@ -87,7 +95,7 @@ export function TreeNavigationPage() { setPathTaken(sessionData.path_taken) setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root') setDecisions(sessionData.decisions as DecisionRecord[]) - setCustomSteps(sessionData.custom_steps || []) + customStepFlow.initCustomSteps(sessionData.custom_steps || []) setTicketNumber(sessionData.ticket_number || '') setClientName(sessionData.client_name || '') setShowMetadataForm(false) @@ -119,70 +127,6 @@ export function TreeNavigationPage() { } } - const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => { - if (!structure) return null - if (structure.id === nodeId) return structure - if (structure.children) { - for (const child of structure.children) { - const found = findNode(nodeId, child) - if (found) return found - } - } - return null - } - - const findCustomStep = (nodeId: string): CustomStep | null => { - return customSteps.find(cs => cs.id === nodeId) || null - } - - // Get descendant nodes two levels deep (grandchildren) from a decision node's options. - // This skips the immediate children (which often mirror the option labels) and shows - // the actual next-level nodes the user would encounter further down each path. - const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => { - const decisionNode = findNode(decisionNodeId, tree?.tree_structure) - if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) { - return [] - } - - const descendants: DescendantNode[] = [] - - for (const option of decisionNode.options) { - if (!option.next_node_id) continue - const childNode = findNode(option.next_node_id, tree?.tree_structure) - if (!childNode) continue - - // Go one level deeper: get the grandchildren - if (childNode.type === 'decision' && childNode.options) { - for (const childOption of childNode.options) { - if (!childOption.next_node_id) continue - const grandchild = findNode(childOption.next_node_id, tree?.tree_structure) - if (!grandchild) continue - - descendants.push({ - id: grandchild.id, - label: grandchild.question || grandchild.title || 'Untitled', - type: grandchild.type, - parentOptionLabel: `${option.label} \u2192 ${childOption.label}` - }) - } - } else if (childNode.type === 'action' && childNode.next_node_id) { - const grandchild = findNode(childNode.next_node_id, tree?.tree_structure) - if (grandchild) { - descendants.push({ - id: grandchild.id, - label: grandchild.question || grandchild.title || 'Untitled', - type: grandchild.type, - parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}` - }) - } - } - // Solution children have no further descendants - skip them - } - - return descendants - } - - // Handler functions - defined before hook call to avoid temporal dead zone const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => { if (!session || !tree) return @@ -208,7 +152,6 @@ export function TreeNavigationPage() { setCurrentNodeId(nextNodeId) setNotes('') - // Update session on backend try { await sessionsApi.update(session.id, { path_taken: newPath, @@ -259,7 +202,6 @@ export function TreeNavigationPage() { setIsCompleting(true) setError(null) try { - // Add final decision const node = findNode(currentNodeId, tree.tree_structure) if (node) { const finalDecision: DecisionRecord = { @@ -296,232 +238,9 @@ export function TreeNavigationPage() { setCurrentNodeId(newPath[newPath.length - 1]) } - // Navigate back to a previously-created custom step from the decision node - const handleNavigateToCustomStep = (customStep: CustomStep) => { - const newPath = [...pathTaken, customStep.id] - setPathTaken(newPath) - setCurrentNodeId(customStep.id) - } - - // Called when CustomStepModal submits - show action modal instead of inserting directly - const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => { - setPendingStep(step) - setPendingStepIsFromLibrary(isFromLibrary) - setShowCustomStepModal(false) - setShowPostStepModal(true) - } - - const resetPendingStep = () => { - setShowPostStepModal(false) - setPendingStep(null) - setPendingStepIsFromLibrary(false) - setIsSavingStep(false) - } - - // Save to library only, don't insert into session - const handleSaveForLater = async () => { - if (!pendingStep) return - - setIsSavingStep(true) - try { - if (!pendingStepIsFromLibrary) { - await stepsApi.create({ - title: pendingStep.title, - step_type: pendingStep.step_type, - content: pendingStep.content, - visibility: 'private', - tags: pendingStep.tags || [] - }) - } - resetPendingStep() - } catch (err) { - console.error('Failed to save step to library:', err) - setIsSavingStep(false) - } - } - - // Insert into session and show continuation options - const handleUseNow = async () => { - if (!pendingStep || !session) return - - setIsSavingStep(true) - try { - // Remember where we branched from (for showing descendants later) - setBranchOriginNodeId(currentNodeId) - - // Create custom step object - const customStep: CustomStep = { - id: crypto.randomUUID(), - inserted_after_node_id: currentNodeId, - step_data: pendingStep, - timestamp: new Date().toISOString() - } - - // Record decision (so it appears in exports) - const newDecision: DecisionRecord = { - node_id: customStep.id, - question: null, - answer: null, - action_performed: `Custom Step: ${pendingStep.title}`, - notes: pendingStep.content.instructions || null, - automation_used: false, - timestamp: new Date().toISOString(), - attachments: [] - } - - // Update state - const newCustomSteps = [...customSteps, customStep] - const newDecisions = [...decisions, newDecision] - const newPath = [...pathTaken, customStep.id] - - setCustomSteps(newCustomSteps) - setDecisions(newDecisions) - setPathTaken(newPath) - setCurrentNodeId(customStep.id) - - // Persist to backend - await sessionsApi.update(session.id, { - path_taken: newPath, - decisions: newDecisions, - custom_steps: newCustomSteps - }) - - resetPendingStep() - - // Show continuation modal - setShowContinuationModal(true) - } catch (err) { - console.error('Failed to insert custom step:', err) - setIsSavingStep(false) - } - } - - // Save to library AND insert into session - const handleBoth = async () => { - if (!pendingStep) return - - setIsSavingStep(true) - try { - // Save to library first - if (!pendingStepIsFromLibrary) { - await stepsApi.create({ - title: pendingStep.title, - step_type: pendingStep.step_type, - content: pendingStep.content, - visibility: 'private', - tags: pendingStep.tags || [] - }) - } - - // Then use now (this will handle the rest and reset pending step) - await handleUseNow() - } catch (err) { - console.error('Failed to save and insert step:', err) - setIsSavingStep(false) - } - } - - // Handle selecting a descendant node from continuation modal. - // Stores the selection so the user can write notes on their custom step first. - const handleSelectDescendant = (nodeId: string) => { - setShowContinuationModal(false) - setBranchOriginNodeId(null) - setPendingContinuationNodeId(nodeId) - } - - // Navigate to the previously-selected descendant node - const handleContinueToDescendant = async () => { - if (!pendingContinuationNodeId || !session) return - - const newPath = [...pathTaken, pendingContinuationNodeId] - setPathTaken(newPath) - setCurrentNodeId(pendingContinuationNodeId) - setNotes('') - setPendingContinuationNodeId(null) - - try { - await sessionsApi.update(session.id, { path_taken: newPath }) - } catch (err) { - console.error('Failed to update session path:', err) - } - } - - // Enter custom branch building mode - const handleBuildCustomBranch = () => { - setShowContinuationModal(false) - setCustomBranchMode(true) - } - - // Complete session from custom branch mode - const handleCustomBranchComplete = async () => { - if (!session) return - - setIsCompleting(true) - setError(null) - - try { - // Record completion decision - const completionDecision: DecisionRecord = { - node_id: currentNodeId, - question: null, - answer: null, - action_performed: 'Custom Branch Completed', - notes: notes || 'Issue resolved via custom troubleshooting steps', - automation_used: false, - timestamp: new Date().toISOString(), - attachments: [] - } - - await sessionsApi.update(session.id, { - decisions: [...decisions, completionDecision] - }) - - await sessionsApi.complete(session.id) - - // Show fork modal if custom steps exist - if (customSteps.length > 0) { - setShowForkModal(true) - } else { - navigate(`/sessions/${session.id}`) - } - } catch (err) { - console.error('Failed to complete session:', err) - setError('Failed to complete session. Please try again.') - } finally { - setIsCompleting(false) - } - } - - // Fork tree with custom branch - const handleForkTree = async (name: string, description: string) => { - if (!tree) return - - try { - // Create a forked tree structure - // For now, we'll create a simple copy - the custom steps are documented in the session - const forkedTree = await treesApi.create({ - name, - description, - tree_structure: tree.tree_structure, // Base structure - is_public: false - }) - - navigate(`/trees/${forkedTree.id}/edit`) - } catch (err) { - console.error('Failed to fork tree:', err) - throw err - } - } - - // Skip forking and go to session detail - const handleSkipFork = () => { - setShowForkModal(false) - navigate(`/sessions/${session!.id}`) - } - // Compute current node for keyboard shortcuts (must be before any returns for hooks rules) const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null - const currentCustomStep = findCustomStep(currentNodeId) + const currentCustomStep = customStepFlow.findCustomStep(currentNodeId) const currentOptions = currentNode?.options || [] // Keyboard shortcuts - must be called unconditionally (React hooks rules) @@ -669,7 +388,7 @@ export function TreeNavigationPage() {
{pathTaken.map((nodeId, index) => { const node = findNode(nodeId, tree?.tree_structure) - const customStep = findCustomStep(nodeId) + const customStep = customStepFlow.findCustomStep(nodeId) const label = node?.question || node?.title || customStep?.step_data.title || nodeId return ( @@ -722,18 +441,18 @@ export function TreeNavigationPage() { ))}
{/* Previously-created custom steps at this node */} - {customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && ( + {customStepFlow.customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && (

Your Custom Steps

- {customSteps + {customStepFlow.customSteps .filter(cs => cs.inserted_after_node_id === currentNodeId) .map(cs => (
-- 2.49.1 From a027e683e3a79e14ee2089e20265250f4d1baec3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 8 Feb 2026 14:21:29 -0500 Subject: [PATCH 2/2] fix: repair all test fixtures - add missing solution fields and fix httpx API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing `solution` field to solution-type nodes in test tree structures (required by `can_publish_tree` validation for published trees) - Fix `AsyncClient(app=...)` → `ASGITransport(app=...)` in test_save_session_as_tree (httpx deprecated the `app` parameter in favor of transport) - All 189 tests now pass (was 84 passed, 1 failed) Files fixed: conftest.py, test_permissions_account.py, test_subscription_limits.py, test_trees.py, test_save_session_as_tree.py Co-Authored-By: Claude Opus 4.6 --- backend/tests/conftest.py | 6 ++++-- backend/tests/test_permissions_account.py | 10 +++++----- backend/tests/test_save_session_as_tree.py | 4 ++-- backend/tests/test_subscription_limits.py | 9 ++++++--- backend/tests/test_trees.py | 12 ++++++++---- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e40acb29..a1d0221d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -174,13 +174,15 @@ async def test_tree(client, auth_headers): "id": "solution1", "type": "solution", "title": "Test Confirmed", - "description": "This is a test tree" + "description": "This is a test tree", + "solution": "Test confirmed - this is a test tree" }, { "id": "solution2", "type": "solution", "title": "Not a Test", - "description": "This should not happen" + "description": "This should not happen", + "solution": "Not a test - this should not happen" } ] } diff --git a/backend/tests/test_permissions_account.py b/backend/tests/test_permissions_account.py index 211a7340..a18067a9 100644 --- a/backend/tests/test_permissions_account.py +++ b/backend/tests/test_permissions_account.py @@ -39,7 +39,7 @@ class TestAccountPermissions: # Try to create tree response = await client.post("/api/v1/trees", json={ "name": "Viewer Tree", - "tree_structure": {"id": "root", "type": "solution", "title": "Test", "description": "Test"} + "tree_structure": {"id": "root", "type": "solution", "title": "Test", "description": "Test", "solution": "Test solution"} }, headers=viewer_headers) assert response.status_code == 403 @@ -53,7 +53,7 @@ class TestAccountPermissions: # Create a public tree as the regular user first await client.post("/api/v1/trees", json={ "name": "Public Tree", - "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"}, + "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T", "solution": "Test solution"}, "is_public": True }, headers=auth_headers) @@ -119,7 +119,7 @@ class TestAccountPermissions: # Engineer creates a tree tree_resp = await client.post("/api/v1/trees", json={ "name": "Engineer's Tree", - "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"} + "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T", "solution": "Test solution"} }, headers=eng_headers) assert tree_resp.status_code == 201 tree_id = tree_resp.json()["id"] @@ -143,7 +143,7 @@ class TestAccountPermissions: # Owner creates a private tree tree_resp = await client.post("/api/v1/trees", json={ "name": "Private Account Tree", - "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"}, + "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T", "solution": "Test solution"}, "is_public": False }, headers=auth_headers) assert tree_resp.status_code == 201 @@ -181,7 +181,7 @@ class TestAccountPermissions: # Owner creates a private tree tree_resp = await client.post("/api/v1/trees", json={ "name": "Secret Tree", - "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"}, + "tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T", "solution": "Test solution"}, "is_public": False }, headers=auth_headers) assert tree_resp.status_code == 201 diff --git a/backend/tests/test_save_session_as_tree.py b/backend/tests/test_save_session_as_tree.py index 18dc759c..f60eee8f 100644 --- a/backend/tests/test_save_session_as_tree.py +++ b/backend/tests/test_save_session_as_tree.py @@ -362,8 +362,8 @@ class TestSaveSessionAsTreeAPI: await test_db.refresh(session) # Try to save the session as test_user (should fail - filtered by user_id) - from httpx import AsyncClient - async with AsyncClient(app=client._transport.app, base_url="http://test") as test_client: # type: ignore + from httpx import AsyncClient, ASGITransport + async with AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test") as test_client: # type: ignore # Login as test_user login_response = await test_client.post( "/api/v1/auth/login", diff --git a/backend/tests/test_subscription_limits.py b/backend/tests/test_subscription_limits.py index 540e42af..6f07266f 100644 --- a/backend/tests/test_subscription_limits.py +++ b/backend/tests/test_subscription_limits.py @@ -18,7 +18,8 @@ class TestSubscriptionLimits: "id": "root", "type": "solution", "title": "Test", - "description": "Test tree" + "description": "Test tree", + "solution": "Test solution" } } @@ -50,7 +51,8 @@ class TestSubscriptionLimits: "id": "root", "type": "solution", "title": "Test", - "description": "Test" + "description": "Test", + "solution": "Test solution" } } create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers) @@ -73,7 +75,8 @@ class TestSubscriptionLimits: "id": "root", "type": "solution", "title": "Test", - "description": "Test tree" + "description": "Test tree", + "solution": "Test solution" }, "is_default": True # Default trees skip limit check } diff --git a/backend/tests/test_trees.py b/backend/tests/test_trees.py index 266ed039..79347246 100644 --- a/backend/tests/test_trees.py +++ b/backend/tests/test_trees.py @@ -38,7 +38,8 @@ class TestTrees: "id": "check_cable", "type": "solution", "title": "Check Network Cable", - "description": "Verify the network cable is connected" + "description": "Verify the network cable is connected", + "solution": "Check and reseat the network cable" } ] } @@ -189,7 +190,8 @@ class TestTrees: "id": "root", "type": "solution", "title": "Private", - "description": "Private tree" + "description": "Private tree", + "solution": "Private solution" }, "is_public": False, "is_default": False @@ -220,7 +222,8 @@ class TestTrees: "id": "root", "type": "solution", "title": "Test", - "description": "Test tree" + "description": "Test tree", + "solution": "Test solution" } } create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers) @@ -337,7 +340,8 @@ class TestTrees: "id": "root", "type": "solution", "title": "Test", - "description": "Test tree" + "description": "Test tree", + "solution": "Test solution" } response = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers) assert response.status_code == 201 -- 2.49.1