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:
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
@@ -0,0 +1,364 @@
|
||||
# app/modules/billing/tests/unit/test_signup_service.py
|
||||
"""Unit tests for SignupService (simplified 3-step signup)."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.exceptions import (
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.billing.models import TierCode
|
||||
from app.modules.billing.services.signup_service import SignupService, _signup_sessions
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_sessions():
|
||||
"""Clear in-memory signup sessions before each test."""
|
||||
_signup_sessions.clear()
|
||||
yield
|
||||
_signup_sessions.clear()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestSignupServiceSession:
|
||||
"""Tests for SignupService session management."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = SignupService()
|
||||
|
||||
def test_create_session_stores_language(self):
|
||||
"""create_session stores the user's browsing language."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="loyalty",
|
||||
language="de",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session is not None
|
||||
assert session["language"] == "de"
|
||||
|
||||
def test_create_session_default_language_fr(self):
|
||||
"""create_session defaults to French when no language provided."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="oms",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session["language"] == "fr"
|
||||
|
||||
def test_create_session_stores_platform_code(self):
|
||||
"""create_session stores the platform code."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.PROFESSIONAL.value,
|
||||
is_annual=True,
|
||||
platform_code="loyalty",
|
||||
language="en",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session["platform_code"] == "loyalty"
|
||||
assert session["is_annual"] is True
|
||||
assert session["tier_code"] == TierCode.PROFESSIONAL.value
|
||||
|
||||
def test_create_session_raises_without_platform_code(self):
|
||||
"""create_session raises ValidationException when platform_code is empty."""
|
||||
with pytest.raises(ValidationException):
|
||||
self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="",
|
||||
)
|
||||
|
||||
def test_get_session_or_raise_missing(self):
|
||||
"""get_session_or_raise raises ResourceNotFoundException for invalid session."""
|
||||
with pytest.raises(ResourceNotFoundException):
|
||||
self.service.get_session_or_raise("nonexistent_session_id")
|
||||
|
||||
def test_delete_session(self):
|
||||
"""delete_session removes the session from storage."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="oms",
|
||||
)
|
||||
assert self.service.get_session(session_id) is not None
|
||||
|
||||
self.service.delete_session(session_id)
|
||||
assert self.service.get_session(session_id) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestSignupServiceAccountCreation:
|
||||
"""Tests for SignupService.create_account (merged store creation)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = SignupService()
|
||||
|
||||
def test_create_account_creates_store(self, db):
|
||||
"""create_account creates User + Merchant + Store atomically."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
# Create platform
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
# Create a tier for the platform
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
# Create session
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
language="de",
|
||||
)
|
||||
|
||||
# Mock Stripe
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_test123"
|
||||
|
||||
result = self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||
password="securepass123", # noqa: SEC-001 # noqa: SEC-001
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
merchant_name="John's Shop",
|
||||
)
|
||||
|
||||
# Verify result includes store info
|
||||
assert result.user_id is not None
|
||||
assert result.merchant_id is not None
|
||||
assert result.store_id is not None
|
||||
assert result.store_code is not None
|
||||
assert result.stripe_customer_id == "cus_test123"
|
||||
|
||||
# Verify store was created with correct language
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||
assert store is not None
|
||||
assert store.name == "John's Shop"
|
||||
assert store.default_language == "de"
|
||||
|
||||
def test_create_account_uses_session_language(self, db):
|
||||
"""create_account sets store default_language from the signup session."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
language="en",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_test456"
|
||||
|
||||
result = self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="Jane",
|
||||
last_name="Smith",
|
||||
merchant_name="Jane's Bakery",
|
||||
)
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||
assert store.default_language == "en"
|
||||
|
||||
def test_create_account_rejects_duplicate_email(self, db):
|
||||
"""create_account raises ConflictException for existing email."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
email = f"dup_{uuid.uuid4().hex[:8]}@example.com"
|
||||
|
||||
# Create first account
|
||||
session_id1 = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_first"
|
||||
self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id1,
|
||||
email=email,
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="First",
|
||||
last_name="User",
|
||||
merchant_name="First Shop",
|
||||
)
|
||||
|
||||
# Try to create second account with same email
|
||||
session_id2 = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
with pytest.raises(ConflictException):
|
||||
self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id2,
|
||||
email=email,
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
merchant_name="Second Shop",
|
||||
)
|
||||
|
||||
def test_create_account_updates_session(self, db):
|
||||
"""create_account updates session with user/merchant/store IDs."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_test789"
|
||||
|
||||
result = self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
merchant_name="Test Shop",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session["user_id"] == result.user_id
|
||||
assert session["merchant_id"] == result.merchant_id
|
||||
assert session["store_id"] == result.store_id
|
||||
assert session["store_code"] == result.store_code
|
||||
assert session["stripe_customer_id"] == "cus_test789"
|
||||
assert session["step"] == "account_created"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestSignupServicePostRedirect:
|
||||
"""Tests for SignupService._get_post_signup_redirect."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = SignupService()
|
||||
|
||||
def test_always_redirects_to_dashboard(self, db):
|
||||
"""Post-signup redirect always goes to store dashboard."""
|
||||
session = {"platform_code": "loyalty"}
|
||||
url = self.service._get_post_signup_redirect(db, session, "MY_STORE")
|
||||
assert url == "/store/MY_STORE/dashboard"
|
||||
|
||||
def test_redirect_for_oms_platform(self, db):
|
||||
"""OMS platform also redirects to dashboard (not onboarding wizard)."""
|
||||
session = {"platform_code": "oms"}
|
||||
url = self.service._get_post_signup_redirect(db, session, "OMS_STORE")
|
||||
assert url == "/store/OMS_STORE/dashboard"
|
||||
Reference in New Issue
Block a user