"""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 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