feat: production routing support for subdomain and custom domain modes
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 45m18s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

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:
2026-02-26 00:15:06 +01:00
parent 6a82d7c12d
commit ce5b54f27b
36 changed files with 822 additions and 151 deletions

View 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

View 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