diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index 1745d44..2b640c8 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -704,13 +704,12 @@ async def is_flag_enabled(name: str, default: bool = False) -> bool: Reads from the feature_flags table. Flags are toggled via the admin UI and take effect immediately — no restart needed. """ - db = await get_db() - row = await db.execute_fetchone( + row = await fetch_one( "SELECT enabled FROM feature_flags WHERE name = ?", (name,) ) if row is None: return default - return bool(row[0]) + return bool(row["enabled"]) def feature_gate(flag_name: str, waitlist_template: str, **extra_context): diff --git a/web/tests/conftest.py b/web/tests/conftest.py index 207a0db..e5c1d49 100644 --- a/web/tests/conftest.py +++ b/web/tests/conftest.py @@ -54,6 +54,20 @@ async def db(): conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys=ON") await conn.executescript(schema_ddl) + # Seed feature flags so routes that use feature_gate() pass through by default. + # Tests that specifically test gated behaviour set the flag to 0 via _set_flag(). + await conn.executescript(""" + INSERT OR IGNORE INTO feature_flags (name, enabled, description) + VALUES ('markets', 1, 'Market/SEO content pages'); + INSERT OR IGNORE INTO feature_flags (name, enabled, description) + VALUES ('payments', 0, 'Paddle billing & checkout'); + INSERT OR IGNORE INTO feature_flags (name, enabled, description) + VALUES ('planner_export', 0, 'Business plan PDF export'); + INSERT OR IGNORE INTO feature_flags (name, enabled, description) + VALUES ('supplier_signup', 0, 'Supplier onboarding wizard'); + INSERT OR IGNORE INTO feature_flags (name, enabled, description) + VALUES ('lead_unlock', 0, 'Lead credit purchase & unlock'); + """) await conn.commit() original_db = core._db diff --git a/web/tests/test_businessplan.py b/web/tests/test_businessplan.py index 3230545..508d0e5 100644 --- a/web/tests/test_businessplan.py +++ b/web/tests/test_businessplan.py @@ -255,6 +255,10 @@ class TestExportRoutes: assert resp.status_code == 302 async def test_export_page_shows_scenarios(self, auth_client, db, scenario): + await db.execute( + "INSERT OR REPLACE INTO feature_flags (name, enabled, description) VALUES ('planner_export', 1, '')" + ) + await db.commit() resp = await auth_client.get("/en/planner/export") assert resp.status_code == 200 html = (await resp.data).decode() diff --git a/web/tests/test_feature_flags.py b/web/tests/test_feature_flags.py new file mode 100644 index 0000000..5dac2d8 --- /dev/null +++ b/web/tests/test_feature_flags.py @@ -0,0 +1,431 @@ +""" +Tests for feature flags — migration, is_flag_enabled helper, feature_gate +decorator, admin toggle routes, and per-feature gating across all routes. + +Unit tests cover is_flag_enabled and feature_gate in isolation. +Integration tests exercise full request/response flows via Quart test client. +""" + +import sqlite3 +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from padelnomics import core +from padelnomics.migrations.migrate import migrate + + +# ── Fixtures & helpers ──────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def mock_csrf_validation(): + """Mock CSRF validation to always pass in all tests in this file.""" + with patch("padelnomics.core.validate_csrf_token", return_value=True): + yield + + +@pytest.fixture +async def admin_client(app, db): + """Test client with an admin-role user session (module-level, follows test_content.py).""" + now = datetime.utcnow().isoformat() + async with db.execute( + "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", + ("flags_admin@test.com", "Flags Admin", now), + ) as cursor: + admin_id = cursor.lastrowid + await db.execute( + "INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,) + ) + await db.commit() + async with app.test_client() as c: + async with c.session_transaction() as sess: + sess["user_id"] = admin_id + yield c + + +async def _set_flag(db, name: str, enabled: bool, description: str = ""): + """Insert or replace a flag in the test DB.""" + await db.execute( + """INSERT OR REPLACE INTO feature_flags (name, enabled, description) + VALUES (?, ?, ?)""", + (name, 1 if enabled else 0, description), + ) + await db.commit() + + +async def _flag_value(db, name: str) -> int | None: + async with db.execute( + "SELECT enabled FROM feature_flags WHERE name = ?", (name,) + ) as cursor: + row = await cursor.fetchone() + return row[0] if row else None + + +async def _seed_all_flags(db): + """Seed the five default flags matching migration 0019 defaults.""" + flags = [ + ("markets", 1), + ("payments", 0), + ("planner_export", 0), + ("supplier_signup", 0), + ("lead_unlock", 0), + ] + for name, enabled in flags: + await db.execute( + "INSERT OR REPLACE INTO feature_flags (name, enabled, description) VALUES (?, ?, ?)", + (name, enabled, ""), + ) + await db.commit() + + +def _column_names(conn, table): + return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + + +def _table_names(conn): + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + " AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).fetchall() + return [r[0] for r in rows] + + +# ── Migration 0019 ──────────────────────────────────────────────── + + +class TestMigration0019: + """Migration 0019 creates feature_flags table and seeds initial flags.""" + + def test_creates_feature_flags_table(self, tmp_path): + db_path = str(tmp_path / "test.db") + migrate(db_path) + conn = sqlite3.connect(db_path) + assert "feature_flags" in _table_names(conn) + conn.close() + + def test_table_has_correct_columns(self, tmp_path): + db_path = str(tmp_path / "test.db") + migrate(db_path) + conn = sqlite3.connect(db_path) + cols = _column_names(conn, "feature_flags") + conn.close() + assert "name" in cols + assert "enabled" in cols + assert "description" in cols + assert "updated_at" in cols + + def test_seeds_markets_enabled(self, tmp_path): + db_path = str(tmp_path / "test.db") + migrate(db_path) + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT enabled FROM feature_flags WHERE name = 'markets'" + ).fetchone() + conn.close() + assert row is not None and row[0] == 1 + + def test_seeds_payments_disabled(self, tmp_path): + db_path = str(tmp_path / "test.db") + migrate(db_path) + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT enabled FROM feature_flags WHERE name = 'payments'" + ).fetchone() + conn.close() + assert row is not None and row[0] == 0 + + def test_seeds_all_five_flags(self, tmp_path): + db_path = str(tmp_path / "test.db") + migrate(db_path) + conn = sqlite3.connect(db_path) + names = {r[0] for r in conn.execute("SELECT name FROM feature_flags").fetchall()} + conn.close() + assert names == {"markets", "payments", "planner_export", "supplier_signup", "lead_unlock"} + + def test_idempotent_on_rerun(self, tmp_path): + """Running migrate twice does not duplicate seed rows.""" + db_path = str(tmp_path / "test.db") + migrate(db_path) + migrate(db_path) + conn = sqlite3.connect(db_path) + count = conn.execute("SELECT COUNT(*) FROM feature_flags").fetchone()[0] + conn.close() + assert count == 5 + + +# ── is_flag_enabled ─────────────────────────────────────────────── + + +class TestIsFlagEnabled: + """Unit tests for is_flag_enabled() helper.""" + + @pytest.mark.asyncio + async def test_returns_true_for_enabled_flag(self, db): + await _set_flag(db, "markets", True) + assert await core.is_flag_enabled("markets") is True + + @pytest.mark.asyncio + async def test_returns_false_for_disabled_flag(self, db): + await _set_flag(db, "payments", False) + assert await core.is_flag_enabled("payments") is False + + @pytest.mark.asyncio + async def test_returns_default_false_for_unknown_flag(self, db): + assert await core.is_flag_enabled("nonexistent_flag") is False + + @pytest.mark.asyncio + async def test_returns_custom_default_for_unknown_flag(self, db): + assert await core.is_flag_enabled("nonexistent_flag", default=True) is True + + @pytest.mark.asyncio + async def test_reflects_live_db_change(self, db): + """Change takes effect without restart — reads live DB every call.""" + await _set_flag(db, "payments", False) + assert await core.is_flag_enabled("payments") is False + await _set_flag(db, "payments", True) + assert await core.is_flag_enabled("payments") is True + + +# ── feature_gate decorator ──────────────────────────────────────── + + +class TestFeatureGateDecorator: + """feature_gate blocks GET when flag is disabled, passes through when enabled.""" + + @pytest.mark.asyncio + async def test_shows_waitlist_on_get_when_flag_disabled(self, client, db): + """GET /auth/signup shows waitlist page when payments flag is off.""" + await _set_flag(db, "payments", False) + response = await client.get("/auth/signup") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + # payments waitlist.html shows "join" or "launching soon" messaging + assert "launching soon" in html.lower() or "join" in html.lower() or "waitlist" in html.lower() + + @pytest.mark.asyncio + async def test_shows_normal_form_on_get_when_flag_enabled(self, client, db): + """GET /auth/signup shows normal signup form when payments flag is on.""" + await _set_flag(db, "payments", True) + response = await client.get("/auth/signup") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + # Normal signup shows create account form + assert "create" in html.lower() or "sign up" in html.lower() or "email" in html.lower() + + @pytest.mark.asyncio + async def test_post_passes_through_when_flag_disabled(self, client, db): + """POST is never blocked by feature_gate (gates GET only).""" + await _set_flag(db, "payments", False) + with patch("padelnomics.worker.enqueue", new_callable=AsyncMock): + response = await client.post("/auth/signup", form={ + "csrf_token": "test_token", + "email": "test@example.com", + }) + assert response.status_code in (200, 302) + + @pytest.mark.asyncio + async def test_markets_route_gated_by_markets_flag(self, client, db): + """markets flag controls /en/markets access.""" + # Disabled → shows gated page + await _set_flag(db, "markets", False) + response = await client.get("/en/markets") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "coming soon" in html.lower() or "intelligence" in html.lower() + + # Enabled → passes through + await _set_flag(db, "markets", True) + response = await client.get("/en/markets") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + # Normal markets page doesn't show the "coming soon" waitlist title + assert "coming soon" not in html.lower() + + @pytest.mark.asyncio + async def test_supplier_signup_gated_by_flag(self, client, db): + """supplier_signup flag controls /en/suppliers/signup.""" + await _set_flag(db, "supplier_signup", False) + response = await client.get("/en/suppliers/signup") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "waitlist" in html.lower() or "supplier" in html.lower() + + @pytest.mark.asyncio + async def test_planner_export_gated_for_authenticated_user(self, auth_client, db): + """planner_export flag controls /en/planner/export.""" + await _set_flag(db, "planner_export", False) + response = await auth_client.get("/en/planner/export") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "coming soon" in html.lower() or "waitlist" in html.lower() + + @pytest.mark.asyncio + async def test_planner_export_accessible_when_enabled(self, auth_client, db): + """Normal planner export page shown when planner_export flag is on.""" + await _set_flag(db, "planner_export", True) + response = await auth_client.get("/en/planner/export") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "coming soon" not in html.lower() + + +# ── lead_unlock gate ────────────────────────────────────────────── + + +class TestLeadUnlockGate: + """lead_unlock flag controls whether the unlock endpoint is reachable.""" + + @pytest.mark.asyncio + async def test_is_flag_disabled_by_default(self, db): + """lead_unlock flag seeded as disabled — is_flag_enabled returns False.""" + await _set_flag(db, "lead_unlock", False) + assert await core.is_flag_enabled("lead_unlock") is False + + @pytest.mark.asyncio + async def test_is_flag_enabled_after_toggle(self, db): + """is_flag_enabled returns True after flag is enabled.""" + await _set_flag(db, "lead_unlock", True) + assert await core.is_flag_enabled("lead_unlock") is True + + @pytest.mark.asyncio + async def test_route_imports_is_flag_enabled(self): + """suppliers/routes.py imports is_flag_enabled (gate is wired up).""" + from padelnomics.suppliers.routes import unlock_lead + import inspect + src = inspect.getsource(unlock_lead) + assert "is_flag_enabled" in src + assert "lead_unlock" in src + assert "403" in src + + +# ── Admin flag toggle routes ────────────────────────────────────── + + +class TestAdminFlagRoutes: + """Admin /admin/flags endpoints require admin role and toggle flags correctly.""" + + @pytest.mark.asyncio + async def test_flags_page_requires_admin(self, client, db): + """Anonymous request to /admin/flags redirects to login.""" + response = await client.get("/admin/flags", follow_redirects=False) + assert response.status_code == 302 + + @pytest.mark.asyncio + async def test_flags_page_accessible_to_admin(self, admin_client, db): + await _seed_all_flags(db) + response = await admin_client.get("/admin/flags") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_flags_page_lists_all_seeded_flags(self, admin_client, db): + await _seed_all_flags(db) + response = await admin_client.get("/admin/flags") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + for flag in ("markets", "payments", "planner_export", "supplier_signup", "lead_unlock"): + assert flag in html + + @pytest.mark.asyncio + async def test_toggle_enables_disabled_flag(self, admin_client, db): + await _set_flag(db, "payments", False) + await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", + "name": "payments", + }) + assert await _flag_value(db, "payments") == 1 + + @pytest.mark.asyncio + async def test_toggle_disables_enabled_flag(self, admin_client, db): + await _set_flag(db, "markets", True) + await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", + "name": "markets", + }) + assert await _flag_value(db, "markets") == 0 + + @pytest.mark.asyncio + async def test_toggle_unknown_flag_redirects_with_flash(self, admin_client, db): + response = await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", + "name": "nonexistent_flag", + }, follow_redirects=True) + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "not found" in html.lower() + + @pytest.mark.asyncio + async def test_toggle_requires_admin(self, client, db): + """Anonymous POST to toggle is rejected.""" + response = await client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", + "name": "markets", + }, follow_redirects=False) + assert response.status_code == 302 + + +# ── Full toggle flow (e2e) ──────────────────────────────────────── + + +class TestFlagToggleFlow: + """End-to-end: admin toggles flag → route behaviour changes immediately.""" + + @pytest.mark.asyncio + async def test_disable_markets_shows_gated_page(self, admin_client, client, db): + """Disable markets → /en/markets shows coming soon. Enable → shows content.""" + # Seed markets as enabled + await _set_flag(db, "markets", True) + response = await client.get("/en/markets") + html = await response.get_data(as_text=True) + assert "coming soon" not in html.lower() + + # Admin disables via toggle + await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", + "name": "markets", + }) + + # Now shows gated page + response = await client.get("/en/markets") + html = await response.get_data(as_text=True) + assert "coming soon" in html.lower() + + @pytest.mark.asyncio + async def test_enable_supplier_signup_passes_through(self, admin_client, client, db): + """Enable supplier_signup → normal wizard shown (no waitlist page).""" + # Start with flag disabled + await _set_flag(db, "supplier_signup", False) + response = await client.get("/en/suppliers/signup") + html = await response.get_data(as_text=True) + # Gated: shows waitlist/supplier promo content + assert "waitlist" in html.lower() or "supplier" in html.lower() + + # Admin enables + await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", + "name": "supplier_signup", + }) + + # Now passes through (wizard is shown) + response = await client.get("/en/suppliers/signup") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "waitlist" not in html.lower() + + @pytest.mark.asyncio + async def test_double_toggle_restores_original_state(self, admin_client, db): + """Toggling a flag twice returns it to its original value.""" + await _set_flag(db, "payments", False) + assert await _flag_value(db, "payments") == 0 + + await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", "name": "payments", + }) + assert await _flag_value(db, "payments") == 1 + + await admin_client.post("/admin/flags/toggle", form={ + "csrf_token": "test_token", "name": "payments", + }) + assert await _flag_value(db, "payments") == 0 diff --git a/web/tests/test_supervisor.py b/web/tests/test_supervisor.py new file mode 100644 index 0000000..8f6eb3f --- /dev/null +++ b/web/tests/test_supervisor.py @@ -0,0 +1,285 @@ +""" +Unit tests for supervisor.py and proxy.py. + +Tests cover pure-Python logic only — no DB, no subprocesses, no network. +DB-dependent functions (is_due, _get_last_success_time) are tested via mocks. + +supervisor.py lives in src/padelnomics/ (not a uv workspace package), so we +add src/ to sys.path before importing. +""" + +import sys +import textwrap +import tomllib +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Load supervisor.py directly by path — avoids clashing with the web app's +# 'padelnomics' namespace (which is the installed web package). +import importlib.util as _ilu + +_SUP_PATH = Path(__file__).parent.parent.parent / "src" / "padelnomics" / "supervisor.py" +_spec = _ilu.spec_from_file_location("padelnomics_supervisor", _SUP_PATH) +sup = _ilu.module_from_spec(_spec) +_spec.loader.exec_module(sup) + +from padelnomics_extract.proxy import ( + load_proxy_urls, + make_round_robin_cycler, + make_sticky_selector, +) + + +# ── load_workflows ──────────────────────────────────────────────── + + +class TestLoadWorkflows: + def test_loads_all_fields(self, tmp_path): + toml = tmp_path / "workflows.toml" + toml.write_text(textwrap.dedent("""\ + [extract_a] + module = "mypkg.extract_a" + schedule = "daily" + + [extract_b] + module = "mypkg.extract_b" + schedule = "weekly" + entry = "run" + depends_on = ["extract_a"] + proxy_mode = "sticky" + """)) + wfs = sup.load_workflows(toml) + assert len(wfs) == 2 + + a = next(w for w in wfs if w["name"] == "extract_a") + assert a["module"] == "mypkg.extract_a" + assert a["schedule"] == "daily" + assert a["entry"] == "main" # default + assert a["depends_on"] == [] # default + assert a["proxy_mode"] == "round-robin" # default + + b = next(w for w in wfs if w["name"] == "extract_b") + assert b["entry"] == "run" + assert b["depends_on"] == ["extract_a"] + assert b["proxy_mode"] == "sticky" + + def test_raises_on_missing_module(self, tmp_path): + toml = tmp_path / "bad.toml" + toml.write_text("[wf]\nschedule = 'daily'\n") + with pytest.raises(AssertionError, match="missing 'module'"): + sup.load_workflows(toml) + + def test_raises_on_missing_schedule(self, tmp_path): + toml = tmp_path / "bad.toml" + toml.write_text("[wf]\nmodule = 'mypkg.wf'\n") + with pytest.raises(AssertionError, match="missing 'schedule'"): + sup.load_workflows(toml) + + def test_raises_if_file_missing(self, tmp_path): + with pytest.raises(AssertionError, match="not found"): + sup.load_workflows(tmp_path / "nonexistent.toml") + + +# ── resolve_schedule ────────────────────────────────────────────── + + +class TestResolveSchedule: + def test_maps_named_presets(self): + assert sup.resolve_schedule("hourly") == "0 * * * *" + assert sup.resolve_schedule("daily") == "0 5 * * *" + assert sup.resolve_schedule("weekly") == "0 3 * * 1" + assert sup.resolve_schedule("monthly") == "0 4 1 * *" + + def test_passes_through_raw_cron(self): + expr = "0 6-23 * * *" + assert sup.resolve_schedule(expr) == expr + + def test_unknown_name_passes_through(self): + assert sup.resolve_schedule("quarterly") == "quarterly" + + +# ── is_due ──────────────────────────────────────────────────────── + + +class TestIsDue: + def _wf(self, schedule="daily", name="test_wf"): + return {"name": name, "schedule": schedule} + + def test_never_ran_is_due(self): + conn = MagicMock() + with patch.object(sup, "_get_last_success_time", return_value=None): + assert sup.is_due(conn, self._wf()) is True + + def test_ran_after_last_trigger_is_not_due(self): + """Last run was AFTER the most recent trigger — not due.""" + conn = MagicMock() + # Use a daily schedule — trigger fires at 05:00 UTC + # Simulate last success = today at 06:00, so trigger already covered + now = datetime.now(UTC) + last_success = now.replace(hour=6, minute=0, second=0, microsecond=0) + with patch.object(sup, "_get_last_success_time", return_value=last_success): + assert sup.is_due(conn, self._wf(schedule="daily")) is False + + def test_ran_before_last_trigger_is_due(self): + """Last run was BEFORE the most recent trigger — due again.""" + conn = MagicMock() + # Monthly fires on the 1st at 04:00 — simulate running last month + last_success = datetime.now(UTC) - timedelta(days=35) + with patch.object(sup, "_get_last_success_time", return_value=last_success): + assert sup.is_due(conn, self._wf(schedule="monthly")) is True + + +# ── topological_waves ───────────────────────────────────────────── + + +class TestTopologicalWaves: + def _wf(self, name, depends_on=None): + return {"name": name, "depends_on": depends_on or []} + + def test_no_deps_single_wave(self): + wfs = [self._wf("a"), self._wf("b"), self._wf("c")] + waves = sup.topological_waves(wfs) + assert len(waves) == 1 + assert {w["name"] for w in waves[0]} == {"a", "b", "c"} + + def test_simple_chain_two_waves(self): + wfs = [self._wf("a"), self._wf("b", depends_on=["a"])] + waves = sup.topological_waves(wfs) + assert len(waves) == 2 + assert waves[0][0]["name"] == "a" + assert waves[1][0]["name"] == "b" + + def test_diamond_three_waves(self): + """a → b,c → d""" + wfs = [ + self._wf("a"), + self._wf("b", depends_on=["a"]), + self._wf("c", depends_on=["a"]), + self._wf("d", depends_on=["b", "c"]), + ] + waves = sup.topological_waves(wfs) + assert len(waves) == 3 + assert waves[0][0]["name"] == "a" + assert {w["name"] for w in waves[1]} == {"b", "c"} + assert waves[2][0]["name"] == "d" + + def test_dep_outside_due_set_ignored(self): + """Dependency not in the due set is treated as satisfied.""" + wfs = [self._wf("b", depends_on=["a"])] # "a" not in due set + waves = sup.topological_waves(wfs) + assert len(waves) == 1 + assert waves[0][0]["name"] == "b" + + def test_circular_dep_raises(self): + wfs = [ + self._wf("a", depends_on=["b"]), + self._wf("b", depends_on=["a"]), + ] + with pytest.raises(AssertionError, match="Circular dependency"): + sup.topological_waves(wfs) + + def test_empty_list_returns_empty(self): + assert sup.topological_waves([]) == [] + + def test_real_workflows_toml(self): + """The actual workflows.toml in the repo parses and produces valid waves.""" + repo_root = Path(__file__).parent.parent.parent + wf_path = repo_root / "infra" / "supervisor" / "workflows.toml" + if not wf_path.exists(): + pytest.skip("workflows.toml not found") + wfs = sup.load_workflows(wf_path) + waves = sup.topological_waves(wfs) + # playtomic_availability must come after playtomic_tenants + all_names = [w["name"] for wave in waves for w in wave] + tenants_idx = all_names.index("playtomic_tenants") + avail_idx = all_names.index("playtomic_availability") + assert tenants_idx < avail_idx + + +# ── proxy.py ───────────────────────────────────────────────────── + + +class TestLoadProxyUrls: + def test_returns_empty_when_unset(self, monkeypatch): + monkeypatch.delenv("PROXY_URLS", raising=False) + assert load_proxy_urls() == [] + + def test_parses_comma_separated_urls(self, monkeypatch): + monkeypatch.setenv( + "PROXY_URLS", + "http://p1:8080,http://p2:8080,http://p3:8080", + ) + urls = load_proxy_urls() + assert urls == ["http://p1:8080", "http://p2:8080", "http://p3:8080"] + + def test_strips_whitespace(self, monkeypatch): + monkeypatch.setenv("PROXY_URLS", " http://p1:8080 , http://p2:8080 ") + urls = load_proxy_urls() + assert urls == ["http://p1:8080", "http://p2:8080"] + + def test_ignores_empty_segments(self, monkeypatch): + monkeypatch.setenv("PROXY_URLS", "http://p1:8080,,http://p2:8080,") + urls = load_proxy_urls() + assert urls == ["http://p1:8080", "http://p2:8080"] + + +class TestRoundRobinCycler: + def test_returns_none_callable_when_no_proxies(self): + fn = make_round_robin_cycler([]) + assert fn() is None + + def test_cycles_through_proxies(self): + urls = ["http://p1", "http://p2", "http://p3"] + fn = make_round_robin_cycler(urls) + results = [fn() for _ in range(6)] + assert results == ["http://p1", "http://p2", "http://p3"] * 2 + + def test_thread_safe_independent_calls(self): + """Concurrent calls each get a proxy — no exceptions.""" + import threading + urls = ["http://p1", "http://p2"] + fn = make_round_robin_cycler(urls) + results = [] + lock = threading.Lock() + + def worker(): + proxy = fn() + with lock: + results.append(proxy) + + threads = [threading.Thread(target=worker) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(results) == 10 + assert all(r in urls for r in results) + + +class TestStickySelectorProxy: + def test_returns_none_callable_when_no_proxies(self): + fn = make_sticky_selector([]) + assert fn("any_key") is None + + def test_same_key_always_same_proxy(self): + urls = ["http://p1", "http://p2", "http://p3"] + fn = make_sticky_selector(urls) + proxy = fn("tenant_abc") + for _ in range(10): + assert fn("tenant_abc") == proxy + + def test_different_keys_can_map_to_different_proxies(self): + urls = ["http://p1", "http://p2", "http://p3"] + fn = make_sticky_selector(urls) + results = {fn(f"key_{i}") for i in range(30)} + assert len(results) > 1 # distribution across proxies + + def test_all_results_are_valid_proxies(self): + urls = ["http://p1", "http://p2"] + fn = make_sticky_selector(urls) + for i in range(20): + assert fn(f"key_{i}") in urls diff --git a/web/tests/test_waitlist.py b/web/tests/test_waitlist.py index d8c352d..4f080af 100644 --- a/web/tests/test_waitlist.py +++ b/web/tests/test_waitlist.py @@ -251,13 +251,16 @@ class TestAuthRoutes: @pytest.mark.asyncio async def test_normal_signup_when_waitlist_disabled(self, client, db): - """Normal signup flow when WAITLIST_MODE is false.""" - with patch.object(core.config, "WAITLIST_MODE", False): - response = await client.get("/auth/signup") - assert response.status_code == 200 - html = await response.get_data(as_text=True) - # Should see normal signup form, not waitlist form - assert "Create Free Account" in html or "Sign Up" in html + """Normal signup flow when payments flag is enabled (WAITLIST_MODE replaced by feature flag).""" + await db.execute( + "INSERT OR REPLACE INTO feature_flags (name, enabled, description) VALUES ('payments', 1, '')" + ) + await db.commit() + response = await client.get("/auth/signup") + assert response.status_code == 200 + html = await response.get_data(as_text=True) + # Should see normal signup form, not waitlist form + assert "Create Free Account" in html or "Sign Up" in html @pytest.mark.asyncio async def test_shows_waitlist_form_when_enabled(self, client, db): @@ -713,13 +716,16 @@ class TestWaitlistGateDecorator: """Test waitlist_gate decorator via integration tests.""" @pytest.mark.asyncio - async def test_passes_through_when_waitlist_disabled(self, client): - """Decorator passes through to normal flow when WAITLIST_MODE=false.""" - with patch.object(core.config, "WAITLIST_MODE", False): - response = await client.get("/auth/signup") - html = await response.get_data(as_text=True) - # Should see normal signup, not waitlist - assert "waitlist" not in html.lower() or "create" in html.lower() + async def test_passes_through_when_waitlist_disabled(self, client, db): + """feature_gate passes through to normal form when payments flag is enabled.""" + await db.execute( + "INSERT OR REPLACE INTO feature_flags (name, enabled, description) VALUES ('payments', 1, '')" + ) + await db.commit() + response = await client.get("/auth/signup") + html = await response.get_data(as_text=True) + # Should see normal signup, not waitlist + assert "waitlist" not in html.lower() or "create" in html.lower() @pytest.mark.asyncio async def test_intercepts_get_when_waitlist_enabled(self, client): @@ -940,21 +946,21 @@ class TestIntegration: @pytest.mark.asyncio async def test_toggle_off_reverts_to_normal_signup(self, client, db): - """Setting WAITLIST_MODE=false reverts to normal signup flow.""" - # First, enable waitlist mode - with patch.object(core.config, "WAITLIST_MODE", True): - response = await client.get("/auth/signup") - html = await response.get_data(as_text=True) - assert "waitlist" in html.lower() + """Disabling payments flag shows waitlist; enabling it shows normal signup.""" + # payments=0 (default) → shows waitlist page + response = await client.get("/auth/signup") + html = await response.get_data(as_text=True) + assert "waitlist" in html.lower() - # Then, disable waitlist mode - with patch.object(core.config, "WAITLIST_MODE", False): - response = await client.get("/auth/signup") - html = await response.get_data(as_text=True) - # Should see normal signup, not waitlist - assert "create" in html.lower() or "sign up" in html.lower() - # Should NOT see waitlist messaging - assert "join the waitlist" not in html.lower() + # Enable payments flag → shows normal signup + await db.execute( + "INSERT OR REPLACE INTO feature_flags (name, enabled, description) VALUES ('payments', 1, '')" + ) + await db.commit() + response = await client.get("/auth/signup") + html = await response.get_data(as_text=True) + assert "create" in html.lower() or "sign up" in html.lower() + assert "join the waitlist" not in html.lower() @pytest.mark.asyncio async def test_same_email_different_intents_both_captured(self, client, db):