From cb3a6c919cbe18bb7658278986e8c44395edc910 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 12:27:18 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Added=20GET/PUT=20notification=20prefer?= =?UTF-8?q?ences=20endpoints,=20signed-token=20un=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- .gsd/milestones/M025/slices/S01/S01-PLAN.md | 2 +- .../M025/slices/S01/tasks/T02-VERIFY.json | 30 ++ .../M025/slices/S01/tasks/T03-SUMMARY.md | 85 ++++ backend/main.py | 3 +- backend/routers/notifications.py | 146 +++++++ backend/tests/notifications/conftest.py | 3 + backend/tests/notifications/pytest.ini | 2 + .../tests/notifications/test_notifications.py | 395 ++++++++++++++++++ backend/tests/test_notifications.py | 395 ++++++++++++++++++ frontend/src/api/notifications.ts | 28 ++ frontend/src/pages/CreatorSettings.tsx | 87 +++- 11 files changed, 1173 insertions(+), 3 deletions(-) create mode 100644 .gsd/milestones/M025/slices/S01/tasks/T02-VERIFY.json create mode 100644 .gsd/milestones/M025/slices/S01/tasks/T03-SUMMARY.md create mode 100644 backend/routers/notifications.py create mode 100644 backend/tests/notifications/conftest.py create mode 100644 backend/tests/notifications/pytest.ini create mode 100644 backend/tests/notifications/test_notifications.py create mode 100644 backend/tests/test_notifications.py create mode 100644 frontend/src/api/notifications.ts diff --git a/.gsd/milestones/M025/slices/S01/S01-PLAN.md b/.gsd/milestones/M025/slices/S01/S01-PLAN.md index b6b4b1d..d628b18 100644 --- a/.gsd/milestones/M025/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M025/slices/S01/S01-PLAN.md @@ -111,7 +111,7 @@ - Estimate: 1.5h - 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' -- [ ] **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 diff --git a/.gsd/milestones/M025/slices/S01/tasks/T02-VERIFY.json b/.gsd/milestones/M025/slices/S01/tasks/T02-VERIFY.json new file mode 100644 index 0000000..775e80d --- /dev/null +++ b/.gsd/milestones/M025/slices/S01/tasks/T02-VERIFY.json @@ -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 +} diff --git a/.gsd/milestones/M025/slices/S01/tasks/T03-SUMMARY.md b/.gsd/milestones/M025/slices/S01/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..97d8958 --- /dev/null +++ b/.gsd/milestones/M025/slices/S01/tasks/T03-SUMMARY.md @@ -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. diff --git a/backend/main.py b/backend/main.py index 4971f06..58d16e6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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: @@ -97,6 +97,7 @@ app.include_router(creators.admin_router, prefix="/api/v1") app.include_router(follows.router, prefix="/api/v1") app.include_router(highlights.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(posts.router, prefix="/api/v1") app.include_router(files.router, prefix="/api/v1") diff --git a/backend/routers/notifications.py b/backend/routers/notifications.py new file mode 100644 index 0000000..ec5c2be --- /dev/null +++ b/backend/routers/notifications.py @@ -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 = """ + +Unsubscribed + +

Unsubscribed

+

You've been unsubscribed from email digests. You can re-enable them in your settings.

+
""" + +_UNSUB_ERROR_HTML = """ + +Unsubscribe Error + +

Link Expired or Invalid

+

This unsubscribe link is no longer valid. Please log in to manage your notification preferences.

+
""" + + +@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) diff --git a/backend/tests/notifications/conftest.py b/backend/tests/notifications/conftest.py new file mode 100644 index 0000000..8610828 --- /dev/null +++ b/backend/tests/notifications/conftest.py @@ -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). diff --git a/backend/tests/notifications/pytest.ini b/backend/tests/notifications/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/backend/tests/notifications/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/backend/tests/notifications/test_notifications.py b/backend/tests/notifications/test_notifications.py new file mode 100644 index 0000000..207bbf5 --- /dev/null +++ b/backend/tests/notifications/test_notifications.py @@ -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 diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..207bbf5 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -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 diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..cfb0e68 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -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 { + return request(`${BASE}/notifications/preferences`); +} + +export async function updateNotificationPreferences( + prefs: NotificationPreferencesUpdate, +): Promise { + return request(`${BASE}/notifications/preferences`, { + method: "PUT", + body: JSON.stringify(prefs), + }); +} diff --git a/frontend/src/pages/CreatorSettings.tsx b/frontend/src/pages/CreatorSettings.tsx index 1a14f74..0f962a0 100644 --- a/frontend/src/pages/CreatorSettings.tsx +++ b/frontend/src/pages/CreatorSettings.tsx @@ -1,7 +1,8 @@ -import { useState, type FormEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; import { useAuth, ApiError } from "../context/AuthContext"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { authUpdateProfile } from "../api"; +import { getNotificationPreferences, updateNotificationPreferences } from "../api/notifications"; import { SidebarNav } from "./CreatorDashboard"; import dashStyles from "./CreatorDashboard.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 [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) => { e.preventDefault(); setProfileMsg(null); @@ -168,6 +215,44 @@ export default function CreatorSettings() { + + {/* Email Notifications section */} +
+

Email Notifications

+ {notifMsg && ( +
+ {notifMsg.text} +
+ )} + {notifLoading ? ( +

Loading preferences…

+ ) : ( +
+ + {digestsEnabled && ( + + )} +
+ )} +
);