"""GUI activation + license-gate tests. These exercise the chrome-level gate that ``hide_streamlit_chrome`` installs: when no valid license is on disk, every page renders the activation form instead of the page body, and tool widgets do NOT appear. We test against the Find Duplicates page since it's the smallest real-world tool that depends on chrome. The autouse fixture in ``tests/conftest.py`` sets ``DATATOOLS_DEV_MODE=1``, which the GUI gate respects. Each test below uses ``monkeypatch`` to clear that env var so the real gate fires; ``isolated_license_path`` then redirects the manager to a tmp file. """ from __future__ import annotations import pytest from streamlit.testing.v1 import AppTest from .conftest import collected_text, stash_upload, with_language @pytest.fixture def no_license_env(monkeypatch, tmp_path): """Clear dev mode and point the license at a fresh empty tmp path.""" monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False) monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json")) from src.license.manager import reset_singleton_for_tests reset_singleton_for_tests() yield tmp_path / "license.json" reset_singleton_for_tests() @pytest.fixture def trial_license(no_license_env): """Pre-activate a 1-year trial license for tests that need to pass the gate.""" from src.license import LicenseManager, Tier mgr = LicenseManager() mgr.issue_trial(name="Test User", email="test@example.com") yield mgr class TestGateBlocksWithoutLicense: """When no license file exists, every page should render the activation form and short-circuit the tool body.""" def test_home_renders_activation_form(self, no_license_env, home_app): home_app.run() text = collected_text(home_app) assert "Activate DataTools" in text or "Activar DataTools" in text def test_dedup_page_does_not_render_tool_widgets( self, no_license_env, app_factory, ): app = app_factory("1_Deduplicator") app.run() # Without a license, the page should NOT have the dedup- # specific advanced-options expander or Find Duplicates button. labels = [b.label for b in app.button] assert not any("Find Duplicates" in lbl for lbl in labels), ( f"tool widgets leaked past the gate; got: {labels}" ) def test_activation_form_localizes_to_spanish( self, no_license_env, home_app, ): with_language(home_app, "es") home_app.run() text = collected_text(home_app) assert "Activar DataTools" in text def test_sidebar_shows_not_activated(self, no_license_env, home_app): home_app.run() # Sidebar caption "🔒 Not activated". captions = [c.value for c in home_app.sidebar.caption] joined = " ".join(captions) assert "Not activated" in joined or "Sin activar" in joined class TestGatePassesWithTrialLicense: def test_home_renders_full_grid(self, trial_license, home_app): home_app.run() text = collected_text(home_app) # With a valid license, the activation form should NOT be the # primary content; we should see the home tagline + tool cards. assert "Clean. Normalize. Transform." in text assert "Activate DataTools" not in text # form not shown inline def test_sidebar_shows_active_status(self, trial_license, home_app): home_app.run() captions = " ".join(c.value for c in home_app.sidebar.caption) # "Trial · 364 days left" (give or take one). assert "Trial" in captions or "Prueba" in captions assert "days left" in captions or "días" in captions def test_dedup_page_renders_tool_widgets( self, trial_license, app_factory, small_csv_bytes, ): app = app_factory("1_Deduplicator") stash_upload(app, name="messy.csv", data=small_csv_bytes) app.run() labels = [b.label for b in app.button] assert any("Find Duplicates" in lbl for lbl in labels), ( f"tool widgets blocked by gate even with valid license; " f"got: {labels}" ) class TestActivationFormSubmission: """End-to-end: paste a generated blob into the inline form and confirm the gate releases on next render.""" def test_paste_blob_activates_and_unlocks( self, no_license_env, home_app, ): # Generate a blob the same way scripts/generate_license.py does. from src.license import LicenseManager, Tier from src.license.crypto import encode_blob mint_mgr = LicenseManager() # Use a separate tmp path so the trial we mint doesn't fight # with the manager the GUI will use. from tempfile import mkstemp from pathlib import Path _, p = mkstemp(suffix=".json") mint_mgr._path = Path(p) lic = mint_mgr._mint( name="Buyer", email="buyer@example.com", tier=Tier.CORE, ) blob = encode_blob(lic.to_dict()) # Wipe the temporary mint manager so its file doesn't collide. mint_mgr.deactivate() # Now drive the real GUI manager. home_app.run() # The activation form is inline in chrome — its widget keys # are prefixed ``gate_``. home_app.text_input(key="gate_name").set_value("Buyer").run() home_app.text_input(key="gate_email").set_value("buyer@example.com").run() home_app.text_area(key="gate_blob").set_value(blob).run() # Submit primary form button. submit = next( b for b in home_app.button if b.label in ("Activate", "Apply renewal") ) submit.click().run() # After activation the page reruns and the activation form # should be gone — we should see the home page proper. text = collected_text(home_app) assert "Clean. Normalize. Transform." in text def test_trial_button_absent_paid_only(self, no_license_env, home_app): """v1.6 dropped the user-facing trial flow — paid licenses only. Verify the trial button is not on the activation form.""" home_app.run() labels = [b.label for b in home_app.button] for lbl in labels: assert "trial" not in lbl.lower(), ( f"trial button leaked into activation form: {lbl!r}" ) assert "prueba" not in lbl.lower(), ( f"Spanish trial button leaked into form: {lbl!r}" ) class TestActivationPageDirect: """``pages/_Activate.py`` renders the same form regardless of license state — buyer can revisit it to review or deactivate.""" def test_activate_page_renders_with_valid_license( self, trial_license, app_factory, ): app = app_factory("_Activate") app.run() text = collected_text(app) # Page title localized. assert "Activate DataTools" in text # Deactivate option only shown after activation. labels = [b.label for b in app.button] assert any("Deactivate" in lbl for lbl in labels) def test_activate_page_renders_without_license( self, no_license_env, app_factory, ): app = app_factory("_Activate") app.run() text = collected_text(app) assert "Activate DataTools" in text # Deactivate button should NOT appear when nothing is active. labels = [b.label for b in app.button] assert not any("Deactivate" in lbl for lbl in labels) class TestSidebarRenewalWarning: """A license with <30 days remaining surfaces a sidebar warning.""" def test_renewal_warning_appears_under_30_days( self, no_license_env, home_app, monkeypatch, ): # Mint a license with 7 days left. from datetime import datetime, timedelta, timezone from src.license import License, LicenseManager, Tier from src.license import crypto as _crypto from src.license.features import all_features_for_tier future = (datetime.now(timezone.utc) + timedelta(days=7)).strftime( "%Y-%m-%dT%H:%M:%SZ" ) lic = License( name="X", email="x@x.com", license_key="DT1-CORE-EXPIRING", tier=Tier.CORE, features=all_features_for_tier(Tier.CORE), issued_at="2026-05-13T00:00:00Z", expires_at=future, signature="", ) sig = _crypto.sign(lic.to_canonical_dict()) signed = License(**{**lic.__dict__, "signature": sig}) LicenseManager().save(signed) home_app.run() sidebar_warnings = [w.body for w in home_app.sidebar.warning if w.body] joined = " ".join(sidebar_warnings) assert "expires in" in joined.lower() or "caduca en" in joined.lower(), ( f"expected renewal warning in sidebar; got: {sidebar_warnings}" ) class TestLicenseStatusBadgeI18n: """Sidebar status badge tier name must localize.""" def test_core_tier_localizes_in_spanish( self, no_license_env, home_app, monkeypatch, ): from src.license import LicenseManager, Tier LicenseManager()._mint( name="X", email="x@x.com", tier=Tier.CORE, ) with_language(home_app, "es") home_app.run() captions = " ".join(c.value for c in home_app.sidebar.caption) # The es pack maps ``license.tier_core`` to "Core" — same word # in Spanish — but the surrounding template (``días restantes``) # localizes. assert "días restantes" in captions, ( f"Spanish status label missing; sidebar captions: {captions}" )