Files
orion/app/modules/billing/tests/unit/test_signup_service.py
Samir Boulahtit ef9ea29643 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>
2026-02-28 23:39:42 +01:00

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"