refactor: flatten padelnomics/padelnomics/ → repo root

git mv all tracked files from the nested padelnomics/ workspace
directory to the git repo root. Merged .gitignore files.
No code changes — pure path rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 00:44:40 +01:00
parent 5e471567b9
commit 4ae00b35d1
235 changed files with 45 additions and 42 deletions

199
web/tests/conftest.py Normal file
View File

@@ -0,0 +1,199 @@
"""
Shared test fixtures for the Padelnomics test suite.
"""
import hashlib
import hmac
import sqlite3
import tempfile
import time
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
from padelnomics import core
from padelnomics.app import create_app
from padelnomics.migrations.migrate import migrate
_SCHEMA_CACHE = None
def _get_schema_ddl():
"""Run all migrations once against a temp DB and cache the resulting DDL."""
global _SCHEMA_CACHE
if _SCHEMA_CACHE is not None:
return _SCHEMA_CACHE
tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db")
migrate(tmp_db)
tmp_conn = sqlite3.connect(tmp_db)
rows = tmp_conn.execute(
"SELECT sql FROM sqlite_master"
" WHERE sql IS NOT NULL"
" AND name NOT LIKE 'sqlite_%'"
" AND name NOT LIKE '%_fts_%'" # FTS5 shadow tables (created by VIRTUAL TABLE)
" AND name != '_migrations'"
" ORDER BY rowid"
).fetchall()
tmp_conn.close()
_SCHEMA_CACHE = ";\n".join(r[0] for r in rows) + ";"
return _SCHEMA_CACHE
# ── Database ─────────────────────────────────────────────────
@pytest.fixture
async def db():
"""In-memory SQLite with full schema from replaying migrations."""
schema_ddl = _get_schema_ddl()
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_ddl)
await conn.commit()
original_db = core._db
core._db = conn
yield conn
core._db = original_db
await conn.close()
# ── App & client ─────────────────────────────────────────────
@pytest.fixture
async def app(db):
"""Quart app with DB already initialized (init_db/close_db patched to no-op)."""
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock):
application = create_app()
application.config["TESTING"] = True
yield application
@pytest.fixture
async def client(app):
"""Unauthenticated test client."""
async with app.test_client() as c:
yield c
# ── Users ────────────────────────────────────────────────────
@pytest.fixture
async def test_user(db):
"""Create a test user, return dict with id/email/name."""
now = datetime.utcnow().isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("test@example.com", "Test User", now),
) as cursor:
user_id = cursor.lastrowid
await db.commit()
return {"id": user_id, "email": "test@example.com", "name": "Test User"}
@pytest.fixture
async def auth_client(app, test_user):
"""Test client with session['user_id'] pre-set."""
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
yield c
# ── Subscriptions ────────────────────────────────────────────
@pytest.fixture
def create_subscription(db):
"""Factory: create a subscription row for a user."""
async def _create(
user_id: int,
plan: str = "pro",
status: str = "active",
provider_customer_id: str = "ctm_test123",
provider_subscription_id: str = "sub_test456",
current_period_end: str = "2025-03-01T00:00:00Z",
) -> int:
now = datetime.utcnow().isoformat()
# Create billing_customers record if provider_customer_id given
if provider_customer_id:
await db.execute(
"""INSERT OR IGNORE INTO billing_customers
(user_id, provider_customer_id, created_at) VALUES (?, ?, ?)""",
(user_id, provider_customer_id, now),
)
async with db.execute(
"""INSERT INTO subscriptions
(user_id, plan, status,
provider_subscription_id, current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, provider_subscription_id,
current_period_end, now, now),
) as cursor:
sub_id = cursor.lastrowid
await db.commit()
return sub_id
return _create
# ── Config ───────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def patch_config():
"""Set test Paddle config values."""
original_values = {}
test_values = {
"PADDLE_API_KEY": "test_api_key_123",
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
"BASE_URL": "http://localhost:5000",
"DEBUG": True,
"WAITLIST_MODE": False,
}
for key, val in test_values.items():
original_values[key] = getattr(core.config, key, None)
setattr(core.config, key, val)
yield
for key, val in original_values.items():
setattr(core.config, key, val)
# ── Webhook helpers ──────────────────────────────────────────
def make_webhook_payload(
event_type: str,
subscription_id: str = "sub_test456",
customer_id: str = "ctm_test123",
user_id: str = "1",
plan: str = "starter",
status: str = "active",
ends_at: str = "2025-03-01T00:00:00.000000Z",
) -> dict:
"""Build a Paddle webhook payload dict."""
return {
"event_type": event_type,
"data": {
"id": subscription_id,
"status": status,
"customer_id": customer_id,
"custom_data": {"user_id": user_id, "plan": plan},
"current_billing_period": {
"starts_at": "2025-02-01T00:00:00.000000Z",
"ends_at": ends_at,
},
},
}
def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str:
"""Build a Paddle-format signature header: ts=<unix>;h1=<hmac_sha256>."""
ts = str(int(time.time()))
data = f"{ts}:{payload_bytes.decode()}".encode()
h1 = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
return f"ts={ts};h1={h1}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,304 @@
"""
Unit tests for billing SQL helpers, feature/limit access, and plan determination.
"""
import pytest
from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from padelnomics.billing.routes import (
can_access_feature,
get_subscription,
get_subscription_by_provider_id,
is_within_limits,
update_subscription_status,
upsert_billing_customer,
upsert_subscription,
)
from padelnomics.core import config
# ════════════════════════════════════════════════════════════
# get_subscription
# ════════════════════════════════════════════════════════════
class TestGetSubscription:
async def test_returns_none_for_user_without_subscription(self, db, test_user):
result = await get_subscription(test_user["id"])
assert result is None
async def test_returns_subscription_for_user(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
result = await get_subscription(test_user["id"])
assert result is not None
assert result["plan"] == "pro"
assert result["status"] == "active"
assert result["user_id"] == test_user["id"]
# ════════════════════════════════════════════════════════════
# upsert_subscription
# ════════════════════════════════════════════════════════════
class TestUpsertSubscription:
async def test_insert_new_subscription(self, db, test_user):
await upsert_billing_customer(test_user["id"], "cust_abc")
sub_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_subscription_id="sub_xyz",
current_period_end="2025-06-01T00:00:00Z",
)
assert sub_id > 0
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["status"] == "active"
assert row["provider_subscription_id"] == "sub_xyz"
assert row["current_period_end"] == "2025-06-01T00:00:00Z"
async def test_update_existing_subscription(self, db, test_user, create_subscription):
original_id = await create_subscription(
test_user["id"], plan="starter", status="active",
provider_subscription_id="sub_old",
)
returned_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_subscription_id="sub_old",
)
assert returned_id == original_id
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["provider_subscription_id"] == "sub_old"
async def test_upsert_with_none_period_end(self, db, test_user):
await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_subscription_id="sub_1",
current_period_end=None,
)
row = await get_subscription(test_user["id"])
assert row["current_period_end"] is None
# ════════════════════════════════════════════════════════════
# get_subscription_by_provider_id
# ════════════════════════════════════════════════════════════
class TestGetSubscriptionByProviderId:
async def test_returns_none_for_unknown_id(self, db):
result = await get_subscription_by_provider_id("nonexistent")
assert result is None
async def test_finds_by_provider_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_findme")
result = await get_subscription_by_provider_id("sub_findme")
assert result is not None
assert result["user_id"] == test_user["id"]
# ════════════════════════════════════════════════════════════
# update_subscription_status
# ════════════════════════════════════════════════════════════
class TestUpdateSubscriptionStatus:
async def test_updates_status(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_upd")
await update_subscription_status("sub_upd", status="cancelled")
row = await get_subscription(test_user["id"])
assert row["status"] == "cancelled"
assert row["updated_at"] is not None
async def test_updates_extra_fields(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_extra")
await update_subscription_status(
"sub_extra",
status="active",
plan="starter",
current_period_end="2026-01-01T00:00:00Z",
)
row = await get_subscription(test_user["id"])
assert row["status"] == "active"
assert row["plan"] == "starter"
assert row["current_period_end"] == "2026-01-01T00:00:00Z"
async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_known", status="active")
await update_subscription_status("sub_unknown", status="expired")
row = await get_subscription(test_user["id"])
assert row["status"] == "active" # unchanged
# ════════════════════════════════════════════════════════════
# can_access_feature
# ════════════════════════════════════════════════════════════
class TestCanAccessFeature:
async def test_no_subscription_gets_free_features(self, db, test_user):
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is False
assert await can_access_feature(test_user["id"], "api") is False
async def test_active_pro_gets_all_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "api") is True
assert await can_access_feature(test_user["id"], "priority_support") is True
async def test_active_starter_gets_starter_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "api") is False
async def test_cancelled_still_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
assert await can_access_feature(test_user["id"], "api") is True
async def test_on_trial_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
assert await can_access_feature(test_user["id"], "api") is True
async def test_expired_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await can_access_feature(test_user["id"], "api") is False
assert await can_access_feature(test_user["id"], "basic") is True
async def test_past_due_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
assert await can_access_feature(test_user["id"], "export") is False
async def test_paused_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="paused")
assert await can_access_feature(test_user["id"], "api") is False
async def test_nonexistent_feature_returns_false(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "teleportation") is False
# ════════════════════════════════════════════════════════════
# is_within_limits
# ════════════════════════════════════════════════════════════
class TestIsWithinLimits:
async def test_free_user_within_limits(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 50) is True
async def test_free_user_at_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 100) is False
async def test_free_user_over_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 150) is False
async def test_pro_unlimited(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await is_within_limits(test_user["id"], "items", 999999) is True
assert await is_within_limits(test_user["id"], "api_calls", 999999) is True
async def test_starter_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await is_within_limits(test_user["id"], "items", 999) is True
assert await is_within_limits(test_user["id"], "items", 1000) is False
async def test_expired_pro_gets_free_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await is_within_limits(test_user["id"], "items", 100) is False
async def test_unknown_resource_returns_false(self, db, test_user):
assert await is_within_limits(test_user["id"], "unicorns", 0) is False
# ════════════════════════════════════════════════════════════
# Parameterized: status x feature access matrix
# ════════════════════════════════════════════════════════════
STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
FEATURES = ["basic", "export", "api", "priority_support"]
ACTIVE_STATUSES = {"active", "on_trial", "cancelled"}
@pytest.mark.parametrize("status", STATUSES)
@pytest.mark.parametrize("feature", FEATURES)
async def test_feature_access_matrix(db, test_user, create_subscription, status, feature):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
result = await can_access_feature(test_user["id"], feature)
if status in ACTIVE_STATUSES:
expected = feature in config.PLAN_FEATURES["pro"]
else:
expected = feature in config.PLAN_FEATURES["free"]
assert result == expected, f"status={status}, feature={feature}"
# ════════════════════════════════════════════════════════════
# Parameterized: plan x feature matrix (active status)
# ════════════════════════════════════════════════════════════
PLANS = ["free", "starter", "pro"]
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("feature", FEATURES)
async def test_plan_feature_matrix(db, test_user, create_subscription, plan, feature):
if plan != "free":
await create_subscription(test_user["id"], plan=plan, status="active")
result = await can_access_feature(test_user["id"], feature)
expected = feature in config.PLAN_FEATURES.get(plan, [])
assert result == expected, f"plan={plan}, feature={feature}"
# ════════════════════════════════════════════════════════════
# Parameterized: plan x resource limit boundaries
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("resource,at_limit", [
("items", 100),
("items", 1000),
("api_calls", 1000),
("api_calls", 10000),
])
async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resource, at_limit):
if plan != "free":
await create_subscription(test_user["id"], plan=plan, status="active")
plan_limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
result = await is_within_limits(test_user["id"], resource, at_limit)
if plan_limit == -1:
assert result is True
elif at_limit < plan_limit:
assert result is True
else:
assert result is False
# ════════════════════════════════════════════════════════════
# Hypothesis: limit boundaries
# ════════════════════════════════════════════════════════════
class TestLimitsHypothesis:
@given(count=st.integers(min_value=0, max_value=10000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_free_limit_boundary_items(self, db, test_user, count):
result = await is_within_limits(test_user["id"], "items", count)
assert result == (count < 100)
@given(count=st.integers(min_value=0, max_value=100000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_pro_always_within_limits(self, db, test_user, create_subscription, count):
# Use upsert to avoid duplicate inserts across Hypothesis examples
await upsert_subscription(
user_id=test_user["id"], plan="pro", status="active",
provider_subscription_id="sub_hyp",
)
result = await is_within_limits(test_user["id"], "items", count)
assert result is True

View File

@@ -0,0 +1,222 @@
"""
Route integration tests for Paddle billing endpoints.
Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK.
"""
from unittest.mock import MagicMock, patch
import pytest
CHECKOUT_PLAN = "starter"
# ════════════════════════════════════════════════════════════
# Public routes (pricing, success)
# ════════════════════════════════════════════════════════════
class TestPricingPage:
async def test_accessible_without_auth(self, client, db):
response = await client.get("/billing/pricing")
assert response.status_code == 200
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/pricing")
assert response.status_code == 200
async def test_with_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
response = await auth_client.get("/billing/pricing")
assert response.status_code == 200
class TestSuccessPage:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/success", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/success")
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# Checkout (Paddle.js overlay — returns JSON)
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
async def test_requires_auth(self, client, db):
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_returns_checkout_json(self, auth_client, db, test_user):
# Insert a paddle_products row so get_paddle_price() finds it
await db.execute(
"INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
("starter", "pro_test", "pri_starter_123", "Starter", 1900, "EUR", "subscription"),
)
await db.commit()
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
assert response.status_code == 200
data = await response.get_json()
assert "items" in data
assert data["items"][0]["priceId"] == "pri_starter_123"
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
response = await auth_client.post("/billing/checkout/nonexistent_plan")
assert response.status_code == 400
data = await response.get_json()
assert "error" in data
# ════════════════════════════════════════════════════════════
# Manage subscription / Portal
# ════════════════════════════════════════════════════════════
class TestManageRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_requires_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_sub = MagicMock()
mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123"
mock_client = MagicMock()
mock_client.subscriptions.get.return_value = mock_sub
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# Cancel subscription
# ════════════════════════════════════════════════════════════
class TestCancelRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_no_error_without_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_client = MagicMock()
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_client.subscriptions.cancel.assert_called_once()
# ════════════════════════════════════════════════════════════
# subscription_required decorator
# ════════════════════════════════════════════════════════════
from padelnomics.auth.routes import subscription_required # noqa: E402
from quart import Blueprint # noqa: E402
test_bp = Blueprint("test", __name__)
@test_bp.route("/protected")
@subscription_required()
async def protected_route():
return "success", 200
@test_bp.route("/custom_allowed")
@subscription_required(allowed=("active", "past_due"))
async def custom_allowed_route():
return "success", 200
class TestSubscriptionRequiredDecorator:
@pytest.fixture
async def test_app(self, app):
app.register_blueprint(test_bp)
return app
@pytest.fixture
async def test_client(self, test_app):
async with test_app.test_client() as c:
yield c
async def test_redirects_unauthenticated(self, test_client, db):
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_redirects_without_subscription(self, test_client, db, test_user):
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_allows_active_subscription(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_on_trial(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_cancelled(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_rejects_expired(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@pytest.mark.parametrize("status", ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"])
async def test_default_allowed_tuple(self, test_client, db, test_user, create_subscription, status):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
if status in ("active", "on_trial", "cancelled"):
assert response.status_code == 200
else:
assert response.status_code in (302, 303, 307)
async def test_custom_allowed_tuple(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/custom_allowed")
assert response.status_code == 200

View File

@@ -0,0 +1,308 @@
"""
Integration tests for Paddle webhook handling.
Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing.
"""
import json
import pytest
from conftest import make_webhook_payload, sign_payload
from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from padelnomics.billing.routes import get_subscription
WEBHOOK_PATH = "/billing/webhook/paddle"
SIG_HEADER = "Paddle-Signature"
# ════════════════════════════════════════════════════════════
# Signature Verification
# ════════════════════════════════════════════════════════════
class TestWebhookSignature:
async def test_missing_signature_rejected(self, client, db):
payload = make_webhook_payload("subscription.activated")
payload_bytes = json.dumps(payload).encode()
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={"Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
async def test_invalid_signature_rejected(self, client, db):
payload = make_webhook_payload("subscription.activated")
payload_bytes = json.dumps(payload).encode()
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: "invalid_signature", "Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
async def test_valid_signature_accepted(self, client, db, test_user):
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
async def test_modified_payload_rejected(self, client, db, test_user):
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
tampered = payload_bytes + b"extra"
response = await client.post(
WEBHOOK_PATH,
data=tampered,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
async def test_empty_payload_rejected(self, client, db):
sig = sign_payload(b"")
response = await client.post(
WEBHOOK_PATH,
data=b"",
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 400
async def test_null_custom_data_does_not_crash(self, client, db):
"""Paddle sends null custom_data on lifecycle events like subscription.updated."""
payload = {
"event_type": "subscription.updated",
"data": {
"id": "sub_test456",
"status": "active",
"customer_id": "ctm_test123",
"custom_data": None,
"current_billing_period": {
"starts_at": "2025-02-01T00:00:00.000000Z",
"ends_at": "2025-03-01T00:00:00.000000Z",
},
},
}
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 200
async def test_null_custom_data_activated_does_not_crash(self, client, db):
"""subscription.activated with null custom_data must not FK-violate on user_id=0."""
payload = {
"event_type": "subscription.activated",
"data": {
"id": "sub_test456",
"status": "active",
"customer_id": "ctm_test123",
"custom_data": None,
"current_billing_period": {
"starts_at": "2025-02-01T00:00:00.000000Z",
"ends_at": "2025-03-01T00:00:00.000000Z",
},
},
}
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 200
async def test_null_data_does_not_crash(self, client, db):
"""Guard against data being null in the event payload."""
payload = {
"event_type": "subscription.updated",
"data": None,
}
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# Subscription Lifecycle Events
# ════════════════════════════════════════════════════════════
class TestWebhookSubscriptionActivated:
async def test_creates_subscription(self, client, db, test_user):
payload = make_webhook_payload(
"subscription.activated",
user_id=str(test_user["id"]),
plan="starter",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub is not None
assert sub["plan"] == "starter"
assert sub["status"] == "active"
class TestWebhookSubscriptionUpdated:
async def test_updates_subscription_status(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.updated",
subscription_id="sub_test456",
status="paused",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == "paused"
class TestWebhookSubscriptionCanceled:
async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.canceled",
subscription_id="sub_test456",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == "cancelled"
class TestWebhookSubscriptionPastDue:
async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.past_due",
subscription_id="sub_test456",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == "past_due"
# ════════════════════════════════════════════════════════════
# Parameterized: event -> status transitions
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("event_type,expected_status", [
("subscription.activated", "active"),
("subscription.updated", "active"),
("subscription.canceled", "cancelled"),
("subscription.past_due", "past_due"),
])
async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status):
if event_type != "subscription.activated":
await create_subscription(test_user["id"], provider_subscription_id="sub_test456")
payload = make_webhook_payload(event_type, user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == expected_status
# ════════════════════════════════════════════════════════════
# Hypothesis: fuzz webhook payloads
# ════════════════════════════════════════════════════════════
fuzz_event_type = st.sampled_from([
"subscription.activated",
"subscription.updated",
"subscription.canceled",
"subscription.past_due",
])
fuzz_status = st.sampled_from(["active", "paused", "past_due", "canceled"])
@st.composite
def fuzz_payload(draw):
event_type = draw(fuzz_event_type)
return make_webhook_payload(
event_type=event_type,
subscription_id=f"sub_{draw(st.integers(min_value=100, max_value=999999))}",
user_id=str(draw(st.integers(min_value=1, max_value=999999))),
status=draw(fuzz_status),
)
class TestWebhookHypothesis:
@given(payload_dict=fuzz_payload())
@h_settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_webhook_never_500s(self, client, db, test_user, payload_dict):
# Pin user_id to the test user so subscription_created/activated events don't hit FK violations
payload_dict["data"]["custom_data"]["user_id"] = str(test_user["id"])
payload_bytes = json.dumps(payload_dict).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code < 500

1118
web/tests/test_calculator.py Normal file

File diff suppressed because it is too large Load Diff

1113
web/tests/test_content.py Normal file

File diff suppressed because it is too large Load Diff

292
web/tests/test_credits.py Normal file
View File

@@ -0,0 +1,292 @@
"""
Tests for the credit system (credits.py).
Pure SQL operations against real in-memory SQLite — no mocking needed.
"""
from datetime import datetime
import pytest
from padelnomics.credits import (
InsufficientCredits,
add_credits,
already_unlocked,
compute_credit_cost,
get_balance,
get_ledger,
monthly_credit_refill,
spend_credits,
unlock_lead,
)
# ── Fixtures ─────────────────────────────────────────────────
@pytest.fixture
async def supplier(db):
"""Supplier with credit_balance=100, monthly_credits=30, tier=growth."""
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
credit_balance, monthly_credits, created_at)
VALUES ('Test Supplier', 'test-supplier', 'DE', 'Europe', 'Courts',
'growth', 100, 30, ?)""",
(now,),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
return {"id": supplier_id}
@pytest.fixture
async def lead(db):
"""Lead request with heat_score=warm, credit_cost=20."""
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO lead_requests
(lead_type, heat_score, credit_cost, status, created_at)
VALUES ('supplier_quote', 'warm', 20, 'new', ?)""",
(now,),
) as cursor:
lead_id = cursor.lastrowid
await db.commit()
return {"id": lead_id, "credit_cost": 20}
# ── GetBalance ───────────────────────────────────────────────
class TestGetBalance:
async def test_returns_zero_for_unknown_supplier(self, db):
assert await get_balance(99999) == 0
async def test_returns_current_balance(self, db, supplier):
assert await get_balance(supplier["id"]) == 100
# ── AddCredits ───────────────────────────────────────────────
class TestAddCredits:
async def test_adds_credits_and_updates_balance(self, db, supplier):
new_balance = await add_credits(supplier["id"], 50, "pack_purchase")
assert new_balance == 150
assert await get_balance(supplier["id"]) == 150
async def test_creates_ledger_entry(self, db, supplier):
await add_credits(
supplier["id"], 50, "pack_purchase",
reference_id=42, note="Credit pack: credits_50",
)
rows = await db.execute_fetchall(
"SELECT * FROM credit_ledger WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(rows) == 1
row = rows[0]
assert row[2] == 50 # delta
assert row[3] == 150 # balance_after
assert row[4] == "pack_purchase" # event_type
assert row[5] == 42 # reference_id
assert row[6] == "Credit pack: credits_50" # note
async def test_multiple_adds_accumulate(self, db, supplier):
await add_credits(supplier["id"], 10, "pack_purchase")
await add_credits(supplier["id"], 10, "pack_purchase")
await add_credits(supplier["id"], 10, "pack_purchase")
assert await get_balance(supplier["id"]) == 130
# ── SpendCredits ─────────────────────────────────────────────
class TestSpendCredits:
async def test_spends_credits_and_updates_balance(self, db, supplier):
new_balance = await spend_credits(supplier["id"], 30, "lead_unlock")
assert new_balance == 70
assert await get_balance(supplier["id"]) == 70
async def test_creates_negative_ledger_entry(self, db, supplier):
await spend_credits(supplier["id"], 30, "lead_unlock")
rows = await db.execute_fetchall(
"SELECT * FROM credit_ledger WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(rows) == 1
assert rows[0][2] == -30 # delta
assert rows[0][3] == 70 # balance_after
async def test_raises_insufficient_credits(self, db, supplier):
with pytest.raises(InsufficientCredits) as exc_info:
await spend_credits(supplier["id"], 200, "lead_unlock")
assert exc_info.value.balance == 100
assert exc_info.value.required == 200
async def test_spend_exact_balance(self, db, supplier):
new_balance = await spend_credits(supplier["id"], 100, "lead_unlock")
assert new_balance == 0
assert await get_balance(supplier["id"]) == 0
# ── ComputeCreditCost ────────────────────────────────────────
class TestComputeCreditCost:
def test_hot_costs_35(self):
assert compute_credit_cost({"heat_score": "hot"}) == 35
def test_warm_costs_20(self):
assert compute_credit_cost({"heat_score": "warm"}) == 20
def test_cool_costs_8(self):
assert compute_credit_cost({"heat_score": "cool"}) == 8
def test_missing_heat_defaults_to_cool(self):
assert compute_credit_cost({}) == 8
assert compute_credit_cost({"heat_score": None}) == 8
# ── AlreadyUnlocked ──────────────────────────────────────────
class TestAlreadyUnlocked:
async def test_returns_false_when_not_unlocked(self, db, supplier, lead):
assert await already_unlocked(supplier["id"], lead["id"]) is False
async def test_returns_true_after_unlock(self, db, supplier, lead):
now = datetime.utcnow().isoformat()
await db.execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
VALUES (?, ?, 20, ?)""",
(lead["id"], supplier["id"], now),
)
await db.commit()
assert await already_unlocked(supplier["id"], lead["id"]) is True
# ── UnlockLead ────────────────────────────────────────────────
class TestUnlockLead:
async def test_unlocks_lead_and_returns_details(self, db, supplier, lead):
result = await unlock_lead(supplier["id"], lead["id"])
assert result["forward_id"] is not None
assert result["credit_cost"] == 20
assert result["new_balance"] == 80
assert result["lead"]["id"] == lead["id"]
# Verify lead_forwards row
row = await db.execute_fetchall(
"SELECT * FROM lead_forwards WHERE supplier_id = ? AND lead_id = ?",
(supplier["id"], lead["id"]),
)
assert len(row) == 1
assert row[0][3] == 20 # credit_cost
# Verify ledger entry
ledger = await db.execute_fetchall(
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'lead_unlock'",
(supplier["id"],),
)
assert len(ledger) == 1
assert ledger[0][2] == -20 # delta
assert ledger[0][3] == 80 # balance_after
# Verify unlock_count incremented
lead_row = await db.execute_fetchall(
"SELECT unlock_count FROM lead_requests WHERE id = ?", (lead["id"],),
)
assert lead_row[0][0] == 1
async def test_raises_on_duplicate_unlock(self, db, supplier, lead):
await unlock_lead(supplier["id"], lead["id"])
with pytest.raises(ValueError, match="already unlocked"):
await unlock_lead(supplier["id"], lead["id"])
async def test_raises_on_missing_lead(self, db, supplier):
with pytest.raises(ValueError, match="not found"):
await unlock_lead(supplier["id"], 99999)
async def test_raises_insufficient_credits(self, db, lead):
"""Supplier with only 5 credits tries to unlock a 20-credit lead."""
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
credit_balance, monthly_credits, created_at)
VALUES ('Poor Supplier', 'poor-supplier', 'DE', 'Europe', 'Courts',
'growth', 5, 0, ?)""",
(now,),
) as cursor:
poor_id = cursor.lastrowid
await db.commit()
with pytest.raises(InsufficientCredits) as exc_info:
await unlock_lead(poor_id, lead["id"])
assert exc_info.value.balance == 5
assert exc_info.value.required == 20
# ── MonthlyRefill ─────────────────────────────────────────────
class TestMonthlyRefill:
async def test_refills_monthly_credits(self, db, supplier):
new_balance = await monthly_credit_refill(supplier["id"])
assert new_balance == 130 # 100 + 30
assert await get_balance(supplier["id"]) == 130
# Verify ledger entry
ledger = await db.execute_fetchall(
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'monthly_allocation'",
(supplier["id"],),
)
assert len(ledger) == 1
assert ledger[0][2] == 30 # delta
async def test_noop_when_no_monthly_credits(self, db):
"""Supplier with monthly_credits=0 gets no refill."""
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
credit_balance, monthly_credits, created_at)
VALUES ('Free Supplier', 'free-supplier', 'DE', 'Europe', 'Courts',
'free', 50, 0, ?)""",
(now,),
) as cursor:
free_id = cursor.lastrowid
await db.commit()
result = await monthly_credit_refill(free_id)
assert result == 0
assert await get_balance(free_id) == 50
# ── GetLedger ─────────────────────────────────────────────────
class TestGetLedger:
async def test_returns_entries_in_descending_order(self, db, supplier):
await add_credits(supplier["id"], 10, "pack_purchase", note="first")
await add_credits(supplier["id"], 20, "pack_purchase", note="second")
await add_credits(supplier["id"], 30, "pack_purchase", note="third")
entries = await get_ledger(supplier["id"])
assert len(entries) == 3
# Most recent first
assert entries[0]["note"] == "third"
assert entries[1]["note"] == "second"
assert entries[2]["note"] == "first"
async def test_respects_limit(self, db, supplier):
for i in range(5):
await add_credits(supplier["id"], 1, "pack_purchase", note=f"entry_{i}")
entries = await get_ledger(supplier["id"], limit=2)
assert len(entries) == 2
async def test_empty_for_unknown_supplier(self, db):
entries = await get_ledger(99999)
assert entries == []

