feat: module-driven onboarding system + simplified 3-step signup

Add OnboardingProviderProtocol so modules declare their own post-signup
onboarding steps. The core OnboardingAggregator discovers enabled
providers and exposes a dashboard API (GET /dashboard/onboarding).
A session-scoped banner on the store dashboard shows a checklist that
guides merchants through setup without blocking signup.

Signup is simplified from 4 steps to 3 (Plan → Account → Payment):
store creation is merged into account creation, store language is
captured from the user's browsing language, and platform-specific
template branching is removed.

Includes 47 unit and integration tests covering all new providers,
the aggregator, the API endpoint, and the signup service changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 23:39:42 +01:00
parent f8a2394da5
commit ef9ea29643
26 changed files with 2055 additions and 699 deletions

View File

@@ -0,0 +1,215 @@
# app/modules/core/tests/integration/test_onboarding_routes.py
"""
Integration tests for the onboarding API endpoint.
Tests the store dashboard onboarding endpoint at:
GET /api/v1/store/dashboard/onboarding
Verifies:
- Endpoint returns onboarding steps with completion status
- Endpoint requires store authentication
- Response structure matches expected schema
"""
import uuid
import pytest
from app.api.deps import get_current_store_api
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def onb_owner(db):
"""Create a store owner user for onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"onbroute_{uuid.uuid4().hex[:8]}@test.com",
username=f"onbroute_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def onb_platform(db):
"""Create a platform for onboarding tests."""
platform = Platform(
code=f"onbp_{uuid.uuid4().hex[:8]}",
name="Onboarding Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def onb_merchant(db, onb_owner):
"""Create a merchant for onboarding tests."""
merchant = Merchant(
name="Onboarding Route Test Merchant",
owner_user_id=onb_owner.id,
contact_email=onb_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def onb_store(db, onb_merchant):
"""Create a store for onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=onb_merchant.id,
store_code=f"ONBROUTE_{uid.upper()}",
subdomain=f"onbroute{uid.lower()}",
name="Onboarding Route Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def onb_store_platform(db, onb_store, onb_platform):
"""Link store to platform."""
sp = StorePlatform(
store_id=onb_store.id,
platform_id=onb_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def onb_auth(onb_owner, onb_store):
"""Override auth dependency for store API auth."""
user_context = UserContext(
id=onb_owner.id,
email=onb_owner.email,
username=onb_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=onb_store.id,
token_store_code=onb_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_api] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_api, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestOnboardingEndpoint:
"""Tests for GET /api/v1/store/dashboard/onboarding."""
def test_returns_onboarding_summary(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Endpoint returns onboarding summary with expected structure."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
assert "steps" in data
assert "total_steps" in data
assert "completed_steps" in data
assert "progress_percentage" in data
assert "all_completed" in data
assert isinstance(data["steps"], list)
assert isinstance(data["total_steps"], int)
assert isinstance(data["completed_steps"], int)
def test_includes_tenancy_step(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Response includes the tenancy customize_store step (always present)."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
step_keys = [s["key"] for s in data["steps"]]
assert "tenancy.customize_store" in step_keys
def test_step_data_has_required_fields(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Each step has key, title_key, description_key, icon, route, completed."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "key" in step
assert "title_key" in step
assert "description_key" in step
assert "icon" in step
assert "route" in step
assert "completed" in step
assert isinstance(step["completed"], bool)
def test_routes_contain_store_code(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Step routes have {store_code} replaced with the actual store code."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "{store_code}" not in step["route"]
if "/store/" in step["route"]:
assert onb_store.store_code in step["route"]
def test_requires_auth(self, client):
"""Returns 401 without authentication."""
app.dependency_overrides.pop(get_current_store_api, None)
response = client.get(
"/api/v1/store/dashboard/onboarding",
)
assert response.status_code == 401