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

View File

@@ -0,0 +1,275 @@
# app/modules/core/tests/unit/test_onboarding_aggregator.py
"""Unit tests for OnboardingAggregatorService."""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.contracts.onboarding import (
OnboardingStepDefinition,
OnboardingStepStatus,
)
from app.modules.core.services.onboarding_aggregator import OnboardingAggregatorService
def _make_step(key: str, order: int = 100, category: str = "test") -> OnboardingStepDefinition:
return OnboardingStepDefinition(
key=key,
title_key=f"{key}.title",
description_key=f"{key}.desc",
icon="circle",
route_template="/store/{store_code}/test",
order=order,
category=category,
)
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSteps:
"""Tests for OnboardingAggregatorService.get_onboarding_steps."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_aggregates_steps_from_multiple_providers(self, db):
"""Collects steps from all enabled module providers."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 2
assert steps[0].step.key == "tenancy.customize"
assert steps[0].completed is True
assert steps[1].step.key == "marketplace.connect"
assert steps[1].completed is False
def test_steps_sorted_by_order(self, db):
"""Steps are returned sorted by order regardless of provider order."""
step_high = _make_step("loyalty.program", order=300)
step_low = _make_step("tenancy.customize", order=100)
provider_high = MagicMock()
provider_high.onboarding_category = "loyalty"
provider_high.get_onboarding_steps.return_value = [step_high]
provider_high.is_step_completed.return_value = False
provider_low = MagicMock()
provider_low.onboarding_category = "tenancy"
provider_low.get_onboarding_steps.return_value = [step_low]
provider_low.is_step_completed.return_value = False
module_high = MagicMock()
module_high.code = "loyalty"
module_low = MagicMock()
module_low.code = "tenancy"
# Provide high-order first
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_high, provider_high), (module_low, provider_low)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps[0].step.key == "tenancy.customize"
assert steps[1].step.key == "loyalty.program"
def test_empty_when_no_providers(self, db):
"""Returns empty list when no modules have onboarding providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_provider_error_gracefully(self, db):
"""Skips a provider that raises an exception."""
provider = MagicMock()
provider.onboarding_category = "broken"
provider.get_onboarding_steps.side_effect = RuntimeError("DB error")
module = MagicMock()
module.code = "broken"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_step_completion_check_error(self, db):
"""Marks step as incomplete when completion check raises."""
step = _make_step("test.step")
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.side_effect = RuntimeError("query error")
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 1
assert steps[0].completed is False
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSummary:
"""Tests for OnboardingAggregatorService.get_onboarding_summary."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_summary_with_mixed_completion(self, db):
"""Returns correct progress for partially completed onboarding."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
step_c = _make_step("marketplace.import", order=210, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b, step_c]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MYSTORE"
)
assert summary["total_steps"] == 3
assert summary["completed_steps"] == 1
assert summary["progress_percentage"] == 33
assert summary["all_completed"] is False
assert len(summary["steps"]) == 3
def test_summary_all_completed(self, db):
"""Returns all_completed=True when every step is done."""
step = _make_step("tenancy.customize")
provider = MagicMock()
provider.onboarding_category = "tenancy"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "tenancy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["all_completed"] is True
assert summary["progress_percentage"] == 100
def test_summary_empty_providers(self, db):
"""Returns zeros and all_completed=False when no providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["total_steps"] == 0
assert summary["completed_steps"] == 0
assert summary["progress_percentage"] == 0
assert summary["all_completed"] is False
def test_summary_replaces_store_code_in_routes(self, db):
"""Route templates have {store_code} replaced with actual store code."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="circle",
route_template="/store/{store_code}/settings",
order=100,
category="test",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = False
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MY_STORE"
)
assert summary["steps"][0]["route"] == "/store/MY_STORE/settings"
def test_summary_step_data_includes_all_fields(self, db):
"""Each step in summary includes key, title_key, description_key, icon, route, completed, category."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="settings",
route_template="/store/{store_code}/test",
order=100,
category="test_cat",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="S1"
)
step_data = summary["steps"][0]
assert step_data["key"] == "test.step"
assert step_data["title_key"] == "test.title"
assert step_data["description_key"] == "test.desc"
assert step_data["icon"] == "settings"
assert step_data["route"] == "/store/S1/test"
assert step_data["completed"] is True
assert step_data["category"] == "test_cat"