feat: auto-create Resend audiences per blueprint

Removes RESEND_AUDIENCE_WAITLIST env var. capture_waitlist_email() now
derives audience name from request.blueprints[0] (e.g. waitlist-auth,
waitlist-suppliers), lazily creates it via Resend API on first signup,
and caches the ID in a new resend_audiences table. Zero config beyond
RESEND_API_KEY — adding @waitlist_gate to any new blueprint auto-creates
its audience on first use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 23:41:45 +01:00
parent 05b7397687
commit 77d801e326
5 changed files with 168 additions and 56 deletions

View File

@@ -79,11 +79,6 @@ class TestConfiguration:
assert hasattr(core.config, "WAITLIST_MODE")
assert isinstance(core.config.WAITLIST_MODE, bool)
def test_resend_audience_waitlist_exists(self):
"""RESEND_AUDIENCE_WAITLIST config should exist."""
assert hasattr(core.config, "RESEND_AUDIENCE_WAITLIST")
assert isinstance(core.config.RESEND_AUDIENCE_WAITLIST, str)
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):
@@ -385,22 +380,95 @@ class TestAuthRoutes:
@pytest.mark.asyncio
async def test_adds_to_resend_audience_when_configured(self, client, db):
"""POST adds email to Resend audience if RESEND_AUDIENCE_WAITLIST is set."""
"""POST auto-creates Resend audience per blueprint and adds the contact."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_AUDIENCE_WAITLIST", "aud_test123"), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Contacts.create") as mock_resend:
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_resend.assert_called_once()
call_args = mock_resend.call_args[0][0]
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_test123"
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("/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 ────────────────────────────────────────────
@@ -589,8 +657,9 @@ class TestEdgeCases:
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_AUDIENCE_WAITLIST", "aud_test"), \
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={