507
web/tests/test_e2e_flows.py Normal file
View File

@@ -0,0 +1,507 @@
"""
Comprehensive E2E flow tests using Playwright.
Covers all major user flows: public pages, planner, auth, directory, quote wizard,
and cross-cutting checks (translations, footer, language switcher).
Skipped by default (requires `playwright install chromium`).
Run explicitly with:
uv run pytest -m visual tests/test_e2e_flows.py -v
Server runs on port 5113 (isolated from test_visual.py on 5111 and
test_quote_wizard.py on 5112).
"""
import asyncio
import multiprocessing
import sqlite3
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.app import create_app
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
pytestmark = pytest.mark.visual
PORT = 5113
BASE = f"http://127.0.0.1:{PORT}"
# =============================================================================
# Server / Browser Fixtures
# =============================================================================
def _run_server(ready_event):
"""Run the Quart dev server in a subprocess with in-memory SQLite."""
import aiosqlite
async def _serve():
tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db")
migrate(tmp_db)
tmp_conn = sqlite3.connect(tmp_db)
rows = tmp_conn.execute(
"SELECT sql FROM sqlite_master"
" WHERE sql IS NOT NULL"
" AND name NOT LIKE 'sqlite_%'"
" AND name NOT LIKE '%_fts_%'"
" AND name != '_migrations'"
" ORDER BY rowid"
).fetchall()
tmp_conn.close()
schema_ddl = ";\n".join(r[0] for r in rows) + ";"
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_ddl)
await conn.commit()
core._db = conn
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock):
app = create_app()
app.config["TESTING"] = True
ready_event.set()
await app.run_task(host="127.0.0.1", port=PORT)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield BASE
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture
def page(browser):
pg = browser.new_page(viewport={"width": 1440, "height": 900})
yield pg
pg.close()
@pytest.fixture
def mobile_page(browser):
pg = browser.new_page(viewport={"width": 390, "height": 844})
yield pg
pg.close()
def dev_login(page, base, email="test@example.com"):
"""Instantly authenticate via dev-login endpoint."""
page.goto(f"{base}/auth/dev-login?email={email}")
page.wait_for_load_state("networkidle")
# =============================================================================
# A. Public Pages — smoke test every public GET returns 200
# =============================================================================
def test_root_redirects_to_lang(live_server, page):
"""GET / should redirect to /<lang>/."""
resp = page.goto(live_server + "/")
assert resp.ok, f"/ returned {resp.status}"
assert "/en/" in page.url or "/de/" in page.url, f"No lang in URL: {page.url}"
@pytest.mark.parametrize("path", [
"/en/",
"/de/",
"/en/features",
"/en/terms",
"/en/privacy",
"/en/about",
"/en/imprint",
"/en/suppliers",
"/de/features",
"/de/about",
"/de/suppliers",
])
def test_public_page_200(live_server, page, path):
"""Every public page should return 200 and contain meaningful content."""
resp = page.goto(live_server + path)
assert resp.ok, f"{path} returned {resp.status}"
# Verify page has <h1> or <h2> — not a blank error page
heading = page.locator("h1, h2").first
expect(heading).to_be_visible()
def test_robots_txt(live_server, page):
resp = page.goto(live_server + "/robots.txt")
assert resp.ok
assert "User-agent" in page.content()
def test_sitemap_xml(live_server, page):
resp = page.goto(live_server + "/sitemap.xml")
assert resp.ok
assert "<urlset" in page.content() or "<sitemapindex" in page.content()
def test_health_endpoint(live_server, page):
resp = page.goto(live_server + "/health")
assert resp.ok
# =============================================================================
# B. Planner Flow
# =============================================================================
def test_planner_en_loads(live_server, page):
"""Planner page renders wizard and tab bar."""
resp = page.goto(live_server + "/en/planner/")
assert resp.ok
# Tab bar
expect(page.locator("#tab-bar")).to_be_visible()
# Wizard form
expect(page.locator("#planner-form")).to_be_visible()
def test_planner_de_loads(live_server, page):
"""German planner renders with German UI strings."""
resp = page.goto(live_server + "/de/planner/")
assert resp.ok
# Should contain a German-language label somewhere
content = page.content()
assert "Investition" in content or "Annahmen" in content or "Anlage" in content
def test_planner_calculate_htmx(live_server, page):
"""Adjusting a planner input fires HTMX and updates the results panel."""
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
# Wait for tab content to be present
tab_content = page.locator("#tab-content")
expect(tab_content).to_be_visible()
# Trigger a change on a number input to fire HTMX recalc
first_input = page.locator("#planner-form input[type='number']").first
first_input.click()
first_input.press("ArrowUp")
# Wait for HTMX response to update tab-content
page.wait_for_timeout(800)
# Tab content should still exist and be non-empty after recalc
expect(tab_content).to_be_visible()
assert len(tab_content.inner_html()) > 100
def test_planner_tab_switching(live_server, page):
"""Clicking all result tabs renders different content each time."""
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
tabs = ["capex", "operating", "cashflow", "returns", "metrics"]
seen_contents = set()
for tab_id in tabs:
btn = page.locator(f"button[data-tab='{tab_id}'], [data-tab='{tab_id}']").first
if btn.count() == 0:
# Try clicking by visible text
btn = page.get_by_role("button", name=tab_id, exact=False).first
btn.click()
page.wait_for_timeout(600)
html = page.locator("#tab-content").inner_html()
seen_contents.add(html[:100]) # first 100 chars as fingerprint
# All tabs should render distinct content
assert len(seen_contents) >= 3, "Tab content didn't change across tabs"
def test_planner_chart_data_present(live_server, page):
"""Result tabs embed chart data as JSON script tags."""
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
# Charts on the capex tab
chart_scripts = page.locator("script[type='application/json']")
assert chart_scripts.count() >= 1, "No chart JSON script tags found"
def test_planner_quote_sidebar_visible_wide(live_server, page):
"""Quote sidebar should be visible on wide viewport (>1400px)."""
pg = page.context.new_page()
pg.set_viewport_size({"width": 1600, "height": 900})
pg.goto(live_server + "/en/planner/")
pg.wait_for_load_state("networkidle")
sidebar = pg.locator(".quote-sidebar")
if sidebar.count() > 0:
# If present, should not be display:none
display = pg.evaluate(
"getComputedStyle(document.querySelector('.quote-sidebar')).display"
)
assert display != "none", f"Quote sidebar is hidden on wide viewport: display={display}"
pg.close()
# =============================================================================
# C. Auth Flow
# =============================================================================
def test_login_page_loads(live_server, page):
resp = page.goto(live_server + "/auth/login")
assert resp.ok
expect(page.locator("form")).to_be_visible()
expect(page.locator("input[type='email']")).to_be_visible()
def test_signup_page_loads(live_server, page):
resp = page.goto(live_server + "/auth/signup")
assert resp.ok
expect(page.locator("form")).to_be_visible()
def test_dev_login_redirects_to_dashboard(live_server, page):
"""Dev login should create session and redirect to dashboard."""
page.goto(live_server + "/auth/dev-login?email=devtest@example.com")
page.wait_for_load_state("networkidle")
assert "/dashboard" in page.url, f"Expected dashboard redirect, got: {page.url}"
def test_authenticated_dashboard_loads(live_server, page):
"""After dev-login, dashboard should be accessible."""
dev_login(page, live_server, "dash@example.com")
assert "/dashboard" in page.url
resp_status = page.evaluate("() => document.readyState")
assert resp_status == "complete"
def test_unauthenticated_dashboard_redirects(live_server, page):
"""Without auth, /dashboard/ should redirect to login."""
page.goto(live_server + "/dashboard/", wait_until="networkidle")
assert "login" in page.url or "auth" in page.url, (
f"Expected redirect to login, got: {page.url}"
)
def test_magic_link_sent_page(live_server, page):
"""Magic link sent page renders with the email address shown."""
resp = page.goto(live_server + "/auth/magic-link-sent?email=test@example.com")
assert resp.ok
expect(page.get_by_text("test@example.com")).to_be_visible()
# =============================================================================
# D. Directory Flow
# =============================================================================
def test_directory_en_loads(live_server, page):
resp = page.goto(live_server + "/en/directory/")
assert resp.ok
expect(page.locator("h1, h2").first).to_be_visible()
def test_directory_de_loads(live_server, page):
resp = page.goto(live_server + "/de/directory/")
assert resp.ok
content = page.content()
# Should have German-language UI (filter label, heading, etc.)
assert "Lieferanten" in content or "Anbieter" in content or "Kategorie" in content or resp.ok
def test_directory_search_htmx(live_server, page):
"""Typing in directory search fires HTMX and returns results partial."""
page.goto(live_server + "/en/directory/")
page.wait_for_load_state("networkidle")
search = page.locator("input[type='search'], input[name='q'], input[type='text']").first
if search.count() == 0:
pytest.skip("No search input found")
search.fill("padel")
page.wait_for_timeout(600)
# Results container should exist
results = page.locator("#supplier-results, #results, [id*='result']").first
expect(results).to_be_visible()
# =============================================================================
# E. Quote Flow (key steps)
# =============================================================================
def test_quote_step1_loads(live_server, page):
"""Quote wizard step 1 renders the form."""
resp = page.goto(live_server + "/en/leads/quote")
assert resp.ok
expect(page.locator("form")).to_be_visible()
def test_quote_step1_de_loads(live_server, page):
resp = page.goto(live_server + "/de/leads/quote")
assert resp.ok
content = page.content()
# Should have at least some German text
assert "Anlage" in content or "Platz" in content or "Projekt" in content or resp.ok
def test_quote_verify_url_includes_lang(live_server, page):
"""Verify the leads/verify route exists at /<lang>/leads/verify."""
# GET with no token should redirect to login or show error — but should NOT 404
resp = page.goto(live_server + "/en/leads/verify?token=invalid")
# Should be 200 (shows error) or a redirect — not 404
assert resp.status != 404, "Verify endpoint returned 404 — lang prefix missing?"
# =============================================================================
# F. Authenticated Flows (planner scenarios, leads forms)
# =============================================================================
def test_authenticated_planner_loads(live_server, page):
"""Authenticated user can access the planner."""
dev_login(page, live_server, "planneruser@example.com")
resp = page.goto(live_server + "/en/planner/")
assert resp.ok
expect(page.locator("#planner-form")).to_be_visible()
def test_planner_scenarios_list(live_server, page):
"""Authenticated user can access scenarios list."""
dev_login(page, live_server, "scenuser@example.com")
resp = page.goto(live_server + "/en/planner/scenarios")
assert resp.ok
# =============================================================================
# G. Cross-Cutting Checks
# =============================================================================
def test_footer_present_on_public_pages(live_server, page):
"""Footer should be present on all public pages."""
for path in ["/en/", "/en/features", "/en/about"]:
page.goto(live_server + path)
footer = page.locator("footer")
expect(footer).to_be_visible()
def test_footer_has_four_column_layout(live_server, page):
"""Footer grid should have 4 link columns."""
page.goto(live_server + "/en/")
page.wait_for_load_state("networkidle")
# Count footer navigation columns (divs/sections with link lists)
footer_cols = page.evaluate("""
(() => {
const footer = document.querySelector('footer');
if (!footer) return 0;
const grid = footer.querySelector('[class*="grid"]');
if (!grid) return 0;
return grid.children.length;
})()
""")
assert footer_cols >= 4, f"Expected 4 footer columns, found {footer_cols}"
def test_language_switcher_en_to_de(live_server, page):
"""Language switcher should navigate from /en/ to /de/."""
page.goto(live_server + "/en/")
page.wait_for_load_state("networkidle")
# Find language switcher link for DE
de_link = page.locator("a[href*='/de/'], a[href='/de']").first
if de_link.count() == 0:
pytest.skip("No DE language switcher link found")
de_link.click()
page.wait_for_load_state("networkidle")
assert "/de/" in page.url or page.url.endswith("/de"), f"Language switch failed: {page.url}"
def test_no_missing_translations_en(live_server, page):
"""EN pages should not contain 'None' or 'undefined' translation markers."""
for path in ["/en/", "/en/features", "/en/about"]:
page.goto(live_server + path)
content = page.locator("body").inner_text()
# Check for obvious translation failure markers
assert "t.auth_" not in content, f"Untranslated key in {path}"
assert "{{" not in content, f"Jinja template not rendered in {path}"
def test_no_missing_translations_de(live_server, page):
"""DE pages should contain German text, not raw English keys."""
page.goto(live_server + "/de/")
content = page.locator("body").inner_text()
assert "{{" not in content, "Jinja template not rendered in /de/"
assert "t.auth_" not in content, "Untranslated key in /de/"
def test_auth_login_page_german(live_server, page):
"""Auth login should render in German when lang cookie is 'de'."""
page.context.add_cookies([{
"name": "lang", "value": "de",
"domain": "127.0.0.1", "path": "/"
}])
page.goto(live_server + "/auth/login")
content = page.content()
# German auth page should contain German text
assert "Anmelden" in content or "E-Mail" in content or "Weiter" in content
def test_404_for_nonexistent_page(live_server, page):
"""Non-existent pages should return 404."""
resp = page.goto(live_server + "/en/this-page-does-not-exist-xyz")
assert resp.status == 404, f"Expected 404, got {resp.status}"
def test_legacy_redirect_terms(live_server, page):
"""Legacy /terms should redirect to /en/terms."""
resp = page.goto(live_server + "/terms")
# Either 301/302 redirect or 200 at /en/terms
assert resp.ok or resp.status in (301, 302)
# =============================================================================
# H. Markets Waitlist (WAITLIST_MODE=False by default — page should load)
# =============================================================================
def test_markets_hub_loads(live_server, page):
"""Markets hub should load normally when WAITLIST_MODE is off."""
resp = page.goto(live_server + "/en/markets")
assert resp.ok
expect(page.locator("h1, h2").first).to_be_visible()
def test_markets_results_partial_loads(live_server, page):
"""Markets results HTMX partial should return 200."""
resp = page.goto(live_server + "/en/markets/results")
assert resp.ok
# =============================================================================
# I. Tooltip Presence (result tab tooltips)
# =============================================================================
def test_planner_tooltips_present(live_server, page):
"""Result tabs should contain tooltip spans for complex financial terms."""
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
# The returns tab should have tooltip spans
returns_btn = page.locator("button[data-tab='returns'], [data-tab='returns']").first
if returns_btn.count() > 0:
returns_btn.click()
page.wait_for_timeout(600)
# After clicking returns tab, look for tooltip info spans
ti_spans = page.locator(".ti")
assert ti_spans.count() >= 1, "No tooltip spans (.ti) found on results tab"

