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:
@@ -24,6 +24,7 @@ from app.modules.core.schemas.dashboard import (
|
||||
StoreProductStats,
|
||||
StoreRevenueStats,
|
||||
)
|
||||
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
|
||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||
from app.modules.tenancy.exceptions import StoreNotActiveException
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
@@ -124,3 +125,27 @@ def get_store_dashboard_stats(
|
||||
this_month=float(revenue_this_month),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@store_dashboard_router.get("/onboarding")
|
||||
def get_onboarding_status(
|
||||
request: Request,
|
||||
current_user: UserContext = Depends(get_current_store_api),
|
||||
platform=Depends(require_platform),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get onboarding checklist status for the current store.
|
||||
|
||||
Returns steps from all enabled modules with completion status,
|
||||
progress percentage, and whether all steps are completed.
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
store = store_service.get_store_by_id(db, store_id)
|
||||
|
||||
return onboarding_aggregator.get_onboarding_summary(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
platform_id=platform.id,
|
||||
store_code=store.store_code,
|
||||
)
|
||||
|
||||
157
app/modules/core/services/onboarding_aggregator.py
Normal file
157
app/modules/core/services/onboarding_aggregator.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# app/modules/core/services/onboarding_aggregator.py
|
||||
"""
|
||||
Onboarding aggregator service for collecting onboarding steps from all modules.
|
||||
|
||||
This service lives in core because the dashboard onboarding banner is core
|
||||
functionality. It discovers OnboardingProviders from all enabled modules and
|
||||
provides a unified interface for the dashboard checklist.
|
||||
|
||||
Usage:
|
||||
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
|
||||
|
||||
summary = onboarding_aggregator.get_onboarding_summary(
|
||||
db=db, store_id=123, platform_id=1, store_code="my-store"
|
||||
)
|
||||
# Returns: {steps, total_steps, completed_steps, progress_percentage, all_completed}
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.contracts.onboarding import (
|
||||
OnboardingProviderProtocol,
|
||||
OnboardingStepStatus,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OnboardingAggregatorService:
|
||||
"""
|
||||
Aggregates onboarding steps from all module providers.
|
||||
|
||||
Discovers OnboardingProviders from enabled modules and provides
|
||||
a unified interface for the dashboard onboarding banner.
|
||||
"""
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, platform_id: int
|
||||
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
|
||||
"""
|
||||
Get onboarding providers from enabled modules.
|
||||
|
||||
Returns:
|
||||
List of (module, provider) tuples for enabled modules with providers
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
if not module.has_onboarding_provider():
|
||||
continue
|
||||
|
||||
# Core modules are always enabled, check others
|
||||
if not module.is_core:
|
||||
try:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
provider = module.get_onboarding_provider_instance()
|
||||
if provider is not None:
|
||||
providers.append((module, provider))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get onboarding provider for module {module.code}: {e}"
|
||||
)
|
||||
|
||||
return providers
|
||||
|
||||
def get_onboarding_steps(
|
||||
self, db: Session, store_id: int, platform_id: int
|
||||
) -> list[OnboardingStepStatus]:
|
||||
"""
|
||||
Get all onboarding steps with completion status, sorted by order.
|
||||
|
||||
Returns:
|
||||
Sorted list of OnboardingStepStatus objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
steps: list[OnboardingStepStatus] = []
|
||||
|
||||
for module, provider in providers:
|
||||
try:
|
||||
step_defs = provider.get_onboarding_steps()
|
||||
for step_def in step_defs:
|
||||
try:
|
||||
completed = provider.is_step_completed(db, store_id, step_def.key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check step {step_def.key} from {module.code}: {e}"
|
||||
)
|
||||
completed = False
|
||||
steps.append(OnboardingStepStatus(step=step_def, completed=completed))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get onboarding steps from module {module.code}: {e}"
|
||||
)
|
||||
|
||||
steps.sort(key=lambda s: s.step.order)
|
||||
return steps
|
||||
|
||||
def get_onboarding_summary(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
platform_id: int,
|
||||
store_code: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get onboarding summary for the dashboard API.
|
||||
|
||||
Returns:
|
||||
Dict with: steps[], total_steps, completed_steps, progress_percentage, all_completed
|
||||
"""
|
||||
step_statuses = self.get_onboarding_steps(db, store_id, platform_id)
|
||||
|
||||
total = len(step_statuses)
|
||||
completed = sum(1 for s in step_statuses if s.completed)
|
||||
|
||||
steps_data = []
|
||||
for status in step_statuses:
|
||||
route = status.step.route_template.replace("{store_code}", store_code)
|
||||
steps_data.append({
|
||||
"key": status.step.key,
|
||||
"title_key": status.step.title_key,
|
||||
"description_key": status.step.description_key,
|
||||
"icon": status.step.icon,
|
||||
"route": route,
|
||||
"completed": status.completed,
|
||||
"category": status.step.category,
|
||||
})
|
||||
|
||||
return {
|
||||
"steps": steps_data,
|
||||
"total_steps": total,
|
||||
"completed_steps": completed,
|
||||
"progress_percentage": round((completed / total * 100) if total > 0 else 0),
|
||||
"all_completed": completed == total and total > 0,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
onboarding_aggregator = OnboardingAggregatorService()
|
||||
|
||||
__all__ = ["OnboardingAggregatorService", "onboarding_aggregator"]
|
||||
@@ -10,6 +10,47 @@ const storeDashLog = window.LogConfig.loggers.dashboard ||
|
||||
storeDashLog.info('Loading...');
|
||||
storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
|
||||
|
||||
/**
|
||||
* Onboarding banner component.
|
||||
* Fetches onboarding steps from API, supports session-scoped dismiss.
|
||||
*/
|
||||
function onboardingBanner() {
|
||||
return {
|
||||
visible: false,
|
||||
steps: [],
|
||||
totalSteps: 0,
|
||||
completedSteps: 0,
|
||||
progressPercentage: 0,
|
||||
|
||||
async init() {
|
||||
// Check session-scoped dismiss
|
||||
if (sessionStorage.getItem('onboarding_dismissed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/store/dashboard/onboarding');
|
||||
this.steps = response.steps || [];
|
||||
this.totalSteps = response.total_steps || 0;
|
||||
this.completedSteps = response.completed_steps || 0;
|
||||
this.progressPercentage = response.progress_percentage || 0;
|
||||
|
||||
// Show banner only if there are incomplete steps
|
||||
if (this.totalSteps > 0 && !response.all_completed) {
|
||||
this.visible = true;
|
||||
}
|
||||
} catch (error) {
|
||||
storeDashLog.error('Failed to load onboarding status', error);
|
||||
}
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
sessionStorage.setItem('onboarding_dismissed', 'true');
|
||||
this.visible = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function storeDashboard() {
|
||||
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
|
||||
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
|
||||
{% block alpine_data %}storeDashboard(){% endblock %}
|
||||
|
||||
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
|
||||
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Email Settings Warning -->
|
||||
{{ email_settings_warning() }}
|
||||
|
||||
<!-- Onboarding Banner -->
|
||||
{{ onboarding_banner() }}
|
||||
|
||||
<!-- Limit Warnings -->
|
||||
{{ limit_warning("orders") }}
|
||||
{{ limit_warning("products") }}
|
||||
|
||||
215
app/modules/core/tests/integration/test_onboarding_routes.py
Normal file
215
app/modules/core/tests/integration/test_onboarding_routes.py
Normal 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
|
||||
275
app/modules/core/tests/unit/test_onboarding_aggregator.py
Normal file
275
app/modules/core/tests/unit/test_onboarding_aggregator.py
Normal 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"
|
||||
Reference in New Issue
Block a user