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:
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