Compare commits
2 Commits
531487f5c9
...
77b76afb3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 77b76afb3f | |||
| 8968e7d9cd |
@@ -9,13 +9,6 @@ It includes:
|
||||
- Task retry policies
|
||||
- Sentry integration for error tracking
|
||||
- Module-based task discovery (discovers tasks from app/modules/*/tasks/)
|
||||
|
||||
Task Discovery:
|
||||
- Legacy tasks: Explicitly listed in the 'include' parameter
|
||||
- Module tasks: Auto-discovered via discover_module_tasks()
|
||||
|
||||
As modules are migrated, their tasks will move from the legacy include list
|
||||
to automatic discovery from the module's tasks/ directory.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -48,38 +41,27 @@ if SENTRY_DSN:
|
||||
# =============================================================================
|
||||
# TASK DISCOVERY
|
||||
# =============================================================================
|
||||
# Legacy tasks (will be migrated to modules over time)
|
||||
# MIGRATION STATUS:
|
||||
# - subscription: MIGRATED to billing module (kept for capture_capacity_snapshot -> monitoring)
|
||||
# - marketplace, letzshop, export: MIGRATED to marketplace module
|
||||
# - code_quality, test_runner: Will migrate to dev-tools module
|
||||
LEGACY_TASK_MODULES: list[str] = [
|
||||
# All legacy tasks have been migrated to their respective modules.
|
||||
# Task discovery now happens via app.modules.tasks.discover_module_tasks()
|
||||
]
|
||||
|
||||
|
||||
def get_all_task_modules() -> list[str]:
|
||||
"""
|
||||
Get all task modules (legacy + module-based).
|
||||
Get all task modules via module-based discovery.
|
||||
|
||||
Returns:
|
||||
Combined list of legacy task modules and discovered module tasks
|
||||
List of discovered module task packages
|
||||
"""
|
||||
all_modules = list(LEGACY_TASK_MODULES)
|
||||
|
||||
try:
|
||||
from app.modules.tasks import discover_module_tasks
|
||||
|
||||
module_tasks = discover_module_tasks()
|
||||
all_modules.extend(module_tasks)
|
||||
logger.info(f"Discovered {len(module_tasks)} module task packages")
|
||||
return module_tasks
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not import module task discovery: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering module tasks: {e}")
|
||||
|
||||
return all_modules
|
||||
return []
|
||||
|
||||
|
||||
# Create Celery application
|
||||
|
||||
@@ -19,24 +19,18 @@ from app.exceptions.base import (
|
||||
__all__ = [
|
||||
# Base billing exception
|
||||
"BillingException",
|
||||
"BillingServiceError", # Alias for backwards compatibility
|
||||
# Subscription exceptions
|
||||
"SubscriptionNotFoundException",
|
||||
"NoActiveSubscriptionException",
|
||||
"NoActiveSubscriptionError", # Alias for backwards compatibility
|
||||
"SubscriptionNotCancelledException",
|
||||
"SubscriptionNotCancelledError", # Alias for backwards compatibility
|
||||
"SubscriptionAlreadyCancelledException",
|
||||
# Tier exceptions
|
||||
"TierNotFoundException",
|
||||
"TierNotFoundError",
|
||||
"TierLimitExceededException",
|
||||
# Payment exceptions
|
||||
"PaymentSystemNotConfiguredException",
|
||||
"PaymentSystemNotConfiguredError", # Alias for backwards compatibility
|
||||
"StripeNotConfiguredException",
|
||||
"StripePriceNotConfiguredException",
|
||||
"StripePriceNotConfiguredError", # Alias for backwards compatibility
|
||||
"PaymentFailedException",
|
||||
# Webhook exceptions
|
||||
"InvalidWebhookSignatureException",
|
||||
@@ -44,7 +38,6 @@ __all__ = [
|
||||
"WebhookVerificationException",
|
||||
# Feature exceptions
|
||||
"FeatureNotFoundException",
|
||||
"FeatureNotFoundError",
|
||||
"FeatureNotAvailableException",
|
||||
"InvalidFeatureCodesError",
|
||||
]
|
||||
@@ -62,10 +55,6 @@ class BillingException(BusinessLogicException):
|
||||
super().__init__(message=message, error_code=error_code, details=details)
|
||||
|
||||
|
||||
# Alias for backwards compatibility with billing_service.py
|
||||
BillingServiceError = BillingException
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Subscription Exceptions
|
||||
# =============================================================================
|
||||
@@ -92,10 +81,6 @@ class NoActiveSubscriptionException(BusinessLogicException):
|
||||
)
|
||||
|
||||
|
||||
# Alias for backwards compatibility with billing_service.py
|
||||
NoActiveSubscriptionError = NoActiveSubscriptionException
|
||||
|
||||
|
||||
class SubscriptionNotCancelledException(BusinessLogicException):
|
||||
"""Raised when trying to reactivate a subscription that is not cancelled."""
|
||||
|
||||
@@ -106,10 +91,6 @@ class SubscriptionNotCancelledException(BusinessLogicException):
|
||||
)
|
||||
|
||||
|
||||
# Alias for backwards compatibility with billing_service.py
|
||||
SubscriptionNotCancelledError = SubscriptionNotCancelledException
|
||||
|
||||
|
||||
class SubscriptionAlreadyCancelledException(BusinessLogicException):
|
||||
"""Raised when trying to cancel an already cancelled subscription."""
|
||||
|
||||
@@ -138,18 +119,6 @@ class TierNotFoundException(ResourceNotFoundException):
|
||||
self.tier_code = tier_code
|
||||
|
||||
|
||||
class TierNotFoundError(ResourceNotFoundException):
|
||||
"""Subscription tier not found (alternate naming)."""
|
||||
|
||||
def __init__(self, tier_code: str):
|
||||
super().__init__(
|
||||
resource_type="SubscriptionTier",
|
||||
identifier=tier_code,
|
||||
message=f"Tier '{tier_code}' not found",
|
||||
)
|
||||
self.tier_code = tier_code
|
||||
|
||||
|
||||
class TierLimitExceededException(BillingException):
|
||||
"""Raised when a tier limit is exceeded."""
|
||||
|
||||
@@ -180,10 +149,6 @@ class PaymentSystemNotConfiguredException(ServiceUnavailableException):
|
||||
super().__init__(message="Payment system not configured")
|
||||
|
||||
|
||||
# Alias for backwards compatibility with billing_service.py
|
||||
PaymentSystemNotConfiguredError = PaymentSystemNotConfiguredException
|
||||
|
||||
|
||||
class StripeNotConfiguredException(BillingException):
|
||||
"""Raised when Stripe is not configured."""
|
||||
|
||||
@@ -206,10 +171,6 @@ class StripePriceNotConfiguredException(BusinessLogicException):
|
||||
self.tier_code = tier_code
|
||||
|
||||
|
||||
# Alias for backwards compatibility with billing_service.py
|
||||
StripePriceNotConfiguredError = StripePriceNotConfiguredException
|
||||
|
||||
|
||||
class PaymentFailedException(BillingException):
|
||||
"""Raised when a payment fails."""
|
||||
|
||||
@@ -277,18 +238,6 @@ class FeatureNotFoundException(ResourceNotFoundException):
|
||||
self.feature_code = feature_code
|
||||
|
||||
|
||||
class FeatureNotFoundError(ResourceNotFoundException):
|
||||
"""Feature not found (alternate naming)."""
|
||||
|
||||
def __init__(self, feature_code: str):
|
||||
super().__init__(
|
||||
resource_type="Feature",
|
||||
identifier=feature_code,
|
||||
message=f"Feature '{feature_code}' not found",
|
||||
)
|
||||
self.feature_code = feature_code
|
||||
|
||||
|
||||
class FeatureNotAvailableException(BillingException):
|
||||
"""Raised when a feature is not available in current tier."""
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.modules.billing.exceptions import FeatureNotFoundError
|
||||
from app.modules.billing.exceptions import FeatureNotFoundException
|
||||
from app.modules.billing.schemas.billing import (
|
||||
CategoryListResponse,
|
||||
FeatureCodeListResponse,
|
||||
@@ -275,7 +275,7 @@ def get_feature_detail(
|
||||
# Get feature declaration
|
||||
decl = feature_aggregator.get_declaration(feature_code)
|
||||
if not decl:
|
||||
raise FeatureNotFoundError(feature_code)
|
||||
raise FeatureNotFoundException(feature_code)
|
||||
|
||||
# Check availability
|
||||
is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code)
|
||||
|
||||
@@ -5,14 +5,6 @@ Billing module services.
|
||||
Provides subscription management, Stripe integration, and admin operations.
|
||||
"""
|
||||
|
||||
from app.modules.billing.exceptions import (
|
||||
BillingServiceError,
|
||||
NoActiveSubscriptionError,
|
||||
PaymentSystemNotConfiguredError,
|
||||
StripePriceNotConfiguredError,
|
||||
SubscriptionNotCancelledError,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.modules.billing.services.admin_subscription_service import (
|
||||
AdminSubscriptionService,
|
||||
admin_subscription_service,
|
||||
@@ -56,12 +48,6 @@ __all__ = [
|
||||
"admin_subscription_service",
|
||||
"BillingService",
|
||||
"billing_service",
|
||||
"BillingServiceError",
|
||||
"PaymentSystemNotConfiguredError",
|
||||
"TierNotFoundError",
|
||||
"StripePriceNotConfiguredError",
|
||||
"NoActiveSubscriptionError",
|
||||
"SubscriptionNotCancelledError",
|
||||
"FeatureService",
|
||||
"feature_service",
|
||||
"PlatformPricingService",
|
||||
|
||||
@@ -16,12 +16,12 @@ from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.billing.exceptions import (
|
||||
BillingServiceError,
|
||||
NoActiveSubscriptionError,
|
||||
PaymentSystemNotConfiguredError,
|
||||
StripePriceNotConfiguredError,
|
||||
SubscriptionNotCancelledError,
|
||||
TierNotFoundError,
|
||||
BillingException,
|
||||
NoActiveSubscriptionException,
|
||||
PaymentSystemNotConfiguredException,
|
||||
StripePriceNotConfiguredException,
|
||||
SubscriptionNotCancelledException,
|
||||
TierNotFoundException,
|
||||
)
|
||||
from app.modules.billing.models import (
|
||||
AddOnProduct,
|
||||
@@ -93,7 +93,7 @@ class BillingService:
|
||||
Get a tier by its code.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier doesn't exist
|
||||
TierNotFoundException: If tier doesn't exist
|
||||
"""
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
@@ -105,7 +105,7 @@ class BillingService:
|
||||
)
|
||||
|
||||
if not tier:
|
||||
raise TierNotFoundError(tier_code)
|
||||
raise TierNotFoundException(tier_code)
|
||||
|
||||
return tier
|
||||
|
||||
@@ -126,12 +126,12 @@ class BillingService:
|
||||
Dict with checkout_url and session_id
|
||||
|
||||
Raises:
|
||||
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||
TierNotFoundError: If tier doesn't exist
|
||||
StripePriceNotConfiguredError: If price not configured
|
||||
PaymentSystemNotConfiguredException: If Stripe not configured
|
||||
TierNotFoundException: If tier doesn't exist
|
||||
StripePriceNotConfiguredException: If price not configured
|
||||
"""
|
||||
if not stripe_service.is_configured:
|
||||
raise PaymentSystemNotConfiguredError()
|
||||
raise PaymentSystemNotConfiguredException()
|
||||
|
||||
tier = self.get_tier_by_code(db, tier_code)
|
||||
|
||||
@@ -142,7 +142,7 @@ class BillingService:
|
||||
)
|
||||
|
||||
if not price_id:
|
||||
raise StripePriceNotConfiguredError(tier_code)
|
||||
raise StripePriceNotConfiguredException(tier_code)
|
||||
|
||||
# Check if this is a new subscription (for trial)
|
||||
existing_sub = subscription_service.get_merchant_subscription(
|
||||
@@ -188,18 +188,18 @@ class BillingService:
|
||||
Dict with portal_url
|
||||
|
||||
Raises:
|
||||
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||
NoActiveSubscriptionError: If no subscription with customer ID
|
||||
PaymentSystemNotConfiguredException: If Stripe not configured
|
||||
NoActiveSubscriptionException: If no subscription with customer ID
|
||||
"""
|
||||
if not stripe_service.is_configured:
|
||||
raise PaymentSystemNotConfiguredError()
|
||||
raise PaymentSystemNotConfiguredException()
|
||||
|
||||
subscription = subscription_service.get_merchant_subscription(
|
||||
db, merchant_id, platform_id
|
||||
)
|
||||
|
||||
if not subscription or not subscription.stripe_customer_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
raise NoActiveSubscriptionException()
|
||||
|
||||
session = stripe_service.create_portal_session(
|
||||
customer_id=subscription.stripe_customer_id,
|
||||
@@ -266,14 +266,14 @@ class BillingService:
|
||||
Dict with message and effective_date
|
||||
|
||||
Raises:
|
||||
NoActiveSubscriptionError: If no subscription to cancel
|
||||
NoActiveSubscriptionException: If no subscription to cancel
|
||||
"""
|
||||
subscription = subscription_service.get_merchant_subscription(
|
||||
db, merchant_id, platform_id
|
||||
)
|
||||
|
||||
if not subscription or not subscription.stripe_subscription_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
raise NoActiveSubscriptionException()
|
||||
|
||||
if stripe_service.is_configured:
|
||||
stripe_service.cancel_subscription(
|
||||
@@ -308,18 +308,18 @@ class BillingService:
|
||||
Dict with success message
|
||||
|
||||
Raises:
|
||||
NoActiveSubscriptionError: If no subscription
|
||||
SubscriptionNotCancelledError: If not cancelled
|
||||
NoActiveSubscriptionException: If no subscription
|
||||
SubscriptionNotCancelledException: If not cancelled
|
||||
"""
|
||||
subscription = subscription_service.get_merchant_subscription(
|
||||
db, merchant_id, platform_id
|
||||
)
|
||||
|
||||
if not subscription or not subscription.stripe_subscription_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
raise NoActiveSubscriptionException()
|
||||
|
||||
if not subscription.cancelled_at:
|
||||
raise SubscriptionNotCancelledError()
|
||||
raise SubscriptionNotCancelledException()
|
||||
|
||||
if stripe_service.is_configured:
|
||||
stripe_service.reactivate_subscription(subscription.stripe_subscription_id)
|
||||
@@ -339,14 +339,14 @@ class BillingService:
|
||||
Dict with amount_due_cents, currency, next_payment_date, line_items
|
||||
|
||||
Raises:
|
||||
NoActiveSubscriptionError: If no subscription with customer ID
|
||||
NoActiveSubscriptionException: If no subscription with customer ID
|
||||
"""
|
||||
subscription = subscription_service.get_merchant_subscription(
|
||||
db, merchant_id, platform_id
|
||||
)
|
||||
|
||||
if not subscription or not subscription.stripe_customer_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
raise NoActiveSubscriptionException()
|
||||
|
||||
if not stripe_service.is_configured:
|
||||
return {
|
||||
@@ -399,16 +399,16 @@ class BillingService:
|
||||
Dict with message, new_tier, effective_immediately
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier doesn't exist
|
||||
NoActiveSubscriptionError: If no subscription
|
||||
StripePriceNotConfiguredError: If price not configured
|
||||
TierNotFoundException: If tier doesn't exist
|
||||
NoActiveSubscriptionException: If no subscription
|
||||
StripePriceNotConfiguredException: If price not configured
|
||||
"""
|
||||
subscription = subscription_service.get_merchant_subscription(
|
||||
db, merchant_id, platform_id
|
||||
)
|
||||
|
||||
if not subscription or not subscription.stripe_subscription_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
raise NoActiveSubscriptionException()
|
||||
|
||||
tier = self.get_tier_by_code(db, new_tier_code)
|
||||
|
||||
@@ -419,7 +419,7 @@ class BillingService:
|
||||
)
|
||||
|
||||
if not price_id:
|
||||
raise StripePriceNotConfiguredError(new_tier_code)
|
||||
raise StripePriceNotConfiguredException(new_tier_code)
|
||||
|
||||
# Update in Stripe
|
||||
if stripe_service.is_configured:
|
||||
@@ -472,11 +472,11 @@ class BillingService:
|
||||
Dict with checkout_url and session_id
|
||||
|
||||
Raises:
|
||||
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||
BillingServiceError: If addon doesn't exist
|
||||
PaymentSystemNotConfiguredException: If Stripe not configured
|
||||
BillingException: If addon doesn't exist
|
||||
"""
|
||||
if not stripe_service.is_configured:
|
||||
raise PaymentSystemNotConfiguredError()
|
||||
raise PaymentSystemNotConfiguredException()
|
||||
|
||||
addon = (
|
||||
db.query(AddOnProduct)
|
||||
@@ -488,10 +488,10 @@ class BillingService:
|
||||
)
|
||||
|
||||
if not addon:
|
||||
raise BillingServiceError(f"Add-on '{addon_code}' not found")
|
||||
raise BillingException(f"Add-on '{addon_code}' not found")
|
||||
|
||||
if not addon.stripe_price_id:
|
||||
raise BillingServiceError(f"Stripe price not configured for add-on '{addon_code}'")
|
||||
raise BillingException(f"Stripe price not configured for add-on '{addon_code}'")
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
@@ -522,7 +522,7 @@ class BillingService:
|
||||
Dict with message and addon_code
|
||||
|
||||
Raises:
|
||||
BillingServiceError: If addon not found or not owned by store
|
||||
BillingException: If addon not found or not owned by store
|
||||
"""
|
||||
store_addon = (
|
||||
db.query(StoreAddOn)
|
||||
@@ -534,7 +534,7 @@ class BillingService:
|
||||
)
|
||||
|
||||
if not store_addon:
|
||||
raise BillingServiceError("Add-on not found")
|
||||
raise BillingException("Add-on not found")
|
||||
|
||||
addon_code = store_addon.addon_product.code
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.billing.exceptions import (
|
||||
NoActiveSubscriptionException,
|
||||
PaymentSystemNotConfiguredException,
|
||||
StripePriceNotConfiguredException,
|
||||
SubscriptionNotCancelledException,
|
||||
TierNotFoundException,
|
||||
)
|
||||
from app.modules.billing.models import (
|
||||
AddOnProduct,
|
||||
BillingHistory,
|
||||
@@ -13,14 +20,7 @@ from app.modules.billing.models import (
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.billing.services.billing_service import (
|
||||
BillingService,
|
||||
NoActiveSubscriptionError,
|
||||
PaymentSystemNotConfiguredError,
|
||||
StripePriceNotConfiguredError,
|
||||
SubscriptionNotCancelledError,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.modules.billing.services.billing_service import BillingService
|
||||
|
||||
# ============================================================================
|
||||
# Tier Lookup
|
||||
@@ -41,17 +41,17 @@ class TestBillingServiceTiers:
|
||||
assert tier.code == "essential"
|
||||
|
||||
def test_get_tier_by_code_not_found(self, db):
|
||||
"""Nonexistent tier raises TierNotFoundError."""
|
||||
with pytest.raises(TierNotFoundError) as exc_info:
|
||||
"""Nonexistent tier raises TierNotFoundException."""
|
||||
with pytest.raises(TierNotFoundException) as exc_info:
|
||||
self.service.get_tier_by_code(db, "nonexistent")
|
||||
assert exc_info.value.tier_code == "nonexistent"
|
||||
|
||||
def test_get_tier_by_code_inactive_not_returned(self, db, bs_tier_essential):
|
||||
"""Inactive tier raises TierNotFoundError (only active tiers returned)."""
|
||||
"""Inactive tier raises TierNotFoundException (only active tiers returned)."""
|
||||
bs_tier_essential.is_active = False
|
||||
db.flush()
|
||||
|
||||
with pytest.raises(TierNotFoundError):
|
||||
with pytest.raises(TierNotFoundException):
|
||||
self.service.get_tier_by_code(db, "essential")
|
||||
|
||||
|
||||
@@ -249,8 +249,8 @@ class TestBillingServiceChangeTier:
|
||||
self.service = BillingService()
|
||||
|
||||
def test_change_tier_no_subscription_raises(self, db, bs_tiers):
|
||||
"""Raises NoActiveSubscriptionError when no subscription exists."""
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
"""Raises NoActiveSubscriptionException when no subscription exists."""
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.change_tier(db, 99999, 99999, "professional", False)
|
||||
|
||||
def test_change_tier_no_stripe_subscription_raises(
|
||||
@@ -258,7 +258,7 @@ class TestBillingServiceChangeTier:
|
||||
):
|
||||
"""Raises when subscription has no stripe_subscription_id."""
|
||||
# bs_subscription has no Stripe IDs
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.change_tier(
|
||||
db,
|
||||
bs_subscription.merchant_id,
|
||||
@@ -270,8 +270,8 @@ class TestBillingServiceChangeTier:
|
||||
def test_change_tier_nonexistent_tier_raises(
|
||||
self, db, bs_stripe_subscription
|
||||
):
|
||||
"""Raises TierNotFoundError for nonexistent tier."""
|
||||
with pytest.raises(TierNotFoundError):
|
||||
"""Raises TierNotFoundException for nonexistent tier."""
|
||||
with pytest.raises(TierNotFoundException):
|
||||
self.service.change_tier(
|
||||
db,
|
||||
bs_stripe_subscription.merchant_id,
|
||||
@@ -283,9 +283,9 @@ class TestBillingServiceChangeTier:
|
||||
def test_change_tier_no_price_id_raises(
|
||||
self, db, bs_stripe_subscription, bs_tiers
|
||||
):
|
||||
"""Raises StripePriceNotConfiguredError when tier has no Stripe price."""
|
||||
"""Raises StripePriceNotConfiguredException when tier has no Stripe price."""
|
||||
# bs_tiers have no stripe_price_* set
|
||||
with pytest.raises(StripePriceNotConfiguredError):
|
||||
with pytest.raises(StripePriceNotConfiguredException):
|
||||
self.service.change_tier(
|
||||
db,
|
||||
bs_stripe_subscription.merchant_id,
|
||||
@@ -382,12 +382,12 @@ class TestBillingServiceCancel:
|
||||
|
||||
def test_cancel_no_subscription_raises(self, db):
|
||||
"""Raises when no subscription found."""
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.cancel_subscription(db, 99999, 99999, None, False)
|
||||
|
||||
def test_cancel_no_stripe_id_raises(self, db, bs_subscription):
|
||||
"""Raises when subscription has no stripe_subscription_id."""
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.cancel_subscription(
|
||||
db,
|
||||
bs_subscription.merchant_id,
|
||||
@@ -431,12 +431,12 @@ class TestBillingServiceReactivate:
|
||||
|
||||
def test_reactivate_no_subscription_raises(self, db):
|
||||
"""Raises when no subscription found."""
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.reactivate_subscription(db, 99999, 99999)
|
||||
|
||||
def test_reactivate_not_cancelled_raises(self, db, bs_stripe_subscription):
|
||||
"""Raises SubscriptionNotCancelledError when not cancelled."""
|
||||
with pytest.raises(SubscriptionNotCancelledError):
|
||||
"""Raises SubscriptionNotCancelledException when not cancelled."""
|
||||
with pytest.raises(SubscriptionNotCancelledException):
|
||||
self.service.reactivate_subscription(
|
||||
db,
|
||||
bs_stripe_subscription.merchant_id,
|
||||
@@ -480,25 +480,25 @@ class TestBillingServiceCheckout:
|
||||
self.service = BillingService()
|
||||
|
||||
def test_checkout_stripe_not_configured_raises(self, db, bs_tiers_with_stripe):
|
||||
"""Raises PaymentSystemNotConfiguredError when Stripe is off."""
|
||||
"""Raises PaymentSystemNotConfiguredException when Stripe is off."""
|
||||
with patch(
|
||||
"app.modules.billing.services.billing_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.is_configured = False
|
||||
|
||||
with pytest.raises(PaymentSystemNotConfiguredError):
|
||||
with pytest.raises(PaymentSystemNotConfiguredException):
|
||||
self.service.create_checkout_session(
|
||||
db, 1, 1, "essential", False, "http://ok", "http://cancel"
|
||||
)
|
||||
|
||||
def test_checkout_nonexistent_tier_raises(self, db):
|
||||
"""Raises TierNotFoundError for nonexistent tier."""
|
||||
"""Raises TierNotFoundException for nonexistent tier."""
|
||||
with patch(
|
||||
"app.modules.billing.services.billing_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.is_configured = True
|
||||
|
||||
with pytest.raises(TierNotFoundError):
|
||||
with pytest.raises(TierNotFoundException):
|
||||
self.service.create_checkout_session(
|
||||
db, 1, 1, "nonexistent", False, "http://ok", "http://cancel"
|
||||
)
|
||||
@@ -518,23 +518,23 @@ class TestBillingServicePortal:
|
||||
self.service = BillingService()
|
||||
|
||||
def test_portal_stripe_not_configured_raises(self, db):
|
||||
"""Raises PaymentSystemNotConfiguredError when Stripe is off."""
|
||||
"""Raises PaymentSystemNotConfiguredException when Stripe is off."""
|
||||
with patch(
|
||||
"app.modules.billing.services.billing_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.is_configured = False
|
||||
|
||||
with pytest.raises(PaymentSystemNotConfiguredError):
|
||||
with pytest.raises(PaymentSystemNotConfiguredException):
|
||||
self.service.create_portal_session(db, 1, 1, "http://return")
|
||||
|
||||
def test_portal_no_subscription_raises(self, db):
|
||||
"""Raises NoActiveSubscriptionError when no subscription found."""
|
||||
"""Raises NoActiveSubscriptionException when no subscription found."""
|
||||
with patch(
|
||||
"app.modules.billing.services.billing_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.is_configured = True
|
||||
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.create_portal_session(db, 99999, 99999, "http://return")
|
||||
|
||||
def test_portal_no_customer_id_raises(self, db, bs_subscription):
|
||||
@@ -544,7 +544,7 @@ class TestBillingServicePortal:
|
||||
) as mock_stripe:
|
||||
mock_stripe.is_configured = True
|
||||
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.create_portal_session(
|
||||
db,
|
||||
bs_subscription.merchant_id,
|
||||
@@ -568,12 +568,12 @@ class TestBillingServiceUpcomingInvoice:
|
||||
|
||||
def test_upcoming_invoice_no_subscription_raises(self, db):
|
||||
"""Raises when no subscription exists."""
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.get_upcoming_invoice(db, 99999, 99999)
|
||||
|
||||
def test_upcoming_invoice_no_customer_id_raises(self, db, bs_subscription):
|
||||
"""Raises when subscription has no stripe_customer_id."""
|
||||
with pytest.raises(NoActiveSubscriptionError):
|
||||
with pytest.raises(NoActiveSubscriptionException):
|
||||
self.service.get_upcoming_invoice(
|
||||
db, bs_subscription.merchant_id, bs_subscription.platform_id
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ Admin pages for managing platform and store content pages.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
@@ -22,22 +22,6 @@ router = APIRouter()
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/platform-homepage", include_in_schema=False)
|
||||
async def admin_platform_homepage_manager(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Deprecated: Redirects to platforms page.
|
||||
|
||||
Platform homepages are now managed via:
|
||||
- /admin/platforms → Select platform → Homepage button
|
||||
- Or directly: /admin/content-pages?platform_code={code}&slug=home
|
||||
"""
|
||||
return RedirectResponse(url="/admin/platforms", status_code=302)
|
||||
|
||||
|
||||
@router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_content_pages_list(
|
||||
request: Request,
|
||||
|
||||
@@ -90,7 +90,6 @@ class ContentPageResponse(BaseModel):
|
||||
show_in_header: bool
|
||||
show_in_legal: bool
|
||||
is_platform_page: bool = False
|
||||
is_platform_default: bool = False # Deprecated: use is_platform_page
|
||||
is_store_default: bool = False
|
||||
is_store_override: bool = False
|
||||
page_tier: str | None = None
|
||||
|
||||
@@ -37,7 +37,6 @@ class MediaItemResponse(BaseModel):
|
||||
filename: str
|
||||
original_filename: str | None = None
|
||||
file_url: str
|
||||
url: str | None = None # Alias for file_url for JS compatibility
|
||||
thumbnail_url: str | None = None
|
||||
media_type: str # image, video, document
|
||||
mime_type: str | None = None
|
||||
@@ -53,11 +52,6 @@ class MediaItemResponse(BaseModel):
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Set url from file_url if not provided."""
|
||||
if self.url is None:
|
||||
object.__setattr__(self, "url", self.file_url)
|
||||
|
||||
|
||||
class MediaListResponse(BaseModel):
|
||||
"""Paginated list of media items."""
|
||||
@@ -80,13 +74,6 @@ class MediaUploadResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: str | None = None
|
||||
media: MediaItemResponse | None = None
|
||||
# Legacy fields for backwards compatibility
|
||||
id: int | None = None
|
||||
file_url: str | None = None
|
||||
thumbnail_url: str | None = None
|
||||
filename: str | None = None
|
||||
file_size: int | None = None
|
||||
media_type: str | None = None
|
||||
|
||||
|
||||
class UploadedFileInfo(BaseModel):
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.modules.cms.services.media_service import (
|
||||
)
|
||||
from app.modules.cms.services.store_email_settings_service import (
|
||||
StoreEmailSettingsService,
|
||||
get_store_email_settings_service, # Deprecated: use store_email_settings_service
|
||||
store_email_settings_service,
|
||||
)
|
||||
from app.modules.cms.services.store_theme_service import (
|
||||
@@ -32,5 +31,4 @@ __all__ = [
|
||||
"store_theme_service",
|
||||
"StoreEmailSettingsService",
|
||||
"store_email_settings_service",
|
||||
"get_store_email_settings_service", # Deprecated
|
||||
]
|
||||
|
||||
@@ -481,14 +481,3 @@ class StoreEmailSettingsService:
|
||||
|
||||
# Module-level service instance (singleton pattern)
|
||||
store_email_settings_service = StoreEmailSettingsService()
|
||||
|
||||
|
||||
# Deprecated: Factory function for backwards compatibility
|
||||
def get_store_email_settings_service(db: Session) -> StoreEmailSettingsService:
|
||||
"""
|
||||
Factory function to get a StoreEmailSettingsService instance.
|
||||
|
||||
Deprecated: Use the singleton `store_email_settings_service` instead and pass
|
||||
`db` to individual methods.
|
||||
"""
|
||||
return store_email_settings_service
|
||||
|
||||
@@ -262,9 +262,9 @@ function mediaPickerMixin(storeIdGetter, multiSelect = false) {
|
||||
*/
|
||||
setMainImage(media) {
|
||||
if (this.form) {
|
||||
this.form.primary_image_url = media.url;
|
||||
this.form.primary_image_url = media.file_url;
|
||||
}
|
||||
mediaPickerLog.info('Main image set:', media.url);
|
||||
mediaPickerLog.info('Main image set:', media.file_url);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -272,13 +272,13 @@ function mediaPickerMixin(storeIdGetter, multiSelect = false) {
|
||||
*/
|
||||
addAdditionalImages(mediaList) {
|
||||
if (this.form && Array.isArray(this.form.additional_images)) {
|
||||
const newUrls = mediaList.map(m => m.url);
|
||||
const newUrls = mediaList.map(m => m.file_url);
|
||||
this.form.additional_images = [
|
||||
...this.form.additional_images,
|
||||
...newUrls
|
||||
];
|
||||
}
|
||||
mediaPickerLog.info('Additional images added:', mediaList.map(m => m.url));
|
||||
mediaPickerLog.info('Additional images added:', mediaList.map(m => m.file_url));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,7 +73,7 @@ function storeContentPagesManager() {
|
||||
|
||||
// Platform pages - filter to only show actual platform defaults
|
||||
const allPages = platformResponse.data || platformResponse || [];
|
||||
this.platformPages = allPages.filter(p => p.is_platform_default);
|
||||
this.platformPages = allPages.filter(p => p.is_platform_page);
|
||||
|
||||
// Store's custom pages (includes overrides)
|
||||
this.customPages = storeResponse.data || storeResponse || [];
|
||||
|
||||
@@ -34,7 +34,6 @@ from app.modules.messaging.schemas.message import (
|
||||
ConversationCreate,
|
||||
ConversationDetailResponse,
|
||||
ConversationListResponse,
|
||||
ConversationResponse,
|
||||
ConversationSummary,
|
||||
MarkReadResponse,
|
||||
# Message schemas
|
||||
@@ -90,7 +89,6 @@ __all__ = [
|
||||
"ConversationSummary",
|
||||
"ConversationDetailResponse",
|
||||
"ConversationListResponse",
|
||||
"ConversationResponse",
|
||||
# Unread count
|
||||
"UnreadCountResponse",
|
||||
# Notification preferences
|
||||
|
||||
@@ -192,10 +192,6 @@ class ConversationListResponse(BaseModel):
|
||||
limit: int
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
ConversationResponse = ConversationDetailResponse
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unread Count Schemas
|
||||
# ============================================================================
|
||||
|
||||
@@ -21,10 +21,6 @@ from app.modules.orders.schemas.invoice import (
|
||||
InvoiceResponse,
|
||||
# Address schemas
|
||||
InvoiceSellerDetails,
|
||||
# Backward compatibility
|
||||
InvoiceSettingsCreate,
|
||||
InvoiceSettingsResponse,
|
||||
InvoiceSettingsUpdate,
|
||||
InvoiceStatsResponse,
|
||||
InvoiceStatusUpdate,
|
||||
# Invoice settings schemas
|
||||
@@ -148,8 +144,4 @@ __all__ = [
|
||||
# PDF
|
||||
"InvoicePDFGeneratedResponse",
|
||||
"InvoiceStatsResponse",
|
||||
# Backward compatibility
|
||||
"InvoiceSettingsCreate",
|
||||
"InvoiceSettingsUpdate",
|
||||
"InvoiceSettingsResponse",
|
||||
]
|
||||
|
||||
@@ -308,9 +308,3 @@ class InvoiceStatsResponse(BaseModel):
|
||||
@property
|
||||
def total_revenue(self) -> float:
|
||||
return self.total_revenue_cents / 100
|
||||
|
||||
|
||||
# Backward compatibility re-exports
|
||||
InvoiceSettingsCreate = StoreInvoiceSettingsCreate
|
||||
InvoiceSettingsUpdate = StoreInvoiceSettingsUpdate
|
||||
InvoiceSettingsResponse = StoreInvoiceSettingsResponse
|
||||
|
||||
@@ -61,7 +61,7 @@ class RouteInfo:
|
||||
include_in_schema: bool = True
|
||||
module_code: str = ""
|
||||
route_type: str = "" # "api" or "pages"
|
||||
frontend: str = "" # "admin", "store", "shop"
|
||||
frontend: str = "" # "admin", "store", "storefront"
|
||||
priority: int = 0 # Higher = registered later (for catch-all routes)
|
||||
custom_prefix: str = "" # Custom prefix from ROUTE_CONFIG
|
||||
|
||||
@@ -76,7 +76,7 @@ def discover_module_routes() -> list[RouteInfo]:
|
||||
Route discovery looks for:
|
||||
- routes/api/admin.py -> admin API routes
|
||||
- routes/api/store.py -> store API routes
|
||||
- routes/api/shop.py -> shop API routes
|
||||
- routes/api/storefront.py -> storefront API routes
|
||||
- routes/pages/admin.py -> admin page routes
|
||||
- routes/pages/store.py -> store page routes
|
||||
|
||||
|
||||
@@ -93,92 +93,6 @@
|
||||
{{ pagination_simple() }}
|
||||
#}
|
||||
|
||||
{#
|
||||
Pagination Full Macro (First/Prev/Numbers/Next/Last)
|
||||
=====================================================
|
||||
⚠️ DEPRECATED: Use the standard 'pagination' macro instead.
|
||||
|
||||
This macro expects flat variables (total, skip, page, limit) but our Alpine.js
|
||||
components use nested pagination objects (pagination.total, pagination.page, etc.).
|
||||
|
||||
Use:
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
|
||||
---
|
||||
Legacy documentation (for reference only):
|
||||
|
||||
Required Alpine.js data properties:
|
||||
- page: Current page number
|
||||
- total: Total number of items
|
||||
- limit: Items per page
|
||||
- skip: Current skip value (page - 1) * limit
|
||||
- totalPages: Computed total pages (Math.ceil(total / limit))
|
||||
|
||||
Required Alpine.js methods:
|
||||
- getPageNumbers(): Returns array of page numbers to display
|
||||
- goToPage(pageNum): Go to specific page
|
||||
- loadData(): Function to reload data (called internally as loadFn parameter)
|
||||
#}
|
||||
|
||||
{% macro pagination_full(show_condition="total > limit", load_fn="loadData()", item_label="items") %}
|
||||
<div x-show="{{ show_condition }}" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t dark:border-gray-700">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, total)"></span> of <span x-text="total"></span> {{ item_label }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{# First Page #}
|
||||
<button
|
||||
@click="page = 1; {{ load_fn }}"
|
||||
:disabled="page <= 1"
|
||||
class="px-2 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="First page"
|
||||
>
|
||||
<span x-html="$icon('chevron-double-left', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
{# Previous Page #}
|
||||
<button
|
||||
@click="page--; {{ load_fn }}"
|
||||
:disabled="page <= 1"
|
||||
class="px-2 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Previous page"
|
||||
>
|
||||
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
{# Page Numbers #}
|
||||
<template x-for="p in getPageNumbers()" :key="p">
|
||||
<button
|
||||
@click="goToPage(p)"
|
||||
class="px-3 py-1 text-sm font-medium rounded-md border transition-colors"
|
||||
:class="p === page
|
||||
? 'bg-purple-600 text-white border-purple-600 dark:bg-purple-500 dark:border-purple-500'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
|
||||
x-text="p"
|
||||
></button>
|
||||
</template>
|
||||
{# Next Page #}
|
||||
<button
|
||||
@click="page++; {{ load_fn }}"
|
||||
:disabled="page >= totalPages"
|
||||
class="px-2 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Next page"
|
||||
>
|
||||
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
{# Last Page #}
|
||||
<button
|
||||
@click="page = totalPages; {{ load_fn }}"
|
||||
:disabled="page >= totalPages"
|
||||
class="px-2 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Last page"
|
||||
>
|
||||
<span x-html="$icon('chevron-double-right', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro pagination_simple(show_condition="true") %}
|
||||
<div x-show="{{ show_condition }}" class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -172,41 +172,6 @@ if FrontendDetector.is_storefront(host, path, has_store_context=True):
|
||||
| Custom domain | mybakery.lu | /products | STOREFRONT |
|
||||
| Platform root | oms.lu | /pricing | PLATFORM |
|
||||
|
||||
## Migration from RequestContext
|
||||
|
||||
The previous `RequestContext` enum is deprecated. Here's the mapping:
|
||||
|
||||
| Old (RequestContext) | New (FrontendType) |
|
||||
|---------------------|-------------------|
|
||||
| `API` | Use `FrontendDetector.is_api_request()` + FrontendType |
|
||||
| `ADMIN` | `FrontendType.ADMIN` |
|
||||
| `STORE_DASHBOARD` | `FrontendType.STORE` |
|
||||
| `SHOP` | `FrontendType.STOREFRONT` |
|
||||
| `FALLBACK` | `FrontendType.PLATFORM` |
|
||||
|
||||
### Code Migration
|
||||
|
||||
**Before (deprecated):**
|
||||
```python
|
||||
from middleware.context import RequestContext, get_request_context
|
||||
|
||||
context = get_request_context(request)
|
||||
if context == RequestContext.SHOP:
|
||||
# Storefront logic
|
||||
pass
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
frontend_type = get_frontend_type(request)
|
||||
if frontend_type == FrontendType.STOREFRONT:
|
||||
# Storefront logic
|
||||
pass
|
||||
```
|
||||
|
||||
## Request State
|
||||
|
||||
After `FrontendTypeMiddleware` runs, the following is available:
|
||||
@@ -255,10 +220,9 @@ pytest tests/unit/core/test_frontend_detector.py tests/unit/middleware/test_fron
|
||||
|
||||
### DON'T
|
||||
|
||||
1. **Don't use `RequestContext`** - it's deprecated
|
||||
2. **Don't duplicate path detection logic** - use FrontendDetector
|
||||
3. **Don't hardcode path patterns** in middleware - they're centralized in FrontendDetector
|
||||
4. **Don't check `request.state.context_type`** - use `request.state.frontend_type`
|
||||
1. **Don't duplicate path detection logic** - use FrontendDetector
|
||||
2. **Don't hardcode path patterns** in middleware - they're centralized in FrontendDetector
|
||||
3. **Don't check `request.state.context_type`** - use `request.state.frontend_type`
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
|
||||
@@ -186,19 +186,19 @@ All middleware components follow a consistent naming pattern for maintainability
|
||||
```
|
||||
✅ Good:
|
||||
middleware/logging.py
|
||||
middleware/context.py
|
||||
middleware/store_context.py
|
||||
middleware/auth.py
|
||||
|
||||
❌ Avoid:
|
||||
middleware/logging_middleware.py
|
||||
middleware/context_middleware.py
|
||||
middleware/store_context_middleware.py
|
||||
middleware/auth_middleware.py
|
||||
```
|
||||
|
||||
**Rationale**:
|
||||
- Keeps names concise and consistent
|
||||
- Follows Django, Flask, and FastAPI conventions
|
||||
- Makes imports cleaner: `from middleware.logging import LoggingMiddleware`
|
||||
- Makes imports cleaner: `from middleware.store_context import StoreContextMiddleware`
|
||||
- Reduces redundancy (the `middleware/` directory already indicates the purpose)
|
||||
|
||||
#### Test File Naming: Mirror the Source File
|
||||
@@ -206,10 +206,9 @@ middleware/auth_middleware.py
|
||||
Test files directly mirror the middleware filename with a `test_` prefix:
|
||||
|
||||
```
|
||||
middleware/logging.py → tests/unit/middleware/test_logging.py
|
||||
middleware/context.py → tests/unit/middleware/test_context.py
|
||||
middleware/auth.py → tests/unit/middleware/test_auth.py
|
||||
middleware/logging.py → tests/unit/middleware/test_logging.py
|
||||
middleware/store_context.py → tests/unit/middleware/test_store_context.py
|
||||
middleware/auth.py → tests/unit/middleware/test_auth.py
|
||||
```
|
||||
|
||||
#### One Component Per File
|
||||
@@ -229,19 +228,15 @@ class FrontendTypeMiddleware: # ASGI wrapper for frontend detection
|
||||
class AuthManager: # Authentication logic
|
||||
```
|
||||
|
||||
> **Note**: The old `middleware/context.py` with `ContextMiddleware` and `RequestContext` is deprecated.
|
||||
> Use `FrontendTypeMiddleware` and `FrontendType` enum instead.
|
||||
> See [Frontend Detection Architecture](frontend-detection.md) for migration guide.
|
||||
|
||||
#### One Test File Per Component
|
||||
|
||||
Follow the Single Responsibility Principle - each test file tests exactly one component:
|
||||
|
||||
```
|
||||
✅ Good:
|
||||
tests/unit/middleware/test_logging.py # Tests only LoggingMiddleware
|
||||
tests/unit/middleware/test_context.py # Tests only ContextManager/Middleware
|
||||
tests/unit/middleware/test_decorators.py # Tests only rate_limit decorator
|
||||
tests/unit/middleware/test_logging.py # Tests only LoggingMiddleware
|
||||
tests/unit/middleware/test_store_context.py # Tests only StoreContextManager/Middleware
|
||||
tests/unit/middleware/test_decorators.py # Tests only rate_limit decorator
|
||||
|
||||
❌ Avoid:
|
||||
tests/unit/middleware/test_all_middleware.py # Tests multiple components
|
||||
@@ -261,12 +256,12 @@ When importing middleware components, use explicit imports:
|
||||
```python
|
||||
# ✅ Preferred - Explicit and clear
|
||||
from middleware.logging import LoggingMiddleware
|
||||
from middleware.context import ContextManager, RequestContext
|
||||
from middleware.store_context import StoreContextManager
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
# ❌ Avoid - Less clear
|
||||
from middleware import logging_middleware
|
||||
from middleware import context_middleware
|
||||
from middleware import store_context_middleware
|
||||
```
|
||||
|
||||
**See**: [Complete Naming Conventions Guide](../development/naming-conventions.md) for project-wide standards.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Error Handling System - Developer Documentation
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025
|
||||
**Status:** Phase 1 Complete (Admin), Phase 2-3 Pending (Store, Shop)
|
||||
|
||||
---
|
||||
@@ -120,12 +120,13 @@ class RequestContext(str, Enum):
|
||||
**Usage:**
|
||||
|
||||
```python
|
||||
from middleware.context_middleware import get_request_context, RequestContext
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
def my_handler(request: Request):
|
||||
context = get_request_context(request)
|
||||
|
||||
if context == RequestContext.ADMIN:
|
||||
frontend_type = get_frontend_type(request)
|
||||
|
||||
if frontend_type == FrontendType.ADMIN:
|
||||
# Handle admin-specific logic
|
||||
pass
|
||||
```
|
||||
@@ -285,7 +286,7 @@ API endpoints MUST always return JSON, even if the client sends `Accept: text/ht
|
||||
|
||||
### 2. HTML Error Pages (HTML Page Requests)
|
||||
|
||||
**When:**
|
||||
**When:**
|
||||
- NOT an API request
|
||||
- GET request
|
||||
- Accept header includes `text/html`
|
||||
@@ -309,7 +310,7 @@ API endpoints MUST always return JSON, even if the client sends `Accept: text/ht
|
||||
|
||||
### 3. Login Redirects (401 Unauthorized)
|
||||
|
||||
**When:**
|
||||
**When:**
|
||||
- HTML page request
|
||||
- 401 status code
|
||||
|
||||
@@ -628,7 +629,7 @@ message="SELECT * FROM products WHERE id=123 returned 0 rows"
|
||||
error_code="PRODUCT_OUT_OF_STOCK"
|
||||
error_code="PAYMENT_PROCESSING_FAILED"
|
||||
|
||||
# Bad
|
||||
# Bad
|
||||
error_code="error1"
|
||||
error_code="ProductOutOfStockException"
|
||||
```
|
||||
@@ -789,25 +790,18 @@ raise ServiceUnavailableException("Maintenance in progress")
|
||||
**Test Context Detection:**
|
||||
|
||||
```python
|
||||
# tests/test_context_middleware.py
|
||||
from middleware.context_middleware import ContextManager, RequestContext
|
||||
from fastapi import Request
|
||||
# tests/test_frontend_detector.py
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
def test_api_context_detection():
|
||||
request = MockRequest(path="/api/v1/stores")
|
||||
context = ContextManager.detect_context(request)
|
||||
assert context == RequestContext.API
|
||||
def test_admin_detection():
|
||||
assert FrontendDetector.is_admin("localhost", "/admin/dashboard") is True
|
||||
|
||||
def test_admin_context_detection():
|
||||
request = MockRequest(path="/admin/dashboard")
|
||||
context = ContextManager.detect_context(request)
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_shop_context_detection():
|
||||
request = MockRequest(path="/products")
|
||||
request.state.store = MockStore(id=1, name="Test Store")
|
||||
context = ContextManager.detect_context(request)
|
||||
assert context == RequestContext.SHOP
|
||||
def test_storefront_detection():
|
||||
frontend_type = FrontendDetector.detect(
|
||||
host="localhost", path="/storefront/products", has_store_context=True
|
||||
)
|
||||
assert frontend_type == FrontendType.STOREFRONT
|
||||
```
|
||||
|
||||
**Test Error Renderer:**
|
||||
@@ -966,7 +960,7 @@ def test_error_page_performance(client: TestClient):
|
||||
)
|
||||
assert response.status_code == 404
|
||||
duration = time.time() - start
|
||||
|
||||
|
||||
# Should complete in reasonable time (< 5 seconds)
|
||||
assert duration < 5.0
|
||||
print(f"100 error pages rendered in {duration:.2f}s")
|
||||
@@ -1196,45 +1190,26 @@ if context_type == RequestContext.SHOP:
|
||||
|
||||
## API Reference
|
||||
|
||||
### Context Detection
|
||||
### Frontend Type Detection
|
||||
|
||||
#### `ContextManager.detect_context(request: Request) -> RequestContext`
|
||||
#### `get_frontend_type(request: Request) -> FrontendType`
|
||||
|
||||
Detects the request context type.
|
||||
Gets the frontend type for the current request.
|
||||
|
||||
**Parameters:**
|
||||
- `request` (Request): FastAPI request object
|
||||
|
||||
**Returns:**
|
||||
- RequestContext enum value
|
||||
- FrontendType enum value
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
from middleware.context_middleware import ContextManager, RequestContext
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
if context == RequestContext.ADMIN:
|
||||
# Handle admin-specific logic
|
||||
pass
|
||||
```
|
||||
|
||||
#### `get_request_context(request: Request) -> RequestContext`
|
||||
|
||||
Helper function to get current request context from request state.
|
||||
|
||||
**Parameters:**
|
||||
- `request` (Request): FastAPI request object
|
||||
|
||||
**Returns:**
|
||||
- RequestContext enum value (defaults to FALLBACK if not set)
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
from middleware.context_middleware import get_request_context, RequestContext
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
def my_handler(request: Request):
|
||||
context = get_request_context(request)
|
||||
return {"context": context.value}
|
||||
frontend_type = get_frontend_type(request)
|
||||
return {"frontend_type": frontend_type.value}
|
||||
```
|
||||
|
||||
### Error Rendering
|
||||
@@ -1398,28 +1373,28 @@ STATUS_CODE_MESSAGES = {
|
||||
## Best Practices Summary
|
||||
|
||||
### DO:
|
||||
✅ Use specific exception classes for different error scenarios
|
||||
✅ Provide clear, user-friendly error messages
|
||||
✅ Include relevant details in the `details` dictionary
|
||||
✅ Use consistent error codes across the application
|
||||
✅ Test both API and HTML responses
|
||||
✅ Keep error templates simple and accessible
|
||||
✅ Use debug mode responsibly (admin only)
|
||||
✅ Follow the template inheritance pattern
|
||||
✅ Document any new exception types
|
||||
✅ Test error pages manually in browsers
|
||||
✅ Use specific exception classes for different error scenarios
|
||||
✅ Provide clear, user-friendly error messages
|
||||
✅ Include relevant details in the `details` dictionary
|
||||
✅ Use consistent error codes across the application
|
||||
✅ Test both API and HTML responses
|
||||
✅ Keep error templates simple and accessible
|
||||
✅ Use debug mode responsibly (admin only)
|
||||
✅ Follow the template inheritance pattern
|
||||
✅ Document any new exception types
|
||||
✅ Test error pages manually in browsers
|
||||
|
||||
### DON'T:
|
||||
❌ Expose sensitive information in error messages
|
||||
❌ Use generic exceptions for domain-specific errors
|
||||
❌ Return HTML for API endpoints
|
||||
❌ Skip the Accept header check
|
||||
❌ Hardcode HTML in exception handlers
|
||||
❌ Forget to add fallback templates
|
||||
❌ Show technical details to customers
|
||||
❌ Use complex JavaScript in error pages
|
||||
❌ Forget to test 401 redirects
|
||||
❌ Mix API and page response logic
|
||||
❌ Expose sensitive information in error messages
|
||||
❌ Use generic exceptions for domain-specific errors
|
||||
❌ Return HTML for API endpoints
|
||||
❌ Skip the Accept header check
|
||||
❌ Hardcode HTML in exception handlers
|
||||
❌ Forget to add fallback templates
|
||||
❌ Show technical details to customers
|
||||
❌ Use complex JavaScript in error pages
|
||||
❌ Forget to test 401 redirects
|
||||
❌ Mix API and page response logic
|
||||
|
||||
---
|
||||
|
||||
@@ -1435,7 +1410,7 @@ For questions or issues with the error handling system:
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0.0
|
||||
**Last Updated:** 2025
|
||||
**Maintained By:** [Your team name]
|
||||
**Document Version:** 1.0.0
|
||||
**Last Updated:** 2025
|
||||
**Maintained By:** [Your team name]
|
||||
**Next Review:** After Phase 2 & 3 completion
|
||||
|
||||
@@ -160,14 +160,14 @@ Response:
|
||||
"slug": "about",
|
||||
"title": "About Wizamart", // Store override
|
||||
"is_store_override": true,
|
||||
"is_platform_default": false
|
||||
"is_platform_page": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"slug": "shipping",
|
||||
"title": "Shipping Information", // Platform default
|
||||
"is_store_override": false,
|
||||
"is_platform_default": true
|
||||
"is_platform_page": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -582,11 +582,11 @@ Custom exceptions for billing operations (`app/modules/billing/exceptions.py`):
|
||||
|
||||
| Exception | HTTP Status | Description |
|
||||
|-----------|-------------|-------------|
|
||||
| `PaymentSystemNotConfiguredError` | 503 | Stripe not configured |
|
||||
| `TierNotFoundError` | 404 | Invalid tier code |
|
||||
| `StripePriceNotConfiguredError` | 400 | No Stripe price for tier |
|
||||
| `NoActiveSubscriptionError` | 400 | Operation requires subscription |
|
||||
| `SubscriptionNotCancelledError` | 400 | Cannot reactivate active subscription |
|
||||
| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
|
||||
| `TierNotFoundException` | 404 | Invalid tier code |
|
||||
| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
|
||||
| `NoActiveSubscriptionException` | 400 | Operation requires subscription |
|
||||
| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# middleware/context.py
|
||||
"""
|
||||
DEPRECATED: This module is deprecated in favor of middleware/frontend_type.py
|
||||
|
||||
The RequestContext enum and ContextMiddleware have been replaced by:
|
||||
- FrontendType enum (app/modules/enums.py)
|
||||
- FrontendTypeMiddleware (middleware/frontend_type.py)
|
||||
- FrontendDetector (app/core/frontend_detector.py)
|
||||
|
||||
This file is kept for backwards compatibility during the migration period.
|
||||
All new code should use FrontendType and FrontendTypeMiddleware instead.
|
||||
|
||||
Migration guide:
|
||||
- RequestContext.API -> Check with FrontendDetector.is_api_request()
|
||||
- RequestContext.ADMIN -> FrontendType.ADMIN
|
||||
- RequestContext.STORE_DASHBOARD -> FrontendType.STORE
|
||||
- RequestContext.STOREFRONT -> FrontendType.STOREFRONT
|
||||
- RequestContext.FALLBACK -> FrontendType.PLATFORM (or handle API separately)
|
||||
|
||||
- get_request_context(request) -> get_frontend_type(request)
|
||||
- request.state.context_type -> request.state.frontend_type
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestContext(str, Enum):
|
||||
"""
|
||||
DEPRECATED: Use FrontendType enum instead.
|
||||
|
||||
Request context types for the application.
|
||||
This enum is kept for backwards compatibility.
|
||||
|
||||
Migration:
|
||||
- API -> Use FrontendDetector.is_api_request() + FrontendType
|
||||
- ADMIN -> FrontendType.ADMIN
|
||||
- STORE_DASHBOARD -> FrontendType.STORE
|
||||
- STOREFRONT -> FrontendType.STOREFRONT
|
||||
- FALLBACK -> FrontendType.PLATFORM
|
||||
"""
|
||||
|
||||
API = "api"
|
||||
ADMIN = "admin"
|
||||
STORE_DASHBOARD = "store"
|
||||
STOREFRONT = "storefront"
|
||||
FALLBACK = "fallback"
|
||||
|
||||
|
||||
def get_request_context(request: Request) -> RequestContext:
|
||||
"""
|
||||
DEPRECATED: Use get_frontend_type() from middleware.frontend_type instead.
|
||||
|
||||
Helper function to get current request context.
|
||||
This function maps FrontendType to RequestContext for backwards compatibility.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
RequestContext enum value (defaults to FALLBACK if not set)
|
||||
"""
|
||||
warnings.warn(
|
||||
"get_request_context() is deprecated. Use get_frontend_type() from "
|
||||
"middleware.frontend_type instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Get the new frontend_type
|
||||
frontend_type = get_frontend_type(request)
|
||||
|
||||
# Map FrontendType to RequestContext for backwards compatibility
|
||||
mapping = {
|
||||
FrontendType.ADMIN: RequestContext.ADMIN,
|
||||
FrontendType.STORE: RequestContext.STORE_DASHBOARD,
|
||||
FrontendType.STOREFRONT: RequestContext.STOREFRONT,
|
||||
FrontendType.PLATFORM: RequestContext.FALLBACK,
|
||||
}
|
||||
|
||||
# Check if it's an API request
|
||||
if request.url.path.startswith("/api/"):
|
||||
return RequestContext.API
|
||||
|
||||
return mapping.get(frontend_type, RequestContext.FALLBACK)
|
||||
|
||||
|
||||
# ContextManager and ContextMiddleware are removed.
|
||||
# They have been replaced by FrontendDetector and FrontendTypeMiddleware.
|
||||
# Import from the new locations:
|
||||
# from app.core.frontend_detector import FrontendDetector
|
||||
# from middleware.frontend_type import FrontendTypeMiddleware, get_frontend_type
|
||||
@@ -257,18 +257,6 @@ class PlatformContextManager:
|
||||
# For domain-based or default, path remains unchanged
|
||||
return request.url.path
|
||||
|
||||
@staticmethod
|
||||
def is_admin_request(request: Request) -> bool:
|
||||
"""
|
||||
Check if request is for admin interface.
|
||||
|
||||
DEPRECATED: Use FrontendDetector.is_admin() instead.
|
||||
Kept for backwards compatibility.
|
||||
"""
|
||||
host = request.headers.get("host", "")
|
||||
path = request.url.path
|
||||
return FrontendDetector.is_admin(host, path)
|
||||
|
||||
@staticmethod
|
||||
def is_static_file_request(request: Request) -> bool:
|
||||
"""Check if request is for static files."""
|
||||
@@ -478,16 +466,6 @@ class PlatformContextMiddleware:
|
||||
return True
|
||||
return "favicon.ico" in path_lower
|
||||
|
||||
def _is_admin_request(self, path: str, host: str) -> bool:
|
||||
"""
|
||||
Check if request is for admin interface.
|
||||
|
||||
DEPRECATED: Use FrontendDetector.is_admin() instead.
|
||||
Kept for backwards compatibility.
|
||||
"""
|
||||
return FrontendDetector.is_admin(host, path)
|
||||
|
||||
|
||||
def get_current_platform(request: Request) -> Platform | None:
|
||||
"""Helper function to get current platform from request state."""
|
||||
return getattr(request.state, "platform", None)
|
||||
|
||||
@@ -222,18 +222,6 @@ class StoreContextManager:
|
||||
|
||||
return request.url.path
|
||||
|
||||
@staticmethod
|
||||
def is_admin_request(request: Request) -> bool:
|
||||
"""
|
||||
Check if request is for admin interface.
|
||||
|
||||
DEPRECATED: Use FrontendDetector.is_admin() instead.
|
||||
Kept for backwards compatibility.
|
||||
"""
|
||||
host = request.headers.get("host", "")
|
||||
path = request.url.path
|
||||
return FrontendDetector.is_admin(host, path)
|
||||
|
||||
@staticmethod
|
||||
def is_api_request(request: Request) -> bool:
|
||||
"""Check if request is for API endpoints."""
|
||||
@@ -430,7 +418,7 @@ class StoreContextMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
# Skip store detection for admin, static files, and system requests
|
||||
if (
|
||||
StoreContextManager.is_admin_request(request)
|
||||
FrontendDetector.is_admin(request.headers.get("host", ""), request.url.path)
|
||||
or StoreContextManager.is_static_file_request(request)
|
||||
or request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"]
|
||||
):
|
||||
|
||||
@@ -8,8 +8,5 @@ isort>=5.13.2
|
||||
ruff==0.8.4 # Pinned — must match pre-commit hook and local version
|
||||
mypy>=1.13.0
|
||||
|
||||
# Optional: More advanced linting (if you prefer flake8 over ruff)
|
||||
# Note: flake8 itself works with 3.13, but some plugins may not
|
||||
# flake8>=7.1.0
|
||||
# flake8-docstrings>=1.7.0
|
||||
# flake8-import-order>=0.18.2
|
||||
# Pre-commit hooks
|
||||
pre-commit>=3.6.0
|
||||
|
||||
@@ -377,19 +377,6 @@ async def test_clean_path_context(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@api_router.get("/enum")
|
||||
async def test_api_enum(request: Request):
|
||||
"""Test context enum instance."""
|
||||
from middleware.context import RequestContext
|
||||
|
||||
context = getattr(request.state, "context_type", None)
|
||||
return {
|
||||
"is_enum": isinstance(context, RequestContext) if context else False,
|
||||
"enum_name": context.name if context else None,
|
||||
"enum_value": context.value if context else None,
|
||||
}
|
||||
|
||||
|
||||
@api_router.get("/theme")
|
||||
async def test_api_theme(request: Request):
|
||||
"""Test theme in API context."""
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# tests/unit/middleware/test_context.py
|
||||
"""
|
||||
DEPRECATED: Tests for backward compatibility of middleware.context module.
|
||||
|
||||
The ContextMiddleware and ContextManager classes have been replaced by:
|
||||
- FrontendTypeMiddleware (middleware/frontend_type.py)
|
||||
- FrontendDetector (app/core/frontend_detector.py)
|
||||
|
||||
These tests verify the backward compatibility layer still works for code
|
||||
that uses the deprecated RequestContext enum and get_request_context() function.
|
||||
|
||||
For new tests, see:
|
||||
- tests/unit/core/test_frontend_detector.py
|
||||
- tests/unit/middleware/test_frontend_type.py
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from middleware.context import RequestContext, get_request_context
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequestContextEnumBackwardCompatibility:
|
||||
"""Test suite for deprecated RequestContext enum."""
|
||||
|
||||
def test_request_context_values(self):
|
||||
"""Test RequestContext enum has correct values."""
|
||||
assert RequestContext.API.value == "api"
|
||||
assert RequestContext.ADMIN.value == "admin"
|
||||
assert RequestContext.STORE_DASHBOARD.value == "store"
|
||||
assert RequestContext.STOREFRONT.value == "storefront"
|
||||
assert RequestContext.FALLBACK.value == "fallback"
|
||||
|
||||
def test_request_context_types(self):
|
||||
"""Test RequestContext enum values are strings."""
|
||||
for context in RequestContext:
|
||||
assert isinstance(context.value, str)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetRequestContextBackwardCompatibility:
|
||||
"""Test suite for deprecated get_request_context() function."""
|
||||
|
||||
def test_get_request_context_returns_api_for_api_paths(self):
|
||||
"""Test get_request_context returns API for /api/ paths."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/v1/stores")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = None
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_get_request_context_deprecation_warning(self):
|
||||
"""Test get_request_context raises DeprecationWarning."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.ADMIN
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="get_request_context.*deprecated"):
|
||||
get_request_context(request)
|
||||
|
||||
def test_get_request_context_maps_admin(self):
|
||||
"""Test get_request_context maps FrontendType.ADMIN to RequestContext.ADMIN."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.ADMIN
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_get_request_context_maps_store(self):
|
||||
"""Test get_request_context maps FrontendType.STORE to RequestContext.STORE_DASHBOARD."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/store/settings")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.STORE
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.STORE_DASHBOARD
|
||||
|
||||
def test_get_request_context_maps_storefront(self):
|
||||
"""Test get_request_context maps FrontendType.STOREFRONT to RequestContext.STOREFRONT."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/storefront/products")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.STOREFRONT
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.STOREFRONT
|
||||
|
||||
def test_get_request_context_maps_platform_to_fallback(self):
|
||||
"""Test get_request_context maps FrontendType.PLATFORM to RequestContext.FALLBACK."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/pricing")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.PLATFORM
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.FALLBACK
|
||||
@@ -22,6 +22,7 @@ import pytest
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from middleware.platform_context import (
|
||||
DEFAULT_PLATFORM_CODE,
|
||||
PlatformContextManager,
|
||||
@@ -210,7 +211,7 @@ class TestPlatformContextManager:
|
||||
request.headers = {"host": "admin.localhost"}
|
||||
request.url = Mock(path="/dashboard")
|
||||
|
||||
assert PlatformContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("admin.localhost", "/dashboard") is True
|
||||
|
||||
context = PlatformContextManager.detect_platform_context(request)
|
||||
assert context is None
|
||||
@@ -221,26 +222,18 @@ class TestPlatformContextManager:
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/admin/stores")
|
||||
|
||||
assert PlatformContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("localhost", "/admin/stores") is True
|
||||
|
||||
context = PlatformContextManager.detect_platform_context(request)
|
||||
assert context is None
|
||||
|
||||
def test_skip_admin_path_with_port(self):
|
||||
"""Test admin detection with port in host."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "admin.localhost:9999"}
|
||||
request.url = Mock(path="/dashboard")
|
||||
|
||||
assert PlatformContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("admin.localhost:9999", "/dashboard") is True
|
||||
|
||||
def test_not_admin_regular_path(self):
|
||||
"""Test non-admin path is not detected as admin."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/shop/products")
|
||||
|
||||
assert PlatformContextManager.is_admin_request(request) is False
|
||||
assert FrontendDetector.is_admin("localhost", "/shop/products") is False
|
||||
|
||||
# ========================================================================
|
||||
# Static File Detection Tests
|
||||
@@ -925,11 +918,7 @@ class TestEdgeCases:
|
||||
|
||||
def test_admin_subdomain_with_production_domain(self):
|
||||
"""Test admin subdomain detection for production domains."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "admin.oms.lu"}
|
||||
request.url = Mock(path="/dashboard")
|
||||
|
||||
assert PlatformContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("admin.oms.lu", "/dashboard") is True
|
||||
|
||||
def test_static_file_case_insensitive(self):
|
||||
"""Test static file detection is case-insensitive."""
|
||||
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
from middleware.store_context import (
|
||||
StoreContextManager,
|
||||
@@ -372,35 +373,19 @@ class TestStoreContextManager:
|
||||
|
||||
def test_is_admin_request_admin_subdomain(self):
|
||||
"""Test admin request detection from subdomain."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "admin.platform.com"}
|
||||
request.url = Mock(path="/dashboard")
|
||||
|
||||
assert StoreContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("admin.platform.com", "/dashboard") is True
|
||||
|
||||
def test_is_admin_request_admin_path(self):
|
||||
"""Test admin request detection from path."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
|
||||
assert StoreContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("localhost", "/admin/dashboard") is True
|
||||
|
||||
def test_is_admin_request_with_port(self):
|
||||
"""Test admin request detection with port number."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "admin.localhost:8000"}
|
||||
request.url = Mock(path="/dashboard")
|
||||
|
||||
assert StoreContextManager.is_admin_request(request) is True
|
||||
assert FrontendDetector.is_admin("admin.localhost:8000", "/dashboard") is True
|
||||
|
||||
def test_is_not_admin_request(self):
|
||||
"""Test non-admin request."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "store1.platform.com"}
|
||||
request.url = Mock(path="/storefront")
|
||||
|
||||
assert StoreContextManager.is_admin_request(request) is False
|
||||
assert FrontendDetector.is_admin("store1.platform.com", "/storefront") is False
|
||||
|
||||
def test_is_api_request(self):
|
||||
"""Test API request detection."""
|
||||
@@ -599,7 +584,7 @@ class TestStoreContextMiddleware:
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
with patch.object(StoreContextManager, "is_admin_request", return_value=True):
|
||||
with patch.object(FrontendDetector, "is_admin", return_value=True):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.store is None
|
||||
@@ -775,7 +760,7 @@ class TestStoreContextMiddleware:
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
with (
|
||||
patch.object(StoreContextManager, "is_admin_request", return_value=False),
|
||||
patch.object(FrontendDetector, "is_admin", return_value=False),
|
||||
patch.object(
|
||||
StoreContextManager, "is_static_file_request", return_value=False
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user