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,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)