Merge branch 'worktree-supervisor-flags'
Python supervisor + DB-backed feature flags - supervisor.py replaces supervisor.sh (topological wave scheduling, croniter) - workflows.toml workflow registry (5 extractors, cron presets, depends_on) - proxy.py round-robin + sticky proxy rotation via PROXY_URLS - Feature flags: migration 0019, is_flag_enabled(), feature_gate() decorator - Admin /admin/flags UI with toggle (admin-only) - lead_unlock gate on unlock_lead route - 59 new tests (test_supervisor.py + test_feature_flags.py) - Fix is_flag_enabled bug (fetch_one instead of execute_fetchone) # Conflicts: # CHANGELOG.md # web/pyproject.toml
This commit is contained in:
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"duckdb>=1.0.0",
|
||||
"pyarrow>=23.0.1",
|
||||
"pyyaml>=6.0",
|
||||
"croniter>=6.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"google-api-python-client>=2.100.0",
|
||||
"google-auth>=2.23.0",
|
||||
|
||||
@@ -835,6 +835,46 @@ async def supplier_tier(supplier_id: int):
|
||||
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature Flags
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/flags")
|
||||
@role_required("admin")
|
||||
async def flags():
|
||||
"""Feature flags management."""
|
||||
flag_list = await fetch_all("SELECT * FROM feature_flags ORDER BY name")
|
||||
return await render_template("admin/flags.html", flags=flag_list, admin_page="flags")
|
||||
|
||||
|
||||
@bp.route("/flags/toggle", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def flag_toggle():
|
||||
"""Toggle a feature flag on/off."""
|
||||
form = await request.form
|
||||
flag_name = form.get("name", "").strip()
|
||||
if not flag_name:
|
||||
await flash("Flag name required.", "error")
|
||||
return redirect(url_for("admin.flags"))
|
||||
|
||||
# Get current state and flip it
|
||||
row = await fetch_one("SELECT enabled FROM feature_flags WHERE name = ?", (flag_name,))
|
||||
if not row:
|
||||
await flash(f"Flag '{flag_name}' not found.", "error")
|
||||
return redirect(url_for("admin.flags"))
|
||||
|
||||
new_enabled = 0 if row["enabled"] else 1
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?",
|
||||
(new_enabled, now, flag_name),
|
||||
)
|
||||
state = "enabled" if new_enabled else "disabled"
|
||||
await flash(f"Flag '{flag_name}' {state}.", "success")
|
||||
return redirect(url_for("admin.flags"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feedback Management
|
||||
# =============================================================================
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">System</div>
|
||||
<a href="{{ url_for('admin.flags') }}" class="{% if admin_page == 'flags' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"/></svg>
|
||||
Flags
|
||||
</a>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||
Tasks
|
||||
|
||||
72
web/src/padelnomics/admin/templates/admin/flags.html
Normal file
72
web/src/padelnomics/admin/templates/admin/flags.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.flags-table { width: 100%; border-collapse: collapse; }
|
||||
.flags-table th, .flags-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #E2E8F0; }
|
||||
.flags-table th { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.flag-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
|
||||
.flag-badge--on { background: #DCFCE7; color: #166534; }
|
||||
.flag-badge--off { background: #FEE2E2; color: #991B1B; }
|
||||
.flag-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.flag-dot--on { background: #22C55E; }
|
||||
.flag-dot--off { background: #EF4444; }
|
||||
.flag-toggle-btn {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 0.8125rem; font-weight: 500;
|
||||
border: 1px solid #E2E8F0; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.flag-toggle-btn--enable { background: #F0FDF4; color: #166534; border-color: #BBF7D0; }
|
||||
.flag-toggle-btn--enable:hover { background: #DCFCE7; }
|
||||
.flag-toggle-btn--disable { background: #FEF2F2; color: #991B1B; border-color: #FECACA; }
|
||||
.flag-toggle-btn--disable:hover { background: #FEE2E2; }
|
||||
.flag-name { font-weight: 600; color: #0F172A; }
|
||||
.flag-desc { font-size: 0.8125rem; color: #64748B; }
|
||||
.flag-updated { font-size: 0.75rem; color: #94A3B8; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<h1 style="font-size:1.5rem; font-weight:700; margin-bottom:0.5rem;">Feature Flags</h1>
|
||||
<p style="color:#64748B; margin-bottom:1.5rem; font-size:0.875rem;">
|
||||
Toggle features on/off without redeployment. Changes take effect immediately.
|
||||
</p>
|
||||
|
||||
<table class="flags-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Flag</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Last Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in flags %}
|
||||
<tr>
|
||||
<td><span class="flag-name">{{ f.name }}</span></td>
|
||||
<td><span class="flag-desc">{{ f.description or '—' }}</span></td>
|
||||
<td>
|
||||
{% if f.enabled %}
|
||||
<span class="flag-badge flag-badge--on"><span class="flag-dot flag-dot--on"></span> Enabled</span>
|
||||
{% else %}
|
||||
<span class="flag-badge flag-badge--off"><span class="flag-dot flag-dot--off"></span> Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="flag-updated">{{ f.updated_at or '—' }}</span></td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin.flag_toggle') }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="name" value="{{ f.name }}">
|
||||
{% if f.enabled %}
|
||||
<button type="submit" class="flag-toggle-btn flag-toggle-btn--disable">Disable</button>
|
||||
{% else %}
|
||||
<button type="submit" class="flag-toggle-btn flag-toggle-btn--enable">Enable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||
|
||||
from .analytics import close_analytics_db, open_analytics_db
|
||||
from .core import close_db, config, get_csrf_token, init_db, setup_request_id
|
||||
from .core import close_db, config, get_csrf_token, init_db, is_flag_enabled, setup_request_id
|
||||
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
|
||||
|
||||
_ASSET_VERSION = str(int(time.time()))
|
||||
@@ -224,6 +224,7 @@ def create_app() -> Quart:
|
||||
"lang": effective_lang,
|
||||
"t": get_translations(effective_lang),
|
||||
"v": _ASSET_VERSION,
|
||||
"flag": is_flag_enabled,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -14,9 +14,10 @@ from ..core import (
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
feature_gate,
|
||||
fetch_one,
|
||||
is_disposable_email,
|
||||
waitlist_gate,
|
||||
is_flag_enabled,
|
||||
)
|
||||
from ..i18n import SUPPORTED_LANGS, get_translations
|
||||
|
||||
@@ -248,14 +249,14 @@ async def login():
|
||||
|
||||
@bp.route("/signup", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
@feature_gate("payments", "waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
async def signup():
|
||||
"""Signup page - same as login but with different messaging."""
|
||||
if g.get("user"):
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# Waitlist POST handling
|
||||
if config.WAITLIST_MODE and request.method == "POST":
|
||||
# Waitlist POST handling (when payments flag is disabled)
|
||||
if not await is_flag_enabled("payments") and request.method == "POST":
|
||||
_t = get_translations(g.lang)
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
|
||||
@@ -9,7 +9,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from markupsafe import Markup
|
||||
from quart import Blueprint, abort, render_template, request
|
||||
|
||||
from ..core import capture_waitlist_email, config, csrf_protect, fetch_all, fetch_one, waitlist_gate
|
||||
from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
@@ -106,10 +106,11 @@ async def bake_scenario_cards(html: str, lang: str = "en") -> str:
|
||||
|
||||
@bp.route("/markets", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("markets_waitlist.html")
|
||||
@feature_gate("markets", "markets_waitlist.html")
|
||||
async def markets():
|
||||
"""Hub page: search + country/region filter for articles."""
|
||||
if config.WAITLIST_MODE and request.method == "POST":
|
||||
from ..core import is_flag_enabled
|
||||
if not await is_flag_enabled("markets") and request.method == "POST":
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
if email and "@" in email:
|
||||
@@ -147,7 +148,7 @@ async def markets():
|
||||
|
||||
|
||||
@bp.route("/markets/results")
|
||||
@waitlist_gate("markets_waitlist.html")
|
||||
@feature_gate("markets", "markets_waitlist.html")
|
||||
async def market_results():
|
||||
"""HTMX partial: filtered article cards."""
|
||||
q = request.args.get("q", "").strip()
|
||||
|
||||
@@ -724,27 +724,60 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
||||
return decorator
|
||||
|
||||
|
||||
def waitlist_gate(template: str, **extra_context):
|
||||
"""Parameterized decorator that intercepts GET requests when WAITLIST_MODE is enabled.
|
||||
async def is_flag_enabled(name: str, default: bool = False) -> bool:
|
||||
"""Check if a feature flag is enabled. Falls back to default if flag doesn't exist.
|
||||
|
||||
If WAITLIST_MODE is true and the request is a GET, renders the given template
|
||||
instead of calling the wrapped function. POST requests and non-waitlist mode
|
||||
always pass through.
|
||||
Reads from the feature_flags table. Flags are toggled via the admin UI
|
||||
and take effect immediately — no restart needed.
|
||||
"""
|
||||
row = await fetch_one(
|
||||
"SELECT enabled FROM feature_flags WHERE name = ?", (name,)
|
||||
)
|
||||
if row is None:
|
||||
return default
|
||||
return bool(row["enabled"])
|
||||
|
||||
|
||||
def feature_gate(flag_name: str, waitlist_template: str, **extra_context):
|
||||
"""Gate a route behind a feature flag. Shows waitlist template if flag is disabled.
|
||||
|
||||
Replaces the old waitlist_gate() which used a global WAITLIST_MODE env var.
|
||||
This checks per-feature flags from the database instead.
|
||||
|
||||
Args:
|
||||
template: Template path to render in waitlist mode (e.g., "waitlist.html")
|
||||
**extra_context: Additional context variables to pass to template.
|
||||
Values can be callables (evaluated at request time) or static.
|
||||
flag_name: Name of the feature flag (e.g., "payments", "supplier_signup")
|
||||
waitlist_template: Template to render when the flag is OFF and method is GET
|
||||
**extra_context: Additional context. Values can be callables (evaluated at request time).
|
||||
|
||||
Usage:
|
||||
@bp.route("/signup", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
@feature_gate("payments", "waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
async def signup():
|
||||
# POST handling and normal signup code here
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if not await is_flag_enabled(flag_name) and request.method == "GET":
|
||||
ctx = {}
|
||||
for key, val in extra_context.items():
|
||||
ctx[key] = val() if callable(val) else val
|
||||
return await render_template(waitlist_template, **ctx)
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def waitlist_gate(template: str, **extra_context):
|
||||
"""DEPRECATED: Use feature_gate() instead. Kept for backwards compatibility.
|
||||
|
||||
Intercepts GET requests when WAITLIST_MODE is enabled.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add feature_flags table for per-feature gating.
|
||||
|
||||
Replaces the global WAITLIST_MODE env var with granular per-feature flags
|
||||
that can be toggled at runtime via the admin UI.
|
||||
"""
|
||||
|
||||
|
||||
def up(conn):
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# Seed initial flags — markets live, everything else behind waitlist
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO feature_flags (name, enabled, description) VALUES (?, ?, ?)",
|
||||
[
|
||||
("markets", 1, "Market/SEO content pages"),
|
||||
("payments", 0, "Paddle billing & checkout"),
|
||||
("planner_export", 0, "Business plan PDF export"),
|
||||
("supplier_signup", 0, "Supplier onboarding wizard"),
|
||||
("lead_unlock", 0, "Lead credit purchase & unlock"),
|
||||
],
|
||||
)
|
||||
@@ -14,10 +14,10 @@ from ..core import (
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
feature_gate,
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
|
||||
@@ -596,7 +596,7 @@ async def set_default(scenario_id: int):
|
||||
|
||||
@bp.route("/export")
|
||||
@login_required
|
||||
@waitlist_gate("export_waitlist.html")
|
||||
@feature_gate("planner_export", "export_waitlist.html")
|
||||
async def export():
|
||||
"""Export options page — language, scenario picker, pricing."""
|
||||
scenarios = await get_scenarios(g.user["id"])
|
||||
|
||||
@@ -15,8 +15,9 @@ from ..core import (
|
||||
execute,
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
feature_gate,
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
is_flag_enabled,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
|
||||
@@ -243,8 +244,8 @@ def _lead_tier_required(f):
|
||||
|
||||
|
||||
@bp.route("/signup")
|
||||
@waitlist_gate(
|
||||
"suppliers/waitlist.html",
|
||||
@feature_gate(
|
||||
"supplier_signup", "suppliers/waitlist.html",
|
||||
plan=lambda: request.args.get("plan", "supplier_growth"),
|
||||
plans=lambda: PLAN_FEATURES,
|
||||
)
|
||||
@@ -574,6 +575,9 @@ async def lead_feed():
|
||||
@csrf_protect
|
||||
async def unlock_lead(token: str):
|
||||
"""Spend credits to unlock a lead. Returns full-details card via HTMX."""
|
||||
if not await is_flag_enabled("lead_unlock"):
|
||||
return jsonify({"error": "Lead unlock is not available yet."}), 403
|
||||
|
||||
from ..credits import InsufficientCredits
|
||||
from ..credits import unlock_lead as do_unlock
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
431
web/tests/test_feature_flags.py
Normal file
431
web/tests/test_feature_flags.py
Normal 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
|
||||
285
web/tests/test_supervisor.py
Normal file
285
web/tests/test_supervisor.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user