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:
2026-02-03 16:15:19 +01:00
parent e77535e2cd
commit b769f5a047
17 changed files with 1393 additions and 915 deletions

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

View File

@@ -14,7 +14,8 @@ from fastapi import Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates 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__) logger = logging.getLogger(__name__)
@@ -65,11 +66,11 @@ class ErrorPageRenderer:
show_debug: bool = False, show_debug: bool = False,
) -> HTMLResponse: ) -> HTMLResponse:
""" """
Render appropriate error page based on request context. Render appropriate error page based on request frontend type.
Template Selection Priority: Template Selection Priority:
1. Context-specific error page: {context}/errors/{status_code}.html 1. Frontend-specific error page: {frontend}/errors/{status_code}.html
2. Context-specific generic: {context}/errors/generic.html 2. Frontend-specific generic: {frontend}/errors/generic.html
3. Shared fallback error page: shared/{status_code}-fallback.html 3. Shared fallback error page: shared/{status_code}-fallback.html
4. Shared fallback generic: shared/generic-fallback.html 4. Shared fallback generic: shared/generic-fallback.html
@@ -84,13 +85,13 @@ class ErrorPageRenderer:
Returns: Returns:
HTMLResponse with rendered error page HTMLResponse with rendered error page
""" """
# Get request context # Get frontend type
context_type = get_request_context(request) frontend_type = get_frontend_type(request)
# Prepare template data # Prepare template data
template_data = ErrorPageRenderer._prepare_template_data( template_data = ErrorPageRenderer._prepare_template_data(
request=request, request=request,
context_type=context_type, frontend_type=frontend_type,
status_code=status_code, status_code=status_code,
error_code=error_code, error_code=error_code,
message=message, message=message,
@@ -104,16 +105,16 @@ class ErrorPageRenderer:
# Try to find appropriate template # Try to find appropriate template
template_path = ErrorPageRenderer._find_template( template_path = ErrorPageRenderer._find_template(
context_type=context_type, frontend_type=frontend_type,
status_code=status_code, status_code=status_code,
) )
logger.info( 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={ extra={
"status_code": status_code, "status_code": status_code,
"error_code": error_code, "error_code": error_code,
"context": context_type.value, "frontend": frontend_type.value,
"template": template_path, "template": template_path,
}, },
) )
@@ -141,38 +142,37 @@ class ErrorPageRenderer:
@staticmethod @staticmethod
def _find_template( def _find_template(
context_type: RequestContext, frontend_type: FrontendType,
status_code: int, status_code: int,
) -> str: ) -> str:
""" """
Find appropriate error template based on context and status code. Find appropriate error template based on frontend type and status code.
Priority: Priority:
1. {context}/errors/{status_code}.html 1. {frontend}/errors/{status_code}.html
2. {context}/errors/generic.html 2. {frontend}/errors/generic.html
3. shared/{status_code}-fallback.html 3. shared/{status_code}-fallback.html
4. shared/generic-fallback.html 4. shared/generic-fallback.html
""" """
templates_dir = ErrorPageRenderer.get_templates_dir() templates_dir = ErrorPageRenderer.get_templates_dir()
# Map context type to folder name # Map frontend type to folder name
context_folders = { frontend_folders = {
RequestContext.ADMIN: "admin", FrontendType.ADMIN: "admin",
RequestContext.VENDOR_DASHBOARD: "vendor", FrontendType.VENDOR: "vendor",
RequestContext.SHOP: "shop", FrontendType.STOREFRONT: "storefront",
RequestContext.API: "fallback", # API shouldn't get here, but just in case FrontendType.PLATFORM: "fallback", # Platform uses fallback templates
RequestContext.FALLBACK: "fallback",
} }
context_folder = context_folders.get(context_type, "fallback") frontend_folder = frontend_folders.get(frontend_type, "fallback")
# Try context-specific status code template # Try frontend-specific status code template
specific_template = f"{context_folder}/errors/{status_code}.html" specific_template = f"{frontend_folder}/errors/{status_code}.html"
if (templates_dir / specific_template).exists(): if (templates_dir / specific_template).exists():
return specific_template return specific_template
# Try context-specific generic template # Try frontend-specific generic template
generic_template = f"{context_folder}/errors/generic.html" generic_template = f"{frontend_folder}/errors/generic.html"
if (templates_dir / generic_template).exists(): if (templates_dir / generic_template).exists():
return generic_template return generic_template
@@ -187,7 +187,7 @@ class ErrorPageRenderer:
@staticmethod @staticmethod
def _prepare_template_data( def _prepare_template_data(
request: Request, request: Request,
context_type: RequestContext, frontend_type: FrontendType,
status_code: int, status_code: int,
error_code: str, error_code: str,
message: str, message: str,
@@ -212,8 +212,8 @@ class ErrorPageRenderer:
# Only show to admins (we can check user role if available) # Only show to admins (we can check user role if available)
display_debug = show_debug and ErrorPageRenderer._is_admin_user(request) display_debug = show_debug and ErrorPageRenderer._is_admin_user(request)
# Get context-specific data # Get frontend-specific data
context_data = ErrorPageRenderer._get_context_data(request, context_type) frontend_data = ErrorPageRenderer._get_frontend_data(request, frontend_type)
return { return {
"status_code": status_code, "status_code": status_code,
@@ -222,20 +222,20 @@ class ErrorPageRenderer:
"message": user_message, "message": user_message,
"details": details or {}, "details": details or {},
"show_debug": display_debug, "show_debug": display_debug,
"context_type": context_type.value, "frontend_type": frontend_type.value,
"path": request.url.path, "path": request.url.path,
**context_data, **frontend_data,
} }
@staticmethod @staticmethod
def _get_context_data( def _get_frontend_data(
request: Request, context_type: RequestContext request: Request, frontend_type: FrontendType
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Get context-specific data for error templates.""" """Get frontend-specific data for error templates."""
data = {} data = {}
# Add vendor information if available (for shop context) # Add vendor information if available (for storefront frontend)
if context_type == RequestContext.SHOP: if frontend_type == FrontendType.STOREFRONT:
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if vendor: if vendor:
# Pass minimal vendor info for templates # Pass minimal vendor info for templates
@@ -261,7 +261,7 @@ class ErrorPageRenderer:
"custom_css": getattr(theme, "custom_css", None), "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) vendor_context = getattr(request.state, "vendor_context", None)
access_method = ( access_method = (
vendor_context.get("detection_method", "unknown") vendor_context.get("detection_method", "unknown")
@@ -289,9 +289,9 @@ class ErrorPageRenderer:
This is a placeholder - implement based on your auth system. This is a placeholder - implement based on your auth system.
""" """
# TODO: Implement actual admin check based on JWT/session # TODO: Implement actual admin check based on JWT/session
# For now, check if we're in admin context # For now, check if we're in admin frontend
context_type = get_request_context(request) frontend_type = get_frontend_type(request)
return context_type == RequestContext.ADMIN return frontend_type == FrontendType.ADMIN
@staticmethod @staticmethod
def _render_basic_html_fallback( def _render_basic_html_fallback(

View File

@@ -16,7 +16,8 @@ from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse 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 .base import WizamartException
from .error_renderer import ErrorPageRenderer from .error_renderer import ErrorPageRenderer
@@ -382,17 +383,17 @@ def _is_html_page_request(request: Request) -> bool:
def _redirect_to_login(request: Request) -> RedirectResponse: 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). 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") logger.debug("Redirecting to /admin/login")
return RedirectResponse(url="/admin/login", status_code=302) 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 # Extract vendor code from the request path
# Path format: /vendor/{vendor_code}/... # Path format: /vendor/{vendor_code}/...
path_parts = request.url.path.split("/") path_parts = request.url.path.split("/")
@@ -417,8 +418,8 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
logger.debug(f"Redirecting to {login_url}") logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302) return RedirectResponse(url=login_url, status_code=302)
if context_type == RequestContext.SHOP: if frontend_type == FrontendType.STOREFRONT:
# For shop context, redirect to shop login (customer login) # For storefront context, redirect to storefront login (customer login)
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access) # Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
vendor_context = getattr(request.state, "vendor_context", 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}/" 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}") logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302) return RedirectResponse(url=login_url, status_code=302)
# Fallback to root for unknown contexts # Fallback to root for unknown contexts (PLATFORM)
logger.debug("Unknown context, redirecting to /") logger.debug("Platform context, redirecting to /")
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)

View File

@@ -10,7 +10,7 @@ Public and authenticated endpoints for customer operations in storefront:
Uses vendor from middleware context (VendorContextMiddleware). Uses vendor from middleware context (VendorContextMiddleware).
Implements dual token storage with path restriction: 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) - Returns token in response for localStorage (API calls)
""" """
@@ -182,14 +182,14 @@ def customer_login(
else "unknown" else "unknown"
) )
cookie_path = "/shop" cookie_path = "/storefront"
if access_method == "path": if access_method == "path":
full_prefix = ( full_prefix = (
vendor_context.get("full_prefix", "/vendor/") vendor_context.get("full_prefix", "/vendor/")
if vendor_context if vendor_context
else "/vendor/" else "/vendor/"
) )
cookie_path = f"{full_prefix}{vendor.subdomain}/shop" cookie_path = f"{full_prefix}{vendor.subdomain}/storefront"
response.set_cookie( response.set_cookie(
key="customer_token", key="customer_token",
@@ -240,14 +240,14 @@ def customer_logout(request: Request, response: Response):
else "unknown" else "unknown"
) )
cookie_path = "/shop" cookie_path = "/storefront"
if access_method == "path" and vendor: if access_method == "path" and vendor:
full_prefix = ( full_prefix = (
vendor_context.get("full_prefix", "/vendor/") vendor_context.get("full_prefix", "/vendor/")
if vendor_context if vendor_context
else "/vendor/" 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) response.delete_cookie(key="customer_token", path=cookie_path)

View 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

View File

@@ -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. **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**: **What it does**:
- Analyzes the request path (using clean_path) - Uses centralized `FrontendDetector` class for all detection logic
- Determines which interface is being accessed: - Determines which frontend is being accessed:
- `API` - `/api/*` paths - `ADMIN` - `/admin/*`, `/api/v1/admin/*` paths or `admin.*` subdomain
- `ADMIN` - `/admin/*` paths or `admin.*` subdomain - `VENDOR` - `/vendor/*`, `/api/v1/vendor/*` paths (management area)
- `VENDOR_DASHBOARD` - `/vendor/*` paths (management area) - `STOREFRONT` - Customer shop pages (`/storefront/*`, `/vendors/*`, vendor subdomains)
- `SHOP` - Storefront pages (has vendor + not admin/vendor/API) - `PLATFORM` - Marketing pages (`/`, `/pricing`, `/about`)
- `FALLBACK` - Unknown context - Injects `request.state.frontend_type` (FrontendType enum)
- Injects `request.state.context_type`
**Detection Rules**: **Detection Priority** (handled by `FrontendDetector`):
```python ```python
if path.startswith("/api/"): 1. Admin subdomain (admin.oms.lu) ADMIN
context = API 2. Path-based detection:
elif path.startswith("/admin/") or host.startswith("admin."): - /admin/* or /api/v1/admin/* ADMIN
context = ADMIN - /vendor/* or /api/v1/vendor/* VENDOR
elif path.startswith("/vendor/"): - /storefront/*, /shop/*, /vendors/* STOREFRONT
context = VENDOR_DASHBOARD - /api/v1/platform/* PLATFORM
elif request.state.vendor exists: 3. Vendor subdomain (wizamart.oms.lu) STOREFRONT
context = SHOP 4. Vendor context set by middleware STOREFRONT
else: 5. Default PLATFORM
context = FALLBACK
``` ```
**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 ### 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): class LoggingMiddleware(BaseHTTPMiddleware):
"""Request/response logging middleware""" """Request/response logging middleware"""
# middleware/context.py # middleware/frontend_type.py
class ContextManager: # Business logic class FrontendTypeMiddleware: # ASGI wrapper for frontend detection
class ContextMiddleware: # ASGI wrapper # Uses FrontendDetector from app/core/frontend_detector.py
class RequestContext(Enum): # Related enum
# middleware/auth.py # middleware/auth.py
class AuthManager: # Authentication logic class AuthManager: # Authentication logic
``` ```
> **Note**: The old `middleware/context.py` with `ContextMiddleware` and `RequestContext` is deprecated.
> Use `FrontendTypeMiddleware` and `FrontendType` enum instead.
> See [Frontend Detection Architecture](frontend-detection.md) for migration guide.
#### One Test File Per Component #### One Test File Per Component
Follow the Single Responsibility Principle - each test file tests exactly one component: Follow the Single Responsibility Principle - each test file tests exactly one component:
@@ -368,8 +371,8 @@ async def get_products(request: Request):
} }
</style> </style>
{# Access context #} {# Access frontend type #}
{% if request.state.context_type.value == "admin" %} {% if request.state.frontend_type.value == "admin" %}
<div class="admin-badge">Admin Mode</div> <div class="admin-badge">Admin Mode</div>
{% endif %} {% endif %}
``` ```
@@ -394,11 +397,11 @@ async def get_products(request: Request):
↓ Sets: request.state.vendor_id = 1 ↓ Sets: request.state.vendor_id = 1
↓ Sets: request.state.clean_path = "/shop/products" ↓ Sets: request.state.clean_path = "/shop/products"
3. ContextDetectionMiddleware 3. FrontendTypeMiddleware
Analyzes path: "/shop/products" Uses FrontendDetector with path: "/shop/products"
↓ Has vendor: Yes ↓ Has vendor context: Yes
Not admin/api/vendor dashboard Detects storefront frontend
↓ Sets: request.state.context_type = RequestContext.SHOP ↓ Sets: request.state.frontend_type = FrontendType.STOREFRONT
4. ThemeContextMiddleware 4. ThemeContextMiddleware
↓ Loads theme for vendor_id = 1 ↓ 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 - If database error: Logs error, allows request to continue
- Fallback: Request proceeds without vendor context - Fallback: Request proceeds without vendor context
### ContextDetectionMiddleware ### FrontendTypeMiddleware
- If clean_path missing: Uses original path - If clean_path missing: Uses original path
- If vendor missing: Defaults to FALLBACK context - If vendor missing: Defaults to PLATFORM frontend type
- Always sets a context_type (never None) - Always sets a frontend_type (never None)
### ThemeContextMiddleware ### ThemeContextMiddleware
- If vendor missing: Skips theme loading - If vendor missing: Skips theme loading
@@ -479,8 +482,10 @@ Middleware is registered in `main.py`:
# Add in REVERSE order (LIFO execution) # Add in REVERSE order (LIFO execution)
app.add_middleware(LoggingMiddleware) app.add_middleware(LoggingMiddleware)
app.add_middleware(ThemeContextMiddleware) app.add_middleware(ThemeContextMiddleware)
app.add_middleware(ContextDetectionMiddleware) app.add_middleware(LanguageMiddleware)
app.add_middleware(FrontendTypeMiddleware)
app.add_middleware(VendorContextMiddleware) app.add_middleware(VendorContextMiddleware)
app.add_middleware(PlatformContextMiddleware)
``` ```
**Note**: FastAPI's `add_middleware` executes in **reverse order** (Last In, First Out) **Note**: FastAPI's `add_middleware` executes in **reverse order** (Last In, First Out)

View File

@@ -147,27 +147,25 @@ app.include_router(shop_pages.router, prefix="/vendors/{vendor_code}/shop")
**Detection Logic**: **Detection Logic**:
```python ```python
host = request.headers.get("host", "")
path = request.state.clean_path # "/shop/products" path = request.state.clean_path # "/shop/products"
has_vendor = hasattr(request.state, 'vendor') and request.state.vendor
if path.startswith("/api/"): # FrontendDetector handles all detection logic centrally
context = RequestContext.API frontend_type = FrontendDetector.detect(host, path, has_vendor)
elif path.startswith("/admin/"): # Returns: FrontendType.STOREFRONT # ← Our example
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
request.state.context_type = context request.state.frontend_type = frontend_type
``` ```
**Request State After**: **Request State After**:
```python ```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 ### 6. ThemeContextMiddleware
**What happens**: **What happens**:
@@ -363,7 +361,7 @@ sequenceDiagram
Context->>Router: Route request Context->>Router: Route request
Router->>Handler: Call API handler Router->>Handler: Call API handler
Handler->>DB: Query products Handler->>DB: Query products
DB-->>Handler: Product data DB-->>Handler: Product data
Handler-->>Router: JSON response Handler-->>Router: JSON response
Router-->>Client: {products: [...]} Router-->>Client: {products: [...]}
``` ```
@@ -390,7 +388,7 @@ sequenceDiagram
Context->>Theme: Pass request Context->>Theme: Pass request
Note over Theme: Skip theme<br/>(No vendor) Note over Theme: Skip theme<br/>(No vendor)
Theme->>Router: Route request Theme->>Router: Route request
Router->>Handler: Call handler Router->>Handler: Call handler
Handler->>Template: Render admin template Handler->>Template: Render admin template
Template-->>Client: Admin HTML page Template-->>Client: Admin HTML page
``` ```
@@ -423,7 +421,7 @@ sequenceDiagram
Context->>Theme: Pass request Context->>Theme: Pass request
Theme->>DB: Query theme Theme->>DB: Query theme
DB-->>Theme: Theme config DB-->>Theme: Theme config
Note over Theme: Set theme in request.state Note over Theme: Set theme in request.state
Theme->>Router: Route request Theme->>Router: Route request
Router->>Handler: Call handler Router->>Handler: Call handler
Handler->>DB: Query products for vendor Handler->>DB: Query products for vendor
@@ -450,12 +448,12 @@ After VendorContextMiddleware:
{ {
vendor: <Vendor: Wizamart>, vendor: <Vendor: Wizamart>,
vendor_id: 1, vendor_id: 1,
clean_path: "/shop/products", clean_path: "/shop/products",
frontend_type: FrontendType.STOREFRONT frontend_type: FrontendType.STOREFRONT
} }
After ThemeContextMiddleware: After ThemeContextMiddleware:
{ {
vendor: <Vendor: Wizamart>, vendor: <Vendor: Wizamart>,
vendor_id: 1, vendor_id: 1,
clean_path: "/shop/products", clean_path: "/shop/products",
@@ -463,7 +461,7 @@ After ThemeContextMiddleware:
theme: { theme: {
primary_color: "#3B82F6", primary_color: "#3B82F6",
secondary_color: "#10B981", secondary_color: "#10B981",
logo_url: "/static/vendors/wizamart/logo.png", logo_url: "/static/vendors/wizamart/logo.png",
custom_css: "..." custom_css: "..."
} }
} }
@@ -481,7 +479,7 @@ Typical request timings:
| - ThemeContextMiddleware | 2ms | 1% | | - ThemeContextMiddleware | 2ms | 1% |
| Database Queries | 15ms | 10% | | Database Queries | 15ms | 10% |
| Business Logic | 50ms | 35% | | Business Logic | 50ms | 35% |
| Template Rendering | 75ms | 52% | | Template Rendering | 75ms | 52% |
| **Total** | **145ms** | **100%** | | **Total** | **145ms** | **100%** |
## Error Handling in Flow ## Error Handling in Flow
@@ -550,7 +548,7 @@ async def debug_state(request: Request):
"has_theme": bool(getattr(request.state, 'theme', None)) "has_theme": bool(getattr(request.state, 'theme', None))
} }
``` ```
### Check Middleware Order ### Check Middleware Order
In `main.py`, middleware registration order is critical: 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(LanguageMiddleware) # Runs fifth
app.add_middleware(FrontendTypeMiddleware) # Runs fourth app.add_middleware(FrontendTypeMiddleware) # Runs fourth
app.add_middleware(VendorContextMiddleware) # Runs second 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 app.add_middleware(VendorContextMiddleware) # Runs second
``` ```

View File

@@ -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: options:
show_source: false show_source: false
heading_level: 4 heading_level: 4
show_root_heading: false show_root_heading: false
members: members:
- API - PLATFORM
- ADMIN - ADMIN
- VENDOR_DASHBOARD - VENDOR
- SHOP - STOREFRONT
- FALLBACK
### 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:** **Detection Rules (Priority Order):**
- `/api/` → API context 1. Admin subdomain (`admin.*`) → ADMIN
- `/admin/` → Admin context 2. Path-based detection:
- `/vendor/` → Vendor Dashboard context - `/admin/*`, `/api/v1/admin/*` → ADMIN
- `/shop/` → Shop context - `/vendor/*`, `/api/v1/vendor/*` → VENDOR
- Default → Fallback context - `/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: options:
show_source: false show_source: false
heading_level: 4 heading_level: 4
show_root_heading: false 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: options:
show_source: false show_source: false
heading_level: 4 heading_level: 4
show_root_heading: false 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 ## Theme Management
@@ -235,20 +241,24 @@ The middleware stack must be configured in the correct order for proper function
```mermaid ```mermaid
graph TD graph TD
A[Request] --> B[LoggingMiddleware] A[Request] --> B[LoggingMiddleware]
B --> C[VendorContextMiddleware] B --> C[PlatformContextMiddleware]
C --> D[ContextMiddleware] C --> D[VendorContextMiddleware]
D --> E[ThemeContextMiddleware] D --> E[FrontendTypeMiddleware]
E --> F[Application Routes] E --> F[LanguageMiddleware]
F --> G[Response] F --> G[ThemeContextMiddleware]
G --> H[Application Routes]
H --> I[Response]
``` ```
**Critical Dependencies:** **Critical Dependencies:**
1. **LoggingMiddleware** runs first for request timing 1. **LoggingMiddleware** runs first for request timing
2. **VendorContextMiddleware** detects vendor and sets clean_path 2. **PlatformContextMiddleware** detects platform and sets platform context
3. **ContextMiddleware** detects context type (API/Admin/Vendor/Shop) 3. **VendorContextMiddleware** detects vendor and sets clean_path
4. **ThemeContextMiddleware** loads vendor theme based on context 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 | | Variable | Set By | Type | Description |
|----------|--------|------|-------------| |----------|--------|------|-------------|
| `platform` | PlatformContextMiddleware | Platform | Current platform object |
| `vendor` | VendorContextMiddleware | Vendor | Current vendor object | | `vendor` | VendorContextMiddleware | Vendor | Current vendor object |
| `vendor_id` | VendorContextMiddleware | int | Current vendor ID | | `vendor_id` | VendorContextMiddleware | int | Current vendor ID |
| `clean_path` | VendorContextMiddleware | str | Path without vendor prefix | | `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 | | `theme` | ThemeContextMiddleware | dict | Vendor theme configuration |
**Usage in Routes:** **Usage in Routes:**
```python ```python
from fastapi import Request 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): async def get_products(request: Request):
vendor = request.state.vendor vendor = request.state.vendor
context = request.state.context_type frontend_type = get_frontend_type(request)
theme = request.state.theme 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 ## 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 - [Authentication Guide](../api/authentication.md) - User authentication and JWT tokens
- [RBAC Documentation](../api/rbac.md) - Role-based access control - [RBAC Documentation](../api/rbac.md) - Role-based access control
- [Error Handling](../api/error-handling.md) - Exception handling patterns - [Error Handling](../api/error-handling.md) - Exception handling patterns

16
main.py
View File

@@ -69,7 +69,7 @@ from app.modules.routes import (
get_vendor_page_routes, get_vendor_page_routes,
) )
from app.utils.i18n import get_jinja2_globals 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.language import LanguageMiddleware
from middleware.logging import LoggingMiddleware from middleware.logging import LoggingMiddleware
from middleware.theme_context import ThemeContextMiddleware from middleware.theme_context import ThemeContextMiddleware
@@ -122,15 +122,15 @@ app.add_middleware(
# Desired execution order: # Desired execution order:
# 1. PlatformContextMiddleware (detect platform from domain/path) # 1. PlatformContextMiddleware (detect platform from domain/path)
# 2. VendorContextMiddleware (detect vendor, uses platform_clean_path) # 2. VendorContextMiddleware (detect vendor, uses platform_clean_path)
# 3. ContextMiddleware (detect context using clean_path) # 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector)
# 4. LanguageMiddleware (detect language based on context) # 4. LanguageMiddleware (detect language based on frontend type)
# 5. ThemeContextMiddleware (load theme) # 5. ThemeContextMiddleware (load theme)
# 6. LoggingMiddleware (log all requests) # 6. LoggingMiddleware (log all requests)
# #
# Therefore we add them in REVERSE: # Therefore we add them in REVERSE:
# - Add ThemeContextMiddleware FIRST (runs LAST in request) # - Add ThemeContextMiddleware FIRST (runs LAST in request)
# - Add LanguageMiddleware SECOND # - Add LanguageMiddleware SECOND
# - Add ContextMiddleware THIRD # - Add FrontendTypeMiddleware THIRD
# - Add VendorContextMiddleware FOURTH # - Add VendorContextMiddleware FOURTH
# - Add PlatformContextMiddleware FIFTH # - Add PlatformContextMiddleware FIFTH
# - Add LoggingMiddleware LAST (runs FIRST for timing) # - Add LoggingMiddleware LAST (runs FIRST for timing)
@@ -152,9 +152,9 @@ app.add_middleware(ThemeContextMiddleware)
logger.info("Adding LanguageMiddleware (detects language based on context)") logger.info("Adding LanguageMiddleware (detects language based on context)")
app.add_middleware(LanguageMiddleware) app.add_middleware(LanguageMiddleware)
# Add context detection middleware (runs after vendor context extraction) # Add frontend type detection middleware (runs after vendor context extraction)
logger.info("Adding ContextMiddleware (detects context type using clean_path)") logger.info("Adding FrontendTypeMiddleware (detects frontend type using FrontendDetector)")
app.add_middleware(ContextMiddleware) app.add_middleware(FrontendTypeMiddleware)
# Add vendor context middleware (runs after platform context) # Add vendor context middleware (runs after platform context)
logger.info("Adding VendorContextMiddleware (detects vendor, uses platform_clean_path)") 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(" 1. LoggingMiddleware (timing)")
logger.info(" 2. PlatformContextMiddleware (platform detection)") logger.info(" 2. PlatformContextMiddleware (platform detection)")
logger.info(" 3. VendorContextMiddleware (vendor 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(" 5. LanguageMiddleware (language detection)")
logger.info(" 6. ThemeContextMiddleware (theme loading)") logger.info(" 6. ThemeContextMiddleware (theme loading)")
logger.info(" 7. FastAPI Router") logger.info(" 7. FastAPI Router")

View File

@@ -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) The RequestContext enum and ContextMiddleware have been replaced by:
and injects it into request.state for use by error handlers and other components. - 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. This file is kept for backwards compatibility during the migration period.
MUST run BEFORE theme_context_middleware (which needs context_type). All new code should use FrontendType and FrontendTypeMiddleware instead.
Class-based middleware provides: Migration guide:
- Better state management - RequestContext.API -> Check with FrontendDetector.is_api_request()
- Easier testing - RequestContext.ADMIN -> FrontendType.ADMIN
- More organized code - RequestContext.VENDOR_DASHBOARD -> FrontendType.VENDOR
- Standard ASGI pattern - 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 logging
import warnings
from enum import Enum from enum import Enum
from fastapi import Request 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__) logger = logging.getLogger(__name__)
class RequestContext(str, Enum): 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" API = "api"
ADMIN = "admin" ADMIN = "admin"
@@ -34,169 +55,12 @@ class RequestContext(str, Enum):
FALLBACK = "fallback" 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: def get_request_context(request: Request) -> RequestContext:
""" """
DEPRECATED: Use get_frontend_type() from middleware.frontend_type instead.
Helper function to get current request context. Helper function to get current request context.
This function maps FrontendType to RequestContext for backwards compatibility.
Args: Args:
request: FastAPI request object request: FastAPI request object
@@ -204,4 +68,33 @@ def get_request_context(request: Request) -> RequestContext:
Returns: Returns:
RequestContext enum value (defaults to FALLBACK if not set) 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

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

View File

@@ -18,6 +18,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from app.modules.enums import FrontendType
from app.utils.i18n import ( from app.utils.i18n import (
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
@@ -45,9 +46,8 @@ class LanguageMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response: async def dispatch(self, request: Request, call_next) -> Response:
"""Process the request and set language.""" """Process the request and set language."""
# Get context type from previous middleware # Get frontend type from FrontendTypeMiddleware
context_type = getattr(request.state, "context_type", None) frontend_type = getattr(request.state, "frontend_type", None)
context_value = context_type.value if context_type else None
# Get vendor from previous middleware (if available) # Get vendor from previous middleware (if available)
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
@@ -59,13 +59,13 @@ class LanguageMiddleware(BaseHTTPMiddleware):
accept_language = request.headers.get("accept-language") accept_language = request.headers.get("accept-language")
browser_language = parse_accept_language(accept_language) browser_language = parse_accept_language(accept_language)
# Resolve language based on context # Resolve language based on frontend type
if context_value == "admin": if frontend_type == FrontendType.ADMIN:
# Admin dashboard: English only (for now) # Admin dashboard: English only (for now)
# TODO: Implement admin language support later # TODO: Implement admin language support later
language = "en" language = "en"
elif context_value == "vendor_dashboard": elif frontend_type == FrontendType.VENDOR:
# Vendor dashboard # Vendor dashboard
user_preferred = self._get_user_language_from_token(request) user_preferred = self._get_user_language_from_token(request)
vendor_dashboard = vendor.dashboard_language if vendor else None vendor_dashboard = vendor.dashboard_language if vendor else None
@@ -75,7 +75,7 @@ class LanguageMiddleware(BaseHTTPMiddleware):
vendor_dashboard=vendor_dashboard, vendor_dashboard=vendor_dashboard,
) )
elif context_value == "shop": elif frontend_type == FrontendType.STOREFRONT:
# Storefront # Storefront
customer_preferred = self._get_customer_language_from_token(request) customer_preferred = self._get_customer_language_from_token(request)
vendor_storefront = vendor.storefront_language if vendor else None vendor_storefront = vendor.storefront_language if vendor else None
@@ -89,12 +89,12 @@ class LanguageMiddleware(BaseHTTPMiddleware):
enabled_languages=enabled_languages, enabled_languages=enabled_languages,
) )
elif context_value == "api": elif frontend_type == FrontendType.PLATFORM:
# API requests: Use Accept-Language or cookie # Platform marketing pages: Use cookie, browser, or default
language = cookie_language or browser_language or DEFAULT_LANGUAGE language = cookie_language or browser_language or DEFAULT_LANGUAGE
else: 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 language = cookie_language or browser_language or DEFAULT_LANGUAGE
# Validate language is supported # Validate language is supported
@@ -109,13 +109,14 @@ class LanguageMiddleware(BaseHTTPMiddleware):
"code": language, "code": language,
"cookie": cookie_language, "cookie": cookie_language,
"browser": browser_language, "browser": browser_language,
"context": context_value, "frontend_type": frontend_type.value if frontend_type else None,
} }
# Log language detection for debugging # Log language detection for debugging
frontend_value = frontend_type.value if frontend_type else "unknown"
logger.debug( logger.debug(
f"Language detected: {language} " 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 # Process request

View File

@@ -22,6 +22,8 @@ from fastapi import Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db 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 from app.modules.tenancy.models import Platform
# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting # 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 host_without_port = host.split(":")[0] if ":" in host else host
# Skip platform detection for admin routes - admin is global # Skip platform detection for admin routes - admin is global
if PlatformContextManager.is_admin_request(request): if FrontendDetector.is_admin(host, path):
return None return None
# Method 1: Domain-based detection (production) # Method 1: Domain-based detection (production)
@@ -208,17 +210,15 @@ class PlatformContextManager:
@staticmethod @staticmethod
def is_admin_request(request: Request) -> bool: 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", "") host = request.headers.get("host", "")
path = request.url.path path = request.url.path
return FrontendDetector.is_admin(host, path)
if ":" in host:
host = host.split(":")[0]
if host.startswith("admin."):
return True
return path.startswith("/admin")
@staticmethod @staticmethod
def is_static_file_request(request: Request) -> bool: def is_static_file_request(request: Request) -> bool:
@@ -299,7 +299,7 @@ class PlatformContextMiddleware:
return return
# Skip for admin requests # Skip for admin requests
if self._is_admin_request(path, host): if FrontendDetector.is_admin(host, path):
scope["state"]["platform"] = None scope["state"]["platform"] = None
scope["state"]["platform_context"] = None scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path scope["state"]["platform_clean_path"] = path
@@ -427,11 +427,13 @@ class PlatformContextMiddleware:
return "favicon.ico" in path_lower return "favicon.ico" in path_lower
def _is_admin_request(self, path: str, host: str) -> bool: 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 Check if request is for admin interface.
if host_without_port.startswith("admin."):
return True DEPRECATED: Use FrontendDetector.is_admin() instead.
return path.startswith("/admin") Kept for backwards compatibility.
"""
return FrontendDetector.is_admin(host, path)
def get_current_platform(request: Request) -> Platform | None: def get_current_platform(request: Request) -> Platform | None:

View File

@@ -23,6 +23,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db 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 Vendor
from app.modules.tenancy.models import VendorDomain from app.modules.tenancy.models import VendorDomain
@@ -194,22 +195,20 @@ class VendorContextManager:
@staticmethod @staticmethod
def is_admin_request(request: Request) -> bool: 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", "") host = request.headers.get("host", "")
path = request.url.path path = request.url.path
return FrontendDetector.is_admin(host, path)
if ":" in host:
host = host.split(":")[0]
if host.startswith("admin."):
return True
return path.startswith("/admin")
@staticmethod @staticmethod
def is_api_request(request: Request) -> bool: def is_api_request(request: Request) -> bool:
"""Check if request is for API endpoints.""" """Check if request is for API endpoints."""
return request.url.path.startswith("/api/") return FrontendDetector.is_api_request(request.url.path)
@staticmethod @staticmethod
def is_shop_api_request(request: Request) -> bool: def is_shop_api_request(request: Request) -> bool:

View 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

View File

@@ -1,31 +1,31 @@
# tests/unit/middleware/test_context.py # tests/unit/middleware/test_context.py
""" """
Comprehensive unit tests for ContextMiddleware and ContextManager. DEPRECATED: Tests for backward compatibility of middleware.context module.
Tests cover: The ContextMiddleware and ContextManager classes have been replaced by:
- Context detection for API, Admin, Vendor Dashboard, Shop, and Fallback - FrontendTypeMiddleware (middleware/frontend_type.py)
- Clean path usage for correct context detection - FrontendDetector (app/core/frontend_detector.py)
- Host and path-based context determination
- Middleware state injection These tests verify the backward compatibility layer still works for code
- Edge cases and error handling 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 import pytest
from fastapi import Request from fastapi import Request
from middleware.context import ( from middleware.context import RequestContext, get_request_context
ContextManager,
ContextMiddleware,
RequestContext,
get_request_context,
)
@pytest.mark.unit @pytest.mark.unit
class TestRequestContextEnum: class TestRequestContextEnumBackwardCompatibility:
"""Test suite for RequestContext enum.""" """Test suite for deprecated RequestContext enum."""
def test_request_context_values(self): def test_request_context_values(self):
"""Test RequestContext enum has correct values.""" """Test RequestContext enum has correct values."""
@@ -42,554 +42,90 @@ class TestRequestContextEnum:
@pytest.mark.unit @pytest.mark.unit
class TestContextManagerDetection: class TestGetRequestContextBackwardCompatibility:
"""Test suite for ContextManager.detect_context().""" """Test suite for deprecated get_request_context() function."""
# ======================================================================== def test_get_request_context_returns_api_for_api_paths(self):
# API Context Tests (Highest Priority) """Test get_request_context returns API for /api/ paths."""
# ========================================================================
def test_detect_api_context(self):
"""Test API context detection."""
request = Mock(spec=Request) request = Mock(spec=Request)
request.url = Mock(path="/api/v1/vendors") request.url = Mock(path="/api/v1/vendors")
request.headers = {"host": "localhost"} request.state = Mock()
request.state = Mock(clean_path="/api/v1/vendors") request.state.frontend_type = None
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
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
context = get_request_context(request) 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 = Mock(spec=Request)
request.url = Mock(path="") request.url = Mock(path="/admin/dashboard")
request.headers = {"host": "localhost"} request.state = Mock()
request.state = Mock(clean_path="", vendor=None) 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 = Mock(spec=Request)
request.url = Mock(path="/shop/products") request.url = Mock(path="/admin/dashboard")
request.headers = {} request.state = Mock()
request.state = Mock(clean_path="/shop/products", vendor=None) 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 assert context == RequestContext.SHOP
def test_detect_context_case_sensitivity(self): def test_get_request_context_maps_platform_to_fallback(self):
"""Test that context detection is case-sensitive for paths.""" """Test get_request_context maps FrontendType.PLATFORM to RequestContext.FALLBACK."""
from app.modules.enums import FrontendType
request = Mock(spec=Request) request = Mock(spec=Request)
request.url = Mock(path="/API/vendors") # Uppercase request.url = Mock(path="/pricing")
request.headers = {"host": "localhost"} request.state = Mock()
request.state = Mock(clean_path="/API/vendors") 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.FALLBACK
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)

View 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