test: e2e + unit tests for supervisor, proxy, and feature flags

- test_supervisor.py: 28 tests covering load_workflows, resolve_schedule,
  is_due, topological_waves, and proxy round-robin / sticky selection
- test_feature_flags.py: 31 tests covering migration 0019, is_flag_enabled,
  feature_gate decorator, admin toggle routes, and full toggle e2e flows
- conftest.py: seed feature flags with production defaults (markets=1,
  others=0) so all routes behave consistently in tests
- Fix is_flag_enabled bug: replace non-existent db.execute_fetchone()
  with fetch_one() helper
- Update 4 test_waitlist / test_businessplan tests that relied on
  WAITLIST_MODE patches — now enable the relevant DB flag instead

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 15:26:40 +01:00
parent a1faddbed6
commit b5c9a4e573
6 changed files with 770 additions and 31 deletions

View File

@@ -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