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>
365 lines
12 KiB
Python
365 lines
12 KiB
Python
# 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"
|