"""Gate tests — ``require_normalization_gate()`` behaviour. The gate sits between every tool page and the user's data. Three states exist, each pinned here: 1. **No upload** — gate is a no-op; the page proceeds and its own uploader handles the file. 2. **Upload but no normalization result** — gate shows a warning and a "Go to Review & Normalize" button, then ``st.stop()`` short-circuits the rest of the page. 3. **Upload + matching passed normalization** — gate is a no-op; the page proceeds. We exercise the gate via the Deduplicator page (any tool page would work; dedup is the smallest one that doesn't depend on heavy widgets). """ from __future__ import annotations import pytest from .conftest import ( collected_text, stash_upload, stash_upload_without_gate, with_language, ) # Deduplicator is our canary — it calls ``require_normalization_gate`` # on the second line of the module. If the gate blocks, the dedup- # specific title shouldn't even render. GATED_PAGE = "1_Deduplicator" class TestGateNoUpload: """No upload → the gate exits early and the page renders normally, showing its own file uploader. (This is the "user opened the dedup page first instead of coming from home" path.)""" def test_no_upload_lets_page_render(self, app_factory): app = app_factory(GATED_PAGE) app.run() assert not app.exception text = collected_text(app) # The dedup page title is the unambiguous signal that the gate # didn't short-circuit. assert "Find Duplicates" in text def test_no_upload_no_gate_warning(self, app_factory): app = app_factory(GATED_PAGE) app.run() # The gate's warning string starts with the upload filename. No # warning should be present when there's no upload. for w in app.warning: assert "normalization gate" not in (w.body or "") class TestGateBlocksWithoutNormalization: """Upload present but no passing normalization → gate fires: warning + Go-to-Review button + page short-circuit.""" def test_gate_warning_renders(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes) app.run() warnings = [w.body for w in app.warning if w.body] joined = " ".join(warnings) assert "normalization gate" in joined, ( f"expected gate warning; got warnings: {warnings}" ) assert "messy.csv" in joined, ( "gate warning should name the offending file" ) def test_gate_renders_go_to_review_button(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes) app.run() labels = [b.label for b in app.button] assert any("Review & Normalize" in lbl for lbl in labels), ( f"missing 'Go to Review & Normalize' button; got: {labels}" ) def test_gate_short_circuits_page(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes) app.run() # When the gate fires it calls ``st.stop()`` after the warning. # The page-body widgets (e.g., the advanced-options expander, the # dedup-strategy widgets) must NOT be present. labels = [b.label for b in app.button] # The Run-Dedup primary action lives below the gate — make sure # the gate killed the render before it. assert not any("Run Deduplication" in lbl for lbl in labels), ( f"gate failed to short-circuit; saw button: {labels}" ) def test_gate_warning_localizes_to_spanish(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) with_language(app, "es") stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes) app.run() warnings = " ".join(w.body for w in app.warning if w.body) # Spanish pack: ``debe pasar la verificación de normalización CSV``. assert "normalización" in warnings def test_gate_button_localizes_to_spanish(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) with_language(app, "es") stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes) app.run() labels = [b.label for b in app.button] assert any("Revisar y Normalizar" in lbl for lbl in labels), ( f"Spanish gate button missing; got: {labels}" ) class TestGateAllowsWithPassedNormalization: """Upload + passed normalization → gate is a no-op and the page renders past the gate.""" def test_passed_gate_lets_page_render(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) stash_upload(app, name="messy.csv", data=small_csv_bytes) app.run() assert not app.exception, f"page raised past gate: {app.exception}" # The pickup banner uses the upload name — that's our signal # that the gate let us through AND the pickup helper engaged. text = collected_text(app) assert "messy.csv" in text class TestGateMismatchedHash: """Upload changes (different bytes) but normalization_for still points at the old hash → gate fires again because the result is stale. Pins the security-relevant "stale fix doesn't carry over to a new file" invariant.""" def test_stale_normalization_blocks_new_upload(self, app_factory, small_csv_bytes): app = app_factory(GATED_PAGE) # Stash bytes A but a normalization_for hash that points at B. app.session_state["home_uploaded_bytes"] = small_csv_bytes app.session_state["home_uploaded_name"] = "new.csv" app.session_state["home_uploaded_size"] = len(small_csv_bytes) app.session_state["normalization_for"] = "different-hash-from-an-old-upload" # A passed-result object exists but is keyed to a different file. class _Passed: passed = True app.session_state["normalization_result"] = _Passed() app.run() warnings = " ".join(w.body for w in app.warning if w.body) assert "normalization gate" in warnings, ( "stale gate result should not unlock a new upload" )