View File

@@ -0,0 +1,112 @@
"""
Tests that EN and DE locale files are consistent.
"""
from padelnomics.i18n import _CALC_ITEM_NAMES, _TRANSLATIONS, SUPPORTED_LANGS
# Keys with identical EN/DE values are acceptable — financial abbreviations,
# month abbreviations, English brand names used in German, and UI chrome
# that intentionally stays English in DE (Dashboard, Admin, Feedback).
_IDENTICAL_VALUE_ALLOWLIST = {
# Financial / technical abbreviations (same in DE)
"card_cash_on_cash", "card_irr", "card_moic", "card_rev_pah",
"th_dscr", "th_ebitda", "wf_moic", "wiz_capex", "wiz_irr",
# Month abbreviations (same in DE: Jan, Feb, Apr, Jun, Jul, Aug, Sep, Nov)
"month_jan", "month_feb", "month_apr", "month_jun",
"month_jul", "month_aug", "month_sep", "month_nov",
# Indoor / Outdoor (English terms used in DE)
"label_indoor", "label_outdoor", "toggle_indoor", "toggle_outdoor",
"features_card_3_h2", "landing_feature_3_h3",
"q1_facility_indoor", "q1_facility_outdoor", "q1_facility_both",
# Revenue streams and product labels (English terms used in DE)
"stream_coaching", "stream_fb",
"pill_light_led_standard", "q1_lighting_led_std",
# Plan names (brand names, same in DE)
"sup_basic_name", "sup_growth_name", "sup_pro_name",
"suppliers_basic_name", "suppliers_growth_name", "suppliers_pro_name",
"dir_card_featured", "dir_card_growth", "dir_cat_software",
# Supplier boost option names
"sup_boost_logo", "sup_boost_sticky", "sup_boosts_h3",
# Comparison table headers that use English terms in DE
"sup_cmp_th_ads", "sup_cmp_th_us",
# Problem section heading — "Google Ads" is a brand name
"sup_prob_ads_h3", "suppliers_problem_2_h3",
# Budget/lead labels that are English in DE
"sup_lead_budget", "suppliers_leads_budget",
# UI chrome that stays English in DE
"nav_admin", "nav_dashboard", "nav_feedback",
# Country names that are the same
"country_uk", "country_us",
"dir_country_CN", "dir_country_PT", "dir_country_TH",
# Optional annotation
"q9_company_note",
# Dashboard chrome that stays English in DE (brand term)
"dash_page_title", "dash_h1", "dash_name_label",
# Supplier dashboard — analytics brand name
"sd_ov_via_umami",
# Lead heat filter labels (English terms used in DE)
"sd_leads_filter_hot", "sd_leads_filter_warm", "sd_leads_filter_cool",
# "Budget", "Name", "Phase", "Investor" — same in both languages
"sd_leads_budget", "sd_card_budget", "sd_unlocked_label_budget",
"sd_unlocked_label_name", "sd_unlocked_label_phase", "sd_stakeholder_investor",
# Listing form labels that are English brand terms / same in DE
"sd_lst_logo", "sd_lst_website",
# Boost option name — "Logo" is the same in DE
"sd_boost_logo_name",
# Business plan — Indoor/Outdoor same in DE, financial abbreviations
"bp_indoor", "bp_outdoor",
"bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex",
}
def test_en_de_key_parity():
"""EN and DE must have identical key sets."""
en_keys = set(_TRANSLATIONS["en"].keys())
de_keys = set(_TRANSLATIONS["de"].keys())
en_only = en_keys - de_keys
de_only = de_keys - en_keys
assert not en_only, f"Keys in EN but not DE: {sorted(en_only)}"
assert not de_only, f"Keys in DE but not EN: {sorted(de_only)}"
def test_all_values_non_empty():
"""No translation value should be an empty string."""
for lang in SUPPORTED_LANGS:
for key, value in _TRANSLATIONS[lang].items():
assert value, f"Empty value for key {key!r} in lang {lang!r}"
def test_no_untranslated_copy_paste():
"""No key should have an identical EN and DE value (catches missed translations).
Some keys are exempt — see _IDENTICAL_VALUE_ALLOWLIST.
"""
en = _TRANSLATIONS["en"]
de = _TRANSLATIONS["de"]
violations = []
for key in en:
if key in _IDENTICAL_VALUE_ALLOWLIST:
continue
if en[key] == de[key]:
violations.append((key, en[key]))
assert not violations, (
"Keys with identical EN/DE values (likely untranslated): "
+ ", ".join(f"{k!r}={v!r}" for k, v in violations[:10])
+ (f" ... and {len(violations) - 10} more" if len(violations) > 10 else "")
)
def test_calc_item_names_key_parity():
"""EN and DE calc item names must have identical key sets."""
en_keys = set(_CALC_ITEM_NAMES["en"].keys())
de_keys = set(_CALC_ITEM_NAMES["de"].keys())
assert en_keys == de_keys, (
f"Calc item key mismatch — "
f"EN-only: {sorted(en_keys - de_keys)}, DE-only: {sorted(de_keys - en_keys)}"
)
def test_calc_item_names_non_empty():
"""No calc item name should be empty."""
for lang in SUPPORTED_LANGS:
for key, value in _CALC_ITEM_NAMES[lang].items():
assert value, f"Empty calc item name for key {key!r} in lang {lang!r}"

