diff --git a/backend/app/services/l1_session_service.py b/backend/app/services/l1_session_service.py index 5a33c5e3..1d18f4da 100644 --- a/backend/app/services/l1_session_service.py +++ b/backend/app/services/l1_session_service.py @@ -2,6 +2,8 @@ start_* functions live in T12; step/notes are T13; resolve/escalate are T14. """ +import json +from datetime import datetime, timezone from typing import Optional from uuid import UUID @@ -91,3 +93,59 @@ async def start_adhoc_session( db.add(session) await db.flush() return session + + +async def record_step( + db: AsyncSession, + *, + session_id: UUID, + node_id: str, + question: str, + answer: str, + note: Optional[str] = None, +) -> L1WalkSession: + """Record an answered step in a tree walk. Appends to walked_path JSONB and + advances current_node_id. Raises ValueError on adhoc sessions or inactive + sessions. Updates last_step_at.""" + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.session_kind == "adhoc": + raise ValueError("Cannot record step on adhoc session — use update_notes") + if session.status != "active": + raise ValueError(f"Session {session_id} is not active (status={session.status})") + entry = { + "node_id": node_id, + "question": question, + "answer": answer, + "l1_note": note, + } + # JSONB requires assigning a new list — in-place mutation isn't tracked + session.walked_path = [*session.walked_path, entry] + session.current_node_id = node_id + session.last_step_at = datetime.now(timezone.utc) + await db.flush() + return session + + +async def update_notes( + db: AsyncSession, + *, + session_id: UUID, + notes: list[dict], +) -> L1WalkSession: + """Replace walk_notes on an active session. Used by adhoc walks for + debounced autosave. Raises ValueError if missing or inactive. Caps notes + payload at 256KB to prevent unbounded growth.""" + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.status != "active": + raise ValueError(f"Session {session_id} is not active (status={session.status})") + encoded_size = len(json.dumps(notes).encode("utf-8")) + if encoded_size > 256 * 1024: + raise ValueError("walk_notes exceeds 256KB cap — consider escalating") + session.walk_notes = notes + session.last_step_at = datetime.now(timezone.utc) + await db.flush() + return session diff --git a/backend/tests/test_l1_session_service.py b/backend/tests/test_l1_session_service.py index d6bd687d..462e1148 100644 --- a/backend/tests/test_l1_session_service.py +++ b/backend/tests/test_l1_session_service.py @@ -1,4 +1,4 @@ -"""Tests for l1_session_service start_* functions (T12).""" +"""Tests for l1_session_service start_* functions (T12) and record_step/update_notes (T13).""" import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession @@ -13,6 +13,8 @@ from app.services.l1_session_service import ( start_proposal_session, start_adhoc_session, _resolve_acting_as, + record_step, + update_notes, ) @@ -219,3 +221,201 @@ async def test_engineer_with_coverage_gets_acting_as_tag(test_db: AsyncSession): ticket_kind="internal", ) assert session.acting_as == "l1_coverage" + + +# --------------------------------------------------------------------------- +# T13: record_step and update_notes tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_record_step_appends_to_walked_path(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id="t1", + ticket_kind="internal", + ) + updated = await record_step( + test_db, + session_id=session.id, + node_id="n1", + question="Is the device powered on?", + answer="yes", + ) + assert len(updated.walked_path) == 1 + assert updated.walked_path[0] == { + "node_id": "n1", + "question": "Is the device powered on?", + "answer": "yes", + "l1_note": None, + } + assert updated.current_node_id == "n1" + assert updated.last_step_at is not None + + +@pytest.mark.asyncio +async def test_record_step_two_sequential_steps_accumulate(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id="t2", + ticket_kind="internal", + ) + await record_step( + test_db, + session_id=session.id, + node_id="n1", + question="Step 1?", + answer="yes", + ) + updated = await record_step( + test_db, + session_id=session.id, + node_id="n2", + question="Step 2?", + answer="no", + ) + assert len(updated.walked_path) == 2 + assert updated.walked_path[0]["node_id"] == "n1" + assert updated.walked_path[1]["node_id"] == "n2" + assert updated.current_node_id == "n2" + + +@pytest.mark.asyncio +async def test_record_step_blocks_adhoc(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id="t3", + ticket_kind="internal", + ) + with pytest.raises(ValueError, match="adhoc"): + await record_step( + test_db, + session_id=session.id, + node_id="n1", + question="Q?", + answer="yes", + ) + + +@pytest.mark.asyncio +async def test_record_step_blocks_inactive_session(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id="t4", + ticket_kind="internal", + ) + # Manually mark resolved to simulate inactive state + session.status = "resolved" + await test_db.flush() + with pytest.raises(ValueError, match="not active"): + await record_step( + test_db, + session_id=session.id, + node_id="n1", + question="Q?", + answer="yes", + ) + + +@pytest.mark.asyncio +async def test_record_step_includes_note_when_provided(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id="t5", + ticket_kind="internal", + ) + updated = await record_step( + test_db, + session_id=session.id, + node_id="n1", + question="Q?", + answer="yes", + note="Customer mentioned it started yesterday", + ) + assert updated.walked_path[0]["l1_note"] == "Customer mentioned it started yesterday" + + +@pytest.mark.asyncio +async def test_update_notes_replaces_walk_notes(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id="t6", + ticket_kind="internal", + ) + new_notes = [{"timestamp": "2026-05-28T10:00:00Z", "content": "Customer rebooted"}] + updated = await update_notes(test_db, session_id=session.id, notes=new_notes) + assert updated.walk_notes == new_notes + assert updated.last_step_at is not None + + +@pytest.mark.asyncio +async def test_update_notes_raises_if_session_not_found(test_db: AsyncSession): + missing_id = uuid.uuid4() + with pytest.raises(ValueError, match="not found"): + await update_notes(test_db, session_id=missing_id, notes=[]) + + +@pytest.mark.asyncio +async def test_update_notes_raises_if_session_not_active(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id="t7", + ticket_kind="internal", + ) + session.status = "escalated" + await test_db.flush() + with pytest.raises(ValueError, match="not active"): + await update_notes(test_db, session_id=session.id, notes=[]) + + +@pytest.mark.asyncio +async def test_update_notes_size_cap(test_db: AsyncSession): + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id="t8", + ticket_kind="internal", + ) + # Create a notes payload larger than 256KB + big_content = "x" * (256 * 1024 + 100) + notes = [{"timestamp": "2026-05-28T10:00:00Z", "content": big_content}] + with pytest.raises(ValueError, match="256KB"): + await update_notes(test_db, session_id=session.id, notes=notes)