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:
@@ -65,12 +65,13 @@ def client(db):
|
||||
# Patch get_db in middleware modules - they have their own imports
|
||||
# The middleware calls: db_gen = get_db(); db = next(db_gen)
|
||||
# Also patch settings.platform_domain so subdomain detection works with test hosts
|
||||
with patch("middleware.store_context.get_db", override_get_db):
|
||||
with patch("middleware.theme_context.get_db", override_get_db):
|
||||
with patch("middleware.store_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "platform.com"
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
with patch("middleware.platform_context.get_db", override_get_db):
|
||||
with patch("middleware.store_context.get_db", override_get_db):
|
||||
with patch("middleware.theme_context.get_db", override_get_db):
|
||||
with patch("middleware.store_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "platform.com"
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
# Clean up
|
||||
if get_db in app.dependency_overrides:
|
||||
|
||||
@@ -79,6 +79,21 @@ async def test_custom_domain_www(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/merchant-domain-detection")
|
||||
async def test_merchant_domain_detection(request: Request):
|
||||
"""Test store detection via merchant domain routing."""
|
||||
store = getattr(request.state, "store", None)
|
||||
store_context = getattr(request.state, "store_context", None)
|
||||
return {
|
||||
"store_detected": store is not None,
|
||||
"store_id": store.id if store else None,
|
||||
"store_code": store.store_code if store else None,
|
||||
"store_name": store.name if store else None,
|
||||
"merchant_domain": store_context.get("merchant_domain") if store_context else None,
|
||||
"merchant_id": store_context.get("merchant_id") if store_context else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/inactive-store-detection")
|
||||
async def test_inactive_store_detection(request: Request):
|
||||
"""Test inactive store detection."""
|
||||
|
||||
160
tests/integration/middleware/test_merchant_domain_flow.py
Normal file
160
tests/integration/middleware/test_merchant_domain_flow.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# tests/integration/middleware/test_merchant_domain_flow.py
|
||||
"""
|
||||
Integration tests for merchant domain resolution end-to-end flow.
|
||||
|
||||
Tests verify that merchant domain detection works correctly through real HTTP
|
||||
requests, including:
|
||||
- Merchant domain → platform resolved → store resolved
|
||||
- Store-specific domain overrides merchant domain
|
||||
- Merchant domain resolves to first active store
|
||||
- Existing StoreDomain and subdomain routing still work (backward compatibility)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store, StoreDomain
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.middleware
|
||||
class TestMerchantDomainFlow:
|
||||
"""Test merchant domain resolution through real HTTP requests."""
|
||||
|
||||
def test_merchant_domain_resolves_store(
|
||||
self, client, db, middleware_test_merchant, store_with_subdomain
|
||||
):
|
||||
"""Test merchant domain resolves to merchant's first active store."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=middleware_test_merchant.id,
|
||||
domain=f"mflow{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"mf_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/middleware-test/merchant-domain-detection",
|
||||
headers={"host": f"mflow{unique_id}.lu"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["store_detected"] is True
|
||||
assert data["merchant_domain"] is True
|
||||
assert data["merchant_id"] == middleware_test_merchant.id
|
||||
|
||||
def test_store_domain_overrides_merchant_domain(
|
||||
self, client, db, middleware_test_merchant
|
||||
):
|
||||
"""Test that StoreDomain takes priority over MerchantDomain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Create a store with a custom StoreDomain
|
||||
store = Store(
|
||||
merchant_id=middleware_test_merchant.id,
|
||||
name="Override Store",
|
||||
store_code=f"OVERRIDE_{unique_id.upper()}",
|
||||
subdomain=f"override{unique_id}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
domain_name = f"storeoverride{unique_id}.lu"
|
||||
sd = StoreDomain(
|
||||
store_id=store.id,
|
||||
domain=domain_name,
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"so_{unique_id}",
|
||||
)
|
||||
db.add(sd)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/middleware-test/custom-domain",
|
||||
headers={"host": domain_name},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
if data["store_detected"]:
|
||||
assert data["store_code"] == store.store_code
|
||||
|
||||
def test_subdomain_routing_still_works(self, client, store_with_subdomain):
|
||||
"""Test backward compatibility: subdomain routing still works."""
|
||||
response = client.get(
|
||||
"/middleware-test/subdomain-detection",
|
||||
headers={
|
||||
"host": f"{store_with_subdomain.subdomain}.platform.com"
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["store_detected"] is True
|
||||
assert data["store_code"] == store_with_subdomain.store_code
|
||||
|
||||
def test_inactive_merchant_domain_not_resolved(
|
||||
self, client, db, middleware_test_merchant
|
||||
):
|
||||
"""Test that inactive merchant domain is not resolved."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=middleware_test_merchant.id,
|
||||
domain=f"inactive{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()
|
||||
|
||||
response = client.get(
|
||||
"/middleware-test/merchant-domain-detection",
|
||||
headers={"host": f"inactive{unique_id}.lu"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["store_detected"] is False
|
||||
|
||||
def test_unverified_merchant_domain_not_resolved(
|
||||
self, client, db, middleware_test_merchant
|
||||
):
|
||||
"""Test that unverified merchant domain is not resolved."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
md = MerchantDomain(
|
||||
merchant_id=middleware_test_merchant.id,
|
||||
domain=f"unver{unique_id}.lu",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
verification_token=f"uv_{unique_id}",
|
||||
)
|
||||
db.add(md)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/middleware-test/merchant-domain-detection",
|
||||
headers={"host": f"unver{unique_id}.lu"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["store_detected"] is False
|
||||
Reference in New Issue
Block a user