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:
2026-02-09 22:04:49 +01:00
parent c914e10cb8
commit 0984ff7d17
26 changed files with 2972 additions and 34 deletions

View File

@@ -0,0 +1,219 @@
# tests/integration/api/v1/admin/test_merchant_domains.py
"""Integration tests for admin merchant domain management endpoints.
Tests the /api/v1/admin/merchants/{id}/domains/* and
/api/v1/admin/merchants/domains/merchant/* endpoints.
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.modules.tenancy.models.merchant_domain import MerchantDomain
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
class TestAdminMerchantDomainsAPI:
"""Test admin merchant domain management endpoints."""
def test_create_merchant_domain(self, client, admin_headers, test_merchant):
"""Test POST create merchant domain returns 201-equivalent (200 with data)."""
unique_id = str(uuid.uuid4())[:8]
response = client.post(
f"/api/v1/admin/merchants/{test_merchant.id}/domains",
headers=admin_headers,
json={
"domain": f"newmerch{unique_id}.example.com",
"is_primary": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["domain"] == f"newmerch{unique_id}.example.com"
assert data["merchant_id"] == test_merchant.id
assert data["is_primary"] is True
assert data["is_verified"] is False
assert data["is_active"] is False
assert data["verification_token"] is not None
def test_create_duplicate_domain(
self, client, admin_headers, test_merchant, db
):
"""Test POST create duplicate domain returns 409 conflict."""
unique_id = str(uuid.uuid4())[:8]
domain_name = f"dup{unique_id}.example.com"
# Create existing domain
existing = MerchantDomain(
merchant_id=test_merchant.id,
domain=domain_name,
verification_token=f"dup_{unique_id}",
)
db.add(existing)
db.commit()
response = client.post(
f"/api/v1/admin/merchants/{test_merchant.id}/domains",
headers=admin_headers,
json={"domain": domain_name, "is_primary": True},
)
assert response.status_code == 409
data = response.json()
assert data["error_code"] == "MERCHANT_DOMAIN_ALREADY_EXISTS"
def test_create_domain_invalid_format(
self, client, admin_headers, test_merchant
):
"""Test POST create domain with invalid format returns 422."""
response = client.post(
f"/api/v1/admin/merchants/{test_merchant.id}/domains",
headers=admin_headers,
json={"domain": "notadomain", "is_primary": True},
)
assert response.status_code == 422
def test_create_domain_merchant_not_found(self, client, admin_headers):
"""Test POST create domain for non-existent merchant returns 404."""
response = client.post(
"/api/v1/admin/merchants/99999/domains",
headers=admin_headers,
json={"domain": "test.example.com", "is_primary": True},
)
assert response.status_code == 404
def test_list_merchant_domains(
self, client, admin_headers, test_merchant, db
):
"""Test GET list merchant domains returns 200."""
unique_id = str(uuid.uuid4())[:8]
domain = MerchantDomain(
merchant_id=test_merchant.id,
domain=f"list{unique_id}.example.com",
verification_token=f"list_{unique_id}",
)
db.add(domain)
db.commit()
response = client.get(
f"/api/v1/admin/merchants/{test_merchant.id}/domains",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
domain_names = [d["domain"] for d in data["domains"]]
assert f"list{unique_id}.example.com" in domain_names
def test_get_domain_detail(self, client, admin_headers, test_merchant, db):
"""Test GET domain detail returns 200."""
unique_id = str(uuid.uuid4())[:8]
domain = MerchantDomain(
merchant_id=test_merchant.id,
domain=f"detail{unique_id}.example.com",
verification_token=f"det_{unique_id}",
)
db.add(domain)
db.commit()
db.refresh(domain)
response = client.get(
f"/api/v1/admin/merchants/domains/merchant/{domain.id}",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["id"] == domain.id
assert data["domain"] == f"detail{unique_id}.example.com"
def test_update_domain(self, client, admin_headers, test_merchant, db):
"""Test PUT update domain returns 200."""
unique_id = str(uuid.uuid4())[:8]
domain = MerchantDomain(
merchant_id=test_merchant.id,
domain=f"update{unique_id}.example.com",
is_verified=True,
verified_at=datetime.now(UTC),
is_active=False,
is_primary=False,
verification_token=f"upd_{unique_id}",
)
db.add(domain)
db.commit()
db.refresh(domain)
response = client.put(
f"/api/v1/admin/merchants/domains/merchant/{domain.id}",
headers=admin_headers,
json={"is_active": True, "is_primary": True},
)
assert response.status_code == 200
data = response.json()
assert data["is_active"] is True
assert data["is_primary"] is True
def test_update_activate_unverified_domain(
self, client, admin_headers, test_merchant, db
):
"""Test PUT activate unverified domain returns 400."""
unique_id = str(uuid.uuid4())[:8]
domain = MerchantDomain(
merchant_id=test_merchant.id,
domain=f"unver{unique_id}.example.com",
is_verified=False,
is_active=False,
verification_token=f"unv_{unique_id}",
)
db.add(domain)
db.commit()
db.refresh(domain)
response = client.put(
f"/api/v1/admin/merchants/domains/merchant/{domain.id}",
headers=admin_headers,
json={"is_active": True},
)
assert response.status_code == 400
data = response.json()
assert data["error_code"] == "DOMAIN_NOT_VERIFIED"
def test_delete_domain(self, client, admin_headers, test_merchant, db):
"""Test DELETE domain returns 200."""
unique_id = str(uuid.uuid4())[:8]
domain = MerchantDomain(
merchant_id=test_merchant.id,
domain=f"del{unique_id}.example.com",
verification_token=f"del_{unique_id}",
)
db.add(domain)
db.commit()
db.refresh(domain)
response = client.delete(
f"/api/v1/admin/merchants/domains/merchant/{domain.id}",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "deleted" in data["message"].lower()
assert data["merchant_id"] == test_merchant.id
def test_non_admin_access(self, client, auth_headers, test_merchant):
"""Test non-admin access returns 403."""
response = client.get(
f"/api/v1/admin/merchants/{test_merchant.id}/domains",
headers=auth_headers,
)
assert response.status_code == 403

View File

@@ -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:

View File

@@ -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."""

View 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