"""Unit: SSE frame serialization for POST /api/chat (PLAN §4 contract).""" from __future__ import annotations import json from app.api.chat import sse_event from app.schemas import ChatErrorEvent, ChatThinkingEvent, ChatToolEvent def _payload(frame: str) -> dict: assert frame.startswith("data: ") assert frame.endswith("\n\n") return json.loads(frame.removeprefix("data: ").strip()) def test_delta_frame_serializes_exactly() -> None: frame = sse_event({"type": "delta", "text": "hi"}) assert frame == 'data: {"type": "delta", "text": "hi"}\n\n' assert _payload(frame) == {"type": "delta", "text": "hi"} def test_done_frame_carries_full_contract_shape() -> None: payload = { "type": "done", "deflected": False, "sources": [{"source": "Homelab", "path": "kubernetes.md", "title": "K8s"}], "suggestions": [], } assert _payload(sse_event(payload)) == payload def test_error_frame_serializes() -> None: frame = sse_event({"type": "error", "detail": "boom"}) assert _payload(frame) == {"type": "error", "detail": "boom"} def test_unicode_survives_roundtrip() -> None: frame = sse_event({"type": "delta", "text": "🧠 café — \"quoted\""}) # ensure_ascii=False keeps the frame readable (no \uXXXX escapes). assert "🧠 café" in frame assert _payload(frame)["text"] == "🧠 café — \"quoted\"" def test_multi_line_text_stays_one_frame() -> None: """Newlines inside the JSON payload must be escaped so the frame delimiter ``\\n\\n`` remains unambiguous.""" frame = sse_event({"type": "delta", "text": "line1\nline2\n\n"}) assert frame.count("\n\n") == 1 # only the frame terminator assert _payload(frame)["text"] == "line1\nline2\n\n" def test_error_event_model_serializes_exact_frame() -> None: """The ``ChatErrorEvent`` model is the wire shape of every server-side failure the UI's state machine (phase 06) must recover from.""" frame = sse_event(ChatErrorEvent(detail="boom").model_dump()) assert frame == 'data: {"type": "error", "detail": "boom"}\n\n' assert _payload(frame) == {"type": "error", "detail": "boom"} def test_error_event_shape_is_type_and_detail_only() -> None: dumped = ChatErrorEvent(detail="The chat model dropped the connection").model_dump() assert set(dumped.keys()) == {"type", "detail"} assert dumped["type"] == "error" # default — call sites never spell it out def test_thinking_frame_serializes_exactly() -> None: """Phase 17 (PLAN §4 extension): the ``thinking`` frame is exactly ``{type: "thinking", text: str}`` — the sibling shape of ``delta`` the client's readSSE handler will branch on.""" frame = sse_event(ChatThinkingEvent(text="Step 1: check the docs…").model_dump()) assert frame == 'data: {"type": "thinking", "text": "Step 1: check the docs…"}\n\n' assert _payload(frame) == {"type": "thinking", "text": "Step 1: check the docs…"} def test_thinking_event_shape_is_type_and_text_only() -> None: dumped = ChatThinkingEvent(text="hmm").model_dump() assert set(dumped.keys()) == {"type", "text"} assert dumped["type"] == "thinking" # default — call sites never spell it out def test_tool_frame_serializes_exactly() -> None: """Phase 37 (PLAN §4 extension): the ``tool`` frame is exactly ``{type: "tool", name: str, argument: str | null}`` — one per model-requested document tool call, streamed ahead of the ``delta`` frames of the answer.""" frame = sse_event(ChatToolEvent(name="read_document", argument="S/p.md").model_dump()) assert frame == 'data: {"type": "tool", "name": "read_document", "argument": "S/p.md"}\n\n' assert _payload(frame) == {"type": "tool", "name": "read_document", "argument": "S/p.md"} def test_tool_frame_argument_is_null_for_parameterless_tools() -> None: """``list_documents`` takes no parameters, so its frame's ``argument`` serializes as JSON null (the client renders the name alone).""" dumped = ChatToolEvent(name="list_documents").model_dump() assert dumped == {"type": "tool", "name": "list_documents", "argument": None} assert _payload(sse_event(dumped))["argument"] is None def test_tool_event_shape_is_type_name_argument_only() -> None: dumped = ChatToolEvent(name="read_document", argument="S/p.md").model_dump() assert set(dumped.keys()) == {"type", "name", "argument"} assert dumped["type"] == "tool" # default — call sites never spell it out