feat: Brevo double opt-in + contact sync

- Add brevo.py: transactional email sending + contact list sync via Brevo API
- Add token.py: stateless HMAC-signed confirmation tokens (no DB migration needed)
- Update routes.py: POST /waitlist sends confirmation email, GET /waitlist/confirm verifies token
- Update config.py: Brevo + confirmation settings (gracefully disabled when BREVO_API_KEY is empty)
- Update .env.example with new Brevo and confirmation variables
- Add httpx dependency
- Add 8 new tests (token roundtrip/expiry/tamper, confirm endpoint, Brevo mock)
This commit is contained in:
Roberto Musso
2026-04-11 18:48:58 +02:00
parent 7553a0c02b
commit f956f0a260
7 changed files with 528 additions and 4 deletions

View File

@@ -1,12 +1,17 @@
import time
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.main import app
from app.models import Base
from app.models import Base, WaitlistEntry
from app.db import get_db
from app.rate_limit import _hits_store
from app.token import generate_token, verify_token
# Use SQLite for tests (no Postgres dependency)
TEST_DB_URL = "sqlite+aiosqlite:///./test_waitlist.db"
@@ -107,3 +112,115 @@ async def test_rate_limit(client):
# The 6th request should be rate-limited (limit is 5)
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# ── Confirmation token tests ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_token_roundtrip():
"""A generated token should verify back to the same email."""
email = "token@example.com"
token = generate_token(email)
assert verify_token(token) == email
@pytest.mark.asyncio
async def test_token_expired():
"""An expired token should return None."""
email = "expired@example.com"
with patch("app.token.time") as mock_time:
# Generate token "49 hours ago"
past = time.time() - 49 * 3600
mock_time.time.return_value = past
token = generate_token(email)
# Now verify with real time — should be expired (>48h)
assert verify_token(token) is None
@pytest.mark.asyncio
async def test_token_tampered():
"""A tampered token should return None."""
token = generate_token("legit@example.com")
# Flip a character in the token
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert verify_token(tampered) is None
# ── Confirm endpoint tests ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_confirm_valid_token(client, db_session):
"""GET /confirm with valid token marks email as confirmed."""
# Seed an unconfirmed entry
entry = WaitlistEntry(email="confirm@example.com", source="website")
db_session.add(entry)
await db_session.commit()
token = generate_token("confirm@example.com")
resp = await client.get(f"/api/v1/waitlist/confirm?token={token}")
assert resp.status_code == 200
assert "confirmed" in resp.text.lower() or "verified" in resp.text.lower()
# Verify DB state
result = await db_session.execute(
select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com")
)
assert result.scalar_one().confirmed is True
@pytest.mark.asyncio
async def test_confirm_invalid_token(client):
"""GET /confirm with invalid token returns 400."""
resp = await client.get("/api/v1/waitlist/confirm?token=garbage")
assert resp.status_code == 400
assert "invalid" in resp.text.lower() or "expired" in resp.text.lower()
@pytest.mark.asyncio
async def test_confirm_idempotent(client, db_session):
"""Confirming an already confirmed email returns 200 (idempotent)."""
entry = WaitlistEntry(email="idem@example.com", source="website", confirmed=True)
db_session.add(entry)
await db_session.commit()
token = generate_token("idem@example.com")
resp = await client.get(f"/api/v1/waitlist/confirm?token={token}")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_confirm_unknown_email(client):
"""Token for a non-existent email returns 400."""
token = generate_token("unknown@example.com")
resp = await client.get(f"/api/v1/waitlist/confirm?token={token}")
assert resp.status_code == 400
# ── Brevo integration tests (mocked) ────────────────────────────────
@pytest.mark.asyncio
async def test_signup_triggers_confirmation_email(client, db_session):
"""When Brevo is configured, signup sends a confirmation email."""
with patch("app.routes.settings") as mock_settings, \
patch("app.routes.send_confirmation_email", new_callable=AsyncMock) as mock_send:
mock_settings.brevo_configured = True
mock_settings.CONFIRM_BASE_URL = "http://test"
resp = await client.post(
"/api/v1/waitlist",
json={"email": "brevo@example.com"},
)
assert resp.status_code == 200
# Wait for fire-and-forget task
import asyncio
await asyncio.sleep(0.1)
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args[0][0] == "brevo@example.com"
assert "confirm" in call_args[0][1]