Files
padelnomics/web/tests/test_waitlist.py
2026-02-22 23:22:51 +01:00

989 lines
41 KiB
Python

"""
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.worker import handle_send_waitlist_confirmation
from padelnomics import core
# ── 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