feat(onboarding): add multi-step user onboarding wizard
Add a 5-step onboarding flow for new users: - Migration 017: adds onboarding_completed, onboarding_completed_at, contact_email, preferred_destination to user_profiles - app/api/onboarding.py: REST endpoints (status, profile, plan, storage, complete) with session-based auth using sub/preferred_username/email/id priority chain - app/views/onboarding.py: GET /onboarding view with configured-destination detection helper for all 8 supported storage providers - frontend/templates/onboarding.html: Alpine.js wizard with progress indicator, tier cards (server-rendered), storage destination cards, accessible markup (WCAG AA), and all fetch() API calls - app/auth.py: redirect first-time OAuth users (onboarding_completed=False) to /onboarding after login - 16 unit tests covering all endpoints, auth enforcement, and edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from app.api.duplicates import router as duplicates_router
|
||||
from app.api.files import router as files_router
|
||||
from app.api.google_drive import router as google_drive_router
|
||||
from app.api.logs import router as logs_router
|
||||
from app.api.onboarding import router as onboarding_router
|
||||
from app.api.onedrive import router as onedrive_router
|
||||
from app.api.openai import router as openai_router
|
||||
from app.api.plans import router as plans_router
|
||||
@@ -60,3 +61,4 @@ router.include_router(webhooks_router)
|
||||
router.include_router(database_router)
|
||||
router.include_router(subscriptions_router)
|
||||
router.include_router(plans_router)
|
||||
router.include_router(onboarding_router)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""API endpoints for the user onboarding wizard.
|
||||
|
||||
Provides a REST interface for the multi-step onboarding flow, allowing
|
||||
authenticated users to set their profile, choose a subscription plan,
|
||||
select a storage destination, and mark onboarding as complete.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import UserProfile
|
||||
from app.utils.subscription import TIERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/onboarding", tags=["onboarding"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_current_user_id(request: Request) -> str:
|
||||
"""Extract the stable user_id from the session using the same priority as _ensure_user_profile.
|
||||
|
||||
Priority: sub → preferred_username → email → id.
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if the user is not authenticated.
|
||||
"""
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return user_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfileBody(BaseModel):
|
||||
"""Body for the profile step of the onboarding wizard."""
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
contact_email: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class PlanBody(BaseModel):
|
||||
"""Body for the plan step of the onboarding wizard."""
|
||||
|
||||
subscription_tier: str
|
||||
billing_cycle: str = Field(pattern="^(monthly|yearly)$")
|
||||
|
||||
|
||||
class StorageBody(BaseModel):
|
||||
"""Body for the storage step of the onboarding wizard."""
|
||||
|
||||
preferred_destination: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
"""Serialize a UserProfile to a plain dict for API responses."""
|
||||
return {
|
||||
"user_id": profile.user_id,
|
||||
"display_name": profile.display_name,
|
||||
"contact_email": profile.contact_email,
|
||||
"subscription_tier": profile.subscription_tier or "free",
|
||||
"subscription_billing_cycle": profile.subscription_billing_cycle or "monthly",
|
||||
"preferred_destination": profile.preferred_destination,
|
||||
"onboarding_completed": bool(profile.onboarding_completed),
|
||||
"onboarding_completed_at": profile.onboarding_completed_at.isoformat()
|
||||
if profile.onboarding_completed_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _get_or_create_profile(db: Session, user_id: str) -> UserProfile:
|
||||
"""Return the UserProfile for *user_id*, creating one if it does not 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)
|
||||
db.flush()
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/status", summary="Get onboarding status for the current user")
|
||||
def get_onboarding_status(request: Request, db: DbSession) -> dict[str, Any]:
|
||||
"""Return whether onboarding has been completed and the current step.
|
||||
|
||||
The ``step`` field is a best-effort estimate: 1 for brand-new profiles,
|
||||
further along when partial data has already been saved.
|
||||
"""
|
||||
user_id = _get_current_user_id(request)
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||
|
||||
if profile is None:
|
||||
return {"completed": False, "step": 1, "profile": None}
|
||||
|
||||
# Derive a sensible current step from saved data so the wizard can resume.
|
||||
step = 1
|
||||
if profile.display_name or profile.contact_email:
|
||||
step = 2
|
||||
if profile.subscription_tier and profile.subscription_tier != "free":
|
||||
step = 3
|
||||
if profile.preferred_destination:
|
||||
step = 4
|
||||
if profile.onboarding_completed:
|
||||
step = 5
|
||||
|
||||
return {
|
||||
"completed": bool(profile.onboarding_completed),
|
||||
"step": step,
|
||||
"profile": _profile_to_dict(profile),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/profile", summary="Save profile step during onboarding")
|
||||
def save_profile(request: Request, body: ProfileBody, db: DbSession) -> dict[str, Any]:
|
||||
"""Persist the user's display name and contact email from the profile step."""
|
||||
user_id = _get_current_user_id(request)
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
|
||||
if body.display_name is not None:
|
||||
profile.display_name = body.display_name
|
||||
if body.contact_email is not None:
|
||||
profile.contact_email = body.contact_email
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Onboarding: saved profile for user %s", user_id)
|
||||
return _profile_to_dict(profile)
|
||||
|
||||
|
||||
@router.post("/plan", summary="Save plan selection during onboarding")
|
||||
def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]:
|
||||
"""Persist the chosen subscription tier and billing cycle from the plan step.
|
||||
|
||||
Raises:
|
||||
HTTPException: 422 if the tier is not a recognised value.
|
||||
"""
|
||||
user_id = _get_current_user_id(request)
|
||||
|
||||
if body.subscription_tier not in TIERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
|
||||
)
|
||||
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
profile.subscription_tier = body.subscription_tier
|
||||
profile.subscription_billing_cycle = body.billing_cycle
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Onboarding: saved plan %s/%s for user %s", body.subscription_tier, body.billing_cycle, user_id)
|
||||
return _profile_to_dict(profile)
|
||||
|
||||
|
||||
@router.post("/storage", summary="Save storage preference during onboarding")
|
||||
def save_storage(request: Request, body: StorageBody, db: DbSession) -> dict[str, Any]:
|
||||
"""Persist the user's preferred storage destination from the storage step."""
|
||||
user_id = _get_current_user_id(request)
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
profile.preferred_destination = body.preferred_destination
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Onboarding: saved storage preference '%s' for user %s", body.preferred_destination, user_id)
|
||||
return _profile_to_dict(profile)
|
||||
|
||||
|
||||
@router.post("/complete", summary="Mark onboarding as completed")
|
||||
def complete_onboarding(request: Request, db: DbSession) -> dict[str, bool]:
|
||||
"""Set onboarding_completed=True and record the completion timestamp."""
|
||||
user_id = _get_current_user_id(request)
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
profile.onboarding_completed = True
|
||||
profile.onboarding_completed_at = datetime.now(tz=timezone.utc)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Onboarding: completed for user %s", user_id)
|
||||
return {"success": True}
|
||||
+13
@@ -167,6 +167,19 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
# Log the successful authentication
|
||||
logger.info(f"[SECURITY] OAUTH_LOGIN_SUCCESS user={user_data.get('email', 'unknown')} admin={is_admin}")
|
||||
|
||||
# Redirect first-time users to onboarding
|
||||
user_id = (
|
||||
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
|
||||
)
|
||||
if user_id:
|
||||
from app.models import UserProfile as _UserProfile
|
||||
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
request.session["post_onboarding_redirect"] = post_onboarding
|
||||
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
# Redirect to original destination or default
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
||||
|
||||
@@ -209,6 +209,12 @@ class UserProfile(Base):
|
||||
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
|
||||
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# Onboarding tracking (added in migration 017)
|
||||
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
contact_email = Column(String(255), nullable=True)
|
||||
preferred_destination = Column(String(50), 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())
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.views.filemanager import router as filemanager_router
|
||||
from app.views.general import router as general_router
|
||||
from app.views.google_drive import router as google_drive_router
|
||||
from app.views.license_routes import router as license_router # Add the license router
|
||||
from app.views.onboarding import router as onboarding_router
|
||||
from app.views.onedrive import router as onedrive_router
|
||||
from app.views.plans import router as plans_router # Admin Plan Designer
|
||||
from app.views.queue import router as queue_router
|
||||
@@ -39,3 +40,4 @@ router.include_router(search_router)
|
||||
router.include_router(queue_router)
|
||||
router.include_router(subscriptions_router) # Pricing + subscription pages
|
||||
router.include_router(plans_router) # Admin Plan Designer
|
||||
router.include_router(onboarding_router) # User onboarding wizard
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""View route for the user onboarding wizard."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings as _settings
|
||||
from app.models import UserProfile
|
||||
from app.utils.subscription import get_all_tiers
|
||||
from app.views.base import APIRouter, get_db, require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Destination helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DESTINATION_META: list[dict] = [
|
||||
{"id": "dropbox", "name": "Dropbox", "icon": "fab fa-dropbox"},
|
||||
{"id": "gdrive", "name": "Google Drive", "icon": "fab fa-google-drive"},
|
||||
{"id": "onedrive", "name": "OneDrive", "icon": "fab fa-microsoft"},
|
||||
{"id": "s3", "name": "Amazon S3", "icon": "fab fa-aws"},
|
||||
{"id": "nextcloud", "name": "Nextcloud", "icon": "fas fa-cloud"},
|
||||
{"id": "webdav", "name": "WebDAV", "icon": "fas fa-server"},
|
||||
{"id": "sftp", "name": "SFTP", "icon": "fas fa-terminal"},
|
||||
{"id": "ftp", "name": "FTP", "icon": "fas fa-server"},
|
||||
]
|
||||
|
||||
|
||||
def _get_configured_destinations(cfg) -> list[dict]:
|
||||
"""Return which storage providers are fully configured in the current settings.
|
||||
|
||||
Each entry is a dict with ``id``, ``name``, and ``icon`` keys.
|
||||
|
||||
Args:
|
||||
cfg: The application settings object (``app.config.settings``).
|
||||
|
||||
Returns:
|
||||
A list of destination dicts for providers that have the required
|
||||
credentials set.
|
||||
"""
|
||||
checks: dict[str, bool] = {
|
||||
"dropbox": bool(cfg.dropbox_refresh_token and cfg.dropbox_app_key),
|
||||
"gdrive": bool(cfg.google_drive_credentials_json or cfg.google_drive_refresh_token),
|
||||
"onedrive": bool(cfg.onedrive_refresh_token and cfg.onedrive_client_id),
|
||||
"s3": bool(cfg.aws_access_key_id and cfg.s3_bucket_name),
|
||||
"nextcloud": bool(cfg.nextcloud_upload_url and cfg.nextcloud_username),
|
||||
"webdav": bool(cfg.webdav_url and cfg.webdav_username),
|
||||
"sftp": bool(cfg.sftp_host and cfg.sftp_username),
|
||||
"ftp": bool(cfg.ftp_host and cfg.ftp_username),
|
||||
}
|
||||
return [meta for meta in _DESTINATION_META if checks.get(meta["id"], False)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/onboarding", include_in_schema=False)
|
||||
@require_login
|
||||
async def onboarding_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Render the multi-step onboarding wizard.
|
||||
|
||||
Redirects to ``/upload`` when the user has already completed onboarding.
|
||||
"""
|
||||
user = request.session.get("user") or {}
|
||||
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
||||
|
||||
if user_id:
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||
if profile and profile.onboarding_completed:
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse(url="/upload", status_code=302)
|
||||
|
||||
configured_destinations = _get_configured_destinations(_settings)
|
||||
tiers = get_all_tiers(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"onboarding.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"configured_destinations": configured_destinations,
|
||||
"tiers": tiers,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,470 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Welcome to DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 py-8 px-4"
|
||||
x-data="onboardingWizard()" x-init="init()">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
|
||||
<!-- Brand header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center gap-2 text-indigo-600 font-bold text-2xl mb-2">
|
||||
<i class="fas fa-file-alt" aria-hidden="true"></i>
|
||||
DocuElevate
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm">Let's get you set up</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<div class="flex items-center justify-between mb-8 relative" role="list" aria-label="Onboarding steps">
|
||||
<!-- Background track -->
|
||||
<div class="absolute left-0 right-0 top-4 h-0.5 bg-gray-200" aria-hidden="true"></div>
|
||||
<!-- Progress fill -->
|
||||
<div class="absolute left-0 top-4 h-0.5 bg-indigo-500 transition-all duration-500"
|
||||
:style="`width: ${progressPercent}%`" aria-hidden="true"></div>
|
||||
|
||||
<template x-for="(label, i) in stepLabels" :key="i">
|
||||
<div class="flex flex-col items-center z-10" role="listitem">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-all duration-300 bg-white"
|
||||
:class="i + 1 < step ? 'bg-indigo-600 border-indigo-600 text-white' : i + 1 === step ? 'border-indigo-600 text-indigo-600' : 'border-gray-300 text-gray-400'"
|
||||
:aria-current="i + 1 === step ? 'step' : undefined">
|
||||
<span x-show="i + 1 < step" aria-label="Completed">
|
||||
<i class="fas fa-check text-xs" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span x-show="i + 1 >= step" x-text="i + 1" aria-hidden="true"></span>
|
||||
</div>
|
||||
<span class="text-xs mt-1 hidden sm:block transition-all duration-300"
|
||||
:class="i + 1 === step ? 'text-indigo-600 font-semibold' : 'text-gray-400'"
|
||||
x-text="label"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Wizard card -->
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
|
||||
<!-- ================================================================
|
||||
Step 1: Welcome
|
||||
================================================================ -->
|
||||
<div x-show="step === 1"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-4"
|
||||
x-transition:enter-end="opacity-100 translate-x-0">
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-white text-center">
|
||||
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-hand-wave text-3xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold mb-2">
|
||||
Welcome, {{ user.given_name | default(user.name) | default("there") }}! 👋
|
||||
</h1>
|
||||
<p class="text-indigo-100 text-lg">You're just a few steps away from transforming how you handle documents.</p>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
<div class="text-center p-4 rounded-xl bg-blue-50">
|
||||
<i class="fas fa-magic text-blue-500 text-2xl mb-2" aria-hidden="true"></i>
|
||||
<h3 class="font-semibold text-gray-800 text-sm">Smart OCR</h3>
|
||||
<p class="text-gray-500 text-xs mt-1">AI extracts text from any document</p>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-xl bg-indigo-50">
|
||||
<i class="fas fa-tags text-indigo-500 text-2xl mb-2" aria-hidden="true"></i>
|
||||
<h3 class="font-semibold text-gray-800 text-sm">Auto-tagging</h3>
|
||||
<p class="text-gray-500 text-xs mt-1">Documents organised automatically</p>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-xl bg-purple-50">
|
||||
<i class="fas fa-cloud-upload-alt text-purple-500 text-2xl mb-2" aria-hidden="true"></i>
|
||||
<h3 class="font-semibold text-gray-800 text-sm">Cloud Sync</h3>
|
||||
<p class="text-gray-500 text-xs mt-1">Instantly backed up to your storage</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm text-center mb-6">
|
||||
This quick setup takes about 2 minutes. You can change everything later in your settings.
|
||||
</p>
|
||||
<button @click="step++"
|
||||
class="w-full py-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition min-h-[44px]">
|
||||
Let's get started <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Step 2: Your Profile
|
||||
================================================================ -->
|
||||
<div x-show="step === 2"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-4"
|
||||
x-transition:enter-end="opacity-100 translate-x-0">
|
||||
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 px-8 py-6 text-white">
|
||||
<i class="fas fa-user-circle text-4xl mb-2" aria-hidden="true"></i>
|
||||
<h2 class="text-2xl font-bold">Your Profile</h2>
|
||||
<p class="text-blue-100 text-sm">Tell us a little about yourself</p>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<div class="mb-5">
|
||||
<label for="displayName" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input id="displayName"
|
||||
type="text"
|
||||
x-model="displayName"
|
||||
placeholder="Your name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
aria-describedby="displayNameHint" />
|
||||
<p id="displayNameHint" class="text-gray-400 text-xs mt-1">This is how you'll appear in DocuElevate.</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="contactEmail" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contact Email
|
||||
</label>
|
||||
<input id="contactEmail"
|
||||
type="email"
|
||||
x-model="contactEmail"
|
||||
placeholder="you@example.com"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
aria-describedby="contactEmailHint" />
|
||||
<p id="contactEmailHint" class="text-gray-400 text-xs mt-1">Used for notifications. Can be different from your login email.</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="goBack()"
|
||||
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
|
||||
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
||||
</button>
|
||||
<button @click="goNext()"
|
||||
:disabled="loading"
|
||||
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
|
||||
<span x-show="!loading">Continue <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i></span>
|
||||
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Step 3: Choose Your Plan
|
||||
================================================================ -->
|
||||
<div x-show="step === 3"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-4"
|
||||
x-transition:enter-end="opacity-100 translate-x-0">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-pink-600 px-8 py-6 text-white">
|
||||
<i class="fas fa-layer-group text-4xl mb-2" aria-hidden="true"></i>
|
||||
<h2 class="text-2xl font-bold">Choose Your Plan</h2>
|
||||
<p class="text-purple-100 text-sm">Start free, upgrade when you're ready</p>
|
||||
</div>
|
||||
<div class="p-6" x-data="{ annual: false }">
|
||||
|
||||
<!-- Monthly / Annual toggle -->
|
||||
<div class="flex items-center justify-center gap-3 mb-6">
|
||||
<span class="text-sm font-medium" :class="!annual ? 'text-indigo-600' : 'text-gray-500'">Monthly</span>
|
||||
<button @click="annual = !annual; billingCycle = annual ? 'yearly' : 'monthly'"
|
||||
class="relative w-12 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
:class="annual ? 'bg-indigo-600' : 'bg-gray-300'"
|
||||
role="switch"
|
||||
:aria-checked="annual.toString()"
|
||||
aria-label="Toggle billing cycle">
|
||||
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-300"
|
||||
:class="annual ? 'translate-x-6' : 'translate-x-0'"></span>
|
||||
</button>
|
||||
<span class="text-sm font-medium" :class="annual ? 'text-indigo-600' : 'text-gray-500'">
|
||||
Annual <span class="bg-green-100 text-green-700 text-xs px-1.5 py-0.5 rounded-full ml-1">2 months free</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tier cards (server-rendered by Jinja2) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
|
||||
{% for tier in tiers %}
|
||||
<button type="button"
|
||||
@click="selectedTier = '{{ tier.id }}'"
|
||||
class="text-left p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
|
||||
:class="selectedTier === '{{ tier.id }}' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
:aria-pressed="(selectedTier === '{{ tier.id }}').toString()">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-bold text-gray-800">{{ tier.name }}</span>
|
||||
<span class="text-lg font-extrabold text-indigo-600">
|
||||
{% if tier.price_monthly == 0 %}
|
||||
Free
|
||||
{% else %}
|
||||
<span x-show="!annual">${{ tier.price_monthly }}<span class="text-xs font-normal text-gray-500">/mo</span></span>
|
||||
<span x-show="annual">${{ "%.2f" | format(tier.price_monthly * 10) }}<span class="text-xs font-normal text-gray-500">/yr</span></span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs">{{ tier.tagline }}</p>
|
||||
{% if tier.id != "free" and tier.get("trial_days") %}
|
||||
<span class="inline-block mt-1.5 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
|
||||
{{ tier.trial_days }}-day free trial
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Beta notice -->
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-3 mb-5 flex gap-2 text-sm text-amber-700">
|
||||
<i class="fas fa-info-circle mt-0.5 shrink-0" aria-hidden="true"></i>
|
||||
<span>Payment processing is coming soon. You can select a plan now and billing will activate at launch.</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="goBack()"
|
||||
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
|
||||
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
||||
</button>
|
||||
<button @click="goNext()"
|
||||
:disabled="loading"
|
||||
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
|
||||
<span x-show="!loading">Continue <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i></span>
|
||||
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Step 4: Storage Destination
|
||||
================================================================ -->
|
||||
<div x-show="step === 4"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-4"
|
||||
x-transition:enter-end="opacity-100 translate-x-0">
|
||||
<div class="bg-gradient-to-r from-green-500 to-teal-600 px-8 py-6 text-white">
|
||||
<i class="fas fa-hdd text-4xl mb-2" aria-hidden="true"></i>
|
||||
<h2 class="text-2xl font-bold">Storage Destination</h2>
|
||||
<p class="text-green-100 text-sm">Where should your processed documents go?</p>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
{% if configured_destinations %}
|
||||
<p class="text-gray-600 text-sm mb-4">Select where you'd like your documents stored. You can change this later in settings.</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6" role="listbox" aria-label="Storage destinations">
|
||||
{% for dest in configured_destinations %}
|
||||
<button type="button"
|
||||
@click="selectedDestination = '{{ dest.id }}'"
|
||||
role="option"
|
||||
:aria-selected="(selectedDestination === '{{ dest.id }}').toString()"
|
||||
class="flex items-center gap-3 p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 min-h-[44px]"
|
||||
:class="selectedDestination === '{{ dest.id }}' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300 bg-white'">
|
||||
<i class="{{ dest.icon }} text-xl text-gray-600" aria-hidden="true"></i>
|
||||
<span class="font-medium text-gray-800">{{ dest.name }}</span>
|
||||
<span x-show="selectedDestination === '{{ dest.id }}'" class="ml-auto text-green-600">
|
||||
<i class="fas fa-check-circle" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-gray-400 text-xs mb-4">
|
||||
Not seeing your provider?
|
||||
{% if user.is_admin %}
|
||||
<a href="/settings" class="text-indigo-500 hover:underline">Go to Settings</a> to configure more destinations.
|
||||
{% else %}
|
||||
Ask your administrator to configure additional storage providers.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-hdd text-2xl text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-700 mb-2">No Storage Configured Yet</h3>
|
||||
<p class="text-gray-500 text-sm mb-4">
|
||||
No storage destinations have been configured for this instance yet.
|
||||
You can skip this step and set one up later.
|
||||
</p>
|
||||
{% if user.is_admin %}
|
||||
<a href="/settings"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition text-sm font-semibold min-h-[44px]">
|
||||
<i class="fas fa-cog" aria-hidden="true"></i> Configure Storage
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-gray-400 text-xs">Contact your administrator to set up a storage destination.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="goBack()"
|
||||
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
|
||||
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
||||
</button>
|
||||
<button @click="goNext()"
|
||||
:disabled="loading"
|
||||
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
|
||||
<span x-show="!loading">
|
||||
{% if configured_destinations %}Continue{% else %}Skip{% endif %}
|
||||
<i class="fas fa-arrow-right ml-1" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Step 5: All Set!
|
||||
================================================================ -->
|
||||
<div x-show="step === 5"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-4"
|
||||
x-transition:enter-end="opacity-100 translate-x-0">
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-white text-center">
|
||||
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-check-circle text-4xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-extrabold mb-2">You're all set! 🎉</h2>
|
||||
<p class="text-indigo-100">Your account is configured and ready to go.</p>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<ul class="space-y-3 mb-8" aria-label="Setup summary">
|
||||
<li class="flex items-center gap-3 text-gray-700">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center shrink-0">
|
||||
<i class="fas fa-check text-green-600 text-sm" aria-hidden="true"></i>
|
||||
</div>
|
||||
<span>Profile saved</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-gray-700">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center shrink-0">
|
||||
<i class="fas fa-check text-green-600 text-sm" aria-hidden="true"></i>
|
||||
</div>
|
||||
<span>Plan selected: <strong x-text="selectedTier"></strong></span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-gray-700">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="selectedDestination ? 'bg-green-100' : 'bg-gray-100'">
|
||||
<i class="fas text-sm"
|
||||
:class="selectedDestination ? 'fa-check text-green-600' : 'fa-minus text-gray-400'"
|
||||
aria-hidden="true"></i>
|
||||
</div>
|
||||
<span x-show="selectedDestination">Storage: <strong x-text="selectedDestination"></strong></span>
|
||||
<span x-show="!selectedDestination" class="text-gray-400">No storage destination selected (can be set later)</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button @click="goNext()"
|
||||
:disabled="loading"
|
||||
class="w-full py-3 px-6 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
|
||||
<span x-show="!loading">
|
||||
<i class="fas fa-rocket mr-2" aria-hidden="true"></i> Start uploading documents
|
||||
</span>
|
||||
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting up…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /card -->
|
||||
|
||||
<!-- Error display -->
|
||||
<div x-show="error"
|
||||
x-text="error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm text-center">
|
||||
</div>
|
||||
|
||||
</div><!-- /max-w-2xl -->
|
||||
</div><!-- /min-h-screen -->
|
||||
|
||||
<script>
|
||||
function onboardingWizard() {
|
||||
return {
|
||||
step: 1,
|
||||
totalSteps: 5,
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// Form data – pre-populated from Jinja2 context
|
||||
displayName: '{{ user.name | default("") | e }}',
|
||||
contactEmail: '{{ user.email | default("") | e }}',
|
||||
selectedTier: 'free',
|
||||
billingCycle: 'monthly',
|
||||
selectedDestination: '',
|
||||
|
||||
get progressPercent() {
|
||||
return ((this.step - 1) / (this.totalSteps - 1)) * 100;
|
||||
},
|
||||
|
||||
stepLabels: ['Welcome', 'Your Profile', 'Your Plan', 'Storage', 'All Done!'],
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/status');
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
if (data.completed) {
|
||||
window.location.href = '/upload';
|
||||
return;
|
||||
}
|
||||
if (data.profile) {
|
||||
if (data.profile.display_name) this.displayName = data.profile.display_name;
|
||||
if (data.profile.contact_email) this.contactEmail = data.profile.contact_email;
|
||||
if (data.profile.subscription_tier) this.selectedTier = data.profile.subscription_tier;
|
||||
if (data.profile.subscription_billing_cycle) this.billingCycle = data.profile.subscription_billing_cycle;
|
||||
if (data.profile.preferred_destination) this.selectedDestination = data.profile.preferred_destination;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal: proceed with defaults
|
||||
}
|
||||
},
|
||||
|
||||
async goNext() {
|
||||
if (this.step === 2) await this.saveProfile();
|
||||
else if (this.step === 3) await this.savePlan();
|
||||
else if (this.step === 4) await this.saveStorage();
|
||||
else if (this.step === 5) { await this.completeOnboarding(); return; }
|
||||
if (!this.error) this.step++;
|
||||
},
|
||||
|
||||
goBack() {
|
||||
if (this.step > 1) this.step--;
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.loading = true; this.error = '';
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: this.displayName, contact_email: this.contactEmail })
|
||||
});
|
||||
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save profile'; }
|
||||
} catch (_) { this.error = 'Network error. Please try again.'; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async savePlan() {
|
||||
this.loading = true; this.error = '';
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription_tier: this.selectedTier, billing_cycle: this.billingCycle })
|
||||
});
|
||||
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save plan'; }
|
||||
} catch (_) { this.error = 'Network error. Please try again.'; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async saveStorage() {
|
||||
this.loading = true; this.error = '';
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/storage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preferred_destination: this.selectedDestination || null })
|
||||
});
|
||||
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save storage preference'; }
|
||||
} catch (_) { this.error = 'Network error. Please try again.'; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async completeOnboarding() {
|
||||
this.loading = true; this.error = '';
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/complete', { method: 'POST' });
|
||||
if (r.ok) { window.location.href = '/upload'; }
|
||||
else { const d = await r.json(); this.error = d.detail || 'Failed to complete onboarding'; }
|
||||
} catch (_) { this.error = 'Network error. Please try again.'; }
|
||||
finally { this.loading = false; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add onboarding fields to user_profiles
|
||||
|
||||
Revision ID: 017_add_onboarding_fields
|
||||
Revises: 016_add_userprofile_billing
|
||||
Create Date: 2026-03-08
|
||||
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "017_add_onboarding_fields"
|
||||
down_revision: Union[str, None] = "016_add_userprofile_billing"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add onboarding_completed, onboarding_completed_at, contact_email, preferred_destination to user_profiles."""
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("onboarding_completed", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("onboarding_completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("contact_email", sa.String(255), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("preferred_destination", sa.String(50), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove onboarding columns from user_profiles."""
|
||||
op.drop_column("user_profiles", "preferred_destination")
|
||||
op.drop_column("user_profiles", "contact_email")
|
||||
op.drop_column("user_profiles", "onboarding_completed_at")
|
||||
op.drop_column("user_profiles", "onboarding_completed")
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Unit tests for the onboarding wizard API (/api/onboarding).
|
||||
|
||||
Covers:
|
||||
- GET /api/onboarding/status (auth required, new user, returning user)
|
||||
- POST /api/onboarding/profile (saves display_name / contact_email)
|
||||
- POST /api/onboarding/plan (saves tier + billing cycle, rejects invalid tiers)
|
||||
- POST /api/onboarding/storage (saves preferred_destination)
|
||||
- POST /api/onboarding/complete (marks onboarding_completed=True)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
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 UserProfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TEST_USER = {
|
||||
"sub": "user-onb-123",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"is_admin": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ob_engine():
|
||||
"""In-memory SQLite engine for onboarding tests."""
|
||||
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 ob_session(ob_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=ob_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ob_client_authed(ob_engine):
|
||||
"""TestClient that bypasses auth by monkey-patching _get_current_user_id."""
|
||||
from app.api import onboarding as ob_module
|
||||
from app.main import app
|
||||
|
||||
original = ob_module._get_current_user_id
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=ob_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def fake_user_id(_request):
|
||||
return _TEST_USER["sub"]
|
||||
|
||||
ob_module._get_current_user_id = fake_user_id
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
|
||||
ob_module._get_current_user_id = original
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_profile(session, user_id: str, **kwargs) -> UserProfile:
|
||||
"""Insert a UserProfile row with sensible defaults."""
|
||||
kwargs.setdefault("is_blocked", False)
|
||||
kwargs.setdefault("onboarding_completed", False)
|
||||
profile = UserProfile(user_id=user_id, **kwargs)
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestOnboardingAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOnboardingAPI:
|
||||
"""Unit tests for the /api/onboarding endpoints."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/onboarding/status
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_status_requires_auth(self, ob_engine):
|
||||
"""Unauthenticated requests to /status must return 401."""
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=ob_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 client:
|
||||
# No session user injected → _get_current_user_id raises 401
|
||||
resp = client.get("/api/onboarding/status")
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_status_returns_not_completed_for_new_user(self, ob_client_authed, ob_session):
|
||||
"""A user with no profile should get completed=False and step=1."""
|
||||
resp = ob_client_authed.get("/api/onboarding/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["completed"] is False
|
||||
assert data["step"] == 1
|
||||
assert data["profile"] is None
|
||||
|
||||
def test_status_returns_not_completed_for_existing_incomplete_profile(self, ob_client_authed, ob_session):
|
||||
"""A user with an existing profile but onboarding_completed=False → completed=False."""
|
||||
_make_profile(ob_session, _TEST_USER["sub"], display_name="Alice", onboarding_completed=False)
|
||||
resp = ob_client_authed.get("/api/onboarding/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["completed"] is False
|
||||
assert data["profile"]["display_name"] == "Alice"
|
||||
|
||||
def test_status_returns_completed_when_done(self, ob_client_authed, ob_session):
|
||||
"""A user with onboarding_completed=True → completed=True and step=5."""
|
||||
_make_profile(ob_session, _TEST_USER["sub"], onboarding_completed=True)
|
||||
resp = ob_client_authed.get("/api/onboarding/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["completed"] is True
|
||||
assert data["step"] == 5
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/onboarding/profile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_save_profile_updates_display_name(self, ob_client_authed, ob_session):
|
||||
"""POST /profile should persist display_name to UserProfile."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/profile",
|
||||
json={"display_name": "Jane Doe", "contact_email": "jane@example.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["display_name"] == "Jane Doe"
|
||||
assert data["contact_email"] == "jane@example.com"
|
||||
|
||||
# Verify DB was actually updated
|
||||
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
|
||||
assert profile is not None
|
||||
assert profile.display_name == "Jane Doe"
|
||||
assert profile.contact_email == "jane@example.com"
|
||||
|
||||
def test_save_profile_requires_auth(self, ob_engine):
|
||||
"""POST /profile without auth should return 401."""
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=ob_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 client:
|
||||
resp = client.post("/api/onboarding/profile", json={"display_name": "x"})
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_save_profile_null_fields_allowed(self, ob_client_authed, ob_session):
|
||||
"""Sending null for display_name and contact_email should succeed."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/profile",
|
||||
json={"display_name": None, "contact_email": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/onboarding/plan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_save_plan_updates_subscription_tier(self, ob_client_authed, ob_session):
|
||||
"""POST /plan should persist the chosen tier and billing cycle."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/plan",
|
||||
json={"subscription_tier": "starter", "billing_cycle": "monthly"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["subscription_tier"] == "starter"
|
||||
assert data["subscription_billing_cycle"] == "monthly"
|
||||
|
||||
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
|
||||
assert profile.subscription_tier == "starter"
|
||||
|
||||
def test_save_plan_yearly_billing(self, ob_client_authed, ob_session):
|
||||
"""POST /plan with billing_cycle=yearly should persist correctly."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/plan",
|
||||
json={"subscription_tier": "professional", "billing_cycle": "yearly"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["subscription_billing_cycle"] == "yearly"
|
||||
|
||||
def test_save_plan_rejects_invalid_tier(self, ob_client_authed):
|
||||
"""POST /plan with an unknown tier should return 422."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/plan",
|
||||
json={"subscription_tier": "unicorn", "billing_cycle": "monthly"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "Invalid subscription_tier" in resp.json()["detail"]
|
||||
|
||||
def test_save_plan_rejects_invalid_billing_cycle(self, ob_client_authed):
|
||||
"""POST /plan with an invalid billing_cycle value should return 422."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/plan",
|
||||
json={"subscription_tier": "free", "billing_cycle": "weekly"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/onboarding/storage
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_save_storage_updates_preferred_destination(self, ob_client_authed, ob_session):
|
||||
"""POST /storage should persist the preferred_destination."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/storage",
|
||||
json={"preferred_destination": "dropbox"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["preferred_destination"] == "dropbox"
|
||||
|
||||
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
|
||||
assert profile.preferred_destination == "dropbox"
|
||||
|
||||
def test_save_storage_accepts_null_destination(self, ob_client_authed, ob_session):
|
||||
"""POST /storage with null preferred_destination should succeed (skip storage)."""
|
||||
resp = ob_client_authed.post(
|
||||
"/api/onboarding/storage",
|
||||
json={"preferred_destination": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["preferred_destination"] is None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/onboarding/complete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_complete_sets_onboarding_completed(self, ob_client_authed, ob_session):
|
||||
"""POST /complete should set onboarding_completed=True on the profile."""
|
||||
_make_profile(ob_session, _TEST_USER["sub"])
|
||||
resp = ob_client_authed.post("/api/onboarding/complete")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"success": True}
|
||||
|
||||
# Re-query to see persisted value
|
||||
ob_session.expire_all()
|
||||
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
|
||||
assert profile.onboarding_completed is True
|
||||
assert profile.onboarding_completed_at is not None
|
||||
|
||||
def test_complete_creates_profile_if_missing(self, ob_client_authed, ob_session):
|
||||
"""POST /complete should create a profile when none exists and mark it done."""
|
||||
resp = ob_client_authed.post("/api/onboarding/complete")
|
||||
assert resp.status_code == 200
|
||||
|
||||
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
|
||||
assert profile is not None
|
||||
assert profile.onboarding_completed is True
|
||||
|
||||
def test_complete_requires_auth(self, ob_engine):
|
||||
"""POST /complete without auth should return 401."""
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=ob_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 client:
|
||||
resp = client.post("/api/onboarding/complete")
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 401
|
||||
Reference in New Issue
Block a user