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:
@@ -3,7 +3,7 @@
|
|||||||
script_location = alembic
|
script_location = alembic
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = space
|
version_path_separator = space
|
||||||
version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions
|
version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/tenancy/migrations/versions
|
||||||
# This will be overridden by alembic\env.py using settings.database_url
|
# This will be overridden by alembic\env.py using settings.database_url
|
||||||
sqlalchemy.url =
|
sqlalchemy.url =
|
||||||
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ tenancy_module = ModuleDefinition(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
migrations_path="migrations",
|
||||||
services_path="app.modules.tenancy.services",
|
services_path="app.modules.tenancy.services",
|
||||||
models_path="app.modules.tenancy.models",
|
models_path="app.modules.tenancy.models",
|
||||||
schemas_path="app.modules.tenancy.schemas",
|
schemas_path="app.modules.tenancy.schemas",
|
||||||
|
|||||||
@@ -933,6 +933,43 @@ class InvalidInvitationTokenException(ValidationException):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Merchant Domain Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when a merchant domain is not found."""
|
||||||
|
|
||||||
|
def __init__(self, domain_identifier: str, identifier_type: str = "ID"):
|
||||||
|
if identifier_type.lower() == "domain":
|
||||||
|
message = f"Merchant domain '{domain_identifier}' not found"
|
||||||
|
else:
|
||||||
|
message = f"Merchant domain with ID '{domain_identifier}' not found"
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
resource_type="MerchantDomain",
|
||||||
|
identifier=domain_identifier,
|
||||||
|
message=message,
|
||||||
|
error_code="MERCHANT_DOMAIN_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainAlreadyExistsException(ConflictException):
|
||||||
|
"""Raised when trying to add a domain that already exists."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str, existing_merchant_id: int | None = None):
|
||||||
|
details = {"domain": domain}
|
||||||
|
if existing_merchant_id:
|
||||||
|
details["existing_merchant_id"] = existing_merchant_id
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
message=f"Domain '{domain}' is already registered",
|
||||||
|
error_code="MERCHANT_DOMAIN_ALREADY_EXISTS",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StoreDomainNotFoundException(ResourceNotFoundException):
|
class StoreDomainNotFoundException(ResourceNotFoundException):
|
||||||
"""Raised when a store domain is not found."""
|
"""Raised when a store domain is not found."""
|
||||||
|
|
||||||
@@ -1129,6 +1166,9 @@ __all__ = [
|
|||||||
"TeamValidationException",
|
"TeamValidationException",
|
||||||
"InvalidInvitationDataException",
|
"InvalidInvitationDataException",
|
||||||
"InvalidInvitationTokenException",
|
"InvalidInvitationTokenException",
|
||||||
|
# Merchant Domain
|
||||||
|
"MerchantDomainNotFoundException",
|
||||||
|
"MerchantDomainAlreadyExistsException",
|
||||||
# Store Domain
|
# Store Domain
|
||||||
"StoreDomainNotFoundException",
|
"StoreDomainNotFoundException",
|
||||||
"StoreDomainAlreadyExistsException",
|
"StoreDomainAlreadyExistsException",
|
||||||
|
|||||||
0
app/modules/tenancy/migrations/__init__.py
Normal file
0
app/modules/tenancy/migrations/__init__.py
Normal file
0
app/modules/tenancy/migrations/versions/__init__.py
Normal file
0
app/modules/tenancy/migrations/versions/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""tenancy: add merchant_domains table for merchant-level domain routing
|
||||||
|
|
||||||
|
Revision ID: tenancy_001
|
||||||
|
Revises: dev_tools_001
|
||||||
|
Create Date: 2026-02-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "tenancy_001"
|
||||||
|
down_revision = "dev_tools_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"merchant_domains",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"merchant_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("merchants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("domain", sa.String(255), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("ssl_status", sa.String(50), server_default="pending"),
|
||||||
|
sa.Column("ssl_verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("verification_token", sa.String(100), unique=True, nullable=True),
|
||||||
|
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"platform_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("platforms.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Platform this domain is associated with (for platform context resolution)",
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_domain_platform"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"idx_merchant_domain_active", "merchant_domains", ["domain", "is_active"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_merchant_domain_primary", "merchant_domains", ["merchant_id", "is_primary"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_merchant_domain_platform", "merchant_domains", ["platform_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_merchant_domain_platform", table_name="merchant_domains")
|
||||||
|
op.drop_index("idx_merchant_domain_primary", table_name="merchant_domains")
|
||||||
|
op.drop_index("idx_merchant_domain_active", table_name="merchant_domains")
|
||||||
|
op.drop_table("merchant_domains")
|
||||||
@@ -34,6 +34,7 @@ from app.modules.tenancy.models.platform import Platform
|
|||||||
from app.modules.tenancy.models.platform_module import PlatformModule
|
from app.modules.tenancy.models.platform_module import PlatformModule
|
||||||
from app.modules.tenancy.models.user import User, UserRole
|
from app.modules.tenancy.models.user import User, UserRole
|
||||||
from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserType
|
from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserType
|
||||||
|
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||||
from app.modules.tenancy.models.store_domain import StoreDomain
|
from app.modules.tenancy.models.store_domain import StoreDomain
|
||||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ __all__ = [
|
|||||||
"StoreUser",
|
"StoreUser",
|
||||||
"StoreUserType",
|
"StoreUserType",
|
||||||
"Role",
|
"Role",
|
||||||
|
# Merchant configuration
|
||||||
|
"MerchantDomain",
|
||||||
# Store configuration
|
# Store configuration
|
||||||
"StoreDomain",
|
"StoreDomain",
|
||||||
"StorePlatform",
|
"StorePlatform",
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ class Merchant(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
"""All store brands operated by this merchant."""
|
"""All store brands operated by this merchant."""
|
||||||
|
|
||||||
|
merchant_domains = relationship(
|
||||||
|
"MerchantDomain",
|
||||||
|
back_populates="merchant",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
"""Custom domains registered at the merchant level (inherited by all stores)."""
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""String representation of the Merchant object."""
|
"""String representation of the Merchant object."""
|
||||||
return f"<Merchant(id={self.id}, name='{self.name}', stores={len(self.stores) if self.stores else 0})>"
|
return f"<Merchant(id={self.id}, name='{self.name}', stores={len(self.stores) if self.stores else 0})>"
|
||||||
@@ -98,6 +105,14 @@ class Merchant(Base, TimestampMixin):
|
|||||||
"""Get the number of stores belonging to this merchant."""
|
"""Get the number of stores belonging to this merchant."""
|
||||||
return len(self.stores) if self.stores else 0
|
return len(self.stores) if self.stores else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_domain(self) -> str | None:
|
||||||
|
"""Get the primary active and verified merchant domain."""
|
||||||
|
for md in self.merchant_domains:
|
||||||
|
if md.is_primary and md.is_active and md.is_verified:
|
||||||
|
return md.domain
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_store_count(self) -> int:
|
def active_store_count(self) -> int:
|
||||||
"""Get the number of active stores belonging to this merchant."""
|
"""Get the number of active stores belonging to this merchant."""
|
||||||
|
|||||||
117
app/modules/tenancy/models/merchant_domain.py
Normal file
117
app/modules/tenancy/models/merchant_domain.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# app/modules/tenancy/models/merchant_domain.py
|
||||||
|
"""
|
||||||
|
Merchant Domain Model - Maps custom domains to merchants for merchant-level domain routing.
|
||||||
|
|
||||||
|
When a merchant subscribes to a platform (e.g., loyalty), they can register a domain
|
||||||
|
(e.g., myloyaltyprogram.lu) that serves as the default for all their stores.
|
||||||
|
Individual stores can optionally override this with their own custom StoreDomain.
|
||||||
|
|
||||||
|
Domain Resolution Priority:
|
||||||
|
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||||
|
2. Merchant domain (MerchantDomain) -> inherited default
|
||||||
|
3. Store subdomain ({store.subdomain}.loyalty.lu) -> fallback
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomain(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Maps custom domains to merchants for merchant-level domain routing.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- myloyaltyprogram.lu -> Merchant "WizaCorp" (all stores inherit)
|
||||||
|
- Store WIZAMART overrides with StoreDomain -> mysuperloyaltyprogram.lu
|
||||||
|
- Store WIZAGADGETS -> inherits myloyaltyprogram.lu
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "merchant_domains"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
merchant_id = Column(
|
||||||
|
Integer, ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Domain configuration
|
||||||
|
domain = Column(String(255), nullable=False, unique=True, index=True)
|
||||||
|
is_primary = Column(Boolean, default=True, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# SSL/TLS status (for monitoring)
|
||||||
|
ssl_status = Column(
|
||||||
|
String(50), default="pending"
|
||||||
|
) # pending, active, expired, error
|
||||||
|
ssl_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# DNS verification (to confirm domain ownership)
|
||||||
|
verification_token = Column(String(100), unique=True, nullable=True)
|
||||||
|
is_verified = Column(Boolean, default=False, nullable=False)
|
||||||
|
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Platform association (for platform context resolution from custom domains)
|
||||||
|
platform_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("platforms.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Platform this domain is associated with (for platform context resolution)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
merchant = relationship("Merchant", back_populates="merchant_domains")
|
||||||
|
platform = relationship("Platform")
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_domain_platform"),
|
||||||
|
Index("idx_merchant_domain_active", "domain", "is_active"),
|
||||||
|
Index("idx_merchant_domain_primary", "merchant_id", "is_primary"),
|
||||||
|
Index("idx_merchant_domain_platform", "platform_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<MerchantDomain(domain='{self.domain}', merchant_id={self.merchant_id})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_url(self):
|
||||||
|
"""Return full URL with https"""
|
||||||
|
return f"https://{self.domain}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_domain(cls, domain: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize domain for consistent storage.
|
||||||
|
|
||||||
|
Reuses the same logic as StoreDomain.normalize_domain().
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- https://example.com -> example.com
|
||||||
|
- www.example.com -> example.com
|
||||||
|
- EXAMPLE.COM -> example.com
|
||||||
|
"""
|
||||||
|
# Remove protocol
|
||||||
|
domain = domain.replace("https://", "").replace("http://", "") # noqa: SEC-034
|
||||||
|
|
||||||
|
# Remove trailing slash
|
||||||
|
domain = domain.rstrip("/")
|
||||||
|
|
||||||
|
# Convert to lowercase
|
||||||
|
domain = domain.lower()
|
||||||
|
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["MerchantDomain"]
|
||||||
@@ -329,6 +329,22 @@ class Store(Base, TimestampMixin):
|
|||||||
return domain.domain # Return the domain if it's primary and active
|
return domain.domain # Return the domain if it's primary and active
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_domain(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Get effective domain: store override > merchant domain > subdomain fallback.
|
||||||
|
|
||||||
|
Domain Resolution Priority:
|
||||||
|
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||||
|
2. Merchant domain (MerchantDomain) -> inherited default
|
||||||
|
3. Store subdomain ({store.subdomain}.{platform_domain}) -> fallback
|
||||||
|
"""
|
||||||
|
if self.primary_domain:
|
||||||
|
return self.primary_domain
|
||||||
|
if self.merchant and self.merchant.primary_domain:
|
||||||
|
return self.merchant.primary_domain
|
||||||
|
return f"{self.subdomain}.{settings.platform_domain}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all_domains(self):
|
def all_domains(self):
|
||||||
"""Get all active domains (subdomain + custom domains)."""
|
"""Get all active domains (subdomain + custom domains)."""
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .admin_merchants import admin_merchants_router
|
|||||||
from .admin_platforms import admin_platforms_router
|
from .admin_platforms import admin_platforms_router
|
||||||
from .admin_stores import admin_stores_router
|
from .admin_stores import admin_stores_router
|
||||||
from .admin_store_domains import admin_store_domains_router
|
from .admin_store_domains import admin_store_domains_router
|
||||||
|
from .admin_merchant_domains import admin_merchant_domains_router
|
||||||
from .admin_modules import router as admin_modules_router
|
from .admin_modules import router as admin_modules_router
|
||||||
from .admin_module_config import router as admin_module_config_router
|
from .admin_module_config import router as admin_module_config_router
|
||||||
|
|
||||||
@@ -38,5 +39,6 @@ admin_router.include_router(admin_merchants_router, tags=["admin-merchants"])
|
|||||||
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
|
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
|
||||||
admin_router.include_router(admin_stores_router, tags=["admin-stores"])
|
admin_router.include_router(admin_stores_router, tags=["admin-stores"])
|
||||||
admin_router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
|
admin_router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
|
||||||
|
admin_router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"])
|
||||||
admin_router.include_router(admin_modules_router, tags=["admin-modules"])
|
admin_router.include_router(admin_modules_router, tags=["admin-modules"])
|
||||||
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])
|
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])
|
||||||
|
|||||||
297
app/modules/tenancy/routes/api/admin_merchant_domains.py
Normal file
297
app/modules/tenancy/routes/api/admin_merchant_domains.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# app/modules/tenancy/routes/api/admin_merchant_domains.py
|
||||||
|
"""
|
||||||
|
Admin endpoints for managing merchant-level custom domains.
|
||||||
|
|
||||||
|
Follows the same pattern as admin_store_domains.py:
|
||||||
|
- Endpoints only handle HTTP layer
|
||||||
|
- Business logic in service layer
|
||||||
|
- Domain exceptions bubble up to global handler
|
||||||
|
- Pydantic schemas for validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Path
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_admin_api
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.tenancy.services.merchant_domain_service import (
|
||||||
|
merchant_domain_service,
|
||||||
|
)
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
from app.modules.tenancy.schemas.merchant_domain import (
|
||||||
|
MerchantDomainCreate,
|
||||||
|
MerchantDomainDeletionResponse,
|
||||||
|
MerchantDomainListResponse,
|
||||||
|
MerchantDomainResponse,
|
||||||
|
MerchantDomainUpdate,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.schemas.store_domain import (
|
||||||
|
DomainVerificationInstructions,
|
||||||
|
DomainVerificationResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_merchant_domains_router = APIRouter(prefix="/merchants")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.post(
|
||||||
|
"/{merchant_id}/domains", response_model=MerchantDomainResponse
|
||||||
|
)
|
||||||
|
def add_merchant_domain(
|
||||||
|
merchant_id: int = Path(..., description="Merchant ID", gt=0),
|
||||||
|
domain_data: MerchantDomainCreate = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Register a merchant-level domain (Admin only).
|
||||||
|
|
||||||
|
The domain serves as the default for all the merchant's stores.
|
||||||
|
Individual stores can override with their own StoreDomain.
|
||||||
|
|
||||||
|
**Domain Resolution Priority:**
|
||||||
|
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||||
|
2. Merchant domain (MerchantDomain) -> inherited default
|
||||||
|
3. Store subdomain ({store.subdomain}.platform.lu) -> fallback
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Merchant not found
|
||||||
|
- 409: Domain already registered
|
||||||
|
- 422: Invalid domain format or reserved subdomain
|
||||||
|
"""
|
||||||
|
domain = merchant_domain_service.add_domain(
|
||||||
|
db=db, merchant_id=merchant_id, domain_data=domain_data
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return MerchantDomainResponse(
|
||||||
|
id=domain.id,
|
||||||
|
merchant_id=domain.merchant_id,
|
||||||
|
domain=domain.domain,
|
||||||
|
is_primary=domain.is_primary,
|
||||||
|
is_active=domain.is_active,
|
||||||
|
is_verified=domain.is_verified,
|
||||||
|
ssl_status=domain.ssl_status,
|
||||||
|
platform_id=domain.platform_id,
|
||||||
|
verification_token=domain.verification_token,
|
||||||
|
verified_at=domain.verified_at,
|
||||||
|
ssl_verified_at=domain.ssl_verified_at,
|
||||||
|
created_at=domain.created_at,
|
||||||
|
updated_at=domain.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.get(
|
||||||
|
"/{merchant_id}/domains", response_model=MerchantDomainListResponse
|
||||||
|
)
|
||||||
|
def list_merchant_domains(
|
||||||
|
merchant_id: int = Path(..., description="Merchant ID", gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all domains for a merchant (Admin only).
|
||||||
|
|
||||||
|
Returns domains ordered by:
|
||||||
|
1. Primary domains first
|
||||||
|
2. Creation date (newest first)
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Merchant not found
|
||||||
|
"""
|
||||||
|
domains = merchant_domain_service.get_merchant_domains(db, merchant_id)
|
||||||
|
|
||||||
|
return MerchantDomainListResponse(
|
||||||
|
domains=[
|
||||||
|
MerchantDomainResponse(
|
||||||
|
id=d.id,
|
||||||
|
merchant_id=d.merchant_id,
|
||||||
|
domain=d.domain,
|
||||||
|
is_primary=d.is_primary,
|
||||||
|
is_active=d.is_active,
|
||||||
|
is_verified=d.is_verified,
|
||||||
|
ssl_status=d.ssl_status,
|
||||||
|
platform_id=d.platform_id,
|
||||||
|
verification_token=d.verification_token if not d.is_verified else None,
|
||||||
|
verified_at=d.verified_at,
|
||||||
|
ssl_verified_at=d.ssl_verified_at,
|
||||||
|
created_at=d.created_at,
|
||||||
|
updated_at=d.updated_at,
|
||||||
|
)
|
||||||
|
for d in domains
|
||||||
|
],
|
||||||
|
total=len(domains),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.get(
|
||||||
|
"/domains/merchant/{domain_id}", response_model=MerchantDomainResponse
|
||||||
|
)
|
||||||
|
def get_merchant_domain_details(
|
||||||
|
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed information about a specific merchant domain (Admin only).
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Domain not found
|
||||||
|
"""
|
||||||
|
domain = merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||||
|
|
||||||
|
return MerchantDomainResponse(
|
||||||
|
id=domain.id,
|
||||||
|
merchant_id=domain.merchant_id,
|
||||||
|
domain=domain.domain,
|
||||||
|
is_primary=domain.is_primary,
|
||||||
|
is_active=domain.is_active,
|
||||||
|
is_verified=domain.is_verified,
|
||||||
|
ssl_status=domain.ssl_status,
|
||||||
|
platform_id=domain.platform_id,
|
||||||
|
verification_token=(
|
||||||
|
domain.verification_token if not domain.is_verified else None
|
||||||
|
),
|
||||||
|
verified_at=domain.verified_at,
|
||||||
|
ssl_verified_at=domain.ssl_verified_at,
|
||||||
|
created_at=domain.created_at,
|
||||||
|
updated_at=domain.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.put(
|
||||||
|
"/domains/merchant/{domain_id}", response_model=MerchantDomainResponse
|
||||||
|
)
|
||||||
|
def update_merchant_domain(
|
||||||
|
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||||
|
domain_update: MerchantDomainUpdate = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update merchant domain settings (Admin only).
|
||||||
|
|
||||||
|
**Can update:**
|
||||||
|
- `is_primary`: Set as primary domain for merchant
|
||||||
|
- `is_active`: Activate or deactivate domain
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- Cannot activate unverified domains
|
||||||
|
- Setting a domain as primary will unset other primary domains
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Domain not found
|
||||||
|
- 400: Cannot activate unverified domain
|
||||||
|
"""
|
||||||
|
domain = merchant_domain_service.update_domain(
|
||||||
|
db=db, domain_id=domain_id, domain_update=domain_update
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return MerchantDomainResponse(
|
||||||
|
id=domain.id,
|
||||||
|
merchant_id=domain.merchant_id,
|
||||||
|
domain=domain.domain,
|
||||||
|
is_primary=domain.is_primary,
|
||||||
|
is_active=domain.is_active,
|
||||||
|
is_verified=domain.is_verified,
|
||||||
|
ssl_status=domain.ssl_status,
|
||||||
|
platform_id=domain.platform_id,
|
||||||
|
verification_token=None,
|
||||||
|
verified_at=domain.verified_at,
|
||||||
|
ssl_verified_at=domain.ssl_verified_at,
|
||||||
|
created_at=domain.created_at,
|
||||||
|
updated_at=domain.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.delete(
|
||||||
|
"/domains/merchant/{domain_id}",
|
||||||
|
response_model=MerchantDomainDeletionResponse,
|
||||||
|
)
|
||||||
|
def delete_merchant_domain(
|
||||||
|
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a merchant domain (Admin only).
|
||||||
|
|
||||||
|
**Warning:** This is permanent and cannot be undone.
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Domain not found
|
||||||
|
"""
|
||||||
|
domain = merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||||
|
merchant_id = domain.merchant_id
|
||||||
|
domain_name = domain.domain
|
||||||
|
|
||||||
|
message = merchant_domain_service.delete_domain(db, domain_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return MerchantDomainDeletionResponse(
|
||||||
|
message=message, domain=domain_name, merchant_id=merchant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.post(
|
||||||
|
"/domains/merchant/{domain_id}/verify",
|
||||||
|
response_model=DomainVerificationResponse,
|
||||||
|
)
|
||||||
|
def verify_merchant_domain_ownership(
|
||||||
|
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify merchant domain ownership via DNS TXT record (Admin only).
|
||||||
|
|
||||||
|
**Verification Process:**
|
||||||
|
1. Queries DNS for TXT record: `_wizamart-verify.{domain}`
|
||||||
|
2. Checks if verification token matches
|
||||||
|
3. If found, marks domain as verified
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Domain not found
|
||||||
|
- 400: Already verified, or verification failed
|
||||||
|
- 502: DNS query failed
|
||||||
|
"""
|
||||||
|
domain, message = merchant_domain_service.verify_domain(db, domain_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return DomainVerificationResponse(
|
||||||
|
message=message,
|
||||||
|
domain=domain.domain,
|
||||||
|
verified_at=domain.verified_at,
|
||||||
|
is_verified=domain.is_verified,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_merchant_domains_router.get(
|
||||||
|
"/domains/merchant/{domain_id}/verification-instructions",
|
||||||
|
response_model=DomainVerificationInstructions,
|
||||||
|
)
|
||||||
|
def get_merchant_domain_verification_instructions(
|
||||||
|
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get DNS verification instructions for merchant domain (Admin only).
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
- 404: Domain not found
|
||||||
|
"""
|
||||||
|
instructions = merchant_domain_service.get_verification_instructions(
|
||||||
|
db, domain_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return DomainVerificationInstructions(
|
||||||
|
domain=instructions["domain"],
|
||||||
|
verification_token=instructions["verification_token"],
|
||||||
|
instructions=instructions["instructions"],
|
||||||
|
txt_record=instructions["txt_record"],
|
||||||
|
common_registrars=instructions["common_registrars"],
|
||||||
|
)
|
||||||
109
app/modules/tenancy/schemas/merchant_domain.py
Normal file
109
app/modules/tenancy/schemas/merchant_domain.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# app/modules/tenancy/schemas/merchant_domain.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for Merchant Domain operations.
|
||||||
|
|
||||||
|
Schemas include:
|
||||||
|
- MerchantDomainCreate: For adding custom domains to merchants
|
||||||
|
- MerchantDomainUpdate: For updating domain settings
|
||||||
|
- MerchantDomainResponse: Standard domain response
|
||||||
|
- MerchantDomainListResponse: Paginated domain list
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainCreate(BaseModel):
|
||||||
|
"""Schema for adding a custom domain to a merchant."""
|
||||||
|
|
||||||
|
domain: str = Field(
|
||||||
|
...,
|
||||||
|
description="Custom domain (e.g., myloyaltyprogram.lu)",
|
||||||
|
min_length=3,
|
||||||
|
max_length=255,
|
||||||
|
)
|
||||||
|
is_primary: bool = Field(
|
||||||
|
default=True, description="Set as primary domain for the merchant"
|
||||||
|
)
|
||||||
|
platform_id: int | None = Field(None, description="Platform this domain belongs to")
|
||||||
|
|
||||||
|
@field_validator("domain")
|
||||||
|
@classmethod
|
||||||
|
def validate_domain(cls, v: str) -> str:
|
||||||
|
"""Validate and normalize domain."""
|
||||||
|
# Remove protocol if present
|
||||||
|
domain = v.replace("https://", "").replace("http://", "") # noqa: SEC-034
|
||||||
|
|
||||||
|
# Remove trailing slash
|
||||||
|
domain = domain.rstrip("/")
|
||||||
|
|
||||||
|
# Convert to lowercase
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if not domain or "/" in domain:
|
||||||
|
raise ValueError("Invalid domain format")
|
||||||
|
|
||||||
|
if "." not in domain:
|
||||||
|
raise ValueError("Domain must have at least one dot")
|
||||||
|
|
||||||
|
# Check for reserved subdomains
|
||||||
|
reserved = ["www", "admin", "api", "mail", "smtp", "ftp", "cpanel", "webmail"]
|
||||||
|
first_part = domain.split(".")[0]
|
||||||
|
if first_part in reserved:
|
||||||
|
raise ValueError(
|
||||||
|
f"Domain cannot start with reserved subdomain: {first_part}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate domain format (basic regex)
|
||||||
|
domain_pattern = r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
|
||||||
|
if not re.match(domain_pattern, domain):
|
||||||
|
raise ValueError("Invalid domain format")
|
||||||
|
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainUpdate(BaseModel):
|
||||||
|
"""Schema for updating merchant domain settings."""
|
||||||
|
|
||||||
|
is_primary: bool | None = Field(None, description="Set as primary domain")
|
||||||
|
is_active: bool | None = Field(None, description="Activate or deactivate domain")
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainResponse(BaseModel):
|
||||||
|
"""Standard schema for merchant domain response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
merchant_id: int
|
||||||
|
domain: str
|
||||||
|
is_primary: bool
|
||||||
|
is_active: bool
|
||||||
|
is_verified: bool
|
||||||
|
ssl_status: str
|
||||||
|
platform_id: int | None = None
|
||||||
|
verification_token: str | None = None
|
||||||
|
verified_at: datetime | None = None
|
||||||
|
ssl_verified_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainListResponse(BaseModel):
|
||||||
|
"""Schema for paginated merchant domain list."""
|
||||||
|
|
||||||
|
domains: list[MerchantDomainResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainDeletionResponse(BaseModel):
|
||||||
|
"""Response after merchant domain deletion."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
domain: str
|
||||||
|
merchant_id: int
|
||||||
452
app/modules/tenancy/services/merchant_domain_service.py
Normal file
452
app/modules/tenancy/services/merchant_domain_service.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# app/modules/tenancy/services/merchant_domain_service.py
|
||||||
|
"""
|
||||||
|
Merchant domain service for managing merchant-level custom domain operations.
|
||||||
|
|
||||||
|
This module provides classes and functions for:
|
||||||
|
- Adding and removing merchant domains
|
||||||
|
- Domain verification via DNS
|
||||||
|
- Domain activation and deactivation
|
||||||
|
- Setting primary domains
|
||||||
|
- Domain validation and normalization
|
||||||
|
|
||||||
|
Follows the same pattern as StoreDomainService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import ValidationException
|
||||||
|
from app.modules.tenancy.exceptions import (
|
||||||
|
DNSVerificationException,
|
||||||
|
DomainAlreadyVerifiedException,
|
||||||
|
DomainNotVerifiedException,
|
||||||
|
DomainVerificationFailedException,
|
||||||
|
InvalidDomainFormatException,
|
||||||
|
MaxDomainsReachedException,
|
||||||
|
MerchantDomainAlreadyExistsException,
|
||||||
|
MerchantDomainNotFoundException,
|
||||||
|
MerchantNotFoundException,
|
||||||
|
ReservedDomainException,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.models import Merchant
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDomainService:
|
||||||
|
"""Service class for merchant domain operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.max_domains_per_merchant = 5
|
||||||
|
self.reserved_subdomains = [
|
||||||
|
"www",
|
||||||
|
"admin",
|
||||||
|
"api",
|
||||||
|
"mail",
|
||||||
|
"smtp",
|
||||||
|
"ftp",
|
||||||
|
"cpanel",
|
||||||
|
"webmail",
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_domain(
|
||||||
|
self, db: Session, merchant_id: int, domain_data: MerchantDomainCreate
|
||||||
|
) -> MerchantDomain:
|
||||||
|
"""
|
||||||
|
Add a custom domain to a merchant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
merchant_id: Merchant ID to add domain to
|
||||||
|
domain_data: Domain creation data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created MerchantDomain object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MerchantNotFoundException: If merchant not found
|
||||||
|
MerchantDomainAlreadyExistsException: If domain already registered
|
||||||
|
MaxDomainsReachedException: If merchant has reached max domains
|
||||||
|
InvalidDomainFormatException: If domain format is invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify merchant exists
|
||||||
|
merchant = self._get_merchant_by_id_or_raise(db, merchant_id)
|
||||||
|
|
||||||
|
# Check domain limit
|
||||||
|
self._check_domain_limit(db, merchant_id)
|
||||||
|
|
||||||
|
# Normalize domain
|
||||||
|
normalized_domain = MerchantDomain.normalize_domain(domain_data.domain)
|
||||||
|
|
||||||
|
# Validate domain format
|
||||||
|
self._validate_domain_format(normalized_domain)
|
||||||
|
|
||||||
|
# Check if domain already exists (in both StoreDomain and MerchantDomain)
|
||||||
|
if self._domain_exists_globally(db, normalized_domain):
|
||||||
|
raise MerchantDomainAlreadyExistsException(normalized_domain)
|
||||||
|
|
||||||
|
# If setting as primary, unset other primary domains
|
||||||
|
if domain_data.is_primary:
|
||||||
|
self._unset_primary_domains(db, merchant_id)
|
||||||
|
|
||||||
|
# Resolve platform_id: use provided value, or auto-resolve from merchant's primary StorePlatform
|
||||||
|
platform_id = domain_data.platform_id
|
||||||
|
if not platform_id:
|
||||||
|
from app.modules.tenancy.models import Store, StorePlatform
|
||||||
|
# Get platform from merchant's first store's primary StorePlatform
|
||||||
|
store_ids = (
|
||||||
|
db.query(Store.id)
|
||||||
|
.filter(Store.merchant_id == merchant_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
primary_sp = (
|
||||||
|
db.query(StorePlatform)
|
||||||
|
.filter(
|
||||||
|
StorePlatform.store_id.in_(store_ids),
|
||||||
|
StorePlatform.is_primary.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
platform_id = primary_sp.platform_id if primary_sp else None
|
||||||
|
|
||||||
|
# Create domain record
|
||||||
|
new_domain = MerchantDomain(
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
domain=normalized_domain,
|
||||||
|
is_primary=domain_data.is_primary,
|
||||||
|
platform_id=platform_id,
|
||||||
|
verification_token=secrets.token_urlsafe(32),
|
||||||
|
is_verified=False,
|
||||||
|
is_active=False,
|
||||||
|
ssl_status="pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_domain)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(new_domain)
|
||||||
|
|
||||||
|
logger.info(f"Domain {normalized_domain} added to merchant {merchant_id}")
|
||||||
|
return new_domain
|
||||||
|
|
||||||
|
except (
|
||||||
|
MerchantNotFoundException,
|
||||||
|
MerchantDomainAlreadyExistsException,
|
||||||
|
MaxDomainsReachedException,
|
||||||
|
InvalidDomainFormatException,
|
||||||
|
ReservedDomainException,
|
||||||
|
):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding merchant domain: {str(e)}")
|
||||||
|
raise ValidationException("Failed to add merchant domain")
|
||||||
|
|
||||||
|
def get_merchant_domains(
|
||||||
|
self, db: Session, merchant_id: int
|
||||||
|
) -> list[MerchantDomain]:
|
||||||
|
"""
|
||||||
|
Get all domains for a merchant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
merchant_id: Merchant ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of MerchantDomain objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._get_merchant_by_id_or_raise(db, merchant_id)
|
||||||
|
|
||||||
|
domains = (
|
||||||
|
db.query(MerchantDomain)
|
||||||
|
.filter(MerchantDomain.merchant_id == merchant_id)
|
||||||
|
.order_by(
|
||||||
|
MerchantDomain.is_primary.desc(),
|
||||||
|
MerchantDomain.created_at.desc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return domains
|
||||||
|
|
||||||
|
except MerchantNotFoundException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting merchant domains: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve merchant domains")
|
||||||
|
|
||||||
|
def get_domain_by_id(self, db: Session, domain_id: int) -> MerchantDomain:
|
||||||
|
"""
|
||||||
|
Get merchant domain by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
domain_id: Domain ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MerchantDomain object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MerchantDomainNotFoundException: If domain not found
|
||||||
|
"""
|
||||||
|
domain = (
|
||||||
|
db.query(MerchantDomain)
|
||||||
|
.filter(MerchantDomain.id == domain_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not domain:
|
||||||
|
raise MerchantDomainNotFoundException(str(domain_id))
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def update_domain(
|
||||||
|
self, db: Session, domain_id: int, domain_update: MerchantDomainUpdate
|
||||||
|
) -> MerchantDomain:
|
||||||
|
"""
|
||||||
|
Update merchant domain settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
domain_id: Domain ID
|
||||||
|
domain_update: Update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated MerchantDomain object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MerchantDomainNotFoundException: If domain not found
|
||||||
|
DomainNotVerifiedException: If trying to activate unverified domain
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
domain = self.get_domain_by_id(db, domain_id)
|
||||||
|
|
||||||
|
# If setting as primary, unset other primary domains
|
||||||
|
if domain_update.is_primary:
|
||||||
|
self._unset_primary_domains(
|
||||||
|
db, domain.merchant_id, exclude_domain_id=domain_id
|
||||||
|
)
|
||||||
|
domain.is_primary = True
|
||||||
|
|
||||||
|
# If activating, check verification
|
||||||
|
if domain_update.is_active is True and not domain.is_verified:
|
||||||
|
raise DomainNotVerifiedException(domain_id, domain.domain)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if domain_update.is_active is not None:
|
||||||
|
domain.is_active = domain_update.is_active
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
db.refresh(domain)
|
||||||
|
|
||||||
|
logger.info(f"Merchant domain {domain.domain} updated")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
except (MerchantDomainNotFoundException, DomainNotVerifiedException):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating merchant domain: {str(e)}")
|
||||||
|
raise ValidationException("Failed to update merchant domain")
|
||||||
|
|
||||||
|
def delete_domain(self, db: Session, domain_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Delete a merchant domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
domain_id: Domain ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
domain = self.get_domain_by_id(db, domain_id)
|
||||||
|
domain_name = domain.domain
|
||||||
|
merchant_id = domain.merchant_id
|
||||||
|
|
||||||
|
db.delete(domain)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Domain {domain_name} deleted from merchant {merchant_id}"
|
||||||
|
)
|
||||||
|
return f"Domain {domain_name} deleted successfully"
|
||||||
|
|
||||||
|
except MerchantDomainNotFoundException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting merchant domain: {str(e)}")
|
||||||
|
raise ValidationException("Failed to delete merchant domain")
|
||||||
|
|
||||||
|
def verify_domain(
|
||||||
|
self, db: Session, domain_id: int
|
||||||
|
) -> tuple[MerchantDomain, str]:
|
||||||
|
"""
|
||||||
|
Verify merchant domain ownership via DNS TXT record.
|
||||||
|
|
||||||
|
The merchant must add a TXT record:
|
||||||
|
Name: _wizamart-verify.{domain}
|
||||||
|
Value: {verification_token}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
domain = self.get_domain_by_id(db, domain_id)
|
||||||
|
|
||||||
|
if domain.is_verified:
|
||||||
|
raise DomainAlreadyVerifiedException(domain_id, domain.domain)
|
||||||
|
|
||||||
|
try:
|
||||||
|
txt_records = dns.resolver.resolve(
|
||||||
|
f"_wizamart-verify.{domain.domain}", "TXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
for txt in txt_records:
|
||||||
|
txt_value = txt.to_text().strip('"')
|
||||||
|
if txt_value == domain.verification_token:
|
||||||
|
domain.is_verified = True
|
||||||
|
domain.verified_at = datetime.now(UTC)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(domain)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Merchant domain {domain.domain} verified successfully"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
domain,
|
||||||
|
f"Domain {domain.domain} verified successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
raise DomainVerificationFailedException(
|
||||||
|
domain.domain,
|
||||||
|
"Verification token not found in DNS records",
|
||||||
|
)
|
||||||
|
|
||||||
|
except dns.resolver.NXDOMAIN:
|
||||||
|
raise DomainVerificationFailedException(
|
||||||
|
domain.domain,
|
||||||
|
f"DNS record _wizamart-verify.{domain.domain} not found",
|
||||||
|
)
|
||||||
|
except dns.resolver.NoAnswer:
|
||||||
|
raise DomainVerificationFailedException(
|
||||||
|
domain.domain, "No TXT records found for verification"
|
||||||
|
)
|
||||||
|
except DomainVerificationFailedException:
|
||||||
|
raise
|
||||||
|
except Exception as dns_error:
|
||||||
|
raise DNSVerificationException(domain.domain, str(dns_error))
|
||||||
|
|
||||||
|
except (
|
||||||
|
MerchantDomainNotFoundException,
|
||||||
|
DomainAlreadyVerifiedException,
|
||||||
|
DomainVerificationFailedException,
|
||||||
|
DNSVerificationException,
|
||||||
|
):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying merchant domain: {str(e)}")
|
||||||
|
raise ValidationException("Failed to verify merchant domain")
|
||||||
|
|
||||||
|
def get_verification_instructions(self, db: Session, domain_id: int) -> dict:
|
||||||
|
"""Get DNS verification instructions for a merchant domain."""
|
||||||
|
domain = self.get_domain_by_id(db, domain_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"domain": domain.domain,
|
||||||
|
"verification_token": domain.verification_token,
|
||||||
|
"instructions": {
|
||||||
|
"step1": "Go to your domain's DNS settings (at your domain registrar)",
|
||||||
|
"step2": "Add a new TXT record with the following values:",
|
||||||
|
"step3": "Wait for DNS propagation (5-15 minutes)",
|
||||||
|
"step4": "Click 'Verify Domain' button in admin panel",
|
||||||
|
},
|
||||||
|
"txt_record": {
|
||||||
|
"type": "TXT",
|
||||||
|
"name": "_wizamart-verify",
|
||||||
|
"value": domain.verification_token,
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
"common_registrars": {
|
||||||
|
"Cloudflare": "https://dash.cloudflare.com",
|
||||||
|
"GoDaddy": "https://dcc.godaddy.com/manage/dns",
|
||||||
|
"Namecheap": "https://www.namecheap.com/myaccount/domain-list/",
|
||||||
|
"Google Domains": "https://domains.google.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
def _get_merchant_by_id_or_raise(
|
||||||
|
self, db: Session, merchant_id: int
|
||||||
|
) -> Merchant:
|
||||||
|
"""Get merchant by ID or raise exception."""
|
||||||
|
merchant = (
|
||||||
|
db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||||
|
)
|
||||||
|
if not merchant:
|
||||||
|
raise MerchantNotFoundException(merchant_id, identifier_type="id")
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
def _check_domain_limit(self, db: Session, merchant_id: int) -> None:
|
||||||
|
"""Check if merchant has reached maximum domain limit."""
|
||||||
|
domain_count = (
|
||||||
|
db.query(MerchantDomain)
|
||||||
|
.filter(MerchantDomain.merchant_id == merchant_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
if domain_count >= self.max_domains_per_merchant:
|
||||||
|
raise MaxDomainsReachedException(
|
||||||
|
merchant_id, self.max_domains_per_merchant
|
||||||
|
)
|
||||||
|
|
||||||
|
def _domain_exists_globally(self, db: Session, domain: str) -> bool:
|
||||||
|
"""Check if domain already exists in system (StoreDomain or MerchantDomain)."""
|
||||||
|
store_exists = (
|
||||||
|
db.query(StoreDomain)
|
||||||
|
.filter(StoreDomain.domain == domain)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if store_exists:
|
||||||
|
return True
|
||||||
|
|
||||||
|
merchant_exists = (
|
||||||
|
db.query(MerchantDomain)
|
||||||
|
.filter(MerchantDomain.domain == domain)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
return merchant_exists
|
||||||
|
|
||||||
|
def _validate_domain_format(self, domain: str) -> None:
|
||||||
|
"""Validate domain format and check for reserved subdomains."""
|
||||||
|
first_part = domain.split(".")[0]
|
||||||
|
if first_part in self.reserved_subdomains:
|
||||||
|
raise ReservedDomainException(domain, first_part)
|
||||||
|
|
||||||
|
def _unset_primary_domains(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
exclude_domain_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Unset all primary domains for merchant."""
|
||||||
|
query = db.query(MerchantDomain).filter(
|
||||||
|
MerchantDomain.merchant_id == merchant_id,
|
||||||
|
MerchantDomain.is_primary == True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if exclude_domain_id:
|
||||||
|
query = query.filter(MerchantDomain.id != exclude_domain_id)
|
||||||
|
|
||||||
|
query.update({"is_primary": False})
|
||||||
|
|
||||||
|
|
||||||
|
# Create service instance
|
||||||
|
merchant_domain_service = MerchantDomainService()
|
||||||
@@ -181,11 +181,19 @@ or **subdomains** of `loyalty.lu` (from `Store.subdomain`).
|
|||||||
|
|
||||||
### URL Routing Summary
|
### URL Routing Summary
|
||||||
|
|
||||||
| Routing mode | Pattern | Example |
|
| Routing mode | Priority | Pattern | Example |
|
||||||
|-------------|---------|---------|
|
|-------------|----------|---------|---------|
|
||||||
| Platform domain | `loyalty.lu/...` | Admin pages, public API |
|
| Platform domain | — | `loyalty.lu/...` | Admin pages, public API |
|
||||||
| Custom domain | `{custom_domain}/...` | All store pages (store has custom domain) |
|
| Store custom domain | 1 (highest) | `{custom_domain}/...` | Store with its own domain (overrides merchant domain) |
|
||||||
| Store subdomain | `{store_code}.loyalty.lu/...` | All store pages (no custom domain) |
|
| Merchant domain | 2 | `{merchant_domain}/...` | All stores inherit merchant's domain |
|
||||||
|
| Store subdomain | 3 (fallback) | `{store_code}.loyalty.lu/...` | Default when no custom/merchant domain |
|
||||||
|
|
||||||
|
!!! info "Domain Resolution Priority"
|
||||||
|
When a request arrives, the middleware resolves the store in this order:
|
||||||
|
|
||||||
|
1. **Store custom domain** (`store_domains` table) — highest priority, store-specific override
|
||||||
|
2. **Merchant domain** (`merchant_domains` table) — inherited by all merchant's stores
|
||||||
|
3. **Store subdomain** (`Store.subdomain` + platform domain) — fallback
|
||||||
|
|
||||||
### Case 1: Store with custom domain (e.g., `wizamart.shop`)
|
### Case 1: Store with custom domain (e.g., `wizamart.shop`)
|
||||||
|
|
||||||
@@ -233,10 +241,61 @@ The store has a verified entry in the `store_domains` table. **All** store URLs
|
|||||||
| POST enroll | `https://wizamart.shop/api/store/loyalty/cards/enroll` |
|
| POST enroll | `https://wizamart.shop/api/store/loyalty/cards/enroll` |
|
||||||
| POST lookup | `https://wizamart.shop/api/store/loyalty/cards/lookup` |
|
| POST lookup | `https://wizamart.shop/api/store/loyalty/cards/lookup` |
|
||||||
|
|
||||||
### Case 2: Store without custom domain (uses platform subdomain)
|
### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`)
|
||||||
|
|
||||||
The store has no entry in `store_domains`. **All** store URLs are served via a
|
The merchant has registered a domain in the `merchant_domains` table. Stores without
|
||||||
subdomain of the platform domain: `{store_code}.loyalty.lu`.
|
their own custom domain inherit the merchant domain. The middleware resolves the
|
||||||
|
merchant domain to the merchant's first active store by default, or to a specific
|
||||||
|
store when the URL includes `/store/{store_code}/...`.
|
||||||
|
|
||||||
|
**Storefront (customer-facing):**
|
||||||
|
|
||||||
|
| Page | Production URL |
|
||||||
|
|------|----------------|
|
||||||
|
| Loyalty Dashboard | `https://myloyaltyprogram.lu/account/loyalty` |
|
||||||
|
| Transaction History | `https://myloyaltyprogram.lu/account/loyalty/history` |
|
||||||
|
| Self-Enrollment | `https://myloyaltyprogram.lu/loyalty/join` |
|
||||||
|
| Enrollment Success | `https://myloyaltyprogram.lu/loyalty/join/success` |
|
||||||
|
|
||||||
|
**Storefront API:**
|
||||||
|
|
||||||
|
| Method | Production URL |
|
||||||
|
|--------|----------------|
|
||||||
|
| GET card | `https://myloyaltyprogram.lu/api/storefront/loyalty/card` |
|
||||||
|
| GET transactions | `https://myloyaltyprogram.lu/api/storefront/loyalty/transactions` |
|
||||||
|
| POST enroll | `https://myloyaltyprogram.lu/api/storefront/loyalty/enroll` |
|
||||||
|
| GET program | `https://myloyaltyprogram.lu/api/storefront/loyalty/program` |
|
||||||
|
|
||||||
|
**Store backend (staff/owner):**
|
||||||
|
|
||||||
|
| Page | Production URL |
|
||||||
|
|------|----------------|
|
||||||
|
| Store Login | `https://myloyaltyprogram.lu/store/WIZAGADGETS/login` |
|
||||||
|
| Terminal | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/terminal` |
|
||||||
|
| Cards | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/cards` |
|
||||||
|
| Settings | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/settings` |
|
||||||
|
| Stats | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/stats` |
|
||||||
|
|
||||||
|
**Store API:**
|
||||||
|
|
||||||
|
| Method | Production URL |
|
||||||
|
|--------|----------------|
|
||||||
|
| GET program | `https://myloyaltyprogram.lu/api/store/loyalty/program` |
|
||||||
|
| POST stamp | `https://myloyaltyprogram.lu/api/store/loyalty/stamp` |
|
||||||
|
| POST points | `https://myloyaltyprogram.lu/api/store/loyalty/points` |
|
||||||
|
| POST enroll | `https://myloyaltyprogram.lu/api/store/loyalty/cards/enroll` |
|
||||||
|
| POST lookup | `https://myloyaltyprogram.lu/api/store/loyalty/cards/lookup` |
|
||||||
|
|
||||||
|
!!! note "Merchant domain resolves to first active store"
|
||||||
|
When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path,
|
||||||
|
the middleware resolves to the merchant's **first active store** (ordered by ID).
|
||||||
|
This is ideal for storefront pages like `/loyalty/join` where the customer doesn't
|
||||||
|
need to know which specific store they're interacting with.
|
||||||
|
|
||||||
|
### Case 3: Store without custom domain (uses platform subdomain)
|
||||||
|
|
||||||
|
The store has no entry in `store_domains` and the merchant has no registered domain.
|
||||||
|
**All** store URLs are served via a subdomain of the platform domain: `{store_code}.loyalty.lu`.
|
||||||
|
|
||||||
**Storefront (customer-facing):**
|
**Storefront (customer-facing):**
|
||||||
|
|
||||||
@@ -291,26 +350,141 @@ subdomain of the platform domain: `{store_code}.loyalty.lu`.
|
|||||||
|
|
||||||
### Domain configuration per store (current DB state)
|
### Domain configuration per store (current DB state)
|
||||||
|
|
||||||
| Store | Custom Domain | Production URL |
|
**Merchant domains** (`merchant_domains` table):
|
||||||
|-------|---------------|----------------|
|
|
||||||
| WIZAMART | `wizamart.shop` | `https://wizamart.shop/...` |
|
| Merchant | Merchant Domain | Status |
|
||||||
| FASHIONHUB | `fashionhub.store` | `https://fashionhub.store/...` |
|
|----------|-----------------|--------|
|
||||||
| WIZAGADGETS | _(none)_ | `https://wizagadgets.loyalty.lu/...` |
|
| WizaCorp Ltd. | _(none yet)_ | — |
|
||||||
| WIZAHOME | _(none)_ | `https://wizahome.loyalty.lu/...` |
|
| Fashion Group S.A. | _(none yet)_ | — |
|
||||||
| FASHIONOUTLET | _(none)_ | `https://fashionoutlet.loyalty.lu/...` |
|
| BookWorld Publishing | _(none yet)_ | — |
|
||||||
| BOOKSTORE | _(none)_ | `https://bookstore.loyalty.lu/...` |
|
|
||||||
| BOOKDIGITAL | _(none)_ | `https://bookdigital.loyalty.lu/...` |
|
**Store domains** (`store_domains` table) and effective resolution:
|
||||||
|
|
||||||
|
| Store | Merchant | Store Custom Domain | Effective Domain |
|
||||||
|
|-------|----------|---------------------|------------------|
|
||||||
|
| WIZAMART | WizaCorp | `wizamart.shop` | `wizamart.shop` (store override) |
|
||||||
|
| FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) |
|
||||||
|
| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.loyalty.lu` (subdomain fallback) |
|
||||||
|
| WIZAHOME | WizaCorp | _(none)_ | `wizahome.loyalty.lu` (subdomain fallback) |
|
||||||
|
| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.loyalty.lu` (subdomain fallback) |
|
||||||
|
| BOOKSTORE | BookWorld | _(none)_ | `bookstore.loyalty.lu` (subdomain fallback) |
|
||||||
|
| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.loyalty.lu` (subdomain fallback) |
|
||||||
|
|
||||||
|
!!! example "After merchant domain registration"
|
||||||
|
If WizaCorp registers `myloyaltyprogram.lu` as their merchant domain, the table becomes:
|
||||||
|
|
||||||
|
| Store | Effective Domain | Reason |
|
||||||
|
|-------|------------------|--------|
|
||||||
|
| WIZAMART | `wizamart.shop` | Store custom domain takes priority |
|
||||||
|
| WIZAGADGETS | `myloyaltyprogram.lu` | Inherits merchant domain |
|
||||||
|
| WIZAHOME | `myloyaltyprogram.lu` | Inherits merchant domain |
|
||||||
|
|
||||||
!!! info "`{store_domain}` in journey URLs"
|
!!! info "`{store_domain}` in journey URLs"
|
||||||
In the journeys below, `{store_domain}` refers to the store's resolved domain:
|
In the journeys below, `{store_domain}` refers to the store's **effective domain**, resolved in priority order:
|
||||||
|
|
||||||
- **Custom domain**: `wizamart.shop` (from `store_domains` table)
|
1. **Store custom domain**: `wizamart.shop` (from `store_domains` table) — highest priority
|
||||||
- **Subdomain fallback**: `wizamart.loyalty.lu` (from `Store.subdomain` + platform domain)
|
2. **Merchant domain**: `myloyaltyprogram.lu` (from `merchant_domains` table) — inherited default
|
||||||
|
3. **Subdomain fallback**: `wizamart.loyalty.lu` (from `Store.subdomain` + platform domain)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Journeys
|
## User Journeys
|
||||||
|
|
||||||
|
### Journey 0: Merchant Subscription & Domain Setup
|
||||||
|
|
||||||
|
**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com) + Platform Admin
|
||||||
|
**Goal:** Subscribe to the loyalty platform, register a merchant domain, and optionally configure store domain overrides
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Merchant owner logs in] --> B[Navigate to billing page]
|
||||||
|
B --> C[Choose subscription tier]
|
||||||
|
C --> D[Complete Stripe checkout]
|
||||||
|
D --> E[Subscription active]
|
||||||
|
E --> F{Register merchant domain?}
|
||||||
|
F -->|Yes| G[Admin registers merchant domain]
|
||||||
|
G --> H[Verify DNS ownership]
|
||||||
|
H --> I[Activate merchant domain]
|
||||||
|
I --> J{Store-specific override?}
|
||||||
|
J -->|Yes| K[Register store custom domain]
|
||||||
|
K --> L[Verify & activate store domain]
|
||||||
|
J -->|No| M[All stores inherit merchant domain]
|
||||||
|
F -->|No| N[Stores use subdomain fallback]
|
||||||
|
L --> O[Domain setup complete]
|
||||||
|
M --> O
|
||||||
|
N --> O
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1: Subscribe to the platform**
|
||||||
|
|
||||||
|
1. Login as `john.owner@wizacorp.com` and navigate to billing:
|
||||||
|
- Dev: `http://localhost:9999/platforms/loyalty/store/WIZAMART/billing`
|
||||||
|
- Prod (custom domain): `https://wizamart.shop/store/WIZAMART/billing`
|
||||||
|
- Prod (subdomain): `https://wizamart.loyalty.lu/store/WIZAMART/billing`
|
||||||
|
2. View available subscription tiers:
|
||||||
|
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers`
|
||||||
|
- API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers`
|
||||||
|
3. Select a tier and initiate Stripe checkout:
|
||||||
|
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/store/billing/checkout`
|
||||||
|
- API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout`
|
||||||
|
4. Complete payment on Stripe checkout page
|
||||||
|
5. Webhook `checkout.session.completed` activates the subscription
|
||||||
|
6. Verify subscription is active:
|
||||||
|
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/subscription`
|
||||||
|
- API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription`
|
||||||
|
|
||||||
|
**Step 2: Register merchant domain (admin action)**
|
||||||
|
|
||||||
|
!!! note "Admin-only operation"
|
||||||
|
Merchant domain registration is currently an admin operation. The platform admin
|
||||||
|
registers the domain on behalf of the merchant via the admin API.
|
||||||
|
|
||||||
|
1. Platform admin registers a merchant domain:
|
||||||
|
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains`
|
||||||
|
- API Prod: `POST https://loyalty.lu/api/v1/admin/merchants/{merchant_id}/domains`
|
||||||
|
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
|
||||||
|
2. The API returns a `verification_token` for DNS verification
|
||||||
|
3. Get DNS verification instructions:
|
||||||
|
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
|
||||||
|
- API Prod: `GET https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
|
||||||
|
4. Merchant adds a DNS TXT record: `_wizamart-verify.myloyaltyprogram.lu TXT {verification_token}`
|
||||||
|
5. Verify the domain:
|
||||||
|
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||||||
|
- API Prod: `POST https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||||||
|
6. Activate the domain:
|
||||||
|
- API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}`
|
||||||
|
- API Prod: `PUT https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}`
|
||||||
|
- Body: `{"is_active": true}`
|
||||||
|
7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain
|
||||||
|
|
||||||
|
**Step 3: (Optional) Register store-specific domain override**
|
||||||
|
|
||||||
|
If a store needs its own domain (e.g., WIZAMART is a major brand and wants `mysuperloyaltyprogram.lu`):
|
||||||
|
|
||||||
|
1. Platform admin registers a store domain:
|
||||||
|
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains`
|
||||||
|
- API Prod: `POST https://loyalty.lu/api/v1/admin/stores/{store_id}/domains`
|
||||||
|
- Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}`
|
||||||
|
2. Follow the same DNS verification and activation flow as merchant domains
|
||||||
|
3. Once active, this store's effective domain becomes `mysuperloyaltyprogram.lu` (overrides merchant domain)
|
||||||
|
4. Other stores (WIZAGADGETS, WIZAHOME) continue to use `myloyaltyprogram.lu`
|
||||||
|
|
||||||
|
**Result after domain setup for WizaCorp:**
|
||||||
|
|
||||||
|
| Store | Effective Domain | Source |
|
||||||
|
|-------|------------------|--------|
|
||||||
|
| WIZAMART | `mysuperloyaltyprogram.lu` | Store custom domain (override) |
|
||||||
|
| WIZAGADGETS | `myloyaltyprogram.lu` | Merchant domain (inherited) |
|
||||||
|
| WIZAHOME | `myloyaltyprogram.lu` | Merchant domain (inherited) |
|
||||||
|
|
||||||
|
**Expected blockers in current state:**
|
||||||
|
|
||||||
|
- No subscriptions exist yet - create one first via billing page or admin API
|
||||||
|
- No merchant domains registered - admin must register via API
|
||||||
|
- DNS verification requires actual DNS records (mock in tests)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Journey 1: Merchant Owner - First-Time Setup
|
### Journey 1: Merchant Owner - First-Time Setup
|
||||||
|
|
||||||
**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com)
|
**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com)
|
||||||
@@ -356,9 +530,14 @@ flowchart TD
|
|||||||
|
|
||||||
**Expected blockers in current state:**
|
**Expected blockers in current state:**
|
||||||
|
|
||||||
- No subscriptions exist - feature gating may prevent program creation
|
|
||||||
- No loyalty programs exist - this is the first journey to test
|
- No loyalty programs exist - this is the first journey to test
|
||||||
|
|
||||||
|
!!! note "Subscription is not required for program creation"
|
||||||
|
The loyalty module currently has **no feature gating** — program creation works
|
||||||
|
without an active subscription. Journey 0 (subscription & domain setup) is
|
||||||
|
independent and can be done before or after program creation. However, in production
|
||||||
|
you would typically subscribe first to get a custom domain for your loyalty URLs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Journey 2: Store Staff - Daily Operations (Stamps)
|
### Journey 2: Store Staff - Daily Operations (Stamps)
|
||||||
@@ -601,9 +780,15 @@ flowchart TD
|
|||||||
## Recommended Test Order
|
## Recommended Test Order
|
||||||
|
|
||||||
1. **Journey 1** - Create a program first (nothing else works without this)
|
1. **Journey 1** - Create a program first (nothing else works without this)
|
||||||
2. **Journey 4** - Enroll a test customer
|
2. **Journey 0** - Subscribe and set up domains (independent, but needed for custom domain URLs)
|
||||||
3. **Journey 2 or 3** - Process stamps/points
|
3. **Journey 4** - Enroll a test customer
|
||||||
4. **Journey 5** - Verify customer can see their data
|
4. **Journey 2 or 3** - Process stamps/points
|
||||||
5. **Journey 7** - Test void/return
|
5. **Journey 5** - Verify customer can see their data
|
||||||
6. **Journey 8** - Test cross-store (enroll via WIZAMART, redeem via WIZAGADGETS)
|
6. **Journey 7** - Test void/return
|
||||||
7. **Journey 6** - Admin overview (verify data appears correctly)
|
7. **Journey 8** - Test cross-store (enroll via WIZAMART, redeem via WIZAGADGETS)
|
||||||
|
8. **Journey 6** - Admin overview (verify data appears correctly)
|
||||||
|
|
||||||
|
!!! tip "Journey 0 and Journey 1 are independent"
|
||||||
|
There is no feature gating on loyalty program creation — you can test them in
|
||||||
|
either order. Journey 0 is listed second because domain setup is about URL
|
||||||
|
presentation, not a functional prerequisite for the loyalty module.
|
||||||
|
|||||||
@@ -174,6 +174,32 @@ class PlatformContextManager:
|
|||||||
)
|
)
|
||||||
return platform
|
return platform
|
||||||
|
|
||||||
|
# Fallback: Check MerchantDomain for merchant-level domains
|
||||||
|
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||||
|
merchant_domain = (
|
||||||
|
db.query(MerchantDomain)
|
||||||
|
.filter(
|
||||||
|
MerchantDomain.domain == domain,
|
||||||
|
MerchantDomain.is_active.is_(True),
|
||||||
|
MerchantDomain.is_verified.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if merchant_domain and merchant_domain.platform_id:
|
||||||
|
platform = (
|
||||||
|
db.query(Platform)
|
||||||
|
.filter(
|
||||||
|
Platform.id == merchant_domain.platform_id,
|
||||||
|
Platform.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if platform:
|
||||||
|
logger.debug(
|
||||||
|
f"[PLATFORM] Platform found via merchant domain: {domain} → {platform.name}"
|
||||||
|
)
|
||||||
|
return platform
|
||||||
|
|
||||||
logger.debug(f"[PLATFORM] No platform found for domain: {domain}")
|
logger.debug(f"[PLATFORM] No platform found for domain: {domain}")
|
||||||
|
|
||||||
# Method 2: Path-prefix lookup
|
# Method 2: Path-prefix lookup
|
||||||
|
|||||||
@@ -149,6 +149,36 @@ class StoreContextManager:
|
|||||||
f"[OK] Store found via custom domain: {domain} → {store.name}"
|
f"[OK] Store found via custom domain: {domain} → {store.name}"
|
||||||
)
|
)
|
||||||
return store
|
return store
|
||||||
|
|
||||||
|
# Fallback: Try merchant-level domain
|
||||||
|
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||||
|
merchant_domain = (
|
||||||
|
db.query(MerchantDomain)
|
||||||
|
.filter(
|
||||||
|
MerchantDomain.domain == domain,
|
||||||
|
MerchantDomain.is_active.is_(True),
|
||||||
|
MerchantDomain.is_verified.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if merchant_domain:
|
||||||
|
store = (
|
||||||
|
db.query(Store)
|
||||||
|
.filter(
|
||||||
|
Store.merchant_id == merchant_domain.merchant_id,
|
||||||
|
Store.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(Store.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if store:
|
||||||
|
context["merchant_domain"] = True
|
||||||
|
context["merchant_id"] = merchant_domain.merchant_id
|
||||||
|
logger.info(
|
||||||
|
f"[OK] Store found via merchant domain: {domain} → {store.name}"
|
||||||
|
)
|
||||||
|
return store
|
||||||
|
|
||||||
logger.warning(f"No active store found for custom domain: {domain}")
|
logger.warning(f"No active store found for custom domain: {domain}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -176,4 +176,5 @@ pytest_plugins = [
|
|||||||
"tests.fixtures.message_fixtures",
|
"tests.fixtures.message_fixtures",
|
||||||
"tests.fixtures.testing_fixtures",
|
"tests.fixtures.testing_fixtures",
|
||||||
"tests.fixtures.content_page_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
|
# Patch get_db in middleware modules - they have their own imports
|
||||||
# The middleware calls: db_gen = get_db(); db = next(db_gen)
|
# The middleware calls: db_gen = get_db(); db = next(db_gen)
|
||||||
# Also patch settings.platform_domain so subdomain detection works with test hosts
|
# 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.platform_context.get_db", override_get_db):
|
||||||
with patch("middleware.theme_context.get_db", override_get_db):
|
with patch("middleware.store_context.get_db", override_get_db):
|
||||||
with patch("middleware.store_context.settings") as mock_settings:
|
with patch("middleware.theme_context.get_db", override_get_db):
|
||||||
mock_settings.platform_domain = "platform.com"
|
with patch("middleware.store_context.settings") as mock_settings:
|
||||||
client = TestClient(app)
|
mock_settings.platform_domain = "platform.com"
|
||||||
yield client
|
client = TestClient(app)
|
||||||
|
yield client
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
if get_db in app.dependency_overrides:
|
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")
|
@router.get("/inactive-store-detection")
|
||||||
async def test_inactive_store_detection(request: Request):
|
async def test_inactive_store_detection(request: Request):
|
||||||
"""Test inactive store detection."""
|
"""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