Files
copilot-swe-agent[bot] e0de0fd6fb feat: add multi-step user onboarding wizard
- 5-step wizard: Welcome → Profile → Plan → Storage → All Set!
- New migration 017: onboarding_completed, contact_email, preferred_destination fields
- REST API at /api/onboarding/{status,profile,plan,storage,complete}
- GET /onboarding view with configured-destinations helper
- OAuth callback redirects first-time users to onboarding
- 16 unit tests for all endpoints; 65 total tests pass

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-07 11:12:21 +00:00

318 lines
12 KiB
Python

"""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
data = resp.json()
assert data["success"] is True
assert "redirect_url" in data
# 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