diff --git a/app/api/__init__.py b/app/api/__init__.py
index 28b9fbba..9936d7f4 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -32,6 +32,7 @@ from app.api.openai import router as openai_router
from app.api.pipelines import router as pipelines_router
from app.api.plans import router as plans_router
from app.api.process import router as process_router
+from app.api.profile import router as profile_router
from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_router
from app.api.scheduled_jobs import router as scheduled_jobs_router
@@ -83,6 +84,7 @@ router.include_router(plans_router)
router.include_router(onboarding_router)
router.include_router(billing_router)
router.include_router(pipelines_router)
+router.include_router(profile_router)
router.include_router(imap_accounts_router)
router.include_router(imap_profiles_router)
router.include_router(integrations_router)
diff --git a/app/api/profile.py b/app/api/profile.py
new file mode 100644
index 00000000..eb3b0e5b
--- /dev/null
+++ b/app/api/profile.py
@@ -0,0 +1,312 @@
+"""User self-service profile API.
+
+Provides endpoints for the authenticated user to view and update their own
+profile settings without requiring admin access.
+
+Routes:
+ GET /api/profile — read current user's profile
+ PATCH /api/profile — update display name, language, theme
+ POST /api/profile/avatar — upload a new profile picture (JPEG/PNG/GIF/WebP, max 2 MB)
+ DELETE /api/profile/avatar — remove custom avatar (reverts to Gravatar)
+ POST /api/profile/change-password — change password (local-auth users only)
+"""
+
+from __future__ import annotations
+
+import base64
+import logging
+from hashlib import md5
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from app.auth import require_login
+from app.database import get_db
+from app.models import LocalUser, UserProfile
+from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/profile", tags=["profile"])
+
+DbSession = Annotated[Session, Depends(get_db)]
+
+# Maximum avatar upload size: 2 MB
+_MAX_AVATAR_BYTES = 2 * 1024 * 1024
+
+# Allowed MIME types for avatar uploads
+_ALLOWED_AVATAR_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
+
+# Valid theme values
+_VALID_THEMES = {"light", "dark", "system"}
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _get_user_id(request: Request) -> str:
+ """Return the stable user identifier from the session.
+
+ Raises HTTP 401 if no user is logged in.
+ """
+ user = request.session.get("user")
+ if not user or not isinstance(user, dict):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+ uid = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
+ if not uid:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot determine user identity")
+ return uid
+
+
+def _gravatar_url(email: str | None) -> str:
+ """Generate a Gravatar URL for *email*, falling back to identicon."""
+ if not email:
+ return "https://www.gravatar.com/avatar/?d=identicon"
+ # MD5 used for Gravatar URL generation only — not for security
+ h = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest()
+ return f"https://www.gravatar.com/avatar/{h}?d=identicon"
+
+
+def _get_or_create_profile(db: Session, user_id: str) -> UserProfile:
+ """Return the UserProfile for *user_id*, creating a stub if one doesn't exist."""
+ profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
+ if profile is None:
+ profile = UserProfile(user_id=user_id)
+ db.add(profile)
+ try:
+ db.commit()
+ db.refresh(profile)
+ except Exception:
+ db.rollback()
+ raise
+ return profile
+
+
+# ---------------------------------------------------------------------------
+# Pydantic schemas
+# ---------------------------------------------------------------------------
+
+
+class ProfileResponse(BaseModel):
+ """Response body for GET /api/profile."""
+
+ user_id: str
+ display_name: str | None
+ contact_email: str | None
+ preferred_language: str | None
+ preferred_theme: str | None
+ avatar_url: str
+ """Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
+ is_local_user: bool
+ """True when the account was created via local email/password sign-up."""
+
+
+class ProfileUpdateRequest(BaseModel):
+ """Request body for PATCH /api/profile."""
+
+ display_name: str | None = Field(default=None, max_length=255, description="Human-readable display name")
+ contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail")
+ preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'")
+ preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'")
+
+
+class ChangePasswordRequest(BaseModel):
+ """Request body for POST /api/profile/change-password."""
+
+ current_password: str = Field(..., min_length=1, max_length=128)
+ new_password: str = Field(..., min_length=8, max_length=128)
+ new_password_confirm: str = Field(..., min_length=8, max_length=128)
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.get("", response_model=ProfileResponse)
+@require_login
+async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
+ """Return the current user's profile settings."""
+ user_id = _get_user_id(request)
+ profile = _get_or_create_profile(db, user_id)
+
+ session_user = request.session.get("user", {})
+ email = session_user.get("email") if isinstance(session_user, dict) else None
+
+ # Determine avatar: prefer stored data, fall back to Gravatar
+ avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
+
+ # Check whether this is a local (email/password) account
+ is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
+
+ return ProfileResponse(
+ user_id=user_id,
+ display_name=profile.display_name, # type: ignore[arg-type]
+ contact_email=profile.contact_email, # type: ignore[arg-type]
+ preferred_language=profile.preferred_language, # type: ignore[arg-type]
+ preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
+ avatar_url=avatar_url,
+ is_local_user=is_local,
+ )
+
+
+@router.patch("", response_model=ProfileResponse)
+@require_login
+async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSession) -> ProfileResponse:
+ """Update the current user's editable profile settings."""
+ user_id = _get_user_id(request)
+ profile = _get_or_create_profile(db, user_id)
+
+ # Validate language code
+ if body.preferred_language is not None:
+ lang = body.preferred_language.lower().strip()
+ if lang and lang not in SUPPORTED_LANGUAGE_CODES:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"Unsupported language code: {lang}",
+ )
+ profile.preferred_language = lang or None # type: ignore[assignment]
+
+ # Validate theme
+ if body.preferred_theme is not None:
+ theme = body.preferred_theme.lower().strip()
+ if theme and theme not in _VALID_THEMES:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"Invalid theme: {theme}. Must be one of: {', '.join(sorted(_VALID_THEMES))}",
+ )
+ profile.preferred_theme = theme or None # type: ignore[assignment]
+
+ if body.display_name is not None:
+ profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
+
+ if body.contact_email is not None:
+ profile.contact_email = body.contact_email.strip() or None # type: ignore[assignment]
+
+ try:
+ db.commit()
+ db.refresh(profile)
+ except Exception:
+ db.rollback()
+ raise
+
+ session_user = request.session.get("user", {})
+ email = session_user.get("email") if isinstance(session_user, dict) else None
+ avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
+ is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
+
+ return ProfileResponse(
+ user_id=user_id,
+ display_name=profile.display_name, # type: ignore[arg-type]
+ contact_email=profile.contact_email, # type: ignore[arg-type]
+ preferred_language=profile.preferred_language, # type: ignore[arg-type]
+ preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
+ avatar_url=avatar_url,
+ is_local_user=is_local,
+ )
+
+
+@router.post("/avatar", status_code=status.HTTP_200_OK)
+@require_login
+async def upload_avatar(
+ request: Request,
+ db: DbSession,
+ file: UploadFile = File(..., description="Profile picture (JPEG, PNG, GIF or WebP; max 2 MB)"),
+) -> dict:
+ """Upload a new profile picture.
+
+ The image is stored as a base64-encoded data URL in ``UserProfile.avatar_data``.
+ Accepts JPEG, PNG, GIF, or WebP files up to 2 MB.
+ """
+ user_id = _get_user_id(request)
+
+ content_type = (file.content_type or "").lower()
+ if content_type not in _ALLOWED_AVATAR_TYPES:
+ raise HTTPException(
+ status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
+ detail=f"Unsupported image type '{content_type}'. Allowed: JPEG, PNG, GIF, WebP.",
+ )
+
+ raw = await file.read(_MAX_AVATAR_BYTES + 1)
+ if len(raw) > _MAX_AVATAR_BYTES:
+ raise HTTPException(
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+ detail="Avatar image must be 2 MB or smaller.",
+ )
+
+ b64 = base64.b64encode(raw).decode("ascii")
+ data_url = f"data:{content_type};base64,{b64}"
+
+ profile = _get_or_create_profile(db, user_id)
+ profile.avatar_data = data_url # type: ignore[assignment]
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise
+
+ return {"avatar_url": data_url}
+
+
+@router.delete("/avatar", status_code=status.HTTP_200_OK)
+@require_login
+async def delete_avatar(request: Request, db: DbSession) -> dict:
+ """Remove the custom avatar and revert to the Gravatar fallback."""
+ user_id = _get_user_id(request)
+ profile = _get_or_create_profile(db, user_id)
+ profile.avatar_data = None # type: ignore[assignment]
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise
+
+ session_user = request.session.get("user", {})
+ email = session_user.get("email") if isinstance(session_user, dict) else None
+ return {"avatar_url": _gravatar_url(email)}
+
+
+@router.post("/change-password", status_code=status.HTTP_200_OK)
+@require_login
+async def change_password(body: ChangePasswordRequest, request: Request, db: DbSession) -> dict:
+ """Change the password for local (email/password) accounts.
+
+ Raises 403 if the account is not a local account or the current password is wrong.
+ Raises 422 if the new passwords do not match.
+ """
+ from app.utils.local_auth import hash_password, verify_password
+
+ user_id = _get_user_id(request)
+
+ local_user = db.query(LocalUser).filter(LocalUser.username == user_id).first()
+ if local_user is None:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Password change is only available for local accounts.",
+ )
+
+ if not verify_password(body.current_password, local_user.hashed_password):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Current password is incorrect.",
+ )
+
+ if body.new_password != body.new_password_confirm:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="New passwords do not match.",
+ )
+
+ local_user.hashed_password = hash_password(body.new_password)
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise
+
+ logger.info("Password changed for local user: %s", user_id)
+ return {"detail": "Password changed successfully."}
diff --git a/app/api/user.py b/app/api/user.py
index 4fa0ce36..2f8cd926 100644
--- a/app/api/user.py
+++ b/app/api/user.py
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
-from app.models import FileRecord
+from app.models import FileRecord, UserProfile
# Set up logging
logger = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
-async def whoami_handler(request: Request):
+async def whoami_handler(request: Request, db: Session):
"""
Returns user info if logged in, else 401.
"""
@@ -41,20 +41,33 @@ async def whoami_handler(request: Request):
# Add the gravatar URL to the user object instead of creating a new response
user_response = user.copy() # Create a copy to avoid modifying the session
- user_response["picture"] = gravatar_url
+
+ # Check if the user has a custom avatar stored in their profile
+ user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
+ if user_id:
+ try:
+ profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
+ if profile and profile.avatar_data:
+ user_response["picture"] = profile.avatar_data
+ else:
+ user_response["picture"] = gravatar_url
+ except Exception:
+ user_response["picture"] = gravatar_url
+ else:
+ user_response["picture"] = gravatar_url
return user_response
# Register the same handler under two different paths
@router.get("/whoami")
-async def whoami(request: Request):
- return await whoami_handler(request)
+async def whoami(request: Request, db: DbSession):
+ return await whoami_handler(request, db)
@router.get("/auth/whoami")
-async def auth_whoami(request: Request):
- return await whoami_handler(request)
+async def auth_whoami(request: Request, db: DbSession):
+ return await whoami_handler(request, db)
@router.get("/users/search")
diff --git a/app/models.py b/app/models.py
index 5ba06e81..bb1ee985 100644
--- a/app/models.py
+++ b/app/models.py
@@ -281,6 +281,13 @@ class UserProfile(Base):
# NULL means "auto-detect from browser Accept-Language header"
preferred_language = Column(String(10), nullable=True)
+ # UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
+ preferred_theme = Column(String(10), nullable=True)
+
+ # Custom profile avatar stored as a base64 data-URL (e.g. "data:image/png;base64,...")
+ # NULL means use the Gravatar fallback derived from the user's e-mail address.
+ avatar_data = Column(Text, nullable=True)
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/app/views/__init__.py b/app/views/__init__.py
index b2814ff5..b25a3be8 100644
--- a/app/views/__init__.py
+++ b/app/views/__init__.py
@@ -25,6 +25,7 @@ from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router
from app.views.pipelines import router as pipelines_router # Processing pipelines
from app.views.plans import router as plans_router # Admin Plan Designer
+from app.views.profile import router as profile_router # User self-service profile
from app.views.queue import router as queue_router
from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs
from app.views.search import router as search_router
@@ -58,6 +59,7 @@ router.include_router(subscriptions_router) # Pricing + subscription pages
router.include_router(plans_router) # Admin Plan Designer
router.include_router(onboarding_router) # User onboarding wizard
router.include_router(pipelines_router) # Processing pipelines
+router.include_router(profile_router) # User self-service profile settings
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard
diff --git a/app/views/profile.py b/app/views/profile.py
new file mode 100644
index 00000000..d6885cda
--- /dev/null
+++ b/app/views/profile.py
@@ -0,0 +1,40 @@
+"""View route for the user self-service profile settings page.
+
+Route:
+ GET /profile — renders the profile settings HTML page (requires login)
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import Depends, Request
+from sqlalchemy.orm import Session
+
+from app.models import UserProfile
+from app.utils.i18n import SUPPORTED_LANGUAGES
+from app.views.base import APIRouter, get_db, require_login, templates
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+@router.get("/profile", include_in_schema=False)
+@require_login
+async def profile_page(request: Request, db: Session = Depends(get_db)):
+ """Serve the user profile settings page."""
+ user = request.session.get("user") or {}
+ user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
+
+ profile = None
+ if user_id:
+ profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
+
+ return templates.TemplateResponse(
+ "profile.html",
+ {
+ "request": request,
+ "profile": profile,
+ "supported_languages": SUPPORTED_LANGUAGES,
+ },
+ )
diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js
index 573cc098..b09beb42 100644
--- a/frontend/static/js/common.js
+++ b/frontend/static/js/common.js
@@ -206,6 +206,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
// Links section
const linksDiv = document.createElement('div');
linksDiv.className = 'py-1';
+ linksDiv.appendChild(
+ _makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', 'Profile Settings', 'text-gray-700')
+ );
linksDiv.appendChild(
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
);
@@ -272,6 +275,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
userRow.appendChild(mUserInfo);
mobileAuthSection.appendChild(userRow);
+ // Profile Settings link
+ const profileLink = document.createElement('a');
+ profileLink.href = '/profile';
+ profileLink.className =
+ 'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
+ const profileIcon = document.createElement('i');
+ profileIcon.className = 'fas fa-user-circle mr-2 text-blue-400';
+ profileIcon.setAttribute('aria-hidden', 'true');
+ profileLink.appendChild(profileIcon);
+ profileLink.appendChild(document.createTextNode('Profile Settings'));
+ mobileAuthSection.appendChild(profileLink);
+
// Subscription link
const subLink = document.createElement('a');
subLink.href = '/subscription';
diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html
new file mode 100644
index 00000000..75c04d39
--- /dev/null
+++ b/frontend/templates/profile.html
@@ -0,0 +1,517 @@
+{% extends "base.html" %}
+{% block title %}Profile Settings – DocuElevate{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ Profile Settings
+
+
+ Manage your display name, avatar, language, and theme preferences.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Profile Picture
+
+
+
+
+
+
![Your profile picture]()
+
+
+
+
+
+
+
+
+ Upload a JPEG, PNG, GIF, or WebP image up to 2 MB.
+ If no custom picture is set, your Gravatar is shown.
+
+
+
+
+
+
+
+
+
+
+ General Information
+
+
+
+
+
+
+
+
+ Leave blank to use your account username or email.
+
+
+
+
+
+
+
+
+ Used for system notifications. This does not change your login e-mail.
+
+
+
+
+
+
+
+
+ Preferences
+
+
+
+
+
+
+
+
+ Choose your preferred interface language. "Auto-detect" follows your browser settings.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Change Password
+
+
+
+
+
+
+
+
+
+
+
Minimum 8 characters.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/migrations/versions/034_add_user_profile_settings.py b/migrations/versions/034_add_user_profile_settings.py
new file mode 100644
index 00000000..104bf814
--- /dev/null
+++ b/migrations/versions/034_add_user_profile_settings.py
@@ -0,0 +1,47 @@
+"""Add preferred_theme and avatar_data columns to user_profiles.
+
+Revision ID: 034_add_user_profile_settings
+Revises: 033_add_imap_ingestion_profiles
+Create Date: 2026-03-12
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "034_add_user_profile_settings"
+down_revision: Union[str, None] = "033_add_imap_ingestion_profiles"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Add preferred_theme and avatar_data columns to user_profiles table."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if "user_profiles" not in inspector.get_table_names():
+ return
+ existing_columns = {col["name"] for col in inspector.get_columns("user_profiles")}
+ if "preferred_theme" not in existing_columns:
+ op.add_column(
+ "user_profiles",
+ sa.Column("preferred_theme", sa.String(10), nullable=True, server_default=None),
+ )
+ if "avatar_data" not in existing_columns:
+ op.add_column(
+ "user_profiles",
+ sa.Column("avatar_data", sa.Text, nullable=True, server_default=None),
+ )
+
+
+def downgrade() -> None:
+ """Remove preferred_theme and avatar_data columns from user_profiles table."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if "user_profiles" not in inspector.get_table_names():
+ return
+ existing_columns = {col["name"] for col in inspector.get_columns("user_profiles")}
+ if "avatar_data" in existing_columns:
+ op.drop_column("user_profiles", "avatar_data")
+ if "preferred_theme" in existing_columns:
+ op.drop_column("user_profiles", "preferred_theme")
diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py
new file mode 100644
index 00000000..8cc069c3
--- /dev/null
+++ b/tests/test_api_profile.py
@@ -0,0 +1,500 @@
+"""Tests for app/api/profile.py — user self-service profile API.
+
+Unit tests call handler functions directly with mock request objects.
+Integration tests use a dedicated TestClient with DB override.
+"""
+
+from __future__ import annotations
+
+import base64
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi import HTTPException
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.database import Base, get_db
+from app.models import LocalUser, UserProfile
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def prof_engine():
+ """In-memory SQLite engine scoped to one test."""
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ yield engine
+ Base.metadata.drop_all(bind=engine)
+
+
+@pytest.fixture()
+def prof_session(prof_engine):
+ """DB session for one profile test."""
+ Session = sessionmaker(bind=prof_engine)
+ session = Session()
+ yield session
+ session.close()
+
+
+@pytest.fixture()
+def prof_client(prof_engine):
+ """TestClient with the in-memory DB injected."""
+ from app.main import app
+
+ def override_db():
+ Session = sessionmaker(bind=prof_engine)
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ app.dependency_overrides[get_db] = override_db
+ with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
+ yield c
+ app.dependency_overrides.pop(get_db, None)
+
+
+# ---------------------------------------------------------------------------
+# Unit tests — helper functions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGravatarUrl:
+ """Tests for the _gravatar_url helper."""
+
+ def test_returns_gravatar_for_valid_email(self):
+ from app.api.profile import _gravatar_url
+
+ url = _gravatar_url("Test@Example.COM")
+ assert "gravatar.com/avatar/" in url
+ assert url.endswith("?d=identicon")
+
+ def test_fallback_for_none_email(self):
+ from app.api.profile import _gravatar_url
+
+ url = _gravatar_url(None)
+ assert "gravatar.com/avatar/" in url
+ assert "?d=identicon" in url
+
+
+@pytest.mark.unit
+class TestGetUserId:
+ """Tests for the _get_user_id helper."""
+
+ def test_extracts_sub(self):
+ from app.api.profile import _get_user_id
+
+ req = MagicMock()
+ req.session = {"user": {"sub": "sub-123", "email": "a@b.com"}}
+ assert _get_user_id(req) == "sub-123"
+
+ def test_extracts_preferred_username_fallback(self):
+ from app.api.profile import _get_user_id
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "alice", "email": "a@b.com"}}
+ assert _get_user_id(req) == "alice"
+
+ def test_extracts_email_fallback(self):
+ from app.api.profile import _get_user_id
+
+ req = MagicMock()
+ req.session = {"user": {"email": "a@b.com"}}
+ assert _get_user_id(req) == "a@b.com"
+
+ def test_raises_401_when_no_session_user(self):
+ from app.api.profile import _get_user_id
+
+ req = MagicMock()
+ req.session = {}
+ with pytest.raises(HTTPException) as exc:
+ _get_user_id(req)
+ assert exc.value.status_code == 401
+
+ def test_raises_401_when_no_identifier(self):
+ from app.api.profile import _get_user_id
+
+ req = MagicMock()
+ req.session = {"user": {"name": "Someone"}}
+ with pytest.raises(HTTPException) as exc:
+ _get_user_id(req)
+ assert exc.value.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# Unit tests — GET /api/profile handler
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetProfileHandler:
+ """Unit tests for the get_profile endpoint handler."""
+
+ @pytest.mark.asyncio
+ async def test_returns_profile_from_db(self, prof_session):
+ """get_profile reads from DB and returns correct data."""
+ from app.api.profile import get_profile
+
+ profile = UserProfile(
+ user_id="alice",
+ display_name="Alice",
+ preferred_language="fr",
+ preferred_theme="dark",
+ )
+ prof_session.add(profile)
+ prof_session.commit()
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "alice", "email": "alice@example.com"}}
+
+ result = await get_profile(req, prof_session)
+ assert result.user_id == "alice"
+ assert result.display_name == "Alice"
+ assert result.preferred_language == "fr"
+ assert result.preferred_theme == "dark"
+ assert "gravatar.com" in result.avatar_url
+
+ @pytest.mark.asyncio
+ async def test_returns_custom_avatar_when_stored(self, prof_session):
+ """get_profile returns the data: URI when avatar_data is set."""
+ from app.api.profile import get_profile
+
+ profile = UserProfile(
+ user_id="bob",
+ avatar_data="data:image/png;base64,abc",
+ )
+ prof_session.add(profile)
+ prof_session.commit()
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "bob", "email": "bob@example.com"}}
+
+ result = await get_profile(req, prof_session)
+ assert result.avatar_url == "data:image/png;base64,abc"
+
+ @pytest.mark.asyncio
+ async def test_creates_profile_if_missing(self, prof_session):
+ """get_profile creates a stub profile row when none exists."""
+ from app.api.profile import get_profile
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "newbie", "email": "newbie@example.com"}}
+
+ result = await get_profile(req, prof_session)
+ assert result.user_id == "newbie"
+ row = prof_session.query(UserProfile).filter_by(user_id="newbie").first()
+ assert row is not None
+
+
+# ---------------------------------------------------------------------------
+# Unit tests — PATCH /api/profile handler
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestUpdateProfileHandler:
+ """Unit tests for the update_profile endpoint handler."""
+
+ @pytest.mark.asyncio
+ async def test_updates_display_name(self, prof_session):
+ """update_profile updates display_name."""
+ from app.api.profile import ProfileUpdateRequest, update_profile
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
+
+ body = ProfileUpdateRequest(display_name="Carol Smith")
+ result = await update_profile(body, req, prof_session)
+ assert result.display_name == "Carol Smith"
+
+ @pytest.mark.asyncio
+ async def test_updates_language(self, prof_session):
+ """update_profile updates preferred_language."""
+ from app.api.profile import ProfileUpdateRequest, update_profile
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
+
+ body = ProfileUpdateRequest(preferred_language="de")
+ result = await update_profile(body, req, prof_session)
+ assert result.preferred_language == "de"
+
+ @pytest.mark.asyncio
+ async def test_updates_theme(self, prof_session):
+ """update_profile updates preferred_theme."""
+ from app.api.profile import ProfileUpdateRequest, update_profile
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
+
+ body = ProfileUpdateRequest(preferred_theme="light")
+ result = await update_profile(body, req, prof_session)
+ assert result.preferred_theme == "light"
+
+ @pytest.mark.asyncio
+ async def test_rejects_invalid_language(self, prof_session):
+ """update_profile raises 422 for unsupported language code."""
+ from app.api.profile import ProfileUpdateRequest, update_profile
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
+
+ body = ProfileUpdateRequest(preferred_language="xx")
+ with pytest.raises(HTTPException) as exc:
+ await update_profile(body, req, prof_session)
+ assert exc.value.status_code == 422
+
+ @pytest.mark.asyncio
+ async def test_rejects_invalid_theme(self, prof_session):
+ """update_profile raises 422 for invalid theme value."""
+ from app.api.profile import ProfileUpdateRequest, update_profile
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
+
+ body = ProfileUpdateRequest(preferred_theme="rainbow")
+ with pytest.raises(HTTPException) as exc:
+ await update_profile(body, req, prof_session)
+ assert exc.value.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# Unit tests — POST /api/profile/avatar handler
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestUploadAvatarHandler:
+ """Unit tests for the upload_avatar endpoint handler."""
+
+ @pytest.mark.asyncio
+ async def test_stores_base64_data_url(self, prof_session):
+ """upload_avatar stores the image as a data: URI."""
+ from app.api.profile import upload_avatar
+
+ png_bytes = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/Z+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="
+ )
+
+ upload = MagicMock()
+ upload.content_type = "image/png"
+ upload.read = AsyncMock(return_value=png_bytes)
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "avataruser", "email": "av@example.com"}}
+
+ result = await upload_avatar(req, prof_session, upload)
+ assert result["avatar_url"].startswith("data:image/png;base64,")
+
+ @pytest.mark.asyncio
+ async def test_rejects_unsupported_mime(self, prof_session):
+ """upload_avatar raises 415 for non-image content types."""
+ from app.api.profile import upload_avatar
+
+ upload = MagicMock()
+ upload.content_type = "application/pdf"
+ upload.read = AsyncMock(return_value=b"%PDF")
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "pdfuser", "email": "pdf@example.com"}}
+
+ with pytest.raises(HTTPException) as exc:
+ await upload_avatar(req, prof_session, upload)
+ assert exc.value.status_code == 415
+
+ @pytest.mark.asyncio
+ async def test_rejects_oversized_image(self, prof_session):
+ """upload_avatar raises 413 when image exceeds 2 MB."""
+ from app.api.profile import upload_avatar
+
+ big_data = b"x" * (2 * 1024 * 1024 + 1)
+
+ upload = MagicMock()
+ upload.content_type = "image/png"
+ upload.read = AsyncMock(return_value=big_data)
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "biguser", "email": "big@example.com"}}
+
+ with pytest.raises(HTTPException) as exc:
+ await upload_avatar(req, prof_session, upload)
+ assert exc.value.status_code == 413
+
+
+# ---------------------------------------------------------------------------
+# Unit tests — DELETE /api/profile/avatar handler
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDeleteAvatarHandler:
+ """Unit tests for the delete_avatar endpoint handler."""
+
+ @pytest.mark.asyncio
+ async def test_clears_avatar_data(self, prof_session):
+ """delete_avatar removes avatar_data and returns a Gravatar URL."""
+ from app.api.profile import delete_avatar
+
+ profile = UserProfile(
+ user_id="delavatar",
+ avatar_data="data:image/png;base64,abc",
+ )
+ prof_session.add(profile)
+ prof_session.commit()
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "delavatar", "email": "del@example.com"}}
+
+ result = await delete_avatar(req, prof_session)
+ assert "gravatar.com" in result["avatar_url"]
+
+ row = prof_session.query(UserProfile).filter_by(user_id="delavatar").first()
+ assert row.avatar_data is None
+
+
+# ---------------------------------------------------------------------------
+# Unit tests — POST /api/profile/change-password handler
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestChangePasswordHandler:
+ """Unit tests for the change_password endpoint handler."""
+
+ @pytest.mark.asyncio
+ async def test_rejects_non_local_user(self, prof_session):
+ """change_password raises 403 for OAuth-only accounts."""
+ from app.api.profile import ChangePasswordRequest, change_password
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "oauthonly", "email": "oauth@example.com"}}
+
+ body = ChangePasswordRequest(
+ current_password="old",
+ new_password="newpassword1",
+ new_password_confirm="newpassword1",
+ )
+ with pytest.raises(HTTPException) as exc:
+ await change_password(body, req, prof_session)
+ assert exc.value.status_code == 403
+
+ @pytest.mark.asyncio
+ async def test_rejects_wrong_current_password(self, prof_session):
+ """change_password raises 403 when current password is wrong."""
+ from app.api.profile import ChangePasswordRequest, change_password
+ from app.utils.local_auth import hash_password
+
+ local_user = LocalUser(
+ email="local@example.com",
+ username="localwrong",
+ hashed_password=hash_password("correctpassword"),
+ is_active=True,
+ )
+ prof_session.add(local_user)
+ prof_session.commit()
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "localwrong", "email": "local@example.com"}}
+
+ body = ChangePasswordRequest(
+ current_password="wrongpassword",
+ new_password="newpassword1",
+ new_password_confirm="newpassword1",
+ )
+ with pytest.raises(HTTPException) as exc:
+ await change_password(body, req, prof_session)
+ assert exc.value.status_code == 403
+
+ @pytest.mark.asyncio
+ async def test_rejects_password_mismatch(self, prof_session):
+ """change_password raises 422 when new passwords do not match."""
+ from app.api.profile import ChangePasswordRequest, change_password
+ from app.utils.local_auth import hash_password
+
+ local_user = LocalUser(
+ email="mismatch@example.com",
+ username="mismatchpw",
+ hashed_password=hash_password("currentpw"),
+ is_active=True,
+ )
+ prof_session.add(local_user)
+ prof_session.commit()
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "mismatchpw", "email": "mismatch@example.com"}}
+
+ body = ChangePasswordRequest(
+ current_password="currentpw",
+ new_password="newpassword1",
+ new_password_confirm="differentpassword",
+ )
+ with pytest.raises(HTTPException) as exc:
+ await change_password(body, req, prof_session)
+ assert exc.value.status_code == 422
+
+ @pytest.mark.asyncio
+ async def test_changes_password_successfully(self, prof_session):
+ """change_password updates hashed_password for correct input."""
+ from app.api.profile import ChangePasswordRequest, change_password
+ from app.utils.local_auth import hash_password, verify_password
+
+ local_user = LocalUser(
+ email="success@example.com",
+ username="successpw",
+ hashed_password=hash_password("oldpassword"),
+ is_active=True,
+ )
+ prof_session.add(local_user)
+ prof_session.commit()
+
+ req = MagicMock()
+ req.session = {"user": {"preferred_username": "successpw", "email": "success@example.com"}}
+
+ body = ChangePasswordRequest(
+ current_password="oldpassword",
+ new_password="newpassword1",
+ new_password_confirm="newpassword1",
+ )
+ result = await change_password(body, req, prof_session)
+ assert "successfully" in result["detail"].lower()
+
+ updated_user = prof_session.query(LocalUser).filter_by(username="successpw").first()
+ assert verify_password("newpassword1", updated_user.hashed_password)
+
+
+# ---------------------------------------------------------------------------
+# Integration tests — HTTP endpoint registration
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestProfileEndpoints:
+ """Verify profile endpoints are registered and reachable."""
+
+ def test_get_profile_without_session_returns_401(self, prof_client):
+ """GET /api/profile returns 401 when no user in session."""
+ response = prof_client.get("/api/profile")
+ assert response.status_code == 401
+
+ def test_patch_profile_without_session_returns_401(self, prof_client):
+ """PATCH /api/profile returns 401 when no user in session."""
+ response = prof_client.patch("/api/profile", json={"display_name": "Test"})
+ assert response.status_code == 401
+
+ def test_profile_page_accessible(self, prof_client):
+ """GET /profile page renders successfully (auth disabled in tests)."""
+ response = prof_client.get("/profile", follow_redirects=False)
+ # AUTH_ENABLED=False in tests so no redirect; page should render
+ assert response.status_code in (200, 302)
diff --git a/tests/test_api_user.py b/tests/test_api_user.py
index 9332bf33..2b5b16a3 100644
--- a/tests/test_api_user.py
+++ b/tests/test_api_user.py
@@ -14,18 +14,38 @@ class TestWhoamiHandler:
@pytest.mark.asyncio
async def test_returns_user_with_gravatar(self):
- """Test that handler returns user data with gravatar URL."""
+ """Test that handler returns user data with gravatar URL when no custom avatar."""
mock_request = MagicMock()
email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
- result = await whoami_handler(mock_request)
+ # Mock DB: no UserProfile found (no custom avatar)
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+
+ result = await whoami_handler(mock_request, mock_db)
assert result["id"] == "1"
assert result["name"] == "Test"
- # Should have gravatar URL
+ # Should have gravatar URL since no custom avatar
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
+ @pytest.mark.asyncio
+ async def test_returns_custom_avatar_when_set(self):
+ """Test that handler returns custom avatar URL when profile has avatar_data."""
+ mock_request = MagicMock()
+ email = "test@example.com"
+ mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
+
+ # Mock DB: UserProfile with avatar_data
+ mock_profile = MagicMock()
+ mock_profile.avatar_data = "data:image/png;base64,abc123"
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
+
+ result = await whoami_handler(mock_request, mock_db)
+ assert result["picture"] == "data:image/png;base64,abc123"
+
@pytest.mark.asyncio
async def test_raises_401_when_no_user(self):
"""Test that 401 is raised when no user in session."""
@@ -33,9 +53,10 @@ class TestWhoamiHandler:
mock_request = MagicMock()
mock_request.session = {}
+ mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info:
- await whoami_handler(mock_request)
+ await whoami_handler(mock_request, mock_db)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
@@ -45,11 +66,26 @@ class TestWhoamiHandler:
mock_request = MagicMock()
mock_request.session = {"user": {"id": "1", "name": "Test"}}
+ mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info:
- await whoami_handler(mock_request)
+ await whoami_handler(mock_request, mock_db)
assert exc_info.value.status_code == 400
+ @pytest.mark.asyncio
+ async def test_falls_back_to_gravatar_on_db_error(self):
+ """Test that gravatar is used when DB lookup raises an exception."""
+ mock_request = MagicMock()
+ email = "test@example.com"
+ mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
+
+ mock_db = MagicMock()
+ mock_db.query.side_effect = Exception("DB error")
+
+ result = await whoami_handler(mock_request, mock_db)
+ expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
+ assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
+
@pytest.mark.integration
class TestWhoamiEndpoints: