test: Added GET/PUT notification preferences endpoints, signed-token un…
- "backend/routers/notifications.py" - "backend/main.py" - "backend/tests/notifications/test_notifications.py" - "frontend/src/api/notifications.ts" - "frontend/src/pages/CreatorSettings.tsx" GSD-Task: S01/T03
This commit is contained in:
parent
5e4b173917
commit
cb3a6c919c
11 changed files with 1173 additions and 3 deletions
|
|
@ -111,7 +111,7 @@
|
||||||
- Estimate: 1.5h
|
- Estimate: 1.5h
|
||||||
- Files: backend/tasks/__init__.py, backend/tasks/notifications.py, backend/worker.py, docker-compose.yml
|
- Files: backend/tasks/__init__.py, backend/tasks/notifications.py, backend/worker.py, docker-compose.yml
|
||||||
- Verify: cd backend && python -c "from tasks.notifications import send_digest_emails; print('Task imports OK')" && python -c "from worker import celery_app; assert 'send-digest-emails' in celery_app.conf.beat_schedule; print('Beat schedule OK')" && grep -q '\-\-beat' ../docker-compose.yml && echo 'Docker beat flag OK'
|
- Verify: cd backend && python -c "from tasks.notifications import send_digest_emails; print('Task imports OK')" && python -c "from worker import celery_app; assert 'send-digest-emails' in celery_app.conf.beat_schedule; print('Beat schedule OK')" && grep -q '\-\-beat' ../docker-compose.yml && echo 'Docker beat flag OK'
|
||||||
- [ ] **T03: Notification preferences API, unsubscribe endpoint, frontend settings, and integration test** — Wire the user-facing surfaces: API endpoints for reading/updating notification preferences, a public unsubscribe endpoint (no auth required, uses signed token), a frontend settings toggle in the creator settings page, and an integration test that verifies the full digest flow with mocked SMTP.
|
- [x] **T03: Added GET/PUT notification preferences endpoints, signed-token unsubscribe page, email digest toggle in CreatorSettings, and 12 integration tests covering auth, token validation, and digest task flow** — Wire the user-facing surfaces: API endpoints for reading/updating notification preferences, a public unsubscribe endpoint (no auth required, uses signed token), a frontend settings toggle in the creator settings page, and an integration test that verifies the full digest flow with mocked SMTP.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
30
.gsd/milestones/M025/slices/S01/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M025/slices/S01/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T02",
|
||||||
|
"unitId": "M025/S01/T02",
|
||||||
|
"timestamp": 1775304943904,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd backend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 6,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q '\\-\\-beat' ../docker-compose.yml",
|
||||||
|
"exitCode": 2,
|
||||||
|
"durationMs": 8,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "echo 'Docker beat flag OK'",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 9,
|
||||||
|
"verdict": "pass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
85
.gsd/milestones/M025/slices/S01/tasks/T03-SUMMARY.md
Normal file
85
.gsd/milestones/M025/slices/S01/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S01
|
||||||
|
milestone: M025
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["backend/routers/notifications.py", "backend/main.py", "backend/tests/notifications/test_notifications.py", "frontend/src/api/notifications.ts", "frontend/src/pages/CreatorSettings.tsx"]
|
||||||
|
key_decisions: ["Standalone ASGI test client for notification tests to avoid importing full app (container may lack newer routers)"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "All 12 tests pass in Docker container on ub01 (preferences CRUD, auth enforcement, unsubscribe valid/expired/tampered tokens, digest task happy path with mocked SMTP, no-content skip, SMTP-unconfigured no-op). Frontend TypeScript compiles cleanly. Router mounts verified. --beat flag confirmed in docker-compose.yml."
|
||||||
|
completed_at: 2026-04-04T12:26:16.997Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Added GET/PUT notification preferences endpoints, signed-token unsubscribe page, email digest toggle in CreatorSettings, and 12 integration tests covering auth, token validation, and digest task flow
|
||||||
|
|
||||||
|
> Added GET/PUT notification preferences endpoints, signed-token unsubscribe page, email digest toggle in CreatorSettings, and 12 integration tests covering auth, token validation, and digest task flow
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S01
|
||||||
|
milestone: M025
|
||||||
|
key_files:
|
||||||
|
- backend/routers/notifications.py
|
||||||
|
- backend/main.py
|
||||||
|
- backend/tests/notifications/test_notifications.py
|
||||||
|
- frontend/src/api/notifications.ts
|
||||||
|
- frontend/src/pages/CreatorSettings.tsx
|
||||||
|
key_decisions:
|
||||||
|
- Standalone ASGI test client for notification tests to avoid importing full app (container may lack newer routers)
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-04T12:26:16.998Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Added GET/PUT notification preferences endpoints, signed-token unsubscribe page, email digest toggle in CreatorSettings, and 12 integration tests covering auth, token validation, and digest task flow
|
||||||
|
|
||||||
|
**Added GET/PUT notification preferences endpoints, signed-token unsubscribe page, email digest toggle in CreatorSettings, and 12 integration tests covering auth, token validation, and digest task flow**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Created backend/routers/notifications.py with GET/PUT preferences and unsubscribe endpoints. PUT validates digest_frequency against allowed values. Unsubscribe uses PyJWT verify_unsubscribe_token from T02, returns styled HTML pages for success/error. Mounted in main.py. Built frontend API client and extended CreatorSettings with email notification toggle and frequency selector with optimistic updates. Created 12-test integration suite using standalone ASGI app pattern to avoid container image compatibility issues with the full main.py import.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
All 12 tests pass in Docker container on ub01 (preferences CRUD, auth enforcement, unsubscribe valid/expired/tampered tokens, digest task happy path with mocked SMTP, no-content skip, SMTP-unconfigured no-op). Frontend TypeScript compiles cleanly. Router mounts verified. --beat flag confirmed in docker-compose.yml.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `docker exec ... python -m pytest test_notifications.py -v --rootdir=/app/tests_notif` | 0 | ✅ pass | 12270ms |
|
||||||
|
| 2 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 11200ms |
|
||||||
|
| 3 | `python -c 'from routers.notifications import router'` | 0 | ✅ pass | 200ms |
|
||||||
|
| 4 | `grep -q '--beat' docker-compose.yml` | 0 | ✅ pass | 10ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Used standalone ASGI test app instead of shared conftest due to container image lacking newer routers. Tests in subdirectory with own pytest.ini.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
PyJWT warns about default app_secret_key being under 32 bytes — production should use longer secret.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `backend/routers/notifications.py`
|
||||||
|
- `backend/main.py`
|
||||||
|
- `backend/tests/notifications/test_notifications.py`
|
||||||
|
- `frontend/src/api/notifications.ts`
|
||||||
|
- `frontend/src/pages/CreatorSettings.tsx`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
Used standalone ASGI test app instead of shared conftest due to container image lacking newer routers. Tests in subdirectory with own pytest.ini.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
PyJWT warns about default app_secret_key being under 32 bytes — production should use longer secret.
|
||||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, shorts_public, stats, techniques, topics, videos
|
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, notifications, pipeline, posts, reports, search, shorts, shorts_public, stats, techniques, topics, videos
|
||||||
|
|
||||||
|
|
||||||
def _setup_logging() -> None:
|
def _setup_logging() -> None:
|
||||||
|
|
@ -97,6 +97,7 @@ app.include_router(creators.admin_router, prefix="/api/v1")
|
||||||
app.include_router(follows.router, prefix="/api/v1")
|
app.include_router(follows.router, prefix="/api/v1")
|
||||||
app.include_router(highlights.router, prefix="/api/v1")
|
app.include_router(highlights.router, prefix="/api/v1")
|
||||||
app.include_router(ingest.router, prefix="/api/v1")
|
app.include_router(ingest.router, prefix="/api/v1")
|
||||||
|
app.include_router(notifications.router, prefix="/api/v1")
|
||||||
app.include_router(pipeline.router, prefix="/api/v1")
|
app.include_router(pipeline.router, prefix="/api/v1")
|
||||||
app.include_router(posts.router, prefix="/api/v1")
|
app.include_router(posts.router, prefix="/api/v1")
|
||||||
app.include_router(files.router, prefix="/api/v1")
|
app.include_router(files.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
146
backend/routers/notifications.py
Normal file
146
backend/routers/notifications.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Notification preferences and unsubscribe endpoints.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /notifications/preferences — current user's preferences (auth required)
|
||||||
|
PUT /notifications/preferences — update preferences (auth required)
|
||||||
|
GET /notifications/unsubscribe — signed-token unsubscribe (no auth)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from config import get_settings
|
||||||
|
from database import get_session
|
||||||
|
from models import User
|
||||||
|
from schemas import NotificationPreferences, NotificationPreferencesUpdate
|
||||||
|
from tasks.notifications import verify_unsubscribe_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preferences", response_model=NotificationPreferences)
|
||||||
|
async def get_preferences(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
"""Return the current user's notification preferences."""
|
||||||
|
prefs = current_user.notification_preferences or {}
|
||||||
|
return NotificationPreferences(
|
||||||
|
email_digests=prefs.get("email_digests", True),
|
||||||
|
digest_frequency=prefs.get("digest_frequency", "daily"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences", response_model=NotificationPreferences)
|
||||||
|
async def update_preferences(
|
||||||
|
body: NotificationPreferencesUpdate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
):
|
||||||
|
"""Update the current user's notification preferences.
|
||||||
|
|
||||||
|
Only provided fields are updated; omitted fields keep their current value.
|
||||||
|
"""
|
||||||
|
# Validate digest_frequency if provided
|
||||||
|
valid_frequencies = {"daily", "weekly"}
|
||||||
|
if body.digest_frequency is not None and body.digest_frequency not in valid_frequencies:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"digest_frequency must be one of: {', '.join(sorted(valid_frequencies))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_prefs = dict(current_user.notification_preferences or {})
|
||||||
|
|
||||||
|
if body.email_digests is not None:
|
||||||
|
current_prefs["email_digests"] = body.email_digests
|
||||||
|
if body.digest_frequency is not None:
|
||||||
|
current_prefs["digest_frequency"] = body.digest_frequency
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == current_user.id)
|
||||||
|
.values(notification_preferences=current_prefs)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"notification_preferences updated user_id=%s prefs=%s",
|
||||||
|
current_user.id,
|
||||||
|
current_prefs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationPreferences(
|
||||||
|
email_digests=current_prefs.get("email_digests", True),
|
||||||
|
digest_frequency=current_prefs.get("digest_frequency", "daily"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_UNSUB_SUCCESS_HTML = """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Unsubscribed</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
|
||||||
|
align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:2rem;max-width:400px;
|
||||||
|
box-shadow:0 2px 8px rgba(0,0,0,.1);text-align:center}
|
||||||
|
h1{color:#333;font-size:1.5rem}p{color:#666}</style></head>
|
||||||
|
<body><div class="card"><h1>Unsubscribed</h1>
|
||||||
|
<p>You've been unsubscribed from email digests. You can re-enable them in your settings.</p>
|
||||||
|
</div></body></html>"""
|
||||||
|
|
||||||
|
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Unsubscribe Error</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
|
||||||
|
align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:2rem;max-width:400px;
|
||||||
|
box-shadow:0 2px 8px rgba(0,0,0,.1);text-align:center}
|
||||||
|
h1{color:#c33;font-size:1.5rem}p{color:#666}</style></head>
|
||||||
|
<body><div class="card"><h1>Link Expired or Invalid</h1>
|
||||||
|
<p>This unsubscribe link is no longer valid. Please log in to manage your notification preferences.</p>
|
||||||
|
</div></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unsubscribe", response_class=HTMLResponse)
|
||||||
|
async def unsubscribe(
|
||||||
|
token: Annotated[str, Query(description="Signed unsubscribe token")],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
):
|
||||||
|
"""Unsubscribe a user from email digests via signed token.
|
||||||
|
|
||||||
|
No authentication required — the token itself proves identity.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
user_id = verify_unsubscribe_token(token, settings.app_secret_key)
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
logger.warning("unsubscribe: invalid or expired token")
|
||||||
|
return HTMLResponse(content=_UNSUB_ERROR_HTML, status_code=400)
|
||||||
|
|
||||||
|
# Update user preferences
|
||||||
|
result = await session.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
logger.warning("unsubscribe: user_id=%s not found", user_id)
|
||||||
|
return HTMLResponse(content=_UNSUB_ERROR_HTML, status_code=400)
|
||||||
|
|
||||||
|
prefs = dict(user.notification_preferences or {})
|
||||||
|
prefs["email_digests"] = False
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == user.id)
|
||||||
|
.values(notification_preferences=prefs)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info("unsubscribe: user_id=%s unsubscribed via token", user_id)
|
||||||
|
return HTMLResponse(content=_UNSUB_SUCCESS_HTML, status_code=200)
|
||||||
3
backend/tests/notifications/conftest.py
Normal file
3
backend/tests/notifications/conftest.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Standalone conftest for notification tests — intentionally shadows
|
||||||
|
# the parent conftest.py to avoid importing the full app (which may
|
||||||
|
# reference routers not present in the running container image).
|
||||||
2
backend/tests/notifications/pytest.ini
Normal file
2
backend/tests/notifications/pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
395
backend/tests/notifications/test_notifications.py
Normal file
395
backend/tests/notifications/test_notifications.py
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
"""Integration tests for notification preferences, unsubscribe, and digest task.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- GET/PUT notification preferences (auth required)
|
||||||
|
- PUT with invalid digest_frequency value → 422
|
||||||
|
- GET/PUT without auth → 401
|
||||||
|
- Unsubscribe with valid token
|
||||||
|
- Unsubscribe with expired token
|
||||||
|
- Unsubscribe with tampered token
|
||||||
|
- Digest task happy path (mocked SMTP)
|
||||||
|
- Digest task skips when no new content
|
||||||
|
- Digest task no-op when SMTP unconfigured
|
||||||
|
|
||||||
|
Uses a standalone ASGI test client to avoid importing the full app
|
||||||
|
(which may reference routers not present in the running container image).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import select, update as sa_update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
# Ensure backend/ on path
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from database import Base, get_session # noqa: E402
|
||||||
|
from models import ( # noqa: E402
|
||||||
|
Creator,
|
||||||
|
CreatorFollow,
|
||||||
|
EmailDigestLog,
|
||||||
|
InviteCode,
|
||||||
|
Post,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = os.getenv(
|
||||||
|
"TEST_DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://chrysopedia:changeme@localhost:5433/chrysopedia_test",
|
||||||
|
)
|
||||||
|
TEST_DATABASE_URL_SYNC = TEST_DATABASE_URL.replace(
|
||||||
|
"postgresql+asyncpg://", "postgresql+psycopg2://"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SECRET = "changeme-generate-a-real-secret"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Standalone test app ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_test_app():
|
||||||
|
"""Build a minimal FastAPI app with only the notifications router + auth."""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from routers.notifications import router as notif_router
|
||||||
|
from routers.auth import router as auth_router
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(auth_router, prefix="/api/v1")
|
||||||
|
test_app.include_router(notif_router, prefix="/api/v1")
|
||||||
|
return test_app
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def db_engine():
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def sync_engine_fix(db_engine):
|
||||||
|
"""Sync engine pointing at same test DB (tables created by db_engine)."""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
engine = create_engine(TEST_DATABASE_URL_SYNC, echo=False, poolclass=NullPool)
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(db_engine, tmp_path):
|
||||||
|
test_app = _make_test_app()
|
||||||
|
session_factory = async_sessionmaker(
|
||||||
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _override_get_session():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
test_app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
|
||||||
|
# Clear settings cache so tests get fresh defaults
|
||||||
|
from config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
transport = ASGITransport(app=test_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
test_app.dependency_overrides.clear()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def invite_code(db_engine):
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
code = InviteCode(code="NOTIF-TEST-2026", uses_remaining=10)
|
||||||
|
session.add(code)
|
||||||
|
await session.commit()
|
||||||
|
return "NOTIF-TEST-2026"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def registered_user(client, invite_code):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "notiftest@chrysopedia.com",
|
||||||
|
"password": "testpass123",
|
||||||
|
"display_name": "Notif Test User",
|
||||||
|
"invite_code": invite_code,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Register failed: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def auth_headers(client, registered_user):
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"email": "notiftest@chrysopedia.com",
|
||||||
|
"password": "testpass123",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_unsubscribe_token(user_id: str, secret: str = _SECRET, iat: float | None = None) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"purpose": "unsubscribe",
|
||||||
|
"iat": iat or datetime.now(timezone.utc).timestamp(),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preference endpoint tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preferences_requires_auth(client):
|
||||||
|
resp = await client.get("/api/v1/notifications/preferences")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_requires_auth(client):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"email_digests": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preferences_defaults(client, auth_headers):
|
||||||
|
resp = await client.get("/api/v1/notifications/preferences", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["email_digests"] is True
|
||||||
|
assert data["digest_frequency"] == "daily"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_update_digests(client, auth_headers):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"email_digests": False},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["email_digests"] is False
|
||||||
|
assert resp.json()["digest_frequency"] == "daily"
|
||||||
|
|
||||||
|
# Verify persistence
|
||||||
|
resp2 = await client.get("/api/v1/notifications/preferences", headers=auth_headers)
|
||||||
|
assert resp2.json()["email_digests"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_update_frequency(client, auth_headers):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"digest_frequency": "weekly"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["digest_frequency"] == "weekly"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_invalid_frequency(client, auth_headers):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"digest_frequency": "monthly"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unsubscribe endpoint tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_valid_token(client, auth_headers, db_engine):
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user_id = str(user.id)
|
||||||
|
|
||||||
|
token = _make_unsubscribe_token(user_id)
|
||||||
|
resp = await client.get(f"/api/v1/notifications/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Unsubscribed" in resp.text
|
||||||
|
|
||||||
|
# Verify preferences updated
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).where(User.id == user.id))
|
||||||
|
updated = result.scalar_one()
|
||||||
|
assert updated.notification_preferences.get("email_digests") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_expired_token(client, auth_headers, db_engine):
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
expired_iat = time.time() - (31 * 24 * 3600)
|
||||||
|
token = _make_unsubscribe_token(str(user.id), iat=expired_iat)
|
||||||
|
resp = await client.get(f"/api/v1/notifications/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_tampered_token(client):
|
||||||
|
token = _make_unsubscribe_token(str(uuid.uuid4()), secret="wrong-secret")
|
||||||
|
resp = await client.get(f"/api/v1/notifications/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digest task tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_digest_task_smtp_unconfigured():
|
||||||
|
"""Digest task gracefully skips when SMTP is not configured."""
|
||||||
|
from tasks.notifications import send_digest_emails
|
||||||
|
with patch("tasks.notifications.is_smtp_configured", return_value=False):
|
||||||
|
result = send_digest_emails()
|
||||||
|
assert result["skipped"] is True
|
||||||
|
assert result["reason"] == "smtp_not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_digest_task_happy_path(client, auth_headers, db_engine, sync_engine_fix):
|
||||||
|
"""Full digest: user follows creator, new post exists, SMTP mocked → email sent."""
|
||||||
|
sync_factory = sessionmaker(bind=sync_engine_fix)
|
||||||
|
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user_id = user.id
|
||||||
|
user_email = user.email
|
||||||
|
|
||||||
|
# Ensure digests enabled
|
||||||
|
await session.execute(
|
||||||
|
sa_update(User).where(User.id == user_id).values(
|
||||||
|
notification_preferences={"email_digests": True, "digest_frequency": "daily"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Create creator
|
||||||
|
creator = Creator(
|
||||||
|
name="DigestTestCreator",
|
||||||
|
slug="digest-test-creator",
|
||||||
|
folder_name="DigestTestCreator",
|
||||||
|
)
|
||||||
|
session.add(creator)
|
||||||
|
await session.flush()
|
||||||
|
creator_id = creator.id
|
||||||
|
|
||||||
|
# Follow
|
||||||
|
follow = CreatorFollow(user_id=user_id, creator_id=creator_id)
|
||||||
|
session.add(follow)
|
||||||
|
|
||||||
|
# Published post
|
||||||
|
post = Post(
|
||||||
|
creator_id=creator_id,
|
||||||
|
title="Test Digest Post",
|
||||||
|
body_json={"blocks": [{"type": "paragraph", "content": "Some content"}]},
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
session.add(post)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Mock SMTP and sync session
|
||||||
|
mock_send = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("tasks.notifications.is_smtp_configured", return_value=True),
|
||||||
|
patch("tasks.notifications.send_email", mock_send),
|
||||||
|
patch("tasks.notifications._get_sync_session") as mock_session_fn,
|
||||||
|
):
|
||||||
|
sync_session = sync_factory()
|
||||||
|
mock_session_fn.return_value = sync_session
|
||||||
|
|
||||||
|
from tasks.notifications import send_digest_emails
|
||||||
|
result = send_digest_emails()
|
||||||
|
|
||||||
|
assert result["sent"] >= 1
|
||||||
|
assert mock_send.called
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
assert user_email in str(call_args)
|
||||||
|
|
||||||
|
# Verify EmailDigestLog
|
||||||
|
verify_session = sync_factory()
|
||||||
|
try:
|
||||||
|
logs = verify_session.execute(
|
||||||
|
select(EmailDigestLog).where(EmailDigestLog.user_id == user_id)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(logs) >= 1
|
||||||
|
finally:
|
||||||
|
verify_session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_digest_task_no_new_content(client, auth_headers, db_engine, sync_engine_fix):
|
||||||
|
"""Digest task skips users with no new content."""
|
||||||
|
sync_factory = sessionmaker(bind=sync_engine_fix)
|
||||||
|
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
# Digests enabled but no follows
|
||||||
|
await session.execute(
|
||||||
|
sa_update(User).where(User.id == user_id).values(
|
||||||
|
notification_preferences={"email_digests": True, "digest_frequency": "daily"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
mock_send = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("tasks.notifications.is_smtp_configured", return_value=True),
|
||||||
|
patch("tasks.notifications.send_email", mock_send),
|
||||||
|
patch("tasks.notifications._get_sync_session") as mock_session_fn,
|
||||||
|
):
|
||||||
|
sync_session = sync_factory()
|
||||||
|
mock_session_fn.return_value = sync_session
|
||||||
|
|
||||||
|
from tasks.notifications import send_digest_emails
|
||||||
|
result = send_digest_emails()
|
||||||
|
|
||||||
|
assert result["sent"] == 0
|
||||||
|
assert result["skipped"] >= 1
|
||||||
|
assert not mock_send.called
|
||||||
395
backend/tests/test_notifications.py
Normal file
395
backend/tests/test_notifications.py
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
"""Integration tests for notification preferences, unsubscribe, and digest task.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- GET/PUT notification preferences (auth required)
|
||||||
|
- PUT with invalid digest_frequency value → 422
|
||||||
|
- GET/PUT without auth → 401
|
||||||
|
- Unsubscribe with valid token
|
||||||
|
- Unsubscribe with expired token
|
||||||
|
- Unsubscribe with tampered token
|
||||||
|
- Digest task happy path (mocked SMTP)
|
||||||
|
- Digest task skips when no new content
|
||||||
|
- Digest task no-op when SMTP unconfigured
|
||||||
|
|
||||||
|
Uses a standalone ASGI test client to avoid importing the full app
|
||||||
|
(which may reference routers not present in the running container image).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import select, update as sa_update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
# Ensure backend/ on path
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from database import Base, get_session # noqa: E402
|
||||||
|
from models import ( # noqa: E402
|
||||||
|
Creator,
|
||||||
|
CreatorFollow,
|
||||||
|
EmailDigestLog,
|
||||||
|
InviteCode,
|
||||||
|
Post,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = os.getenv(
|
||||||
|
"TEST_DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://chrysopedia:changeme@localhost:5433/chrysopedia_test",
|
||||||
|
)
|
||||||
|
TEST_DATABASE_URL_SYNC = TEST_DATABASE_URL.replace(
|
||||||
|
"postgresql+asyncpg://", "postgresql+psycopg2://"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SECRET = "changeme-generate-a-real-secret"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Standalone test app ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_test_app():
|
||||||
|
"""Build a minimal FastAPI app with only the notifications router + auth."""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from routers.notifications import router as notif_router
|
||||||
|
from routers.auth import router as auth_router
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(auth_router, prefix="/api/v1")
|
||||||
|
test_app.include_router(notif_router, prefix="/api/v1")
|
||||||
|
return test_app
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def db_engine():
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def sync_engine_fix(db_engine):
|
||||||
|
"""Sync engine pointing at same test DB (tables created by db_engine)."""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
engine = create_engine(TEST_DATABASE_URL_SYNC, echo=False, poolclass=NullPool)
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(db_engine, tmp_path):
|
||||||
|
test_app = _make_test_app()
|
||||||
|
session_factory = async_sessionmaker(
|
||||||
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _override_get_session():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
test_app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
|
||||||
|
# Clear settings cache so tests get fresh defaults
|
||||||
|
from config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
transport = ASGITransport(app=test_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
test_app.dependency_overrides.clear()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def invite_code(db_engine):
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
code = InviteCode(code="NOTIF-TEST-2026", uses_remaining=10)
|
||||||
|
session.add(code)
|
||||||
|
await session.commit()
|
||||||
|
return "NOTIF-TEST-2026"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def registered_user(client, invite_code):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "notiftest@chrysopedia.com",
|
||||||
|
"password": "testpass123",
|
||||||
|
"display_name": "Notif Test User",
|
||||||
|
"invite_code": invite_code,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Register failed: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def auth_headers(client, registered_user):
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"email": "notiftest@chrysopedia.com",
|
||||||
|
"password": "testpass123",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_unsubscribe_token(user_id: str, secret: str = _SECRET, iat: float | None = None) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"purpose": "unsubscribe",
|
||||||
|
"iat": iat or datetime.now(timezone.utc).timestamp(),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preference endpoint tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preferences_requires_auth(client):
|
||||||
|
resp = await client.get("/api/v1/notifications/preferences")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_requires_auth(client):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"email_digests": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preferences_defaults(client, auth_headers):
|
||||||
|
resp = await client.get("/api/v1/notifications/preferences", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["email_digests"] is True
|
||||||
|
assert data["digest_frequency"] == "daily"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_update_digests(client, auth_headers):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"email_digests": False},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["email_digests"] is False
|
||||||
|
assert resp.json()["digest_frequency"] == "daily"
|
||||||
|
|
||||||
|
# Verify persistence
|
||||||
|
resp2 = await client.get("/api/v1/notifications/preferences", headers=auth_headers)
|
||||||
|
assert resp2.json()["email_digests"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_update_frequency(client, auth_headers):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"digest_frequency": "weekly"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["digest_frequency"] == "weekly"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_preferences_invalid_frequency(client, auth_headers):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/notifications/preferences",
|
||||||
|
json={"digest_frequency": "monthly"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unsubscribe endpoint tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_valid_token(client, auth_headers, db_engine):
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user_id = str(user.id)
|
||||||
|
|
||||||
|
token = _make_unsubscribe_token(user_id)
|
||||||
|
resp = await client.get(f"/api/v1/notifications/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Unsubscribed" in resp.text
|
||||||
|
|
||||||
|
# Verify preferences updated
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).where(User.id == user.id))
|
||||||
|
updated = result.scalar_one()
|
||||||
|
assert updated.notification_preferences.get("email_digests") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_expired_token(client, auth_headers, db_engine):
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
expired_iat = time.time() - (31 * 24 * 3600)
|
||||||
|
token = _make_unsubscribe_token(str(user.id), iat=expired_iat)
|
||||||
|
resp = await client.get(f"/api/v1/notifications/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_tampered_token(client):
|
||||||
|
token = _make_unsubscribe_token(str(uuid.uuid4()), secret="wrong-secret")
|
||||||
|
resp = await client.get(f"/api/v1/notifications/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digest task tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_digest_task_smtp_unconfigured():
|
||||||
|
"""Digest task gracefully skips when SMTP is not configured."""
|
||||||
|
from tasks.notifications import send_digest_emails
|
||||||
|
with patch("tasks.notifications.is_smtp_configured", return_value=False):
|
||||||
|
result = send_digest_emails()
|
||||||
|
assert result["skipped"] is True
|
||||||
|
assert result["reason"] == "smtp_not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_digest_task_happy_path(client, auth_headers, db_engine, sync_engine_fix):
|
||||||
|
"""Full digest: user follows creator, new post exists, SMTP mocked → email sent."""
|
||||||
|
sync_factory = sessionmaker(bind=sync_engine_fix)
|
||||||
|
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user_id = user.id
|
||||||
|
user_email = user.email
|
||||||
|
|
||||||
|
# Ensure digests enabled
|
||||||
|
await session.execute(
|
||||||
|
sa_update(User).where(User.id == user_id).values(
|
||||||
|
notification_preferences={"email_digests": True, "digest_frequency": "daily"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Create creator
|
||||||
|
creator = Creator(
|
||||||
|
name="DigestTestCreator",
|
||||||
|
slug="digest-test-creator",
|
||||||
|
folder_name="DigestTestCreator",
|
||||||
|
)
|
||||||
|
session.add(creator)
|
||||||
|
await session.flush()
|
||||||
|
creator_id = creator.id
|
||||||
|
|
||||||
|
# Follow
|
||||||
|
follow = CreatorFollow(user_id=user_id, creator_id=creator_id)
|
||||||
|
session.add(follow)
|
||||||
|
|
||||||
|
# Published post
|
||||||
|
post = Post(
|
||||||
|
creator_id=creator_id,
|
||||||
|
title="Test Digest Post",
|
||||||
|
body_json={"blocks": [{"type": "paragraph", "content": "Some content"}]},
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
session.add(post)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Mock SMTP and sync session
|
||||||
|
mock_send = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("tasks.notifications.is_smtp_configured", return_value=True),
|
||||||
|
patch("tasks.notifications.send_email", mock_send),
|
||||||
|
patch("tasks.notifications._get_sync_session") as mock_session_fn,
|
||||||
|
):
|
||||||
|
sync_session = sync_factory()
|
||||||
|
mock_session_fn.return_value = sync_session
|
||||||
|
|
||||||
|
from tasks.notifications import send_digest_emails
|
||||||
|
result = send_digest_emails()
|
||||||
|
|
||||||
|
assert result["sent"] >= 1
|
||||||
|
assert mock_send.called
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
assert user_email in str(call_args)
|
||||||
|
|
||||||
|
# Verify EmailDigestLog
|
||||||
|
verify_session = sync_factory()
|
||||||
|
try:
|
||||||
|
logs = verify_session.execute(
|
||||||
|
select(EmailDigestLog).where(EmailDigestLog.user_id == user_id)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(logs) >= 1
|
||||||
|
finally:
|
||||||
|
verify_session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_digest_task_no_new_content(client, auth_headers, db_engine, sync_engine_fix):
|
||||||
|
"""Digest task skips users with no new content."""
|
||||||
|
sync_factory = sessionmaker(bind=sync_engine_fix)
|
||||||
|
|
||||||
|
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(select(User).limit(1))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
# Digests enabled but no follows
|
||||||
|
await session.execute(
|
||||||
|
sa_update(User).where(User.id == user_id).values(
|
||||||
|
notification_preferences={"email_digests": True, "digest_frequency": "daily"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
mock_send = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("tasks.notifications.is_smtp_configured", return_value=True),
|
||||||
|
patch("tasks.notifications.send_email", mock_send),
|
||||||
|
patch("tasks.notifications._get_sync_session") as mock_session_fn,
|
||||||
|
):
|
||||||
|
sync_session = sync_factory()
|
||||||
|
mock_session_fn.return_value = sync_session
|
||||||
|
|
||||||
|
from tasks.notifications import send_digest_emails
|
||||||
|
result = send_digest_emails()
|
||||||
|
|
||||||
|
assert result["sent"] == 0
|
||||||
|
assert result["skipped"] >= 1
|
||||||
|
assert not mock_send.called
|
||||||
28
frontend/src/api/notifications.ts
Normal file
28
frontend/src/api/notifications.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { request, BASE } from "./client";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
email_digests: boolean;
|
||||||
|
digest_frequency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreferencesUpdate {
|
||||||
|
email_digests?: boolean;
|
||||||
|
digest_frequency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Functions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getNotificationPreferences(): Promise<NotificationPreferences> {
|
||||||
|
return request<NotificationPreferences>(`${BASE}/notifications/preferences`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotificationPreferences(
|
||||||
|
prefs: NotificationPreferencesUpdate,
|
||||||
|
): Promise<NotificationPreferences> {
|
||||||
|
return request<NotificationPreferences>(`${BASE}/notifications/preferences`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(prefs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, useEffect, type FormEvent } from "react";
|
||||||
import { useAuth, ApiError } from "../context/AuthContext";
|
import { useAuth, ApiError } from "../context/AuthContext";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
import { authUpdateProfile } from "../api";
|
import { authUpdateProfile } from "../api";
|
||||||
|
import { getNotificationPreferences, updateNotificationPreferences } from "../api/notifications";
|
||||||
import { SidebarNav } from "./CreatorDashboard";
|
import { SidebarNav } from "./CreatorDashboard";
|
||||||
import dashStyles from "./CreatorDashboard.module.css";
|
import dashStyles from "./CreatorDashboard.module.css";
|
||||||
import styles from "./CreatorSettings.module.css";
|
import styles from "./CreatorSettings.module.css";
|
||||||
|
|
@ -22,6 +23,52 @@ export default function CreatorSettings() {
|
||||||
const [passwordMsg, setPasswordMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [passwordMsg, setPasswordMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Notification preferences
|
||||||
|
const [digestsEnabled, setDigestsEnabled] = useState(true);
|
||||||
|
const [digestFrequency, setDigestFrequency] = useState("daily");
|
||||||
|
const [notifMsg, setNotifMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
const [notifLoading, setNotifLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getNotificationPreferences()
|
||||||
|
.then((prefs) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDigestsEnabled(prefs.email_digests);
|
||||||
|
setDigestFrequency(prefs.digest_frequency);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setNotifMsg({ type: "error", text: "Failed to load notification preferences." });
|
||||||
|
})
|
||||||
|
.finally(() => { if (!cancelled) setNotifLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDigestToggle = async (enabled: boolean) => {
|
||||||
|
setDigestsEnabled(enabled);
|
||||||
|
setNotifMsg(null);
|
||||||
|
try {
|
||||||
|
await updateNotificationPreferences({ email_digests: enabled });
|
||||||
|
setNotifMsg({ type: "success", text: "Notification preferences updated." });
|
||||||
|
} catch (err) {
|
||||||
|
setDigestsEnabled(!enabled); // revert
|
||||||
|
setNotifMsg({ type: "error", text: err instanceof ApiError ? err.detail : "Failed to update preferences." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFrequencyChange = async (freq: string) => {
|
||||||
|
const prev = digestFrequency;
|
||||||
|
setDigestFrequency(freq);
|
||||||
|
setNotifMsg(null);
|
||||||
|
try {
|
||||||
|
await updateNotificationPreferences({ digest_frequency: freq });
|
||||||
|
setNotifMsg({ type: "success", text: "Notification preferences updated." });
|
||||||
|
} catch (err) {
|
||||||
|
setDigestFrequency(prev); // revert
|
||||||
|
setNotifMsg({ type: "error", text: err instanceof ApiError ? err.detail : "Failed to update preferences." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProfileSubmit = async (e: FormEvent) => {
|
const handleProfileSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setProfileMsg(null);
|
setProfileMsg(null);
|
||||||
|
|
@ -168,6 +215,44 @@ export default function CreatorSettings() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Notifications section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Email Notifications</h2>
|
||||||
|
{notifMsg && (
|
||||||
|
<div className={notifMsg.type === "error" ? styles.error : styles.success}>
|
||||||
|
{notifMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notifLoading ? (
|
||||||
|
<p style={{ color: "var(--text-secondary, #888)" }}>Loading preferences…</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={digestsEnabled}
|
||||||
|
onChange={(e) => handleDigestToggle(e.target.checked)}
|
||||||
|
style={{ width: "1.25rem", height: "1.25rem", accentColor: "var(--accent, #6366f1)" }}
|
||||||
|
/>
|
||||||
|
Receive email digests for new content from creators you follow
|
||||||
|
</label>
|
||||||
|
{digestsEnabled && (
|
||||||
|
<label className={styles.label}>
|
||||||
|
Frequency
|
||||||
|
<select
|
||||||
|
className={styles.input}
|
||||||
|
value={digestFrequency}
|
||||||
|
onChange={(e) => handleFrequencyChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue