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:
477
app/modules/core/tests/integration/test_merchant_menu_routes.py
Normal file
477
app/modules/core/tests/integration/test_merchant_menu_routes.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user