feat: production routing support for subdomain and custom domain modes
Some checks failed
Some checks failed
Double-mount store routes at /store/* and /store/{store_code}/* so the
same handlers work in dev path-based, prod path-based, prod subdomain,
and prod custom-domain modes. Wire StorePlatform.custom_subdomain into
StoreContextMiddleware for per-platform subdomain overrides. Add admin
custom-domain management UI, fix stale /shop/ reset link, add
/merchants/ to reserved paths, and server-render window.STORE_CODE for
JS that previously parsed the URL.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
119
tests/unit/api/test_get_resolved_store_code.py
Normal file
119
tests/unit/api/test_get_resolved_store_code.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# tests/unit/api/test_get_resolved_store_code.py
|
||||
"""
|
||||
Unit tests for get_resolved_store_code dependency.
|
||||
|
||||
Tests the store code resolution logic that supports both:
|
||||
- Path-based routing: /store/{store_code}/dashboard (store_code in URL)
|
||||
- Subdomain/custom domain: /store/dashboard (store resolved by middleware)
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.deps import get_resolved_store_code
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.stores
|
||||
class TestGetResolvedStoreCode:
|
||||
"""Test suite for get_resolved_store_code dependency."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_store_code_from_path_params(self):
|
||||
"""When store_code is in path params (path-based routing), return it."""
|
||||
request = Mock()
|
||||
request.path_params = {"store_code": "ACME"}
|
||||
request.state = MagicMock()
|
||||
|
||||
result = await get_resolved_store_code(request)
|
||||
|
||||
assert result == "ACME"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_store_code_from_middleware_state(self):
|
||||
"""When no path param but middleware resolved store, return store.store_code."""
|
||||
mock_store = Mock()
|
||||
mock_store.store_code = "ORION"
|
||||
|
||||
request = Mock()
|
||||
request.path_params = {}
|
||||
request.state = MagicMock()
|
||||
request.state.store = mock_store
|
||||
|
||||
result = await get_resolved_store_code(request)
|
||||
|
||||
assert result == "ORION"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_param_takes_priority_over_middleware(self):
|
||||
"""Path param should be preferred even if middleware also resolved a store."""
|
||||
mock_store = Mock()
|
||||
mock_store.store_code = "MIDDLEWARE_STORE"
|
||||
|
||||
request = Mock()
|
||||
request.path_params = {"store_code": "PATH_STORE"}
|
||||
request.state = MagicMock()
|
||||
request.state.store = mock_store
|
||||
|
||||
result = await get_resolved_store_code(request)
|
||||
|
||||
assert result == "PATH_STORE"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_404_when_no_store_found(self):
|
||||
"""When neither path param nor middleware provides store, raise 404."""
|
||||
request = Mock()
|
||||
request.path_params = {}
|
||||
request.state = MagicMock()
|
||||
request.state.store = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_resolved_store_code(request)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "Store not found" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_path_param_falls_through(self):
|
||||
"""Empty string path param should fall through to middleware."""
|
||||
mock_store = Mock()
|
||||
mock_store.store_code = "FALLBACK"
|
||||
|
||||
request = Mock()
|
||||
request.path_params = {"store_code": ""}
|
||||
request.state = MagicMock()
|
||||
request.state.store = mock_store
|
||||
|
||||
result = await get_resolved_store_code(request)
|
||||
|
||||
assert result == "FALLBACK"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_path_params_key_falls_through(self):
|
||||
"""When path_params doesn't contain store_code, fall through to middleware."""
|
||||
mock_store = Mock()
|
||||
mock_store.store_code = "WIDGET"
|
||||
|
||||
request = Mock()
|
||||
request.path_params = {"order_id": "123"}
|
||||
request.state = MagicMock()
|
||||
request.state.store = mock_store
|
||||
|
||||
result = await get_resolved_store_code(request)
|
||||
|
||||
assert result == "WIDGET"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_404_when_no_state_store_attribute(self):
|
||||
"""When request.state has no store attribute at all, raise 404."""
|
||||
request = Mock()
|
||||
request.path_params = {}
|
||||
# Simulate missing attribute
|
||||
request.state = MagicMock(spec=[])
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_resolved_store_code(request)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
221
tests/unit/middleware/test_store_context_custom_subdomain.py
Normal file
221
tests/unit/middleware/test_store_context_custom_subdomain.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# tests/unit/middleware/test_store_context_custom_subdomain.py
|
||||
"""
|
||||
Unit tests for StorePlatform.custom_subdomain resolution in StoreContextMiddleware.
|
||||
|
||||
Tests the platform-specific subdomain override that allows a single store
|
||||
to have different subdomains on different platforms:
|
||||
- acme.omsflow.lu → Store.subdomain="acme" (default, works for all platforms)
|
||||
- acme-rewards.rewardflow.lu → StorePlatform.custom_subdomain="acme-rewards" on Loyalty platform
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from middleware.store_context import StoreContextManager
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.stores
|
||||
class TestCustomSubdomainResolution:
|
||||
"""Test StorePlatform.custom_subdomain lookup in get_store_from_context."""
|
||||
|
||||
def test_custom_subdomain_found_returns_store(self):
|
||||
"""When custom_subdomain matches on the platform, return the store."""
|
||||
mock_db = Mock(spec=Session)
|
||||
mock_platform = Mock()
|
||||
mock_platform.id = 2
|
||||
mock_platform.code = "loyalty"
|
||||
|
||||
mock_store_platform = Mock()
|
||||
mock_store_platform.store_id = 42
|
||||
|
||||
mock_store = Mock()
|
||||
mock_store.id = 42
|
||||
mock_store.is_active = True
|
||||
mock_store.name = "Acme Corp"
|
||||
|
||||
# First query: StorePlatform lookup (custom_subdomain match)
|
||||
# Second query: Store lookup by id
|
||||
call_count = [0]
|
||||
|
||||
def side_effect_query(model):
|
||||
result = MagicMock()
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# StorePlatform query
|
||||
result.filter.return_value.first.return_value = mock_store_platform
|
||||
else:
|
||||
# Store query
|
||||
result.filter.return_value.first.return_value = mock_store
|
||||
return result
|
||||
|
||||
mock_db.query.side_effect = side_effect_query
|
||||
|
||||
context = {
|
||||
"detection_method": "subdomain",
|
||||
"subdomain": "acme-rewards",
|
||||
"_platform": mock_platform,
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||
|
||||
assert store is mock_store
|
||||
|
||||
def test_custom_subdomain_not_found_falls_back_to_store_subdomain(self):
|
||||
"""When custom_subdomain doesn't match, fall back to Store.subdomain."""
|
||||
mock_db = Mock(spec=Session)
|
||||
mock_platform = Mock()
|
||||
mock_platform.id = 2
|
||||
mock_platform.code = "loyalty"
|
||||
|
||||
mock_store = Mock()
|
||||
mock_store.is_active = True
|
||||
mock_store.name = "Acme Corp"
|
||||
|
||||
# Query sequence:
|
||||
# 1. StorePlatform query → None (no custom_subdomain match)
|
||||
# 2. Store query → mock_store (subdomain match)
|
||||
call_count = [0]
|
||||
|
||||
def side_effect_query(model):
|
||||
result = MagicMock()
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# StorePlatform query → no match
|
||||
result.filter.return_value.first.return_value = None
|
||||
else:
|
||||
# Store.subdomain fallback
|
||||
result.filter.return_value.filter.return_value.first.return_value = mock_store
|
||||
return result
|
||||
|
||||
mock_db.query.side_effect = side_effect_query
|
||||
|
||||
context = {
|
||||
"detection_method": "subdomain",
|
||||
"subdomain": "acme",
|
||||
"_platform": mock_platform,
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||
|
||||
assert store is mock_store
|
||||
|
||||
def test_no_platform_skips_custom_subdomain_lookup(self):
|
||||
"""When no platform in context, skip custom_subdomain and use Store.subdomain."""
|
||||
mock_db = Mock(spec=Session)
|
||||
mock_store = Mock()
|
||||
mock_store.is_active = True
|
||||
|
||||
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_store
|
||||
|
||||
context = {
|
||||
"detection_method": "subdomain",
|
||||
"subdomain": "acme",
|
||||
# No "_platform" key
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||
|
||||
assert store is mock_store
|
||||
|
||||
def test_path_based_detection_skips_custom_subdomain(self):
|
||||
"""Path-based detection should not check custom_subdomain (only subdomain mode)."""
|
||||
mock_db = Mock(spec=Session)
|
||||
mock_platform = Mock()
|
||||
mock_platform.id = 1
|
||||
|
||||
mock_store = Mock()
|
||||
mock_store.is_active = True
|
||||
|
||||
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_store
|
||||
|
||||
context = {
|
||||
"detection_method": "path",
|
||||
"subdomain": "acme",
|
||||
"_platform": mock_platform,
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||
|
||||
assert store is mock_store
|
||||
|
||||
def test_custom_subdomain_store_inactive_returns_none(self):
|
||||
"""When custom_subdomain matches but the store is inactive, fall back."""
|
||||
mock_db = Mock(spec=Session)
|
||||
mock_platform = Mock()
|
||||
mock_platform.id = 2
|
||||
mock_platform.code = "loyalty"
|
||||
|
||||
mock_store_platform = Mock()
|
||||
mock_store_platform.store_id = 42
|
||||
|
||||
# Query sequence:
|
||||
# 1. StorePlatform → match
|
||||
# 2. Store by id → None (inactive)
|
||||
# 3. Store by subdomain → None (fallback also fails)
|
||||
call_count = [0]
|
||||
|
||||
def side_effect_query(model):
|
||||
result = MagicMock()
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# StorePlatform query → match
|
||||
result.filter.return_value.first.return_value = mock_store_platform
|
||||
elif call_count[0] == 2:
|
||||
# Store by id → None (inactive, filtered out)
|
||||
result.filter.return_value.first.return_value = None
|
||||
else:
|
||||
# Store.subdomain fallback → None
|
||||
result.filter.return_value.filter.return_value.first.return_value = None
|
||||
return result
|
||||
|
||||
mock_db.query.side_effect = side_effect_query
|
||||
|
||||
context = {
|
||||
"detection_method": "subdomain",
|
||||
"subdomain": "acme-rewards",
|
||||
"_platform": mock_platform,
|
||||
}
|
||||
|
||||
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||
|
||||
assert store is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.middleware
|
||||
class TestPlatformInjectionIntoStoreContext:
|
||||
"""Test that platform is properly injected into store_context for subdomain lookup."""
|
||||
|
||||
def test_platform_injected_into_context_when_available(self):
|
||||
"""StoreContextMiddleware should inject platform into store_context."""
|
||||
# This tests the dispatch method's platform injection logic
|
||||
# We verify that when request.state.platform is set, it gets added
|
||||
# to the store_context dict before get_store_from_context is called
|
||||
|
||||
mock_platform = Mock()
|
||||
mock_platform.id = 1
|
||||
mock_platform.code = "oms"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.state = MagicMock()
|
||||
mock_request.state.platform = mock_platform
|
||||
mock_request.state.platform_clean_path = "/store/login"
|
||||
mock_request.headers = {"host": "acme.omsflow.lu"}
|
||||
mock_request.url = Mock(path="/store/login")
|
||||
|
||||
with patch("middleware.store_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "omsflow.lu"
|
||||
|
||||
context = StoreContextManager.detect_store_context(mock_request)
|
||||
|
||||
assert context is not None
|
||||
assert context["detection_method"] == "subdomain"
|
||||
assert context["subdomain"] == "acme"
|
||||
|
||||
# The middleware dispatch adds _platform after detect_store_context
|
||||
# Verify the context is suitable for platform injection
|
||||
context["_platform"] = mock_platform
|
||||
assert context.get("_platform") is mock_platform
|
||||
Reference in New Issue
Block a user