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

@@ -39,7 +39,7 @@ The cookie path restrictions prevent cross-context cookie leakage:
import logging
from datetime import UTC
from fastapi import Cookie, Depends, Request
from fastapi import Cookie, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@@ -73,6 +73,19 @@ logger = logging.getLogger(__name__)
# ============================================================================
async def get_resolved_store_code(request: Request) -> str:
"""Get store code from path parameter (path-based) or middleware (subdomain/custom domain)."""
# Path parameter from double-mount prefix (/store/{store_code}/...)
store_code = request.path_params.get("store_code")
if store_code:
return store_code
# Middleware-resolved store (subdomain or custom domain)
store = getattr(request.state, "store", None)
if store:
return store.store_code
raise HTTPException(status_code=404, detail="Store not found")
def _get_token_from_request(
credentials: HTTPAuthorizationCredentials | None,
cookie_value: str | None,

View File

@@ -7,11 +7,15 @@ Store pages for analytics dashboard.
import logging
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.services.platform_settings_service import (
platform_settings_service, # MOD-004 - shared platform service
)
@@ -73,11 +77,11 @@ def get_store_context(
@router.get(
"/{store_code}/analytics", response_class=HTMLResponse, include_in_schema=False
"/analytics", response_class=HTMLResponse, include_in_schema=False
)
async def store_analytics_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -7,11 +7,15 @@ Store pages for billing management:
- Invoices
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -25,11 +29,11 @@ router = APIRouter()
@router.get(
"/{store_code}/billing", response_class=HTMLResponse, include_in_schema=False
"/billing", response_class=HTMLResponse, include_in_schema=False
)
async def store_billing_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -44,11 +48,11 @@ async def store_billing_page(
@router.get(
"/{store_code}/invoices", response_class=HTMLResponse, include_in_schema=False
"/invoices", response_class=HTMLResponse, include_in_schema=False
)
async def store_invoices_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -178,10 +178,11 @@
},
/**
* Get store code from URL
* Get store code from server-rendered value or URL fallback
* @returns {string|null}
*/
getStoreCode() {
if (window.STORE_CODE) return window.STORE_CODE;
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
// Direct: /store/{code}/...

View File

@@ -139,9 +139,10 @@
},
/**
* Get store code from URL
* Get store code from server-rendered value or URL fallback
*/
getStoreCode() {
if (window.STORE_CODE) return window.STORE_CODE;
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'store' && segments[1]) {

View File

@@ -7,11 +7,15 @@ Store pages for product management:
- Product create
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -25,11 +29,11 @@ router = APIRouter()
@router.get(
"/{store_code}/products", response_class=HTMLResponse, include_in_schema=False
"/products", response_class=HTMLResponse, include_in_schema=False
)
async def store_products_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -44,13 +48,13 @@ async def store_products_page(
@router.get(
"/{store_code}/products/create",
"/products/create",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_product_create_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -11,7 +11,11 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.cms.services import content_page_service
from app.modules.core.services.platform_settings_service import (
platform_settings_service, # MOD-004 - shared platform service
@@ -83,11 +87,11 @@ def get_store_context(
@router.get(
"/{store_code}/content-pages", response_class=HTMLResponse, include_in_schema=False
"/content-pages", response_class=HTMLResponse, include_in_schema=False
)
async def store_content_pages_list(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -102,13 +106,13 @@ async def store_content_pages_list(
@router.get(
"/{store_code}/content-pages/create",
"/content-pages/create",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_content_page_create(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -122,13 +126,13 @@ async def store_content_page_create(
@router.get(
"/{store_code}/content-pages/{page_id}/edit",
"/content-pages/{page_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_content_page_edit(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
page_id: int = Path(..., description="Content page ID"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
@@ -148,11 +152,11 @@ async def store_content_page_edit(
@router.get(
"/{store_code}/{slug}", response_class=HTMLResponse, include_in_schema=False
"/{slug}", response_class=HTMLResponse, include_in_schema=False
)
async def store_content_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
slug: str = Path(..., description="Content page slug"),
db: Session = Depends(get_db),
):

View File

@@ -8,11 +8,15 @@ Store pages for core functionality:
- Notifications
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -26,11 +30,11 @@ router = APIRouter()
@router.get(
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
"/dashboard", response_class=HTMLResponse, include_in_schema=False
)
async def store_dashboard_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -55,11 +59,11 @@ async def store_dashboard_page(
@router.get(
"/{store_code}/media", response_class=HTMLResponse, include_in_schema=False
"/media", response_class=HTMLResponse, include_in_schema=False
)
async def store_media_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -79,13 +83,13 @@ async def store_media_page(
@router.get(
"/{store_code}/notifications",
"/notifications",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_notifications_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -74,8 +74,9 @@ function data() {
? segments[segments.length - 2]
: last;
// Get store code from URL
if (segments[0] === 'store' && segments[1]) {
// Get store code from server-rendered value or URL fallback
this.storeCode = window.STORE_CODE || null;
if (!this.storeCode && segments[0] === 'store' && segments[1]) {
this.storeCode = segments[1];
}
@@ -281,14 +282,18 @@ function emailSettingsWarning() {
storeCode: null,
async init() {
// Get store code from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'store' && segments[1]) {
this.storeCode = segments[1];
// Get store code from server-rendered value or URL fallback
this.storeCode = window.STORE_CODE || null;
if (!this.storeCode) {
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'store' && segments[1]) {
this.storeCode = segments[1];
}
}
// Skip if we're on the settings page (to avoid showing banner on config page)
const path = window.location.pathname;
if (path.includes('/settings')) {
this.loading = false;
return;

View File

@@ -262,7 +262,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
scheme = "https" if should_use_secure_cookies() else "http"
host = request.headers.get("host", "localhost")
reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}"
reset_link = f"{scheme}://{host}/account/reset-password?token={plaintext_token}"
email_service = EmailService(db)
email_service.send_template(

View File

@@ -6,11 +6,15 @@ Store pages for customer management:
- Customers list
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -24,11 +28,11 @@ router = APIRouter()
@router.get(
"/{store_code}/customers", response_class=HTMLResponse, include_in_schema=False
"/customers", response_class=HTMLResponse, include_in_schema=False
)
async def store_customers_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -6,11 +6,15 @@ Store pages for inventory management:
- Inventory list
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -24,11 +28,11 @@ router = APIRouter()
@router.get(
"/{store_code}/inventory", response_class=HTMLResponse, include_in_schema=False
"/inventory", response_class=HTMLResponse, include_in_schema=False
)
async def store_inventory_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -8,7 +8,7 @@ Store pages for:
- Program settings
- Stats dashboard
Routes follow the standard store convention: /{store_code}/loyalty/...
Routes follow the standard store convention: /loyalty/...
so they match the menu URLs in definition.py.
"""
@@ -18,7 +18,11 @@ from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.services.platform_settings_service import (
platform_settings_service,
)
@@ -86,12 +90,12 @@ def get_store_context(
@router.get(
"/{store_code}/loyalty",
"/loyalty",
response_class=RedirectResponse,
include_in_schema=False,
)
async def store_loyalty_root(
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
):
"""
@@ -110,13 +114,13 @@ async def store_loyalty_root(
@router.get(
"/{store_code}/loyalty/terminal",
"/loyalty/terminal",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_terminal(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -136,13 +140,13 @@ async def store_loyalty_terminal(
@router.get(
"/{store_code}/loyalty/cards",
"/loyalty/cards",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_cards(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -157,13 +161,13 @@ async def store_loyalty_cards(
@router.get(
"/{store_code}/loyalty/cards/{card_id}",
"/loyalty/cards/{card_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_card_detail(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
card_id: int = Path(..., description="Loyalty card ID"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
@@ -184,13 +188,13 @@ async def store_loyalty_card_detail(
@router.get(
"/{store_code}/loyalty/stats",
"/loyalty/stats",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_stats(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -210,13 +214,13 @@ async def store_loyalty_stats(
@router.get(
"/{store_code}/loyalty/enroll",
"/loyalty/enroll",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_enroll(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -8,11 +8,15 @@ Store pages for marketplace management:
- Letzshop integration
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.marketplace.services.onboarding_service import OnboardingService
from app.modules.service import module_service
@@ -29,11 +33,11 @@ router = APIRouter()
@router.get(
"/{store_code}/onboarding", response_class=HTMLResponse, include_in_schema=False
"/onboarding", response_class=HTMLResponse, include_in_schema=False
)
async def store_onboarding_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -80,11 +84,11 @@ async def store_onboarding_page(
@router.get(
"/{store_code}/marketplace", response_class=HTMLResponse, include_in_schema=False
"/marketplace", response_class=HTMLResponse, include_in_schema=False
)
async def store_marketplace_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -112,11 +116,11 @@ async def store_marketplace_page(
@router.get(
"/{store_code}/letzshop", response_class=HTMLResponse, include_in_schema=False
"/letzshop", response_class=HTMLResponse, include_in_schema=False
)
async def store_letzshop_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -615,10 +615,10 @@ function storeOnboarding(initialLang = 'en') {
async handleLogout() {
onboardingLog.info('Logging out from onboarding...');
// Get store code from URL
// Get store code from server-rendered value or URL fallback
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
const storeCode = segments[0] === 'store' && segments[1] ? segments[1] : '';
const storeCode = window.STORE_CODE || (segments[0] === 'store' && segments[1] ? segments[1] : '');
try {
// Call logout API

View File

@@ -12,7 +12,11 @@ from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -26,11 +30,11 @@ router = APIRouter()
@router.get(
"/{store_code}/messages", response_class=HTMLResponse, include_in_schema=False
"/messages", response_class=HTMLResponse, include_in_schema=False
)
async def store_messages_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -45,13 +49,13 @@ async def store_messages_page(
@router.get(
"/{store_code}/messages/{conversation_id}",
"/messages/{conversation_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_message_detail_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
conversation_id: int = Path(..., description="Conversation ID"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
@@ -74,13 +78,13 @@ async def store_message_detail_page(
@router.get(
"/{store_code}/email-templates",
"/email-templates",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_email_templates_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -11,7 +11,11 @@ from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.api.deps import (
get_current_store_from_cookie_or_header,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -25,11 +29,11 @@ router = APIRouter()
@router.get(
"/{store_code}/orders", response_class=HTMLResponse, include_in_schema=False
"/orders", response_class=HTMLResponse, include_in_schema=False
)
async def store_orders_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -44,13 +48,13 @@ async def store_orders_page(
@router.get(
"/{store_code}/orders/{order_id}",
"/orders/{order_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_order_detail_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
order_id: int = Path(..., description="Order ID"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),

View File

@@ -10,7 +10,7 @@ Store pages for authentication and account management:
- Settings
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
@@ -18,6 +18,7 @@ from app.api.deps import (
get_current_store_from_cookie_or_header,
get_current_store_optional,
get_db,
get_resolved_store_code,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
@@ -31,24 +32,13 @@ router = APIRouter()
# ============================================================================
@router.get("/{store_code}", response_class=RedirectResponse, include_in_schema=False)
async def store_root_no_slash(store_code: str = Path(..., description="Store code")):
"""
Redirect /store/{code} (no trailing slash) to login page.
Handles requests without trailing slash.
"""
return RedirectResponse(url=f"/store/{store_code}/login", status_code=302)
@router.get(
"/{store_code}/", response_class=RedirectResponse, include_in_schema=False
)
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def store_root(
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User | None = Depends(get_current_store_optional),
):
"""
Redirect /store/{code}/ based on authentication status.
Redirect /store/ or /store/{code}/ based on authentication status.
- Authenticated store users -> /store/{code}/dashboard
- Unauthenticated users -> /store/{code}/login
@@ -62,11 +52,11 @@ async def store_root(
@router.get(
"/{store_code}/login", response_class=HTMLResponse, include_in_schema=False
"/login", response_class=HTMLResponse, include_in_schema=False
)
async def store_login_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User | None = Depends(get_current_store_optional),
):
"""
@@ -74,11 +64,6 @@ async def store_login_page(
If user is already authenticated as store, redirect to dashboard.
Otherwise, show login form.
JavaScript will:
- Load store info via API
- Handle login form submission
- Redirect to dashboard on success
"""
if current_user:
return RedirectResponse(
@@ -100,11 +85,11 @@ async def store_login_page(
@router.get(
"/{store_code}/team", response_class=HTMLResponse, include_in_schema=False
"/team", response_class=HTMLResponse, include_in_schema=False
)
async def store_team_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -119,11 +104,11 @@ async def store_team_page(
@router.get(
"/{store_code}/profile", response_class=HTMLResponse, include_in_schema=False
"/profile", response_class=HTMLResponse, include_in_schema=False
)
async def store_profile_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
@@ -138,11 +123,11 @@ async def store_profile_page(
@router.get(
"/{store_code}/settings", response_class=HTMLResponse, include_in_schema=False
"/settings", response_class=HTMLResponse, include_in_schema=False
)
async def store_settings_page(
request: Request,
store_code: str = Path(..., description="Store code"),
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):

View File

@@ -20,6 +20,13 @@ function adminStoreDetail() {
showDeleteStoreModal: false,
showDeleteStoreFinalModal: false,
// Domain management state
domains: [],
domainsLoading: false,
showAddDomainForm: false,
domainSaving: false,
newDomain: { domain: '', platform_id: '' },
// Initialize
async init() {
// Load i18n translations
@@ -42,9 +49,12 @@ function adminStoreDetail() {
this.storeCode = match[1];
detailLog.info('Viewing store:', this.storeCode);
await this.loadStore();
// Load subscription after store is loaded
// Load subscription and domains after store is loaded
if (this.store?.id) {
await this.loadSubscriptions();
await Promise.all([
this.loadSubscriptions(),
this.loadDomains(),
]);
}
} else {
detailLog.error('No store code in URL');
@@ -180,6 +190,90 @@ function adminStoreDetail() {
}
},
// ====================================================================
// DOMAIN MANAGEMENT
// ====================================================================
async loadDomains() {
if (!this.store?.id) return;
this.domainsLoading = true;
try {
const url = `/admin/stores/${this.store.id}/domains`;
const response = await apiClient.get(url);
this.domains = response.domains || [];
detailLog.info('Domains loaded:', this.domains.length);
} catch (error) {
if (error.status === 404) {
this.domains = [];
} else {
detailLog.warn('Failed to load domains:', error.message);
}
} finally {
this.domainsLoading = false;
}
},
async addDomain() {
if (!this.newDomain.domain || this.domainSaving) return;
this.domainSaving = true;
try {
const payload = { domain: this.newDomain.domain };
if (this.newDomain.platform_id) {
payload.platform_id = parseInt(this.newDomain.platform_id);
}
await apiClient.post(`/admin/stores/${this.store.id}/domains`, payload);
Utils.showToast('Domain added successfully', 'success');
this.showAddDomainForm = false;
this.newDomain = { domain: '', platform_id: '' };
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to add domain', 'error');
} finally {
this.domainSaving = false;
}
},
async verifyDomain(domainId) {
try {
const response = await apiClient.post(`/admin/stores/domains/${domainId}/verify`);
Utils.showToast(response.message || 'Domain verified!', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Verification failed — check DNS records', 'error');
}
},
async toggleDomainActive(domainId, activate) {
try {
await apiClient.put(`/admin/stores/domains/${domainId}`, { is_active: activate });
Utils.showToast(activate ? 'Domain activated' : 'Domain deactivated', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to update domain', 'error');
}
},
async setDomainPrimary(domainId) {
try {
await apiClient.put(`/admin/stores/domains/${domainId}`, { is_primary: true });
Utils.showToast('Domain set as primary', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to set primary domain', 'error');
}
},
async deleteDomain(domainId, domainName) {
if (!confirm(`Delete domain "${domainName}"? This cannot be undone.`)) return;
try {
await apiClient.delete(`/admin/stores/domains/${domainId}`);
Utils.showToast('Domain deleted', 'success');
await this.loadDomains();
} catch (error) {
Utils.showToast(error.message || 'Failed to delete domain', 'error');
}
},
// Refresh store data
async refresh() {
detailLog.info('=== STORE REFRESH TRIGGERED ===');

View File

@@ -356,6 +356,142 @@
</div>
</div>
<!-- Custom Domains -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Custom Domains
</h3>
<button
@click="showAddDomainForm = !showAddDomainForm"
class="flex items-center px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Add Domain
</button>
</div>
<!-- Add Domain Form -->
<div x-show="showAddDomainForm" x-transition class="mb-4 p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Domain</label>
<input
type="text"
x-model="newDomain.domain"
placeholder="shop.example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" />
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Platform</label>
<select
x-model="newDomain.platform_id"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Select platform...</option>
<template x-for="entry in subscriptions" :key="entry.subscription?.id">
<option :value="entry.platform_id" x-text="entry.platform_name"></option>
</template>
</select>
</div>
</div>
<div class="flex justify-end gap-2 mt-3">
<button
@click="showAddDomainForm = false; newDomain = {domain: '', platform_id: ''}"
class="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500">
Cancel
</button>
<button
@click="addDomain()"
:disabled="!newDomain.domain || domainSaving"
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!domainSaving">Add Domain</span>
<span x-show="domainSaving">Adding...</span>
</button>
</div>
</div>
<!-- Domains Loading -->
<div x-show="domainsLoading" class="text-center py-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading domains...</p>
</div>
<!-- Domains List -->
<div x-show="!domainsLoading && domains.length > 0" class="space-y-3">
<template x-for="domain in domains" :key="domain.id">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700 flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate" x-text="domain.domain"></span>
<span x-show="domain.is_primary"
class="px-1.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
Primary
</span>
<span :class="domain.is_verified
? 'text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300'
: 'text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300'"
class="px-1.5 py-0.5 text-xs font-medium rounded-full"
x-text="domain.is_verified ? 'Verified' : 'Unverified'">
</span>
<span :class="domain.is_active
? 'text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300'
: 'text-red-800 bg-red-100 dark:bg-red-900 dark:text-red-300'"
class="px-1.5 py-0.5 text-xs font-medium rounded-full"
x-text="domain.is_active ? 'Active' : 'Inactive'">
</span>
<span x-show="domain.ssl_status === 'active'"
class="px-1.5 py-0.5 text-xs font-medium text-green-800 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-300">
SSL
</span>
</div>
<!-- Verification instructions (shown when unverified) -->
<div x-show="!domain.is_verified && domain.verification_token" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Add TXT record: <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">_orion-verify.<span x-text="domain.domain"></span></code>
with value <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs" x-text="domain.verification_token"></code>
</div>
</div>
<div class="flex items-center gap-1 ml-3 flex-shrink-0">
<!-- Verify Button -->
<button
x-show="!domain.is_verified"
@click="verifyDomain(domain.id)"
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
title="Verify DNS">
<span x-html="$icon('badge-check', 'w-4 h-4')"></span>
</button>
<!-- Toggle Active -->
<button
x-show="domain.is_verified"
@click="toggleDomainActive(domain.id, !domain.is_active)"
:class="domain.is_active ? 'text-yellow-600 hover:text-yellow-700' : 'text-green-600 hover:text-green-700'"
class="px-2 py-1 text-xs font-medium"
:title="domain.is_active ? 'Deactivate' : 'Activate'">
<span x-html="$icon(domain.is_active ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
</button>
<!-- Set Primary -->
<button
x-show="domain.is_verified && !domain.is_primary"
@click="setDomainPrimary(domain.id)"
class="px-2 py-1 text-xs font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400"
title="Set as primary">
<span x-html="$icon('star', 'w-4 h-4')"></span>
</button>
<!-- Delete -->
<button
@click="deleteDomain(domain.id, domain.domain)"
class="px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
title="Delete domain">
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</div>
</template>
</div>
<!-- No Domains -->
<div x-show="!domainsLoading && domains.length === 0" class="text-center py-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No custom domains configured.</p>
</div>
</div>
<!-- More Actions -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">

View File

@@ -56,6 +56,7 @@
<!-- 1.5: Store Configuration (resolved via PlatformSettingsService) -->
<script>
window.STORE_CODE = '{{ store_code | default("") }}';
window.STORE_CONFIG = {
locale: '{{ storefront_locale }}',
currency: '{{ storefront_currency }}',

View File

@@ -15,7 +15,7 @@
<div class="action-buttons">
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
</div>
{% if show_debug %}
@@ -39,6 +39,6 @@
{% endif %}
<div class="support-link">
Need help? <a href="/store/support">Contact Support</a>
Need help? <a href="/store/{{ store_code }}/support">Contact Support</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -14,8 +14,8 @@
<div class="error-code">Error Code: {{ error_code }}</div>
<div class="action-buttons">
<a href="/store/login" class="btn btn-primary">Log In</a>
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
<a href="/store/{{ store_code }}/login" class="btn btn-primary">Log In</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
</div>
{% if show_debug %}
@@ -39,6 +39,6 @@
{% endif %}
<div class="support-link">
Having login issues? <a href="/store/support">Contact Support</a>
Having login issues? <a href="/store/{{ store_code }}/support">Contact Support</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -14,7 +14,7 @@
<div class="error-code">Error Code: {{ error_code }}</div>
<div class="action-buttons">
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
@@ -39,6 +39,6 @@
{% endif %}
<div class="support-link">
Need elevated permissions? <a href="/store/support">Contact Your Manager</a>
Need elevated permissions? <a href="/store/{{ store_code }}/support">Contact Your Manager</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -14,7 +14,7 @@
<div class="error-code">Error Code: {{ error_code }}</div>
<div class="action-buttons">
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
@@ -39,6 +39,6 @@
{% endif %}
<div class="support-link">
Can't find what you're looking for? <a href="/store/support">Contact Support</a>
Can't find what you're looking for? <a href="/store/{{ store_code }}/support">Contact Support</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -29,7 +29,7 @@
<div class="action-buttons">
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
</div>
{% if show_debug %}
@@ -53,6 +53,6 @@
{% endif %}
<div class="support-link">
Still having issues? <a href="/store/support">Contact Support</a>
Still having issues? <a href="/store/{{ store_code }}/support">Contact Support</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -23,7 +23,7 @@
<div class="action-buttons">
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
</div>
{% if show_debug %}
@@ -47,6 +47,6 @@
{% endif %}
<div class="support-link">
Experiencing persistent rate limits? <a href="/store/support">Contact Support</a>
Experiencing persistent rate limits? <a href="/store/{{ store_code }}/support">Contact Support</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -14,7 +14,7 @@
<div class="error-code">Error Code: {{ error_code }}</div>
<div class="action-buttons">
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="javascript:location.reload()" class="btn btn-secondary">Retry</a>
</div>
@@ -39,6 +39,6 @@
{% endif %}
<div class="support-link">
Issue persisting? <a href="/store/support">Report this error</a>
Issue persisting? <a href="/store/{{ store_code }}/support">Report this error</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -15,7 +15,7 @@
<div class="action-buttons">
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
</div>
{% if show_debug %}
@@ -39,6 +39,6 @@
{% endif %}
<div class="support-link">
Service unavailable for extended period? <a href="/store/support">Check Status</a>
Service unavailable for extended period? <a href="/store/{{ store_code }}/support">Check Status</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -185,7 +185,7 @@
<div class="action-buttons">
{% block action_buttons %}
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
{% endblock %}
</div>
@@ -214,10 +214,10 @@
<div class="support-link">
{% block support_link %}
Need help? <a href="/store/support">Contact Store Support</a>
Need help? <a href="/store/{{ store_code }}/support">Contact Store Support</a>
{% endblock %}
</div>
{% endblock %}
</div>
</body>
</html>
</html>

View File

@@ -12,7 +12,7 @@
<div class="error-code">Error Code: {{ error_code }}</div>
<div class="action-buttons">
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
@@ -37,6 +37,6 @@
{% endif %}
<div class="support-link">
Need assistance? <a href="/store/support">Contact Support</a>
Need assistance? <a href="/store/{{ store_code }}/support">Contact Support</a>
</div>
{% endblock %}
{% endblock %}

18
main.py
View File

@@ -417,13 +417,18 @@ for route_info in merchant_page_routes:
# =============================================================================
# STORE PAGES
# =============================================================================
# Store dashboard pages - Register at TWO prefixes:
# 1. /store/* (for prod: subdomain/custom domain, after path rewrite by middleware)
# 2. /store/{store_code}/* (for dev: path-based, after /platforms/{code}/ strip)
logger.info("Auto-discovering store page routes...")
store_page_routes = get_store_page_routes()
logger.info(f" Found {len(store_page_routes)} store page route modules")
# Mount 1: /store/* (production subdomain/custom domain — store resolved by middleware)
logger.info(" Registering store routes at /store/*")
for route_info in store_page_routes:
prefix = f"/store{route_info.custom_prefix}" if route_info.custom_prefix else "/store"
logger.info(f" Registering {route_info.module_code} store pages at {prefix} (priority={route_info.priority})")
logger.info(f" - {route_info.module_code} at {prefix} (priority={route_info.priority})")
app.include_router(
route_info.router,
prefix=prefix,
@@ -431,6 +436,17 @@ for route_info in store_page_routes:
include_in_schema=route_info.include_in_schema,
)
# Mount 2: /store/{store_code}/* (dev path-based + production path-based demo/trial)
logger.info(" Registering store routes at /store/{store_code}/*")
for route_info in store_page_routes:
prefix = f"/store/{{store_code}}{route_info.custom_prefix}" if route_info.custom_prefix else "/store/{store_code}"
app.include_router(
route_info.router,
prefix=prefix,
tags=route_info.tags,
include_in_schema=False,
)
# =============================================================================
# STOREFRONT PAGES (Customer Shop)
# =============================================================================

View File

@@ -410,7 +410,7 @@ class PlatformContextMiddleware:
_RESERVED = (
"/store/", "/admin/", "/api/", "/static/",
"/storefront/", "/health", "/docs", "/redoc",
"/media/", "/assets/",
"/media/", "/assets/", "/merchants/",
)
if not any(clean_path.startswith(p) for p in _RESERVED):
new_path = "/storefront" + clean_path

View File

@@ -185,6 +185,35 @@ class StoreContextManager:
# Method 2 & 3: Subdomain or path-based lookup
if "subdomain" in context:
subdomain = context["subdomain"]
# 2a. Check StorePlatform.custom_subdomain (platform-specific override)
# e.g. acme-rewards.rewardflow.lu → StorePlatform with custom_subdomain="acme-rewards"
platform = context.get("_platform")
if platform and context.get("detection_method") == "subdomain":
from app.modules.tenancy.models.store_platform import StorePlatform
store_platform = (
db.query(StorePlatform)
.filter(
func.lower(StorePlatform.custom_subdomain) == subdomain.lower(),
StorePlatform.platform_id == platform.id,
StorePlatform.is_active.is_(True),
)
.first()
)
if store_platform:
store = (
db.query(Store)
.filter(Store.id == store_platform.store_id, Store.is_active.is_(True))
.first()
)
if store:
logger.info(
f"[OK] Store found via custom_subdomain: {subdomain}{store.name} (platform={platform.code})"
)
return store
# 2b. Fallback to Store.subdomain (global default)
store = (
db.query(Store)
.filter(func.lower(Store.subdomain) == subdomain.lower())
@@ -493,6 +522,12 @@ class StoreContextMiddleware(BaseHTTPMiddleware):
store_context = StoreContextManager.detect_store_context(request)
if store_context:
# Pass platform from middleware state so subdomain lookup can check
# StorePlatform.custom_subdomain for platform-specific overrides
platform = getattr(request.state, "platform", None)
if platform:
store_context["_platform"] = platform
db_gen = get_db()
db = next(db_gen)
try:

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