111
web/tests/test_i18n_tips.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Tests for i18n tip key completeness.
Regression for: "i" tooltip spans showing untranslated English text in the
German planner because tip_* keys were missing from the DE translation dict.
Also covers wiz_summary_label used in the wizard preview summary bar.
"""
import pytest
from padelnomics.i18n import get_translations
EN = get_translations("en")
DE = get_translations("de")
# Every key that a slider tip or inline tooltip now references via t.tip_*
TIP_KEYS = [
"wiz_summary_label",
"tip_permits_compliance",
"tip_dbl_courts",
"tip_sgl_courts",
"tip_sqm_dbl_hall",
"tip_sqm_sgl_hall",
"tip_sqm_dbl_outdoor",
"tip_sqm_sgl_outdoor",
"tip_rate_peak",
"tip_rate_offpeak",
"tip_rate_single",
"tip_peak_pct",
"tip_booking_fee",
"tip_util_target",
"tip_hours_per_day",
"tip_days_indoor",
"tip_days_outdoor",
"tip_membership_rev",
"tip_fb_rev",
"tip_coaching_rev",
"tip_retail_rev",
"tip_glass_type",
"tip_court_cost_dbl",
"tip_court_cost_sgl",
"tip_hall_cost_sqm",
"tip_foundation_sqm",
"tip_land_price_sqm",
"tip_hvac",
"tip_electrical",
"tip_sanitary",
"tip_fire_protection",
"tip_planning",
"tip_floor_prep",
"tip_hvac_upgrade",
"tip_lighting_upgrade",
"tip_fitout",
"tip_outdoor_foundation",
"tip_outdoor_site_work",
"tip_outdoor_lighting",
"tip_outdoor_fencing",
"tip_working_capital",
"tip_contingency",
"tip_budget_target",
"tip_rent_sqm",
"tip_outdoor_rent",
"tip_property_tax",
"tip_insurance",
"tip_electricity",
"tip_heating",
"tip_water",
"tip_maintenance",
"tip_cleaning",
"tip_marketing",
"tip_staff",
"tip_loan_pct",
"tip_interest_rate",
"tip_loan_term",
"tip_construction_months",
"tip_hold_years",
"tip_exit_multiple",
"tip_annual_rev_growth",
]
@pytest.mark.parametrize("key", TIP_KEYS)
def test_key_present_in_english(key):
assert key in EN, f"Missing EN translation key: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_key_present_in_german(key):
assert key in DE, f"Missing DE translation key: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_english_value_non_empty(key):
assert EN[key], f"Empty EN value for: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_german_value_non_empty(key):
assert DE[key], f"Empty DE value for: {key}"
@pytest.mark.parametrize("key", TIP_KEYS)
def test_german_differs_from_english(key):
"""Every tooltip must be translated, not just copied from English."""
assert EN[key] != DE[key], f"DE translation identical to EN for: {key}"
def test_wiz_summary_label_english_value():
assert EN["wiz_summary_label"] == "Live Summary"
def test_wiz_summary_label_german_value():
assert DE["wiz_summary_label"] == "Aktuelle Werte"

View File

@@ -0,0 +1,269 @@
"""
Tests for the sequential migration runner.
Synchronous tests — migrate.py uses stdlib sqlite3, not aiosqlite.
Uses tmp_path for isolated DB files and monkeypatch for DATABASE_PATH.
"""
import importlib
import sqlite3
from pathlib import Path
from unittest.mock import patch
import pytest
from padelnomics.migrations.migrate import _discover_versions, migrate
VERSIONS_DIR = (
Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "versions"
)
# ── Helpers ───────────────────────────────────────────────────
def _table_names(conn):
"""Return sorted list of user-visible table names."""
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]
def _column_names(conn, table):
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def fresh_db_path(tmp_path):
"""Path to a non-existent DB file."""
return str(tmp_path / "fresh.db")
@pytest.fixture
def existing_db(tmp_path):
"""DB with 0000 baseline applied (simulates an existing production DB)."""
db_path = str(tmp_path / "existing.db")
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA foreign_keys=ON")
# Create _migrations table and apply only 0000
conn.execute("""
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
mod_0000 = importlib.import_module(
"padelnomics.migrations.versions.0000_initial_schema"
)
mod_0000.up(conn)
conn.execute(
"INSERT INTO _migrations (name) VALUES (?)",
("0000_initial_schema",),
)
conn.commit()
conn.close()
return db_path
@pytest.fixture
def up_to_date_db(tmp_path):
"""DB with all migrations applied via migrate()."""
db_path = str(tmp_path / "uptodate.db")
migrate(db_path)
return db_path
@pytest.fixture
def mock_versions_dir(tmp_path):
"""Empty temp directory for version discovery tests."""
d = tmp_path / "versions"
d.mkdir()
return d
# ── TestFreshDatabase ─────────────────────────────────────────
class TestFreshDatabase:
def test_creates_all_tables(self, fresh_db_path):
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
tables = _table_names(conn)
conn.close()
assert "_migrations" in tables
assert "users" in tables
assert "subscriptions" in tables
assert "scenarios" in tables
def test_records_all_versions_as_applied(self, fresh_db_path):
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
applied = {
r[0] for r in conn.execute("SELECT name FROM _migrations").fetchall()
}
conn.close()
versions = _discover_versions()
assert applied == set(versions)
def test_uses_provider_column_names(self, fresh_db_path):
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
cols = _column_names(conn, "subscriptions")
conn.close()
assert "provider_subscription_id" in cols
assert "paddle_customer_id" not in cols
assert "lemonsqueezy_customer_id" not in cols
def test_creates_rbac_tables(self, fresh_db_path):
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
tables = _table_names(conn)
conn.close()
assert "user_roles" in tables
assert "billing_customers" in tables
# ── TestExistingDatabase ──────────────────────────────────────
class TestExistingDatabase:
def test_applies_pending_migrations(self, existing_db):
migrate(existing_db)
conn = sqlite3.connect(existing_db)
applied = {
r[0] for r in conn.execute("SELECT name FROM _migrations").fetchall()
}
conn.close()
versions = _discover_versions()
assert applied == set(versions)
def test_records_migration_with_timestamp(self, existing_db):
migrate(existing_db)
conn = sqlite3.connect(existing_db)
row = conn.execute(
"SELECT name, applied_at FROM _migrations WHERE name LIKE '0001%'"
).fetchone()
conn.close()
assert row is not None
assert row[0] == "0001_rename_ls_to_paddle"
assert row[1] is not None # timestamp populated
# ── TestUpToDateDatabase ──────────────────────────────────────
class TestUpToDateDatabase:
def test_noop_when_all_applied(self, up_to_date_db):
with patch("padelnomics.migrations.migrate.importlib.import_module") as mock_imp:
migrate(up_to_date_db)
mock_imp.assert_not_called()
def test_no_duplicate_entries_on_rerun(self, up_to_date_db):
migrate(up_to_date_db)
migrate(up_to_date_db)
conn = sqlite3.connect(up_to_date_db)
count = conn.execute("SELECT COUNT(*) FROM _migrations").fetchone()[0]
conn.close()
assert count == len(_discover_versions())
# ── TestIdempotentMigration ───────────────────────────────────
class TestIdempotentMigration:
def test_migrate_twice_is_idempotent(self, fresh_db_path):
"""Running migrate() twice produces the same result."""
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
tables_first = _table_names(conn)
count_first = conn.execute("SELECT COUNT(*) FROM _migrations").fetchone()[0]
conn.close()
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
tables_second = _table_names(conn)
count_second = conn.execute("SELECT COUNT(*) FROM _migrations").fetchone()[0]
conn.close()
assert tables_first == tables_second
assert count_first == count_second
# ── TestDiscoverVersions ─────────────────────────────────────
class TestDiscoverVersions:
def test_finds_and_sorts_version_files(self):
versions = _discover_versions()
assert len(versions) >= 2
assert versions[0] == "0000_initial_schema"
assert versions[1] == "0001_rename_ls_to_paddle"
def test_ignores_non_matching_files(self, mock_versions_dir, monkeypatch):
(mock_versions_dir / "__init__.py").write_text("")
(mock_versions_dir / "readme.txt").write_text("")
(mock_versions_dir / "0001_real.py").write_text("")
monkeypatch.setattr(
"padelnomics.migrations.migrate.VERSIONS_DIR", mock_versions_dir
)
versions = _discover_versions()
assert versions == ["0001_real"]
def test_returns_empty_for_missing_directory(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"padelnomics.migrations.migrate.VERSIONS_DIR",
tmp_path / "nonexistent",
)
assert _discover_versions() == []
# ── TestMigrationOrdering ─────────────────────────────────────
class TestMigrationOrdering:
def test_multiple_pending_run_in_order(self, tmp_path, monkeypatch):
"""Mock two version files and verify they run in sorted order."""
db_path = str(tmp_path / "order.db")
monkeypatch.setenv("DATABASE_PATH", db_path)
# Create fake version files in a temp versions dir
vdir = tmp_path / "vdir"
vdir.mkdir()
(vdir / "0001_first.py").write_text("")
(vdir / "0002_second.py").write_text("")
monkeypatch.setattr(
"padelnomics.migrations.migrate.VERSIONS_DIR", vdir
)
call_order = []
def fake_import(name):
class FakeMod:
@staticmethod
def up(conn):
call_order.append(name)
return FakeMod()
with patch(
"padelnomics.migrations.migrate.importlib.import_module",
side_effect=fake_import,
):
migrate()
assert call_order == [
"padelnomics.migrations.versions.0001_first",
"padelnomics.migrations.versions.0002_second",
]
def test_migrations_table_created_automatically(self, fresh_db_path):
"""A fresh DB gets the _migrations table from migrate()."""
migrate(fresh_db_path)
conn = sqlite3.connect(fresh_db_path)
tables = _table_names(conn)
conn.close()
assert "_migrations" in tables

699
web/tests/test_phase0.py Normal file
View File

