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:
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Auto-create Resend audiences per blueprint: `capture_waitlist_email()` now derives the audience name from `request.blueprints[0]` (e.g., `waitlist-auth`, `waitlist-suppliers`) and lazily creates audiences via the Resend API on first use, caching IDs in a new `resend_audiences` table; removes `RESEND_AUDIENCE_WAITLIST` env var — only `RESEND_API_KEY` needed
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Simple A/B testing with `@ab_test` decorator and Umami `data-tag` integration
|
- Simple A/B testing with `@ab_test` decorator and Umami `data-tag` integration
|
||||||
- SEO defaults in `base.html`: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed
|
- SEO defaults in `base.html`: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Waitlist Mode
|
# Waitlist Mode
|
||||||
|
|
||||||
Waitlist mode allows you to validate market demand before building features. Set `WAITLIST_MODE=true` and selected routes will show waitlist signup forms instead of the normal flow. Emails are captured to a database table and a Resend audience for later launch campaigns.
|
Waitlist mode allows you to validate market demand before building features. Set `WAITLIST_MODE=true` and selected routes will show waitlist signup forms instead of the normal flow. Emails are captured to a database table and automatically segmented into per-blueprint Resend audiences for targeted launch campaigns.
|
||||||
|
|
||||||
## Use Cases
|
## Use Cases
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Waitlist mode allows you to validate market demand before building features. Set
|
|||||||
```bash
|
```bash
|
||||||
# Add to .env
|
# Add to .env
|
||||||
WAITLIST_MODE=true
|
WAITLIST_MODE=true
|
||||||
RESEND_AUDIENCE_WAITLIST=aud_xxx # Optional: Resend audience ID for bulk campaigns
|
RESEND_API_KEY=re_xyz123 # Optional: audiences created automatically per blueprint
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run migration (if not already done)
|
### 2. Run migration (if not already done)
|
||||||
@@ -320,49 +320,54 @@ Edit `handle_send_waitlist_confirmation()` in `worker.py` to:
|
|||||||
|
|
||||||
## Resend Integration
|
## Resend Integration
|
||||||
|
|
||||||
If `RESEND_AUDIENCE_WAITLIST` is set, all waitlist signups are automatically added to that Resend audience. This enables bulk launch campaigns when you're ready to go live.
|
Audiences are created automatically per blueprint — no configuration needed beyond `RESEND_API_KEY`. The first signup from each blueprint creates a named audience in Resend and caches its ID in the `resend_audiences` database table. Subsequent signups from the same blueprint skip the API call and use the cached ID.
|
||||||
|
|
||||||
|
### Audience names
|
||||||
|
|
||||||
|
| Route | Blueprint | Resend audience |
|
||||||
|
|-------|-----------|-----------------|
|
||||||
|
| `/auth/signup` | `auth` | `waitlist-auth` |
|
||||||
|
| `/suppliers/signup/waitlist` | `suppliers` | `waitlist-suppliers` |
|
||||||
|
| `/planner/export` | `planner` | `waitlist-planner` |
|
||||||
|
|
||||||
|
Adding `@waitlist_gate` to any new blueprint automatically creates its own audience on first signup.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Create an audience in Resend
|
Set `RESEND_API_KEY` in `.env` — no audience IDs needed:
|
||||||
2. Copy the audience ID (e.g., `aud_abc123xyz`)
|
|
||||||
3. Add to `.env`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RESEND_AUDIENCE_WAITLIST=aud_abc123xyz
|
RESEND_API_KEY=re_xyz123
|
||||||
```
|
```
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
The `capture_waitlist_email()` helper automatically adds emails to the audience:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY:
|
# Derives audience name from current request's blueprint
|
||||||
try:
|
blueprint = request.blueprints[0] if request.blueprints else "default"
|
||||||
resend.api_key = config.RESEND_API_KEY
|
audience_name = f"waitlist-{blueprint}"
|
||||||
resend.Contacts.create({
|
|
||||||
"email": email,
|
# Lazy-creates audience on first use, caches ID in resend_audiences table
|
||||||
"audience_id": config.RESEND_AUDIENCE_WAITLIST,
|
audience_id = await _get_or_create_resend_audience(audience_name)
|
||||||
})
|
if audience_id:
|
||||||
except Exception:
|
resend.Contacts.create({"email": email, "audience_id": audience_id})
|
||||||
pass # Silent fail - not critical
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Silent failures:**
|
**Silent failures:**
|
||||||
|
- `Audiences.create` error → `audience_id` is None, contact skipped
|
||||||
|
- `Contacts.create` error → ignored
|
||||||
- Duplicate emails (already in audience) → ignored
|
- Duplicate emails (already in audience) → ignored
|
||||||
- Rate limits → ignored
|
- API rate limits → ignored
|
||||||
- API errors → ignored
|
|
||||||
|
|
||||||
This ensures waitlist signups always succeed even if Resend is down.
|
Waitlist signups always succeed even if Resend is down.
|
||||||
|
|
||||||
### Launch campaign
|
### Launch campaign
|
||||||
|
|
||||||
When ready to launch:
|
When ready to launch:
|
||||||
|
|
||||||
1. Go to Resend dashboard → Audiences → your waitlist audience
|
1. Go to Resend dashboard → Audiences → select the segment (e.g., `waitlist-suppliers`)
|
||||||
2. Export contacts (CSV)
|
2. Create a broadcast email: "We're live! Here's your early access..."
|
||||||
3. Create a broadcast email: "We're live! Here's your early access..."
|
3. Send to the audience
|
||||||
4. Send to the audience
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -513,15 +518,15 @@ SELECT DISTINCT email FROM waitlist;
|
|||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|----------|------|---------|-------------|
|
|----------|------|---------|-------------|
|
||||||
| `WAITLIST_MODE` | bool | `false` | Enable waitlist gates on routes |
|
| `WAITLIST_MODE` | bool | `false` | Enable waitlist gates on routes |
|
||||||
| `RESEND_AUDIENCE_WAITLIST` | string | `""` | Resend audience ID for bulk campaigns |
|
| `RESEND_API_KEY` | string | `""` | Resend API key — audiences created automatically per blueprint |
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
WAITLIST_MODE=true
|
WAITLIST_MODE=true
|
||||||
RESEND_AUDIENCE_WAITLIST=aud_abc123xyz
|
|
||||||
RESEND_API_KEY=re_xyz123
|
RESEND_API_KEY=re_xyz123
|
||||||
|
# No audience IDs needed — created automatically on first signup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config Access
|
### Config Access
|
||||||
@@ -675,8 +680,8 @@ uv run python -m padelnomics.worker
|
|||||||
**Symptoms:** Emails captured, confirmations sent, but not appearing in Resend audience
|
**Symptoms:** Emails captured, confirmations sent, but not appearing in Resend audience
|
||||||
|
|
||||||
**Causes:**
|
**Causes:**
|
||||||
1. `RESEND_AUDIENCE_WAITLIST` not set
|
1. `RESEND_API_KEY` not set
|
||||||
2. Invalid audience ID
|
2. `Audiences.create` API error on first signup (check logs)
|
||||||
3. Resend API rate limit
|
3. Resend API rate limit
|
||||||
4. Contacts already in audience (silent duplicate)
|
4. Contacts already in audience (silent duplicate)
|
||||||
|
|
||||||
@@ -685,12 +690,17 @@ uv run python -m padelnomics.worker
|
|||||||
# Check config
|
# Check config
|
||||||
uv run python -c "
|
uv run python -c "
|
||||||
from padelnomics.core import config
|
from padelnomics.core import config
|
||||||
print('Audience:', config.RESEND_AUDIENCE_WAITLIST)
|
|
||||||
print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
|
print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
|
||||||
"
|
"
|
||||||
|
|
||||||
# Verify audience ID in Resend dashboard
|
# Check resend_audiences table for cached IDs
|
||||||
# Format: aud_xxxxxxxxxxxxx
|
uv run python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('data/app.db')
|
||||||
|
rows = conn.execute('SELECT * FROM resend_audiences').fetchall()
|
||||||
|
for row in rows:
|
||||||
|
print(row)
|
||||||
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Decorator not intercepting GET requests
|
### Decorator not intercepting GET requests
|
||||||
@@ -750,7 +760,7 @@ async def signup():
|
|||||||
# Enqueue email
|
# Enqueue email
|
||||||
await enqueue("send_waitlist_confirmation", {"email": email, "intent": "signup"})
|
await enqueue("send_waitlist_confirmation", {"email": email, "intent": "signup"})
|
||||||
|
|
||||||
# Add to Resend
|
# Add to Resend (old: manual audience ID required)
|
||||||
if config.RESEND_AUDIENCE_WAITLIST:
|
if config.RESEND_AUDIENCE_WAITLIST:
|
||||||
try:
|
try:
|
||||||
resend.Contacts.create(...)
|
resend.Contacts.create(...)
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ class Config:
|
|||||||
]
|
]
|
||||||
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
||||||
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
||||||
# Optional Resend audience ID for bulk launch blast when waitlist is ready
|
|
||||||
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
|
|
||||||
|
|
||||||
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||||
@@ -205,6 +203,25 @@ async def send_email(
|
|||||||
# Waitlist
|
# Waitlist
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
async def _get_or_create_resend_audience(name: str) -> str | None:
|
||||||
|
"""Get cached Resend audience ID, or create one via API. Returns None on failure."""
|
||||||
|
row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,))
|
||||||
|
if row:
|
||||||
|
return row["audience_id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
result = resend.Audiences.create({"name": name})
|
||||||
|
audience_id = result["id"]
|
||||||
|
await execute(
|
||||||
|
"INSERT OR IGNORE INTO resend_audiences (name, audience_id) VALUES (?, ?)",
|
||||||
|
(name, audience_id),
|
||||||
|
)
|
||||||
|
return audience_id
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
|
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
|
||||||
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
|
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
|
||||||
|
|
||||||
@@ -235,15 +252,16 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
|
|||||||
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
|
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
|
||||||
|
|
||||||
# Add to Resend audience (silent fail - not critical)
|
# Add to Resend audience (silent fail - not critical)
|
||||||
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY:
|
if config.RESEND_API_KEY:
|
||||||
try:
|
blueprint = request.blueprints[0] if request.blueprints else "default"
|
||||||
resend.api_key = config.RESEND_API_KEY
|
audience_name = f"waitlist-{blueprint}"
|
||||||
resend.Contacts.create({
|
audience_id = await _get_or_create_resend_audience(audience_name)
|
||||||
"email": email,
|
if audience_id:
|
||||||
"audience_id": config.RESEND_AUDIENCE_WAITLIST,
|
try:
|
||||||
})
|
resend.api_key = config.RESEND_API_KEY
|
||||||
except Exception:
|
resend.Contacts.create({"email": email, "audience_id": audience_id})
|
||||||
pass # Silent fail
|
except Exception:
|
||||||
|
pass # Silent fail
|
||||||
|
|
||||||
return is_new
|
return is_new
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Cache table for lazily-created Resend audience IDs."""
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn):
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS resend_audiences (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
audience_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
@@ -79,11 +79,6 @@ class TestConfiguration:
|
|||||||
assert hasattr(core.config, "WAITLIST_MODE")
|
assert hasattr(core.config, "WAITLIST_MODE")
|
||||||
assert isinstance(core.config.WAITLIST_MODE, bool)
|
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):
|
def test_waitlist_mode_can_be_enabled(self):
|
||||||
"""WAITLIST_MODE can be set to True via config."""
|
"""WAITLIST_MODE can be set to True via config."""
|
||||||
with patch.object(core.config, "WAITLIST_MODE", True):
|
with patch.object(core.config, "WAITLIST_MODE", True):
|
||||||
@@ -385,22 +380,95 @@ class TestAuthRoutes:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_adds_to_resend_audience_when_configured(self, client, db):
|
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), \
|
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.object(core.config, "RESEND_API_KEY", "re_test_key"), \
|
||||||
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
|
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={
|
await client.post("/auth/signup", form={
|
||||||
"csrf_token": "test_token",
|
"csrf_token": "test_token",
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
mock_resend.assert_called_once()
|
mock_create_aud.assert_called_once_with({"name": "waitlist-auth"})
|
||||||
call_args = mock_resend.call_args[0][0]
|
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["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 ────────────────────────────────────────────
|
# ── TestSupplierRoutes ────────────────────────────────────────────
|
||||||
@@ -589,8 +657,9 @@ class TestEdgeCases:
|
|||||||
async def test_resend_api_error_handled_gracefully(self, client, db):
|
async def test_resend_api_error_handled_gracefully(self, client, db):
|
||||||
"""Resend API errors don't break the flow."""
|
"""Resend API errors don't break the flow."""
|
||||||
with patch.object(core.config, "WAITLIST_MODE", True), \
|
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("padelnomics.worker.enqueue", new_callable=AsyncMock), \
|
||||||
|
patch("resend.Audiences.create", return_value={"id": "aud_test"}), \
|
||||||
patch("resend.Contacts.create", side_effect=Exception("API error")):
|
patch("resend.Contacts.create", side_effect=Exception("API error")):
|
||||||
|
|
||||||
response = await client.post("/auth/signup", form={
|
response = await client.post("/auth/signup", form={
|
||||||
|
|||||||
Reference in New Issue
Block a user