Spaces:
Sleeping
Sleeping
- Added support for RICS survey levels (1, 2, 3) in document uploads and reports, allowing for better tier management and retrieval filtering.
b76f199 | """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 | |