"""Tests for the report export endpoint and DOCX builder. Covers: * ``GET /reports/{report_id}/export`` returns DOCX bytes with correct headers. * 404 is returned for unknown reports or wrong tenant. * 422 is returned when no sections have been generated yet. * The DOCX builder produces a valid non-empty binary. * The built document contains expected section text. """ from __future__ import annotations import io import uuid from unittest.mock import MagicMock from docx import Document # ── DOCX builder unit tests ─────────────────────────────────────────────────── def _make_payload(text: str, mode: str = "generate", confidence: float = 0.85) -> MagicMock: """Create a minimal SectionPayload mock.""" p = MagicMock() p.text = text p.mode = mode p.confidence = confidence p.cached = False p.style_profile = None p.provenance = [] p.ai_transparency = None return p def test_build_docx_returns_bytes() -> None: """build_docx should return a non-empty bytes object.""" from app.generator.docx_builder import build_docx sections = { "D": _make_payload("The property is a semi-detached house."), "E2": _make_payload("The roof coverings are in fair condition."), } result = build_docx(sections=sections, report_id=str(uuid.uuid4()), tenant_id="tenant_test") assert isinstance(result, bytes) assert len(result) > 500 # a minimal DOCX is never empty def test_build_docx_embeds_section_photos_when_paths_provided(tmp_path) -> None: """If section_photo_paths is provided, DOCX should include image relationships.""" from app.generator.docx_builder import build_docx # 1x1 PNG png = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0cIDATx\x9cc\xf8" b"\xff\xff?\x00\x05\xfe\x02\xfeA\xdd\x8b\xe1\x00\x00\x00\x00IEND\xaeB`\x82" ) img = tmp_path / "p.png" img.write_bytes(png) sections = {"D": _make_payload("The property is a semi-detached house.")} raw = build_docx( sections=sections, report_id="r-img", tenant_id="t", section_photo_paths={"D": [str(img)]}, ) doc = Document(io.BytesIO(raw)) assert doc.part._rels # relationships exist # At least one image rel should be present assert any("image" in str(r.target_ref) for r in doc.part._rels.values()) def test_build_docx_is_valid_docx() -> None: """build_docx output must be parseable as a Word document.""" from app.generator.docx_builder import build_docx sections = {"A": _make_payload("Inspection carried out on 01 March 2026.")} raw = build_docx(sections=sections, report_id="r1", tenant_id="tenant_x") doc = Document(io.BytesIO(raw)) # Title page and section heading must appear somewhere in the document full_text = "\n".join(p.text for p in doc.paragraphs) assert "RICS Level 3 Building Survey Report" in full_text assert "Introduction" in full_text def test_build_docx_title_reflects_condition_report_tier() -> None: """Tier 1 uses Condition Report labelling on the title page.""" from app.generator.docx_builder import build_docx sections = {"A": _make_payload("Brief scope note.")} raw = build_docx(sections=sections, report_id="r-cr", tenant_id="t", survey_level=1) doc = Document(io.BytesIO(raw)) full_text = "\n".join(p.text for p in doc.paragraphs) assert "RICS Condition Report" in full_text def test_build_docx_title_reflects_homebuyer_tier() -> None: """Tier 2 uses Home Survey Level 2 / HomeBuyer wording on the title page.""" from app.generator.docx_builder import build_docx sections = {"A": _make_payload("Brief scope note.")} raw = build_docx(sections=sections, report_id="r-hb", tenant_id="t", survey_level=2) doc = Document(io.BytesIO(raw)) full_text = "\n".join(p.text for p in doc.paragraphs) assert "RICS HomeBuyer Report / Home Survey Level 2" in full_text def test_build_docx_includes_source_references_when_provenance_set() -> None: """Provenance rows should appear in the exported DOCX for traceability.""" from app.generator.docx_builder import build_docx p = _make_payload("The wall is brick.") p.provenance = [ { "doc_id": "d1", "chunk_id": "c1", "score": 0.91, "filename": "survey_notes.pdf", "section_hint": "External walls", "snippet_preview": "Cavity wall construction noted at…", } ] sections = {"E4": p} raw = build_docx(sections=sections, report_id="r-src", tenant_id="t1") doc = Document(io.BytesIO(raw)) full_text = "\n".join(x.text for x in doc.paragraphs) assert "Source references" in full_text assert "survey_notes.pdf" in full_text def test_build_docx_verify_tags_preserved() -> None: """Legacy [VERIFY] tags should not appear in exports (pipeline no longer emits them).""" from app.generator.docx_builder import build_docx sections = { "E2": _make_payload("Roof area is [VERIFY: approximately 85 sqm]."), } raw = build_docx(sections=sections, report_id="r2", tenant_id="t") doc = Document(io.BytesIO(raw)) full_text = "\n".join(p.text for p in doc.paragraphs) assert "[VERIFY" not in full_text def test_build_docx_empty_sections_still_renders() -> None: """Sections with no payload get a placeholder paragraph, not an error.""" from app.generator.docx_builder import build_docx # Pass a dict with only section A; all others should render as placeholders sections: dict = {"A": _make_payload("Brief intro text.")} raw = build_docx(sections=sections, report_id="r3", tenant_id="t") doc = Document(io.BytesIO(raw)) # doc.paragraphs includes Normal and Heading paragraphs full_text = "\n".join(p.text for p in doc.paragraphs) # Placeholder text written for un-generated sections assert "not yet generated" in full_text.lower() def test_build_docx_editor_notes_proofread() -> None: """Editor notes appended by proofread mode should appear in the DOCX.""" from app.generator.docx_builder import build_docx text_with_notes = ( "The boiler appeared serviceable and was last serviced recently." "\n\n[Editor notes: Clarify service date; check certificate is available.]" ) sections = {"G4": _make_payload(text_with_notes, mode="proofread")} raw = build_docx(sections=sections, report_id="r4", tenant_id="t") doc = Document(io.BytesIO(raw)) full_text = "\n".join(p.text for p in doc.paragraphs) assert "Clarify service date" in full_text # ── Rate limiter unit tests ─────────────────────────────────────────────────── def test_rate_limiter_allows_within_quota() -> None: """Requests within the quota window should be allowed.""" from app.api.rate_limit import _SlidingWindowLimiter limiter = _SlidingWindowLimiter(max_requests=5, window_secs=60.0) for _ in range(5): assert limiter.is_allowed("tenant_a") is True def test_rate_limiter_blocks_over_quota() -> None: """The (N+1)th request in the window should be denied.""" from app.api.rate_limit import _SlidingWindowLimiter limiter = _SlidingWindowLimiter(max_requests=3, window_secs=60.0) for _ in range(3): limiter.is_allowed("tenant_b") assert limiter.is_allowed("tenant_b") is False def test_rate_limiter_isolates_tenants() -> None: """Quota exhaustion for one tenant must not affect another tenant.""" from app.api.rate_limit import _SlidingWindowLimiter limiter = _SlidingWindowLimiter(max_requests=2, window_secs=60.0) for _ in range(2): limiter.is_allowed("tenant_c") # tenant_c is now exhausted assert limiter.is_allowed("tenant_c") is False # tenant_d should still be allowed assert limiter.is_allowed("tenant_d") is True def test_rate_limiter_reset_clears_quota() -> None: """``reset()`` should restore full quota for a tenant.""" from app.api.rate_limit import _SlidingWindowLimiter limiter = _SlidingWindowLimiter(max_requests=1, window_secs=60.0) assert limiter.is_allowed("tenant_e") is True assert limiter.is_allowed("tenant_e") is False limiter.reset("tenant_e") assert limiter.is_allowed("tenant_e") is True