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:
@@ -176,4 +176,5 @@ pytest_plugins = [
|
||||
"tests.fixtures.message_fixtures",
|
||||
"tests.fixtures.testing_fixtures",
|
||||
"tests.fixtures.content_page_fixtures",
|
||||
"tests.fixtures.merchant_domain_fixtures",
|
||||
]
|
||||
|
||||
97
tests/fixtures/merchant_domain_fixtures.py
vendored
Normal file
97
tests/fixtures/merchant_domain_fixtures.py
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# tests/fixtures/merchant_domain_fixtures.py
|
||||
"""
|
||||
Merchant domain test fixtures.
|
||||
|
||||
Provides fixtures for:
|
||||
- Merchants with verified merchant domains
|
||||
- Stores with merchant domain overrides (StoreDomain)
|
||||
- MerchantDomain objects for testing
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
from app.modules.tenancy.models.store_domain import StoreDomain
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_merchant_domain(db, test_merchant):
|
||||
"""Create an unverified merchant domain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"merchant{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=False,
|
||||
is_verified=False,
|
||||
verification_token=f"mtoken_{unique_id}",
|
||||
ssl_status="pending",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
return domain
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verified_merchant_domain(db, test_merchant):
|
||||
"""Create a verified and active merchant domain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"verified-merchant{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"vmtoken_{unique_id}",
|
||||
ssl_status="active",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
return domain
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def merchant_with_domain(db, test_merchant, test_platform):
|
||||
"""Create a merchant with a verified merchant domain linked to a platform."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
platform_id=test_platform.id,
|
||||
domain=f"testmerchant{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"mwd_{unique_id}",
|
||||
ssl_status="active",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
return test_merchant, domain
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_with_merchant_domain_override(db, test_store):
|
||||
"""Store that overrides the merchant domain with its own StoreDomain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
store_domain = StoreDomain(
|
||||
store_id=test_store.id,
|
||||
domain=f"storeoverride{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"sod_{unique_id}",
|
||||
ssl_status="active",
|
||||
)
|
||||
db.add(store_domain)
|
||||
db.commit()
|
||||
db.refresh(store_domain)
|
||||
return test_store, store_domain
|
||||
219
tests/integration/api/v1/admin/test_merchant_domains.py
Normal file
219
tests/integration/api/v1/admin/test_merchant_domains.py
Normal 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
|
||||
@@ -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
|
||||
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
|
||||
275
tests/unit/models/test_merchant_domain.py
Normal file
275
tests/unit/models/test_merchant_domain.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# tests/unit/models/test_merchant_domain.py
|
||||
"""Unit tests for MerchantDomain model and related model properties."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
from app.modules.tenancy.models.store_domain import StoreDomain
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODEL TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainModel:
|
||||
"""Test suite for MerchantDomain model."""
|
||||
|
||||
def test_create_merchant_domain(self, db, test_merchant):
|
||||
"""Test creating a MerchantDomain with required fields."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"test{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
verification_token=f"token_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
|
||||
assert domain.id is not None
|
||||
assert domain.merchant_id == test_merchant.id
|
||||
assert domain.is_primary is True
|
||||
assert domain.is_active is True # default
|
||||
assert domain.is_verified is False # default
|
||||
assert domain.ssl_status == "pending" # default
|
||||
assert domain.verified_at is None
|
||||
assert domain.platform_id is None
|
||||
|
||||
def test_merchant_domain_defaults(self, db, test_merchant):
|
||||
"""Test default values for MerchantDomain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"defaults{unique_id}.example.com",
|
||||
verification_token=f"dtoken_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
|
||||
assert domain.is_primary is True
|
||||
assert domain.is_active is True
|
||||
assert domain.is_verified is False
|
||||
assert domain.ssl_status == "pending"
|
||||
|
||||
def test_merchant_domain_repr(self, db, test_merchant):
|
||||
"""Test string representation."""
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain="repr.example.com",
|
||||
)
|
||||
assert "repr.example.com" in repr(domain)
|
||||
assert str(test_merchant.id) in repr(domain)
|
||||
|
||||
def test_merchant_domain_full_url(self):
|
||||
"""Test full_url property."""
|
||||
domain = MerchantDomain(domain="test.example.com")
|
||||
assert domain.full_url == "https://test.example.com"
|
||||
|
||||
def test_normalize_domain_removes_protocol(self):
|
||||
"""Test normalize_domain strips protocols."""
|
||||
assert MerchantDomain.normalize_domain("https://example.com") == "example.com"
|
||||
assert MerchantDomain.normalize_domain("http://example.com") == "example.com"
|
||||
|
||||
def test_normalize_domain_removes_trailing_slash(self):
|
||||
"""Test normalize_domain strips trailing slashes."""
|
||||
assert MerchantDomain.normalize_domain("example.com/") == "example.com"
|
||||
|
||||
def test_normalize_domain_lowercases(self):
|
||||
"""Test normalize_domain converts to lowercase."""
|
||||
assert MerchantDomain.normalize_domain("EXAMPLE.COM") == "example.com"
|
||||
|
||||
def test_unique_domain_constraint(self, db, test_merchant):
|
||||
"""Test that domain must be unique across all merchant domains."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain_name = f"unique{unique_id}.example.com"
|
||||
|
||||
domain1 = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=domain_name,
|
||||
verification_token=f"t1_{unique_id}",
|
||||
)
|
||||
db.add(domain1)
|
||||
db.commit()
|
||||
|
||||
domain2 = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=domain_name,
|
||||
verification_token=f"t2_{unique_id}",
|
||||
)
|
||||
db.add(domain2)
|
||||
with pytest.raises(Exception): # IntegrityError
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MERCHANT.primary_domain PROPERTY TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantPrimaryDomain:
|
||||
"""Test Merchant.primary_domain property."""
|
||||
|
||||
def test_primary_domain_returns_active_verified_primary(self, db, test_merchant):
|
||||
"""Test primary_domain returns domain when active, verified, and primary."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"primary{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"pt_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(test_merchant)
|
||||
|
||||
assert test_merchant.primary_domain == f"primary{unique_id}.example.com"
|
||||
|
||||
def test_primary_domain_returns_none_when_no_domains(self, db, test_merchant):
|
||||
"""Test primary_domain returns None when merchant has no domains."""
|
||||
db.refresh(test_merchant)
|
||||
# Fresh merchant without any domains added in this test
|
||||
# Need to check if it may have domains from other fixtures
|
||||
# Just verify the property works without error
|
||||
result = test_merchant.primary_domain
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
def test_primary_domain_returns_none_when_inactive(self, db, test_merchant):
|
||||
"""Test primary_domain returns None when domain is inactive."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"inactive{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=False,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"it_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(test_merchant)
|
||||
|
||||
assert test_merchant.primary_domain is None
|
||||
|
||||
def test_primary_domain_returns_none_when_unverified(self, db, test_merchant):
|
||||
"""Test primary_domain returns None when domain is unverified."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"unverified{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
verification_token=f"ut_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(test_merchant)
|
||||
|
||||
assert test_merchant.primary_domain is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STORE.effective_domain PROPERTY TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestStoreEffectiveDomain:
|
||||
"""Test Store.effective_domain inheritance chain."""
|
||||
|
||||
def test_effective_domain_returns_store_domain_when_present(self, db, test_store):
|
||||
"""Test effective_domain returns store's own custom domain (highest priority)."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
store_domain = StoreDomain(
|
||||
store_id=test_store.id,
|
||||
domain=f"storeover{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"sd_{unique_id}",
|
||||
)
|
||||
db.add(store_domain)
|
||||
db.commit()
|
||||
db.refresh(test_store)
|
||||
|
||||
assert test_store.effective_domain == f"storeover{unique_id}.example.com"
|
||||
|
||||
def test_effective_domain_returns_merchant_domain_when_no_store_domain(
|
||||
self, db, test_store, test_merchant
|
||||
):
|
||||
"""Test effective_domain returns merchant domain when no store domain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
merchant_domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"merchant{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"md_{unique_id}",
|
||||
)
|
||||
db.add(merchant_domain)
|
||||
db.commit()
|
||||
db.refresh(test_store)
|
||||
db.refresh(test_merchant)
|
||||
|
||||
assert test_store.effective_domain == f"merchant{unique_id}.example.com"
|
||||
|
||||
def test_effective_domain_returns_subdomain_fallback(self, db, test_store):
|
||||
"""Test effective_domain returns subdomain fallback when no custom domains."""
|
||||
db.refresh(test_store)
|
||||
# With no store or merchant domains, should fall back to subdomain
|
||||
result = test_store.effective_domain
|
||||
assert test_store.subdomain in result
|
||||
|
||||
def test_effective_domain_store_domain_overrides_merchant_domain(
|
||||
self, db, test_store, test_merchant
|
||||
):
|
||||
"""Test that store domain takes priority over merchant domain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Add merchant domain
|
||||
merchant_domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"merchantpri{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"mpri_{unique_id}",
|
||||
)
|
||||
db.add(merchant_domain)
|
||||
|
||||
# Add store domain (should take priority)
|
||||
store_domain = StoreDomain(
|
||||
store_id=test_store.id,
|
||||
domain=f"storepri{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"spri_{unique_id}",
|
||||
)
|
||||
db.add(store_domain)
|
||||
db.commit()
|
||||
db.refresh(test_store)
|
||||
db.refresh(test_merchant)
|
||||
|
||||
assert test_store.effective_domain == f"storepri{unique_id}.example.com"
|
||||
526
tests/unit/services/test_merchant_domain_service.py
Normal file
526
tests/unit/services/test_merchant_domain_service.py
Normal file
@@ -0,0 +1,526 @@
|
||||
# tests/unit/services/test_merchant_domain_service.py
|
||||
"""Unit tests for MerchantDomainService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.modules.tenancy.exceptions import (
|
||||
DNSVerificationException,
|
||||
DomainAlreadyVerifiedException,
|
||||
DomainNotVerifiedException,
|
||||
DomainVerificationFailedException,
|
||||
MaxDomainsReachedException,
|
||||
MerchantDomainAlreadyExistsException,
|
||||
MerchantDomainNotFoundException,
|
||||
MerchantNotFoundException,
|
||||
)
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
from app.modules.tenancy.models.store_domain import StoreDomain
|
||||
from app.modules.tenancy.schemas.merchant_domain import (
|
||||
MerchantDomainCreate,
|
||||
MerchantDomainUpdate,
|
||||
)
|
||||
from app.modules.tenancy.services.merchant_domain_service import (
|
||||
merchant_domain_service,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ADD DOMAIN TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainServiceAdd:
|
||||
"""Test suite for adding merchant domains."""
|
||||
|
||||
def test_add_domain_success(self, db, test_merchant):
|
||||
"""Test successfully adding a domain to a merchant."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain_data = MerchantDomainCreate(
|
||||
domain=f"newmerchant{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
result = merchant_domain_service.add_domain(
|
||||
db, test_merchant.id, domain_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result is not None
|
||||
assert result.merchant_id == test_merchant.id
|
||||
assert result.domain == f"newmerchant{unique_id}.example.com"
|
||||
assert result.is_primary is True
|
||||
assert result.is_verified is False
|
||||
assert result.is_active is False
|
||||
assert result.verification_token is not None
|
||||
|
||||
def test_add_domain_merchant_not_found(self, db):
|
||||
"""Test adding domain to non-existent merchant raises exception."""
|
||||
domain_data = MerchantDomainCreate(
|
||||
domain="test.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MerchantNotFoundException):
|
||||
merchant_domain_service.add_domain(db, 99999, domain_data)
|
||||
|
||||
def test_add_domain_already_exists_as_merchant_domain(
|
||||
self, db, test_merchant
|
||||
):
|
||||
"""Test adding a domain that already exists as MerchantDomain raises exception."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
existing = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"existing{unique_id}.example.com",
|
||||
verification_token=f"ext_{unique_id}",
|
||||
)
|
||||
db.add(existing)
|
||||
db.commit()
|
||||
|
||||
domain_data = MerchantDomainCreate(
|
||||
domain=f"existing{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MerchantDomainAlreadyExistsException):
|
||||
merchant_domain_service.add_domain(
|
||||
db, test_merchant.id, domain_data
|
||||
)
|
||||
|
||||
def test_add_domain_already_exists_as_store_domain(
|
||||
self, db, test_merchant, test_store
|
||||
):
|
||||
"""Test adding a domain that already exists as StoreDomain raises exception."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
sd = StoreDomain(
|
||||
store_id=test_store.id,
|
||||
domain=f"storeexist{unique_id}.example.com",
|
||||
verification_token=f"se_{unique_id}",
|
||||
)
|
||||
db.add(sd)
|
||||
db.commit()
|
||||
|
||||
domain_data = MerchantDomainCreate(
|
||||
domain=f"storeexist{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MerchantDomainAlreadyExistsException):
|
||||
merchant_domain_service.add_domain(
|
||||
db, test_merchant.id, domain_data
|
||||
)
|
||||
|
||||
def test_add_domain_max_limit_reached(self, db, test_merchant):
|
||||
"""Test adding domain when max limit reached raises exception."""
|
||||
for i in range(merchant_domain_service.max_domains_per_merchant):
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"limit{i}_{uuid.uuid4().hex[:6]}.example.com",
|
||||
verification_token=f"lim_{i}_{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
domain_data = MerchantDomainCreate(
|
||||
domain="onemore.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MaxDomainsReachedException):
|
||||
merchant_domain_service.add_domain(
|
||||
db, test_merchant.id, domain_data
|
||||
)
|
||||
|
||||
def test_add_domain_reserved_subdomain(self):
|
||||
"""Test adding a domain with reserved subdomain is rejected by schema."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MerchantDomainCreate(
|
||||
domain="admin.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
assert "reserved subdomain" in str(exc_info.value).lower()
|
||||
|
||||
def test_add_domain_sets_primary_unsets_others(self, db, test_merchant):
|
||||
"""Test adding a primary domain unsets other primary domains."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
first = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"first{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
verification_token=f"f_{unique_id}",
|
||||
)
|
||||
db.add(first)
|
||||
db.commit()
|
||||
|
||||
domain_data = MerchantDomainCreate(
|
||||
domain=f"second{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
)
|
||||
result = merchant_domain_service.add_domain(
|
||||
db, test_merchant.id, domain_data
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(first)
|
||||
|
||||
assert result.is_primary is True
|
||||
assert first.is_primary is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET DOMAINS TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainServiceGet:
|
||||
"""Test suite for getting merchant domains."""
|
||||
|
||||
def test_get_merchant_domains_success(self, db, test_merchant):
|
||||
"""Test getting all domains for a merchant."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
d1 = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"get1{unique_id}.example.com",
|
||||
verification_token=f"g1_{unique_id}",
|
||||
)
|
||||
d2 = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"get2{unique_id}.example.com",
|
||||
verification_token=f"g2_{unique_id}",
|
||||
is_primary=False,
|
||||
)
|
||||
db.add_all([d1, d2])
|
||||
db.commit()
|
||||
|
||||
domains = merchant_domain_service.get_merchant_domains(
|
||||
db, test_merchant.id
|
||||
)
|
||||
|
||||
domain_names = [d.domain for d in domains]
|
||||
assert f"get1{unique_id}.example.com" in domain_names
|
||||
assert f"get2{unique_id}.example.com" in domain_names
|
||||
|
||||
def test_get_merchant_domains_merchant_not_found(self, db):
|
||||
"""Test getting domains for non-existent merchant raises exception."""
|
||||
with pytest.raises(MerchantNotFoundException):
|
||||
merchant_domain_service.get_merchant_domains(db, 99999)
|
||||
|
||||
def test_get_domain_by_id_success(self, db, test_merchant):
|
||||
"""Test getting a domain by ID."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"byid{unique_id}.example.com",
|
||||
verification_token=f"bi_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
|
||||
result = merchant_domain_service.get_domain_by_id(db, domain.id)
|
||||
assert result.id == domain.id
|
||||
|
||||
def test_get_domain_by_id_not_found(self, db):
|
||||
"""Test getting non-existent domain raises exception."""
|
||||
with pytest.raises(MerchantDomainNotFoundException):
|
||||
merchant_domain_service.get_domain_by_id(db, 99999)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UPDATE DOMAIN TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainServiceUpdate:
|
||||
"""Test suite for updating merchant domains."""
|
||||
|
||||
def test_update_domain_set_primary(self, db, test_merchant):
|
||||
"""Test setting a domain as primary."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
d1 = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"upd1{unique_id}.example.com",
|
||||
is_primary=True,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"u1_{unique_id}",
|
||||
)
|
||||
d2 = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"upd2{unique_id}.example.com",
|
||||
is_primary=False,
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"u2_{unique_id}",
|
||||
)
|
||||
db.add_all([d1, d2])
|
||||
db.commit()
|
||||
|
||||
update_data = MerchantDomainUpdate(is_primary=True)
|
||||
result = merchant_domain_service.update_domain(
|
||||
db, d2.id, update_data
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(d1)
|
||||
|
||||
assert result.is_primary is True
|
||||
assert d1.is_primary is False
|
||||
|
||||
def test_update_domain_activate_unverified_fails(self, db, test_merchant):
|
||||
"""Test activating an unverified domain raises exception."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"unvact{unique_id}.example.com",
|
||||
is_verified=False,
|
||||
is_active=False,
|
||||
verification_token=f"ua_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
update_data = MerchantDomainUpdate(is_active=True)
|
||||
|
||||
with pytest.raises(DomainNotVerifiedException):
|
||||
merchant_domain_service.update_domain(db, domain.id, update_data)
|
||||
|
||||
def test_update_domain_activate_verified(self, db, test_merchant):
|
||||
"""Test activating a verified domain succeeds."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"veract{unique_id}.example.com",
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
is_active=False,
|
||||
verification_token=f"va_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
update_data = MerchantDomainUpdate(is_active=True)
|
||||
result = merchant_domain_service.update_domain(
|
||||
db, domain.id, update_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result.is_active is True
|
||||
|
||||
def test_update_domain_deactivate(self, db, test_merchant):
|
||||
"""Test deactivating a domain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"deact{unique_id}.example.com",
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
is_active=True,
|
||||
verification_token=f"da_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
update_data = MerchantDomainUpdate(is_active=False)
|
||||
result = merchant_domain_service.update_domain(
|
||||
db, domain.id, update_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result.is_active is False
|
||||
|
||||
def test_update_domain_not_found(self, db):
|
||||
"""Test updating non-existent domain raises exception."""
|
||||
update_data = MerchantDomainUpdate(is_primary=True)
|
||||
|
||||
with pytest.raises(MerchantDomainNotFoundException):
|
||||
merchant_domain_service.update_domain(db, 99999, update_data)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE DOMAIN TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainServiceDelete:
|
||||
"""Test suite for deleting merchant domains."""
|
||||
|
||||
def test_delete_domain_success(self, db, test_merchant):
|
||||
"""Test successfully deleting a domain."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"todel{unique_id}.example.com",
|
||||
verification_token=f"td_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
domain_id = domain.id
|
||||
|
||||
result = merchant_domain_service.delete_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
assert "deleted successfully" in result
|
||||
|
||||
with pytest.raises(MerchantDomainNotFoundException):
|
||||
merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||
|
||||
def test_delete_domain_not_found(self, db):
|
||||
"""Test deleting non-existent domain raises exception."""
|
||||
with pytest.raises(MerchantDomainNotFoundException):
|
||||
merchant_domain_service.delete_domain(db, 99999)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VERIFY DOMAIN TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainServiceVerify:
|
||||
"""Test suite for merchant domain verification."""
|
||||
|
||||
@patch("dns.resolver.resolve")
|
||||
def test_verify_domain_success(self, mock_resolve, db, test_merchant):
|
||||
"""Test successful domain verification."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"verify{unique_id}.example.com",
|
||||
is_verified=False,
|
||||
verification_token=f"vt_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
mock_txt = MagicMock()
|
||||
mock_txt.to_text.return_value = f'"vt_{unique_id}"'
|
||||
mock_resolve.return_value = [mock_txt]
|
||||
|
||||
result_domain, message = merchant_domain_service.verify_domain(
|
||||
db, domain.id
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result_domain.is_verified is True
|
||||
assert result_domain.verified_at is not None
|
||||
assert "verified successfully" in message.lower()
|
||||
|
||||
def test_verify_domain_already_verified(self, db, test_merchant):
|
||||
"""Test verifying already verified domain raises exception."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"alrver{unique_id}.example.com",
|
||||
is_verified=True,
|
||||
verified_at=datetime.now(UTC),
|
||||
verification_token=f"av_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(DomainAlreadyVerifiedException):
|
||||
merchant_domain_service.verify_domain(db, domain.id)
|
||||
|
||||
def test_verify_domain_not_found(self, db):
|
||||
"""Test verifying non-existent domain raises exception."""
|
||||
with pytest.raises(MerchantDomainNotFoundException):
|
||||
merchant_domain_service.verify_domain(db, 99999)
|
||||
|
||||
@patch("dns.resolver.resolve")
|
||||
def test_verify_domain_token_not_found(
|
||||
self, mock_resolve, db, test_merchant
|
||||
):
|
||||
"""Test verification fails when token not found in DNS."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"tnf{unique_id}.example.com",
|
||||
is_verified=False,
|
||||
verification_token=f"tnf_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
mock_txt = MagicMock()
|
||||
mock_txt.to_text.return_value = '"wrong_token"'
|
||||
mock_resolve.return_value = [mock_txt]
|
||||
|
||||
with pytest.raises(DomainVerificationFailedException) as exc_info:
|
||||
merchant_domain_service.verify_domain(db, domain.id)
|
||||
|
||||
assert "token not found" in str(exc_info.value).lower()
|
||||
|
||||
@patch("dns.resolver.resolve")
|
||||
def test_verify_domain_dns_nxdomain(
|
||||
self, mock_resolve, db, test_merchant
|
||||
):
|
||||
"""Test verification fails when DNS record doesn't exist."""
|
||||
import dns.resolver
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"nxdom{unique_id}.example.com",
|
||||
is_verified=False,
|
||||
verification_token=f"nx_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
mock_resolve.side_effect = dns.resolver.NXDOMAIN()
|
||||
|
||||
with pytest.raises(DomainVerificationFailedException):
|
||||
merchant_domain_service.verify_domain(db, domain.id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VERIFICATION INSTRUCTIONS TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantDomainServiceInstructions:
|
||||
"""Test suite for verification instructions."""
|
||||
|
||||
def test_get_verification_instructions(self, db, test_merchant):
|
||||
"""Test getting verification instructions."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
domain = MerchantDomain(
|
||||
merchant_id=test_merchant.id,
|
||||
domain=f"instr{unique_id}.example.com",
|
||||
verification_token=f"inst_{unique_id}",
|
||||
)
|
||||
db.add(domain)
|
||||
db.commit()
|
||||
|
||||
instructions = merchant_domain_service.get_verification_instructions(
|
||||
db, domain.id
|
||||
)
|
||||
|
||||
assert instructions["domain"] == f"instr{unique_id}.example.com"
|
||||
assert instructions["verification_token"] == f"inst_{unique_id}"
|
||||
assert "instructions" in instructions
|
||||
assert "txt_record" in instructions
|
||||
assert instructions["txt_record"]["type"] == "TXT"
|
||||
assert instructions["txt_record"]["name"] == "_wizamart-verify"
|
||||
assert "common_registrars" in instructions
|
||||
|
||||
def test_get_verification_instructions_not_found(self, db):
|
||||
"""Test getting instructions for non-existent domain raises exception."""
|
||||
with pytest.raises(MerchantDomainNotFoundException):
|
||||
merchant_domain_service.get_verification_instructions(db, 99999)
|
||||
Reference in New Issue
Block a user