@@ -0,0 +1,699 @@
"""
Phase 0 tests: guest mode, new calculator variables, heat score, quote flow, migration.
"""
import math
import pytest
from padelnomics.leads.routes import calculate_heat_score
from padelnomics.planner.calculator import DEFAULTS, calc, validate_state
ALL_COMBOS = [
("indoor", "rent"),
("indoor", "buy"),
("outdoor", "rent"),
("outdoor", "buy"),
]
def default_state(**overrides):
s = {**DEFAULTS, **overrides}
return validate_state(s)
def _assert_finite(obj, path=""):
if isinstance(obj, float):
assert math.isfinite(obj), f"Non-finite at {path}: {obj}"
elif isinstance(obj, dict):
for k, v in obj.items():
_assert_finite(v, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
_assert_finite(v, f"{path}[{i}]")
# ════════════════════════════════════════════════════════════
# Guest mode
# ════════════════════════════════════════════════════════════
class TestGuestMode:
async def test_planner_accessible_without_login(self, client):
"""GET /planner/ returns 200 for unauthenticated user."""
resp = await client.get("/en/planner/")
assert resp.status_code == 200
async def test_calculate_endpoint_works_without_login(self, client):
"""POST /planner/calculate returns HTML partial for guest."""
resp = await client.post(
"/en/planner/calculate",
data={"dblCourts": "4", "activeTab": "capex"},
)
assert resp.status_code == 200
html = (await resp.data).decode()
# HTMX endpoint returns an HTML partial containing CAPEX data
assert "capex" in html.lower() or "metric-card" in html
async def test_scenario_routes_require_login(self, client):
"""Save/load/delete/list scenarios still require auth."""
resp = await client.post(
"/en/planner/scenarios/save",
json={"name": "test", "state_json": "{}"},
)
assert resp.status_code in (302, 401)
async def test_planner_hides_save_for_guest(self, client):
"""Planner HTML does not render scenario controls for guests."""
resp = await client.get("/en/planner/")
html = (await resp.data).decode()
assert "saveScenarioBtn" not in html
async def test_planner_shows_save_for_auth(self, auth_client):
"""Planner HTML renders scenario controls for logged-in users."""
resp = await auth_client.get("/en/planner/")
html = (await resp.data).decode()
assert "saveScenarioBtn" in html
# ════════════════════════════════════════════════════════════
# New calculator variables — defaults
# ════════════════════════════════════════════════════════════
class TestNewCalculatorVariables:
def test_budget_target_in_defaults(self):
assert "budgetTarget" in DEFAULTS
assert DEFAULTS["budgetTarget"] == 0
def test_glass_type_in_defaults(self):
assert "glassType" in DEFAULTS
assert DEFAULTS["glassType"] == "standard"
def test_lighting_type_in_defaults(self):
assert "lightingType" in DEFAULTS
assert DEFAULTS["lightingType"] == "led_standard"
# ════════════════════════════════════════════════════════════
# Glass type
# ════════════════════════════════════════════════════════════
class TestGlassType:
def test_panoramic_glass_increases_capex(self):
d_std = calc(default_state(glassType="standard"))
d_pan = calc(default_state(glassType="panoramic"))
assert d_pan["capex"] > d_std["capex"]
@pytest.mark.parametrize("glass", ["standard", "panoramic"])
@pytest.mark.parametrize("venue,own", ALL_COMBOS)
def test_glass_type_all_combos(self, venue, own, glass):
d = calc(default_state(venue=venue, own=own, glassType=glass))
assert d["capex"] > 0
_assert_finite(d)
def test_panoramic_applies_1_4x_multiplier(self):
"""Panoramic courts cost 1.4x standard courts."""
d_std = calc(default_state(glassType="standard"))
d_pan = calc(default_state(glassType="panoramic"))
std_courts = next(i for i in d_std["capexItems"] if i["name"] == "Padel Courts")
pan_courts = next(i for i in d_pan["capexItems"] if i["name"] == "Padel Courts")
assert pan_courts["amount"] == pytest.approx(std_courts["amount"] * 1.4, abs=1)
# ════════════════════════════════════════════════════════════
# Lighting type
# ════════════════════════════════════════════════════════════
class TestLightingType:
def test_led_competition_increases_capex(self):
d_std = calc(default_state(lightingType="led_standard"))
d_comp = calc(default_state(lightingType="led_competition"))
assert d_comp["capex"] > d_std["capex"]
def test_natural_light_outdoor(self):
d_led = calc(default_state(venue="outdoor", lightingType="led_standard"))
d_nat = calc(default_state(venue="outdoor", lightingType="natural"))
assert d_nat["capex"] < d_led["capex"]
def test_natural_light_zeroes_outdoor_lighting(self):
d = calc(default_state(venue="outdoor", lightingType="natural"))
lighting_item = next(
(i for i in d["capexItems"] if i["name"] == "Lighting"), None
)
assert lighting_item is not None
assert lighting_item["amount"] == 0
@pytest.mark.parametrize("light", ["led_standard", "led_competition"])
@pytest.mark.parametrize("venue,own", ALL_COMBOS)
def test_lighting_type_all_combos(self, venue, own, light):
d = calc(default_state(venue=venue, own=own, lightingType=light))
assert d["capex"] > 0
_assert_finite(d)
# ════════════════════════════════════════════════════════════
# Budget target
# ════════════════════════════════════════════════════════════
class TestBudgetTarget:
def test_budget_variance_when_set(self):
d = calc(default_state(budgetTarget=300000))
assert "budgetVariance" in d
assert d["budgetVariance"] == d["capex"] - 300000
def test_budget_variance_zero_when_no_budget(self):
d = calc(default_state(budgetTarget=0))
assert d["budgetVariance"] == 0
assert d["budgetPct"] == 0
def test_budget_pct_calculated(self):
d = calc(default_state(budgetTarget=200000))
assert d["budgetPct"] == pytest.approx(d["capex"] / 200000 * 100)
def test_budget_target_passthrough(self):
d = calc(default_state(budgetTarget=500000))
assert d["budgetTarget"] == 500000
# ════════════════════════════════════════════════════════════
# Heat score
# ════════════════════════════════════════════════════════════
class TestHeatScore:
def test_hot_lead(self):
"""High-readiness signals = hot."""
form = {
"timeline": "asap",
"location_status": "lease_signed",
"financing_status": "self_funded",
"decision_process": "solo",
"previous_supplier_contact": "received_quotes",
"budget_estimate": "500000",
}
assert calculate_heat_score(form) == "hot"
def test_cool_lead(self):
"""Low-readiness signals = cool."""
form = {
"timeline": "12+mo",
"location_status": "still_searching",
"financing_status": "not_started",
"decision_process": "committee",
"previous_supplier_contact": "first_time",
"budget_estimate": "0",
}
assert calculate_heat_score(form) == "cool"
def test_warm_lead(self):
"""Mid-readiness signals = warm."""
form = {
"timeline": "3-6mo",
"location_status": "location_found",
"financing_status": "seeking",
"decision_process": "partners",
"budget_estimate": "150000",
}
assert calculate_heat_score(form) == "warm"
def test_empty_form_is_cool(self):
assert calculate_heat_score({}) == "cool"
def test_timeline_6_12mo_scores_1(self):
assert calculate_heat_score({"timeline": "6-12mo"}) == "cool"
def test_high_budget_alone_not_hot(self):
"""Budget alone shouldn't make a lead hot."""
assert calculate_heat_score({"budget_estimate": "1000000"}) == "cool"
def test_permit_granted_scores_4(self):
assert calculate_heat_score({"location_status": "permit_granted"}) == "cool"
# Combined with timeline makes it warm
form = {"location_status": "permit_granted", "timeline": "asap"}
assert calculate_heat_score(form) == "warm"
def test_permit_pending_scores_3(self):
form = {
"location_status": "permit_pending",
"timeline": "3-6mo",
"financing_status": "self_funded",
}
assert calculate_heat_score(form) == "warm"
def test_converting_existing_scores_2(self):
assert calculate_heat_score({"location_status": "converting_existing"}) == "cool"
def test_permit_not_filed_scores_2(self):
assert calculate_heat_score({"location_status": "permit_not_filed"}) == "cool"
def test_location_found_scores_1(self):
assert calculate_heat_score({"location_status": "location_found"}) == "cool"
# ════════════════════════════════════════════════════════════
# Quote request route
# ════════════════════════════════════════════════════════════
class TestQuoteRequest:
async def test_quote_form_loads(self, client):
"""GET /leads/quote returns 200 with wizard shell."""
resp = await client.get("/en/leads/quote")
assert resp.status_code == 200
async def test_quote_prefill_from_params(self, client):
"""Query params pre-fill the form and start on step 2."""
resp = await client.get("/en/leads/quote?venue=outdoor&courts=6")
assert resp.status_code == 200
async def test_quote_step_endpoint(self, client):
"""GET /leads/quote/step/1 returns 200 partial."""
resp = await client.get("/en/leads/quote/step/1")
assert resp.status_code == 200
async def test_quote_step_invalid(self, client):
"""GET /leads/quote/step/0 returns 400."""
resp = await client.get("/en/leads/quote/step/0")
assert resp.status_code == 400
async def test_quote_step_post_advances(self, client):
"""POST to step 1 with valid data returns step 2."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote/step/1",
form={
"_accumulated": "{}",
"facility_type": "indoor",
"court_count": "4",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Location" in html
async def test_quote_submit_creates_lead(self, client, db):
"""POST /leads/quote creates a lead_requests row as pending_verification."""
# Get CSRF token first
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "4",
"glass_type": "panoramic",
"lighting_type": "led_standard",
"build_context": "new_standalone",
"country": "DE",
"timeline": "3-6mo",
"location_status": "location_found",
"financing_status": "self_funded",
"decision_process": "solo",
"stakeholder_type": "entrepreneur",
"contact_name": "Test User",
"contact_email": "test@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute("SELECT * FROM lead_requests WHERE lead_type = 'quote'") as cur:
rows = await cur.fetchall()
assert len(rows) == 1
row = dict(rows[0])
assert row["status"] == "pending_verification"
assert row["heat_score"] in ("hot", "warm", "cool")
assert row["contact_email"] == "test@example.com"
assert row["facility_type"] == "indoor"
assert row["stakeholder_type"] == "entrepreneur"
async def test_quote_submit_without_login(self, client, db):
"""Guests get a user created and linked; lead is pending_verification."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "2",
"country": "DE",
"timeline": "3-6mo",
"stakeholder_type": "entrepreneur",
"contact_name": "Guest",
"contact_email": "guest@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT user_id, status FROM lead_requests WHERE contact_email = 'guest@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] is not None # user_id linked via get-or-create
assert row[1] == "pending_verification"
async def test_quote_submit_with_login(self, auth_client, db, test_user):
"""Logged-in user with matching email skips verification (status='new')."""
await auth_client.get("/en/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/en/leads/quote",
form={
"facility_type": "outdoor",
"court_count": "6",
"country": "DE",
"timeline": "asap",
"stakeholder_type": "entrepreneur",
"contact_name": "Auth User",
"contact_email": "test@example.com", # matches test_user email
"contact_phone": "+491234567890",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT user_id, status FROM lead_requests WHERE contact_email = 'test@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] == test_user["id"]
assert row[1] == "new"
async def test_venue_search_build_context(self, client, db):
"""Build context 'venue_search' is stored correctly."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "4",
"build_context": "venue_search",
"country": "DE",
"timeline": "6-12mo",
"stakeholder_type": "developer",
"contact_name": "Venue Search",
"contact_email": "venue@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT build_context FROM lead_requests WHERE contact_email = 'venue@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] == "venue_search"
async def test_stakeholder_type_stored(self, client, db):
"""stakeholder_type field is stored correctly."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "6",
"country": "DE",
"timeline": "asap",
"stakeholder_type": "tennis_club",
"contact_name": "Club Owner",
"contact_email": "club@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT stakeholder_type FROM lead_requests WHERE contact_email = 'club@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] == "tennis_club"
async def test_submitted_page_has_context(self, client):
"""Guest quote submission shows 'check your email' verify page."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "6",
"country": "DE",
"timeline": "3-6mo",
"stakeholder_type": "entrepreneur",
"contact_name": "Context Test",
"contact_email": "ctx@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = (await resp.data).decode()
assert "check your email" in html.lower()
assert "ctx@example.com" in html
async def test_quote_validation_rejects_missing_fields(self, client):
"""POST /leads/quote returns 422 JSON when mandatory fields missing."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/en/leads/quote",
json={
"facility_type": "indoor",
"court_count": "4",
"contact_name": "",
"contact_email": "",
},
headers={"X-CSRF-Token": csrf},
)
assert resp.status_code == 422
data = await resp.get_json()
assert data["ok"] is False
assert len(data["errors"]) >= 3 # country, timeline, stakeholder_type + name + email + phone
# ════════════════════════════════════════════════════════════
# Quote verification (double opt-in)
# ════════════════════════════════════════════════════════════
class TestQuoteVerification:
"""Double opt-in email verification for quote requests."""
QUOTE_FORM = {
"facility_type": "indoor",
"court_count": "4",
"country": "DE",
"timeline": "3-6mo",
"stakeholder_type": "entrepreneur",
"contact_name": "Verify Test",
"contact_email": "verify@example.com",
"contact_phone": "+491234567890",
}
async def _submit_guest_quote(self, client, db, email="verify@example.com"):
"""Helper: submit a quote as a guest, return (lead_id, token)."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
form = {**self.QUOTE_FORM, "contact_email": email, "csrf_token": csrf}
await client.post("/en/leads/quote", form=form)
async with db.execute(
"SELECT id FROM lead_requests WHERE contact_email = ?", (email,)
) as cur:
lead_id = (await cur.fetchone())[0]
async with db.execute(
"SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1"
) as cur:
token = (await cur.fetchone())[0]
return lead_id, token
async def test_guest_quote_creates_pending_lead(self, client, db):
"""Guest quote creates lead with status='pending_verification'."""
lead_id, _ = await self._submit_guest_quote(client, db)
async with db.execute(
"SELECT status FROM lead_requests WHERE id = ?", (lead_id,)
) as cur:
row = await cur.fetchone()
assert row[0] == "pending_verification"
async def test_logged_in_same_email_skips_verification(self, auth_client, db, test_user):
"""Logged-in user with matching email gets status='new' and 'matched' page."""
await auth_client.get("/en/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/en/leads/quote",
form={
**self.QUOTE_FORM,
"contact_email": "test@example.com", # matches test_user
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = (await resp.data).decode()
assert "matched" in html.lower()
async with db.execute(
"SELECT status FROM lead_requests WHERE contact_email = 'test@example.com'"
) as cur:
row = await cur.fetchone()
assert row[0] == "new"
async def test_logged_in_different_email_needs_verification(self, auth_client, db, test_user):
"""Logged-in user with different email still needs verification."""
await auth_client.get("/en/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/en/leads/quote",
form={
**self.QUOTE_FORM,
"contact_email": "other@example.com", # different from test_user
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = (await resp.data).decode()
assert "check your email" in html.lower()
async with db.execute(
"SELECT status FROM lead_requests WHERE contact_email = 'other@example.com'"
) as cur:
row = await cur.fetchone()
assert row[0] == "pending_verification"
async def test_verify_link_activates_lead(self, client, db):
"""GET /leads/verify with valid token sets status='new' and verified_at."""
lead_id, token = await self._submit_guest_quote(client, db)
resp = await client.get(f"/en/leads/verify?token={token}&lead={lead_id}")
assert resp.status_code == 200
async with db.execute(
"SELECT status, verified_at FROM lead_requests WHERE id = ?", (lead_id,)
) as cur:
row = await cur.fetchone()
assert row[0] == "new"
assert row[1] is not None # verified_at timestamp set
async def test_verify_sets_session(self, client, db):
"""Verification link logs the user in (sets session user_id)."""
lead_id, token = await self._submit_guest_quote(client, db)
await client.get(f"/en/leads/verify?token={token}&lead={lead_id}")
async with client.session_transaction() as sess:
assert "user_id" in sess
async def test_verify_expired_token(self, client, db):
"""Expired/used token redirects with error."""
lead_id, token = await self._submit_guest_quote(client, db)
# Expire the token
await db.execute("UPDATE auth_tokens SET expires_at = '2000-01-01T00:00:00'")
await db.commit()
resp = await client.get(
f"/en/leads/verify?token={token}&lead={lead_id}",
follow_redirects=False,
)
assert resp.status_code == 302
async def test_verify_already_verified_lead(self, client, db):
"""Attempting to verify an already-activated lead shows error."""
lead_id, token = await self._submit_guest_quote(client, db)
# Manually activate the lead
await db.execute(
"UPDATE lead_requests SET status = 'new' WHERE id = ?", (lead_id,)
)
await db.commit()
resp = await client.get(
f"/en/leads/verify?token={token}&lead={lead_id}",
follow_redirects=False,
)
assert resp.status_code == 302
async def test_verify_missing_params(self, client, db):
"""Missing token or lead params redirects."""
resp = await client.get("/en/leads/verify", follow_redirects=False)
assert resp.status_code == 302
resp = await client.get("/en/leads/verify?token=abc", follow_redirects=False)
assert resp.status_code == 302
async def test_guest_quote_creates_user(self, client, db):
"""Guest quote submission creates a user row for the contact email."""
await self._submit_guest_quote(client, db, email="newuser@example.com")
async with db.execute(
"SELECT id FROM users WHERE email = 'newuser@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
# ════════════════════════════════════════════════════════════
# Migration / schema
# ════════════════════════════════════════════════════════════
class TestSchema:
async def test_schema_has_new_columns(self, db):
"""Fresh DB from schema.sql has all expanded lead_requests columns."""
async with db.execute("PRAGMA table_info(lead_requests)") as cur:
cols = {r[1] for r in await cur.fetchall()}
for expected in (
"facility_type", "glass_type", "lighting_type",
"build_context", "country", "timeline",
"location_status", "financing_status",
"heat_score", "contact_name", "contact_email",
"contact_phone", "contact_company",
"wants_financing_help", "decision_process",
"previous_supplier_contact", "services_needed",
"additional_info", "stakeholder_type", "verified_at",
):
assert expected in cols, f"Missing column: {expected}"
async def test_user_id_nullable(self, db):
"""lead_requests.user_id should accept NULL for guest leads."""
await db.execute(
"INSERT INTO lead_requests (lead_type, contact_email, created_at) VALUES (?, ?, datetime('now'))",
("quote", "guest@example.com"),
)
await db.commit()
async with db.execute(
"SELECT user_id FROM lead_requests WHERE contact_email = 'guest@example.com'"
) as cur:
row = await cur.fetchone()
assert row[0] is None

View File

@@ -0,0 +1,250 @@
"""
Tests for augment_d() chart output.
Regression for: charts not rendering because augment_d() was producing raw
data dicts instead of full Chart.js 4.x config objects. Each chart must have
the shape {type, data: {labels, datasets: [{data, ...}]}, options} so that
initCharts() can pass it directly to new Chart(canvas, config).
"""
import json
import pytest
from padelnomics.planner.calculator import calc, validate_state
from padelnomics.planner.routes import augment_d
def make_result(lang="en", **state_overrides):
"""Return (d, s) with augment_d applied."""
s = validate_state(state_overrides)
d = calc(s)
augment_d(d, s, lang)
return d, s
INDOOR_CHART_KEYS = ["capex_chart", "ramp_chart", "pl_chart", "cf_chart", "cum_chart", "dscr_chart"]
ALL_CHART_KEYS = INDOOR_CHART_KEYS + ["season_chart"]
# ════════════════════════════════════════════════════════════
# Structure: every chart must be a valid Chart.js config
# ════════════════════════════════════════════════════════════
class TestChartStructure:
"""Each chart must have the shape Chart.js 4.x expects: {type, data, options}."""
def test_all_indoor_charts_present(self):
d, _ = make_result()
for key in INDOOR_CHART_KEYS:
assert key in d, f"Missing chart key: {key}"
def test_season_chart_present_for_outdoor(self):
d, _ = make_result(venue="outdoor")
assert "season_chart" in d
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_has_type_string(self, key):
d, _ = make_result()
assert "type" in d[key], f"{key} missing 'type'"
assert isinstance(d[key]["type"], str)
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_has_data_with_datasets_list(self, key):
d, _ = make_result()
chart = d[key]
assert "data" in chart, f"{key} missing 'data'"
assert "datasets" in chart["data"], f"{key} missing 'data.datasets'"
assert isinstance(chart["data"]["datasets"], list)
assert len(chart["data"]["datasets"]) >= 1
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_has_responsive_options(self, key):
d, _ = make_result()
opts = d[key].get("options", {})
assert opts.get("responsive") is True, f"{key} options.responsive must be True"
assert opts.get("maintainAspectRatio") is False, f"{key} options.maintainAspectRatio must be False"
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_chart_is_json_serializable(self, key):
"""Charts are embedded as JSON in templates — they must serialise cleanly."""
d, _ = make_result()
json.dumps(d[key]) # must not raise
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
def test_datasets_each_have_data_array(self, key):
d, _ = make_result()
for ds in d[key]["data"]["datasets"]:
assert "data" in ds, f"{key} dataset missing 'data'"
assert isinstance(ds["data"], list)
# ════════════════════════════════════════════════════════════
# Per-chart specifics
# ════════════════════════════════════════════════════════════
class TestCapexChart:
def test_type_is_doughnut(self):
d, _ = make_result()
assert d["capex_chart"]["type"] == "doughnut"
def test_labels_data_colors_same_length(self):
d, _ = make_result()
chart = d["capex_chart"]
ds = chart["data"]["datasets"][0]
assert len(chart["data"]["labels"]) == len(ds["data"]) == len(ds["backgroundColor"])
def test_only_nonzero_items_included(self):
"""Zero-amount CAPEX items must be excluded from the chart."""
d, _ = make_result()
for val in d["capex_chart"]["data"]["datasets"][0]["data"]:
assert val > 0, "Chart must only contain items with amount > 0"
def test_cutout_set(self):
d, _ = make_result()
assert d["capex_chart"]["options"].get("cutout") == "60%"
def test_legend_position_right(self):
d, _ = make_result()
legend = d["capex_chart"]["options"]["plugins"]["legend"]
assert legend["position"] == "right"
class TestRampChart:
def test_type_is_line(self):
d, _ = make_result()
assert d["ramp_chart"]["type"] == "line"
def test_two_datasets(self):
d, _ = make_result()
assert len(d["ramp_chart"]["data"]["datasets"]) == 2
def test_24_data_points(self):
d, _ = make_result()
for ds in d["ramp_chart"]["data"]["datasets"]:
assert len(ds["data"]) == 24
def test_labels_translated(self):
d_en, _ = make_result(lang="en")
d_de, _ = make_result(lang="de")
en_label = d_en["ramp_chart"]["data"]["datasets"][0]["label"]
de_label = d_de["ramp_chart"]["data"]["datasets"][0]["label"]
assert en_label != de_label, "Ramp chart labels must be translated"
class TestPLChart:
def test_type_is_bar(self):
d, _ = make_result()
assert d["pl_chart"]["type"] == "bar"
def test_horizontal_axis(self):
"""P&L chart must be horizontal (indexAxis='y')."""
d, _ = make_result()
assert d["pl_chart"]["options"].get("indexAxis") == "y"
def test_five_labels_and_values(self):
d, _ = make_result()
assert len(d["pl_chart"]["data"]["labels"]) == 5
assert len(d["pl_chart"]["data"]["datasets"][0]["data"]) == 5
def test_colors_reflect_sign(self):
"""Positive values → green, negative → red."""
d, _ = make_result()
ds = d["pl_chart"]["data"]["datasets"][0]
for val, color in zip(ds["data"], ds["backgroundColor"]):
if val >= 0:
assert "22,163,74" in color, f"Expected green for positive {val}"
else:
assert "239,68,68" in color, f"Expected red for negative {val}"
class TestCFChart:
def test_type_is_bar(self):
d, _ = make_result()
assert d["cf_chart"]["type"] == "bar"
def test_60_data_points(self):
d, _ = make_result()
assert len(d["cf_chart"]["data"]["datasets"][0]["data"]) == 60
def test_colors_reflect_sign(self):
d, _ = make_result()
ds = d["cf_chart"]["data"]["datasets"][0]
for val, color in zip(ds["data"], ds["backgroundColor"]):
if val >= 0:
assert "22,163,74" in color
else:
assert "239,68,68" in color
class TestCumChart:
def test_type_is_line(self):
d, _ = make_result()
assert d["cum_chart"]["type"] == "line"
def test_60_data_points(self):
d, _ = make_result()
assert len(d["cum_chart"]["data"]["datasets"][0]["data"]) == 60
def test_fill_enabled(self):
d, _ = make_result()
assert d["cum_chart"]["data"]["datasets"][0].get("fill") is True
class TestDSCRChart:
def test_type_is_bar(self):
d, _ = make_result()
assert d["dscr_chart"]["type"] == "bar"
def test_five_entries(self):
d, _ = make_result()
assert len(d["dscr_chart"]["data"]["datasets"][0]["data"]) == 5
def test_colors_reflect_1_2_threshold(self):
d, _ = make_result()
ds = d["dscr_chart"]["data"]["datasets"][0]
for val, color in zip(ds["data"], ds["backgroundColor"]):
if val >= 1.2:
assert "22,163,74" in color, f"Expected green for DSCR {val} >= 1.2"
else:
assert "239,68,68" in color, f"Expected red for DSCR {val} < 1.2"
def test_values_capped_at_10(self):
"""DSCR values above 10 (no-debt scenarios) must be capped."""
d, _ = make_result(loanPct=0)
for val in d["dscr_chart"]["data"]["datasets"][0]["data"]:
assert val <= 10
class TestSeasonChart:
def test_type_is_bar(self):
d, _ = make_result(venue="outdoor")
assert d["season_chart"]["type"] == "bar"
def test_12_points(self):
d, _ = make_result(venue="outdoor")
assert len(d["season_chart"]["data"]["datasets"][0]["data"]) == 12
assert len(d["season_chart"]["data"]["labels"]) == 12
def test_values_are_percentages(self):
"""Season values are fractions (01.5) multiplied by 100."""
d, s = make_result(venue="outdoor")
chart_vals = d["season_chart"]["data"]["datasets"][0]["data"]
expected = [v * 100 for v in s["season"]]
assert chart_vals == expected
def test_labels_translated(self):
d_en, _ = make_result(lang="en", venue="outdoor")
d_de, _ = make_result(lang="de", venue="outdoor")
assert d_en["season_chart"]["data"]["labels"] != d_de["season_chart"]["data"]["labels"]
# ════════════════════════════════════════════════════════════
# All combos: charts always valid
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("venue,own", [
("indoor", "rent"), ("indoor", "buy"), ("outdoor", "rent"), ("outdoor", "buy")
])
def test_all_charts_json_serializable_for_all_combos(venue, own):
d, _ = make_result(venue=venue, own=own)
for key in INDOOR_CHART_KEYS:
json.dumps(d[key])

View File

@@ -0,0 +1,123 @@
"""
Tests for planner route responses.
Regression for:
1. OOB swap for #wizPreview stripping class="wizard-preview", causing the
flex layout to break and CAPEX/CF/IRR values to stack vertically.
Fix: calculate_response.html OOB element must include class="wizard-preview".
2. Charts: /calculate must embed valid Chart.js JSON (not raw data dicts).
"""
import json
class TestCalculateEndpoint:
async def test_returns_200(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
assert resp.status_code == 200
async def test_returns_html(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
assert resp.content_type.startswith("text/html")
async def test_all_tabs_render(self, client):
for tab in ("capex", "operating", "cashflow", "returns", "metrics"):
resp = await client.post(
"/en/planner/calculate", form={"activeTab": tab}
)
assert resp.status_code == 200, f"Tab {tab} returned {resp.status_code}"
async def test_german_endpoint_works(self, client):
resp = await client.post("/de/planner/calculate", form={"activeTab": "capex"})
assert resp.status_code == 200
class TestWizPreviewOOBSwap:
"""
Regression: HTMX outerHTML OOB swap replaces the entire #wizPreview element.
If the response element lacks class="wizard-preview", the flex layout is lost
and the three preview values stack vertically on every recalculation.
"""
async def test_oob_element_has_wizard_preview_class(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
# The OOB swap element must carry class="wizard-preview" so the flex
# box layout survives the outerHTML replacement.
assert 'class="wizard-preview"' in body, (
"OOB #wizPreview element must include class='wizard-preview' "
"to preserve flex layout after HTMX outerHTML swap"
)
async def test_oob_element_has_correct_id(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert 'id="wizPreview"' in body
async def test_oob_element_has_hx_swap_oob(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert 'hx-swap-oob="true"' in body
class TestChartJSONInResponse:
"""
Regression: augment_d() was embedding raw data dicts instead of full
Chart.js configs. initCharts() passes the embedded JSON directly to
new Chart(canvas, config) — which requires {type, data, options}.
"""
async def _get_chart_json(self, client, chart_id: str, tab: str) -> dict:
resp = await client.post(
"/en/planner/calculate", form={"activeTab": tab}
)
body = (await resp.get_data()).decode()
# Charts are embedded as: <script type="application/json" id="chartX-data">...</script>
marker = f'id="{chart_id}-data">'
start = body.find(marker)
assert start != -1, f"Chart script tag '{chart_id}-data' not found in response"
start += len(marker)
end = body.find("</script>", start)
return json.loads(body[start:end])
async def test_capex_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartCapex", "capex")
assert config["type"] == "doughnut"
assert "datasets" in config["data"]
assert "options" in config
async def test_cf_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartCF", "cashflow")
assert config["type"] == "bar"
assert "datasets" in config["data"]
assert config["options"]["responsive"] is True
assert config["options"]["maintainAspectRatio"] is False
async def test_dscr_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartDSCR", "returns")
assert config["type"] == "bar"
assert len(config["data"]["datasets"][0]["data"]) == 5
async def test_ramp_chart_is_valid_chartjs_config(self, client):
config = await self._get_chart_json(client, "chartRevRamp", "operating")
assert config["type"] == "line"
assert len(config["data"]["datasets"]) == 2
async def test_pl_chart_is_horizontal_bar(self, client):
config = await self._get_chart_json(client, "chartPL", "operating")
assert config["type"] == "bar"
assert config["options"]["indexAxis"] == "y"
class TestWizSummaryLabel:
"""The wizard preview must include the summary caption."""
async def test_summary_caption_in_response(self, client):
resp = await client.post("/en/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert "Live Summary" in body
async def test_german_summary_caption_in_response(self, client):
resp = await client.post("/de/planner/calculate", form={"activeTab": "capex"})
body = (await resp.get_data()).decode()
assert "Aktuelle Werte" in body

View File

@@ -0,0 +1,244 @@
"""
Playwright tests for the 9-step quote wizard flow.
Tests the full happy path, back navigation with data preservation,
and validation error handling.
Run explicitly with:
uv run pytest -m visual tests/test_quote_wizard.py -v
"""
import asyncio
import multiprocessing
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.app import create_app
from playwright.sync_api import expect, sync_playwright
pytestmark = pytest.mark.visual
SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
SCREENSHOTS_DIR.mkdir(exist_ok=True)
def _run_server(ready_event):
"""Run the Quart dev server in a separate process."""
import aiosqlite
async def _serve():
schema_path = (
Path(__file__).parent.parent
/ "src"
/ "padelnomics"
/ "migrations"
/ "schema.sql"
)
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_path.read_text())
await conn.commit()
core._db = conn
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock):
app = create_app()
app.config["TESTING"] = True
ready_event.set()
await app.run_task(host="127.0.0.1", port=5112)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
"""Start a live Quart server on port 5112 for quote wizard tests."""
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield "http://127.0.0.1:5112"
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
"""Launch a headless Chromium browser."""
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture
def page(browser):
"""Create a page for the quote wizard tests."""
pg = browser.new_page(viewport={"width": 1280, "height": 900})
yield pg
pg.close()
def _fill_step_1(page):
"""Fill step 1: facility type = indoor."""
page.locator("input[name='facility_type'][value='indoor']").check()
def _fill_step_2(page):
"""Fill step 2: country = Germany."""
page.select_option("select[name='country']", "DE")
def _fill_step_5(page):
"""Fill step 5: timeline = 3-6mo."""
page.locator("input[name='timeline'][value='3-6mo']").check()
def _fill_step_7(page):
"""Fill step 7: stakeholder type = entrepreneur."""
page.locator("input[name='stakeholder_type'][value='entrepreneur']").check()
def _fill_step_9(page):
"""Fill step 9: contact details."""
page.fill("input[name='contact_name']", "Test User")
page.fill("input[name='contact_email']", "test@example.com")
page.locator("input[name='consent']").check()
def _click_next(page):
"""Click the Next button and wait for HTMX swap."""
page.locator("button.q-btn-next").click()
page.wait_for_timeout(500)
def _click_back(page):
"""Click the Back button and wait for HTMX swap."""
page.locator("button.q-btn-back").click()
page.wait_for_timeout(500)
def test_quote_wizard_full_flow(live_server, page):
"""Complete all 9 steps and submit — verify success page shown."""
page.goto(f"{live_server}/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: Your Project
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_fill_step_1(page)
_click_next(page)
# Step 2: Location
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
_fill_step_2(page)
_click_next(page)
# Step 3: Build Context (optional — just click next)
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
_click_next(page)
# Step 4: Project Phase (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_next(page)
# Step 5: Timeline
expect(page.locator("h2.q-step-title")).to_contain_text("Timeline")
_fill_step_5(page)
_click_next(page)
# Step 6: Financing (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
_click_next(page)
# Step 7: About You
expect(page.locator("h2.q-step-title")).to_contain_text("About You")
_fill_step_7(page)
_click_next(page)
# Step 8: Services Needed (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed")
_click_next(page)
# Step 9: Contact Details
expect(page.locator("h2.q-step-title")).to_contain_text("Contact Details")
_fill_step_9(page)
# Submit the form
page.locator("button.q-btn-submit").click()
page.wait_for_load_state("networkidle")
# Should see either success page or verification sent page (both acceptable)
body_text = page.locator("body").inner_text()
assert "matched" in body_text.lower() or "check your email" in body_text.lower() or "verify" in body_text.lower(), \
f"Expected success or verification page, got: {body_text[:200]}"
page.screenshot(path=str(SCREENSHOTS_DIR / "quote_wizard_submitted.png"), full_page=True)
def test_quote_wizard_back_navigation(live_server, page):
"""Go forward 3 steps, go back, verify data preserved in form fields."""
page.goto(f"{live_server}/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: select Indoor
_fill_step_1(page)
_click_next(page)
# Step 2: select Germany
_fill_step_2(page)
page.fill("input[name='city']", "Berlin")
_click_next(page)
# Step 3: select a build context
page.locator("input[name='build_context'][value='new_standalone']").check()
_click_next(page)
# Step 4: now go back to step 3
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_back(page)
# Verify we're on step 3 and build_context is preserved
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
checked = page.locator("input[name='build_context'][value='new_standalone']").is_checked()
assert checked, "Build context 'new_standalone' should still be checked after going back"
# Go back to step 2 and verify data preserved
_click_back(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
country_val = page.locator("select[name='country']").input_value()
assert country_val == "DE", f"Country should be DE, got {country_val}"
city_val = page.locator("input[name='city']").input_value()
assert city_val == "Berlin", f"City should be Berlin, got {city_val}"
def test_quote_wizard_validation_errors(live_server, page):
"""Skip a required field on step 1 — verify error shown on same step."""
page.goto(f"{live_server}/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: DON'T select facility_type, just click Next
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_click_next(page)
# Should still be on step 1 with an error hint
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()
# Now fill the field and proceed — should work
_fill_step_1(page)
_click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
# Skip country (required on step 2) — should stay on step 2
_click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()

View File

@@ -0,0 +1,429 @@
"""
Integration tests for supplier webhook handlers.
POST real webhook payloads to /billing/webhook/paddle and verify DB state.
Uses the existing client, db, sign_payload from conftest.
"""
import json
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from conftest import sign_payload
WEBHOOK_PATH = "/billing/webhook/paddle"
SIG_HEADER = "Paddle-Signature"
# ── Fixtures ─────────────────────────────────────────────────
@pytest.fixture
async def supplier(db):
"""Supplier with tier=free, credit_balance=0."""
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
credit_balance, monthly_credits, created_at)
VALUES ('Webhook Test Supplier', 'webhook-test', 'DE', 'Europe',
'Courts', 'free', 0, 0, ?)""",
(now,),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
return {"id": supplier_id}
@pytest.fixture
async def paddle_products(db):
"""Insert paddle_products rows for all keys the handlers need."""
now = datetime.utcnow().isoformat()
products = [
("credits_25", "pri_credits25", "Credit Pack 25", 999, "one_time"),
("credits_100", "pri_credits100", "Credit Pack 100", 3290, "one_time"),
("boost_sticky_week", "pri_sticky_week", "Sticky Week", 4900, "one_time"),
("boost_sticky_month", "pri_sticky_month", "Sticky Month", 14900, "one_time"),
("boost_highlight", "pri_highlight", "Highlight", 2900, "recurring"),
("boost_verified", "pri_verified", "Verified Badge", 1900, "recurring"),
("boost_card_color", "pri_card_color", "Card Color", 1900, "recurring"),
("supplier_growth", "pri_growth", "Growth Plan", 14900, "recurring"),
("supplier_pro", "pri_pro", "Pro Plan", 39900, "recurring"),
("business_plan", "pri_bplan", "Business Plan PDF", 9900, "one_time"),
]
for key, price_id, name, price_cents, billing_type in products:
await db.execute(
"""INSERT INTO paddle_products
(key, paddle_product_id, paddle_price_id, name, price_cents, billing_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(key, f"pro_{key}", price_id, name, price_cents, billing_type, now),
)
await db.commit()
# ── Helpers ──────────────────────────────────────────────────
def make_transaction_payload(items, supplier_id, **custom_data_extra):
"""Build a transaction.completed event payload."""
custom_data = {"supplier_id": str(supplier_id), **custom_data_extra}
return {
"event_type": "transaction.completed",
"data": {
"id": "txn_test_123",
"status": "completed",
"customer_id": "ctm_supplier_test",
"custom_data": custom_data,
"items": [
{"price": {"id": price_id}, "quantity": qty}
for price_id, qty in items
],
},
}
def make_supplier_activation_payload(items, supplier_id, plan, user_id):
"""Build a subscription.activated event for supplier plans."""
return {
"event_type": "subscription.activated",
"data": {
"id": "sub_supplier_test_456",
"status": "active",
"customer_id": "ctm_supplier_test",
"custom_data": {
"supplier_id": str(supplier_id),
"plan": plan,
"user_id": str(user_id),
},
"current_billing_period": {
"starts_at": "2026-02-01T00:00:00.000000Z",
"ends_at": "2026-03-01T00:00:00.000000Z",
},
"items": [
{"price": {"id": price_id}, "quantity": qty}
for price_id, qty in items
],
},
}
def _post_webhook(client, payload):
"""Sign and POST a webhook payload, return awaitable response."""
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
return client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
# ── Credit Pack Purchase ─────────────────────────────────────
class TestCreditPackPurchase:
async def test_credits_25_adds_25_credits(self, client, db, supplier, paddle_products):
payload = make_transaction_payload([("pri_credits25", 1)], supplier["id"])
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier["id"],),
)
assert row[0][0] == 25
ledger = await db.execute_fetchall(
"SELECT delta, event_type, note FROM credit_ledger WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(ledger) == 1
assert ledger[0][0] == 25
assert ledger[0][1] == "pack_purchase"
async def test_credits_100_adds_100_credits(self, client, db, supplier, paddle_products):
payload = make_transaction_payload([("pri_credits100", 1)], supplier["id"])
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier["id"],),
)
assert row[0][0] == 100
# ── Sticky Boost Purchase ────────────────────────────────────
class TestStickyBoostPurchase:
async def test_sticky_week_creates_boost_and_updates_supplier(
self, client, db, supplier, paddle_products,
):
payload = make_transaction_payload(
[("pri_sticky_week", 1)], supplier["id"],
sticky_country="DE",
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
# Verify supplier_boosts row
boosts = await db.execute_fetchall(
"SELECT boost_type, status, expires_at FROM supplier_boosts WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(boosts) == 1
assert boosts[0][0] == "sticky_week"
assert boosts[0][1] == "active"
# expires_at should be ~7 days from now
expires = datetime.fromisoformat(boosts[0][2])
assert abs((expires - datetime.utcnow()).days - 7) <= 1
# Verify sticky_until set on supplier
sup = await db.execute_fetchall(
"SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert sup[0][0] is not None # sticky_until set
assert sup[0][1] == "DE"
async def test_sticky_month_creates_boost_and_updates_supplier(
self, client, db, supplier, paddle_products,
):
payload = make_transaction_payload(
[("pri_sticky_month", 1)], supplier["id"],
sticky_country="ES",
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
boosts = await db.execute_fetchall(
"SELECT boost_type, expires_at FROM supplier_boosts WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(boosts) == 1
assert boosts[0][0] == "sticky_month"
expires = datetime.fromisoformat(boosts[0][1])
assert abs((expires - datetime.utcnow()).days - 30) <= 1
async def test_sticky_boost_sets_country(self, client, db, supplier, paddle_products):
payload = make_transaction_payload(
[("pri_sticky_week", 1)], supplier["id"],
sticky_country="FR",
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
sup = await db.execute_fetchall(
"SELECT sticky_country FROM suppliers WHERE id = ?", (supplier["id"],),
)
assert sup[0][0] == "FR"
# ── Supplier Subscription Activated ──────────────────────────
class TestSupplierSubscriptionActivated:
async def test_growth_plan_sets_tier_and_credits(
self, client, db, supplier, paddle_products, test_user,
):
payload = make_supplier_activation_payload(
items=[("pri_growth", 1)],
supplier_id=supplier["id"],
plan="supplier_growth",
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT tier, credit_balance, monthly_credits, claimed_by FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "growth"
assert row[0][1] == 30
assert row[0][2] == 30
assert row[0][3] == test_user["id"]
async def test_pro_plan_sets_tier_and_credits(
self, client, db, supplier, paddle_products, test_user,
):
payload = make_supplier_activation_payload(
items=[("pri_pro", 1)],
supplier_id=supplier["id"],
plan="supplier_pro",
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT tier, credit_balance, monthly_credits FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "pro"
assert row[0][1] == 100
assert row[0][2] == 100
async def test_boost_items_create_boost_records(
self, client, db, supplier, paddle_products, test_user,
):
payload = make_supplier_activation_payload(
items=[
("pri_growth", 1),
("pri_highlight", 1),
("pri_verified", 1),
],
supplier_id=supplier["id"],
plan="supplier_growth",
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
# Verify boost rows
boosts = await db.execute_fetchall(
"SELECT boost_type FROM supplier_boosts WHERE supplier_id = ? ORDER BY boost_type",
(supplier["id"],),
)
boost_types = [b[0] for b in boosts]
assert "highlight" in boost_types
assert "verified" in boost_types
# Verify denormalized columns
sup = await db.execute_fetchall(
"SELECT highlight, is_verified FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert sup[0][0] == 1 # highlight
assert sup[0][1] == 1 # is_verified
async def test_basic_plan_sets_tier_zero_credits_and_verified(
self, client, db, supplier, paddle_products, test_user,
):
"""Basic plan: tier='basic', 0 credits, is_verified=1, no ledger entry."""
payload = make_supplier_activation_payload(
items=[],
supplier_id=supplier["id"],
plan="supplier_basic",
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT tier, credit_balance, monthly_credits, is_verified FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "basic"
assert row[0][1] == 0
assert row[0][2] == 0
assert row[0][3] == 1
# No credit ledger entry for Basic (0 credits)
ledger = await db.execute_fetchall(
"SELECT id FROM credit_ledger WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(ledger) == 0
async def test_yearly_plan_derives_correct_tier(
self, client, db, supplier, paddle_products, test_user,
):
"""Yearly plan key suffix is stripped; tier derives correctly."""
payload = make_supplier_activation_payload(
items=[("pri_growth", 1)],
supplier_id=supplier["id"],
plan="supplier_growth_yearly", # yearly variant
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT tier, credit_balance FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "growth"
assert row[0][1] == 30
async def test_no_supplier_id_is_noop(
self, client, db, supplier, paddle_products, test_user,
):
"""Activation without supplier_id in custom_data does nothing."""
payload = {
"event_type": "subscription.activated",
"data": {
"id": "sub_no_supplier",
"status": "active",
"customer_id": "ctm_test",
"custom_data": {
"plan": "supplier_growth",
"user_id": str(test_user["id"]),
# no supplier_id
},
"current_billing_period": {
"starts_at": "2026-02-01T00:00:00.000000Z",
"ends_at": "2026-03-01T00:00:00.000000Z",
},
"items": [],
},
}
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
# Supplier unchanged
row = await db.execute_fetchall(
"SELECT tier, credit_balance FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "free"
assert row[0][1] == 0
# ── Business Plan Purchase ───────────────────────────────────
class TestBusinessPlanPurchase:
async def test_creates_export_record(
self, client, db, supplier, paddle_products, test_user,
):
# Need a scenario for the export
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO scenarios (user_id, name, state_json, created_at)
VALUES (?, 'Test Scenario', '{}', ?)""",
(test_user["id"], now),
) as cursor:
scenario_id = cursor.lastrowid
await db.commit()
payload = {
"event_type": "transaction.completed",
"data": {
"id": "txn_bplan_123",
"status": "completed",
"customer_id": "ctm_test",
"custom_data": {
"user_id": str(test_user["id"]),
"scenario_id": str(scenario_id),
"language": "de",
},
"items": [{"price": {"id": "pri_bplan"}, "quantity": 1}],
},
}
# enqueue is lazily imported inside the handler via `from ..worker import enqueue`
# Patching at the module level ensures the lazy import picks up the mock
with patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
# Verify business_plan_exports row
exports = await db.execute_fetchall(
"SELECT user_id, scenario_id, language, status FROM business_plan_exports",
)
assert len(exports) == 1
assert exports[0][0] == test_user["id"]
assert exports[0][1] == scenario_id
assert exports[0][2] == "de"
assert exports[0][3] == "pending"

332
web/tests/test_visual.py Normal file
View File

@@ -0,0 +1,332 @@
"""
Visual regression tests using Playwright.
Takes screenshots of key pages and verifies styling invariants
(heading colors, backgrounds, nav layout, logo presence).
Skipped by default (requires `playwright install chromium`).
Run explicitly with:
uv run pytest -m visual tests/test_visual.py -v
Screenshots are saved to tests/screenshots/ for manual review.
"""
import asyncio
import multiprocessing
import sqlite3
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.app import create_app
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
pytestmark = pytest.mark.visual
SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
SCREENSHOTS_DIR.mkdir(exist_ok=True)
def parse_rgb(color_str):
"""Parse rgb(r,g,b) or rgba(r,g,b,a) into [r, g, b]."""
import re
nums = re.findall(r"[\d.]+", color_str)
return [int(float(x)) for x in nums[:3]]
def _run_server(ready_event):
"""Run the Quart dev server in a separate process."""
import aiosqlite
async def _serve():
# Build schema DDL by replaying migrations against a temp DB
tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db")
migrate(tmp_db)
tmp_conn = sqlite3.connect(tmp_db)
rows = tmp_conn.execute(
"SELECT sql FROM sqlite_master"
" WHERE sql IS NOT NULL"
" AND name NOT LIKE 'sqlite_%'"
" AND name NOT LIKE '%_fts_%'"
" AND name != '_migrations'"
" ORDER BY rowid"
).fetchall()
tmp_conn.close()
schema_ddl = ";\n".join(r[0] for r in rows) + ";"
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_ddl)
await conn.commit()
core._db = conn
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock):
app = create_app()
app.config["TESTING"] = True
# Signal that the server is about to start
ready_event.set()
await app.run_task(host="127.0.0.1", port=5111)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
"""Start a live Quart server on port 5111 for Playwright tests."""
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
# Give server a moment to bind
time.sleep(1)
yield "http://127.0.0.1:5111"
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
"""Launch a headless Chromium browser."""
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture
def page(browser):
"""Create a page with dark OS preference to catch theme leaks."""
pg = browser.new_page(
viewport={"width": 1280, "height": 900},
color_scheme="dark",
)
yield pg
pg.close()
# ── Landing page tests ──────────────────────────────────────
def test_landing_screenshot(live_server, page):
"""Take a full-page screenshot of the landing page."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "landing_full.png"), full_page=True)
def test_landing_light_background(live_server, page):
"""Verify the page has a light background, not dark."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Tailwind sets background on body via base layer
bg_color = page.evaluate("""
(() => {
const html_bg = getComputedStyle(document.documentElement).backgroundColor;
const body_bg = getComputedStyle(document.body).backgroundColor;
// Use whichever is non-transparent
if (body_bg && !body_bg.includes('0, 0, 0, 0')) return body_bg;
return html_bg;
})()
""")
rgb = parse_rgb(bg_color)
brightness = sum(rgb) / 3
assert brightness > 200, f"Background too dark: {bg_color} (brightness={brightness})"
def test_landing_heading_colors(live_server, page):
"""Verify headings are readable (dark on light, white on dark hero)."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# H1 is intentionally white (#fff) on the dark hero background — skip brightness check.
# Instead verify it's not transparent/invisible (i.e. has some color set).
h1_color = page.evaluate(
"getComputedStyle(document.querySelector('h1')).color"
)
rgb = parse_rgb(h1_color)
assert rgb != [0, 0, 0] or sum(rgb) > 0, f"H1 appears unset: {h1_color}"
# H2s on light sections should be dark; skip h2s inside dark containers.
h2_data = page.evaluate("""
Array.from(document.querySelectorAll('h2')).map(el => {
const inDark = el.closest('.hero-dark, .cta-card') !== null;
return {color: getComputedStyle(el).color, inDark};
})
""")
for i, item in enumerate(h2_data):
if item["inDark"]:
continue # white-on-dark is intentional
rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3
assert brightness < 100, f"H2[{i}] too light: {item['color']} (brightness={brightness})"
# H3s on light sections should be dark
h3_data = page.evaluate("""
Array.from(document.querySelectorAll('h3')).map(el => {
const inDark = el.closest('.hero-dark, .cta-card') !== null;
return {color: getComputedStyle(el).color, inDark};
})
""")
for i, item in enumerate(h3_data):
if item["inDark"]:
continue
rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3
# Allow up to 150 — catches near-white text while accepting readable
# medium-gray secondary headings (e.g. slate #64748B ≈ brightness 118).
assert brightness < 150, f"H3[{i}] too light: {item['color']} (brightness={brightness})"
def test_landing_logo_present(live_server, page):
"""Verify the nav logo link is visible."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Logo is a text <span> inside an <a class="nav-logo">, not an <img>
logo = page.locator("nav a.nav-logo")
expect(logo).to_be_visible()
text = logo.inner_text()
assert len(text) > 0, "Nav logo link has no text"
def test_landing_nav_no_overlap(live_server, page):
"""Verify nav items don't overlap each other."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Get bounding boxes of direct children in the nav's right-side flex container
boxes = page.evaluate("""
(() => {
const navDiv = document.querySelector('nav > div');
if (!navDiv) return [];
const items = navDiv.children;
return Array.from(items).map(el => {
const r = el.getBoundingClientRect();
return {top: r.top, bottom: r.bottom, left: r.left, right: r.right};
});
})()
""")
# Check no horizontal overlap between consecutive items
for i in range(len(boxes) - 1):
a, b = boxes[i], boxes[i + 1]
h_overlap = a["right"] - b["left"]
# Allow a few px of overlap from padding/margins, but not significant
assert h_overlap < 10, (
f"Nav items {i} and {i+1} overlap horizontally by {h_overlap:.0f}px"
)
def test_landing_cards_have_colored_borders(live_server, page):
"""Verify landing page cards have a visible left border accent."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
border_widths = page.evaluate("""
Array.from(document.querySelectorAll('.card')).map(
el => parseFloat(getComputedStyle(el).borderLeftWidth)
)
""")
assert len(border_widths) > 0, "No .card elements found"
cards_with_accent = [w for w in border_widths if w >= 4]
assert len(cards_with_accent) >= 6, (
f"Expected >=6 cards with 4px left border, got {len(cards_with_accent)}"
)
def test_landing_logo_links_to_landing(live_server, page):
"""Verify nav-logo links to the landing page (language-prefixed or root)."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
href = page.locator("nav a.nav-logo").get_attribute("href")
# Accept "/" or any language-prefixed landing path, e.g. "/en/"
assert href == "/" or (href.startswith("/") and href.endswith("/")), (
f"Nav logo href unexpected: {href}"
)
def test_landing_teaser_light_theme(live_server, page):
"""Verify the ROI calc card has a white/light background."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Was .teaser-calc; now .roi-calc (white card embedded in dark hero)
teaser_bg = page.evaluate(
"getComputedStyle(document.querySelector('.roi-calc')).backgroundColor"
)
rgb = parse_rgb(teaser_bg)
brightness = sum(rgb) / 3
assert brightness > 240, f"ROI calc background too dark: {teaser_bg}"
# ── Auth page tests ──────────────────────────────────────────
def test_login_screenshot(live_server, page):
"""Take a screenshot of the login page."""
page.goto(f"{live_server}/auth/login")
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "login.png"), full_page=True)
def test_signup_screenshot(live_server, page):
"""Take a screenshot of the signup page."""
page.goto(f"{live_server}/auth/signup")
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "signup.png"), full_page=True)
# ── Mobile viewport tests ───────────────────────────────────
def test_mobile_landing_screenshot(live_server, browser):
"""Take a mobile-width screenshot of the landing page."""
page = browser.new_page(viewport={"width": 375, "height": 812})
page.goto(live_server)
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "landing_mobile.png"), full_page=True)
page.close()
def test_landing_no_dark_remnants(live_server, page):
"""Check that no major elements have dark backgrounds."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
dark_elements = page.evaluate("""
(() => {
const dark = [];
// Known intentional dark sections on the landing page
const allowedClasses = ['hero-dark', 'cta-card'];
const els = document.querySelectorAll('article, section, header, footer, main, div');
for (const el of els) {
// Skip intentionally dark sections
const cls = el.className || '';
if (allowedClasses.some(c => cls.includes(c))) continue;
const bg = getComputedStyle(el).backgroundColor;
if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue;
const m = bg.match(/\\d+/g);
if (m) {
const [r, g, b] = m.map(Number);
const brightness = (r + g + b) / 3;
if (brightness < 50) {
dark.push({tag: el.tagName, class: cls, bg, brightness});
}
}
}
return dark;
})()
""")
assert len(dark_elements) == 0, (
f"Found {len(dark_elements)} unexpected dark-background elements: "
f"{dark_elements[:3]}"
)

987
web/tests/test_waitlist.py Normal file
View File

@@ -0,0 +1,987 @@
"""
Tests for waitlist mode (lean startup smoke test).
Covers configuration, migration, worker tasks, auth routes, supplier routes,
edge cases (duplicates, invalid emails), and full integration flows.
"""
import sqlite3
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.worker import handle_send_waitlist_confirmation
# ── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_csrf_validation():
"""Mock CSRF validation to always pass in tests."""
with patch("padelnomics.core.validate_csrf_token", return_value=True):
yield
# ── Helpers ───────────────────────────────────────────────────────
async def _get_csrf_token(client):
"""Get a valid CSRF token by making a GET request first."""
await client.get("/")
async with client.session_transaction() as sess:
return sess.get("csrf_token", "test_fallback_token")
def _table_names(conn):
"""Return sorted list of user-visible table names (synchronous)."""
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]
def _column_names(conn, table):
"""Return list of column names for a table (synchronous)."""
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
async def _count_rows(db, table):
"""Count rows in a table (async)."""
async with db.execute(f"SELECT COUNT(*) as cnt FROM {table}") as cursor:
row = await cursor.fetchone()
return row[0] if row else 0
async def _get_waitlist_entry(db, email, intent="signup"):
"""Fetch a waitlist entry by email and intent (async)."""
async with db.execute(
"SELECT * FROM waitlist WHERE email = ? AND intent = ?",
(email, intent)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
# ── TestConfiguration ─────────────────────────────────────────────
class TestConfiguration:
"""Test WAITLIST_MODE config flag."""
def test_waitlist_mode_defaults_to_false(self):
"""WAITLIST_MODE should default to false when not set."""
# Config is loaded from env, but we can check the default behavior
# The _env helper treats empty string same as unset
assert hasattr(core.config, "WAITLIST_MODE")
assert isinstance(core.config.WAITLIST_MODE, bool)
def test_waitlist_mode_can_be_enabled(self):
"""WAITLIST_MODE can be set to True via config."""
with patch.object(core.config, "WAITLIST_MODE", True):
assert core.config.WAITLIST_MODE is True
def test_waitlist_mode_can_be_disabled(self):
"""WAITLIST_MODE can be set to False via config."""
with patch.object(core.config, "WAITLIST_MODE", False):
assert core.config.WAITLIST_MODE is False
# ── TestMigration ─────────────────────────────────────────────────
class TestMigration:
"""Test 0014_add_waitlist migration."""
def test_creates_waitlist_table(self, tmp_path):
"""Migration should create waitlist table."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
tables = _table_names(conn)
conn.close()
assert "waitlist" in tables
def test_waitlist_table_has_correct_columns(self, tmp_path):
"""waitlist table should have all required columns."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
cols = _column_names(conn, "waitlist")
conn.close()
assert "id" in cols
assert "email" in cols
assert "intent" in cols
assert "source" in cols
assert "plan" in cols
assert "ip_address" in cols
assert "created_at" in cols
def test_waitlist_has_unique_constraint(self, tmp_path):
"""waitlist should enforce UNIQUE(email, intent)."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
# Insert first row
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "signup")
)
conn.commit()
# Attempt duplicate - should fail without OR IGNORE
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "signup")
)
conn.commit()
conn.close()
def test_waitlist_allows_same_email_different_intent(self, tmp_path):
"""waitlist should allow same email with different intent."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "signup")
)
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "supplier_growth")
)
conn.commit()
count = conn.execute("SELECT COUNT(*) FROM waitlist").fetchone()[0]
conn.close()
assert count == 2
def test_waitlist_has_email_index(self, tmp_path):
"""Migration should create index on email column."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
indexes = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='waitlist'"
).fetchall()
index_names = [idx[0] for idx in indexes]
conn.close()
assert any("email" in idx for idx in index_names)
# ── TestWorkerTask ────────────────────────────────────────────────
class TestWorkerTask:
"""Test send_waitlist_confirmation worker task."""
@pytest.mark.asyncio
async def test_sends_entrepreneur_confirmation(self):
"""Task sends confirmation email for entrepreneur signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "entrepreneur@example.com",
"intent": "signup",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "launching soon" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio
async def test_sends_supplier_confirmation(self):
"""Task sends confirmation email for supplier signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_growth",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio
async def test_supplier_email_includes_plan_name(self):
"""Supplier confirmation should mention the specific plan."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_pro",
})
call_args = mock_send.call_args
html = call_args.kwargs["html"]
assert "pro" in html.lower()
@pytest.mark.asyncio
async def test_uses_transactional_email_address(self):
"""Task should use transactional sender address."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "test@example.com",
"intent": "signup",
})
call_args = mock_send.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
# ── TestAuthRoutes ────────────────────────────────────────────────
class TestAuthRoutes:
"""Test /auth/signup route with WAITLIST_MODE."""
@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
@pytest.mark.asyncio
async def test_shows_waitlist_form_when_enabled(self, client, db):
"""GET /auth/signup shows waitlist form when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/auth/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "join" in html.lower() or "early access" in html.lower()
@pytest.mark.asyncio
async def test_captures_email_to_waitlist_table(self, client, db):
"""POST /auth/signup inserts email into waitlist table."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
"plan": "free",
})
# Check database
entry = await _get_waitlist_entry(db, "new@example.com", "free")
assert entry is not None
assert entry["email"] == "new@example.com"
assert entry["intent"] == "free"
@pytest.mark.asyncio
async def test_enqueues_confirmation_email(self, client, db):
"""POST /auth/signup enqueues waitlist confirmation email."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
"plan": "signup",
})
mock_enqueue.assert_called_once_with(
"send_waitlist_confirmation",
{"email": "new@example.com", "intent": "signup"}
)
@pytest.mark.asyncio
async def test_shows_confirmation_page(self, client, db):
"""POST /auth/signup shows waitlist_confirmed.html."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
})
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "new@example.com" in html
assert "waitlist" in html.lower()
@pytest.mark.asyncio
async def test_handles_duplicate_email_gracefully(self, client, db):
"""Duplicate email submission shows same success page."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Insert first entry directly
await db.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("existing@example.com", "signup")
)
await db.commit()
# Submit same email via form
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "existing@example.com",
"plan": "signup",
})
# Should show success page (not error)
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "existing@example.com" in html
# Should only have one row
count = await _count_rows(db, "waitlist")
assert count == 1
@pytest.mark.asyncio
async def test_rejects_invalid_email(self, client, db):
"""POST with invalid email shows error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "not-an-email",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
@pytest.mark.asyncio
async def test_captures_ip_address(self, client, db):
"""POST captures request IP address in waitlist table."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
entry = await _get_waitlist_entry(db, "test@example.com")
assert entry is not None
assert entry["ip_address"] is not None
@pytest.mark.asyncio
async def test_adds_to_resend_audience_when_configured(self, client, db):
"""POST auto-creates Resend audience per blueprint and adds the contact."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Audiences.create", return_value={"id": "aud_test"}) as mock_create_aud, \
patch("resend.Contacts.create") as mock_create_contact:
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
mock_create_aud.assert_called_once_with({"name": "waitlist-auth"})
mock_create_contact.assert_called_once()
call_args = mock_create_contact.call_args[0][0]
assert call_args["email"] == "test@example.com"
assert call_args["audience_id"] == "aud_test"
@pytest.mark.asyncio
async def test_resend_audience_cached_on_second_signup(self, client, db):
"""Second signup for same blueprint reuses cached audience — no extra API call."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Audiences.create", return_value={"id": "aud_cached"}) as mock_create_aud, \
patch("resend.Contacts.create"):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "first@example.com",
})
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "second@example.com",
})
# Audience created only once; second call reads from DB cache
mock_create_aud.assert_called_once()
@pytest.mark.asyncio
async def test_supplier_and_auth_signups_go_to_different_audiences(self, client, db):
"""Auth and supplier signups are added to separate Resend audiences."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Audiences.create", side_effect=[
{"id": "aud_auth"},
{"id": "aud_suppliers"},
]) as mock_create_aud, \
patch("resend.Contacts.create") as mock_create_contact:
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "entrepreneur@example.com",
})
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
})
audience_names = [
call[0][0]["name"] for call in mock_create_aud.call_args_list
]
assert "waitlist-auth" in audience_names
assert "waitlist-suppliers" in audience_names
contact_audience_ids = [
call[0][0]["audience_id"] for call in mock_create_contact.call_args_list
]
assert "aud_auth" in contact_audience_ids
assert "aud_suppliers" in contact_audience_ids
@pytest.mark.asyncio
async def test_resend_audience_create_failure_still_succeeds(self, client, db):
"""Audiences.create failure is silent — signup still returns success page."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Audiences.create", side_effect=Exception("Resend down")):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "test@example.com" in html
# ── TestSupplierRoutes ────────────────────────────────────────────
class TestSupplierRoutes:
"""Test /suppliers/signup route with WAITLIST_MODE."""
@pytest.mark.asyncio
async def test_shows_waitlist_form_when_enabled(self, client, db):
"""GET /suppliers/signup shows waitlist form when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/en/suppliers/signup?plan=supplier_growth")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "supplier" in html.lower()
assert "growth" in html.lower()
@pytest.mark.asyncio
async def test_shows_normal_wizard_when_disabled(self, client, db):
"""GET /suppliers/signup shows normal wizard when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await client.get("/en/suppliers/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see wizard step 1, not waitlist form
assert "step" in html.lower() or "plan" in html.lower()
@pytest.mark.asyncio
async def test_captures_supplier_email_to_waitlist(self, client, db):
"""POST /suppliers/signup/waitlist inserts email into waitlist table."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
})
# Check database
entry = await _get_waitlist_entry(db, "supplier@example.com", "supplier")
assert entry is not None
assert entry["email"] == "supplier@example.com"
assert entry["intent"] == "supplier"
assert entry["plan"] == "supplier_growth"
@pytest.mark.asyncio
async def test_enqueues_supplier_confirmation_email(self, client, db):
"""POST /suppliers/signup/waitlist enqueues confirmation with plan intent."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_pro",
})
mock_enqueue.assert_called_once_with(
"send_waitlist_confirmation",
{"email": "supplier@example.com", "intent": "supplier_pro"}
)
@pytest.mark.asyncio
async def test_shows_supplier_confirmation_page(self, client, db):
"""POST /suppliers/signup/waitlist shows supplier-specific confirmation."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
})
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "supplier@example.com" in html
assert "supplier" in html.lower()
assert "waitlist" in html.lower()
@pytest.mark.asyncio
async def test_handles_duplicate_supplier_email(self, client, db):
"""Duplicate supplier email shows same success page."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Insert first entry
await db.execute(
"INSERT INTO waitlist (email, intent, plan) VALUES (?, ?, ?)",
("existing@supplier.com", "supplier", "supplier_growth")
)
await db.commit()
# Submit duplicate
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "existing@supplier.com",
"plan": "supplier_growth",
})
# Should show success page
assert response.status_code == 200
# Should still have only one row
count = await _count_rows(db, "waitlist")
assert count == 1
@pytest.mark.asyncio
async def test_rejects_invalid_supplier_email(self, client, db):
"""POST with invalid email redirects with error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "invalid",
"plan": "supplier_growth",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
# ── TestEdgeCases ─────────────────────────────────────────────────
class TestEdgeCases:
"""Test edge cases and error handling."""
@pytest.mark.asyncio
async def test_empty_email_rejected(self, client, db):
"""Empty email field shows error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
@pytest.mark.asyncio
async def test_whitespace_only_email_rejected(self, client, db):
"""Whitespace-only email shows error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": " ",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
@pytest.mark.asyncio
async def test_email_normalized_to_lowercase(self, client, db):
"""Email addresses are normalized to lowercase."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "Test@EXAMPLE.COM",
})
entry = await _get_waitlist_entry(db, "test@example.com")
assert entry is not None
assert entry["email"] == "test@example.com"
@pytest.mark.asyncio
async def test_database_error_shows_success_page(self, client, db):
"""Database errors are silently caught and success page still shown."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("padelnomics.core.execute", side_effect=Exception("DB error")):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
# Should still show success page (fail silently)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_resend_api_error_handled_gracefully(self, client, db):
"""Resend API errors don't break the flow."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Audiences.create", return_value={"id": "aud_test"}), \
patch("resend.Contacts.create", side_effect=Exception("API error")):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
# Should still show success page
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "test@example.com" in html
# ── TestIntegration ───────────────────────────────────────────────
class TestPlannerExport:
"""Test planner export waitlist gating."""
@pytest.mark.asyncio
async def test_export_shows_waitlist_when_enabled(self, auth_client, db):
"""GET /planner/export shows waitlist page when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", 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" in html.lower()
assert "business plan" in html.lower()
@pytest.mark.asyncio
async def test_export_shows_normal_page_when_disabled(self, auth_client, db):
"""GET /planner/export shows normal export page when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await auth_client.get("/en/planner/export")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see normal export page, not waitlist
assert "scenario" in html.lower() or "language" in html.lower()
@pytest.mark.asyncio
async def test_export_requires_login(self, client, db):
"""Export page requires authentication."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/en/planner/export", follow_redirects=False)
# Should redirect to login
assert response.status_code == 302 or response.status_code == 401
# ── TestWaitlistGateDecorator ─────────────────────────────────────
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()
@pytest.mark.asyncio
async def test_intercepts_get_when_waitlist_enabled(self, client):
"""Decorator intercepts GET requests when WAITLIST_MODE=true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/auth/signup")
html = await response.get_data(as_text=True)
# Should see waitlist page
assert "waitlist" in html.lower()
@pytest.mark.asyncio
async def test_ignores_post_requests(self, client, db):
"""Decorator lets POST requests through even when WAITLIST_MODE=true."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# POST should still be handled by waitlist logic, not bypassed
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
assert response.status_code == 200
@pytest.mark.asyncio
async def test_evaluates_callable_context(self, client):
"""Decorator evaluates callable context values at request time."""
with patch.object(core.config, "WAITLIST_MODE", True):
# Test that plan query param is passed to template
response = await client.get("/auth/signup?plan=starter")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_handles_complex_context_variables(self, client):
"""Decorator handles multiple context variables for suppliers."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/en/suppliers/signup?plan=supplier_pro")
html = await response.get_data(as_text=True)
assert response.status_code == 200
assert "supplier" in html.lower()
# ── TestCaptureWaitlistEmail ──────────────────────────────────────
class TestCaptureWaitlistEmail:
"""Test capture_waitlist_email helper via integration tests."""
@pytest.mark.asyncio
async def test_new_insert_returns_true(self, client, db):
"""Submitting new email creates database entry."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
})
# Verify DB entry created
entry = await _get_waitlist_entry(db, "new@example.com")
assert entry is not None
@pytest.mark.asyncio
async def test_duplicate_returns_false(self, client, db):
"""Submitting duplicate email doesn't create second entry."""
# Insert first entry
await db.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("existing@example.com", "signup")
)
await db.commit()
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "existing@example.com",
})
# Should still have only one row
count = await _count_rows(db, "waitlist")
assert count == 1
@pytest.mark.asyncio
async def test_enqueues_only_on_new(self, client, db):
"""Email confirmation only enqueued for new signups."""
# Already tested by test_enqueues_confirmation_email in TestAuthRoutes
pass
@pytest.mark.asyncio
async def test_adds_to_resend_audience(self, client, db):
"""Submitting email adds to Resend audience when configured."""
# Already tested by test_adds_to_resend_audience_when_configured in TestAuthRoutes
pass
@pytest.mark.asyncio
async def test_silent_error_on_db_failure(self, client, db):
"""DB errors are handled gracefully."""
# Already tested by test_database_error_shows_success_page in TestEdgeCases
pass
@pytest.mark.asyncio
async def test_silent_error_on_resend_failure(self, client, db):
"""Resend API errors are handled gracefully."""
# Already tested by test_resend_api_error_handled_gracefully in TestEdgeCases
pass
@pytest.mark.asyncio
async def test_captures_plan_parameter(self, client, db):
"""Supplier signup captures plan parameter."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "test@example.com",
"plan": "supplier_pro",
})
entry = await _get_waitlist_entry(db, "test@example.com", "supplier")
assert entry is not None
assert entry["plan"] == "supplier_pro"
@pytest.mark.asyncio
async def test_captures_ip_address(self, client, db):
"""Signup captures request IP address."""
# Already tested by test_captures_ip_address in TestAuthRoutes
pass
@pytest.mark.asyncio
async def test_email_intent_parameter(self, client, db):
"""Supplier signup uses different intent for email vs database."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "test@example.com",
"plan": "supplier_pro",
})
# DB should have intent="supplier"
entry = await _get_waitlist_entry(db, "test@example.com", "supplier")
assert entry is not None
# Email should have intent="supplier_pro" (plan name)
mock_enqueue.assert_called_with(
"send_waitlist_confirmation",
{"email": "test@example.com", "intent": "supplier_pro"}
)
# ── TestIntegration ───────────────────────────────────────────────
class TestIntegration:
"""End-to-end integration tests."""
@pytest.mark.asyncio
async def test_full_entrepreneur_waitlist_flow(self, client, db):
"""Complete flow: GET form → POST email → see confirmation."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Step 1: GET waitlist form
response = await client.get("/auth/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
# Step 2: POST email
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "entrepreneur@example.com",
"plan": "free",
})
# Step 3: Verify confirmation page
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "entrepreneur@example.com" in html
assert "waitlist" in html.lower()
# Step 4: Verify database entry
entry = await _get_waitlist_entry(db, "entrepreneur@example.com", "free")
assert entry is not None
@pytest.mark.asyncio
async def test_full_supplier_waitlist_flow(self, client, db):
"""Complete flow: GET supplier form → POST email → see confirmation."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Step 1: GET supplier waitlist form
response = await client.get("/en/suppliers/signup?plan=supplier_pro")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "supplier" in html.lower()
# Step 2: POST email
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_pro",
})
# Step 3: Verify confirmation page
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "supplier@example.com" in html
assert "supplier" in html.lower()
# Step 4: Verify database entry
entry = await _get_waitlist_entry(db, "supplier@example.com", "supplier")
assert entry is not None
assert entry["plan"] == "supplier_pro"
@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()
# 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()
@pytest.mark.asyncio
async def test_same_email_different_intents_both_captured(self, client, db):
"""Same email can be on both entrepreneur and supplier waitlists."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Sign up as entrepreneur
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "both@example.com",
"plan": "free",
})
# Sign up as supplier
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "both@example.com",
"plan": "supplier_growth",
})
# Should have 2 rows
count = await _count_rows(db, "waitlist")
assert count == 2
# Both should exist
entry1 = await _get_waitlist_entry(db, "both@example.com", "free")
entry2 = await _get_waitlist_entry(db, "both@example.com", "supplier")
assert entry1 is not None
assert entry2 is not None