diff --git a/app/api/__init__.py b/app/api/__init__.py index ac916de2..36f9345a 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/onboarding.py b/app/api/onboarding.py new file mode 100644 index 00000000..ffdf27ea --- /dev/null +++ b/app/api/onboarding.py @@ -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} diff --git a/app/auth.py b/app/auth.py index ae741038..707f4279 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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) diff --git a/app/models.py b/app/models.py index b5857bb5..1c38ea84 100644 --- a/app/models.py +++ b/app/models.py @@ -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()) diff --git a/app/views/__init__.py b/app/views/__init__.py index 75eafbfb..c6e5ae77 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 diff --git a/app/views/onboarding.py b/app/views/onboarding.py new file mode 100644 index 00000000..8f6a85fa --- /dev/null +++ b/app/views/onboarding.py @@ -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, + }, + ) diff --git a/frontend/templates/onboarding.html b/frontend/templates/onboarding.html new file mode 100644 index 00000000..d3368538 --- /dev/null +++ b/frontend/templates/onboarding.html @@ -0,0 +1,470 @@ +{% extends "base.html" %} +{% block title %}Welcome to DocuElevate{% endblock %} + +{% block content %} +
+
+ + +
+
+ + DocuElevate +
+

Let's get you set up

+
+ + +
+ + + + + + +
+ + +
+ + +
+
+
+ +
+

+ Welcome, {{ user.given_name | default(user.name) | default("there") }}! 👋 +

+

You're just a few steps away from transforming how you handle documents.

+
+
+
+
+ +

Smart OCR

+

AI extracts text from any document

+
+
+ +

Auto-tagging

+

Documents organised automatically

+
+
+ +

Cloud Sync

+

Instantly backed up to your storage

+
+
+

+ This quick setup takes about 2 minutes. You can change everything later in your settings. +

+ +
+
+ + +
+
+ +

Your Profile

+

Tell us a little about yourself

+
+
+
+ + +

This is how you'll appear in DocuElevate.

+
+
+ + +

Used for notifications. Can be different from your login email.

+
+
+ + +
+
+
+ + +
+
+ +

Choose Your Plan

+

Start free, upgrade when you're ready

+
+
+ + +
+ Monthly + + + Annual 2 months free + +
+ + +
+ {% for tier in tiers %} + + {% endfor %} +
+ + +
+ + Payment processing is coming soon. You can select a plan now and billing will activate at launch. +
+ +
+ + +
+
+
+ + +
+
+ +

Storage Destination

+

Where should your processed documents go?

+
+
+ {% if configured_destinations %} +

Select where you'd like your documents stored. You can change this later in settings.

+
+ {% for dest in configured_destinations %} + + {% endfor %} +
+

+ Not seeing your provider? + {% if user.is_admin %} + Go to Settings to configure more destinations. + {% else %} + Ask your administrator to configure additional storage providers. + {% endif %} +

+ {% else %} +
+
+ +
+

No Storage Configured Yet

+

+ No storage destinations have been configured for this instance yet. + You can skip this step and set one up later. +

+ {% if user.is_admin %} + + Configure Storage + + {% else %} +

Contact your administrator to set up a storage destination.

+ {% endif %} +
+ {% endif %} + +
+ + +
+
+
+ + +
+
+
+ +
+

You're all set! 🎉

+

Your account is configured and ready to go.

+
+
+
    +
  • +
    + +
    + Profile saved +
  • +
  • +
    + +
    + Plan selected: +
  • +
  • +
    + +
    + Storage: + No storage destination selected (can be set later) +
  • +
+ +
+
+ +
+ + + + +
+
+ + +{% endblock %} diff --git a/migrations/versions/017_add_onboarding_fields.py b/migrations/versions/017_add_onboarding_fields.py new file mode 100644 index 00000000..d1d4b2a9 --- /dev/null +++ b/migrations/versions/017_add_onboarding_fields.py @@ -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") diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 00000000..6cc27571 --- /dev/null +++ b/tests/test_onboarding.py @@ -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