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,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"