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

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