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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user