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

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