refactor: centralize frontend detection with FrontendDetector

Major architecture change to unify frontend detection:

## Problem Solved
- Eliminated code duplication across 3 middleware files
- Fixed incomplete path detection (now detects /api/v1/admin/*)
- Unified on FrontendType enum (deprecates RequestContext)
- Added request.state.frontend_type for all requests

## New Components
- app/core/frontend_detector.py: Centralized FrontendDetector class
- middleware/frontend_type.py: FrontendTypeMiddleware (replaces ContextMiddleware)
- docs/architecture/frontend-detection.md: Complete architecture documentation

## Changes
- main.py: Use FrontendTypeMiddleware instead of ContextMiddleware
- middleware/context.py: Deprecated (kept for backwards compatibility)
- middleware/platform_context.py: Use FrontendDetector.is_admin()
- middleware/vendor_context.py: Use FrontendDetector.is_admin()
- middleware/language.py: Use FrontendType instead of context_value
- app/exceptions/handler.py: Use FrontendType.STOREFRONT
- app/exceptions/error_renderer.py: Use FrontendType
- Customer routes: Cookie path changed from /shop to /storefront

## Documentation
- docs/architecture/frontend-detection.md: New comprehensive docs
- docs/architecture/middleware.md: Updated for new system
- docs/architecture/request-flow.md: Updated for FrontendType
- docs/backend/middleware-reference.md: Updated API reference

## Tests
- tests/unit/core/test_frontend_detector.py: 37 new tests
- tests/unit/middleware/test_frontend_type.py: 11 new tests
- tests/unit/middleware/test_context.py: Updated for compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 16:15:19 +01:00
parent e77535e2cd
commit b769f5a047
17 changed files with 1393 additions and 915 deletions

View File

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