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:
199
web/tests/conftest.py
Normal file
199
web/tests/conftest.py
Normal 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}"
|
||||
BIN
web/tests/screenshots/landing_full.png
Normal file
BIN
web/tests/screenshots/landing_full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/tests/screenshots/landing_mobile.png
Normal file
BIN
web/tests/screenshots/landing_mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 713 KiB |
BIN
web/tests/screenshots/login.png
Normal file
BIN
web/tests/screenshots/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
web/tests/screenshots/signup.png
Normal file
BIN
web/tests/screenshots/signup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
304
web/tests/test_billing_helpers.py
Normal file
304
web/tests/test_billing_helpers.py
Normal 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
|
||||
222
web/tests/test_billing_routes.py
Normal file
222
web/tests/test_billing_routes.py
Normal 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
|
||||
308
web/tests/test_billing_webhooks.py
Normal file
308
web/tests/test_billing_webhooks.py
Normal 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
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
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
292
web/tests/test_credits.py
Normal 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
507
web/tests/test_e2e_flows.py
Normal 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"
|
||||
112
web/tests/test_i18n_parity.py
Normal file
112
web/tests/test_i18n_parity.py
Normal 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
111
web/tests/test_i18n_tips.py
Normal 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"
|
||||
269
web/tests/test_migrations.py
Normal file
269
web/tests/test_migrations.py
Normal 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
699
web/tests/test_phase0.py
Normal 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
|
||||
|
||||
250
web/tests/test_planner_charts.py
Normal file
250
web/tests/test_planner_charts.py
Normal 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 (0–1.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])
|
||||
123
web/tests/test_planner_routes.py
Normal file
123
web/tests/test_planner_routes.py
Normal 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
|
||||
244
web/tests/test_quote_wizard.py
Normal file
244
web/tests/test_quote_wizard.py
Normal 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()
|
||||
429
web/tests/test_supplier_webhooks.py
Normal file
429
web/tests/test_supplier_webhooks.py
Normal 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
332
web/tests/test_visual.py
Normal 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
987
web/tests/test_waitlist.py
Normal 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
|
||||
Reference in New Issue
Block a user