From b769f5a047aa612f6daba4a13737853d445b4f0b Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 3 Feb 2026 16:15:19 +0100 Subject: [PATCH] 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 --- app/core/frontend_detector.py | 203 ++++++ app/exceptions/error_renderer.py | 80 +-- app/exceptions/handler.py | 23 +- .../customers/routes/api/storefront.py | 10 +- docs/architecture/frontend-detection.md | 278 ++++++++ docs/architecture/middleware.md | 79 ++- docs/architecture/request-flow.md | 43 +- docs/backend/middleware-reference.md | 84 ++- main.py | 16 +- middleware/context.py | 241 ++----- middleware/frontend_type.py | 92 +++ middleware/language.py | 25 +- middleware/platform_context.py | 34 +- middleware/vendor_context.py | 19 +- tests/unit/core/test_frontend_detector.py | 256 +++++++ tests/unit/middleware/test_context.py | 630 +++--------------- tests/unit/middleware/test_frontend_type.py | 195 ++++++ 17 files changed, 1393 insertions(+), 915 deletions(-) create mode 100644 app/core/frontend_detector.py create mode 100644 docs/architecture/frontend-detection.md create mode 100644 middleware/frontend_type.py create mode 100644 tests/unit/core/test_frontend_detector.py create mode 100644 tests/unit/middleware/test_frontend_type.py diff --git a/app/core/frontend_detector.py b/app/core/frontend_detector.py new file mode 100644 index 00000000..584bd579 --- /dev/null +++ b/app/core/frontend_detector.py @@ -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) diff --git a/app/exceptions/error_renderer.py b/app/exceptions/error_renderer.py index 8a983d33..f2bd57a7 100644 --- a/app/exceptions/error_renderer.py +++ b/app/exceptions/error_renderer.py @@ -14,7 +14,8 @@ from fastapi import Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates -from middleware.context import RequestContext, get_request_context +from app.modules.enums import FrontendType +from middleware.frontend_type import get_frontend_type logger = logging.getLogger(__name__) @@ -65,11 +66,11 @@ class ErrorPageRenderer: show_debug: bool = False, ) -> HTMLResponse: """ - Render appropriate error page based on request context. + Render appropriate error page based on request frontend type. Template Selection Priority: - 1. Context-specific error page: {context}/errors/{status_code}.html - 2. Context-specific generic: {context}/errors/generic.html + 1. Frontend-specific error page: {frontend}/errors/{status_code}.html + 2. Frontend-specific generic: {frontend}/errors/generic.html 3. Shared fallback error page: shared/{status_code}-fallback.html 4. Shared fallback generic: shared/generic-fallback.html @@ -84,13 +85,13 @@ class ErrorPageRenderer: Returns: HTMLResponse with rendered error page """ - # Get request context - context_type = get_request_context(request) + # Get frontend type + frontend_type = get_frontend_type(request) # Prepare template data template_data = ErrorPageRenderer._prepare_template_data( request=request, - context_type=context_type, + frontend_type=frontend_type, status_code=status_code, error_code=error_code, message=message, @@ -104,16 +105,16 @@ class ErrorPageRenderer: # Try to find appropriate template template_path = ErrorPageRenderer._find_template( - context_type=context_type, + frontend_type=frontend_type, status_code=status_code, ) logger.info( - f"Rendering error page: {template_path} for {status_code} in {context_type.value} context", + f"Rendering error page: {template_path} for {status_code} in {frontend_type.value} frontend", extra={ "status_code": status_code, "error_code": error_code, - "context": context_type.value, + "frontend": frontend_type.value, "template": template_path, }, ) @@ -141,38 +142,37 @@ class ErrorPageRenderer: @staticmethod def _find_template( - context_type: RequestContext, + frontend_type: FrontendType, status_code: int, ) -> str: """ - Find appropriate error template based on context and status code. + Find appropriate error template based on frontend type and status code. Priority: - 1. {context}/errors/{status_code}.html - 2. {context}/errors/generic.html + 1. {frontend}/errors/{status_code}.html + 2. {frontend}/errors/generic.html 3. shared/{status_code}-fallback.html 4. shared/generic-fallback.html """ templates_dir = ErrorPageRenderer.get_templates_dir() - # Map context type to folder name - context_folders = { - RequestContext.ADMIN: "admin", - RequestContext.VENDOR_DASHBOARD: "vendor", - RequestContext.SHOP: "shop", - RequestContext.API: "fallback", # API shouldn't get here, but just in case - RequestContext.FALLBACK: "fallback", + # Map frontend type to folder name + frontend_folders = { + FrontendType.ADMIN: "admin", + FrontendType.VENDOR: "vendor", + FrontendType.STOREFRONT: "storefront", + FrontendType.PLATFORM: "fallback", # Platform uses fallback templates } - context_folder = context_folders.get(context_type, "fallback") + frontend_folder = frontend_folders.get(frontend_type, "fallback") - # Try context-specific status code template - specific_template = f"{context_folder}/errors/{status_code}.html" + # Try frontend-specific status code template + specific_template = f"{frontend_folder}/errors/{status_code}.html" if (templates_dir / specific_template).exists(): return specific_template - # Try context-specific generic template - generic_template = f"{context_folder}/errors/generic.html" + # Try frontend-specific generic template + generic_template = f"{frontend_folder}/errors/generic.html" if (templates_dir / generic_template).exists(): return generic_template @@ -187,7 +187,7 @@ class ErrorPageRenderer: @staticmethod def _prepare_template_data( request: Request, - context_type: RequestContext, + frontend_type: FrontendType, status_code: int, error_code: str, message: str, @@ -212,8 +212,8 @@ class ErrorPageRenderer: # Only show to admins (we can check user role if available) display_debug = show_debug and ErrorPageRenderer._is_admin_user(request) - # Get context-specific data - context_data = ErrorPageRenderer._get_context_data(request, context_type) + # Get frontend-specific data + frontend_data = ErrorPageRenderer._get_frontend_data(request, frontend_type) return { "status_code": status_code, @@ -222,20 +222,20 @@ class ErrorPageRenderer: "message": user_message, "details": details or {}, "show_debug": display_debug, - "context_type": context_type.value, + "frontend_type": frontend_type.value, "path": request.url.path, - **context_data, + **frontend_data, } @staticmethod - def _get_context_data( - request: Request, context_type: RequestContext + def _get_frontend_data( + request: Request, frontend_type: FrontendType ) -> dict[str, Any]: - """Get context-specific data for error templates.""" + """Get frontend-specific data for error templates.""" data = {} - # Add vendor information if available (for shop context) - if context_type == RequestContext.SHOP: + # Add vendor information if available (for storefront frontend) + if frontend_type == FrontendType.STOREFRONT: vendor = getattr(request.state, "vendor", None) if vendor: # Pass minimal vendor info for templates @@ -261,7 +261,7 @@ class ErrorPageRenderer: "custom_css": getattr(theme, "custom_css", None), } - # Calculate base_url for shop links + # Calculate base_url for storefront links vendor_context = getattr(request.state, "vendor_context", None) access_method = ( vendor_context.get("detection_method", "unknown") @@ -289,9 +289,9 @@ class ErrorPageRenderer: This is a placeholder - implement based on your auth system. """ # TODO: Implement actual admin check based on JWT/session - # For now, check if we're in admin context - context_type = get_request_context(request) - return context_type == RequestContext.ADMIN + # For now, check if we're in admin frontend + frontend_type = get_frontend_type(request) + return frontend_type == FrontendType.ADMIN @staticmethod def _render_basic_html_fallback( diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index b83bbc71..a7ce615f 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -16,7 +16,8 @@ from fastapi import HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, RedirectResponse -from middleware.context import RequestContext, get_request_context +from app.modules.enums import FrontendType +from middleware.frontend_type import get_frontend_type from .base import WizamartException from .error_renderer import ErrorPageRenderer @@ -382,17 +383,17 @@ def _is_html_page_request(request: Request) -> bool: def _redirect_to_login(request: Request) -> RedirectResponse: """ - Redirect to appropriate login page based on request context. + Redirect to appropriate login page based on request frontend type. - Uses context detection to determine admin vs vendor vs shop login. + Uses FrontendType detection to determine admin vs vendor vs storefront login. Properly handles multi-access routing (domain, subdomain, path-based). """ - context_type = get_request_context(request) + frontend_type = get_frontend_type(request) - if context_type == RequestContext.ADMIN: + if frontend_type == FrontendType.ADMIN: logger.debug("Redirecting to /admin/login") return RedirectResponse(url="/admin/login", status_code=302) - if context_type == RequestContext.VENDOR_DASHBOARD: + if frontend_type == FrontendType.VENDOR: # Extract vendor code from the request path # Path format: /vendor/{vendor_code}/... path_parts = request.url.path.split("/") @@ -417,8 +418,8 @@ def _redirect_to_login(request: Request) -> RedirectResponse: logger.debug(f"Redirecting to {login_url}") return RedirectResponse(url=login_url, status_code=302) - if context_type == RequestContext.SHOP: - # For shop context, redirect to shop login (customer login) + if frontend_type == FrontendType.STOREFRONT: + # For storefront context, redirect to storefront login (customer login) # Calculate base_url for proper routing (supports domain, subdomain, and path-based access) vendor = getattr(request.state, "vendor", None) vendor_context = getattr(request.state, "vendor_context", None) @@ -437,11 +438,11 @@ def _redirect_to_login(request: Request) -> RedirectResponse: ) base_url = f"{full_prefix}{vendor.subdomain}/" - login_url = f"{base_url}shop/account/login" + login_url = f"{base_url}storefront/account/login" logger.debug(f"Redirecting to {login_url}") return RedirectResponse(url=login_url, status_code=302) - # Fallback to root for unknown contexts - logger.debug("Unknown context, redirecting to /") + # Fallback to root for unknown contexts (PLATFORM) + logger.debug("Platform context, redirecting to /") return RedirectResponse(url="/", status_code=302) diff --git a/app/modules/customers/routes/api/storefront.py b/app/modules/customers/routes/api/storefront.py index fd349c0a..82946f66 100644 --- a/app/modules/customers/routes/api/storefront.py +++ b/app/modules/customers/routes/api/storefront.py @@ -10,7 +10,7 @@ Public and authenticated endpoints for customer operations in storefront: Uses vendor from middleware context (VendorContextMiddleware). Implements dual token storage with path restriction: -- Sets HTTP-only cookie with path=/shop (restricted to shop routes only) +- Sets HTTP-only cookie with path=/storefront (restricted to storefront routes only) - Returns token in response for localStorage (API calls) """ @@ -182,14 +182,14 @@ def customer_login( else "unknown" ) - cookie_path = "/shop" + cookie_path = "/storefront" if access_method == "path": full_prefix = ( vendor_context.get("full_prefix", "/vendor/") if vendor_context else "/vendor/" ) - cookie_path = f"{full_prefix}{vendor.subdomain}/shop" + cookie_path = f"{full_prefix}{vendor.subdomain}/storefront" response.set_cookie( key="customer_token", @@ -240,14 +240,14 @@ def customer_logout(request: Request, response: Response): else "unknown" ) - cookie_path = "/shop" + cookie_path = "/storefront" if access_method == "path" and vendor: full_prefix = ( vendor_context.get("full_prefix", "/vendor/") if vendor_context else "/vendor/" ) - cookie_path = f"{full_prefix}{vendor.subdomain}/shop" + cookie_path = f"{full_prefix}{vendor.subdomain}/storefront" response.delete_cookie(key="customer_token", path=cookie_path) diff --git a/docs/architecture/frontend-detection.md b/docs/architecture/frontend-detection.md new file mode 100644 index 00000000..97f145a6 --- /dev/null +++ b/docs/architecture/frontend-detection.md @@ -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 diff --git a/docs/architecture/middleware.md b/docs/architecture/middleware.md index 810ba8e7..08a2f385 100644 --- a/docs/architecture/middleware.md +++ b/docs/architecture/middleware.md @@ -120,35 +120,35 @@ Injects: request.state.vendor = **Note on Path-Based Routing:** Previous implementations used a `PathRewriteMiddleware` to rewrite paths at runtime. This has been replaced with **double router mounting** in `main.py`, where shop routes are registered twice with different prefixes (`/shop` and `/vendors/{vendor_code}/shop`). This approach is simpler and uses FastAPI's native routing capabilities. -### 3. Context Detection Middleware +### 3. Frontend Type Detection Middleware -**Purpose**: Determine the type/context of the request +**Purpose**: Determine which frontend the request targets **What it does**: -- Analyzes the request path (using clean_path) -- Determines which interface is being accessed: - - `API` - `/api/*` paths - - `ADMIN` - `/admin/*` paths or `admin.*` subdomain - - `VENDOR_DASHBOARD` - `/vendor/*` paths (management area) - - `SHOP` - Storefront pages (has vendor + not admin/vendor/API) - - `FALLBACK` - Unknown context -- Injects `request.state.context_type` +- Uses centralized `FrontendDetector` class for all detection logic +- Determines which frontend is being accessed: + - `ADMIN` - `/admin/*`, `/api/v1/admin/*` paths or `admin.*` subdomain + - `VENDOR` - `/vendor/*`, `/api/v1/vendor/*` paths (management area) + - `STOREFRONT` - Customer shop pages (`/storefront/*`, `/vendors/*`, vendor subdomains) + - `PLATFORM` - Marketing pages (`/`, `/pricing`, `/about`) +- Injects `request.state.frontend_type` (FrontendType enum) -**Detection Rules**: +**Detection Priority** (handled by `FrontendDetector`): ```python -if path.startswith("/api/"): - context = API -elif path.startswith("/admin/") or host.startswith("admin."): - context = ADMIN -elif path.startswith("/vendor/"): - context = VENDOR_DASHBOARD -elif request.state.vendor exists: - context = SHOP -else: - context = FALLBACK +1. Admin subdomain (admin.oms.lu) → ADMIN +2. Path-based detection: + - /admin/* or /api/v1/admin/* → ADMIN + - /vendor/* or /api/v1/vendor/* → VENDOR + - /storefront/*, /shop/*, /vendors/* → STOREFRONT + - /api/v1/platform/* → PLATFORM +3. Vendor subdomain (wizamart.oms.lu) → STOREFRONT +4. Vendor context set by middleware → STOREFRONT +5. Default → PLATFORM ``` -**Why it's useful**: Error handlers and templates adapt based on context +**Why it's useful**: Error handlers, templates, and language detection adapt based on frontend type + +**See**: [Frontend Detection Architecture](frontend-detection.md) for complete details ### 4. Theme Context Middleware @@ -221,15 +221,18 @@ Each middleware file contains one primary class or a tightly related set of clas class LoggingMiddleware(BaseHTTPMiddleware): """Request/response logging middleware""" -# middleware/context.py -class ContextManager: # Business logic -class ContextMiddleware: # ASGI wrapper -class RequestContext(Enum): # Related enum +# middleware/frontend_type.py +class FrontendTypeMiddleware: # ASGI wrapper for frontend detection +# Uses FrontendDetector from app/core/frontend_detector.py # middleware/auth.py class AuthManager: # Authentication logic ``` +> **Note**: The old `middleware/context.py` with `ContextMiddleware` and `RequestContext` is deprecated. +> Use `FrontendTypeMiddleware` and `FrontendType` enum instead. +> See [Frontend Detection Architecture](frontend-detection.md) for migration guide. + #### One Test File Per Component Follow the Single Responsibility Principle - each test file tests exactly one component: @@ -368,8 +371,8 @@ async def get_products(request: Request): } -{# Access context #} -{% if request.state.context_type.value == "admin" %} +{# Access frontend type #} +{% if request.state.frontend_type.value == "admin" %}
Admin Mode
{% endif %} ``` @@ -394,11 +397,11 @@ async def get_products(request: Request): ↓ Sets: request.state.vendor_id = 1 ↓ Sets: request.state.clean_path = "/shop/products" -3. ContextDetectionMiddleware - ↓ Analyzes path: "/shop/products" - ↓ Has vendor: Yes - ↓ Not admin/api/vendor dashboard - ↓ Sets: request.state.context_type = RequestContext.SHOP +3. FrontendTypeMiddleware + ↓ Uses FrontendDetector with path: "/shop/products" + ↓ Has vendor context: Yes + ↓ Detects storefront frontend + ↓ Sets: request.state.frontend_type = FrontendType.STOREFRONT 4. ThemeContextMiddleware ↓ Loads theme for vendor_id = 1 @@ -430,10 +433,10 @@ Each middleware component handles errors gracefully: - If database error: Logs error, allows request to continue - Fallback: Request proceeds without vendor context -### ContextDetectionMiddleware +### FrontendTypeMiddleware - If clean_path missing: Uses original path -- If vendor missing: Defaults to FALLBACK context -- Always sets a context_type (never None) +- If vendor missing: Defaults to PLATFORM frontend type +- Always sets a frontend_type (never None) ### ThemeContextMiddleware - If vendor missing: Skips theme loading @@ -479,8 +482,10 @@ Middleware is registered in `main.py`: # Add in REVERSE order (LIFO execution) app.add_middleware(LoggingMiddleware) app.add_middleware(ThemeContextMiddleware) -app.add_middleware(ContextDetectionMiddleware) +app.add_middleware(LanguageMiddleware) +app.add_middleware(FrontendTypeMiddleware) app.add_middleware(VendorContextMiddleware) +app.add_middleware(PlatformContextMiddleware) ``` **Note**: FastAPI's `add_middleware` executes in **reverse order** (Last In, First Out) diff --git a/docs/architecture/request-flow.md b/docs/architecture/request-flow.md index b5a86f64..68ba64c6 100644 --- a/docs/architecture/request-flow.md +++ b/docs/architecture/request-flow.md @@ -147,27 +147,25 @@ app.include_router(shop_pages.router, prefix="/vendors/{vendor_code}/shop") **Detection Logic**: ```python +host = request.headers.get("host", "") path = request.state.clean_path # "/shop/products" +has_vendor = hasattr(request.state, 'vendor') and request.state.vendor -if path.startswith("/api/"): - context = RequestContext.API -elif path.startswith("/admin/"): - context = RequestContext.ADMIN -elif path.startswith("/vendor/"): - context = RequestContext.VENDOR_DASHBOARD -elif hasattr(request.state, 'vendor') and request.state.vendor: - context = RequestContext.SHOP # ← Our example -else: - context = RequestContext.FALLBACK +# FrontendDetector handles all detection logic centrally +frontend_type = FrontendDetector.detect(host, path, has_vendor) +# Returns: FrontendType.STOREFRONT # ← Our example -request.state.context_type = context +request.state.frontend_type = frontend_type ``` **Request State After**: ```python -request.state.context_type = RequestContext.SHOP +request.state.frontend_type = FrontendType.STOREFRONT ``` +> **Note**: Detection logic is centralized in `app/core/frontend_detector.py`. +> See [Frontend Detection Architecture](frontend-detection.md) for details. + ### 6. ThemeContextMiddleware **What happens**: @@ -363,7 +361,7 @@ sequenceDiagram Note over Vendor: No vendor detection
(API uses query param) Vendor->>Context: Pass request Context->>Context: Detect API context - Note over Context: context_type = API + Note over Context: frontend_type = API (via FrontendDetector) Context->>Router: Route request Router->>Handler: Call API handler Handler->>DB: Query products @@ -390,7 +388,7 @@ sequenceDiagram Note over Vendor: No vendor
(Admin area) Vendor->>Context: Pass request Context->>Context: Detect Admin context - Note over Context: context_type = ADMIN + Note over Context: frontend_type = ADMIN Context->>Theme: Pass request Note over Theme: Skip theme
(No vendor) Theme->>Router: Route request @@ -423,7 +421,7 @@ sequenceDiagram Note over Path: Path already clean Path->>Context: Pass request Context->>Context: Detect Shop context - Note over Context: context_type = SHOP + Note over Context: frontend_type = STOREFRONT Context->>Theme: Pass request Theme->>DB: Query theme DB-->>Theme: Theme config @@ -450,12 +448,12 @@ After VendorContextMiddleware: clean_path: "/shop/products" } -After ContextDetectionMiddleware: +After FrontendTypeMiddleware: { vendor: , vendor_id: 1, clean_path: "/shop/products", - context_type: RequestContext.SHOP + frontend_type: FrontendType.STOREFRONT } After ThemeContextMiddleware: @@ -463,7 +461,7 @@ After ThemeContextMiddleware: vendor: , vendor_id: 1, clean_path: "/shop/products", - context_type: RequestContext.SHOP, + frontend_type: FrontendType.STOREFRONT, theme: { primary_color: "#3B82F6", secondary_color: "#10B981", @@ -481,7 +479,7 @@ Typical request timings: |-----------|------|------------| | Middleware Stack | 5ms | 3% | | - VendorContextMiddleware | 2ms | 1% | -| - ContextDetectionMiddleware | <1ms | <1% | +| - FrontendTypeMiddleware | <1ms | <1% | | - ThemeContextMiddleware | 2ms | 1% | | Database Queries | 15ms | 10% | | Business Logic | 50ms | 35% | @@ -550,7 +548,7 @@ async def debug_state(request: Request): "vendor": request.state.vendor.name if hasattr(request.state, 'vendor') else None, "vendor_id": getattr(request.state, 'vendor_id', None), "clean_path": getattr(request.state, 'clean_path', None), - "context_type": request.state.context_type.value if hasattr(request.state, 'context_type') else None, + "frontend_type": request.state.frontend_type.value if hasattr(request.state, 'frontend_type') else None, "has_theme": bool(getattr(request.state, 'theme', None)) } ``` @@ -562,7 +560,8 @@ In `main.py`, middleware registration order is critical: ```python # REVERSE order (Last In, First Out) app.add_middleware(LoggingMiddleware) # Runs first -app.add_middleware(ThemeContextMiddleware) # Runs fifth -app.add_middleware(ContextDetectionMiddleware) # Runs fourth +app.add_middleware(ThemeContextMiddleware) # Runs sixth +app.add_middleware(LanguageMiddleware) # Runs fifth +app.add_middleware(FrontendTypeMiddleware) # Runs fourth app.add_middleware(VendorContextMiddleware) # Runs second ``` diff --git a/docs/backend/middleware-reference.md b/docs/backend/middleware-reference.md index b9679fe3..ec4cec43 100644 --- a/docs/backend/middleware-reference.md +++ b/docs/backend/middleware-reference.md @@ -66,51 +66,57 @@ ASGI middleware that wraps VendorContextManager for FastAPI integration. --- -## Request Context Detection +## Frontend Type Detection -### RequestContext +### FrontendType -Enum defining all possible request context types in the application. +Enum defining all possible frontend types in the application. -::: middleware.context.RequestContext +::: app.modules.enums.FrontendType options: show_source: false heading_level: 4 show_root_heading: false members: - - API + - PLATFORM - ADMIN - - VENDOR_DASHBOARD - - SHOP - - FALLBACK + - VENDOR + - STOREFRONT -### ContextManager +### FrontendDetector -Detects the type of request (API, Admin, Vendor Dashboard, Shop) based on URL patterns. +Centralized class for detecting which frontend a request targets based on URL patterns. -**Context Detection Rules:** -- `/api/` → API context -- `/admin/` → Admin context -- `/vendor/` → Vendor Dashboard context -- `/shop/` → Shop context -- Default → Fallback context +**Detection Rules (Priority Order):** +1. Admin subdomain (`admin.*`) → ADMIN +2. Path-based detection: + - `/admin/*`, `/api/v1/admin/*` → ADMIN + - `/vendor/*`, `/api/v1/vendor/*` → VENDOR + - `/storefront/*`, `/shop/*`, `/vendors/*` → STOREFRONT + - `/api/v1/platform/*` → PLATFORM +3. Vendor subdomain → STOREFRONT +4. Vendor context set → STOREFRONT +5. Default → PLATFORM -::: middleware.context.ContextManager +::: app.core.frontend_detector.FrontendDetector options: show_source: false heading_level: 4 show_root_heading: false -### ContextMiddleware +### FrontendTypeMiddleware -ASGI middleware for context detection. Must run AFTER VendorContextMiddleware. +ASGI middleware for frontend type detection. Must run AFTER VendorContextMiddleware. -::: middleware.context.ContextMiddleware +::: middleware.frontend_type.FrontendTypeMiddleware options: show_source: false heading_level: 4 show_root_heading: false +> **Note**: The old `RequestContext` enum and `ContextMiddleware` are deprecated. +> See [Frontend Detection Architecture](../architecture/frontend-detection.md) for migration guide. + --- ## Theme Management @@ -235,20 +241,24 @@ The middleware stack must be configured in the correct order for proper function ```mermaid graph TD A[Request] --> B[LoggingMiddleware] - B --> C[VendorContextMiddleware] - C --> D[ContextMiddleware] - D --> E[ThemeContextMiddleware] - E --> F[Application Routes] - F --> G[Response] + B --> C[PlatformContextMiddleware] + C --> D[VendorContextMiddleware] + D --> E[FrontendTypeMiddleware] + E --> F[LanguageMiddleware] + F --> G[ThemeContextMiddleware] + G --> H[Application Routes] + H --> I[Response] ``` **Critical Dependencies:** 1. **LoggingMiddleware** runs first for request timing -2. **VendorContextMiddleware** detects vendor and sets clean_path -3. **ContextMiddleware** detects context type (API/Admin/Vendor/Shop) -4. **ThemeContextMiddleware** loads vendor theme based on context +2. **PlatformContextMiddleware** detects platform and sets platform context +3. **VendorContextMiddleware** detects vendor and sets clean_path +4. **FrontendTypeMiddleware** detects frontend type (ADMIN/VENDOR/STOREFRONT/PLATFORM) +5. **LanguageMiddleware** resolves language based on frontend type +6. **ThemeContextMiddleware** loads vendor theme based on context -**Note:** Path-based routing (e.g., `/vendors/{code}/shop/*`) is handled by double router mounting in `main.py`, not by middleware. +**Note:** Path-based routing (e.g., `/vendors/{code}/storefront/*`) is handled by double router mounting in `main.py`, not by middleware. --- @@ -258,22 +268,28 @@ Middleware components inject the following variables into `request.state`: | Variable | Set By | Type | Description | |----------|--------|------|-------------| +| `platform` | PlatformContextMiddleware | Platform | Current platform object | | `vendor` | VendorContextMiddleware | Vendor | Current vendor object | | `vendor_id` | VendorContextMiddleware | int | Current vendor ID | | `clean_path` | VendorContextMiddleware | str | Path without vendor prefix | -| `context_type` | ContextMiddleware | RequestContext | Request context (API/Admin/Vendor/Shop) | +| `frontend_type` | FrontendTypeMiddleware | FrontendType | Frontend type (ADMIN/VENDOR/STOREFRONT/PLATFORM) | +| `language` | LanguageMiddleware | str | Detected language code | | `theme` | ThemeContextMiddleware | dict | Vendor theme configuration | **Usage in Routes:** ```python from fastapi import Request +from app.modules.enums import FrontendType +from middleware.frontend_type import get_frontend_type -@app.get("/shop/products") +@app.get("/storefront/products") async def get_products(request: Request): vendor = request.state.vendor - context = request.state.context_type + frontend_type = get_frontend_type(request) theme = request.state.theme - return {"vendor": vendor.name, "context": context} + + if frontend_type == FrontendType.STOREFRONT: + return {"vendor": vendor.name, "frontend": frontend_type.value} ``` --- @@ -308,6 +324,8 @@ For testing examples, see the [Testing Guide](../testing/testing-guide.md). ## Related Documentation +- [Frontend Detection Architecture](../architecture/frontend-detection.md) - Frontend type detection system +- [Middleware Architecture](../architecture/middleware.md) - Middleware stack overview - [Authentication Guide](../api/authentication.md) - User authentication and JWT tokens - [RBAC Documentation](../api/rbac.md) - Role-based access control - [Error Handling](../api/error-handling.md) - Exception handling patterns diff --git a/main.py b/main.py index a5eca17d..34ed97c4 100644 --- a/main.py +++ b/main.py @@ -69,7 +69,7 @@ from app.modules.routes import ( get_vendor_page_routes, ) from app.utils.i18n import get_jinja2_globals -from middleware.context import ContextMiddleware +from middleware.frontend_type import FrontendTypeMiddleware from middleware.language import LanguageMiddleware from middleware.logging import LoggingMiddleware from middleware.theme_context import ThemeContextMiddleware @@ -122,15 +122,15 @@ app.add_middleware( # Desired execution order: # 1. PlatformContextMiddleware (detect platform from domain/path) # 2. VendorContextMiddleware (detect vendor, uses platform_clean_path) -# 3. ContextMiddleware (detect context using clean_path) -# 4. LanguageMiddleware (detect language based on context) +# 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector) +# 4. LanguageMiddleware (detect language based on frontend type) # 5. ThemeContextMiddleware (load theme) # 6. LoggingMiddleware (log all requests) # # Therefore we add them in REVERSE: # - Add ThemeContextMiddleware FIRST (runs LAST in request) # - Add LanguageMiddleware SECOND -# - Add ContextMiddleware THIRD +# - Add FrontendTypeMiddleware THIRD # - Add VendorContextMiddleware FOURTH # - Add PlatformContextMiddleware FIFTH # - Add LoggingMiddleware LAST (runs FIRST for timing) @@ -152,9 +152,9 @@ app.add_middleware(ThemeContextMiddleware) logger.info("Adding LanguageMiddleware (detects language based on context)") app.add_middleware(LanguageMiddleware) -# Add context detection middleware (runs after vendor context extraction) -logger.info("Adding ContextMiddleware (detects context type using clean_path)") -app.add_middleware(ContextMiddleware) +# Add frontend type detection middleware (runs after vendor context extraction) +logger.info("Adding FrontendTypeMiddleware (detects frontend type using FrontendDetector)") +app.add_middleware(FrontendTypeMiddleware) # Add vendor context middleware (runs after platform context) logger.info("Adding VendorContextMiddleware (detects vendor, uses platform_clean_path)") @@ -170,7 +170,7 @@ logger.info(" Execution order (request →):") logger.info(" 1. LoggingMiddleware (timing)") logger.info(" 2. PlatformContextMiddleware (platform detection)") logger.info(" 3. VendorContextMiddleware (vendor detection)") -logger.info(" 4. ContextMiddleware (context detection)") +logger.info(" 4. FrontendTypeMiddleware (frontend type detection)") logger.info(" 5. LanguageMiddleware (language detection)") logger.info(" 6. ThemeContextMiddleware (theme loading)") logger.info(" 7. FastAPI Router") diff --git a/middleware/context.py b/middleware/context.py index ff8252e5..0488c536 100644 --- a/middleware/context.py +++ b/middleware/context.py @@ -1,31 +1,52 @@ -# middleware/context_middleware.py +# middleware/context.py """ -Context Detection Middleware (Class-Based) +DEPRECATED: This module is deprecated in favor of middleware/frontend_type.py -Detects the request context type (API, Admin, Vendor Dashboard, Shop, or Fallback) -and injects it into request.state for use by error handlers and other components. +The RequestContext enum and ContextMiddleware have been replaced by: +- FrontendType enum (app/modules/enums.py) +- FrontendTypeMiddleware (middleware/frontend_type.py) +- FrontendDetector (app/core/frontend_detector.py) -MUST run AFTER vendor_context_middleware to have access to clean_path. -MUST run BEFORE theme_context_middleware (which needs context_type). +This file is kept for backwards compatibility during the migration period. +All new code should use FrontendType and FrontendTypeMiddleware instead. -Class-based middleware provides: -- Better state management -- Easier testing -- More organized code -- Standard ASGI pattern +Migration guide: +- RequestContext.API -> Check with FrontendDetector.is_api_request() +- RequestContext.ADMIN -> FrontendType.ADMIN +- RequestContext.VENDOR_DASHBOARD -> FrontendType.VENDOR +- RequestContext.SHOP -> FrontendType.STOREFRONT +- RequestContext.FALLBACK -> FrontendType.PLATFORM (or handle API separately) + +- get_request_context(request) -> get_frontend_type(request) +- request.state.context_type -> request.state.frontend_type """ import logging +import warnings from enum import Enum from fastapi import Request -from starlette.middleware.base import BaseHTTPMiddleware + +from app.modules.enums import FrontendType +from middleware.frontend_type import get_frontend_type logger = logging.getLogger(__name__) class RequestContext(str, Enum): - """Request context types for the application.""" + """ + DEPRECATED: Use FrontendType enum instead. + + Request context types for the application. + This enum is kept for backwards compatibility. + + Migration: + - API -> Use FrontendDetector.is_api_request() + FrontendType + - ADMIN -> FrontendType.ADMIN + - VENDOR_DASHBOARD -> FrontendType.VENDOR + - SHOP -> FrontendType.STOREFRONT + - FALLBACK -> FrontendType.PLATFORM + """ API = "api" ADMIN = "admin" @@ -34,169 +55,12 @@ class RequestContext(str, Enum): FALLBACK = "fallback" -class ContextManager: - """Manages context detection for multi-area application.""" - - @staticmethod - def detect_context(request: Request) -> RequestContext: - """ - Detect the request context type. - - Priority order: - 1. API → /api/* paths (highest priority, always JSON) - 2. Admin → /admin/* paths or admin.* subdomain - 3. Vendor Dashboard → /vendor/* paths (vendor management area) - 4. Shop → Vendor storefront (custom domain, subdomain, or shop paths) - 5. Fallback → Unknown/generic context - - CRITICAL: Uses clean_path (if available) instead of original path. - This ensures correct context detection for path-based routing. - - Args: - request: FastAPI request object - - Returns: - RequestContext enum value - """ - # Use clean_path if available (extracted by vendor_context_middleware) - # Falls back to original path if clean_path not set - # This is critical for correct context detection with path-based routing - path = getattr(request.state, "clean_path", request.url.path) - - host = request.headers.get("host", "") - - # Remove port from host if present - if ":" in host: - host = host.split(":")[0] - - logger.debug( - "[CONTEXT] Detecting context", - extra={ - "original_path": request.url.path, - "clean_path": getattr(request.state, "clean_path", "NOT SET"), - "path_to_check": path, - "host": host, - }, - ) - - # 1. API context (highest priority) - if path.startswith("/api/"): - logger.debug("[CONTEXT] Detected as API", extra={"path": path}) - return RequestContext.API - - # 2. Admin context - if ContextManager._is_admin_context(request, host, path): - logger.debug( - "[CONTEXT] Detected as ADMIN", extra={"path": path, "host": host} - ) - return RequestContext.ADMIN - - # 3. Vendor Dashboard context (vendor management area) - # Check both clean_path and original path for vendor dashboard - original_path = request.url.path - if ContextManager._is_vendor_dashboard_context( - path - ) or ContextManager._is_vendor_dashboard_context(original_path): - logger.debug( - "[CONTEXT] Detected as VENDOR_DASHBOARD", - extra={"path": path, "original_path": original_path}, - ) - return RequestContext.VENDOR_DASHBOARD - - # 4. Shop context (vendor storefront) - # Check if vendor context exists (set by vendor_context_middleware) - if hasattr(request.state, "vendor") and request.state.vendor: - # If we have a vendor and it's not admin or vendor dashboard, it's shop - logger.debug( - "[CONTEXT] Detected as SHOP (has vendor context)", - extra={"vendor": request.state.vendor.name}, - ) - return RequestContext.SHOP - - # Also check shop-specific paths - if path.startswith("/shop/"): - logger.debug("[CONTEXT] Detected as SHOP (from path)", extra={"path": path}) - return RequestContext.SHOP - - # 5. Fallback for unknown contexts - logger.debug("[CONTEXT] Detected as FALLBACK", extra={"path": path}) - return RequestContext.FALLBACK - - @staticmethod - def _is_admin_context(request: Request, host: str, path: str) -> bool: - """Check if request is in admin context.""" - # Admin subdomain (admin.platform.com) - if host.startswith("admin."): - return True - - # Admin path (/admin/*) - if path.startswith("/admin"): - return True - - return False - - @staticmethod - def _is_vendor_dashboard_context(path: str) -> bool: - """Check if request is in vendor dashboard context.""" - # Vendor dashboard paths (/vendor/{code}/*) - # Note: This is the vendor management area, not the shop - # Important: /vendors/{code}/shop/* should NOT match this - if path.startswith("/vendor/") and not path.startswith("/vendors/"): - return True - - return False - - -class ContextMiddleware(BaseHTTPMiddleware): - """ - Middleware to detect and inject request context into request.state. - - Class-based middleware provides: - - Better lifecycle management - - Easier to test and extend - - Standard ASGI pattern - - Clear separation of concerns - - Runs SECOND in middleware chain (after vendor_context_middleware). - Depends on: - request.state.clean_path (set by vendor_context_middleware) - request.state.vendor (set by vendor_context_middleware) - - Sets: - request.state.context_type: RequestContext enum value - """ - - async def dispatch(self, request: Request, call_next): - """ - Detect context and inject into request state. - """ - # Detect context - context_type = ContextManager.detect_context(request) - - # Inject into request state - request.state.context_type = context_type - - # Log context detection with full details - logger.debug( - f"[CONTEXT_MIDDLEWARE] Context detected: {context_type.value}", - extra={ - "path": request.url.path, - "clean_path": getattr(request.state, "clean_path", "NOT SET"), - "host": request.headers.get("host", ""), - "context": context_type.value, - "has_vendor": hasattr(request.state, "vendor") - and request.state.vendor is not None, - }, - ) - - # Continue processing - response = await call_next(request) - return response - - def get_request_context(request: Request) -> RequestContext: """ + DEPRECATED: Use get_frontend_type() from middleware.frontend_type instead. + Helper function to get current request context. + This function maps FrontendType to RequestContext for backwards compatibility. Args: request: FastAPI request object @@ -204,4 +68,33 @@ def get_request_context(request: Request) -> RequestContext: Returns: RequestContext enum value (defaults to FALLBACK if not set) """ - return getattr(request.state, "context_type", RequestContext.FALLBACK) + warnings.warn( + "get_request_context() is deprecated. Use get_frontend_type() from " + "middleware.frontend_type instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Get the new frontend_type + frontend_type = get_frontend_type(request) + + # Map FrontendType to RequestContext for backwards compatibility + mapping = { + FrontendType.ADMIN: RequestContext.ADMIN, + FrontendType.VENDOR: RequestContext.VENDOR_DASHBOARD, + FrontendType.STOREFRONT: RequestContext.SHOP, + FrontendType.PLATFORM: RequestContext.FALLBACK, + } + + # Check if it's an API request + if request.url.path.startswith("/api/"): + return RequestContext.API + + return mapping.get(frontend_type, RequestContext.FALLBACK) + + +# ContextManager and ContextMiddleware are removed. +# They have been replaced by FrontendDetector and FrontendTypeMiddleware. +# Import from the new locations: +# from app.core.frontend_detector import FrontendDetector +# from middleware.frontend_type import FrontendTypeMiddleware, get_frontend_type diff --git a/middleware/frontend_type.py b/middleware/frontend_type.py new file mode 100644 index 00000000..0e47e1ea --- /dev/null +++ b/middleware/frontend_type.py @@ -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) diff --git a/middleware/language.py b/middleware/language.py index 9b5421a0..4aca7017 100644 --- a/middleware/language.py +++ b/middleware/language.py @@ -18,6 +18,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response +from app.modules.enums import FrontendType from app.utils.i18n import ( DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, @@ -45,9 +46,8 @@ class LanguageMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next) -> Response: """Process the request and set language.""" - # Get context type from previous middleware - context_type = getattr(request.state, "context_type", None) - context_value = context_type.value if context_type else None + # Get frontend type from FrontendTypeMiddleware + frontend_type = getattr(request.state, "frontend_type", None) # Get vendor from previous middleware (if available) vendor = getattr(request.state, "vendor", None) @@ -59,13 +59,13 @@ class LanguageMiddleware(BaseHTTPMiddleware): accept_language = request.headers.get("accept-language") browser_language = parse_accept_language(accept_language) - # Resolve language based on context - if context_value == "admin": + # Resolve language based on frontend type + if frontend_type == FrontendType.ADMIN: # Admin dashboard: English only (for now) # TODO: Implement admin language support later language = "en" - elif context_value == "vendor_dashboard": + elif frontend_type == FrontendType.VENDOR: # Vendor dashboard user_preferred = self._get_user_language_from_token(request) vendor_dashboard = vendor.dashboard_language if vendor else None @@ -75,7 +75,7 @@ class LanguageMiddleware(BaseHTTPMiddleware): vendor_dashboard=vendor_dashboard, ) - elif context_value == "shop": + elif frontend_type == FrontendType.STOREFRONT: # Storefront customer_preferred = self._get_customer_language_from_token(request) vendor_storefront = vendor.storefront_language if vendor else None @@ -89,12 +89,12 @@ class LanguageMiddleware(BaseHTTPMiddleware): enabled_languages=enabled_languages, ) - elif context_value == "api": - # API requests: Use Accept-Language or cookie + elif frontend_type == FrontendType.PLATFORM: + # Platform marketing pages: Use cookie, browser, or default language = cookie_language or browser_language or DEFAULT_LANGUAGE else: - # Fallback: Use cookie, browser, or default + # Fallback (API or unknown): Use Accept-Language or cookie language = cookie_language or browser_language or DEFAULT_LANGUAGE # Validate language is supported @@ -109,13 +109,14 @@ class LanguageMiddleware(BaseHTTPMiddleware): "code": language, "cookie": cookie_language, "browser": browser_language, - "context": context_value, + "frontend_type": frontend_type.value if frontend_type else None, } # Log language detection for debugging + frontend_value = frontend_type.value if frontend_type else "unknown" logger.debug( f"Language detected: {language} " - f"(context={context_value}, cookie={cookie_language}, browser={browser_language})" + f"(frontend={frontend_value}, cookie={cookie_language}, browser={browser_language})" ) # Process request diff --git a/middleware/platform_context.py b/middleware/platform_context.py index feaf5809..5fe66169 100644 --- a/middleware/platform_context.py +++ b/middleware/platform_context.py @@ -22,6 +22,8 @@ from fastapi import Request from sqlalchemy.orm import Session from app.core.database import get_db +from app.core.frontend_detector import FrontendDetector +from app.modules.enums import FrontendType from app.modules.tenancy.models import Platform # Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting @@ -61,7 +63,7 @@ class PlatformContextManager: host_without_port = host.split(":")[0] if ":" in host else host # Skip platform detection for admin routes - admin is global - if PlatformContextManager.is_admin_request(request): + if FrontendDetector.is_admin(host, path): return None # Method 1: Domain-based detection (production) @@ -208,17 +210,15 @@ class PlatformContextManager: @staticmethod def is_admin_request(request: Request) -> bool: - """Check if request is for admin interface.""" + """ + Check if request is for admin interface. + + DEPRECATED: Use FrontendDetector.is_admin() instead. + Kept for backwards compatibility. + """ host = request.headers.get("host", "") path = request.url.path - - if ":" in host: - host = host.split(":")[0] - - if host.startswith("admin."): - return True - - return path.startswith("/admin") + return FrontendDetector.is_admin(host, path) @staticmethod def is_static_file_request(request: Request) -> bool: @@ -299,7 +299,7 @@ class PlatformContextMiddleware: return # Skip for admin requests - if self._is_admin_request(path, host): + if FrontendDetector.is_admin(host, path): scope["state"]["platform"] = None scope["state"]["platform_context"] = None scope["state"]["platform_clean_path"] = path @@ -427,11 +427,13 @@ class PlatformContextMiddleware: return "favicon.ico" in path_lower def _is_admin_request(self, path: str, host: str) -> bool: - """Check if request is for admin interface.""" - host_without_port = host.split(":")[0] if ":" in host else host - if host_without_port.startswith("admin."): - return True - return path.startswith("/admin") + """ + Check if request is for admin interface. + + DEPRECATED: Use FrontendDetector.is_admin() instead. + Kept for backwards compatibility. + """ + return FrontendDetector.is_admin(host, path) def get_current_platform(request: Request) -> Platform | None: diff --git a/middleware/vendor_context.py b/middleware/vendor_context.py index 5aa7e6a2..a298c3f5 100644 --- a/middleware/vendor_context.py +++ b/middleware/vendor_context.py @@ -23,6 +23,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.core.config import settings from app.core.database import get_db +from app.core.frontend_detector import FrontendDetector from app.modules.tenancy.models import Vendor from app.modules.tenancy.models import VendorDomain @@ -194,22 +195,20 @@ class VendorContextManager: @staticmethod def is_admin_request(request: Request) -> bool: - """Check if request is for admin interface.""" + """ + Check if request is for admin interface. + + DEPRECATED: Use FrontendDetector.is_admin() instead. + Kept for backwards compatibility. + """ host = request.headers.get("host", "") path = request.url.path - - if ":" in host: - host = host.split(":")[0] - - if host.startswith("admin."): - return True - - return path.startswith("/admin") + return FrontendDetector.is_admin(host, path) @staticmethod def is_api_request(request: Request) -> bool: """Check if request is for API endpoints.""" - return request.url.path.startswith("/api/") + return FrontendDetector.is_api_request(request.url.path) @staticmethod def is_shop_api_request(request: Request) -> bool: diff --git a/tests/unit/core/test_frontend_detector.py b/tests/unit/core/test_frontend_detector.py new file mode 100644 index 00000000..5f7070d5 --- /dev/null +++ b/tests/unit/core/test_frontend_detector.py @@ -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 diff --git a/tests/unit/middleware/test_context.py b/tests/unit/middleware/test_context.py index 05c12dbf..0a4fc39c 100644 --- a/tests/unit/middleware/test_context.py +++ b/tests/unit/middleware/test_context.py @@ -1,31 +1,31 @@ # tests/unit/middleware/test_context.py """ -Comprehensive unit tests for ContextMiddleware and ContextManager. +DEPRECATED: Tests for backward compatibility of middleware.context module. -Tests cover: -- Context detection for API, Admin, Vendor Dashboard, Shop, and Fallback -- Clean path usage for correct context detection -- Host and path-based context determination -- Middleware state injection -- Edge cases and error handling +The ContextMiddleware and ContextManager classes have been replaced by: +- FrontendTypeMiddleware (middleware/frontend_type.py) +- FrontendDetector (app/core/frontend_detector.py) + +These tests verify the backward compatibility layer still works for code +that uses the deprecated RequestContext enum and get_request_context() function. + +For new tests, see: +- tests/unit/core/test_frontend_detector.py +- tests/unit/middleware/test_frontend_type.py """ -from unittest.mock import AsyncMock, Mock +import warnings +from unittest.mock import Mock import pytest from fastapi import Request -from middleware.context import ( - ContextManager, - ContextMiddleware, - RequestContext, - get_request_context, -) +from middleware.context import RequestContext, get_request_context @pytest.mark.unit -class TestRequestContextEnum: - """Test suite for RequestContext enum.""" +class TestRequestContextEnumBackwardCompatibility: + """Test suite for deprecated RequestContext enum.""" def test_request_context_values(self): """Test RequestContext enum has correct values.""" @@ -42,554 +42,90 @@ class TestRequestContextEnum: @pytest.mark.unit -class TestContextManagerDetection: - """Test suite for ContextManager.detect_context().""" +class TestGetRequestContextBackwardCompatibility: + """Test suite for deprecated get_request_context() function.""" - # ======================================================================== - # API Context Tests (Highest Priority) - # ======================================================================== - - def test_detect_api_context(self): - """Test API context detection.""" + def test_get_request_context_returns_api_for_api_paths(self): + """Test get_request_context returns API for /api/ paths.""" request = Mock(spec=Request) request.url = Mock(path="/api/v1/vendors") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/api/v1/vendors") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.API - - def test_detect_api_context_nested_path(self): - """Test API context detection with nested path.""" - request = Mock(spec=Request) - request.url = Mock(path="/api/v1/vendors/123/products") - request.headers = {"host": "platform.com"} - request.state = Mock(clean_path="/api/v1/vendors/123/products") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.API - - def test_detect_api_context_with_clean_path(self): - """Test API context detection uses clean_path when available.""" - request = Mock(spec=Request) - request.url = Mock(path="/vendor/testvendor/api/products") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/api/products") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.API - - # ======================================================================== - # Admin Context Tests - # ======================================================================== - - def test_detect_admin_context_from_subdomain(self): - """Test admin context detection from subdomain.""" - request = Mock(spec=Request) - request.url = Mock(path="/dashboard") - request.headers = {"host": "admin.platform.com"} - request.state = Mock(clean_path="/dashboard") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.ADMIN - - def test_detect_admin_context_from_path(self): - """Test admin context detection from path.""" - request = Mock(spec=Request) - request.url = Mock(path="/admin/dashboard") - request.headers = {"host": "platform.com"} - request.state = Mock(clean_path="/admin/dashboard") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.ADMIN - - def test_detect_admin_context_with_port(self): - """Test admin context detection with port number.""" - request = Mock(spec=Request) - request.url = Mock(path="/dashboard") - request.headers = {"host": "admin.localhost:8000"} - request.state = Mock(clean_path="/dashboard") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.ADMIN - - def test_detect_admin_context_nested_path(self): - """Test admin context detection with nested admin path.""" - request = Mock(spec=Request) - request.url = Mock(path="/admin/vendors/list") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/admin/vendors/list") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.ADMIN - - # ======================================================================== - # Vendor Dashboard Context Tests - # ======================================================================== - - def test_detect_vendor_dashboard_context(self): - """Test vendor dashboard context detection.""" - request = Mock(spec=Request) - request.url = Mock(path="/vendor/testvendor/dashboard") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/dashboard") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.VENDOR_DASHBOARD - - def test_detect_vendor_dashboard_context_direct_path(self): - """Test vendor dashboard with direct /vendor/ path.""" - request = Mock(spec=Request) - request.url = Mock(path="/vendor/settings") - request.headers = {"host": "testvendor.platform.com"} - request.state = Mock(clean_path="/vendor/settings") - - context = ContextManager.detect_context(request) - - assert context == RequestContext.VENDOR_DASHBOARD - - def test_not_detect_vendors_plural_as_dashboard(self): - """Test that /vendors/ path is not detected as vendor dashboard.""" - request = Mock(spec=Request) - request.url = Mock(path="/vendors/testvendor/shop") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/shop") - - # Should not be vendor dashboard - context = ContextManager.detect_context(request) - - assert context != RequestContext.VENDOR_DASHBOARD - - # ======================================================================== - # Shop Context Tests - # ======================================================================== - - def test_detect_shop_context_with_vendor_state(self): - """Test shop context detection when vendor exists in request state.""" - request = Mock(spec=Request) - request.url = Mock(path="/products") - request.headers = {"host": "testvendor.platform.com"} - mock_vendor = Mock() - mock_vendor.name = "Test Vendor" - request.state = Mock(clean_path="/products", vendor=mock_vendor) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.SHOP - - def test_detect_shop_context_from_shop_path(self): - """Test shop context detection from /shop/ path.""" - request = Mock(spec=Request) - request.url = Mock(path="/shop/products") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/shop/products", vendor=None) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.SHOP - - def test_detect_shop_context_custom_domain(self): - """Test shop context with custom domain and vendor.""" - request = Mock(spec=Request) - request.url = Mock(path="/products") - request.headers = {"host": "customdomain.com"} - mock_vendor = Mock(name="Custom Vendor") - request.state = Mock(clean_path="/products", vendor=mock_vendor) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.SHOP - - # ======================================================================== - # Fallback Context Tests - # ======================================================================== - - def test_detect_fallback_context(self): - """Test fallback context for unknown paths.""" - request = Mock(spec=Request) - request.url = Mock(path="/random/path") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/random/path", vendor=None) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.FALLBACK - - def test_detect_fallback_context_root(self): - """Test fallback context for root path.""" - request = Mock(spec=Request) - request.url = Mock(path="/") - request.headers = {"host": "platform.com"} - request.state = Mock(clean_path="/", vendor=None) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.FALLBACK - - def test_detect_fallback_context_no_vendor(self): - """Test fallback context when no vendor context exists.""" - request = Mock(spec=Request) - request.url = Mock(path="/about") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/about", vendor=None) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.FALLBACK - - # ======================================================================== - # Clean Path Tests - # ======================================================================== - - def test_uses_clean_path_when_available(self): - """Test that clean_path is used over original path.""" - request = Mock(spec=Request) - request.url = Mock(path="/vendor/testvendor/api/products") - request.headers = {"host": "localhost"} - # clean_path shows the rewritten path - request.state = Mock(clean_path="/api/products") - - context = ContextManager.detect_context(request) - - # Should detect as API based on clean_path, not original path - assert context == RequestContext.API - - def test_falls_back_to_original_path(self): - """Test falls back to original path when clean_path not set.""" - request = Mock(spec=Request) - request.url = Mock(path="/api/vendors") - request.headers = {"host": "localhost"} - request.state = Mock(spec=[]) # No clean_path attribute - - context = ContextManager.detect_context(request) - - assert context == RequestContext.API - - # ======================================================================== - # Priority Order Tests - # ======================================================================== - - def test_api_has_highest_priority(self): - """Test API context takes precedence over admin.""" - request = Mock(spec=Request) - request.url = Mock(path="/api/admin/users") - request.headers = {"host": "admin.platform.com"} - request.state = Mock(clean_path="/api/admin/users") - - context = ContextManager.detect_context(request) - - # API should win even though it's admin subdomain - assert context == RequestContext.API - - def test_admin_has_priority_over_shop(self): - """Test admin context takes precedence over shop.""" - request = Mock(spec=Request) - request.url = Mock(path="/admin/shops") - request.headers = {"host": "localhost"} - mock_vendor = Mock() - request.state = Mock(clean_path="/admin/shops", vendor=mock_vendor) - - context = ContextManager.detect_context(request) - - # Admin should win even though vendor exists - assert context == RequestContext.ADMIN - - def test_vendor_dashboard_priority_over_shop(self): - """Test vendor dashboard takes precedence over shop.""" - request = Mock(spec=Request) - request.url = Mock(path="/vendor/settings") - request.headers = {"host": "testvendor.platform.com"} - mock_vendor = Mock() - request.state = Mock(clean_path="/vendor/settings", vendor=mock_vendor) - - context = ContextManager.detect_context(request) - - assert context == RequestContext.VENDOR_DASHBOARD - - -@pytest.mark.unit -class TestContextManagerHelpers: - """Test suite for ContextManager helper methods.""" - - def test_is_admin_context_from_subdomain(self): - """Test _is_admin_context with admin subdomain.""" - request = Mock() - assert ( - ContextManager._is_admin_context( - request, "admin.platform.com", "/dashboard" - ) - is True - ) - - def test_is_admin_context_from_path(self): - """Test _is_admin_context with admin path.""" - request = Mock() - assert ( - ContextManager._is_admin_context(request, "localhost", "/admin/users") - is True - ) - - def test_is_admin_context_both(self): - """Test _is_admin_context with both subdomain and path.""" - request = Mock() - assert ( - ContextManager._is_admin_context( - request, "admin.platform.com", "/admin/users" - ) - is True - ) - - def test_is_not_admin_context(self): - """Test _is_admin_context returns False for non-admin.""" - request = Mock() - assert ( - ContextManager._is_admin_context(request, "vendor.platform.com", "/shop") - is False - ) - - def test_is_vendor_dashboard_context(self): - """Test _is_vendor_dashboard_context with /vendor/ path.""" - assert ContextManager._is_vendor_dashboard_context("/vendor/settings") is True - - def test_is_vendor_dashboard_context_nested(self): - """Test _is_vendor_dashboard_context with nested vendor path.""" - assert ( - ContextManager._is_vendor_dashboard_context("/vendor/products/list") is True - ) - - def test_is_not_vendor_dashboard_context_vendors_plural(self): - """Test _is_vendor_dashboard_context excludes /vendors/ path.""" - assert ( - ContextManager._is_vendor_dashboard_context("/vendors/shop123/products") - is False - ) - - def test_is_not_vendor_dashboard_context(self): - """Test _is_vendor_dashboard_context returns False for non-vendor paths.""" - assert ContextManager._is_vendor_dashboard_context("/shop/products") is False - - -@pytest.mark.unit -class TestContextMiddleware: - """Test suite for ContextMiddleware.""" - - @pytest.mark.asyncio - async def test_middleware_sets_context(self): - """Test middleware successfully sets context in request state.""" - middleware = ContextMiddleware(app=None) - - request = Mock(spec=Request) - request.url = Mock(path="/api/vendors") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/api/vendors", vendor=None) - - call_next = AsyncMock(return_value=Mock()) - - await middleware.dispatch(request, call_next) - - assert hasattr(request.state, "context_type") - assert request.state.context_type == RequestContext.API - call_next.assert_called_once_with(request) - - @pytest.mark.asyncio - async def test_middleware_sets_admin_context(self): - """Test middleware sets admin context.""" - middleware = ContextMiddleware(app=None) - - request = Mock(spec=Request) - request.url = Mock(path="/admin/dashboard") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/admin/dashboard") - - call_next = AsyncMock(return_value=Mock()) - - await middleware.dispatch(request, call_next) - - assert request.state.context_type == RequestContext.ADMIN - call_next.assert_called_once() - - @pytest.mark.asyncio - async def test_middleware_sets_vendor_dashboard_context(self): - """Test middleware sets vendor dashboard context.""" - middleware = ContextMiddleware(app=None) - - request = Mock(spec=Request) - request.url = Mock(path="/vendor/settings") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/vendor/settings") - - call_next = AsyncMock(return_value=Mock()) - - await middleware.dispatch(request, call_next) - - assert request.state.context_type == RequestContext.VENDOR_DASHBOARD - call_next.assert_called_once() - - @pytest.mark.asyncio - async def test_middleware_sets_shop_context(self): - """Test middleware sets shop context.""" - middleware = ContextMiddleware(app=None) - - request = Mock(spec=Request) - request.url = Mock(path="/products") - request.headers = {"host": "shop.platform.com"} - mock_vendor = Mock() - request.state = Mock(clean_path="/products", vendor=mock_vendor) - - call_next = AsyncMock(return_value=Mock()) - - await middleware.dispatch(request, call_next) - - assert request.state.context_type == RequestContext.SHOP - call_next.assert_called_once() - - @pytest.mark.asyncio - async def test_middleware_sets_fallback_context(self): - """Test middleware sets fallback context.""" - middleware = ContextMiddleware(app=None) - - request = Mock(spec=Request) - request.url = Mock(path="/random") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/random", vendor=None) - - call_next = AsyncMock(return_value=Mock()) - - await middleware.dispatch(request, call_next) - - assert request.state.context_type == RequestContext.FALLBACK - call_next.assert_called_once() - - @pytest.mark.asyncio - async def test_middleware_returns_response(self): - """Test middleware returns response from call_next.""" - middleware = ContextMiddleware(app=None) - - request = Mock(spec=Request) - request.url = Mock(path="/api/test") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/api/test") - - expected_response = Mock() - call_next = AsyncMock(return_value=expected_response) - - response = await middleware.dispatch(request, call_next) - - assert response is expected_response - - -@pytest.mark.unit -class TestGetRequestContextHelper: - """Test suite for get_request_context helper function.""" - - def test_get_request_context_exists(self): - """Test getting request context when it exists.""" - request = Mock(spec=Request) - request.state.context_type = RequestContext.API - - context = get_request_context(request) - - assert context == RequestContext.API - - def test_get_request_context_default(self): - """Test getting request context returns FALLBACK as default.""" - request = Mock(spec=Request) - request.state = Mock(spec=[]) # No context_type attribute - - context = get_request_context(request) - - assert context == RequestContext.FALLBACK - - def test_get_request_context_for_all_types(self): - """Test getting all context types.""" - for expected_context in RequestContext: - request = Mock(spec=Request) - request.state.context_type = expected_context + request.state = Mock() + request.state.frontend_type = None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) context = get_request_context(request) - assert context == expected_context + assert context == RequestContext.API + def test_get_request_context_deprecation_warning(self): + """Test get_request_context raises DeprecationWarning.""" + from app.modules.enums import FrontendType -@pytest.mark.unit -class TestEdgeCases: - """Test suite for edge cases and error scenarios.""" - - def test_detect_context_empty_path(self): - """Test context detection with empty path.""" request = Mock(spec=Request) - request.url = Mock(path="") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="", vendor=None) + request.url = Mock(path="/admin/dashboard") + request.state = Mock() + request.state.frontend_type = FrontendType.ADMIN - context = ContextManager.detect_context(request) + with pytest.warns(DeprecationWarning, match="get_request_context.*deprecated"): + get_request_context(request) - assert context == RequestContext.FALLBACK + def test_get_request_context_maps_admin(self): + """Test get_request_context maps FrontendType.ADMIN to RequestContext.ADMIN.""" + from app.modules.enums import FrontendType - def test_detect_context_missing_host(self): - """Test context detection with missing host header.""" request = Mock(spec=Request) - request.url = Mock(path="/shop/products") - request.headers = {} - request.state = Mock(clean_path="/shop/products", vendor=None) + request.url = Mock(path="/admin/dashboard") + request.state = Mock() + request.state.frontend_type = FrontendType.ADMIN - context = ContextManager.detect_context(request) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + context = get_request_context(request) + + assert context == RequestContext.ADMIN + + def test_get_request_context_maps_vendor(self): + """Test get_request_context maps FrontendType.VENDOR to RequestContext.VENDOR_DASHBOARD.""" + from app.modules.enums import FrontendType + + request = Mock(spec=Request) + request.url = Mock(path="/vendor/settings") + request.state = Mock() + request.state.frontend_type = FrontendType.VENDOR + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + context = get_request_context(request) + + assert context == RequestContext.VENDOR_DASHBOARD + + def test_get_request_context_maps_storefront(self): + """Test get_request_context maps FrontendType.STOREFRONT to RequestContext.SHOP.""" + from app.modules.enums import FrontendType + + request = Mock(spec=Request) + request.url = Mock(path="/storefront/products") + request.state = Mock() + request.state.frontend_type = FrontendType.STOREFRONT + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + context = get_request_context(request) assert context == RequestContext.SHOP - def test_detect_context_case_sensitivity(self): - """Test that context detection is case-sensitive for paths.""" + def test_get_request_context_maps_platform_to_fallback(self): + """Test get_request_context maps FrontendType.PLATFORM to RequestContext.FALLBACK.""" + from app.modules.enums import FrontendType + request = Mock(spec=Request) - request.url = Mock(path="/API/vendors") # Uppercase - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/API/vendors") + request.url = Mock(path="/pricing") + request.state = Mock() + request.state.frontend_type = FrontendType.PLATFORM - context = ContextManager.detect_context(request) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + context = get_request_context(request) - # Should NOT match /api/ because it's case-sensitive - assert context != RequestContext.API - - def test_detect_context_path_with_query_params(self): - """Test context detection handles path with query parameters.""" - request = Mock(spec=Request) - request.url = Mock(path="/api/vendors?page=1") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/api/vendors?page=1") - - # path.startswith should still work - context = ContextManager.detect_context(request) - - assert context == RequestContext.API - - def test_detect_context_admin_substring(self): - """Test that 'admin' substring doesn't trigger false positive.""" - request = Mock(spec=Request) - request.url = Mock(path="/administration/docs") - request.headers = {"host": "localhost"} - request.state = Mock(clean_path="/administration/docs") - - context = ContextManager.detect_context(request) - - # Should match because path starts with /admin - assert context == RequestContext.ADMIN - - def test_detect_context_no_state_attribute(self): - """Test context detection when request has no state.""" - request = Mock(spec=Request) - request.url = Mock(path="/api/vendors") - request.headers = {"host": "localhost"} - # No state attribute at all - delattr(request, "state") - - # Should still work, falling back to url.path - with pytest.raises(AttributeError): - # This will raise because we're trying to access request.state - ContextManager.detect_context(request) + assert context == RequestContext.FALLBACK diff --git a/tests/unit/middleware/test_frontend_type.py b/tests/unit/middleware/test_frontend_type.py new file mode 100644 index 00000000..0428932d --- /dev/null +++ b/tests/unit/middleware/test_frontend_type.py @@ -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