Compare commits

...

2 Commits

Author SHA1 Message Date
77b76afb3f chore(deps): add pre-commit to dev requirements
Some checks failed
CI / ruff (push) Successful in 10s
CI / dependency-scanning (push) Has been cancelled
CI / audit (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / architecture (push) Has been cancelled
CI / pytest (push) Has started running
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:00:13 +01:00
8968e7d9cd refactor: remove backward compatibility code for pre-launch baseline
Clean up accumulated backward-compat shims, deprecated wrappers, unused
aliases, and legacy code across the codebase. Since the platform is not
live yet, this establishes a clean baseline.

Changes:
- Delete deprecated middleware/context.py (RequestContext, get_request_context)
- Remove unused factory get_store_email_settings_service()
- Remove deprecated pagination_full macro, /admin/platform-homepage route
- Remove ConversationResponse, InvoiceSettings* unprefixed aliases
- Simplify celery_config.py (remove empty LEGACY_TASK_MODULES)
- Standardize billing exceptions: *Error aliases → *Exception names
- Consolidate duplicate TierNotFoundError/FeatureNotFoundError classes
- Remove deprecated is_admin_request() from Store/PlatformContextManager
- Remove is_platform_default field, MediaUploadResponse legacy flat fields
- Remove MediaItemResponse.url alias, update JS to use file_url
- Update all affected tests and documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:58:59 +01:00
32 changed files with 174 additions and 779 deletions

View File

@@ -9,13 +9,6 @@ It includes:
- Task retry policies - Task retry policies
- Sentry integration for error tracking - Sentry integration for error tracking
- Module-based task discovery (discovers tasks from app/modules/*/tasks/) - 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 import logging
@@ -48,38 +41,27 @@ if SENTRY_DSN:
# ============================================================================= # =============================================================================
# TASK DISCOVERY # 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]: def get_all_task_modules() -> list[str]:
""" """
Get all task modules (legacy + module-based). Get all task modules via module-based discovery.
Returns: Returns:
Combined list of legacy task modules and discovered module tasks List of discovered module task packages
""" """
all_modules = list(LEGACY_TASK_MODULES)
try: try:
from app.modules.tasks import discover_module_tasks from app.modules.tasks import discover_module_tasks
module_tasks = discover_module_tasks() module_tasks = discover_module_tasks()
all_modules.extend(module_tasks)
logger.info(f"Discovered {len(module_tasks)} module task packages") logger.info(f"Discovered {len(module_tasks)} module task packages")
return module_tasks
except ImportError as e: except ImportError as e:
logger.warning(f"Could not import module task discovery: {e}") logger.warning(f"Could not import module task discovery: {e}")
except Exception as e: except Exception as e:
logger.error(f"Error discovering module tasks: {e}") logger.error(f"Error discovering module tasks: {e}")
return all_modules return []
# Create Celery application # Create Celery application

View File

@@ -19,24 +19,18 @@ from app.exceptions.base import (
__all__ = [ __all__ = [
# Base billing exception # Base billing exception
"BillingException", "BillingException",
"BillingServiceError", # Alias for backwards compatibility
# Subscription exceptions # Subscription exceptions
"SubscriptionNotFoundException", "SubscriptionNotFoundException",
"NoActiveSubscriptionException", "NoActiveSubscriptionException",
"NoActiveSubscriptionError", # Alias for backwards compatibility
"SubscriptionNotCancelledException", "SubscriptionNotCancelledException",
"SubscriptionNotCancelledError", # Alias for backwards compatibility
"SubscriptionAlreadyCancelledException", "SubscriptionAlreadyCancelledException",
# Tier exceptions # Tier exceptions
"TierNotFoundException", "TierNotFoundException",
"TierNotFoundError",
"TierLimitExceededException", "TierLimitExceededException",
# Payment exceptions # Payment exceptions
"PaymentSystemNotConfiguredException", "PaymentSystemNotConfiguredException",
"PaymentSystemNotConfiguredError", # Alias for backwards compatibility
"StripeNotConfiguredException", "StripeNotConfiguredException",
"StripePriceNotConfiguredException", "StripePriceNotConfiguredException",
"StripePriceNotConfiguredError", # Alias for backwards compatibility
"PaymentFailedException", "PaymentFailedException",
# Webhook exceptions # Webhook exceptions
"InvalidWebhookSignatureException", "InvalidWebhookSignatureException",
@@ -44,7 +38,6 @@ __all__ = [
"WebhookVerificationException", "WebhookVerificationException",
# Feature exceptions # Feature exceptions
"FeatureNotFoundException", "FeatureNotFoundException",
"FeatureNotFoundError",
"FeatureNotAvailableException", "FeatureNotAvailableException",
"InvalidFeatureCodesError", "InvalidFeatureCodesError",
] ]
@@ -62,10 +55,6 @@ class BillingException(BusinessLogicException):
super().__init__(message=message, error_code=error_code, details=details) super().__init__(message=message, error_code=error_code, details=details)
# Alias for backwards compatibility with billing_service.py
BillingServiceError = BillingException
# ============================================================================= # =============================================================================
# Subscription Exceptions # Subscription Exceptions
# ============================================================================= # =============================================================================
@@ -92,10 +81,6 @@ class NoActiveSubscriptionException(BusinessLogicException):
) )
# Alias for backwards compatibility with billing_service.py
NoActiveSubscriptionError = NoActiveSubscriptionException
class SubscriptionNotCancelledException(BusinessLogicException): class SubscriptionNotCancelledException(BusinessLogicException):
"""Raised when trying to reactivate a subscription that is not cancelled.""" """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): class SubscriptionAlreadyCancelledException(BusinessLogicException):
"""Raised when trying to cancel an already cancelled subscription.""" """Raised when trying to cancel an already cancelled subscription."""
@@ -138,18 +119,6 @@ class TierNotFoundException(ResourceNotFoundException):
self.tier_code = tier_code 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): class TierLimitExceededException(BillingException):
"""Raised when a tier limit is exceeded.""" """Raised when a tier limit is exceeded."""
@@ -180,10 +149,6 @@ class PaymentSystemNotConfiguredException(ServiceUnavailableException):
super().__init__(message="Payment system not configured") super().__init__(message="Payment system not configured")
# Alias for backwards compatibility with billing_service.py
PaymentSystemNotConfiguredError = PaymentSystemNotConfiguredException
class StripeNotConfiguredException(BillingException): class StripeNotConfiguredException(BillingException):
"""Raised when Stripe is not configured.""" """Raised when Stripe is not configured."""
@@ -206,10 +171,6 @@ class StripePriceNotConfiguredException(BusinessLogicException):
self.tier_code = tier_code self.tier_code = tier_code
# Alias for backwards compatibility with billing_service.py
StripePriceNotConfiguredError = StripePriceNotConfiguredException
class PaymentFailedException(BillingException): class PaymentFailedException(BillingException):
"""Raised when a payment fails.""" """Raised when a payment fails."""
@@ -277,18 +238,6 @@ class FeatureNotFoundException(ResourceNotFoundException):
self.feature_code = feature_code 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): class FeatureNotAvailableException(BillingException):
"""Raised when a feature is not available in current tier.""" """Raised when a feature is not available in current tier."""

View File

@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api, require_module_access from app.api.deps import get_current_store_api, require_module_access
from app.core.database import get_db 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 ( from app.modules.billing.schemas.billing import (
CategoryListResponse, CategoryListResponse,
FeatureCodeListResponse, FeatureCodeListResponse,
@@ -275,7 +275,7 @@ def get_feature_detail(
# Get feature declaration # Get feature declaration
decl = feature_aggregator.get_declaration(feature_code) decl = feature_aggregator.get_declaration(feature_code)
if not decl: if not decl:
raise FeatureNotFoundError(feature_code) raise FeatureNotFoundException(feature_code)
# Check availability # Check availability
is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code) is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code)

View File

@@ -5,14 +5,6 @@ Billing module services.
Provides subscription management, Stripe integration, and admin operations. 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 ( from app.modules.billing.services.admin_subscription_service import (
AdminSubscriptionService, AdminSubscriptionService,
admin_subscription_service, admin_subscription_service,
@@ -56,12 +48,6 @@ __all__ = [
"admin_subscription_service", "admin_subscription_service",
"BillingService", "BillingService",
"billing_service", "billing_service",
"BillingServiceError",
"PaymentSystemNotConfiguredError",
"TierNotFoundError",
"StripePriceNotConfiguredError",
"NoActiveSubscriptionError",
"SubscriptionNotCancelledError",
"FeatureService", "FeatureService",
"feature_service", "feature_service",
"PlatformPricingService", "PlatformPricingService",

View File

@@ -16,12 +16,12 @@ from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.modules.billing.exceptions import ( from app.modules.billing.exceptions import (
BillingServiceError, BillingException,
NoActiveSubscriptionError, NoActiveSubscriptionException,
PaymentSystemNotConfiguredError, PaymentSystemNotConfiguredException,
StripePriceNotConfiguredError, StripePriceNotConfiguredException,
SubscriptionNotCancelledError, SubscriptionNotCancelledException,
TierNotFoundError, TierNotFoundException,
) )
from app.modules.billing.models import ( from app.modules.billing.models import (
AddOnProduct, AddOnProduct,
@@ -93,7 +93,7 @@ class BillingService:
Get a tier by its code. Get a tier by its code.
Raises: Raises:
TierNotFoundError: If tier doesn't exist TierNotFoundException: If tier doesn't exist
""" """
tier = ( tier = (
db.query(SubscriptionTier) db.query(SubscriptionTier)
@@ -105,7 +105,7 @@ class BillingService:
) )
if not tier: if not tier:
raise TierNotFoundError(tier_code) raise TierNotFoundException(tier_code)
return tier return tier
@@ -126,12 +126,12 @@ class BillingService:
Dict with checkout_url and session_id Dict with checkout_url and session_id
Raises: Raises:
PaymentSystemNotConfiguredError: If Stripe not configured PaymentSystemNotConfiguredException: If Stripe not configured
TierNotFoundError: If tier doesn't exist TierNotFoundException: If tier doesn't exist
StripePriceNotConfiguredError: If price not configured StripePriceNotConfiguredException: If price not configured
""" """
if not stripe_service.is_configured: if not stripe_service.is_configured:
raise PaymentSystemNotConfiguredError() raise PaymentSystemNotConfiguredException()
tier = self.get_tier_by_code(db, tier_code) tier = self.get_tier_by_code(db, tier_code)
@@ -142,7 +142,7 @@ class BillingService:
) )
if not price_id: if not price_id:
raise StripePriceNotConfiguredError(tier_code) raise StripePriceNotConfiguredException(tier_code)
# Check if this is a new subscription (for trial) # Check if this is a new subscription (for trial)
existing_sub = subscription_service.get_merchant_subscription( existing_sub = subscription_service.get_merchant_subscription(
@@ -188,18 +188,18 @@ class BillingService:
Dict with portal_url Dict with portal_url
Raises: Raises:
PaymentSystemNotConfiguredError: If Stripe not configured PaymentSystemNotConfiguredException: If Stripe not configured
NoActiveSubscriptionError: If no subscription with customer ID NoActiveSubscriptionException: If no subscription with customer ID
""" """
if not stripe_service.is_configured: if not stripe_service.is_configured:
raise PaymentSystemNotConfiguredError() raise PaymentSystemNotConfiguredException()
subscription = subscription_service.get_merchant_subscription( subscription = subscription_service.get_merchant_subscription(
db, merchant_id, platform_id db, merchant_id, platform_id
) )
if not subscription or not subscription.stripe_customer_id: if not subscription or not subscription.stripe_customer_id:
raise NoActiveSubscriptionError() raise NoActiveSubscriptionException()
session = stripe_service.create_portal_session( session = stripe_service.create_portal_session(
customer_id=subscription.stripe_customer_id, customer_id=subscription.stripe_customer_id,
@@ -266,14 +266,14 @@ class BillingService:
Dict with message and effective_date Dict with message and effective_date
Raises: Raises:
NoActiveSubscriptionError: If no subscription to cancel NoActiveSubscriptionException: If no subscription to cancel
""" """
subscription = subscription_service.get_merchant_subscription( subscription = subscription_service.get_merchant_subscription(
db, merchant_id, platform_id db, merchant_id, platform_id
) )
if not subscription or not subscription.stripe_subscription_id: if not subscription or not subscription.stripe_subscription_id:
raise NoActiveSubscriptionError() raise NoActiveSubscriptionException()
if stripe_service.is_configured: if stripe_service.is_configured:
stripe_service.cancel_subscription( stripe_service.cancel_subscription(
@@ -308,18 +308,18 @@ class BillingService:
Dict with success message Dict with success message
Raises: Raises:
NoActiveSubscriptionError: If no subscription NoActiveSubscriptionException: If no subscription
SubscriptionNotCancelledError: If not cancelled SubscriptionNotCancelledException: If not cancelled
""" """
subscription = subscription_service.get_merchant_subscription( subscription = subscription_service.get_merchant_subscription(
db, merchant_id, platform_id db, merchant_id, platform_id
) )
if not subscription or not subscription.stripe_subscription_id: if not subscription or not subscription.stripe_subscription_id:
raise NoActiveSubscriptionError() raise NoActiveSubscriptionException()
if not subscription.cancelled_at: if not subscription.cancelled_at:
raise SubscriptionNotCancelledError() raise SubscriptionNotCancelledException()
if stripe_service.is_configured: if stripe_service.is_configured:
stripe_service.reactivate_subscription(subscription.stripe_subscription_id) 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 Dict with amount_due_cents, currency, next_payment_date, line_items
Raises: Raises:
NoActiveSubscriptionError: If no subscription with customer ID NoActiveSubscriptionException: If no subscription with customer ID
""" """
subscription = subscription_service.get_merchant_subscription( subscription = subscription_service.get_merchant_subscription(
db, merchant_id, platform_id db, merchant_id, platform_id
) )
if not subscription or not subscription.stripe_customer_id: if not subscription or not subscription.stripe_customer_id:
raise NoActiveSubscriptionError() raise NoActiveSubscriptionException()
if not stripe_service.is_configured: if not stripe_service.is_configured:
return { return {
@@ -399,16 +399,16 @@ class BillingService:
Dict with message, new_tier, effective_immediately Dict with message, new_tier, effective_immediately
Raises: Raises:
TierNotFoundError: If tier doesn't exist TierNotFoundException: If tier doesn't exist
NoActiveSubscriptionError: If no subscription NoActiveSubscriptionException: If no subscription
StripePriceNotConfiguredError: If price not configured StripePriceNotConfiguredException: If price not configured
""" """
subscription = subscription_service.get_merchant_subscription( subscription = subscription_service.get_merchant_subscription(
db, merchant_id, platform_id db, merchant_id, platform_id
) )
if not subscription or not subscription.stripe_subscription_id: if not subscription or not subscription.stripe_subscription_id:
raise NoActiveSubscriptionError() raise NoActiveSubscriptionException()
tier = self.get_tier_by_code(db, new_tier_code) tier = self.get_tier_by_code(db, new_tier_code)
@@ -419,7 +419,7 @@ class BillingService:
) )
if not price_id: if not price_id:
raise StripePriceNotConfiguredError(new_tier_code) raise StripePriceNotConfiguredException(new_tier_code)
# Update in Stripe # Update in Stripe
if stripe_service.is_configured: if stripe_service.is_configured:
@@ -472,11 +472,11 @@ class BillingService:
Dict with checkout_url and session_id Dict with checkout_url and session_id
Raises: Raises:
PaymentSystemNotConfiguredError: If Stripe not configured PaymentSystemNotConfiguredException: If Stripe not configured
BillingServiceError: If addon doesn't exist BillingException: If addon doesn't exist
""" """
if not stripe_service.is_configured: if not stripe_service.is_configured:
raise PaymentSystemNotConfiguredError() raise PaymentSystemNotConfiguredException()
addon = ( addon = (
db.query(AddOnProduct) db.query(AddOnProduct)
@@ -488,10 +488,10 @@ class BillingService:
) )
if not addon: 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: 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 from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == store_id).first() store = db.query(Store).filter(Store.id == store_id).first()
@@ -522,7 +522,7 @@ class BillingService:
Dict with message and addon_code Dict with message and addon_code
Raises: Raises:
BillingServiceError: If addon not found or not owned by store BillingException: If addon not found or not owned by store
""" """
store_addon = ( store_addon = (
db.query(StoreAddOn) db.query(StoreAddOn)
@@ -534,7 +534,7 @@ class BillingService:
) )
if not store_addon: if not store_addon:
raise BillingServiceError("Add-on not found") raise BillingException("Add-on not found")
addon_code = store_addon.addon_product.code addon_code = store_addon.addon_product.code

View File

@@ -6,6 +6,13 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from app.modules.billing.exceptions import (
NoActiveSubscriptionException,
PaymentSystemNotConfiguredException,
StripePriceNotConfiguredException,
SubscriptionNotCancelledException,
TierNotFoundException,
)
from app.modules.billing.models import ( from app.modules.billing.models import (
AddOnProduct, AddOnProduct,
BillingHistory, BillingHistory,
@@ -13,14 +20,7 @@ from app.modules.billing.models import (
SubscriptionStatus, SubscriptionStatus,
SubscriptionTier, SubscriptionTier,
) )
from app.modules.billing.services.billing_service import ( from app.modules.billing.services.billing_service import BillingService
BillingService,
NoActiveSubscriptionError,
PaymentSystemNotConfiguredError,
StripePriceNotConfiguredError,
SubscriptionNotCancelledError,
TierNotFoundError,
)
# ============================================================================ # ============================================================================
# Tier Lookup # Tier Lookup
@@ -41,17 +41,17 @@ class TestBillingServiceTiers:
assert tier.code == "essential" assert tier.code == "essential"
def test_get_tier_by_code_not_found(self, db): def test_get_tier_by_code_not_found(self, db):
"""Nonexistent tier raises TierNotFoundError.""" """Nonexistent tier raises TierNotFoundException."""
with pytest.raises(TierNotFoundError) as exc_info: with pytest.raises(TierNotFoundException) as exc_info:
self.service.get_tier_by_code(db, "nonexistent") self.service.get_tier_by_code(db, "nonexistent")
assert exc_info.value.tier_code == "nonexistent" assert exc_info.value.tier_code == "nonexistent"
def test_get_tier_by_code_inactive_not_returned(self, db, bs_tier_essential): 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 bs_tier_essential.is_active = False
db.flush() db.flush()
with pytest.raises(TierNotFoundError): with pytest.raises(TierNotFoundException):
self.service.get_tier_by_code(db, "essential") self.service.get_tier_by_code(db, "essential")
@@ -249,8 +249,8 @@ class TestBillingServiceChangeTier:
self.service = BillingService() self.service = BillingService()
def test_change_tier_no_subscription_raises(self, db, bs_tiers): def test_change_tier_no_subscription_raises(self, db, bs_tiers):
"""Raises NoActiveSubscriptionError when no subscription exists.""" """Raises NoActiveSubscriptionException when no subscription exists."""
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.change_tier(db, 99999, 99999, "professional", False) self.service.change_tier(db, 99999, 99999, "professional", False)
def test_change_tier_no_stripe_subscription_raises( def test_change_tier_no_stripe_subscription_raises(
@@ -258,7 +258,7 @@ class TestBillingServiceChangeTier:
): ):
"""Raises when subscription has no stripe_subscription_id.""" """Raises when subscription has no stripe_subscription_id."""
# bs_subscription has no Stripe IDs # bs_subscription has no Stripe IDs
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.change_tier( self.service.change_tier(
db, db,
bs_subscription.merchant_id, bs_subscription.merchant_id,
@@ -270,8 +270,8 @@ class TestBillingServiceChangeTier:
def test_change_tier_nonexistent_tier_raises( def test_change_tier_nonexistent_tier_raises(
self, db, bs_stripe_subscription self, db, bs_stripe_subscription
): ):
"""Raises TierNotFoundError for nonexistent tier.""" """Raises TierNotFoundException for nonexistent tier."""
with pytest.raises(TierNotFoundError): with pytest.raises(TierNotFoundException):
self.service.change_tier( self.service.change_tier(
db, db,
bs_stripe_subscription.merchant_id, bs_stripe_subscription.merchant_id,
@@ -283,9 +283,9 @@ class TestBillingServiceChangeTier:
def test_change_tier_no_price_id_raises( def test_change_tier_no_price_id_raises(
self, db, bs_stripe_subscription, bs_tiers 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 # bs_tiers have no stripe_price_* set
with pytest.raises(StripePriceNotConfiguredError): with pytest.raises(StripePriceNotConfiguredException):
self.service.change_tier( self.service.change_tier(
db, db,
bs_stripe_subscription.merchant_id, bs_stripe_subscription.merchant_id,
@@ -382,12 +382,12 @@ class TestBillingServiceCancel:
def test_cancel_no_subscription_raises(self, db): def test_cancel_no_subscription_raises(self, db):
"""Raises when no subscription found.""" """Raises when no subscription found."""
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.cancel_subscription(db, 99999, 99999, None, False) self.service.cancel_subscription(db, 99999, 99999, None, False)
def test_cancel_no_stripe_id_raises(self, db, bs_subscription): def test_cancel_no_stripe_id_raises(self, db, bs_subscription):
"""Raises when subscription has no stripe_subscription_id.""" """Raises when subscription has no stripe_subscription_id."""
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.cancel_subscription( self.service.cancel_subscription(
db, db,
bs_subscription.merchant_id, bs_subscription.merchant_id,
@@ -431,12 +431,12 @@ class TestBillingServiceReactivate:
def test_reactivate_no_subscription_raises(self, db): def test_reactivate_no_subscription_raises(self, db):
"""Raises when no subscription found.""" """Raises when no subscription found."""
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.reactivate_subscription(db, 99999, 99999) self.service.reactivate_subscription(db, 99999, 99999)
def test_reactivate_not_cancelled_raises(self, db, bs_stripe_subscription): def test_reactivate_not_cancelled_raises(self, db, bs_stripe_subscription):
"""Raises SubscriptionNotCancelledError when not cancelled.""" """Raises SubscriptionNotCancelledException when not cancelled."""
with pytest.raises(SubscriptionNotCancelledError): with pytest.raises(SubscriptionNotCancelledException):
self.service.reactivate_subscription( self.service.reactivate_subscription(
db, db,
bs_stripe_subscription.merchant_id, bs_stripe_subscription.merchant_id,
@@ -480,25 +480,25 @@ class TestBillingServiceCheckout:
self.service = BillingService() self.service = BillingService()
def test_checkout_stripe_not_configured_raises(self, db, bs_tiers_with_stripe): 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( with patch(
"app.modules.billing.services.billing_service.stripe_service" "app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe: ) as mock_stripe:
mock_stripe.is_configured = False mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredError): with pytest.raises(PaymentSystemNotConfiguredException):
self.service.create_checkout_session( self.service.create_checkout_session(
db, 1, 1, "essential", False, "http://ok", "http://cancel" db, 1, 1, "essential", False, "http://ok", "http://cancel"
) )
def test_checkout_nonexistent_tier_raises(self, db): def test_checkout_nonexistent_tier_raises(self, db):
"""Raises TierNotFoundError for nonexistent tier.""" """Raises TierNotFoundException for nonexistent tier."""
with patch( with patch(
"app.modules.billing.services.billing_service.stripe_service" "app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe: ) as mock_stripe:
mock_stripe.is_configured = True mock_stripe.is_configured = True
with pytest.raises(TierNotFoundError): with pytest.raises(TierNotFoundException):
self.service.create_checkout_session( self.service.create_checkout_session(
db, 1, 1, "nonexistent", False, "http://ok", "http://cancel" db, 1, 1, "nonexistent", False, "http://ok", "http://cancel"
) )
@@ -518,23 +518,23 @@ class TestBillingServicePortal:
self.service = BillingService() self.service = BillingService()
def test_portal_stripe_not_configured_raises(self, db): def test_portal_stripe_not_configured_raises(self, db):
"""Raises PaymentSystemNotConfiguredError when Stripe is off.""" """Raises PaymentSystemNotConfiguredException when Stripe is off."""
with patch( with patch(
"app.modules.billing.services.billing_service.stripe_service" "app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe: ) as mock_stripe:
mock_stripe.is_configured = False mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredError): with pytest.raises(PaymentSystemNotConfiguredException):
self.service.create_portal_session(db, 1, 1, "http://return") self.service.create_portal_session(db, 1, 1, "http://return")
def test_portal_no_subscription_raises(self, db): def test_portal_no_subscription_raises(self, db):
"""Raises NoActiveSubscriptionError when no subscription found.""" """Raises NoActiveSubscriptionException when no subscription found."""
with patch( with patch(
"app.modules.billing.services.billing_service.stripe_service" "app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe: ) as mock_stripe:
mock_stripe.is_configured = True mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.create_portal_session(db, 99999, 99999, "http://return") self.service.create_portal_session(db, 99999, 99999, "http://return")
def test_portal_no_customer_id_raises(self, db, bs_subscription): def test_portal_no_customer_id_raises(self, db, bs_subscription):
@@ -544,7 +544,7 @@ class TestBillingServicePortal:
) as mock_stripe: ) as mock_stripe:
mock_stripe.is_configured = True mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.create_portal_session( self.service.create_portal_session(
db, db,
bs_subscription.merchant_id, bs_subscription.merchant_id,
@@ -568,12 +568,12 @@ class TestBillingServiceUpcomingInvoice:
def test_upcoming_invoice_no_subscription_raises(self, db): def test_upcoming_invoice_no_subscription_raises(self, db):
"""Raises when no subscription exists.""" """Raises when no subscription exists."""
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.get_upcoming_invoice(db, 99999, 99999) self.service.get_upcoming_invoice(db, 99999, 99999)
def test_upcoming_invoice_no_customer_id_raises(self, db, bs_subscription): def test_upcoming_invoice_no_customer_id_raises(self, db, bs_subscription):
"""Raises when subscription has no stripe_customer_id.""" """Raises when subscription has no stripe_customer_id."""
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionException):
self.service.get_upcoming_invoice( self.service.get_upcoming_invoice(
db, bs_subscription.merchant_id, bs_subscription.platform_id db, bs_subscription.merchant_id, bs_subscription.platform_id
) )

View File

@@ -6,7 +6,7 @@ Admin pages for managing platform and store content pages.
""" """
from fastapi import APIRouter, Depends, Path, Request from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access 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) @router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
async def admin_content_pages_list( async def admin_content_pages_list(
request: Request, request: Request,

View File

@@ -90,7 +90,6 @@ class ContentPageResponse(BaseModel):
show_in_header: bool show_in_header: bool
show_in_legal: bool show_in_legal: bool
is_platform_page: bool = False is_platform_page: bool = False
is_platform_default: bool = False # Deprecated: use is_platform_page
is_store_default: bool = False is_store_default: bool = False
is_store_override: bool = False is_store_override: bool = False
page_tier: str | None = None page_tier: str | None = None

View File

@@ -37,7 +37,6 @@ class MediaItemResponse(BaseModel):
filename: str filename: str
original_filename: str | None = None original_filename: str | None = None
file_url: str file_url: str
url: str | None = None # Alias for file_url for JS compatibility
thumbnail_url: str | None = None thumbnail_url: str | None = None
media_type: str # image, video, document media_type: str # image, video, document
mime_type: str | None = None mime_type: str | None = None
@@ -53,11 +52,6 @@ class MediaItemResponse(BaseModel):
model_config = {"from_attributes": True} 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): class MediaListResponse(BaseModel):
"""Paginated list of media items.""" """Paginated list of media items."""
@@ -80,13 +74,6 @@ class MediaUploadResponse(BaseModel):
success: bool = True success: bool = True
message: str | None = None message: str | None = None
media: MediaItemResponse | 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): class UploadedFileInfo(BaseModel):

View File

@@ -15,7 +15,6 @@ from app.modules.cms.services.media_service import (
) )
from app.modules.cms.services.store_email_settings_service import ( from app.modules.cms.services.store_email_settings_service import (
StoreEmailSettingsService, StoreEmailSettingsService,
get_store_email_settings_service, # Deprecated: use store_email_settings_service
store_email_settings_service, store_email_settings_service,
) )
from app.modules.cms.services.store_theme_service import ( from app.modules.cms.services.store_theme_service import (
@@ -32,5 +31,4 @@ __all__ = [
"store_theme_service", "store_theme_service",
"StoreEmailSettingsService", "StoreEmailSettingsService",
"store_email_settings_service", "store_email_settings_service",
"get_store_email_settings_service", # Deprecated
] ]

View File

@@ -481,14 +481,3 @@ class StoreEmailSettingsService:
# Module-level service instance (singleton pattern) # Module-level service instance (singleton pattern)
store_email_settings_service = StoreEmailSettingsService() 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

View File

@@ -262,9 +262,9 @@ function mediaPickerMixin(storeIdGetter, multiSelect = false) {
*/ */
setMainImage(media) { setMainImage(media) {
if (this.form) { 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) { addAdditionalImages(mediaList) {
if (this.form && Array.isArray(this.form.additional_images)) { 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 = [
...this.form.additional_images, ...this.form.additional_images,
...newUrls ...newUrls
]; ];
} }
mediaPickerLog.info('Additional images added:', mediaList.map(m => m.url)); mediaPickerLog.info('Additional images added:', mediaList.map(m => m.file_url));
}, },
/** /**

View File

@@ -73,7 +73,7 @@ function storeContentPagesManager() {
// Platform pages - filter to only show actual platform defaults // Platform pages - filter to only show actual platform defaults
const allPages = platformResponse.data || platformResponse || []; 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) // Store's custom pages (includes overrides)
this.customPages = storeResponse.data || storeResponse || []; this.customPages = storeResponse.data || storeResponse || [];

View File

@@ -34,7 +34,6 @@ from app.modules.messaging.schemas.message import (
ConversationCreate, ConversationCreate,
ConversationDetailResponse, ConversationDetailResponse,
ConversationListResponse, ConversationListResponse,
ConversationResponse,
ConversationSummary, ConversationSummary,
MarkReadResponse, MarkReadResponse,
# Message schemas # Message schemas
@@ -90,7 +89,6 @@ __all__ = [
"ConversationSummary", "ConversationSummary",
"ConversationDetailResponse", "ConversationDetailResponse",
"ConversationListResponse", "ConversationListResponse",
"ConversationResponse",
# Unread count # Unread count
"UnreadCountResponse", "UnreadCountResponse",
# Notification preferences # Notification preferences

View File

@@ -192,10 +192,6 @@ class ConversationListResponse(BaseModel):
limit: int limit: int
# Backward compatibility alias
ConversationResponse = ConversationDetailResponse
# ============================================================================ # ============================================================================
# Unread Count Schemas # Unread Count Schemas
# ============================================================================ # ============================================================================

View File

@@ -21,10 +21,6 @@ from app.modules.orders.schemas.invoice import (
InvoiceResponse, InvoiceResponse,
# Address schemas # Address schemas
InvoiceSellerDetails, InvoiceSellerDetails,
# Backward compatibility
InvoiceSettingsCreate,
InvoiceSettingsResponse,
InvoiceSettingsUpdate,
InvoiceStatsResponse, InvoiceStatsResponse,
InvoiceStatusUpdate, InvoiceStatusUpdate,
# Invoice settings schemas # Invoice settings schemas
@@ -148,8 +144,4 @@ __all__ = [
# PDF # PDF
"InvoicePDFGeneratedResponse", "InvoicePDFGeneratedResponse",
"InvoiceStatsResponse", "InvoiceStatsResponse",
# Backward compatibility
"InvoiceSettingsCreate",
"InvoiceSettingsUpdate",
"InvoiceSettingsResponse",
] ]

View File

@@ -308,9 +308,3 @@ class InvoiceStatsResponse(BaseModel):
@property @property
def total_revenue(self) -> float: def total_revenue(self) -> float:
return self.total_revenue_cents / 100 return self.total_revenue_cents / 100
# Backward compatibility re-exports
InvoiceSettingsCreate = StoreInvoiceSettingsCreate
InvoiceSettingsUpdate = StoreInvoiceSettingsUpdate
InvoiceSettingsResponse = StoreInvoiceSettingsResponse

View File

@@ -61,7 +61,7 @@ class RouteInfo:
include_in_schema: bool = True include_in_schema: bool = True
module_code: str = "" module_code: str = ""
route_type: str = "" # "api" or "pages" 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) priority: int = 0 # Higher = registered later (for catch-all routes)
custom_prefix: str = "" # Custom prefix from ROUTE_CONFIG custom_prefix: str = "" # Custom prefix from ROUTE_CONFIG
@@ -76,7 +76,7 @@ def discover_module_routes() -> list[RouteInfo]:
Route discovery looks for: Route discovery looks for:
- routes/api/admin.py -> admin API routes - routes/api/admin.py -> admin API routes
- routes/api/store.py -> store 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/admin.py -> admin page routes
- routes/pages/store.py -> store page routes - routes/pages/store.py -> store page routes

View File

@@ -93,92 +93,6 @@
{{ pagination_simple() }} {{ 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") %} {% 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 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"> <div class="flex items-center">

View File

@@ -172,41 +172,6 @@ if FrontendDetector.is_storefront(host, path, has_store_context=True):
| Custom domain | mybakery.lu | /products | STOREFRONT | | Custom domain | mybakery.lu | /products | STOREFRONT |
| Platform root | oms.lu | /pricing | PLATFORM | | 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 ## Request State
After `FrontendTypeMiddleware` runs, the following is available: 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 ### DON'T
1. **Don't use `RequestContext`** - it's deprecated 1. **Don't duplicate path detection logic** - use FrontendDetector
2. **Don't duplicate path detection logic** - use FrontendDetector 2. **Don't hardcode path patterns** in middleware - they're centralized in FrontendDetector
3. **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`
4. **Don't check `request.state.context_type`** - use `request.state.frontend_type`
## Architecture Rules ## Architecture Rules

View File

@@ -186,19 +186,19 @@ All middleware components follow a consistent naming pattern for maintainability
``` ```
✅ Good: ✅ Good:
middleware/logging.py middleware/logging.py
middleware/context.py middleware/store_context.py
middleware/auth.py middleware/auth.py
❌ Avoid: ❌ Avoid:
middleware/logging_middleware.py middleware/logging_middleware.py
middleware/context_middleware.py middleware/store_context_middleware.py
middleware/auth_middleware.py middleware/auth_middleware.py
``` ```
**Rationale**: **Rationale**:
- Keeps names concise and consistent - Keeps names concise and consistent
- Follows Django, Flask, and FastAPI conventions - 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) - Reduces redundancy (the `middleware/` directory already indicates the purpose)
#### Test File Naming: Mirror the Source File #### 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: Test files directly mirror the middleware filename with a `test_` prefix:
``` ```
middleware/logging.py → tests/unit/middleware/test_logging.py 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/store_context.py → tests/unit/middleware/test_store_context.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 #### One Component Per File
@@ -229,19 +228,15 @@ class FrontendTypeMiddleware: # ASGI wrapper for frontend detection
class AuthManager: # Authentication logic 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 #### One Test File Per Component
Follow the Single Responsibility Principle - each test file tests exactly one component: Follow the Single Responsibility Principle - each test file tests exactly one component:
``` ```
✅ Good: ✅ Good:
tests/unit/middleware/test_logging.py # Tests only LoggingMiddleware tests/unit/middleware/test_logging.py # Tests only LoggingMiddleware
tests/unit/middleware/test_context.py # Tests only ContextManager/Middleware tests/unit/middleware/test_store_context.py # Tests only StoreContextManager/Middleware
tests/unit/middleware/test_decorators.py # Tests only rate_limit decorator tests/unit/middleware/test_decorators.py # Tests only rate_limit decorator
❌ Avoid: ❌ Avoid:
tests/unit/middleware/test_all_middleware.py # Tests multiple components tests/unit/middleware/test_all_middleware.py # Tests multiple components
@@ -261,12 +256,12 @@ When importing middleware components, use explicit imports:
```python ```python
# ✅ Preferred - Explicit and clear # ✅ Preferred - Explicit and clear
from middleware.logging import LoggingMiddleware from middleware.logging import LoggingMiddleware
from middleware.context import ContextManager, RequestContext from middleware.store_context import StoreContextManager
from middleware.auth import AuthManager from middleware.auth import AuthManager
# ❌ Avoid - Less clear # ❌ Avoid - Less clear
from middleware import logging_middleware 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. **See**: [Complete Naming Conventions Guide](../development/naming-conventions.md) for project-wide standards.

View File

@@ -1,7 +1,7 @@
# Error Handling System - Developer Documentation # Error Handling System - Developer Documentation
**Version:** 1.0.0 **Version:** 1.0.0
**Last Updated:** 2025 **Last Updated:** 2025
**Status:** Phase 1 Complete (Admin), Phase 2-3 Pending (Store, Shop) **Status:** Phase 1 Complete (Admin), Phase 2-3 Pending (Store, Shop)
--- ---
@@ -120,12 +120,13 @@ class RequestContext(str, Enum):
**Usage:** **Usage:**
```python ```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): def my_handler(request: Request):
context = get_request_context(request) frontend_type = get_frontend_type(request)
if context == RequestContext.ADMIN: if frontend_type == FrontendType.ADMIN:
# Handle admin-specific logic # Handle admin-specific logic
pass 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) ### 2. HTML Error Pages (HTML Page Requests)
**When:** **When:**
- NOT an API request - NOT an API request
- GET request - GET request
- Accept header includes `text/html` - 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) ### 3. Login Redirects (401 Unauthorized)
**When:** **When:**
- HTML page request - HTML page request
- 401 status code - 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="PRODUCT_OUT_OF_STOCK"
error_code="PAYMENT_PROCESSING_FAILED" error_code="PAYMENT_PROCESSING_FAILED"
# Bad # Bad
error_code="error1" error_code="error1"
error_code="ProductOutOfStockException" error_code="ProductOutOfStockException"
``` ```
@@ -789,25 +790,18 @@ raise ServiceUnavailableException("Maintenance in progress")
**Test Context Detection:** **Test Context Detection:**
```python ```python
# tests/test_context_middleware.py # tests/test_frontend_detector.py
from middleware.context_middleware import ContextManager, RequestContext from app.core.frontend_detector import FrontendDetector
from fastapi import Request from app.modules.enums import FrontendType
def test_api_context_detection(): def test_admin_detection():
request = MockRequest(path="/api/v1/stores") assert FrontendDetector.is_admin("localhost", "/admin/dashboard") is True
context = ContextManager.detect_context(request)
assert context == RequestContext.API
def test_admin_context_detection(): def test_storefront_detection():
request = MockRequest(path="/admin/dashboard") frontend_type = FrontendDetector.detect(
context = ContextManager.detect_context(request) host="localhost", path="/storefront/products", has_store_context=True
assert context == RequestContext.ADMIN )
assert frontend_type == FrontendType.STOREFRONT
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
``` ```
**Test Error Renderer:** **Test Error Renderer:**
@@ -966,7 +960,7 @@ def test_error_page_performance(client: TestClient):
) )
assert response.status_code == 404 assert response.status_code == 404
duration = time.time() - start duration = time.time() - start
# Should complete in reasonable time (< 5 seconds) # Should complete in reasonable time (< 5 seconds)
assert duration < 5.0 assert duration < 5.0
print(f"100 error pages rendered in {duration:.2f}s") print(f"100 error pages rendered in {duration:.2f}s")
@@ -1196,45 +1190,26 @@ if context_type == RequestContext.SHOP:
## API Reference ## 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:** **Parameters:**
- `request` (Request): FastAPI request object - `request` (Request): FastAPI request object
**Returns:** **Returns:**
- RequestContext enum value - FrontendType enum value
**Example:** **Example:**
```python ```python
from middleware.context_middleware import ContextManager, RequestContext from middleware.frontend_type import get_frontend_type
from app.modules.enums import FrontendType
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
def my_handler(request: Request): def my_handler(request: Request):
context = get_request_context(request) frontend_type = get_frontend_type(request)
return {"context": context.value} return {"frontend_type": frontend_type.value}
``` ```
### Error Rendering ### Error Rendering
@@ -1398,28 +1373,28 @@ STATUS_CODE_MESSAGES = {
## Best Practices Summary ## Best Practices Summary
### DO: ### DO:
✅ Use specific exception classes for different error scenarios ✅ Use specific exception classes for different error scenarios
✅ Provide clear, user-friendly error messages ✅ Provide clear, user-friendly error messages
✅ Include relevant details in the `details` dictionary ✅ Include relevant details in the `details` dictionary
✅ Use consistent error codes across the application ✅ Use consistent error codes across the application
✅ Test both API and HTML responses ✅ Test both API and HTML responses
✅ Keep error templates simple and accessible ✅ Keep error templates simple and accessible
✅ Use debug mode responsibly (admin only) ✅ Use debug mode responsibly (admin only)
✅ Follow the template inheritance pattern ✅ Follow the template inheritance pattern
✅ Document any new exception types ✅ Document any new exception types
✅ Test error pages manually in browsers ✅ Test error pages manually in browsers
### DON'T: ### DON'T:
❌ Expose sensitive information in error messages ❌ Expose sensitive information in error messages
❌ Use generic exceptions for domain-specific errors ❌ Use generic exceptions for domain-specific errors
❌ Return HTML for API endpoints ❌ Return HTML for API endpoints
❌ Skip the Accept header check ❌ Skip the Accept header check
❌ Hardcode HTML in exception handlers ❌ Hardcode HTML in exception handlers
❌ Forget to add fallback templates ❌ Forget to add fallback templates
❌ Show technical details to customers ❌ Show technical details to customers
❌ Use complex JavaScript in error pages ❌ Use complex JavaScript in error pages
❌ Forget to test 401 redirects ❌ Forget to test 401 redirects
❌ Mix API and page response logic ❌ Mix API and page response logic
--- ---
@@ -1435,7 +1410,7 @@ For questions or issues with the error handling system:
--- ---
**Document Version:** 1.0.0 **Document Version:** 1.0.0
**Last Updated:** 2025 **Last Updated:** 2025
**Maintained By:** [Your team name] **Maintained By:** [Your team name]
**Next Review:** After Phase 2 & 3 completion **Next Review:** After Phase 2 & 3 completion

View File

@@ -160,14 +160,14 @@ Response:
"slug": "about", "slug": "about",
"title": "About Wizamart", // Store override "title": "About Wizamart", // Store override
"is_store_override": true, "is_store_override": true,
"is_platform_default": false "is_platform_page": false
}, },
{ {
"id": 2, "id": 2,
"slug": "shipping", "slug": "shipping",
"title": "Shipping Information", // Platform default "title": "Shipping Information", // Platform default
"is_store_override": false, "is_store_override": false,
"is_platform_default": true "is_platform_page": true
} }
] ]
``` ```

View File

@@ -582,11 +582,11 @@ Custom exceptions for billing operations (`app/modules/billing/exceptions.py`):
| Exception | HTTP Status | Description | | Exception | HTTP Status | Description |
|-----------|-------------|-------------| |-----------|-------------|-------------|
| `PaymentSystemNotConfiguredError` | 503 | Stripe not configured | | `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
| `TierNotFoundError` | 404 | Invalid tier code | | `TierNotFoundException` | 404 | Invalid tier code |
| `StripePriceNotConfiguredError` | 400 | No Stripe price for tier | | `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
| `NoActiveSubscriptionError` | 400 | Operation requires subscription | | `NoActiveSubscriptionException` | 400 | Operation requires subscription |
| `SubscriptionNotCancelledError` | 400 | Cannot reactivate active subscription | | `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
## Testing ## Testing

View File

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

View File

@@ -257,18 +257,6 @@ class PlatformContextManager:
# For domain-based or default, path remains unchanged # For domain-based or default, path remains unchanged
return request.url.path 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 @staticmethod
def is_static_file_request(request: Request) -> bool: def is_static_file_request(request: Request) -> bool:
"""Check if request is for static files.""" """Check if request is for static files."""
@@ -478,16 +466,6 @@ class PlatformContextMiddleware:
return True return True
return "favicon.ico" in path_lower 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: def get_current_platform(request: Request) -> Platform | None:
"""Helper function to get current platform from request state.""" """Helper function to get current platform from request state."""
return getattr(request.state, "platform", None) return getattr(request.state, "platform", None)

View File

@@ -222,18 +222,6 @@ class StoreContextManager:
return request.url.path 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 @staticmethod
def is_api_request(request: Request) -> bool: def is_api_request(request: Request) -> bool:
"""Check if request is for API endpoints.""" """Check if request is for API endpoints."""
@@ -430,7 +418,7 @@ class StoreContextMiddleware(BaseHTTPMiddleware):
""" """
# Skip store detection for admin, static files, and system requests # Skip store detection for admin, static files, and system requests
if ( if (
StoreContextManager.is_admin_request(request) FrontendDetector.is_admin(request.headers.get("host", ""), request.url.path)
or StoreContextManager.is_static_file_request(request) or StoreContextManager.is_static_file_request(request)
or request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"] or request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"]
): ):

View File

@@ -8,8 +8,5 @@ isort>=5.13.2
ruff==0.8.4 # Pinned — must match pre-commit hook and local version ruff==0.8.4 # Pinned — must match pre-commit hook and local version
mypy>=1.13.0 mypy>=1.13.0
# Optional: More advanced linting (if you prefer flake8 over ruff) # Pre-commit hooks
# Note: flake8 itself works with 3.13, but some plugins may not pre-commit>=3.6.0
# flake8>=7.1.0
# flake8-docstrings>=1.7.0
# flake8-import-order>=0.18.2

View File

@@ -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") @api_router.get("/theme")
async def test_api_theme(request: Request): async def test_api_theme(request: Request):
"""Test theme in API context.""" """Test theme in API context."""

View File

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

View File

@@ -22,6 +22,7 @@ import pytest
from fastapi import Request from fastapi import Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.frontend_detector import FrontendDetector
from middleware.platform_context import ( from middleware.platform_context import (
DEFAULT_PLATFORM_CODE, DEFAULT_PLATFORM_CODE,
PlatformContextManager, PlatformContextManager,
@@ -210,7 +211,7 @@ class TestPlatformContextManager:
request.headers = {"host": "admin.localhost"} request.headers = {"host": "admin.localhost"}
request.url = Mock(path="/dashboard") 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) context = PlatformContextManager.detect_platform_context(request)
assert context is None assert context is None
@@ -221,26 +222,18 @@ class TestPlatformContextManager:
request.headers = {"host": "localhost"} request.headers = {"host": "localhost"}
request.url = Mock(path="/admin/stores") 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) context = PlatformContextManager.detect_platform_context(request)
assert context is None assert context is None
def test_skip_admin_path_with_port(self): def test_skip_admin_path_with_port(self):
"""Test admin detection with port in host.""" """Test admin detection with port in host."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("admin.localhost:9999", "/dashboard") is True
request.headers = {"host": "admin.localhost:9999"}
request.url = Mock(path="/dashboard")
assert PlatformContextManager.is_admin_request(request) is True
def test_not_admin_regular_path(self): def test_not_admin_regular_path(self):
"""Test non-admin path is not detected as admin.""" """Test non-admin path is not detected as admin."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("localhost", "/shop/products") is False
request.headers = {"host": "localhost"}
request.url = Mock(path="/shop/products")
assert PlatformContextManager.is_admin_request(request) is False
# ======================================================================== # ========================================================================
# Static File Detection Tests # Static File Detection Tests
@@ -925,11 +918,7 @@ class TestEdgeCases:
def test_admin_subdomain_with_production_domain(self): def test_admin_subdomain_with_production_domain(self):
"""Test admin subdomain detection for production domains.""" """Test admin subdomain detection for production domains."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("admin.oms.lu", "/dashboard") is True
request.headers = {"host": "admin.oms.lu"}
request.url = Mock(path="/dashboard")
assert PlatformContextManager.is_admin_request(request) is True
def test_static_file_case_insensitive(self): def test_static_file_case_insensitive(self):
"""Test static file detection is case-insensitive.""" """Test static file detection is case-insensitive."""

View File

@@ -17,6 +17,7 @@ import pytest
from fastapi import Request from fastapi import Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.frontend_detector import FrontendDetector
from app.modules.tenancy.exceptions import StoreNotFoundException from app.modules.tenancy.exceptions import StoreNotFoundException
from middleware.store_context import ( from middleware.store_context import (
StoreContextManager, StoreContextManager,
@@ -372,35 +373,19 @@ class TestStoreContextManager:
def test_is_admin_request_admin_subdomain(self): def test_is_admin_request_admin_subdomain(self):
"""Test admin request detection from subdomain.""" """Test admin request detection from subdomain."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("admin.platform.com", "/dashboard") is True
request.headers = {"host": "admin.platform.com"}
request.url = Mock(path="/dashboard")
assert StoreContextManager.is_admin_request(request) is True
def test_is_admin_request_admin_path(self): def test_is_admin_request_admin_path(self):
"""Test admin request detection from path.""" """Test admin request detection from path."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("localhost", "/admin/dashboard") is True
request.headers = {"host": "localhost"}
request.url = Mock(path="/admin/dashboard")
assert StoreContextManager.is_admin_request(request) is True
def test_is_admin_request_with_port(self): def test_is_admin_request_with_port(self):
"""Test admin request detection with port number.""" """Test admin request detection with port number."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("admin.localhost:8000", "/dashboard") is True
request.headers = {"host": "admin.localhost:8000"}
request.url = Mock(path="/dashboard")
assert StoreContextManager.is_admin_request(request) is True
def test_is_not_admin_request(self): def test_is_not_admin_request(self):
"""Test non-admin request.""" """Test non-admin request."""
request = Mock(spec=Request) assert FrontendDetector.is_admin("store1.platform.com", "/storefront") is False
request.headers = {"host": "store1.platform.com"}
request.url = Mock(path="/storefront")
assert StoreContextManager.is_admin_request(request) is False
def test_is_api_request(self): def test_is_api_request(self):
"""Test API request detection.""" """Test API request detection."""
@@ -599,7 +584,7 @@ class TestStoreContextMiddleware:
call_next = AsyncMock(return_value=Mock()) 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) await middleware.dispatch(request, call_next)
assert request.state.store is None assert request.state.store is None
@@ -775,7 +760,7 @@ class TestStoreContextMiddleware:
call_next = AsyncMock(return_value=Mock()) call_next = AsyncMock(return_value=Mock())
with ( with (
patch.object(StoreContextManager, "is_admin_request", return_value=False), patch.object(FrontendDetector, "is_admin", return_value=False),
patch.object( patch.object(
StoreContextManager, "is_static_file_request", return_value=False StoreContextManager, "is_static_file_request", return_value=False
), ),