refactor: centralize frontend detection with FrontendDetector
Major architecture change to unify frontend detection: ## Problem Solved - Eliminated code duplication across 3 middleware files - Fixed incomplete path detection (now detects /api/v1/admin/*) - Unified on FrontendType enum (deprecates RequestContext) - Added request.state.frontend_type for all requests ## New Components - app/core/frontend_detector.py: Centralized FrontendDetector class - middleware/frontend_type.py: FrontendTypeMiddleware (replaces ContextMiddleware) - docs/architecture/frontend-detection.md: Complete architecture documentation ## Changes - main.py: Use FrontendTypeMiddleware instead of ContextMiddleware - middleware/context.py: Deprecated (kept for backwards compatibility) - middleware/platform_context.py: Use FrontendDetector.is_admin() - middleware/vendor_context.py: Use FrontendDetector.is_admin() - middleware/language.py: Use FrontendType instead of context_value - app/exceptions/handler.py: Use FrontendType.STOREFRONT - app/exceptions/error_renderer.py: Use FrontendType - Customer routes: Cookie path changed from /shop to /storefront ## Documentation - docs/architecture/frontend-detection.md: New comprehensive docs - docs/architecture/middleware.md: Updated for new system - docs/architecture/request-flow.md: Updated for FrontendType - docs/backend/middleware-reference.md: Updated API reference ## Tests - tests/unit/core/test_frontend_detector.py: 37 new tests - tests/unit/middleware/test_frontend_type.py: 11 new tests - tests/unit/middleware/test_context.py: Updated for compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
203
app/core/frontend_detector.py
Normal file
203
app/core/frontend_detector.py
Normal file
@@ -0,0 +1,203 @@
|
||||
# app/core/frontend_detector.py
|
||||
"""
|
||||
Centralized Frontend Detection
|
||||
|
||||
Single source of truth for detecting which frontend type a request targets.
|
||||
Handles both development (path-based) and production (domain-based) routing.
|
||||
|
||||
Detection priority:
|
||||
1. Admin subdomain (admin.oms.lu)
|
||||
2. Path-based admin/vendor (/admin/*, /vendor/*, /api/v1/admin/*)
|
||||
3. Custom domain lookup (mybakery.lu -> STOREFRONT)
|
||||
4. Vendor subdomain (wizamart.oms.lu -> STOREFRONT)
|
||||
5. Storefront paths (/storefront/*, /api/v1/storefront/*)
|
||||
6. Default to PLATFORM (marketing pages)
|
||||
|
||||
This module unifies frontend detection that was previously duplicated across:
|
||||
- middleware/platform_context.py
|
||||
- middleware/vendor_context.py
|
||||
- middleware/context.py
|
||||
|
||||
All middleware and routes should use FrontendDetector for frontend detection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrontendDetector:
|
||||
"""
|
||||
Centralized frontend detection for dev and prod modes.
|
||||
|
||||
Provides consistent detection of frontend type from request characteristics.
|
||||
All path/domain detection logic should be centralized here.
|
||||
"""
|
||||
|
||||
# Reserved subdomains (not vendor shops)
|
||||
RESERVED_SUBDOMAINS = frozenset({"www", "admin", "api", "vendor", "portal"})
|
||||
|
||||
# Path patterns for each frontend type
|
||||
# Note: Order matters - more specific patterns should be checked first
|
||||
ADMIN_PATH_PREFIXES = ("/admin", "/api/v1/admin")
|
||||
VENDOR_PATH_PREFIXES = ("/vendor/", "/api/v1/vendor") # Note: /vendor/ not /vendors/
|
||||
STOREFRONT_PATH_PREFIXES = (
|
||||
"/storefront",
|
||||
"/api/v1/storefront",
|
||||
"/shop", # Legacy support
|
||||
"/api/v1/shop", # Legacy support
|
||||
"/vendors/", # Path-based vendor access
|
||||
)
|
||||
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
|
||||
|
||||
@classmethod
|
||||
def detect(
|
||||
cls,
|
||||
host: str,
|
||||
path: str,
|
||||
has_vendor_context: bool = False,
|
||||
) -> FrontendType:
|
||||
"""
|
||||
Detect frontend type from request.
|
||||
|
||||
Args:
|
||||
host: Request host header (e.g., "oms.lu", "wizamart.oms.lu", "localhost:8000")
|
||||
path: Request path (e.g., "/admin/vendors", "/storefront/products")
|
||||
has_vendor_context: True if request.state.vendor is set (from middleware)
|
||||
|
||||
Returns:
|
||||
FrontendType enum value
|
||||
"""
|
||||
host = cls._strip_port(host)
|
||||
subdomain = cls._get_subdomain(host)
|
||||
|
||||
logger.debug(
|
||||
"[FRONTEND_DETECTOR] Detecting frontend type",
|
||||
extra={
|
||||
"host": host,
|
||||
"path": path,
|
||||
"subdomain": subdomain,
|
||||
"has_vendor_context": has_vendor_context,
|
||||
},
|
||||
)
|
||||
|
||||
# 1. Admin subdomain (admin.oms.lu)
|
||||
if subdomain == "admin":
|
||||
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain")
|
||||
return FrontendType.ADMIN
|
||||
|
||||
# 2. Path-based detection (works for dev and prod)
|
||||
# Check in priority order
|
||||
if cls._matches_any(path, cls.ADMIN_PATH_PREFIXES):
|
||||
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path")
|
||||
return FrontendType.ADMIN
|
||||
|
||||
if cls._matches_any(path, cls.VENDOR_PATH_PREFIXES):
|
||||
logger.debug("[FRONTEND_DETECTOR] Detected VENDOR from path")
|
||||
return FrontendType.VENDOR
|
||||
|
||||
if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES):
|
||||
logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path")
|
||||
return FrontendType.STOREFRONT
|
||||
|
||||
if cls._matches_any(path, cls.PLATFORM_PATH_PREFIXES):
|
||||
logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path")
|
||||
return FrontendType.PLATFORM
|
||||
|
||||
# 3. Vendor subdomain detection (wizamart.oms.lu)
|
||||
# If subdomain exists and is not reserved -> it's a vendor shop
|
||||
if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS:
|
||||
logger.debug(
|
||||
f"[FRONTEND_DETECTOR] Detected STOREFRONT from subdomain: {subdomain}"
|
||||
)
|
||||
return FrontendType.STOREFRONT
|
||||
|
||||
# 4. Custom domain detection (handled by middleware setting vendor context)
|
||||
# If vendor is set but no storefront path -> still storefront
|
||||
if has_vendor_context:
|
||||
logger.debug(
|
||||
"[FRONTEND_DETECTOR] Detected STOREFRONT from vendor context"
|
||||
)
|
||||
return FrontendType.STOREFRONT
|
||||
|
||||
# 5. Default: PLATFORM (marketing pages like /, /pricing, /about)
|
||||
logger.debug("[FRONTEND_DETECTOR] Defaulting to PLATFORM")
|
||||
return FrontendType.PLATFORM
|
||||
|
||||
@classmethod
|
||||
def _strip_port(cls, host: str) -> str:
|
||||
"""Remove port from host if present (e.g., localhost:8000 -> localhost)."""
|
||||
return host.split(":")[0] if ":" in host else host
|
||||
|
||||
@classmethod
|
||||
def _get_subdomain(cls, host: str) -> str | None:
|
||||
"""
|
||||
Extract subdomain from host (e.g., 'wizamart' from 'wizamart.oms.lu').
|
||||
|
||||
Returns None for localhost, IP addresses, or root domains.
|
||||
Handles special case of admin.localhost for development.
|
||||
"""
|
||||
if host in ("localhost", "127.0.0.1"):
|
||||
return None
|
||||
|
||||
parts = host.split(".")
|
||||
|
||||
# Handle localhost subdomains (e.g., admin.localhost)
|
||||
if len(parts) == 2 and parts[1] == "localhost":
|
||||
return parts[0].lower()
|
||||
|
||||
if len(parts) >= 3: # subdomain.domain.tld
|
||||
return parts[0].lower()
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _matches_any(cls, path: str, prefixes: tuple[str, ...]) -> bool:
|
||||
"""Check if path starts with any of the given prefixes."""
|
||||
return any(path.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
# =========================================================================
|
||||
# Convenience methods for specific frontend types
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
def is_admin(cls, host: str, path: str) -> bool:
|
||||
"""Check if request targets admin frontend."""
|
||||
return cls.detect(host, path) == FrontendType.ADMIN
|
||||
|
||||
@classmethod
|
||||
def is_vendor(cls, host: str, path: str) -> bool:
|
||||
"""Check if request targets vendor dashboard frontend."""
|
||||
return cls.detect(host, path) == FrontendType.VENDOR
|
||||
|
||||
@classmethod
|
||||
def is_storefront(
|
||||
cls,
|
||||
host: str,
|
||||
path: str,
|
||||
has_vendor_context: bool = False,
|
||||
) -> bool:
|
||||
"""Check if request targets storefront frontend."""
|
||||
return cls.detect(host, path, has_vendor_context) == FrontendType.STOREFRONT
|
||||
|
||||
@classmethod
|
||||
def is_platform(cls, host: str, path: str) -> bool:
|
||||
"""Check if request targets platform marketing frontend."""
|
||||
return cls.detect(host, path) == FrontendType.PLATFORM
|
||||
|
||||
@classmethod
|
||||
def is_api_request(cls, path: str) -> bool:
|
||||
"""Check if request is for API endpoints (any frontend's API)."""
|
||||
return path.startswith("/api/")
|
||||
|
||||
|
||||
# Convenience function for backwards compatibility
|
||||
def get_frontend_type(host: str, path: str, has_vendor_context: bool = False) -> FrontendType:
|
||||
"""
|
||||
Convenience function to detect frontend type.
|
||||
|
||||
Wrapper around FrontendDetector.detect() for simpler imports.
|
||||
"""
|
||||
return FrontendDetector.detect(host, path, has_vendor_context)
|
||||
@@ -14,7 +14,8 @@ from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from middleware.context import RequestContext, get_request_context
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,11 +66,11 @@ class ErrorPageRenderer:
|
||||
show_debug: bool = False,
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
Render appropriate error page based on request context.
|
||||
Render appropriate error page based on request frontend type.
|
||||
|
||||
Template Selection Priority:
|
||||
1. Context-specific error page: {context}/errors/{status_code}.html
|
||||
2. Context-specific generic: {context}/errors/generic.html
|
||||
1. Frontend-specific error page: {frontend}/errors/{status_code}.html
|
||||
2. Frontend-specific generic: {frontend}/errors/generic.html
|
||||
3. Shared fallback error page: shared/{status_code}-fallback.html
|
||||
4. Shared fallback generic: shared/generic-fallback.html
|
||||
|
||||
@@ -84,13 +85,13 @@ class ErrorPageRenderer:
|
||||
Returns:
|
||||
HTMLResponse with rendered error page
|
||||
"""
|
||||
# Get request context
|
||||
context_type = get_request_context(request)
|
||||
# Get frontend type
|
||||
frontend_type = get_frontend_type(request)
|
||||
|
||||
# Prepare template data
|
||||
template_data = ErrorPageRenderer._prepare_template_data(
|
||||
request=request,
|
||||
context_type=context_type,
|
||||
frontend_type=frontend_type,
|
||||
status_code=status_code,
|
||||
error_code=error_code,
|
||||
message=message,
|
||||
@@ -104,16 +105,16 @@ class ErrorPageRenderer:
|
||||
|
||||
# Try to find appropriate template
|
||||
template_path = ErrorPageRenderer._find_template(
|
||||
context_type=context_type,
|
||||
frontend_type=frontend_type,
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Rendering error page: {template_path} for {status_code} in {context_type.value} context",
|
||||
f"Rendering error page: {template_path} for {status_code} in {frontend_type.value} frontend",
|
||||
extra={
|
||||
"status_code": status_code,
|
||||
"error_code": error_code,
|
||||
"context": context_type.value,
|
||||
"frontend": frontend_type.value,
|
||||
"template": template_path,
|
||||
},
|
||||
)
|
||||
@@ -141,38 +142,37 @@ class ErrorPageRenderer:
|
||||
|
||||
@staticmethod
|
||||
def _find_template(
|
||||
context_type: RequestContext,
|
||||
frontend_type: FrontendType,
|
||||
status_code: int,
|
||||
) -> str:
|
||||
"""
|
||||
Find appropriate error template based on context and status code.
|
||||
Find appropriate error template based on frontend type and status code.
|
||||
|
||||
Priority:
|
||||
1. {context}/errors/{status_code}.html
|
||||
2. {context}/errors/generic.html
|
||||
1. {frontend}/errors/{status_code}.html
|
||||
2. {frontend}/errors/generic.html
|
||||
3. shared/{status_code}-fallback.html
|
||||
4. shared/generic-fallback.html
|
||||
"""
|
||||
templates_dir = ErrorPageRenderer.get_templates_dir()
|
||||
|
||||
# Map context type to folder name
|
||||
context_folders = {
|
||||
RequestContext.ADMIN: "admin",
|
||||
RequestContext.VENDOR_DASHBOARD: "vendor",
|
||||
RequestContext.SHOP: "shop",
|
||||
RequestContext.API: "fallback", # API shouldn't get here, but just in case
|
||||
RequestContext.FALLBACK: "fallback",
|
||||
# Map frontend type to folder name
|
||||
frontend_folders = {
|
||||
FrontendType.ADMIN: "admin",
|
||||
FrontendType.VENDOR: "vendor",
|
||||
FrontendType.STOREFRONT: "storefront",
|
||||
FrontendType.PLATFORM: "fallback", # Platform uses fallback templates
|
||||
}
|
||||
|
||||
context_folder = context_folders.get(context_type, "fallback")
|
||||
frontend_folder = frontend_folders.get(frontend_type, "fallback")
|
||||
|
||||
# Try context-specific status code template
|
||||
specific_template = f"{context_folder}/errors/{status_code}.html"
|
||||
# Try frontend-specific status code template
|
||||
specific_template = f"{frontend_folder}/errors/{status_code}.html"
|
||||
if (templates_dir / specific_template).exists():
|
||||
return specific_template
|
||||
|
||||
# Try context-specific generic template
|
||||
generic_template = f"{context_folder}/errors/generic.html"
|
||||
# Try frontend-specific generic template
|
||||
generic_template = f"{frontend_folder}/errors/generic.html"
|
||||
if (templates_dir / generic_template).exists():
|
||||
return generic_template
|
||||
|
||||
@@ -187,7 +187,7 @@ class ErrorPageRenderer:
|
||||
@staticmethod
|
||||
def _prepare_template_data(
|
||||
request: Request,
|
||||
context_type: RequestContext,
|
||||
frontend_type: FrontendType,
|
||||
status_code: int,
|
||||
error_code: str,
|
||||
message: str,
|
||||
@@ -212,8 +212,8 @@ class ErrorPageRenderer:
|
||||
# Only show to admins (we can check user role if available)
|
||||
display_debug = show_debug and ErrorPageRenderer._is_admin_user(request)
|
||||
|
||||
# Get context-specific data
|
||||
context_data = ErrorPageRenderer._get_context_data(request, context_type)
|
||||
# Get frontend-specific data
|
||||
frontend_data = ErrorPageRenderer._get_frontend_data(request, frontend_type)
|
||||
|
||||
return {
|
||||
"status_code": status_code,
|
||||
@@ -222,20 +222,20 @@ class ErrorPageRenderer:
|
||||
"message": user_message,
|
||||
"details": details or {},
|
||||
"show_debug": display_debug,
|
||||
"context_type": context_type.value,
|
||||
"frontend_type": frontend_type.value,
|
||||
"path": request.url.path,
|
||||
**context_data,
|
||||
**frontend_data,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_context_data(
|
||||
request: Request, context_type: RequestContext
|
||||
def _get_frontend_data(
|
||||
request: Request, frontend_type: FrontendType
|
||||
) -> dict[str, Any]:
|
||||
"""Get context-specific data for error templates."""
|
||||
"""Get frontend-specific data for error templates."""
|
||||
data = {}
|
||||
|
||||
# Add vendor information if available (for shop context)
|
||||
if context_type == RequestContext.SHOP:
|
||||
# Add vendor information if available (for storefront frontend)
|
||||
if frontend_type == FrontendType.STOREFRONT:
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if vendor:
|
||||
# Pass minimal vendor info for templates
|
||||
@@ -261,7 +261,7 @@ class ErrorPageRenderer:
|
||||
"custom_css": getattr(theme, "custom_css", None),
|
||||
}
|
||||
|
||||
# Calculate base_url for shop links
|
||||
# Calculate base_url for storefront links
|
||||
vendor_context = getattr(request.state, "vendor_context", None)
|
||||
access_method = (
|
||||
vendor_context.get("detection_method", "unknown")
|
||||
@@ -289,9 +289,9 @@ class ErrorPageRenderer:
|
||||
This is a placeholder - implement based on your auth system.
|
||||
"""
|
||||
# TODO: Implement actual admin check based on JWT/session
|
||||
# For now, check if we're in admin context
|
||||
context_type = get_request_context(request)
|
||||
return context_type == RequestContext.ADMIN
|
||||
# For now, check if we're in admin frontend
|
||||
frontend_type = get_frontend_type(request)
|
||||
return frontend_type == FrontendType.ADMIN
|
||||
|
||||
@staticmethod
|
||||
def _render_basic_html_fallback(
|
||||
|
||||
@@ -16,7 +16,8 @@ from fastapi import HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from middleware.context import RequestContext, get_request_context
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
|
||||
from .base import WizamartException
|
||||
from .error_renderer import ErrorPageRenderer
|
||||
@@ -382,17 +383,17 @@ def _is_html_page_request(request: Request) -> bool:
|
||||
|
||||
def _redirect_to_login(request: Request) -> RedirectResponse:
|
||||
"""
|
||||
Redirect to appropriate login page based on request context.
|
||||
Redirect to appropriate login page based on request frontend type.
|
||||
|
||||
Uses context detection to determine admin vs vendor vs shop login.
|
||||
Uses FrontendType detection to determine admin vs vendor vs storefront login.
|
||||
Properly handles multi-access routing (domain, subdomain, path-based).
|
||||
"""
|
||||
context_type = get_request_context(request)
|
||||
frontend_type = get_frontend_type(request)
|
||||
|
||||
if context_type == RequestContext.ADMIN:
|
||||
if frontend_type == FrontendType.ADMIN:
|
||||
logger.debug("Redirecting to /admin/login")
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
if context_type == RequestContext.VENDOR_DASHBOARD:
|
||||
if frontend_type == FrontendType.VENDOR:
|
||||
# Extract vendor code from the request path
|
||||
# Path format: /vendor/{vendor_code}/...
|
||||
path_parts = request.url.path.split("/")
|
||||
@@ -417,8 +418,8 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
||||
|
||||
logger.debug(f"Redirecting to {login_url}")
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
if context_type == RequestContext.SHOP:
|
||||
# For shop context, redirect to shop login (customer login)
|
||||
if frontend_type == FrontendType.STOREFRONT:
|
||||
# For storefront context, redirect to storefront login (customer login)
|
||||
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
vendor_context = getattr(request.state, "vendor_context", None)
|
||||
@@ -437,11 +438,11 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
||||
)
|
||||
base_url = f"{full_prefix}{vendor.subdomain}/"
|
||||
|
||||
login_url = f"{base_url}shop/account/login"
|
||||
login_url = f"{base_url}storefront/account/login"
|
||||
logger.debug(f"Redirecting to {login_url}")
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
# Fallback to root for unknown contexts
|
||||
logger.debug("Unknown context, redirecting to /")
|
||||
# Fallback to root for unknown contexts (PLATFORM)
|
||||
logger.debug("Platform context, redirecting to /")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Public and authenticated endpoints for customer operations in storefront:
|
||||
Uses vendor from middleware context (VendorContextMiddleware).
|
||||
|
||||
Implements dual token storage with path restriction:
|
||||
- Sets HTTP-only cookie with path=/shop (restricted to shop routes only)
|
||||
- Sets HTTP-only cookie with path=/storefront (restricted to storefront routes only)
|
||||
- Returns token in response for localStorage (API calls)
|
||||
"""
|
||||
|
||||
@@ -182,14 +182,14 @@ def customer_login(
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
cookie_path = "/shop"
|
||||
cookie_path = "/storefront"
|
||||
if access_method == "path":
|
||||
full_prefix = (
|
||||
vendor_context.get("full_prefix", "/vendor/")
|
||||
if vendor_context
|
||||
else "/vendor/"
|
||||
)
|
||||
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
|
||||
cookie_path = f"{full_prefix}{vendor.subdomain}/storefront"
|
||||
|
||||
response.set_cookie(
|
||||
key="customer_token",
|
||||
@@ -240,14 +240,14 @@ def customer_logout(request: Request, response: Response):
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
cookie_path = "/shop"
|
||||
cookie_path = "/storefront"
|
||||
if access_method == "path" and vendor:
|
||||
full_prefix = (
|
||||
vendor_context.get("full_prefix", "/vendor/")
|
||||
if vendor_context
|
||||
else "/vendor/"
|
||||
)
|
||||
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
|
||||
cookie_path = f"{full_prefix}{vendor.subdomain}/storefront"
|
||||
|
||||
response.delete_cookie(key="customer_token", path=cookie_path)
|
||||
|
||||
|
||||
278
docs/architecture/frontend-detection.md
Normal file
278
docs/architecture/frontend-detection.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Frontend Detection Architecture
|
||||
|
||||
This document describes the centralized frontend detection system that identifies which frontend (ADMIN, VENDOR, STOREFRONT, or PLATFORM) a request targets.
|
||||
|
||||
## Overview
|
||||
|
||||
The application serves multiple frontends from a single codebase:
|
||||
|
||||
| Frontend | Description | Example URLs |
|
||||
|----------|-------------|--------------|
|
||||
| **ADMIN** | Platform administration | `/admin/*`, `/api/v1/admin/*`, `admin.oms.lu/*` |
|
||||
| **VENDOR** | Vendor dashboard | `/vendor/*`, `/api/v1/vendor/*` |
|
||||
| **STOREFRONT** | Customer-facing shop | `/storefront/*`, `/vendors/*`, `wizamart.oms.lu/*` |
|
||||
| **PLATFORM** | Marketing pages | `/`, `/pricing`, `/about` |
|
||||
|
||||
The `FrontendDetector` class provides centralized, consistent detection of which frontend a request targets.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Request Processing │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. PlatformContextMiddleware → Sets request.state.platform │
|
||||
│ │
|
||||
│ 2. VendorContextMiddleware → Sets request.state.vendor │
|
||||
│ │
|
||||
│ 3. FrontendTypeMiddleware → Sets request.state.frontend_type│
|
||||
│ │ │
|
||||
│ └──→ Uses FrontendDetector.detect() │
|
||||
│ │
|
||||
│ 4. LanguageMiddleware → Uses frontend_type for language │
|
||||
│ │
|
||||
│ 5. ThemeContextMiddleware → Uses frontend_type for theming │
|
||||
│ │
|
||||
│ 6. FastAPI Router → Handles request │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/core/frontend_detector.py` | Centralized detection logic |
|
||||
| `middleware/frontend_type.py` | Middleware that sets `request.state.frontend_type` |
|
||||
| `app/modules/enums.py` | Defines `FrontendType` enum |
|
||||
|
||||
## FrontendType Enum
|
||||
|
||||
```python
|
||||
class FrontendType(str, Enum):
|
||||
PLATFORM = "platform" # Marketing pages (/, /pricing, /about)
|
||||
ADMIN = "admin" # Admin panel (/admin/*)
|
||||
VENDOR = "vendor" # Vendor dashboard (/vendor/*)
|
||||
STOREFRONT = "storefront" # Customer shop (/storefront/*, /vendors/*)
|
||||
```
|
||||
|
||||
## Detection Priority
|
||||
|
||||
The `FrontendDetector` uses the following priority order:
|
||||
|
||||
```
|
||||
1. Admin subdomain (admin.oms.lu) → ADMIN
|
||||
2. Path-based detection:
|
||||
- /admin/* or /api/v1/admin/* → ADMIN
|
||||
- /vendor/* or /api/v1/vendor/* → VENDOR
|
||||
- /storefront/*, /shop/*, /vendors/* → STOREFRONT
|
||||
- /api/v1/platform/* → PLATFORM
|
||||
3. Vendor subdomain (wizamart.oms.lu) → STOREFRONT
|
||||
4. Vendor context set by middleware → STOREFRONT
|
||||
5. Default → PLATFORM
|
||||
```
|
||||
|
||||
### Path Patterns
|
||||
|
||||
```python
|
||||
# Admin paths
|
||||
ADMIN_PATH_PREFIXES = ("/admin", "/api/v1/admin")
|
||||
|
||||
# Vendor dashboard paths
|
||||
VENDOR_PATH_PREFIXES = ("/vendor/", "/api/v1/vendor")
|
||||
|
||||
# Storefront paths
|
||||
STOREFRONT_PATH_PREFIXES = (
|
||||
"/storefront",
|
||||
"/api/v1/storefront",
|
||||
"/shop", # Legacy support
|
||||
"/api/v1/shop", # Legacy support
|
||||
"/vendors/", # Path-based vendor access
|
||||
)
|
||||
|
||||
# Platform paths
|
||||
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
|
||||
```
|
||||
|
||||
### Reserved Subdomains
|
||||
|
||||
These subdomains are NOT treated as vendor storefronts:
|
||||
|
||||
```python
|
||||
RESERVED_SUBDOMAINS = {"www", "admin", "api", "vendor", "portal"}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### In Middleware/Routes
|
||||
|
||||
```python
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
@router.get("/some-route")
|
||||
async def some_route(request: Request):
|
||||
frontend_type = get_frontend_type(request)
|
||||
|
||||
if frontend_type == FrontendType.ADMIN:
|
||||
# Admin-specific logic
|
||||
pass
|
||||
elif frontend_type == FrontendType.STOREFRONT:
|
||||
# Storefront-specific logic
|
||||
pass
|
||||
```
|
||||
|
||||
### Direct Detection (without request)
|
||||
|
||||
```python
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
# Full detection
|
||||
frontend_type = FrontendDetector.detect(
|
||||
host="wizamart.oms.lu",
|
||||
path="/products",
|
||||
has_vendor_context=True
|
||||
)
|
||||
# Returns: FrontendType.STOREFRONT
|
||||
|
||||
# Convenience methods
|
||||
if FrontendDetector.is_admin(host, path):
|
||||
# Admin logic
|
||||
pass
|
||||
|
||||
if FrontendDetector.is_storefront(host, path, has_vendor_context=True):
|
||||
# Storefront logic
|
||||
pass
|
||||
```
|
||||
|
||||
## Detection Scenarios
|
||||
|
||||
### Development Mode (localhost)
|
||||
|
||||
| Request | Host | Path | Frontend |
|
||||
|---------|------|------|----------|
|
||||
| Admin page | localhost | /admin/vendors | ADMIN |
|
||||
| Admin API | localhost | /api/v1/admin/users | ADMIN |
|
||||
| Vendor dashboard | localhost | /vendor/settings | VENDOR |
|
||||
| Vendor API | localhost | /api/v1/vendor/products | VENDOR |
|
||||
| Storefront | localhost | /storefront/products | STOREFRONT |
|
||||
| Storefront (path-based) | localhost | /vendors/wizamart/products | STOREFRONT |
|
||||
| Marketing | localhost | /pricing | PLATFORM |
|
||||
|
||||
### Production Mode (domains)
|
||||
|
||||
| Request | Host | Path | Frontend |
|
||||
|---------|------|------|----------|
|
||||
| Admin subdomain | admin.oms.lu | /dashboard | ADMIN |
|
||||
| Vendor subdomain | wizamart.oms.lu | /products | STOREFRONT |
|
||||
| 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` |
|
||||
| `VENDOR_DASHBOARD` | `FrontendType.VENDOR` |
|
||||
| `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:
|
||||
|
||||
```python
|
||||
request.state.frontend_type # FrontendType enum value
|
||||
```
|
||||
|
||||
This is used by:
|
||||
- `LanguageMiddleware` - to determine language resolution strategy
|
||||
- `ErrorRenderer` - to select appropriate error templates
|
||||
- `ExceptionHandler` - to redirect to correct login page
|
||||
- Route handlers - for frontend-specific logic
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Tests are located in:
|
||||
- `tests/unit/core/test_frontend_detector.py` - FrontendDetector tests
|
||||
- `tests/unit/middleware/test_frontend_type.py` - Middleware tests
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all frontend detection tests
|
||||
pytest tests/unit/core/test_frontend_detector.py tests/unit/middleware/test_frontend_type.py -v
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/unit/core/test_frontend_detector.py tests/unit/middleware/test_frontend_type.py --cov=app.core.frontend_detector --cov=middleware.frontend_type
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
1. **Use `get_frontend_type(request)`** in route handlers
|
||||
2. **Use `FrontendDetector.detect()`** when you have host/path but no request
|
||||
3. **Use convenience methods** like `is_admin()`, `is_storefront()` for boolean checks
|
||||
4. **Import from the correct location:**
|
||||
```python
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
These rules are enforced by `scripts/validate_architecture.py`:
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| MID-001 | Use FrontendDetector for frontend detection |
|
||||
| MID-002 | Don't hardcode path patterns in middleware |
|
||||
| MID-003 | Use FrontendType enum, not RequestContext |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Middleware Stack](middleware.md) - Overall middleware architecture
|
||||
- [Request Flow](request-flow.md) - How requests are processed
|
||||
- [URL Routing](url-routing/overview.md) - URL structure and routing patterns
|
||||
- [Multi-Tenant Architecture](multi-tenant.md) - Tenant detection and isolation
|
||||
@@ -120,35 +120,35 @@ Injects: request.state.vendor = <Vendor object>
|
||||
|
||||
**Note on Path-Based Routing:** Previous implementations used a `PathRewriteMiddleware` to rewrite paths at runtime. This has been replaced with **double router mounting** in `main.py`, where shop routes are registered twice with different prefixes (`/shop` and `/vendors/{vendor_code}/shop`). This approach is simpler and uses FastAPI's native routing capabilities.
|
||||
|
||||
### 3. Context Detection Middleware
|
||||
### 3. Frontend Type Detection Middleware
|
||||
|
||||
**Purpose**: Determine the type/context of the request
|
||||
**Purpose**: Determine which frontend the request targets
|
||||
|
||||
**What it does**:
|
||||
- Analyzes the request path (using clean_path)
|
||||
- Determines which interface is being accessed:
|
||||
- `API` - `/api/*` paths
|
||||
- `ADMIN` - `/admin/*` paths or `admin.*` subdomain
|
||||
- `VENDOR_DASHBOARD` - `/vendor/*` paths (management area)
|
||||
- `SHOP` - Storefront pages (has vendor + not admin/vendor/API)
|
||||
- `FALLBACK` - Unknown context
|
||||
- Injects `request.state.context_type`
|
||||
- Uses centralized `FrontendDetector` class for all detection logic
|
||||
- Determines which frontend is being accessed:
|
||||
- `ADMIN` - `/admin/*`, `/api/v1/admin/*` paths or `admin.*` subdomain
|
||||
- `VENDOR` - `/vendor/*`, `/api/v1/vendor/*` paths (management area)
|
||||
- `STOREFRONT` - Customer shop pages (`/storefront/*`, `/vendors/*`, vendor subdomains)
|
||||
- `PLATFORM` - Marketing pages (`/`, `/pricing`, `/about`)
|
||||
- Injects `request.state.frontend_type` (FrontendType enum)
|
||||
|
||||
**Detection Rules**:
|
||||
**Detection Priority** (handled by `FrontendDetector`):
|
||||
```python
|
||||
if path.startswith("/api/"):
|
||||
context = API
|
||||
elif path.startswith("/admin/") or host.startswith("admin."):
|
||||
context = ADMIN
|
||||
elif path.startswith("/vendor/"):
|
||||
context = VENDOR_DASHBOARD
|
||||
elif request.state.vendor exists:
|
||||
context = SHOP
|
||||
else:
|
||||
context = FALLBACK
|
||||
1. Admin subdomain (admin.oms.lu) → ADMIN
|
||||
2. Path-based detection:
|
||||
- /admin/* or /api/v1/admin/* → ADMIN
|
||||
- /vendor/* or /api/v1/vendor/* → VENDOR
|
||||
- /storefront/*, /shop/*, /vendors/* → STOREFRONT
|
||||
- /api/v1/platform/* → PLATFORM
|
||||
3. Vendor subdomain (wizamart.oms.lu) → STOREFRONT
|
||||
4. Vendor context set by middleware → STOREFRONT
|
||||
5. Default → PLATFORM
|
||||
```
|
||||
|
||||
**Why it's useful**: Error handlers and templates adapt based on context
|
||||
**Why it's useful**: Error handlers, templates, and language detection adapt based on frontend type
|
||||
|
||||
**See**: [Frontend Detection Architecture](frontend-detection.md) for complete details
|
||||
|
||||
### 4. Theme Context Middleware
|
||||
|
||||
@@ -221,15 +221,18 @@ Each middleware file contains one primary class or a tightly related set of clas
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Request/response logging middleware"""
|
||||
|
||||
# middleware/context.py
|
||||
class ContextManager: # Business logic
|
||||
class ContextMiddleware: # ASGI wrapper
|
||||
class RequestContext(Enum): # Related enum
|
||||
# middleware/frontend_type.py
|
||||
class FrontendTypeMiddleware: # ASGI wrapper for frontend detection
|
||||
# Uses FrontendDetector from app/core/frontend_detector.py
|
||||
|
||||
# middleware/auth.py
|
||||
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:
|
||||
@@ -368,8 +371,8 @@ async def get_products(request: Request):
|
||||
}
|
||||
</style>
|
||||
|
||||
{# Access context #}
|
||||
{% if request.state.context_type.value == "admin" %}
|
||||
{# Access frontend type #}
|
||||
{% if request.state.frontend_type.value == "admin" %}
|
||||
<div class="admin-badge">Admin Mode</div>
|
||||
{% endif %}
|
||||
```
|
||||
@@ -394,11 +397,11 @@ async def get_products(request: Request):
|
||||
↓ Sets: request.state.vendor_id = 1
|
||||
↓ Sets: request.state.clean_path = "/shop/products"
|
||||
|
||||
3. ContextDetectionMiddleware
|
||||
↓ Analyzes path: "/shop/products"
|
||||
↓ Has vendor: Yes
|
||||
↓ Not admin/api/vendor dashboard
|
||||
↓ Sets: request.state.context_type = RequestContext.SHOP
|
||||
3. FrontendTypeMiddleware
|
||||
↓ Uses FrontendDetector with path: "/shop/products"
|
||||
↓ Has vendor context: Yes
|
||||
↓ Detects storefront frontend
|
||||
↓ Sets: request.state.frontend_type = FrontendType.STOREFRONT
|
||||
|
||||
4. ThemeContextMiddleware
|
||||
↓ Loads theme for vendor_id = 1
|
||||
@@ -430,10 +433,10 @@ Each middleware component handles errors gracefully:
|
||||
- If database error: Logs error, allows request to continue
|
||||
- Fallback: Request proceeds without vendor context
|
||||
|
||||
### ContextDetectionMiddleware
|
||||
### FrontendTypeMiddleware
|
||||
- If clean_path missing: Uses original path
|
||||
- If vendor missing: Defaults to FALLBACK context
|
||||
- Always sets a context_type (never None)
|
||||
- If vendor missing: Defaults to PLATFORM frontend type
|
||||
- Always sets a frontend_type (never None)
|
||||
|
||||
### ThemeContextMiddleware
|
||||
- If vendor missing: Skips theme loading
|
||||
@@ -479,8 +482,10 @@ Middleware is registered in `main.py`:
|
||||
# Add in REVERSE order (LIFO execution)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
app.add_middleware(ThemeContextMiddleware)
|
||||
app.add_middleware(ContextDetectionMiddleware)
|
||||
app.add_middleware(LanguageMiddleware)
|
||||
app.add_middleware(FrontendTypeMiddleware)
|
||||
app.add_middleware(VendorContextMiddleware)
|
||||
app.add_middleware(PlatformContextMiddleware)
|
||||
```
|
||||
|
||||
**Note**: FastAPI's `add_middleware` executes in **reverse order** (Last In, First Out)
|
||||
|
||||
@@ -147,27 +147,25 @@ app.include_router(shop_pages.router, prefix="/vendors/{vendor_code}/shop")
|
||||
**Detection Logic**:
|
||||
|
||||
```python
|
||||
host = request.headers.get("host", "")
|
||||
path = request.state.clean_path # "/shop/products"
|
||||
has_vendor = hasattr(request.state, 'vendor') and request.state.vendor
|
||||
|
||||
if path.startswith("/api/"):
|
||||
context = RequestContext.API
|
||||
elif path.startswith("/admin/"):
|
||||
context = RequestContext.ADMIN
|
||||
elif path.startswith("/vendor/"):
|
||||
context = RequestContext.VENDOR_DASHBOARD
|
||||
elif hasattr(request.state, 'vendor') and request.state.vendor:
|
||||
context = RequestContext.SHOP # ← Our example
|
||||
else:
|
||||
context = RequestContext.FALLBACK
|
||||
# FrontendDetector handles all detection logic centrally
|
||||
frontend_type = FrontendDetector.detect(host, path, has_vendor)
|
||||
# Returns: FrontendType.STOREFRONT # ← Our example
|
||||
|
||||
request.state.context_type = context
|
||||
request.state.frontend_type = frontend_type
|
||||
```
|
||||
|
||||
**Request State After**:
|
||||
```python
|
||||
request.state.context_type = RequestContext.SHOP
|
||||
request.state.frontend_type = FrontendType.STOREFRONT
|
||||
```
|
||||
|
||||
> **Note**: Detection logic is centralized in `app/core/frontend_detector.py`.
|
||||
> See [Frontend Detection Architecture](frontend-detection.md) for details.
|
||||
|
||||
### 6. ThemeContextMiddleware
|
||||
|
||||
**What happens**:
|
||||
@@ -363,7 +361,7 @@ sequenceDiagram
|
||||
Context->>Router: Route request
|
||||
Router->>Handler: Call API handler
|
||||
Handler->>DB: Query products
|
||||
DB-->>Handler: Product data
|
||||
DB-->>Handler: Product data
|
||||
Handler-->>Router: JSON response
|
||||
Router-->>Client: {products: [...]}
|
||||
```
|
||||
@@ -390,7 +388,7 @@ sequenceDiagram
|
||||
Context->>Theme: Pass request
|
||||
Note over Theme: Skip theme<br/>(No vendor)
|
||||
Theme->>Router: Route request
|
||||
Router->>Handler: Call handler
|
||||
Router->>Handler: Call handler
|
||||
Handler->>Template: Render admin template
|
||||
Template-->>Client: Admin HTML page
|
||||
```
|
||||
@@ -423,7 +421,7 @@ sequenceDiagram
|
||||
Context->>Theme: Pass request
|
||||
Theme->>DB: Query theme
|
||||
DB-->>Theme: Theme config
|
||||
Note over Theme: Set theme in request.state
|
||||
Note over Theme: Set theme in request.state
|
||||
Theme->>Router: Route request
|
||||
Router->>Handler: Call handler
|
||||
Handler->>DB: Query products for vendor
|
||||
@@ -450,12 +448,12 @@ After VendorContextMiddleware:
|
||||
{
|
||||
vendor: <Vendor: Wizamart>,
|
||||
vendor_id: 1,
|
||||
clean_path: "/shop/products",
|
||||
clean_path: "/shop/products",
|
||||
frontend_type: FrontendType.STOREFRONT
|
||||
}
|
||||
|
||||
After ThemeContextMiddleware:
|
||||
{
|
||||
{
|
||||
vendor: <Vendor: Wizamart>,
|
||||
vendor_id: 1,
|
||||
clean_path: "/shop/products",
|
||||
@@ -463,7 +461,7 @@ After ThemeContextMiddleware:
|
||||
theme: {
|
||||
primary_color: "#3B82F6",
|
||||
secondary_color: "#10B981",
|
||||
logo_url: "/static/vendors/wizamart/logo.png",
|
||||
logo_url: "/static/vendors/wizamart/logo.png",
|
||||
custom_css: "..."
|
||||
}
|
||||
}
|
||||
@@ -481,7 +479,7 @@ Typical request timings:
|
||||
| - ThemeContextMiddleware | 2ms | 1% |
|
||||
| Database Queries | 15ms | 10% |
|
||||
| Business Logic | 50ms | 35% |
|
||||
| Template Rendering | 75ms | 52% |
|
||||
| Template Rendering | 75ms | 52% |
|
||||
| **Total** | **145ms** | **100%** |
|
||||
|
||||
## Error Handling in Flow
|
||||
@@ -550,7 +548,7 @@ async def debug_state(request: Request):
|
||||
"has_theme": bool(getattr(request.state, 'theme', None))
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Check Middleware Order
|
||||
|
||||
In `main.py`, middleware registration order is critical:
|
||||
@@ -562,7 +560,8 @@ In `main.py`, middleware registration order is critical:
|
||||
app.add_middleware(LanguageMiddleware) # Runs fifth
|
||||
app.add_middleware(FrontendTypeMiddleware) # Runs fourth
|
||||
app.add_middleware(VendorContextMiddleware) # Runs second
|
||||
app.add_middleware(ThemeContextMiddleware) # Runs fifth
|
||||
app.add_middleware(ContextDetectionMiddleware) # Runs fourth
|
||||
```
|
||||
app.add_middleware(LanguageMiddleware) # Runs fifth
|
||||
app.add_middleware(FrontendTypeMiddleware) # Runs fourth
|
||||
app.add_middleware(VendorContextMiddleware) # Runs second
|
||||
```
|
||||
|
||||
@@ -66,51 +66,57 @@ ASGI middleware that wraps VendorContextManager for FastAPI integration.
|
||||
|
||||
---
|
||||
|
||||
## Request Context Detection
|
||||
## Frontend Type Detection
|
||||
|
||||
### RequestContext
|
||||
### FrontendType
|
||||
|
||||
Enum defining all possible request context types in the application.
|
||||
Enum defining all possible frontend types in the application.
|
||||
|
||||
::: middleware.context.RequestContext
|
||||
::: app.modules.enums.FrontendType
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
show_root_heading: false
|
||||
members:
|
||||
- API
|
||||
- PLATFORM
|
||||
- ADMIN
|
||||
- VENDOR_DASHBOARD
|
||||
- SHOP
|
||||
- FALLBACK
|
||||
- VENDOR
|
||||
- STOREFRONT
|
||||
|
||||
### ContextManager
|
||||
### FrontendDetector
|
||||
|
||||
Detects the type of request (API, Admin, Vendor Dashboard, Shop) based on URL patterns.
|
||||
Centralized class for detecting which frontend a request targets based on URL patterns.
|
||||
|
||||
**Context Detection Rules:**
|
||||
- `/api/` → API context
|
||||
- `/admin/` → Admin context
|
||||
- `/vendor/` → Vendor Dashboard context
|
||||
- `/shop/` → Shop context
|
||||
- Default → Fallback context
|
||||
**Detection Rules (Priority Order):**
|
||||
1. Admin subdomain (`admin.*`) → ADMIN
|
||||
2. Path-based detection:
|
||||
- `/admin/*`, `/api/v1/admin/*` → ADMIN
|
||||
- `/vendor/*`, `/api/v1/vendor/*` → VENDOR
|
||||
- `/storefront/*`, `/shop/*`, `/vendors/*` → STOREFRONT
|
||||
- `/api/v1/platform/*` → PLATFORM
|
||||
3. Vendor subdomain → STOREFRONT
|
||||
4. Vendor context set → STOREFRONT
|
||||
5. Default → PLATFORM
|
||||
|
||||
::: middleware.context.ContextManager
|
||||
::: app.core.frontend_detector.FrontendDetector
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
show_root_heading: false
|
||||
|
||||
### ContextMiddleware
|
||||
### FrontendTypeMiddleware
|
||||
|
||||
ASGI middleware for context detection. Must run AFTER VendorContextMiddleware.
|
||||
ASGI middleware for frontend type detection. Must run AFTER VendorContextMiddleware.
|
||||
|
||||
::: middleware.context.ContextMiddleware
|
||||
::: middleware.frontend_type.FrontendTypeMiddleware
|
||||
options:
|
||||
show_source: false
|
||||
heading_level: 4
|
||||
show_root_heading: false
|
||||
|
||||
> **Note**: The old `RequestContext` enum and `ContextMiddleware` are deprecated.
|
||||
> See [Frontend Detection Architecture](../architecture/frontend-detection.md) for migration guide.
|
||||
|
||||
---
|
||||
|
||||
## Theme Management
|
||||
@@ -235,20 +241,24 @@ The middleware stack must be configured in the correct order for proper function
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Request] --> B[LoggingMiddleware]
|
||||
B --> C[VendorContextMiddleware]
|
||||
C --> D[ContextMiddleware]
|
||||
D --> E[ThemeContextMiddleware]
|
||||
E --> F[Application Routes]
|
||||
F --> G[Response]
|
||||
B --> C[PlatformContextMiddleware]
|
||||
C --> D[VendorContextMiddleware]
|
||||
D --> E[FrontendTypeMiddleware]
|
||||
E --> F[LanguageMiddleware]
|
||||
F --> G[ThemeContextMiddleware]
|
||||
G --> H[Application Routes]
|
||||
H --> I[Response]
|
||||
```
|
||||
|
||||
**Critical Dependencies:**
|
||||
1. **LoggingMiddleware** runs first for request timing
|
||||
2. **VendorContextMiddleware** detects vendor and sets clean_path
|
||||
3. **ContextMiddleware** detects context type (API/Admin/Vendor/Shop)
|
||||
4. **ThemeContextMiddleware** loads vendor theme based on context
|
||||
2. **PlatformContextMiddleware** detects platform and sets platform context
|
||||
3. **VendorContextMiddleware** detects vendor and sets clean_path
|
||||
4. **FrontendTypeMiddleware** detects frontend type (ADMIN/VENDOR/STOREFRONT/PLATFORM)
|
||||
5. **LanguageMiddleware** resolves language based on frontend type
|
||||
6. **ThemeContextMiddleware** loads vendor theme based on context
|
||||
|
||||
**Note:** Path-based routing (e.g., `/vendors/{code}/shop/*`) is handled by double router mounting in `main.py`, not by middleware.
|
||||
**Note:** Path-based routing (e.g., `/vendors/{code}/storefront/*`) is handled by double router mounting in `main.py`, not by middleware.
|
||||
|
||||
---
|
||||
|
||||
@@ -258,22 +268,28 @@ Middleware components inject the following variables into `request.state`:
|
||||
|
||||
| Variable | Set By | Type | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `platform` | PlatformContextMiddleware | Platform | Current platform object |
|
||||
| `vendor` | VendorContextMiddleware | Vendor | Current vendor object |
|
||||
| `vendor_id` | VendorContextMiddleware | int | Current vendor ID |
|
||||
| `clean_path` | VendorContextMiddleware | str | Path without vendor prefix |
|
||||
| `context_type` | ContextMiddleware | RequestContext | Request context (API/Admin/Vendor/Shop) |
|
||||
| `frontend_type` | FrontendTypeMiddleware | FrontendType | Frontend type (ADMIN/VENDOR/STOREFRONT/PLATFORM) |
|
||||
| `language` | LanguageMiddleware | str | Detected language code |
|
||||
| `theme` | ThemeContextMiddleware | dict | Vendor theme configuration |
|
||||
|
||||
**Usage in Routes:**
|
||||
```python
|
||||
from fastapi import Request
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
|
||||
@app.get("/shop/products")
|
||||
@app.get("/storefront/products")
|
||||
async def get_products(request: Request):
|
||||
vendor = request.state.vendor
|
||||
context = request.state.context_type
|
||||
frontend_type = get_frontend_type(request)
|
||||
theme = request.state.theme
|
||||
return {"vendor": vendor.name, "context": context}
|
||||
|
||||
if frontend_type == FrontendType.STOREFRONT:
|
||||
return {"vendor": vendor.name, "frontend": frontend_type.value}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -308,6 +324,8 @@ For testing examples, see the [Testing Guide](../testing/testing-guide.md).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Frontend Detection Architecture](../architecture/frontend-detection.md) - Frontend type detection system
|
||||
- [Middleware Architecture](../architecture/middleware.md) - Middleware stack overview
|
||||
- [Authentication Guide](../api/authentication.md) - User authentication and JWT tokens
|
||||
- [RBAC Documentation](../api/rbac.md) - Role-based access control
|
||||
- [Error Handling](../api/error-handling.md) - Exception handling patterns
|
||||
|
||||
16
main.py
16
main.py
@@ -69,7 +69,7 @@ from app.modules.routes import (
|
||||
get_vendor_page_routes,
|
||||
)
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
from middleware.context import ContextMiddleware
|
||||
from middleware.frontend_type import FrontendTypeMiddleware
|
||||
from middleware.language import LanguageMiddleware
|
||||
from middleware.logging import LoggingMiddleware
|
||||
from middleware.theme_context import ThemeContextMiddleware
|
||||
@@ -122,15 +122,15 @@ app.add_middleware(
|
||||
# Desired execution order:
|
||||
# 1. PlatformContextMiddleware (detect platform from domain/path)
|
||||
# 2. VendorContextMiddleware (detect vendor, uses platform_clean_path)
|
||||
# 3. ContextMiddleware (detect context using clean_path)
|
||||
# 4. LanguageMiddleware (detect language based on context)
|
||||
# 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector)
|
||||
# 4. LanguageMiddleware (detect language based on frontend type)
|
||||
# 5. ThemeContextMiddleware (load theme)
|
||||
# 6. LoggingMiddleware (log all requests)
|
||||
#
|
||||
# Therefore we add them in REVERSE:
|
||||
# - Add ThemeContextMiddleware FIRST (runs LAST in request)
|
||||
# - Add LanguageMiddleware SECOND
|
||||
# - Add ContextMiddleware THIRD
|
||||
# - Add FrontendTypeMiddleware THIRD
|
||||
# - Add VendorContextMiddleware FOURTH
|
||||
# - Add PlatformContextMiddleware FIFTH
|
||||
# - Add LoggingMiddleware LAST (runs FIRST for timing)
|
||||
@@ -152,9 +152,9 @@ app.add_middleware(ThemeContextMiddleware)
|
||||
logger.info("Adding LanguageMiddleware (detects language based on context)")
|
||||
app.add_middleware(LanguageMiddleware)
|
||||
|
||||
# Add context detection middleware (runs after vendor context extraction)
|
||||
logger.info("Adding ContextMiddleware (detects context type using clean_path)")
|
||||
app.add_middleware(ContextMiddleware)
|
||||
# Add frontend type detection middleware (runs after vendor context extraction)
|
||||
logger.info("Adding FrontendTypeMiddleware (detects frontend type using FrontendDetector)")
|
||||
app.add_middleware(FrontendTypeMiddleware)
|
||||
|
||||
# Add vendor context middleware (runs after platform context)
|
||||
logger.info("Adding VendorContextMiddleware (detects vendor, uses platform_clean_path)")
|
||||
@@ -170,7 +170,7 @@ logger.info(" Execution order (request →):")
|
||||
logger.info(" 1. LoggingMiddleware (timing)")
|
||||
logger.info(" 2. PlatformContextMiddleware (platform detection)")
|
||||
logger.info(" 3. VendorContextMiddleware (vendor detection)")
|
||||
logger.info(" 4. ContextMiddleware (context detection)")
|
||||
logger.info(" 4. FrontendTypeMiddleware (frontend type detection)")
|
||||
logger.info(" 5. LanguageMiddleware (language detection)")
|
||||
logger.info(" 6. ThemeContextMiddleware (theme loading)")
|
||||
logger.info(" 7. FastAPI Router")
|
||||
|
||||
@@ -1,31 +1,52 @@
|
||||
# middleware/context_middleware.py
|
||||
# middleware/context.py
|
||||
"""
|
||||
Context Detection Middleware (Class-Based)
|
||||
DEPRECATED: This module is deprecated in favor of middleware/frontend_type.py
|
||||
|
||||
Detects the request context type (API, Admin, Vendor Dashboard, Shop, or Fallback)
|
||||
and injects it into request.state for use by error handlers and other components.
|
||||
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)
|
||||
|
||||
MUST run AFTER vendor_context_middleware to have access to clean_path.
|
||||
MUST run BEFORE theme_context_middleware (which needs context_type).
|
||||
This file is kept for backwards compatibility during the migration period.
|
||||
All new code should use FrontendType and FrontendTypeMiddleware instead.
|
||||
|
||||
Class-based middleware provides:
|
||||
- Better state management
|
||||
- Easier testing
|
||||
- More organized code
|
||||
- Standard ASGI pattern
|
||||
Migration guide:
|
||||
- RequestContext.API -> Check with FrontendDetector.is_api_request()
|
||||
- RequestContext.ADMIN -> FrontendType.ADMIN
|
||||
- RequestContext.VENDOR_DASHBOARD -> FrontendType.VENDOR
|
||||
- RequestContext.SHOP -> 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 starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import get_frontend_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestContext(str, Enum):
|
||||
"""Request context types for the application."""
|
||||
"""
|
||||
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
|
||||
- VENDOR_DASHBOARD -> FrontendType.VENDOR
|
||||
- SHOP -> FrontendType.STOREFRONT
|
||||
- FALLBACK -> FrontendType.PLATFORM
|
||||
"""
|
||||
|
||||
API = "api"
|
||||
ADMIN = "admin"
|
||||
@@ -34,169 +55,12 @@ class RequestContext(str, Enum):
|
||||
FALLBACK = "fallback"
|
||||
|
||||
|
||||
class ContextManager:
|
||||
"""Manages context detection for multi-area application."""
|
||||
|
||||
@staticmethod
|
||||
def detect_context(request: Request) -> RequestContext:
|
||||
"""
|
||||
Detect the request context type.
|
||||
|
||||
Priority order:
|
||||
1. API → /api/* paths (highest priority, always JSON)
|
||||
2. Admin → /admin/* paths or admin.* subdomain
|
||||
3. Vendor Dashboard → /vendor/* paths (vendor management area)
|
||||
4. Shop → Vendor storefront (custom domain, subdomain, or shop paths)
|
||||
5. Fallback → Unknown/generic context
|
||||
|
||||
CRITICAL: Uses clean_path (if available) instead of original path.
|
||||
This ensures correct context detection for path-based routing.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
RequestContext enum value
|
||||
"""
|
||||
# Use clean_path if available (extracted by vendor_context_middleware)
|
||||
# Falls back to original path if clean_path not set
|
||||
# This is critical for correct context detection with path-based routing
|
||||
path = getattr(request.state, "clean_path", request.url.path)
|
||||
|
||||
host = request.headers.get("host", "")
|
||||
|
||||
# Remove port from host if present
|
||||
if ":" in host:
|
||||
host = host.split(":")[0]
|
||||
|
||||
logger.debug(
|
||||
"[CONTEXT] Detecting context",
|
||||
extra={
|
||||
"original_path": request.url.path,
|
||||
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
|
||||
"path_to_check": path,
|
||||
"host": host,
|
||||
},
|
||||
)
|
||||
|
||||
# 1. API context (highest priority)
|
||||
if path.startswith("/api/"):
|
||||
logger.debug("[CONTEXT] Detected as API", extra={"path": path})
|
||||
return RequestContext.API
|
||||
|
||||
# 2. Admin context
|
||||
if ContextManager._is_admin_context(request, host, path):
|
||||
logger.debug(
|
||||
"[CONTEXT] Detected as ADMIN", extra={"path": path, "host": host}
|
||||
)
|
||||
return RequestContext.ADMIN
|
||||
|
||||
# 3. Vendor Dashboard context (vendor management area)
|
||||
# Check both clean_path and original path for vendor dashboard
|
||||
original_path = request.url.path
|
||||
if ContextManager._is_vendor_dashboard_context(
|
||||
path
|
||||
) or ContextManager._is_vendor_dashboard_context(original_path):
|
||||
logger.debug(
|
||||
"[CONTEXT] Detected as VENDOR_DASHBOARD",
|
||||
extra={"path": path, "original_path": original_path},
|
||||
)
|
||||
return RequestContext.VENDOR_DASHBOARD
|
||||
|
||||
# 4. Shop context (vendor storefront)
|
||||
# Check if vendor context exists (set by vendor_context_middleware)
|
||||
if hasattr(request.state, "vendor") and request.state.vendor:
|
||||
# If we have a vendor and it's not admin or vendor dashboard, it's shop
|
||||
logger.debug(
|
||||
"[CONTEXT] Detected as SHOP (has vendor context)",
|
||||
extra={"vendor": request.state.vendor.name},
|
||||
)
|
||||
return RequestContext.SHOP
|
||||
|
||||
# Also check shop-specific paths
|
||||
if path.startswith("/shop/"):
|
||||
logger.debug("[CONTEXT] Detected as SHOP (from path)", extra={"path": path})
|
||||
return RequestContext.SHOP
|
||||
|
||||
# 5. Fallback for unknown contexts
|
||||
logger.debug("[CONTEXT] Detected as FALLBACK", extra={"path": path})
|
||||
return RequestContext.FALLBACK
|
||||
|
||||
@staticmethod
|
||||
def _is_admin_context(request: Request, host: str, path: str) -> bool:
|
||||
"""Check if request is in admin context."""
|
||||
# Admin subdomain (admin.platform.com)
|
||||
if host.startswith("admin."):
|
||||
return True
|
||||
|
||||
# Admin path (/admin/*)
|
||||
if path.startswith("/admin"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_vendor_dashboard_context(path: str) -> bool:
|
||||
"""Check if request is in vendor dashboard context."""
|
||||
# Vendor dashboard paths (/vendor/{code}/*)
|
||||
# Note: This is the vendor management area, not the shop
|
||||
# Important: /vendors/{code}/shop/* should NOT match this
|
||||
if path.startswith("/vendor/") and not path.startswith("/vendors/"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class ContextMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to detect and inject request context into request.state.
|
||||
|
||||
Class-based middleware provides:
|
||||
- Better lifecycle management
|
||||
- Easier to test and extend
|
||||
- Standard ASGI pattern
|
||||
- Clear separation of concerns
|
||||
|
||||
Runs SECOND in middleware chain (after vendor_context_middleware).
|
||||
Depends on:
|
||||
request.state.clean_path (set by vendor_context_middleware)
|
||||
request.state.vendor (set by vendor_context_middleware)
|
||||
|
||||
Sets:
|
||||
request.state.context_type: RequestContext enum value
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
Detect context and inject into request state.
|
||||
"""
|
||||
# Detect context
|
||||
context_type = ContextManager.detect_context(request)
|
||||
|
||||
# Inject into request state
|
||||
request.state.context_type = context_type
|
||||
|
||||
# Log context detection with full details
|
||||
logger.debug(
|
||||
f"[CONTEXT_MIDDLEWARE] Context detected: {context_type.value}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
|
||||
"host": request.headers.get("host", ""),
|
||||
"context": context_type.value,
|
||||
"has_vendor": hasattr(request.state, "vendor")
|
||||
and request.state.vendor is not None,
|
||||
},
|
||||
)
|
||||
|
||||
# Continue processing
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
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
|
||||
@@ -204,4 +68,33 @@ def get_request_context(request: Request) -> RequestContext:
|
||||
Returns:
|
||||
RequestContext enum value (defaults to FALLBACK if not set)
|
||||
"""
|
||||
return getattr(request.state, "context_type", RequestContext.FALLBACK)
|
||||
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.VENDOR: RequestContext.VENDOR_DASHBOARD,
|
||||
FrontendType.STOREFRONT: RequestContext.SHOP,
|
||||
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
|
||||
|
||||
92
middleware/frontend_type.py
Normal file
92
middleware/frontend_type.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# middleware/frontend_type.py
|
||||
"""
|
||||
Frontend Type Detection Middleware
|
||||
|
||||
Sets request.state.frontend_type for all requests using centralized FrontendDetector.
|
||||
|
||||
This middleware replaces the old ContextMiddleware and provides a unified way to
|
||||
detect which frontend (ADMIN, VENDOR, STOREFRONT, PLATFORM) is being accessed.
|
||||
|
||||
MUST run AFTER VendorContextMiddleware to have access to vendor context.
|
||||
MUST run BEFORE LanguageMiddleware (which needs frontend_type).
|
||||
|
||||
Sets:
|
||||
request.state.frontend_type: FrontendType enum value
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrontendTypeMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to detect and inject frontend type into request state.
|
||||
|
||||
Uses FrontendDetector for centralized, consistent detection across the app.
|
||||
|
||||
Runs AFTER VendorContextMiddleware in request chain.
|
||||
Depends on:
|
||||
request.state.vendor (optional, set by VendorContextMiddleware)
|
||||
request.state.clean_path (optional, set by VendorContextMiddleware)
|
||||
|
||||
Sets:
|
||||
request.state.frontend_type: FrontendType enum value
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Detect frontend type and inject into request state."""
|
||||
host = request.headers.get("host", "")
|
||||
# Use clean_path if available (from vendor_context_middleware), else original path
|
||||
path = getattr(request.state, "clean_path", None) or request.url.path
|
||||
|
||||
# Check if vendor context exists (set by VendorContextMiddleware)
|
||||
has_vendor_context = (
|
||||
hasattr(request.state, "vendor")
|
||||
and request.state.vendor is not None
|
||||
)
|
||||
|
||||
# Detect frontend type using centralized detector
|
||||
frontend_type = FrontendDetector.detect(
|
||||
host=host,
|
||||
path=path,
|
||||
has_vendor_context=has_vendor_context,
|
||||
)
|
||||
|
||||
# Store in request state
|
||||
request.state.frontend_type = frontend_type
|
||||
|
||||
# Log detection for debugging
|
||||
logger.debug(
|
||||
f"[FRONTEND_TYPE_MIDDLEWARE] Frontend type detected: {frontend_type.value}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
|
||||
"host": host,
|
||||
"frontend_type": frontend_type.value,
|
||||
"has_vendor": has_vendor_context,
|
||||
},
|
||||
)
|
||||
|
||||
# Continue processing
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
def get_frontend_type(request: Request) -> FrontendType:
|
||||
"""
|
||||
Helper function to get current frontend type from request.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
FrontendType enum value (defaults to PLATFORM if not set)
|
||||
"""
|
||||
return getattr(request.state, "frontend_type", FrontendType.PLATFORM)
|
||||
@@ -18,6 +18,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
from app.utils.i18n import (
|
||||
DEFAULT_LANGUAGE,
|
||||
SUPPORTED_LANGUAGES,
|
||||
@@ -45,9 +46,8 @@ class LanguageMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process the request and set language."""
|
||||
# Get context type from previous middleware
|
||||
context_type = getattr(request.state, "context_type", None)
|
||||
context_value = context_type.value if context_type else None
|
||||
# Get frontend type from FrontendTypeMiddleware
|
||||
frontend_type = getattr(request.state, "frontend_type", None)
|
||||
|
||||
# Get vendor from previous middleware (if available)
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
@@ -59,13 +59,13 @@ class LanguageMiddleware(BaseHTTPMiddleware):
|
||||
accept_language = request.headers.get("accept-language")
|
||||
browser_language = parse_accept_language(accept_language)
|
||||
|
||||
# Resolve language based on context
|
||||
if context_value == "admin":
|
||||
# Resolve language based on frontend type
|
||||
if frontend_type == FrontendType.ADMIN:
|
||||
# Admin dashboard: English only (for now)
|
||||
# TODO: Implement admin language support later
|
||||
language = "en"
|
||||
|
||||
elif context_value == "vendor_dashboard":
|
||||
elif frontend_type == FrontendType.VENDOR:
|
||||
# Vendor dashboard
|
||||
user_preferred = self._get_user_language_from_token(request)
|
||||
vendor_dashboard = vendor.dashboard_language if vendor else None
|
||||
@@ -75,7 +75,7 @@ class LanguageMiddleware(BaseHTTPMiddleware):
|
||||
vendor_dashboard=vendor_dashboard,
|
||||
)
|
||||
|
||||
elif context_value == "shop":
|
||||
elif frontend_type == FrontendType.STOREFRONT:
|
||||
# Storefront
|
||||
customer_preferred = self._get_customer_language_from_token(request)
|
||||
vendor_storefront = vendor.storefront_language if vendor else None
|
||||
@@ -89,12 +89,12 @@ class LanguageMiddleware(BaseHTTPMiddleware):
|
||||
enabled_languages=enabled_languages,
|
||||
)
|
||||
|
||||
elif context_value == "api":
|
||||
# API requests: Use Accept-Language or cookie
|
||||
elif frontend_type == FrontendType.PLATFORM:
|
||||
# Platform marketing pages: Use cookie, browser, or default
|
||||
language = cookie_language or browser_language or DEFAULT_LANGUAGE
|
||||
|
||||
else:
|
||||
# Fallback: Use cookie, browser, or default
|
||||
# Fallback (API or unknown): Use Accept-Language or cookie
|
||||
language = cookie_language or browser_language or DEFAULT_LANGUAGE
|
||||
|
||||
# Validate language is supported
|
||||
@@ -109,13 +109,14 @@ class LanguageMiddleware(BaseHTTPMiddleware):
|
||||
"code": language,
|
||||
"cookie": cookie_language,
|
||||
"browser": browser_language,
|
||||
"context": context_value,
|
||||
"frontend_type": frontend_type.value if frontend_type else None,
|
||||
}
|
||||
|
||||
# Log language detection for debugging
|
||||
frontend_value = frontend_type.value if frontend_type else "unknown"
|
||||
logger.debug(
|
||||
f"Language detected: {language} "
|
||||
f"(context={context_value}, cookie={cookie_language}, browser={browser_language})"
|
||||
f"(frontend={frontend_value}, cookie={cookie_language}, browser={browser_language})"
|
||||
)
|
||||
|
||||
# Process request
|
||||
|
||||
@@ -22,6 +22,8 @@ from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting
|
||||
@@ -61,7 +63,7 @@ class PlatformContextManager:
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
|
||||
# Skip platform detection for admin routes - admin is global
|
||||
if PlatformContextManager.is_admin_request(request):
|
||||
if FrontendDetector.is_admin(host, path):
|
||||
return None
|
||||
|
||||
# Method 1: Domain-based detection (production)
|
||||
@@ -208,17 +210,15 @@ class PlatformContextManager:
|
||||
|
||||
@staticmethod
|
||||
def is_admin_request(request: Request) -> bool:
|
||||
"""Check if request is for admin interface."""
|
||||
"""
|
||||
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
|
||||
|
||||
if ":" in host:
|
||||
host = host.split(":")[0]
|
||||
|
||||
if host.startswith("admin."):
|
||||
return True
|
||||
|
||||
return path.startswith("/admin")
|
||||
return FrontendDetector.is_admin(host, path)
|
||||
|
||||
@staticmethod
|
||||
def is_static_file_request(request: Request) -> bool:
|
||||
@@ -299,7 +299,7 @@ class PlatformContextMiddleware:
|
||||
return
|
||||
|
||||
# Skip for admin requests
|
||||
if self._is_admin_request(path, host):
|
||||
if FrontendDetector.is_admin(host, path):
|
||||
scope["state"]["platform"] = None
|
||||
scope["state"]["platform_context"] = None
|
||||
scope["state"]["platform_clean_path"] = path
|
||||
@@ -427,11 +427,13 @@ class PlatformContextMiddleware:
|
||||
return "favicon.ico" in path_lower
|
||||
|
||||
def _is_admin_request(self, path: str, host: str) -> bool:
|
||||
"""Check if request is for admin interface."""
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
if host_without_port.startswith("admin."):
|
||||
return True
|
||||
return path.startswith("/admin")
|
||||
"""
|
||||
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:
|
||||
|
||||
@@ -23,6 +23,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.frontend_detector import FrontendDetector
|
||||
from app.modules.tenancy.models import Vendor
|
||||
from app.modules.tenancy.models import VendorDomain
|
||||
|
||||
@@ -194,22 +195,20 @@ class VendorContextManager:
|
||||
|
||||
@staticmethod
|
||||
def is_admin_request(request: Request) -> bool:
|
||||
"""Check if request is for admin interface."""
|
||||
"""
|
||||
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
|
||||
|
||||
if ":" in host:
|
||||
host = host.split(":")[0]
|
||||
|
||||
if host.startswith("admin."):
|
||||
return True
|
||||
|
||||
return path.startswith("/admin")
|
||||
return FrontendDetector.is_admin(host, path)
|
||||
|
||||
@staticmethod
|
||||
def is_api_request(request: Request) -> bool:
|
||||
"""Check if request is for API endpoints."""
|
||||
return request.url.path.startswith("/api/")
|
||||
return FrontendDetector.is_api_request(request.url.path)
|
||||
|
||||
@staticmethod
|
||||
def is_shop_api_request(request: Request) -> bool:
|
||||
|
||||
256
tests/unit/core/test_frontend_detector.py
Normal file
256
tests/unit/core/test_frontend_detector.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# tests/unit/core/test_frontend_detector.py
|
||||
"""
|
||||
Unit tests for FrontendDetector.
|
||||
|
||||
Tests cover:
|
||||
- Detection for all frontend types (ADMIN, VENDOR, STOREFRONT, PLATFORM)
|
||||
- Path-based detection (dev mode)
|
||||
- Subdomain-based detection (prod mode)
|
||||
- Custom domain detection
|
||||
- Legacy /shop/ path support
|
||||
- Priority order of detection methods
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.frontend_detector import FrontendDetector, get_frontend_type
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendDetectorAdmin:
|
||||
"""Test suite for admin frontend detection."""
|
||||
|
||||
def test_detect_admin_from_subdomain(self):
|
||||
"""Test admin detection from admin subdomain."""
|
||||
result = FrontendDetector.detect(host="admin.oms.lu", path="/dashboard")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_detect_admin_from_subdomain_with_port(self):
|
||||
"""Test admin detection from admin subdomain with port."""
|
||||
result = FrontendDetector.detect(host="admin.localhost:8000", path="/dashboard")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_detect_admin_from_path(self):
|
||||
"""Test admin detection from /admin path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/admin/vendors")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_detect_admin_from_api_path(self):
|
||||
"""Test admin detection from /api/v1/admin path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/api/v1/admin/users")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_detect_admin_nested_path(self):
|
||||
"""Test admin detection with nested admin path."""
|
||||
result = FrontendDetector.detect(host="oms.lu", path="/admin/vendors/123/products")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendDetectorVendor:
|
||||
"""Test suite for vendor dashboard frontend detection."""
|
||||
|
||||
def test_detect_vendor_from_path(self):
|
||||
"""Test vendor detection from /vendor/ path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/vendor/settings")
|
||||
assert result == FrontendType.VENDOR
|
||||
|
||||
def test_detect_vendor_from_api_path(self):
|
||||
"""Test vendor detection from /api/v1/vendor path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/api/v1/vendor/products")
|
||||
assert result == FrontendType.VENDOR
|
||||
|
||||
def test_detect_vendor_nested_path(self):
|
||||
"""Test vendor detection with nested vendor path."""
|
||||
result = FrontendDetector.detect(host="oms.lu", path="/vendor/dashboard/analytics")
|
||||
assert result == FrontendType.VENDOR
|
||||
|
||||
def test_vendors_plural_not_vendor_dashboard(self):
|
||||
"""Test that /vendors/ path is NOT vendor dashboard (it's storefront)."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/vendors/wizamart/storefront")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendDetectorStorefront:
|
||||
"""Test suite for storefront frontend detection."""
|
||||
|
||||
def test_detect_storefront_from_path(self):
|
||||
"""Test storefront detection from /storefront path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/storefront/products")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_detect_storefront_from_api_path(self):
|
||||
"""Test storefront detection from /api/v1/storefront path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/api/v1/storefront/cart")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_detect_storefront_from_vendors_path(self):
|
||||
"""Test storefront detection from /vendors/ path (path-based vendor access)."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/vendors/wizamart/products")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_detect_storefront_from_vendor_subdomain(self):
|
||||
"""Test storefront detection from vendor subdomain."""
|
||||
result = FrontendDetector.detect(host="wizamart.oms.lu", path="/products")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_detect_storefront_from_vendor_context(self):
|
||||
"""Test storefront detection when vendor context is set."""
|
||||
result = FrontendDetector.detect(
|
||||
host="mybakery.lu", path="/about", has_vendor_context=True
|
||||
)
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_detect_storefront_legacy_shop_path(self):
|
||||
"""Test storefront detection from legacy /shop path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/shop/products")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_detect_storefront_legacy_shop_api_path(self):
|
||||
"""Test storefront detection from legacy /api/v1/shop path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/api/v1/shop/cart")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendDetectorPlatform:
|
||||
"""Test suite for platform marketing frontend detection."""
|
||||
|
||||
def test_detect_platform_from_root(self):
|
||||
"""Test platform detection from root path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
def test_detect_platform_from_marketing_page(self):
|
||||
"""Test platform detection from marketing page."""
|
||||
result = FrontendDetector.detect(host="oms.lu", path="/pricing")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
def test_detect_platform_from_about(self):
|
||||
"""Test platform detection from about page."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/about")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
def test_detect_platform_from_api_path(self):
|
||||
"""Test platform detection from /api/v1/platform path."""
|
||||
result = FrontendDetector.detect(host="localhost", path="/api/v1/platform/config")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendDetectorPriority:
|
||||
"""Test suite for detection priority order."""
|
||||
|
||||
def test_admin_subdomain_priority_over_path(self):
|
||||
"""Test that admin subdomain takes priority."""
|
||||
result = FrontendDetector.detect(host="admin.oms.lu", path="/storefront/products")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_admin_path_priority_over_vendor_context(self):
|
||||
"""Test that admin path takes priority over vendor context."""
|
||||
result = FrontendDetector.detect(
|
||||
host="localhost", path="/admin/dashboard", has_vendor_context=True
|
||||
)
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_path_priority_over_subdomain(self):
|
||||
"""Test that explicit path takes priority for vendor/storefront."""
|
||||
# /vendor/ path on a vendor subdomain -> VENDOR (path wins)
|
||||
result = FrontendDetector.detect(host="wizamart.oms.lu", path="/vendor/settings")
|
||||
assert result == FrontendType.VENDOR
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendDetectorHelpers:
|
||||
"""Test suite for helper methods."""
|
||||
|
||||
def test_strip_port(self):
|
||||
"""Test port stripping from host."""
|
||||
assert FrontendDetector._strip_port("localhost:8000") == "localhost"
|
||||
assert FrontendDetector._strip_port("oms.lu") == "oms.lu"
|
||||
assert FrontendDetector._strip_port("admin.localhost:9999") == "admin.localhost"
|
||||
|
||||
def test_get_subdomain(self):
|
||||
"""Test subdomain extraction."""
|
||||
assert FrontendDetector._get_subdomain("wizamart.oms.lu") == "wizamart"
|
||||
assert FrontendDetector._get_subdomain("admin.oms.lu") == "admin"
|
||||
assert FrontendDetector._get_subdomain("oms.lu") is None
|
||||
assert FrontendDetector._get_subdomain("localhost") is None
|
||||
assert FrontendDetector._get_subdomain("127.0.0.1") is None
|
||||
|
||||
def test_is_admin(self):
|
||||
"""Test is_admin convenience method."""
|
||||
assert FrontendDetector.is_admin("admin.oms.lu", "/dashboard") is True
|
||||
assert FrontendDetector.is_admin("localhost", "/admin/vendors") is True
|
||||
assert FrontendDetector.is_admin("localhost", "/vendor/settings") is False
|
||||
|
||||
def test_is_vendor(self):
|
||||
"""Test is_vendor convenience method."""
|
||||
assert FrontendDetector.is_vendor("localhost", "/vendor/settings") is True
|
||||
assert FrontendDetector.is_vendor("localhost", "/api/v1/vendor/products") is True
|
||||
assert FrontendDetector.is_vendor("localhost", "/admin/dashboard") is False
|
||||
|
||||
def test_is_storefront(self):
|
||||
"""Test is_storefront convenience method."""
|
||||
assert FrontendDetector.is_storefront("localhost", "/storefront/products") is True
|
||||
assert FrontendDetector.is_storefront("wizamart.oms.lu", "/products") is True
|
||||
assert FrontendDetector.is_storefront("localhost", "/admin/dashboard") is False
|
||||
|
||||
def test_is_platform(self):
|
||||
"""Test is_platform convenience method."""
|
||||
assert FrontendDetector.is_platform("localhost", "/") is True
|
||||
assert FrontendDetector.is_platform("oms.lu", "/pricing") is True
|
||||
assert FrontendDetector.is_platform("localhost", "/admin/dashboard") is False
|
||||
|
||||
def test_is_api_request(self):
|
||||
"""Test is_api_request convenience method."""
|
||||
assert FrontendDetector.is_api_request("/api/v1/vendors") is True
|
||||
assert FrontendDetector.is_api_request("/api/v1/admin/users") is True
|
||||
assert FrontendDetector.is_api_request("/admin/dashboard") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetFrontendTypeFunction:
|
||||
"""Test suite for get_frontend_type convenience function."""
|
||||
|
||||
def test_get_frontend_type_admin(self):
|
||||
"""Test get_frontend_type returns admin."""
|
||||
result = get_frontend_type("localhost", "/admin/dashboard")
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_get_frontend_type_vendor(self):
|
||||
"""Test get_frontend_type returns vendor."""
|
||||
result = get_frontend_type("localhost", "/vendor/settings")
|
||||
assert result == FrontendType.VENDOR
|
||||
|
||||
def test_get_frontend_type_storefront(self):
|
||||
"""Test get_frontend_type returns storefront."""
|
||||
result = get_frontend_type("localhost", "/storefront/products")
|
||||
assert result == FrontendType.STOREFRONT
|
||||
|
||||
def test_get_frontend_type_platform(self):
|
||||
"""Test get_frontend_type returns platform."""
|
||||
result = get_frontend_type("localhost", "/pricing")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReservedSubdomains:
|
||||
"""Test suite for reserved subdomain handling."""
|
||||
|
||||
def test_www_subdomain_not_storefront(self):
|
||||
"""Test that www subdomain is not treated as vendor storefront."""
|
||||
result = FrontendDetector.detect(host="www.oms.lu", path="/")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
def test_api_subdomain_not_storefront(self):
|
||||
"""Test that api subdomain is not treated as vendor storefront."""
|
||||
result = FrontendDetector.detect(host="api.oms.lu", path="/v1/products")
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
def test_portal_subdomain_not_storefront(self):
|
||||
"""Test that portal subdomain is not treated as vendor storefront."""
|
||||
result = FrontendDetector.detect(host="portal.oms.lu", path="/")
|
||||
assert result == FrontendType.PLATFORM
|
||||
@@ -1,31 +1,31 @@
|
||||
# tests/unit/middleware/test_context.py
|
||||
"""
|
||||
Comprehensive unit tests for ContextMiddleware and ContextManager.
|
||||
DEPRECATED: Tests for backward compatibility of middleware.context module.
|
||||
|
||||
Tests cover:
|
||||
- Context detection for API, Admin, Vendor Dashboard, Shop, and Fallback
|
||||
- Clean path usage for correct context detection
|
||||
- Host and path-based context determination
|
||||
- Middleware state injection
|
||||
- Edge cases and error handling
|
||||
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
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
import warnings
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from middleware.context import (
|
||||
ContextManager,
|
||||
ContextMiddleware,
|
||||
RequestContext,
|
||||
get_request_context,
|
||||
)
|
||||
from middleware.context import RequestContext, get_request_context
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequestContextEnum:
|
||||
"""Test suite for RequestContext enum."""
|
||||
class TestRequestContextEnumBackwardCompatibility:
|
||||
"""Test suite for deprecated RequestContext enum."""
|
||||
|
||||
def test_request_context_values(self):
|
||||
"""Test RequestContext enum has correct values."""
|
||||
@@ -42,554 +42,90 @@ class TestRequestContextEnum:
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContextManagerDetection:
|
||||
"""Test suite for ContextManager.detect_context()."""
|
||||
class TestGetRequestContextBackwardCompatibility:
|
||||
"""Test suite for deprecated get_request_context() function."""
|
||||
|
||||
# ========================================================================
|
||||
# API Context Tests (Highest Priority)
|
||||
# ========================================================================
|
||||
|
||||
def test_detect_api_context(self):
|
||||
"""Test API context detection."""
|
||||
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/vendors")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/api/v1/vendors")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_detect_api_context_nested_path(self):
|
||||
"""Test API context detection with nested path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/v1/vendors/123/products")
|
||||
request.headers = {"host": "platform.com"}
|
||||
request.state = Mock(clean_path="/api/v1/vendors/123/products")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_detect_api_context_with_clean_path(self):
|
||||
"""Test API context detection uses clean_path when available."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/testvendor/api/products")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/api/products")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
# ========================================================================
|
||||
# Admin Context Tests
|
||||
# ========================================================================
|
||||
|
||||
def test_detect_admin_context_from_subdomain(self):
|
||||
"""Test admin context detection from subdomain."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/dashboard")
|
||||
request.headers = {"host": "admin.platform.com"}
|
||||
request.state = Mock(clean_path="/dashboard")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_detect_admin_context_from_path(self):
|
||||
"""Test admin context detection from path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.headers = {"host": "platform.com"}
|
||||
request.state = Mock(clean_path="/admin/dashboard")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_detect_admin_context_with_port(self):
|
||||
"""Test admin context detection with port number."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/dashboard")
|
||||
request.headers = {"host": "admin.localhost:8000"}
|
||||
request.state = Mock(clean_path="/dashboard")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_detect_admin_context_nested_path(self):
|
||||
"""Test admin context detection with nested admin path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/vendors/list")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/admin/vendors/list")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
# ========================================================================
|
||||
# Vendor Dashboard Context Tests
|
||||
# ========================================================================
|
||||
|
||||
def test_detect_vendor_dashboard_context(self):
|
||||
"""Test vendor dashboard context detection."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/testvendor/dashboard")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/dashboard")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.VENDOR_DASHBOARD
|
||||
|
||||
def test_detect_vendor_dashboard_context_direct_path(self):
|
||||
"""Test vendor dashboard with direct /vendor/ path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/settings")
|
||||
request.headers = {"host": "testvendor.platform.com"}
|
||||
request.state = Mock(clean_path="/vendor/settings")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.VENDOR_DASHBOARD
|
||||
|
||||
def test_not_detect_vendors_plural_as_dashboard(self):
|
||||
"""Test that /vendors/ path is not detected as vendor dashboard."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendors/testvendor/shop")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/shop")
|
||||
|
||||
# Should not be vendor dashboard
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context != RequestContext.VENDOR_DASHBOARD
|
||||
|
||||
# ========================================================================
|
||||
# Shop Context Tests
|
||||
# ========================================================================
|
||||
|
||||
def test_detect_shop_context_with_vendor_state(self):
|
||||
"""Test shop context detection when vendor exists in request state."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/products")
|
||||
request.headers = {"host": "testvendor.platform.com"}
|
||||
mock_vendor = Mock()
|
||||
mock_vendor.name = "Test Vendor"
|
||||
request.state = Mock(clean_path="/products", vendor=mock_vendor)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.SHOP
|
||||
|
||||
def test_detect_shop_context_from_shop_path(self):
|
||||
"""Test shop context detection from /shop/ path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/shop/products")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/shop/products", vendor=None)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.SHOP
|
||||
|
||||
def test_detect_shop_context_custom_domain(self):
|
||||
"""Test shop context with custom domain and vendor."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/products")
|
||||
request.headers = {"host": "customdomain.com"}
|
||||
mock_vendor = Mock(name="Custom Vendor")
|
||||
request.state = Mock(clean_path="/products", vendor=mock_vendor)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.SHOP
|
||||
|
||||
# ========================================================================
|
||||
# Fallback Context Tests
|
||||
# ========================================================================
|
||||
|
||||
def test_detect_fallback_context(self):
|
||||
"""Test fallback context for unknown paths."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/random/path")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/random/path", vendor=None)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.FALLBACK
|
||||
|
||||
def test_detect_fallback_context_root(self):
|
||||
"""Test fallback context for root path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/")
|
||||
request.headers = {"host": "platform.com"}
|
||||
request.state = Mock(clean_path="/", vendor=None)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.FALLBACK
|
||||
|
||||
def test_detect_fallback_context_no_vendor(self):
|
||||
"""Test fallback context when no vendor context exists."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/about")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/about", vendor=None)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.FALLBACK
|
||||
|
||||
# ========================================================================
|
||||
# Clean Path Tests
|
||||
# ========================================================================
|
||||
|
||||
def test_uses_clean_path_when_available(self):
|
||||
"""Test that clean_path is used over original path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/testvendor/api/products")
|
||||
request.headers = {"host": "localhost"}
|
||||
# clean_path shows the rewritten path
|
||||
request.state = Mock(clean_path="/api/products")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
# Should detect as API based on clean_path, not original path
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_falls_back_to_original_path(self):
|
||||
"""Test falls back to original path when clean_path not set."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/vendors")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(spec=[]) # No clean_path attribute
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
# ========================================================================
|
||||
# Priority Order Tests
|
||||
# ========================================================================
|
||||
|
||||
def test_api_has_highest_priority(self):
|
||||
"""Test API context takes precedence over admin."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/admin/users")
|
||||
request.headers = {"host": "admin.platform.com"}
|
||||
request.state = Mock(clean_path="/api/admin/users")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
# API should win even though it's admin subdomain
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_admin_has_priority_over_shop(self):
|
||||
"""Test admin context takes precedence over shop."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/shops")
|
||||
request.headers = {"host": "localhost"}
|
||||
mock_vendor = Mock()
|
||||
request.state = Mock(clean_path="/admin/shops", vendor=mock_vendor)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
# Admin should win even though vendor exists
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_vendor_dashboard_priority_over_shop(self):
|
||||
"""Test vendor dashboard takes precedence over shop."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/settings")
|
||||
request.headers = {"host": "testvendor.platform.com"}
|
||||
mock_vendor = Mock()
|
||||
request.state = Mock(clean_path="/vendor/settings", vendor=mock_vendor)
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.VENDOR_DASHBOARD
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContextManagerHelpers:
|
||||
"""Test suite for ContextManager helper methods."""
|
||||
|
||||
def test_is_admin_context_from_subdomain(self):
|
||||
"""Test _is_admin_context with admin subdomain."""
|
||||
request = Mock()
|
||||
assert (
|
||||
ContextManager._is_admin_context(
|
||||
request, "admin.platform.com", "/dashboard"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_is_admin_context_from_path(self):
|
||||
"""Test _is_admin_context with admin path."""
|
||||
request = Mock()
|
||||
assert (
|
||||
ContextManager._is_admin_context(request, "localhost", "/admin/users")
|
||||
is True
|
||||
)
|
||||
|
||||
def test_is_admin_context_both(self):
|
||||
"""Test _is_admin_context with both subdomain and path."""
|
||||
request = Mock()
|
||||
assert (
|
||||
ContextManager._is_admin_context(
|
||||
request, "admin.platform.com", "/admin/users"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_is_not_admin_context(self):
|
||||
"""Test _is_admin_context returns False for non-admin."""
|
||||
request = Mock()
|
||||
assert (
|
||||
ContextManager._is_admin_context(request, "vendor.platform.com", "/shop")
|
||||
is False
|
||||
)
|
||||
|
||||
def test_is_vendor_dashboard_context(self):
|
||||
"""Test _is_vendor_dashboard_context with /vendor/ path."""
|
||||
assert ContextManager._is_vendor_dashboard_context("/vendor/settings") is True
|
||||
|
||||
def test_is_vendor_dashboard_context_nested(self):
|
||||
"""Test _is_vendor_dashboard_context with nested vendor path."""
|
||||
assert (
|
||||
ContextManager._is_vendor_dashboard_context("/vendor/products/list") is True
|
||||
)
|
||||
|
||||
def test_is_not_vendor_dashboard_context_vendors_plural(self):
|
||||
"""Test _is_vendor_dashboard_context excludes /vendors/ path."""
|
||||
assert (
|
||||
ContextManager._is_vendor_dashboard_context("/vendors/shop123/products")
|
||||
is False
|
||||
)
|
||||
|
||||
def test_is_not_vendor_dashboard_context(self):
|
||||
"""Test _is_vendor_dashboard_context returns False for non-vendor paths."""
|
||||
assert ContextManager._is_vendor_dashboard_context("/shop/products") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContextMiddleware:
|
||||
"""Test suite for ContextMiddleware."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_context(self):
|
||||
"""Test middleware successfully sets context in request state."""
|
||||
middleware = ContextMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/vendors")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/api/vendors", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert hasattr(request.state, "context_type")
|
||||
assert request.state.context_type == RequestContext.API
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_admin_context(self):
|
||||
"""Test middleware sets admin context."""
|
||||
middleware = ContextMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/admin/dashboard")
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.context_type == RequestContext.ADMIN
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_vendor_dashboard_context(self):
|
||||
"""Test middleware sets vendor dashboard context."""
|
||||
middleware = ContextMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/settings")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/vendor/settings")
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.context_type == RequestContext.VENDOR_DASHBOARD
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_shop_context(self):
|
||||
"""Test middleware sets shop context."""
|
||||
middleware = ContextMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/products")
|
||||
request.headers = {"host": "shop.platform.com"}
|
||||
mock_vendor = Mock()
|
||||
request.state = Mock(clean_path="/products", vendor=mock_vendor)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.context_type == RequestContext.SHOP
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_fallback_context(self):
|
||||
"""Test middleware sets fallback context."""
|
||||
middleware = ContextMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/random")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/random", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.context_type == RequestContext.FALLBACK
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_returns_response(self):
|
||||
"""Test middleware returns response from call_next."""
|
||||
middleware = ContextMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/test")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/api/test")
|
||||
|
||||
expected_response = Mock()
|
||||
call_next = AsyncMock(return_value=expected_response)
|
||||
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert response is expected_response
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetRequestContextHelper:
|
||||
"""Test suite for get_request_context helper function."""
|
||||
|
||||
def test_get_request_context_exists(self):
|
||||
"""Test getting request context when it exists."""
|
||||
request = Mock(spec=Request)
|
||||
request.state.context_type = RequestContext.API
|
||||
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_get_request_context_default(self):
|
||||
"""Test getting request context returns FALLBACK as default."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[]) # No context_type attribute
|
||||
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.FALLBACK
|
||||
|
||||
def test_get_request_context_for_all_types(self):
|
||||
"""Test getting all context types."""
|
||||
for expected_context in RequestContext:
|
||||
request = Mock(spec=Request)
|
||||
request.state.context_type = expected_context
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = None
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == expected_context
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_get_request_context_deprecation_warning(self):
|
||||
"""Test get_request_context raises DeprecationWarning."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEdgeCases:
|
||||
"""Test suite for edge cases and error scenarios."""
|
||||
|
||||
def test_detect_context_empty_path(self):
|
||||
"""Test context detection with empty path."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="", vendor=None)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.ADMIN
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
with pytest.warns(DeprecationWarning, match="get_request_context.*deprecated"):
|
||||
get_request_context(request)
|
||||
|
||||
assert context == RequestContext.FALLBACK
|
||||
def test_get_request_context_maps_admin(self):
|
||||
"""Test get_request_context maps FrontendType.ADMIN to RequestContext.ADMIN."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
def test_detect_context_missing_host(self):
|
||||
"""Test context detection with missing host header."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/shop/products")
|
||||
request.headers = {}
|
||||
request.state = Mock(clean_path="/shop/products", vendor=None)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.ADMIN
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_get_request_context_maps_vendor(self):
|
||||
"""Test get_request_context maps FrontendType.VENDOR to RequestContext.VENDOR_DASHBOARD."""
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/settings")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.VENDOR
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
assert context == RequestContext.VENDOR_DASHBOARD
|
||||
|
||||
def test_get_request_context_maps_storefront(self):
|
||||
"""Test get_request_context maps FrontendType.STOREFRONT to RequestContext.SHOP."""
|
||||
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.SHOP
|
||||
|
||||
def test_detect_context_case_sensitivity(self):
|
||||
"""Test that context detection is case-sensitive for paths."""
|
||||
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="/API/vendors") # Uppercase
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/API/vendors")
|
||||
request.url = Mock(path="/pricing")
|
||||
request.state = Mock()
|
||||
request.state.frontend_type = FrontendType.PLATFORM
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
context = get_request_context(request)
|
||||
|
||||
# Should NOT match /api/ because it's case-sensitive
|
||||
assert context != RequestContext.API
|
||||
|
||||
def test_detect_context_path_with_query_params(self):
|
||||
"""Test context detection handles path with query parameters."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/vendors?page=1")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/api/vendors?page=1")
|
||||
|
||||
# path.startswith should still work
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
assert context == RequestContext.API
|
||||
|
||||
def test_detect_context_admin_substring(self):
|
||||
"""Test that 'admin' substring doesn't trigger false positive."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/administration/docs")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/administration/docs")
|
||||
|
||||
context = ContextManager.detect_context(request)
|
||||
|
||||
# Should match because path starts with /admin
|
||||
assert context == RequestContext.ADMIN
|
||||
|
||||
def test_detect_context_no_state_attribute(self):
|
||||
"""Test context detection when request has no state."""
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/api/vendors")
|
||||
request.headers = {"host": "localhost"}
|
||||
# No state attribute at all
|
||||
delattr(request, "state")
|
||||
|
||||
# Should still work, falling back to url.path
|
||||
with pytest.raises(AttributeError):
|
||||
# This will raise because we're trying to access request.state
|
||||
ContextManager.detect_context(request)
|
||||
assert context == RequestContext.FALLBACK
|
||||
|
||||
195
tests/unit/middleware/test_frontend_type.py
Normal file
195
tests/unit/middleware/test_frontend_type.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# tests/unit/middleware/test_frontend_type.py
|
||||
"""
|
||||
Unit tests for FrontendTypeMiddleware.
|
||||
|
||||
Tests cover:
|
||||
- Middleware sets frontend_type in request state
|
||||
- All frontend types are correctly detected
|
||||
- get_frontend_type helper function
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
from middleware.frontend_type import FrontendTypeMiddleware, get_frontend_type
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFrontendTypeMiddleware:
|
||||
"""Test suite for FrontendTypeMiddleware."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_admin_frontend_type(self):
|
||||
"""Test middleware sets ADMIN frontend type."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/admin/dashboard", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert hasattr(request.state, "frontend_type")
|
||||
assert request.state.frontend_type == FrontendType.ADMIN
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_vendor_frontend_type(self):
|
||||
"""Test middleware sets VENDOR frontend type."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendor/settings")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/vendor/settings", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.frontend_type == FrontendType.VENDOR
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_storefront_frontend_type(self):
|
||||
"""Test middleware sets STOREFRONT frontend type."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/storefront/products")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/storefront/products", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.frontend_type == FrontendType.STOREFRONT
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_storefront_with_vendor_context(self):
|
||||
"""Test middleware sets STOREFRONT when vendor exists in state."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/products")
|
||||
request.headers = {"host": "wizamart.oms.lu"}
|
||||
mock_vendor = Mock()
|
||||
mock_vendor.name = "Test Vendor"
|
||||
request.state = Mock(clean_path="/products", vendor=mock_vendor)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.frontend_type == FrontendType.STOREFRONT
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_platform_frontend_type(self):
|
||||
"""Test middleware sets PLATFORM frontend type."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/pricing")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/pricing", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.frontend_type == FrontendType.PLATFORM
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_returns_response(self):
|
||||
"""Test middleware returns response from call_next."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/test")
|
||||
request.headers = {"host": "localhost"}
|
||||
request.state = Mock(clean_path="/admin/test")
|
||||
|
||||
expected_response = Mock()
|
||||
call_next = AsyncMock(return_value=expected_response)
|
||||
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert response is expected_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_uses_clean_path_when_available(self):
|
||||
"""Test middleware uses clean_path when available."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/vendors/wizamart/vendor/settings")
|
||||
request.headers = {"host": "localhost"}
|
||||
# clean_path shows the rewritten path
|
||||
request.state = Mock(clean_path="/vendor/settings", vendor=None)
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
# Should detect as VENDOR based on clean_path
|
||||
assert request.state.frontend_type == FrontendType.VENDOR
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_falls_back_to_url_path(self):
|
||||
"""Test middleware falls back to url.path when clean_path not set."""
|
||||
middleware = FrontendTypeMiddleware(app=None)
|
||||
|
||||
request = Mock(spec=Request)
|
||||
request.url = Mock(path="/admin/dashboard")
|
||||
request.headers = {"host": "localhost"}
|
||||
# No clean_path attribute
|
||||
request.state = Mock(spec=[])
|
||||
|
||||
call_next = AsyncMock(return_value=Mock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.frontend_type == FrontendType.ADMIN
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetFrontendTypeHelper:
|
||||
"""Test suite for get_frontend_type helper function."""
|
||||
|
||||
def test_get_frontend_type_exists(self):
|
||||
"""Test getting frontend type when it exists."""
|
||||
request = Mock(spec=Request)
|
||||
request.state.frontend_type = FrontendType.ADMIN
|
||||
|
||||
result = get_frontend_type(request)
|
||||
|
||||
assert result == FrontendType.ADMIN
|
||||
|
||||
def test_get_frontend_type_default(self):
|
||||
"""Test getting frontend type returns PLATFORM as default."""
|
||||
request = Mock(spec=Request)
|
||||
request.state = Mock(spec=[]) # No frontend_type attribute
|
||||
|
||||
result = get_frontend_type(request)
|
||||
|
||||
assert result == FrontendType.PLATFORM
|
||||
|
||||
def test_get_frontend_type_for_all_types(self):
|
||||
"""Test getting all frontend types."""
|
||||
for expected_type in FrontendType:
|
||||
request = Mock(spec=Request)
|
||||
request.state.frontend_type = expected_type
|
||||
|
||||
result = get_frontend_type(request)
|
||||
|
||||
assert result == expected_type
|
||||
Reference in New Issue
Block a user