feat: dynamic merchant sidebar with module-driven menus

Replace the hardcoded merchant sidebar with a dynamic menu system driven
by module definitions, matching the existing admin frontend pattern.
Modules declare FrontendType.MERCHANT menus in their definition.py, and
a new API endpoint unions enabled modules across all platforms the
merchant is subscribed to — so loyalty only appears when enabled.

- Add MERCHANT menu definitions to core, billing, tenancy, loyalty modules
- Extend MenuDiscoveryService with enabled_module_codes parameter
- Create GET /merchants/core/menu/render/merchant endpoint
- Update merchant Alpine.js with loadMenuConfig() and dynamic section state
- Replace hardcoded sidebar.html with x-for rendering + loading skeleton + fallback
- Add 36 unit and integration tests for menu discovery, service, and endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 00:24:11 +01:00
parent 716a4e3d15
commit be248222bc
13 changed files with 1241 additions and 82 deletions

View File

@@ -165,6 +165,10 @@ billing_module = ModuleDefinition(
"billing", # Store billing dashboard
"invoices", # Store invoice history
],
FrontendType.MERCHANT: [
"subscriptions", # Merchant subscriptions
"invoices", # Merchant billing history
],
},
# New module-driven menu definitions
menus={
@@ -199,6 +203,31 @@ billing_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="billing",
label_key="billing.menu.billing_subscriptions",
icon="credit-card",
order=50,
items=[
MenuItemDefinition(
id="subscriptions",
label_key="billing.menu.subscriptions",
icon="clipboard-list",
route="/merchants/billing/subscriptions",
order=10,
is_mandatory=True,
),
MenuItemDefinition(
id="invoices",
label_key="billing.menu.billing_history",
icon="currency-euro",
route="/merchants/billing/invoices",
order=20,
),
],
),
],
FrontendType.STORE: [
MenuSectionDefinition(
id="sales",

View File

@@ -74,6 +74,9 @@ core_module = ModuleDefinition(
"settings",
"email-templates",
],
FrontendType.MERCHANT: [
"dashboard",
],
},
# New module-driven menu definitions
menus={
@@ -121,6 +124,25 @@ core_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="main",
label_key=None,
icon=None,
order=0,
is_collapsible=False,
items=[
MenuItemDefinition(
id="dashboard",
label_key="core.menu.dashboard",
icon="home",
route="/merchants/dashboard",
order=10,
is_mandatory=True,
),
],
),
],
FrontendType.STORE: [
MenuSectionDefinition(
id="main",

View File

@@ -12,6 +12,7 @@ Aggregates:
from fastapi import APIRouter
from .merchant_dashboard import merchant_dashboard_router
from .merchant_menu import merchant_menu_router
ROUTE_CONFIG = {
"prefix": "/core",
@@ -20,3 +21,4 @@ ROUTE_CONFIG = {
router = APIRouter()
router.include_router(merchant_dashboard_router, tags=["merchant-dashboard"])
router.include_router(merchant_menu_router, tags=["merchant-menu"])

View File

@@ -0,0 +1,141 @@
# app/modules/core/routes/api/merchant_menu.py
"""
Merchant menu rendering endpoint.
Provides the dynamic sidebar menu for the merchant portal:
- GET /menu/render/merchant - Get rendered merchant menu for current user
Menu sections are driven by module definitions (FrontendType.MERCHANT).
Only modules enabled on platforms the merchant is actively subscribed to
will appear in the sidebar.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header, get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
merchant_menu_router = APIRouter(prefix="/menu")
# =============================================================================
# Schemas
# =============================================================================
class MenuSectionResponse(BaseModel):
"""Menu section for rendering."""
id: str
label: str | None = None
items: list[dict[str, Any]]
class RenderedMenuResponse(BaseModel):
"""Rendered menu for frontend."""
frontend_type: str
sections: list[MenuSectionResponse]
# =============================================================================
# Helpers
# =============================================================================
def _translate_label(label_key: str | None, language: str) -> str | None:
"""Translate a label key, falling back to a readable version of the key."""
if not label_key:
return None
translated = translate(label_key, language=language)
# If translation returned the key itself, create a readable fallback
if translated == label_key:
parts = label_key.split(".")
last_part = parts[-1]
return last_part.replace("_", " ").title()
return translated
# =============================================================================
# Endpoint
# =============================================================================
@merchant_menu_router.get("/render/merchant", response_model=RenderedMenuResponse)
async def get_rendered_merchant_menu(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
):
"""
Get the rendered merchant menu for the current user.
Returns the filtered menu structure based on modules enabled
on platforms the merchant is subscribed to.
Used by the merchant frontend to render the sidebar dynamically.
"""
# Resolve the merchant for this user (via service layer)
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
if not merchant:
# No merchant found — return empty menu
return RenderedMenuResponse(
frontend_type=FrontendType.MERCHANT.value,
sections=[],
)
# Get union of enabled module codes across all subscribed platforms
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
# Get filtered menu using enabled_module_codes override
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled_codes,
)
# Resolve language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels
sections = []
for section in menu:
translated_items = []
for item in section.items:
translated_items.append(
{
"id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
}
)
sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
items=translated_items,
)
)
return RenderedMenuResponse(
frontend_type=FrontendType.MERCHANT.value,
sections=sections,
)

View File

@@ -123,6 +123,7 @@ class MenuDiscoveryService:
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
enabled_module_codes: set[str] | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get aggregated menu sections for a frontend type.
@@ -134,6 +135,9 @@ class MenuDiscoveryService:
db: Database session
frontend_type: Frontend type to get menus for
platform_id: Platform ID for module enablement filtering
enabled_module_codes: If provided, overrides single-platform lookup.
A module is considered enabled if its code is in this set.
Useful for merchant portal where a merchant may span multiple platforms.
Returns:
List of DiscoveredMenuSection sorted by order
@@ -144,12 +148,15 @@ class MenuDiscoveryService:
sections_map: dict[str, DiscoveredMenuSection] = {}
for module_code, module_def in MODULES.items():
# Check if module is enabled for this platform
is_module_enabled = True
if platform_id:
# Check if module is enabled
if enabled_module_codes is not None:
is_module_enabled = module_code in enabled_module_codes
elif platform_id:
is_module_enabled = module_service.is_module_enabled(
db, platform_id, module_code
)
else:
is_module_enabled = True
# Get menu sections for this frontend type
module_sections = module_def.menus.get(frontend_type, [])
@@ -204,6 +211,7 @@ class MenuDiscoveryService:
user_id: int | None = None,
is_super_admin: bool = False,
store_code: str | None = None,
enabled_module_codes: set[str] | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get filtered menu structure for frontend rendering.
@@ -221,12 +229,16 @@ class MenuDiscoveryService:
user_id: User ID for user-specific visibility (super admins only)
is_super_admin: Whether the user is a super admin
store_code: Store code for route placeholder replacement
enabled_module_codes: If provided, overrides single-platform lookup
for module enablement. Passed through to get_menu_sections_for_frontend.
Returns:
List of DiscoveredMenuSection with filtered and sorted items
"""
# Get all sections with module enablement filtering
sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id)
sections = self.get_menu_sections_for_frontend(
db, frontend_type, platform_id, enabled_module_codes=enabled_module_codes
)
# Get visibility configuration
visible_item_ids = self._get_visible_item_ids(

View File

@@ -228,6 +228,7 @@ class MenuService:
user_id: int | None = None,
is_super_admin: bool = False,
store_code: str | None = None,
enabled_module_codes: set[str] | None = None,
) -> list:
"""
Get filtered menu structure for frontend rendering.
@@ -241,11 +242,14 @@ class MenuService:
Args:
db: Database session
frontend_type: Which frontend (admin or store)
frontend_type: Which frontend (admin, store, or merchant)
platform_id: Platform ID (for platform admins and stores)
user_id: User ID (for super admins only)
is_super_admin: Whether user is super admin (affects admin-only sections)
store_code: Store code for URL placeholder replacement (store frontend)
enabled_module_codes: If provided, overrides single-platform lookup
for module enablement. Used by merchant portal where a merchant
may have subscriptions across multiple platforms.
Returns:
List of DiscoveredMenuSection ready for rendering
@@ -257,6 +261,95 @@ class MenuService:
user_id=user_id,
is_super_admin=is_super_admin,
store_code=store_code,
enabled_module_codes=enabled_module_codes,
)
# =========================================================================
# Merchant Menu
# =========================================================================
def get_merchant_enabled_module_codes(
self,
db: Session,
merchant_id: int,
) -> set[str]:
"""
Get the union of enabled module codes across all platforms the merchant
has an active subscription on.
Core modules (those with is_core=True) are always included.
Args:
db: Database session
merchant_id: Merchant ID
Returns:
Set of enabled module codes
"""
from app.modules.billing.models.merchant_subscription import (
MerchantSubscription,
)
from app.modules.billing.models.subscription import SubscriptionStatus
from app.modules.registry import MODULES
# Always include core modules
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
# Find all platform IDs where merchant has active/trial subscriptions
active_statuses = [
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.PAST_DUE.value,
SubscriptionStatus.CANCELLED.value,
]
subscriptions = (
db.query(MerchantSubscription.platform_id)
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.status.in_(active_statuses),
)
.all()
)
platform_ids = {sub.platform_id for sub in subscriptions}
if not platform_ids:
return core_codes
# Union enabled module codes across all subscribed platforms
all_enabled = set(core_codes)
for platform_id in platform_ids:
platform_codes = module_service.get_enabled_module_codes(db, platform_id)
all_enabled |= platform_codes
return all_enabled
def get_merchant_for_menu(
self,
db: Session,
user_id: int,
):
"""
Get the active merchant owned by a user, for menu rendering.
Args:
db: Database session
user_id: Owner user ID
Returns:
Merchant ORM object or None
"""
from app.modules.tenancy.models import Merchant
return (
db.query(Merchant)
.filter(
Merchant.owner_user_id == user_id,
Merchant.is_active == True, # noqa: E712
)
.order_by(Merchant.id)
.first()
)
# =========================================================================

View File

@@ -21,11 +21,8 @@ function getMerchantSidebarSectionsFromStorage() {
} catch (e) {
console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
billing: true,
account: true
};
// Default: empty — dynamic menu will initialize section state
return {};
}
function saveMerchantSidebarSectionsToStorage(sections) {
@@ -36,6 +33,19 @@ function saveMerchantSidebarSectionsToStorage(sections) {
}
}
// Helper to find section ID for a page from menu data
function findSectionForPage(menuData, pageId) {
if (!menuData?.sections) return null;
for (const section of menuData.sections) {
for (const item of (section.items || [])) {
if (item.id === pageId) {
return section.id;
}
}
}
return null;
}
function data() {
console.log('[MERCHANT INIT-ALPINE] data() function called');
return {
@@ -48,6 +58,10 @@ function data() {
// Sidebar collapsible sections state
openSections: getMerchantSidebarSectionsFromStorage(),
// Dynamic menu (loaded from API)
menuData: null,
menuLoading: false,
init() {
// Set current page from URL
const path = window.location.pathname;
@@ -87,6 +101,9 @@ function data() {
// Ignore storage errors
}
}
// Load dynamic menu
this.loadMenuConfig();
},
toggleSideMenu() {
@@ -116,6 +133,54 @@ function data() {
saveMerchantSidebarSectionsToStorage(this.openSections);
},
// Auto-expand section containing current page
expandSectionForCurrentPage() {
if (!this.menuData) return;
const section = findSectionForPage(this.menuData, this.currentPage);
if (section && !this.openSections[section]) {
this.openSections[section] = true;
saveMerchantSidebarSectionsToStorage(this.openSections);
}
},
// Dynamic menu loading from API
async loadMenuConfig() {
if (this.menuData || this.menuLoading) return;
// Skip if apiClient is not available (e.g., on login page)
if (typeof apiClient === 'undefined') {
console.debug('Menu config: apiClient not available');
return;
}
// Skip if not authenticated
if (!localStorage.getItem('merchant_token')) {
console.debug('Menu config: no merchant_token, skipping');
return;
}
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/merchants/core/menu/render/merchant');
const sections = this.menuData?.sections || [];
for (const section of sections) {
// Initialize openSections for new sections (default: open)
if (this.openSections[section.id] === undefined) {
this.openSections[section.id] = true;
}
}
saveMerchantSidebarSectionsToStorage(this.openSections);
console.debug('Menu config loaded:', sections.length, 'sections');
// Auto-expand section containing current page
this.expandSectionForCurrentPage();
} catch (e) {
console.debug('Menu config not loaded, using fallback:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
async handleLogout() {
console.log('Logging out merchant user...');

View File

@@ -0,0 +1,477 @@
# app/modules/core/tests/integration/test_merchant_menu_routes.py
"""
Integration tests for merchant menu API routes.
Tests the merchant menu rendering endpoint at:
GET /api/v1/merchants/core/menu/render/merchant
Verifies:
- Dynamic menu rendering based on module enablement
- Subscription gating (only modules from subscribed platforms)
- Auth requirement
- Response structure
- Fallback when merchant has no subscriptions
"""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.api.deps import get_current_merchant_from_cookie_or_header
from app.modules.billing.models import (
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.tenancy.models import Merchant, Platform, User
from app.modules.tenancy.models.platform_module import PlatformModule
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/merchants/core/menu/render/merchant"
@pytest.fixture
def menu_owner(db):
"""Create a merchant owner user."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"menuowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"menuowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def menu_admin(db):
"""Create a super admin for enabling modules."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"menuadmin_{uuid.uuid4().hex[:8]}@test.com",
username=f"menuadmin_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="super_admin",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def menu_platform(db):
"""Create a platform."""
platform = Platform(
code=f"mp_{uuid.uuid4().hex[:8]}",
name="Menu Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def menu_merchant(db, menu_owner):
"""Create a merchant owned by menu_owner."""
merchant = Merchant(
name="Menu Test Merchant",
owner_user_id=menu_owner.id,
contact_email=menu_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def menu_tier(db, menu_platform):
"""Create a subscription tier."""
tier = SubscriptionTier(
code=f"pro_{uuid.uuid4().hex[:8]}",
name="Professional",
description="Pro tier",
price_monthly_cents=2900,
price_annual_cents=29000,
display_order=1,
is_active=True,
is_public=True,
platform_id=menu_platform.id,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def menu_subscription(db, menu_merchant, menu_platform, menu_tier):
"""Create an active subscription for the merchant on the platform."""
sub = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=menu_platform.id,
tier_id=menu_tier.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
@pytest.fixture
def menu_loyalty_module(db, menu_platform, menu_admin):
"""Enable loyalty module on the platform."""
pm = PlatformModule(
platform_id=menu_platform.id,
module_code="loyalty",
is_enabled=True,
enabled_at=datetime.now(UTC),
enabled_by_user_id=menu_admin.id,
config={},
)
db.add(pm)
db.commit()
db.refresh(pm)
return pm
@pytest.fixture
def menu_auth(menu_owner):
"""Override auth dependency for merchant cookie/header auth."""
user_context = UserContext(
id=menu_owner.id,
email=menu_owner.email,
username=menu_owner.username,
role="merchant_owner",
is_active=True,
)
def _override():
return user_context
app.dependency_overrides[get_current_merchant_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuRendering:
"""Tests for GET /api/v1/merchants/core/menu/render/merchant."""
def test_requires_auth(self, client):
"""Returns 401 without auth."""
app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None)
response = client.get(BASE)
assert response.status_code == 401
def test_returns_valid_structure(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Response has correct top-level structure."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
assert "frontend_type" in data
assert data["frontend_type"] == "merchant"
assert "sections" in data
assert isinstance(data["sections"], list)
def test_returns_core_sections(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Menu includes core sections: main, billing, account."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
def test_main_section_has_dashboard(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Main section contains the dashboard item."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
main = next(s for s in data["sections"] if s["id"] == "main")
item_ids = [i["id"] for i in main["items"]]
assert "dashboard" in item_ids
def test_section_items_have_required_fields(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Each menu item has id, label, icon, and url."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
for section in data["sections"]:
for item in section["items"]:
assert "id" in item
assert "label" in item
assert "icon" in item
assert "url" in item
assert item["label"] is not None
assert item["url"].startswith("/merchants/")
def test_sections_have_correct_labels(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Labeled sections have non-null labels, main section has null label."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
for section in data["sections"]:
if section["id"] == "main":
assert section["label"] is None
else:
assert section["label"] is not None
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuModuleGating:
"""Tests for module-based menu filtering."""
def test_loyalty_appears_when_module_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
):
"""Loyalty section appears when loyalty module is enabled on subscribed platform."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" in section_ids
def test_loyalty_hidden_when_module_not_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
):
"""Loyalty section is hidden when loyalty module is NOT enabled."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" not in section_ids
def test_loyalty_item_has_correct_route(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
):
"""Loyalty overview item has the correct URL."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
assert overview["url"] == "/merchants/loyalty/overview"
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuNoSubscription:
"""Tests for menu when merchant has no subscriptions."""
def test_empty_menu_when_no_merchant(self, client, db, menu_auth):
"""Returns empty sections when user has no merchant."""
# menu_auth provides a user, but no merchant fixture is loaded
# The endpoint queries for merchant by owner_user_id — none exists for this user
# unless menu_merchant is requested
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
assert data["frontend_type"] == "merchant"
assert data["sections"] == []
def test_core_sections_with_merchant_no_subscription(
self, client, db, menu_auth, menu_merchant,
):
"""With merchant but no subscription, core sections still appear."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
# Core modules (core, billing, tenancy) are always included
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
# Non-core modules require subscription
assert "loyalty" not in section_ids
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuSubscriptionStatus:
"""Tests for subscription status filtering."""
def test_trial_subscription_shows_modules(
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
menu_loyalty_module,
):
"""Trial subscription shows modules from that platform."""
sub = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=menu_platform.id,
tier_id=menu_tier.id,
status=SubscriptionStatus.TRIAL.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=14),
trial_ends_at=datetime.now(UTC) + timedelta(days=14),
)
db.add(sub)
db.commit()
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
assert "loyalty" in section_ids
def test_expired_subscription_hides_non_core_modules(
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
menu_loyalty_module,
):
"""Expired subscription does not show modules from that platform."""
sub = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=menu_platform.id,
tier_id=menu_tier.id,
status=SubscriptionStatus.EXPIRED.value,
is_annual=False,
period_start=datetime.now(UTC) - timedelta(days=60),
period_end=datetime.now(UTC) - timedelta(days=30),
)
db.add(sub)
db.commit()
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty should NOT appear because subscription is expired
assert "loyalty" not in section_ids
# Core sections always appear
assert "main" in section_ids
assert "billing" in section_ids
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuMultiPlatform:
"""Tests for merchants subscribed to multiple platforms."""
def test_union_of_modules_from_multiple_platforms(
self, client, db, menu_auth, menu_merchant, menu_admin,
):
"""Modules enabled on different platforms are unioned together."""
# Platform A with loyalty enabled
platform_a = Platform(
code=f"mpa_{uuid.uuid4().hex[:8]}",
name="Platform A",
is_active=True,
)
db.add(platform_a)
db.flush()
tier_a = SubscriptionTier(
code=f"ta_{uuid.uuid4().hex[:8]}",
name="Tier A",
description="T",
price_monthly_cents=1000,
display_order=1,
is_active=True,
is_public=True,
platform_id=platform_a.id,
)
db.add(tier_a)
db.flush()
db.add(PlatformModule(
platform_id=platform_a.id,
module_code="loyalty",
is_enabled=True,
enabled_at=datetime.now(UTC),
enabled_by_user_id=menu_admin.id,
config={},
))
sub_a = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=platform_a.id,
tier_id=tier_a.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub_a)
# Platform B without loyalty
platform_b = Platform(
code=f"mpb_{uuid.uuid4().hex[:8]}",
name="Platform B",
is_active=True,
)
db.add(platform_b)
db.flush()
tier_b = SubscriptionTier(
code=f"tb_{uuid.uuid4().hex[:8]}",
name="Tier B",
description="T",
price_monthly_cents=1000,
display_order=1,
is_active=True,
is_public=True,
platform_id=platform_b.id,
)
db.add(tier_b)
db.flush()
sub_b = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=platform_b.id,
tier_id=tier_b.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub_b)
db.commit()
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty enabled on Platform A should appear in the union
assert "loyalty" in section_ids
# Core sections always present
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids

View File

@@ -3,6 +3,7 @@
import pytest
from app.modules.core.services.menu_discovery_service import MenuDiscoveryService
from app.modules.enums import FrontendType
@pytest.mark.unit
@@ -16,3 +17,165 @@ class TestMenuDiscoveryService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
def test_discover_all_menus_includes_merchant(self):
"""discover_all_menus() includes FrontendType.MERCHANT entries."""
menus = self.service.discover_all_menus()
assert FrontendType.MERCHANT in menus
merchant_sections = menus[FrontendType.MERCHANT]
assert len(merchant_sections) > 0, "Expected at least one MERCHANT menu section"
def test_merchant_menu_has_expected_section_ids(self):
"""MERCHANT menus contain main, billing, account, and loyalty sections."""
menus = self.service.discover_all_menus()
section_ids = {s.id for s in menus[FrontendType.MERCHANT]}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" in section_ids
def test_merchant_main_section_has_dashboard(self):
"""Main section contains the dashboard item."""
menus = self.service.discover_all_menus()
main_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "main"]
assert len(main_sections) == 1
item_ids = [i.id for i in main_sections[0].items]
assert "dashboard" in item_ids
def test_merchant_billing_section_items(self):
"""Billing section contains subscriptions and invoices."""
menus = self.service.discover_all_menus()
billing_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "billing"]
assert len(billing_sections) == 1
item_ids = [i.id for i in billing_sections[0].items]
assert "subscriptions" in item_ids
assert "invoices" in item_ids
def test_merchant_account_section_items(self):
"""Account section contains stores and profile."""
menus = self.service.discover_all_menus()
account_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "account"]
assert len(account_sections) == 1
item_ids = [i.id for i in account_sections[0].items]
assert "stores" in item_ids
assert "profile" in item_ids
def test_merchant_loyalty_section_items(self):
"""Loyalty section contains loyalty-overview."""
menus = self.service.discover_all_menus()
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
assert len(loyalty_sections) == 1
item_ids = [i.id for i in loyalty_sections[0].items]
assert "loyalty-overview" in item_ids
def test_get_all_menu_items_merchant(self):
"""get_all_menu_items returns items for MERCHANT frontend type."""
items = self.service.get_all_menu_items(FrontendType.MERCHANT)
assert len(items) > 0
item_ids = {i.id for i in items}
assert "dashboard" in item_ids
assert "subscriptions" in item_ids
assert "loyalty-overview" in item_ids
def test_get_all_menu_item_ids_merchant(self):
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
item_ids = self.service.get_all_menu_item_ids(FrontendType.MERCHANT)
assert "dashboard" in item_ids
assert "subscriptions" in item_ids
assert "invoices" in item_ids
assert "stores" in item_ids
assert "profile" in item_ids
assert "loyalty-overview" in item_ids
def test_get_mandatory_item_ids_merchant(self):
"""Mandatory items for MERCHANT include dashboard and subscriptions."""
mandatory = self.service.get_mandatory_item_ids(FrontendType.MERCHANT)
assert "dashboard" in mandatory
assert "subscriptions" in mandatory
def test_merchant_sections_have_expected_orders(self):
"""MERCHANT menu sections have the expected order values."""
menus = self.service.discover_all_menus()
sections = menus[FrontendType.MERCHANT]
order_map = {s.id: s.order for s in sections}
# main is lowest, account is highest
assert order_map["main"] < order_map["billing"]
assert order_map["billing"] < order_map["loyalty"]
assert order_map["loyalty"] < order_map["account"]
@pytest.mark.unit
@pytest.mark.core
class TestMenuDiscoveryServiceEnabledModuleCodes:
"""Test enabled_module_codes parameter on get_menu_sections_for_frontend."""
def setup_method(self):
self.service = MenuDiscoveryService()
def test_all_modules_enabled_shows_all_sections(self, db):
"""When all module codes are provided, all sections appear."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_sections_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" in section_ids
def test_loyalty_disabled_hides_loyalty_section(self, db):
"""When loyalty is not in enabled_module_codes, its section still appears
but items are marked as disabled (filtered out by get_menu_for_frontend)."""
enabled = {"core", "billing", "tenancy"}
sections = self.service.get_menu_sections_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
# Sections still exist, but loyalty items are marked disabled
loyalty_sections = [s for s in sections if s.id == "loyalty"]
if loyalty_sections:
for item in loyalty_sections[0].items:
assert item.is_module_enabled is False
def test_get_menu_for_frontend_filters_disabled_modules(self, db):
"""get_menu_for_frontend removes items from disabled modules."""
enabled = {"core", "billing", "tenancy"} # No loyalty
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" not in section_ids, "Loyalty section should be filtered out"
def test_get_menu_for_frontend_includes_loyalty_when_enabled(self, db):
"""get_menu_for_frontend includes loyalty when its module is enabled."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "loyalty" in section_ids
def test_core_only_shows_minimal_sections(self, db):
"""With only core modules, only main section appears."""
enabled = {"core"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" not in section_ids
assert "loyalty" not in section_ids
def test_enabled_module_codes_none_shows_all(self, db):
"""When enabled_module_codes is None and no platform_id, all modules shown."""
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=None,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "loyalty" in section_ids
assert "account" in section_ids

View File

@@ -3,6 +3,7 @@
import pytest
from app.modules.core.services.menu_service import MenuService
from app.modules.enums import FrontendType
@pytest.mark.unit
@@ -16,3 +17,96 @@ class TestMenuService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
@pytest.mark.unit
@pytest.mark.core
class TestMenuServiceMerchantRendering:
"""Test get_menu_for_rendering with FrontendType.MERCHANT and enabled_module_codes."""
def setup_method(self):
self.service = MenuService()
def test_render_merchant_menu_all_modules(self, db):
"""Rendering with all modules shows all sections."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" in section_ids
def test_render_merchant_menu_without_loyalty(self, db):
"""Rendering without loyalty module hides loyalty section."""
enabled = {"core", "billing", "tenancy"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "loyalty" not in section_ids
assert "main" in section_ids
assert "billing" in section_ids
def test_render_merchant_menu_section_item_structure(self, db):
"""Each section has items with expected attributes."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
for section in sections:
assert section.id
assert len(section.items) > 0
for item in section.items:
assert item.id
assert item.label_key
assert item.icon
assert item.route
assert item.route.startswith("/merchants/")
def test_render_merchant_menu_section_ordering(self, db):
"""Sections are returned in correct order."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
orders = [s.order for s in sections]
assert orders == sorted(orders)
def test_render_merchant_menu_no_enabled_modules_returns_nothing(self, db):
"""With empty enabled set, no sections are returned."""
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=set(),
)
assert len(sections) == 0
def test_enabled_module_codes_propagated(self, db):
"""enabled_module_codes parameter is properly propagated to discovery service."""
# With loyalty
with_loyalty = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes={"core", "billing", "tenancy", "loyalty"},
)
# Without loyalty
without_loyalty = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes={"core", "billing", "tenancy"},
)
with_ids = {s.id for s in with_loyalty}
without_ids = {s.id for s in without_loyalty}
assert "loyalty" in with_ids
assert "loyalty" not in without_ids

