diff --git a/app/api/v1/platform/signup.py b/app/api/v1/platform/signup.py index b00eba6e..7f4643f5 100644 --- a/app/api/v1/platform/signup.py +++ b/app/api/v1/platform/signup.py @@ -267,30 +267,43 @@ async def create_account( ) try: - # Create Company + # Create User first (needed for Company owner) + from middleware.auth import AuthManager + + auth_manager = AuthManager() + + # Generate username from email + username = request.email.split("@")[0] + base_username = username + counter = 1 + while db.query(User).filter(User.username == username).first(): + username = f"{base_username}_{counter}" + counter += 1 + + user = User( + email=request.email, + username=username, + hashed_password=auth_manager.hash_password(request.password), + first_name=request.first_name, + last_name=request.last_name, + role="vendor", + is_active=True, + ) + db.add(user) + db.flush() + + # Create Company (with owner) from models.database.company import Company company = Company( name=request.company_name, + owner_user_id=user.id, contact_email=request.email, contact_phone=request.phone, ) db.add(company) db.flush() - # Create User - from app.core.security import get_password_hash - - user = User( - email=request.email, - hashed_password=get_password_hash(request.password), - first_name=request.first_name, - last_name=request.last_name, - is_active=True, - ) - db.add(user) - db.flush() - # Generate vendor code vendor_code = request.company_name.upper().replace(" ", "_")[:20] # Ensure unique diff --git a/docs/implementation/platform-marketing-homepage.md b/docs/implementation/platform-marketing-homepage.md index d14fc597..b622699f 100644 --- a/docs/implementation/platform-marketing-homepage.md +++ b/docs/implementation/platform-marketing-homepage.md @@ -428,6 +428,32 @@ STRIPE_TRIAL_DAYS=30 ## Testing +### Automated Tests + +Test files located in `tests/integration/api/v1/platform/`: + +| File | Tests | Description | +|------|-------|-------------| +| `test_pricing.py` | 17 | Tier and add-on pricing endpoints | +| `test_letzshop_vendors.py` | 22 | Vendor lookup and listing endpoints | +| `test_signup.py` | 28 | Multi-step signup flow | + +**Run tests:** +```bash +pytest tests/integration/api/v1/platform/ -v +``` + +**Test categories:** +- `TestPlatformPricingAPI` - GET /tiers, /addons, /pricing +- `TestLetzshopVendorLookupAPI` - Vendor lookup and claiming +- `TestLetzshopSlugExtraction` - URL parsing edge cases +- `TestSignupStartAPI` - Signup initiation +- `TestClaimVendorAPI` - Letzshop vendor claiming +- `TestCreateAccountAPI` - Account creation +- `TestSetupPaymentAPI` - Stripe SetupIntent +- `TestCompleteSignupAPI` - Signup completion +- `TestSignupFullFlow` - End-to-end flow + ### Manual Testing 1. **Homepage:** Visit `http://localhost:8000/` diff --git a/tests/integration/api/v1/platform/__init__.py b/tests/integration/api/v1/platform/__init__.py new file mode 100644 index 00000000..f19fba2a --- /dev/null +++ b/tests/integration/api/v1/platform/__init__.py @@ -0,0 +1,2 @@ +# tests/integration/api/v1/platform/__init__.py +"""Platform API integration tests.""" diff --git a/tests/integration/api/v1/platform/test_letzshop_vendors.py b/tests/integration/api/v1/platform/test_letzshop_vendors.py new file mode 100644 index 00000000..3620d0f2 --- /dev/null +++ b/tests/integration/api/v1/platform/test_letzshop_vendors.py @@ -0,0 +1,324 @@ +# tests/integration/api/v1/platform/test_letzshop_vendors.py +"""Integration tests for platform Letzshop vendor lookup API endpoints. + +Tests the /api/v1/platform/letzshop-vendors/* endpoints. +""" + +import pytest + +from models.database.vendor import Vendor +from models.database.company import Company + + +@pytest.fixture +def test_owner_user(db, auth_manager): + """Create a test owner user for the company.""" + from models.database.user import User + + user = User( + email="owner@test.com", + username="test_owner", + hashed_password=auth_manager.hash_password("testpass123"), + role="vendor", + is_active=True, + ) + db.add(user) + db.commit() + return user + + +@pytest.fixture +def test_company(db, test_owner_user): + """Create a test company.""" + company = Company( + name="Test Company", + owner_user_id=test_owner_user.id, + contact_email="test@company.com", + ) + db.add(company) + db.commit() + return company + + +@pytest.fixture +def claimed_vendor(db, test_company): + """Create a vendor that has claimed a Letzshop shop.""" + vendor = Vendor( + company_id=test_company.id, + vendor_code="CLAIMED_VENDOR", + subdomain="claimed-shop", + name="Claimed Shop", + contact_email="claimed@shop.lu", + is_active=True, + letzshop_vendor_slug="claimed-shop", + letzshop_vendor_id="letz_123", + ) + db.add(vendor) + db.commit() + return vendor + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestLetzshopVendorLookupAPI: + """Test Letzshop vendor lookup endpoints at /api/v1/platform/letzshop-vendors/*.""" + + # ========================================================================= + # GET /api/v1/platform/letzshop-vendors + # ========================================================================= + + def test_list_vendors_returns_empty_list(self, client): + """Test listing vendors returns empty list (placeholder).""" + response = client.get("/api/v1/platform/letzshop-vendors") + + assert response.status_code == 200 + data = response.json() + assert "vendors" in data + assert "total" in data + assert "page" in data + assert "limit" in data + assert "has_more" in data + assert isinstance(data["vendors"], list) + + def test_list_vendors_with_pagination(self, client): + """Test listing vendors with pagination parameters.""" + response = client.get("/api/v1/platform/letzshop-vendors?page=2&limit=10") + + assert response.status_code == 200 + data = response.json() + assert data["page"] == 2 + assert data["limit"] == 10 + + def test_list_vendors_with_search(self, client): + """Test listing vendors with search parameter.""" + response = client.get("/api/v1/platform/letzshop-vendors?search=my-shop") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data["vendors"], list) + + def test_list_vendors_with_filters(self, client): + """Test listing vendors with category and city filters.""" + response = client.get( + "/api/v1/platform/letzshop-vendors?category=fashion&city=luxembourg" + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data["vendors"], list) + + def test_list_vendors_limit_validation(self, client): + """Test that limit parameter is validated.""" + # Maximum limit is 50 + response = client.get("/api/v1/platform/letzshop-vendors?limit=100") + assert response.status_code == 422 + + # ========================================================================= + # POST /api/v1/platform/letzshop-vendors/lookup + # ========================================================================= + + def test_lookup_vendor_by_full_url(self, client): + """Test looking up vendor by full Letzshop URL.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/vendors/my-test-shop"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is True + assert data["vendor"]["slug"] == "my-test-shop" + assert "letzshop.lu" in data["vendor"]["letzshop_url"] + + def test_lookup_vendor_by_url_with_language(self, client): + """Test looking up vendor by URL with language prefix.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/en/vendors/my-shop"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is True + assert data["vendor"]["slug"] == "my-shop" + + def test_lookup_vendor_by_url_without_protocol(self, client): + """Test looking up vendor by URL without https://.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "letzshop.lu/vendors/test-shop"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is True + assert data["vendor"]["slug"] == "test-shop" + + def test_lookup_vendor_by_slug_only(self, client): + """Test looking up vendor by slug alone.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "my-shop-name"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is True + assert data["vendor"]["slug"] == "my-shop-name" + + def test_lookup_vendor_normalizes_slug(self, client): + """Test that slug is normalized to lowercase.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/vendors/MY-SHOP-NAME"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["vendor"]["slug"] == "my-shop-name" + + def test_lookup_vendor_shows_claimed_status(self, client, claimed_vendor): + """Test that lookup shows if vendor is already claimed.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "claimed-shop"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is True + assert data["vendor"]["is_claimed"] is True + + def test_lookup_vendor_shows_unclaimed_status(self, client): + """Test that lookup shows if vendor is not claimed.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "unclaimed-new-shop"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is True + assert data["vendor"]["is_claimed"] is False + + def test_lookup_vendor_empty_url(self, client): + """Test lookup with empty URL.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["found"] is False + assert data["error"] is not None + + def test_lookup_vendor_response_has_expected_fields(self, client): + """Test that vendor lookup response has all expected fields.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "test-vendor"}, + ) + + assert response.status_code == 200 + data = response.json() + vendor = data["vendor"] + assert "slug" in vendor + assert "name" in vendor + assert "letzshop_url" in vendor + assert "is_claimed" in vendor + + # ========================================================================= + # GET /api/v1/platform/letzshop-vendors/{slug} + # ========================================================================= + + def test_get_vendor_by_slug(self, client): + """Test getting vendor by slug.""" + response = client.get("/api/v1/platform/letzshop-vendors/my-shop") + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == "my-shop" + assert "name" in data + assert "letzshop_url" in data + assert "is_claimed" in data + + def test_get_vendor_normalizes_slug(self, client): + """Test that get vendor normalizes slug to lowercase.""" + response = client.get("/api/v1/platform/letzshop-vendors/MY-SHOP") + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == "my-shop" + + def test_get_claimed_vendor_shows_status(self, client, claimed_vendor): + """Test that get vendor shows claimed status correctly.""" + response = client.get("/api/v1/platform/letzshop-vendors/claimed-shop") + + assert response.status_code == 200 + data = response.json() + assert data["is_claimed"] is True + + def test_get_unclaimed_vendor_shows_status(self, client): + """Test that get vendor shows unclaimed status correctly.""" + response = client.get("/api/v1/platform/letzshop-vendors/new-unclaimed-shop") + + assert response.status_code == 200 + data = response.json() + assert data["is_claimed"] is False + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestLetzshopSlugExtraction: + """Test slug extraction from various URL formats.""" + + def test_extract_from_full_https_url(self, client): + """Test extraction from https://letzshop.lu/vendors/slug.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/vendors/cafe-luxembourg"}, + ) + assert response.json()["vendor"]["slug"] == "cafe-luxembourg" + + def test_extract_from_http_url(self, client): + """Test extraction from http://letzshop.lu/vendors/slug.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "http://letzshop.lu/vendors/my-shop"}, + ) + assert response.json()["vendor"]["slug"] == "my-shop" + + def test_extract_from_url_with_trailing_slash(self, client): + """Test extraction from URL with trailing slash.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/vendors/my-shop/"}, + ) + assert response.json()["vendor"]["slug"] == "my-shop" + + def test_extract_from_url_with_query_params(self, client): + """Test extraction from URL with query parameters.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/vendors/my-shop?ref=google"}, + ) + assert response.json()["vendor"]["slug"] == "my-shop" + + def test_extract_from_french_url(self, client): + """Test extraction from French language URL.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/fr/vendors/boulangerie-paul"}, + ) + assert response.json()["vendor"]["slug"] == "boulangerie-paul" + + def test_extract_from_german_url(self, client): + """Test extraction from German language URL.""" + response = client.post( + "/api/v1/platform/letzshop-vendors/lookup", + json={"url": "https://letzshop.lu/de/vendors/backerei-muller"}, + ) + assert response.json()["vendor"]["slug"] == "backerei-muller" diff --git a/tests/integration/api/v1/platform/test_pricing.py b/tests/integration/api/v1/platform/test_pricing.py new file mode 100644 index 00000000..9c200a78 --- /dev/null +++ b/tests/integration/api/v1/platform/test_pricing.py @@ -0,0 +1,285 @@ +# tests/integration/api/v1/platform/test_pricing.py +"""Integration tests for platform pricing API endpoints. + +Tests the /api/v1/platform/pricing/* endpoints. +""" + +import pytest + +from models.database.subscription import ( + AddOnProduct, + SubscriptionTier, + TierCode, + TIER_LIMITS, +) + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestPlatformPricingAPI: + """Test platform pricing endpoints at /api/v1/platform/*.""" + + # ========================================================================= + # GET /api/v1/platform/tiers + # ========================================================================= + + def test_get_tiers_returns_all_public_tiers(self, client): + """Test getting all subscription tiers.""" + response = client.get("/api/v1/platform/tiers") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 4 # Essential, Professional, Business, Enterprise + + def test_get_tiers_has_expected_fields(self, client): + """Test that tier response has all expected fields.""" + response = client.get("/api/v1/platform/tiers") + + assert response.status_code == 200 + data = response.json() + assert len(data) > 0 + + tier = data[0] + assert "code" in tier + assert "name" in tier + assert "price_monthly" in tier + assert "price_monthly_cents" in tier + assert "orders_per_month" in tier + assert "products_limit" in tier + assert "team_members" in tier + assert "features" in tier + assert "is_popular" in tier + assert "is_enterprise" in tier + + def test_get_tiers_includes_essential(self, client): + """Test that Essential tier is included.""" + response = client.get("/api/v1/platform/tiers") + + assert response.status_code == 200 + data = response.json() + tier_codes = [t["code"] for t in data] + assert TierCode.ESSENTIAL.value in tier_codes + + def test_get_tiers_includes_professional(self, client): + """Test that Professional tier is included and marked as popular.""" + response = client.get("/api/v1/platform/tiers") + + assert response.status_code == 200 + data = response.json() + + professional = next( + (t for t in data if t["code"] == TierCode.PROFESSIONAL.value), None + ) + assert professional is not None + assert professional["is_popular"] is True + + def test_get_tiers_includes_enterprise(self, client): + """Test that Enterprise tier is included and marked appropriately.""" + response = client.get("/api/v1/platform/tiers") + + assert response.status_code == 200 + data = response.json() + + enterprise = next( + (t for t in data if t["code"] == TierCode.ENTERPRISE.value), None + ) + assert enterprise is not None + assert enterprise["is_enterprise"] is True + + def test_get_tiers_from_database(self, client, db): + """Test getting tiers from database when available.""" + # Create a tier in the database + tier = SubscriptionTier( + code="test_tier", + name="Test Tier", + description="A test tier", + price_monthly_cents=9900, + price_annual_cents=99000, + orders_per_month=1000, + products_limit=500, + team_members=5, + features=["feature1", "feature2"], + is_active=True, + is_public=True, + display_order=99, + ) + db.add(tier) + db.commit() + + response = client.get("/api/v1/platform/tiers") + + assert response.status_code == 200 + data = response.json() + tier_codes = [t["code"] for t in data] + assert "test_tier" in tier_codes + + # ========================================================================= + # GET /api/v1/platform/tiers/{tier_code} + # ========================================================================= + + def test_get_tier_by_code_success(self, client): + """Test getting a specific tier by code.""" + response = client.get(f"/api/v1/platform/tiers/{TierCode.PROFESSIONAL.value}") + + assert response.status_code == 200 + data = response.json() + assert data["code"] == TierCode.PROFESSIONAL.value + assert data["name"] == TIER_LIMITS[TierCode.PROFESSIONAL]["name"] + + def test_get_tier_by_code_essential(self, client): + """Test getting Essential tier details.""" + response = client.get(f"/api/v1/platform/tiers/{TierCode.ESSENTIAL.value}") + + assert response.status_code == 200 + data = response.json() + assert data["code"] == TierCode.ESSENTIAL.value + assert data["price_monthly"] == TIER_LIMITS[TierCode.ESSENTIAL]["price_monthly_cents"] / 100 + + def test_get_tier_by_code_not_found(self, client): + """Test getting a non-existent tier returns 404.""" + response = client.get("/api/v1/platform/tiers/nonexistent_tier") + + assert response.status_code == 404 + data = response.json() + assert "not found" in data["message"].lower() + + # ========================================================================= + # GET /api/v1/platform/addons + # ========================================================================= + + def test_get_addons_empty_when_none_configured(self, client): + """Test getting add-ons when none are configured.""" + response = client.get("/api/v1/platform/addons") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_get_addons_returns_configured_addons(self, client, db): + """Test getting add-ons when configured in database.""" + # Create test add-on + addon = AddOnProduct( + code="test_domain", + name="Custom Domain", + description="Use your own domain", + category="domain", + price_cents=1500, + billing_period="annual", + is_active=True, + display_order=1, + ) + db.add(addon) + db.commit() + + response = client.get("/api/v1/platform/addons") + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + + addon_codes = [a["code"] for a in data] + assert "test_domain" in addon_codes + + def test_get_addons_has_expected_fields(self, client, db): + """Test that addon response has all expected fields.""" + addon = AddOnProduct( + code="test_ssl", + name="Premium SSL", + description="EV certificate", + category="security", + price_cents=4900, + billing_period="annual", + is_active=True, + display_order=1, + ) + db.add(addon) + db.commit() + + response = client.get("/api/v1/platform/addons") + + assert response.status_code == 200 + data = response.json() + assert len(data) > 0 + + addon_response = data[0] + assert "code" in addon_response + assert "name" in addon_response + assert "description" in addon_response + assert "category" in addon_response + assert "price" in addon_response + assert "price_cents" in addon_response + assert "billing_period" in addon_response + + def test_get_addons_excludes_inactive(self, client, db): + """Test that inactive add-ons are excluded.""" + # Create active and inactive add-ons + active_addon = AddOnProduct( + code="active_addon", + name="Active Addon", + category="test", + price_cents=1000, + billing_period="monthly", + is_active=True, + display_order=1, + ) + inactive_addon = AddOnProduct( + code="inactive_addon", + name="Inactive Addon", + category="test", + price_cents=1000, + billing_period="monthly", + is_active=False, + display_order=2, + ) + db.add_all([active_addon, inactive_addon]) + db.commit() + + response = client.get("/api/v1/platform/addons") + + assert response.status_code == 200 + data = response.json() + addon_codes = [a["code"] for a in data] + assert "active_addon" in addon_codes + assert "inactive_addon" not in addon_codes + + # ========================================================================= + # GET /api/v1/platform/pricing + # ========================================================================= + + def test_get_pricing_returns_complete_info(self, client): + """Test getting complete pricing information.""" + response = client.get("/api/v1/platform/pricing") + + assert response.status_code == 200 + data = response.json() + + assert "tiers" in data + assert "addons" in data + assert "trial_days" in data + assert "annual_discount_months" in data + + def test_get_pricing_includes_trial_days(self, client): + """Test that pricing includes correct trial period.""" + response = client.get("/api/v1/platform/pricing") + + assert response.status_code == 200 + data = response.json() + assert data["trial_days"] == 30 # Updated from 14 to 30 + + def test_get_pricing_includes_annual_discount(self, client): + """Test that pricing includes annual discount info.""" + response = client.get("/api/v1/platform/pricing") + + assert response.status_code == 200 + data = response.json() + assert data["annual_discount_months"] == 2 # 2 months free + + def test_get_pricing_tiers_not_empty(self, client): + """Test that pricing always includes tiers.""" + response = client.get("/api/v1/platform/pricing") + + assert response.status_code == 200 + data = response.json() + assert len(data["tiers"]) >= 4 diff --git a/tests/integration/api/v1/platform/test_signup.py b/tests/integration/api/v1/platform/test_signup.py new file mode 100644 index 00000000..3421ad6c --- /dev/null +++ b/tests/integration/api/v1/platform/test_signup.py @@ -0,0 +1,672 @@ +# tests/integration/api/v1/platform/test_signup.py +"""Integration tests for platform signup API endpoints. + +Tests the /api/v1/platform/signup/* endpoints. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from models.database.company import Company +from models.database.subscription import TierCode +from models.database.user import User +from models.database.vendor import Vendor + + +@pytest.fixture +def mock_stripe_service(): + """Mock the Stripe service for tests.""" + with patch("app.api.v1.platform.signup.stripe_service") as mock: + mock.create_customer.return_value = "cus_test_123" + mock.create_setup_intent.return_value = MagicMock( + id="seti_test_123", + client_secret="seti_test_123_secret_abc", + status="requires_payment_method", + ) + mock.get_setup_intent.return_value = MagicMock( + id="seti_test_123", + status="succeeded", + payment_method="pm_test_123", + ) + mock.attach_payment_method_to_customer.return_value = None + yield mock + + +@pytest.fixture +def signup_session(client): + """Create a signup session for testing.""" + response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False}, + ) + return response.json()["session_id"] + + +@pytest.fixture +def existing_user(db, auth_manager): + """Create an existing user for testing duplicate email.""" + user = User( + email="existing@example.com", + username="existing_user", + hashed_password=auth_manager.hash_password("password123"), + first_name="Existing", + last_name="User", + role="vendor", + is_active=True, + ) + db.add(user) + db.commit() + return user + + +@pytest.fixture +def claimed_owner_user(db, auth_manager): + """Create an owner user for the claimed vendor.""" + user = User( + email="claimed_owner@test.com", + username="claimed_owner", + hashed_password=auth_manager.hash_password("testpass123"), + role="vendor", + is_active=True, + ) + db.add(user) + db.commit() + return user + + +@pytest.fixture +def claimed_letzshop_vendor(db, claimed_owner_user): + """Create a vendor that has already claimed a Letzshop shop.""" + company = Company( + name="Claimed Company", + owner_user_id=claimed_owner_user.id, + contact_email="claimed@test.com", + ) + db.add(company) + db.flush() + + vendor = Vendor( + company_id=company.id, + vendor_code="CLAIMED", + subdomain="claimed", + name="Claimed Vendor", + contact_email="claimed@test.com", + is_active=True, + letzshop_vendor_slug="already-claimed-shop", + ) + db.add(vendor) + db.commit() + return vendor + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestSignupStartAPI: + """Test signup start endpoint at /api/v1/platform/signup/start.""" + + def test_start_signup_success(self, client): + """Test starting a signup session.""" + response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, + ) + + assert response.status_code == 200 + data = response.json() + assert "session_id" in data + assert data["tier_code"] == TierCode.ESSENTIAL.value + assert data["is_annual"] is False + + def test_start_signup_with_annual_billing(self, client): + """Test starting signup with annual billing.""" + response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["is_annual"] is True + + def test_start_signup_all_tiers(self, client): + """Test starting signup for all valid tiers.""" + for tier in [TierCode.ESSENTIAL, TierCode.PROFESSIONAL, TierCode.BUSINESS, TierCode.ENTERPRISE]: + response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": tier.value, "is_annual": False}, + ) + assert response.status_code == 200 + assert response.json()["tier_code"] == tier.value + + def test_start_signup_invalid_tier(self, client): + """Test starting signup with invalid tier code.""" + response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": "invalid_tier", "is_annual": False}, + ) + + assert response.status_code == 400 + data = response.json() + assert "invalid tier" in data["message"].lower() + + def test_start_signup_session_id_is_unique(self, client): + """Test that each signup session gets a unique ID.""" + response1 = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, + ) + response2 = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, + ) + + assert response1.json()["session_id"] != response2.json()["session_id"] + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestClaimVendorAPI: + """Test claim vendor endpoint at /api/v1/platform/signup/claim-vendor.""" + + def test_claim_vendor_success(self, client, signup_session): + """Test claiming a Letzshop vendor.""" + response = client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": signup_session, + "letzshop_slug": "my-new-shop", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["session_id"] == signup_session + assert data["letzshop_slug"] == "my-new-shop" + assert data["vendor_name"] is not None + + def test_claim_vendor_with_vendor_id(self, client, signup_session): + """Test claiming vendor with Letzshop vendor ID.""" + response = client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": signup_session, + "letzshop_slug": "my-shop", + "letzshop_vendor_id": "letz_vendor_123", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["letzshop_slug"] == "my-shop" + + def test_claim_vendor_invalid_session(self, client): + """Test claiming vendor with invalid session.""" + response = client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": "invalid_session_id", + "letzshop_slug": "my-shop", + }, + ) + + assert response.status_code == 404 + data = response.json() + assert "not found" in data["message"].lower() + + def test_claim_vendor_already_claimed(self, client, signup_session, claimed_letzshop_vendor): + """Test claiming a vendor that's already claimed.""" + response = client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": signup_session, + "letzshop_slug": "already-claimed-shop", + }, + ) + + assert response.status_code == 400 + data = response.json() + assert "already claimed" in data["message"].lower() + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestCreateAccountAPI: + """Test create account endpoint at /api/v1/platform/signup/create-account.""" + + def test_create_account_success(self, client, signup_session, mock_stripe_service): + """Test creating an account.""" + response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "newuser@example.com", + "password": "SecurePass123!", + "first_name": "John", + "last_name": "Doe", + "company_name": "Test Company", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["session_id"] == signup_session + assert data["user_id"] > 0 + assert data["vendor_id"] > 0 + assert data["stripe_customer_id"] == "cus_test_123" + + def test_create_account_with_phone(self, client, signup_session, mock_stripe_service): + """Test creating an account with phone number.""" + response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "user2@example.com", + "password": "SecurePass123!", + "first_name": "Jane", + "last_name": "Smith", + "company_name": "Another Company", + "phone": "+352 123 456 789", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["user_id"] > 0 + + def test_create_account_invalid_session(self, client, mock_stripe_service): + """Test creating account with invalid session.""" + response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": "invalid_session", + "email": "test@example.com", + "password": "SecurePass123!", + "first_name": "Test", + "last_name": "User", + "company_name": "Test Co", + }, + ) + + assert response.status_code == 404 + + def test_create_account_duplicate_email( + self, client, signup_session, existing_user, mock_stripe_service + ): + """Test creating account with existing email.""" + response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "existing@example.com", + "password": "SecurePass123!", + "first_name": "Another", + "last_name": "User", + "company_name": "Duplicate Co", + }, + ) + + assert response.status_code == 400 + data = response.json() + assert "already exists" in data["message"].lower() + + def test_create_account_invalid_email(self, client, signup_session): + """Test creating account with invalid email format.""" + response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "not-an-email", + "password": "SecurePass123!", + "first_name": "Test", + "last_name": "User", + "company_name": "Test Co", + }, + ) + + assert response.status_code == 422 # Validation error + + def test_create_account_with_letzshop_claim(self, client, mock_stripe_service): + """Test creating account after claiming Letzshop vendor.""" + # Start signup + start_response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False}, + ) + session_id = start_response.json()["session_id"] + + # Claim vendor + client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": session_id, + "letzshop_slug": "my-shop-claim", + }, + ) + + # Create account + response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": session_id, + "email": "shop@example.com", + "password": "SecurePass123!", + "first_name": "Shop", + "last_name": "Owner", + "company_name": "My Shop", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["vendor_id"] > 0 + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestSetupPaymentAPI: + """Test setup payment endpoint at /api/v1/platform/signup/setup-payment.""" + + def test_setup_payment_success(self, client, signup_session, mock_stripe_service): + """Test setting up payment after account creation.""" + # Create account first + client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "payment@example.com", + "password": "SecurePass123!", + "first_name": "Payment", + "last_name": "Test", + "company_name": "Payment Co", + }, + ) + + # Setup payment + response = client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": signup_session}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["session_id"] == signup_session + assert "client_secret" in data + assert data["stripe_customer_id"] == "cus_test_123" + + def test_setup_payment_invalid_session(self, client, mock_stripe_service): + """Test setup payment with invalid session.""" + response = client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": "invalid_session"}, + ) + + assert response.status_code == 404 + + def test_setup_payment_without_account(self, client, signup_session, mock_stripe_service): + """Test setup payment without creating account first.""" + response = client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": signup_session}, + ) + + assert response.status_code == 400 + data = response.json() + assert "account not created" in data["message"].lower() + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestCompleteSignupAPI: + """Test complete signup endpoint at /api/v1/platform/signup/complete.""" + + def test_complete_signup_success(self, client, signup_session, mock_stripe_service, db): + """Test completing signup after payment setup.""" + # Create account + client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "complete@example.com", + "password": "SecurePass123!", + "first_name": "Complete", + "last_name": "User", + "company_name": "Complete Co", + }, + ) + + # Setup payment + client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": signup_session}, + ) + + # Complete signup + response = client.post( + "/api/v1/platform/signup/complete", + json={ + "session_id": signup_session, + "setup_intent_id": "seti_test_123", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "vendor_code" in data + assert "vendor_id" in data + assert "redirect_url" in data + assert "trial_ends_at" in data + + def test_complete_signup_invalid_session(self, client, mock_stripe_service): + """Test completing signup with invalid session.""" + response = client.post( + "/api/v1/platform/signup/complete", + json={ + "session_id": "invalid_session", + "setup_intent_id": "seti_test_123", + }, + ) + + assert response.status_code == 404 + + def test_complete_signup_payment_not_succeeded( + self, client, signup_session, mock_stripe_service + ): + """Test completing signup when payment setup failed.""" + # Create account + client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": signup_session, + "email": "fail@example.com", + "password": "SecurePass123!", + "first_name": "Fail", + "last_name": "User", + "company_name": "Fail Co", + }, + ) + + # Setup payment + client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": signup_session}, + ) + + # Mock failed setup intent + mock_stripe_service.get_setup_intent.return_value = MagicMock( + id="seti_failed", + status="requires_payment_method", + payment_method=None, + ) + + # Complete signup + response = client.post( + "/api/v1/platform/signup/complete", + json={ + "session_id": signup_session, + "setup_intent_id": "seti_failed", + }, + ) + + assert response.status_code == 400 + data = response.json() + assert "not completed" in data["message"].lower() + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestGetSignupSessionAPI: + """Test get signup session endpoint at /api/v1/platform/signup/session/{session_id}.""" + + def test_get_session_after_start(self, client, signup_session): + """Test getting session after starting signup.""" + response = client.get(f"/api/v1/platform/signup/session/{signup_session}") + + assert response.status_code == 200 + data = response.json() + assert data["session_id"] == signup_session + assert data["step"] == "tier_selected" + assert data["tier_code"] == TierCode.PROFESSIONAL.value + assert "created_at" in data + + def test_get_session_after_claim(self, client, signup_session): + """Test getting session after claiming vendor.""" + client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": signup_session, + "letzshop_slug": "my-session-shop", + }, + ) + + response = client.get(f"/api/v1/platform/signup/session/{signup_session}") + + assert response.status_code == 200 + data = response.json() + assert data["step"] == "vendor_claimed" + assert data["letzshop_slug"] == "my-session-shop" + + def test_get_session_invalid_id(self, client): + """Test getting non-existent session.""" + response = client.get("/api/v1/platform/signup/session/invalid_id") + + assert response.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.platform +class TestSignupFullFlow: + """Test complete signup flow end-to-end.""" + + def test_full_signup_flow(self, client, mock_stripe_service, db): + """Test the complete signup flow from start to finish.""" + # Step 1: Start signup + start_response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.BUSINESS.value, "is_annual": True}, + ) + assert start_response.status_code == 200 + session_id = start_response.json()["session_id"] + + # Step 2: Claim Letzshop vendor (optional) + claim_response = client.post( + "/api/v1/platform/signup/claim-vendor", + json={ + "session_id": session_id, + "letzshop_slug": "full-flow-shop", + }, + ) + assert claim_response.status_code == 200 + + # Step 3: Create account + account_response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": session_id, + "email": "fullflow@example.com", + "password": "SecurePass123!", + "first_name": "Full", + "last_name": "Flow", + "company_name": "Full Flow Company", + "phone": "+352 123 456", + }, + ) + assert account_response.status_code == 200 + vendor_id = account_response.json()["vendor_id"] + + # Step 4: Setup payment + payment_response = client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": session_id}, + ) + assert payment_response.status_code == 200 + assert "client_secret" in payment_response.json() + + # Step 5: Complete signup + complete_response = client.post( + "/api/v1/platform/signup/complete", + json={ + "session_id": session_id, + "setup_intent_id": "seti_test_123", + }, + ) + assert complete_response.status_code == 200 + assert complete_response.json()["success"] is True + + # Verify vendor was created with Letzshop link + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + assert vendor is not None + assert vendor.letzshop_vendor_slug == "full-flow-shop" + assert vendor.is_active is True + + def test_signup_flow_without_letzshop_claim(self, client, mock_stripe_service, db): + """Test signup flow skipping Letzshop claim step.""" + # Step 1: Start signup + start_response = client.post( + "/api/v1/platform/signup/start", + json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, + ) + session_id = start_response.json()["session_id"] + + # Skip Step 2, go directly to Step 3 + account_response = client.post( + "/api/v1/platform/signup/create-account", + json={ + "session_id": session_id, + "email": "noletzshop@example.com", + "password": "SecurePass123!", + "first_name": "No", + "last_name": "Letzshop", + "company_name": "Independent Shop", + }, + ) + assert account_response.status_code == 200 + vendor_id = account_response.json()["vendor_id"] + + # Step 4 & 5: Payment and complete + client.post( + "/api/v1/platform/signup/setup-payment", + json={"session_id": session_id}, + ) + + complete_response = client.post( + "/api/v1/platform/signup/complete", + json={ + "session_id": session_id, + "setup_intent_id": "seti_test_123", + }, + ) + assert complete_response.status_code == 200 + + # Verify vendor was created without Letzshop link + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + assert vendor is not None + assert vendor.letzshop_vendor_slug is None