feat(tenancy): add merchant-level domain with store override
Merchants can now register domains (e.g., myloyaltyprogram.lu) that all their stores inherit. Individual stores can override with their own custom domain. Resolution priority: StoreDomain > MerchantDomain > subdomain. - Add MerchantDomain model, schema, service, and admin API endpoints - Add merchant domain fallback in platform and store context middleware - Add Merchant.primary_domain and Store.effective_domain properties - Add Alembic migration for merchant_domains table - Update loyalty user journey docs with subscription & domain setup flow - Add unit tests (50 passing) and integration tests (15 passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
289
tests/unit/middleware/test_merchant_domain_resolution.py
Normal file
289
tests/unit/middleware/test_merchant_domain_resolution.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# tests/unit/middleware/test_merchant_domain_resolution.py
|
||||
"""
|
||||
Unit tests for merchant domain resolution in platform and store context middleware.
|
||||
|
||||
Tests cover:
|
||||
- PlatformContextManager.get_platform_from_context() with merchant domain
|
||||
- StoreContextManager.get_store_from_context() with merchant domain
|
||||
- Priority: StoreDomain > MerchantDomain
|
||||
- Fallthrough when MerchantDomain not found or inactive/unverified
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Platform, Store, StoreDomain
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
from middleware.platform_context import PlatformContextManager
|
||||
from middleware.store_context import StoreContextManager
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLATFORM CONTEXT - MERCHANT DOMAIN RESOLUTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.middleware
|
||||
class TestPlatformContextMerchantDomain:
|
||||
"""Test PlatformContextManager.get_platform_from_context() with merchant domains."""
|
||||
|
||||
def test_resolves_platform_from_merchant_domain(self, db, test_merchant, test_platform):
|
||||
"""Test that platform is resolved from MerchantDomain.platform_id."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
platform_id=test_platform.id,
|
||||
domain=f"mplatform{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"mpt_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": f"mplatform{unique_id}.lu",
|
||||
"detection_method": "domain",
|
||||
"host": f"mplatform{unique_id}.lu",
|
||||
"original_path": "/",
|
||||
}
|
||||
|
||||
platform = PlatformContextManager.get_platform_from_context(db, context)
|
||||
assert platform is not None
|
||||
assert platform.id == test_platform.id
|
||||
|
||||
def test_falls_through_when_merchant_domain_not_found(self, db):
|
||||
"""Test that None is returned when no MerchantDomain matches."""
|
||||
context = {
|
||||
"domain": "nonexistent.lu",
|
||||
"detection_method": "domain",
|
||||
"host": "nonexistent.lu",
|
||||
"original_path": "/",
|
||||
}
|
||||
|
||||
platform = PlatformContextManager.get_platform_from_context(db, context)
|
||||
assert platform is None
|
||||
|
||||
def test_falls_through_when_merchant_domain_inactive(self, db, test_merchant, test_platform):
|
||||
"""Test that inactive MerchantDomain is skipped."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
platform_id=test_platform.id,
|
||||
domain=f"inactive{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=False,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"ipt_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": f"inactive{unique_id}.lu",
|
||||
"detection_method": "domain",
|
||||
"host": f"inactive{unique_id}.lu",
|
||||
"original_path": "/",
|
||||
}
|
||||
|
||||
platform = PlatformContextManager.get_platform_from_context(db, context)
|
||||
assert platform is None
|
||||
|
||||
def test_falls_through_when_merchant_domain_unverified(self, db, test_merchant, test_platform):
|
||||
"""Test that unverified MerchantDomain is skipped."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
platform_id=test_platform.id,
|
||||
domain=f"unverified{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
verification_token=f"upt_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": f"unverified{unique_id}.lu",
|
||||
"detection_method": "domain",
|
||||
"host": f"unverified{unique_id}.lu",
|
||||
"original_path": "/",
|
||||
}
|
||||
|
||||
platform = PlatformContextManager.get_platform_from_context(db, context)
|
||||
assert platform is None
|
||||
|
||||
def test_store_domain_takes_priority_over_merchant_domain(
|
||||
self, db, test_store, test_merchant, test_platform
|
||||
):
|
||||
"""Test that StoreDomain is checked before MerchantDomain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain_name = f"priority{unique_id}.lu"
|
||||
|
||||
# Create a StoreDomain with this domain
|
||||
sd = StoreDomain(
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
domain=domain_name,
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"sdp_{unique_id}",
|
||||
)
|
||||
db.add(sd)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": domain_name,
|
||||
"detection_method": "domain",
|
||||
"host": domain_name,
|
||||
"original_path": "/",
|
||||
}
|
||||
|
||||
platform = PlatformContextManager.get_platform_from_context(db, context)
|
||||
assert platform is not None
|
||||
assert platform.id == test_platform.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STORE CONTEXT - MERCHANT DOMAIN RESOLUTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.middleware
|
||||
class TestStoreContextMerchantDomain:
|
||||
"""Test StoreContextManager.get_store_from_context() with merchant domains."""
|
||||
|
||||
def test_resolves_to_merchants_first_active_store(
|
||||
self, db, test_merchant, test_store
|
||||
):
|
||||
"""Test that merchant domain resolves to merchant's first active store."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"mstore{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"mst_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": f"mstore{unique_id}.lu",
|
||||
"detection_method": "custom_domain",
|
||||
"host": f"mstore{unique_id}.lu",
|
||||
"original_host": f"mstore{unique_id}.lu",
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(db, context)
|
||||
assert store is not None
|
||||
assert store.merchant_id == test_merchant.id
|
||||
assert context.get("merchant_domain") is True
|
||||
assert context.get("merchant_id") == test_merchant.id
|
||||
|
||||
def test_store_domain_takes_priority_over_merchant_domain(
|
||||
self, db, test_store, test_merchant
|
||||
):
|
||||
"""Test that StoreDomain takes priority over MerchantDomain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain_name = f"storepri{unique_id}.lu"
|
||||
|
||||
# Create StoreDomain for this store
|
||||
sd = StoreDomain(
|
||||
store_id=test_store.id,
|
||||
domain=domain_name,
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"sdpri_{unique_id}",
|
||||
)
|
||||
db.add(sd)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": domain_name,
|
||||
"detection_method": "custom_domain",
|
||||
"host": domain_name,
|
||||
"original_host": domain_name,
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(db, context)
|
||||
assert store is not None
|
||||
assert store.id == test_store.id
|
||||
# merchant_domain should NOT be set because StoreDomain resolved first
|
||||
assert context.get("merchant_domain") is None
|
||||
|
||||
def test_falls_through_when_no_active_stores(self, db, other_merchant):
|
||||
"""Test that None is returned when merchant has no active stores."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Create inactive store for the merchant
|
||||
inactive = Store(
|
||||
merchant_id=other_merchant.id,
|
||||
store_code=f"INACTIVE_{unique_id.upper()}",
|
||||
subdomain=f"inactive{unique_id.lower()}",
|
||||
name="Inactive Store",
|
||||
is_active=False,
|
||||
)
|
||||
db.add(inactive)
|
||||
|
||||
md = MerchantDomain(
|
||||
merchant_id=other_merchant.id,
|
||||
domain=f"noactive{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"nat_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": f"noactive{unique_id}.lu",
|
||||
"detection_method": "custom_domain",
|
||||
"host": f"noactive{unique_id}.lu",
|
||||
"original_host": f"noactive{unique_id}.lu",
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(db, context)
|
||||
assert store is None
|
||||
|
||||
def test_falls_through_when_merchant_domain_inactive(self, db, test_merchant):
|
||||
"""Test that inactive MerchantDomain is not resolved to a store."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"inactivem{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=False,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"im_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
context = {
|
||||
"domain": f"inactivem{unique_id}.lu",
|
||||
"detection_method": "custom_domain",
|
||||
"host": f"inactivem{unique_id}.lu",
|
||||
"original_host": f"inactivem{unique_id}.lu",
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(db, context)
|
||||
assert store is None
|
||||
Reference in New Issue
Block a user