View File

@@ -117,6 +117,9 @@ loyalty_module = ModuleDefinition(
"loyalty-cards", # Customer cards
"loyalty-stats", # Store stats
],
FrontendType.MERCHANT: [
"loyalty-overview", # Merchant loyalty overview
],
},
# New module-driven menu definitions
menus={
@@ -175,6 +178,23 @@ loyalty_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="loyalty",
label_key="loyalty.menu.loyalty",
icon="gift",
order=60,
items=[
MenuItemDefinition(
id="loyalty-overview",
label_key="loyalty.menu.overview",
icon="gift",
route="/merchants/loyalty/overview",
order=10,
),
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="account",

View File

@@ -91,6 +91,10 @@ tenancy_module = ModuleDefinition(
FrontendType.STORE: [
"team",
],
FrontendType.MERCHANT: [
"stores",
"profile",
],
},
# New module-driven menu definitions
menus={
@@ -160,6 +164,30 @@ tenancy_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="account",
label_key="tenancy.menu.account",
icon="cog",
order=900,
items=[
MenuItemDefinition(
id="stores",
label_key="tenancy.menu.stores",
icon="shopping-bag",
route="/merchants/account/stores",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="tenancy.menu.profile",
icon="user",
route="/merchants/account/profile",
order=20,
),
],
),
],
FrontendType.STORE: [
MenuSectionDefinition(
id="account",

View File

@@ -1,60 +1,5 @@
{# app/templates/merchant/partials/sidebar.html #}
{# Collapsible sidebar sections with localStorage persistence - matching store pattern #}
{# ============================================================================
REUSABLE MACROS FOR SIDEBAR ITEMS
============================================================================ #}
{# Macro for collapsible section header #}
{% macro section_header(title, section_key, icon=none) %}
<div class="px-6 my-4">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<button
@click="toggleSection('{{ section_key }}')"
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class="flex items-center">
{% if icon %}
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
{% endif %}
{{ title }}
</span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections.{{ section_key }} }"
></span>
</button>
{% endmacro %}
{# Macro for collapsible section content wrapper #}
{% macro section_content(section_key) %}
<ul
x-show="openSections.{{ section_key }}"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="mt-1 overflow-hidden"
>
{{ caller() }}
</ul>
{% endmacro %}
{# Macro for menu item - uses static href (no storeCode needed) #}
{% macro menu_item(page_id, path, icon, label) %}
<li class="relative px-6 py-3">
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
href="{{ path }}">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
<span class="ml-4">{{ label }}</span>
</a>
</li>
{% endmacro %}
{# Dynamic sidebar driven by menu discovery API - matching admin pattern #}
{# ============================================================================
SIDEBAR CONTENT (shared between desktop and mobile)
@@ -69,24 +14,92 @@
<span>Merchant Portal</span>
</a>
<!-- Dashboard (always visible) -->
<ul class="mt-6">
{{ menu_item('dashboard', '/merchants/dashboard', 'home', 'Dashboard') }}
</ul>
<!-- Loading skeleton -->
<div x-show="menuLoading" class="mt-6 px-6 space-y-4 animate-pulse">
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
<!-- Billing Section -->
{{ section_header('Billing', 'billing', 'credit-card') }}
{% call section_content('billing') %}
{{ menu_item('subscriptions', '/merchants/billing/subscriptions', 'clipboard-list', 'Subscriptions') }}
{{ menu_item('invoices', '/merchants/billing/invoices', 'currency-euro', 'Billing History') }}
{% endcall %}
<!-- Dynamic menu from API -->
<div x-show="!menuLoading && menuData" x-cloak>
<template x-for="section in (menuData?.sections || [])" :key="section.id">
<div>
{# Unlabeled sections (e.g. "main" with dashboard) render items directly #}
<template x-if="!section.label">
<ul class="mt-6">
<template x-for="item in section.items" :key="item.id">
<li class="relative px-6 py-3">
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
:href="item.url">
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
<span class="ml-4" x-text="item.label"></span>
</a>
</li>
</template>
</ul>
</template>
<!-- Account Section -->
{{ section_header('Account', 'account', 'cog') }}
{% call section_content('account') %}
{{ menu_item('stores', '/merchants/account/stores', 'shopping-bag', 'Stores') }}
{{ menu_item('profile', '/merchants/account/profile', 'user', 'Profile') }}
{% endcall %}
{# Labeled sections are collapsible #}
<template x-if="section.label">
<div>
<div class="px-6 my-4">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<button
@click="toggleSection(section.id)"
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class="flex items-center" x-text="section.label"></span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections[section.id] }"
></span>
</button>
<ul
x-show="openSections[section.id]"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="mt-1 overflow-hidden"
>
<template x-for="item in section.items" :key="item.id">
<li class="relative px-6 py-3">
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
:href="item.url">
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
<span class="ml-4" x-text="item.label"></span>
</a>
</li>
</template>
</ul>
</div>
</template>
</div>
</template>
</div>
<!-- Fallback: static dashboard link (if menu API fails) -->
<div x-show="!menuLoading && !menuData" x-cloak>
<ul class="mt-6">
<li class="relative px-6 py-3">
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/merchants/dashboard">
<span x-html="$icon('home', 'w-5 h-5')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
</div>
</div>
{% endmacro %}