diff --git a/app/modules/core/routes/api/merchant_menu.py b/app/modules/core/routes/api/merchant_menu.py index 71d5be7e..432eba06 100644 --- a/app/modules/core/routes/api/merchant_menu.py +++ b/app/modules/core/routes/api/merchant_menu.py @@ -101,10 +101,16 @@ async def get_rendered_merchant_menu( # 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 + # Resolve primary platform for AdminMenuConfig visibility lookup + primary_platform_id = menu_service.get_merchant_primary_platform_id( + db, merchant.id + ) + + # Get filtered menu using enabled_module_codes override + platform visibility menu = menu_service.get_menu_for_rendering( db=db, frontend_type=FrontendType.MERCHANT, + platform_id=primary_platform_id, enabled_module_codes=enabled_codes, ) diff --git a/app/modules/core/routes/api/store.py b/app/modules/core/routes/api/store.py index ce545efc..bf2cb284 100644 --- a/app/modules/core/routes/api/store.py +++ b/app/modules/core/routes/api/store.py @@ -5,15 +5,18 @@ Core module store API routes. Aggregates: - /dashboard/* - Dashboard statistics - /settings/* - Store settings management +- /menu/* - Store menu rendering """ from fastapi import APIRouter from .store_dashboard import store_dashboard_router +from .store_menu import store_menu_router from .store_settings import store_settings_router store_router = APIRouter() # Aggregate sub-routers store_router.include_router(store_dashboard_router, tags=["store-dashboard"]) +store_router.include_router(store_menu_router, tags=["store-menu"]) store_router.include_router(store_settings_router, tags=["store-settings"]) diff --git a/app/modules/core/routes/api/store_menu.py b/app/modules/core/routes/api/store_menu.py new file mode 100644 index 00000000..f6dad459 --- /dev/null +++ b/app/modules/core/routes/api/store_menu.py @@ -0,0 +1,139 @@ +# app/modules/core/routes/api/store_menu.py +""" +Store menu rendering endpoint. + +Provides the dynamic sidebar menu for the store portal: +- GET /menu/render/store - Get rendered store menu for current user + +Menu sections are driven by module definitions (FrontendType.STORE). +Only modules enabled on the store's platform will appear in the sidebar. +Visibility is controlled by AdminMenuConfig records for the platform. +""" + +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_store_api +from app.core.database import get_db +from app.modules.core.services.menu_service import menu_service +from app.modules.enums import FrontendType +from app.modules.tenancy.services.store_service import store_service +from app.utils.i18n import translate +from models.schema.auth import UserContext + +logger = logging.getLogger(__name__) + +store_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 +# ============================================================================= + + +@store_menu_router.get("/render/store", response_model=RenderedMenuResponse) +async def get_rendered_store_menu( + request: Request, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_store_api), +): + """ + Get the rendered store menu for the current user. + + Returns the filtered menu structure based on: + - Modules enabled on the store's platform + - AdminMenuConfig visibility for the platform + - Store code for URL placeholder replacement + + Used by the store frontend to render the sidebar dynamically. + """ + # Get the store from the JWT token's store context + store = store_service.get_store_by_id(db, current_user.token_store_id) + + # Resolve the store's platform via service layer + platform_id = menu_service.get_store_primary_platform_id(db, store.id) + + # Get filtered menu with platform visibility and store_code interpolation + menu = menu_service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.STORE, + platform_id=platform_id, + store_code=store.subdomain, + ) + + # 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.STORE.value, + sections=sections, + ) diff --git a/app/modules/core/routes/pages/store.py b/app/modules/core/routes/pages/store.py index 0a5f9603..84575bd0 100644 --- a/app/modules/core/routes/pages/store.py +++ b/app/modules/core/routes/pages/store.py @@ -3,6 +3,7 @@ Core Store Page Routes (HTML rendering). Store pages for core functionality: +- Dashboard - Media library - Notifications """ @@ -19,6 +20,35 @@ from app.templates_config import templates router = APIRouter() +# ============================================================================ +# STORE DASHBOARD +# ============================================================================ + + +@router.get( + "/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False +) +async def store_dashboard_page( + request: Request, + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render store dashboard. + + JavaScript will: + - Load store info via API + - Load dashboard stats via API + - Load recent orders via API + - Handle all interactivity + """ + return templates.TemplateResponse( + "core/store/dashboard.html", + get_store_context(request, db, current_user, store_code), + ) + + # ============================================================================ # MEDIA LIBRARY # ============================================================================ diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index 1eb6243f..a13c91a4 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -325,6 +325,106 @@ class MenuService: return all_enabled + def get_merchant_primary_platform_id( + self, + db: Session, + merchant_id: int, + ) -> int | None: + """ + Get the primary platform ID for a merchant's visibility config. + + Resolution order: + 1. Platform from the store marked is_primary in StorePlatform + 2. First active subscription's platform (fallback) + + Args: + db: Database session + merchant_id: Merchant ID + + Returns: + Platform ID or None if no active subscriptions + """ + from app.modules.billing.models.merchant_subscription import ( + MerchantSubscription, + ) + from app.modules.billing.models.subscription import SubscriptionStatus + from app.modules.tenancy.models import Store + from app.modules.tenancy.models.store_platform import StorePlatform + + active_statuses = [ + SubscriptionStatus.TRIAL.value, + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.PAST_DUE.value, + SubscriptionStatus.CANCELLED.value, + ] + + # Try to find the primary store's platform + primary_platform_id = ( + db.query(StorePlatform.platform_id) + .join(Store, Store.id == StorePlatform.store_id) + .join( + MerchantSubscription, + (MerchantSubscription.platform_id == StorePlatform.platform_id) + & (MerchantSubscription.merchant_id == merchant_id), + ) + .filter( + Store.merchant_id == merchant_id, + Store.is_active == True, # noqa: E712 + StorePlatform.is_primary == True, # noqa: E712 + StorePlatform.is_active == True, # noqa: E712 + MerchantSubscription.status.in_(active_statuses), + ) + .first() + ) + + if primary_platform_id: + return primary_platform_id[0] + + # Fallback: first active subscription's platform + first_sub = ( + db.query(MerchantSubscription.platform_id) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.status.in_(active_statuses), + ) + .order_by(MerchantSubscription.id) + .first() + ) + + return first_sub[0] if first_sub else None + + def get_store_primary_platform_id( + self, + db: Session, + store_id: int, + ) -> int | None: + """ + Get the primary platform ID for a store's menu visibility config. + + Prefers the active StorePlatform marked is_primary, falls back to + the first active StorePlatform by ID. + + Args: + db: Database session + store_id: Store ID + + Returns: + Platform ID or None if no active store-platform link + """ + from app.modules.tenancy.models.store_platform import StorePlatform + + sp = ( + db.query(StorePlatform.platform_id) + .filter( + StorePlatform.store_id == store_id, + StorePlatform.is_active == True, # noqa: E712 + ) + .order_by(StorePlatform.is_primary.desc(), StorePlatform.id) + .first() + ) + + return sp[0] if sp else None + def get_merchant_for_menu( self, db: Session, diff --git a/app/modules/core/static/store/js/init-alpine.js b/app/modules/core/static/store/js/init-alpine.js index 1f52e3a9..33a8d335 100644 --- a/app/modules/core/static/store/js/init-alpine.js +++ b/app/modules/core/static/store/js/init-alpine.js @@ -21,14 +21,20 @@ function getStoreSidebarSectionsFromStorage() { } catch (e) { console.warn('[STORE INIT-ALPINE] Failed to load sidebar state from localStorage:', e); } - // Default: all sections open - return { - products: true, - sales: true, - customers: true, - shop: true, - account: true - }; + // Default: empty (populated dynamically from API response) + return {}; +} + +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 saveStoreSidebarSectionsToStorage(sections) { @@ -51,6 +57,10 @@ function data() { store: null, storeCode: null, + // Dynamic menu state + menuData: null, + menuLoading: false, + // Sidebar collapsible sections state openSections: getStoreSidebarSectionsFromStorage(), @@ -81,8 +91,9 @@ function data() { this.dark = true; } - // Load store info + // Load store info and dynamic menu this.loadStoreInfo(); + this.loadMenuConfig(); // Save last visited page (for redirect after login) // Exclude login, logout, onboarding, error pages @@ -111,6 +122,38 @@ function data() { } }, + async loadMenuConfig() { + if (this.menuData || this.menuLoading) return; + if (typeof apiClient === 'undefined') return; + if (!localStorage.getItem('store_token')) return; + + this.menuLoading = true; + try { + this.menuData = await apiClient.get('/store/core/menu/render/store'); + // Initialize section open state from response + for (const section of (this.menuData?.sections || [])) { + if (this.openSections[section.id] === undefined) { + this.openSections[section.id] = true; + } + } + saveStoreSidebarSectionsToStorage(this.openSections); + this.expandSectionForCurrentPage(); + } catch (e) { + console.debug('Menu config not loaded, using fallback:', e?.message || e); + } finally { + this.menuLoading = false; + } + }, + + expandSectionForCurrentPage() { + if (!this.menuData) return; + const sectionId = findSectionForPage(this.menuData, this.currentPage); + if (sectionId && !this.openSections[sectionId]) { + this.openSections[sectionId] = true; + saveStoreSidebarSectionsToStorage(this.openSections); + } + }, + toggleSideMenu() { this.isSideMenuOpen = !this.isSideMenuOpen; }, diff --git a/app/modules/core/tests/integration/test_store_dashboard_routes.py b/app/modules/core/tests/integration/test_store_dashboard_routes.py new file mode 100644 index 00000000..c5cceda3 --- /dev/null +++ b/app/modules/core/tests/integration/test_store_dashboard_routes.py @@ -0,0 +1,200 @@ +# app/modules/core/tests/integration/test_store_dashboard_routes.py +""" +Integration tests for store dashboard page route. + +Tests the store dashboard page at: + GET /store/{store_code}/dashboard + +Verifies: +- Dashboard renders without any onboarding check +- Dashboard requires authentication +- Dashboard is served from the core module (no marketplace dependency) +""" + +import uuid +from datetime import UTC, datetime + +import pytest + +from app.api.deps import get_current_store_from_cookie_or_header +from app.modules.tenancy.models import Merchant, Platform, Store, User +from app.modules.tenancy.models.store_platform import StorePlatform +from main import app +from models.schema.auth import UserContext + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def sd_owner(db): + """Create a store owner user.""" + from middleware.auth import AuthManager + + auth = AuthManager() + user = User( + email=f"sdowner_{uuid.uuid4().hex[:8]}@test.com", + username=f"sdowner_{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 sd_platform(db): + """Create a platform without marketplace enabled.""" + platform = Platform( + code=f"sdp_{uuid.uuid4().hex[:8]}", + name="Loyalty Only Platform", + is_active=True, + ) + db.add(platform) + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def sd_merchant(db, sd_owner): + """Create a merchant.""" + merchant = Merchant( + name="Dashboard Test Merchant", + owner_user_id=sd_owner.id, + contact_email=sd_owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + return merchant + + +@pytest.fixture +def sd_store(db, sd_merchant): + """Create a store.""" + uid = uuid.uuid4().hex[:8] + store = Store( + merchant_id=sd_merchant.id, + store_code=f"SDSTORE_{uid.upper()}", + subdomain=f"sdstore{uid.lower()}", + name="Dashboard Test Store", + is_active=True, + is_verified=True, + ) + db.add(store) + db.commit() + db.refresh(store) + return store + + +@pytest.fixture +def sd_store_platform(db, sd_store, sd_platform): + """Link store to a loyalty-only platform (no marketplace).""" + sp = StorePlatform( + store_id=sd_store.id, + platform_id=sd_platform.id, + is_active=True, + ) + db.add(sp) + db.commit() + db.refresh(sp) + return sp + + +@pytest.fixture +def sd_auth(sd_owner, sd_store): + """Override auth dependency for store cookie/header auth.""" + user_context = UserContext( + id=sd_owner.id, + email=sd_owner.email, + username=sd_owner.username, + role="merchant_owner", + is_active=True, + token_store_id=sd_store.id, + token_store_code=sd_store.store_code, + ) + + def _override(): + return user_context + + app.dependency_overrides[get_current_store_from_cookie_or_header] = _override + yield {"Authorization": "Bearer fake-token"} + app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None) + + +# ============================================================================ +# Tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.core +class TestStoreDashboardPage: + """Tests for GET /store/{store_code}/dashboard.""" + + def test_dashboard_renders_for_authenticated_store_user( + self, client, db, sd_auth, sd_store, sd_store_platform + ): + """Dashboard page returns 200 for authenticated store user.""" + response = client.get( + f"/store/{sd_store.subdomain}/dashboard", + headers=sd_auth, + follow_redirects=False, + ) + assert response.status_code == 200 + + def test_dashboard_renders_without_onboarding_check( + self, client, db, sd_auth, sd_store, sd_store_platform + ): + """Dashboard loads without any onboarding redirect — no marketplace dependency. + + On a loyalty-only platform, there's no StoreOnboarding record, + yet the dashboard should still render successfully. + """ + response = client.get( + f"/store/{sd_store.subdomain}/dashboard", + headers=sd_auth, + follow_redirects=False, + ) + # Should NOT redirect to onboarding + assert response.status_code == 200 + assert "location" not in response.headers + + def test_dashboard_requires_auth(self, client): + """Returns 401 without auth.""" + app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None) + response = client.get( + "/store/anystore/dashboard", + follow_redirects=False, + ) + assert response.status_code == 401 + + +@pytest.mark.integration +@pytest.mark.core +class TestStoreDashboardNoMarketplace: + """Tests verifying dashboard has no marketplace module dependency.""" + + def test_dashboard_has_no_onboarding_import(self): + """Core store routes module does not import OnboardingService.""" + import app.modules.core.routes.pages.store as core_store_module + + with open(core_store_module.__file__) as f: + source = f.read() + assert "OnboardingService" not in source + assert "onboarding" not in source.lower() + + def test_dashboard_has_no_marketplace_import(self): + """Core store routes module does not import from marketplace.""" + import app.modules.core.routes.pages.store as core_store_module + + with open(core_store_module.__file__) as f: + source = f.read() + assert "app.modules.marketplace" not in source diff --git a/app/modules/marketplace/routes/pages/store.py b/app/modules/marketplace/routes/pages/store.py index ef52ec47..7e675d0d 100644 --- a/app/modules/marketplace/routes/pages/store.py +++ b/app/modules/marketplace/routes/pages/store.py @@ -4,7 +4,6 @@ Marketplace Store Page Routes (HTML rendering). Store pages for marketplace management: - Onboarding wizard -- Dashboard - Marketplace imports - Letzshop integration """ @@ -16,7 +15,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_store_from_cookie_or_header, get_db from app.modules.core.utils.page_context import get_store_context from app.modules.marketplace.services.onboarding_service import OnboardingService +from app.modules.service import module_service from app.modules.tenancy.models import User +from app.modules.tenancy.models.store_platform import StorePlatform from app.templates_config import templates router = APIRouter() @@ -39,14 +40,27 @@ async def store_onboarding_page( """ Render store onboarding wizard. - Mandatory 4-step wizard that must be completed before accessing dashboard: + 4-step wizard for marketplace setup: 1. Merchant Profile Setup 2. Letzshop API Configuration 3. Product & Order Import Configuration 4. Order Sync (historical import) - If onboarding is already completed, redirects to dashboard. + If marketplace module is not enabled for this store's platform, + redirects to dashboard. If onboarding is already completed, + redirects to dashboard. """ + sp = ( + db.query(StorePlatform) + .filter(StorePlatform.store_id == current_user.token_store_id) + .first() + ) + if not sp or not module_service.is_module_enabled(db, sp.platform_id, "marketplace"): + return RedirectResponse( + url=f"/store/{store_code}/dashboard", + status_code=302, + ) + onboarding_service = OnboardingService(db) if onboarding_service.is_completed(current_user.token_store_id): return RedirectResponse( @@ -60,44 +74,6 @@ async def store_onboarding_page( ) -# ============================================================================ -# STORE DASHBOARD -# ============================================================================ - - -@router.get( - "/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False -) -async def store_dashboard_page( - request: Request, - store_code: str = Path(..., description="Store code"), - current_user: User = Depends(get_current_store_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render store dashboard. - - Redirects to onboarding if not completed. - - JavaScript will: - - Load store info via API - - Load dashboard stats via API - - Load recent orders via API - - Handle all interactivity - """ - onboarding_service = OnboardingService(db) - if not onboarding_service.is_completed(current_user.token_store_id): - return RedirectResponse( - url=f"/store/{store_code}/onboarding", - status_code=302, - ) - - return templates.TemplateResponse( - "core/store/dashboard.html", - get_store_context(request, db, current_user, store_code), - ) - - # ============================================================================ # MARKETPLACE IMPORTS # ============================================================================ @@ -115,7 +91,15 @@ async def store_marketplace_page( """ Render marketplace import page. JavaScript loads import jobs and products via API. + Redirects to onboarding if not completed. """ + onboarding_service = OnboardingService(db) + if not onboarding_service.is_completed(current_user.token_store_id): + return RedirectResponse( + url=f"/store/{store_code}/onboarding", + status_code=302, + ) + return templates.TemplateResponse( "marketplace/store/marketplace.html", get_store_context(request, db, current_user, store_code), @@ -139,7 +123,15 @@ async def store_letzshop_page( """ Render Letzshop integration page. JavaScript loads orders, credentials status, and handles fulfillment operations. + Redirects to onboarding if not completed. """ + onboarding_service = OnboardingService(db) + if not onboarding_service.is_completed(current_user.token_store_id): + return RedirectResponse( + url=f"/store/{store_code}/onboarding", + status_code=302, + ) + return templates.TemplateResponse( "marketplace/store/letzshop.html", get_store_context(request, db, current_user, store_code), diff --git a/app/modules/marketplace/tests/integration/__init__.py b/app/modules/marketplace/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/marketplace/tests/integration/test_store_page_routes.py b/app/modules/marketplace/tests/integration/test_store_page_routes.py new file mode 100644 index 00000000..be166927 --- /dev/null +++ b/app/modules/marketplace/tests/integration/test_store_page_routes.py @@ -0,0 +1,388 @@ +# app/modules/marketplace/tests/integration/test_store_page_routes.py +""" +Integration tests for marketplace store page routes. + +Tests the onboarding, marketplace, and letzshop page routes at: + GET /store/{store_code}/onboarding + GET /store/{store_code}/marketplace + GET /store/{store_code}/letzshop + +Verifies: +- Onboarding page redirects to dashboard on non-marketplace platforms +- Onboarding page redirects to dashboard when already completed +- Onboarding page renders when marketplace enabled and not completed +- Marketplace/Letzshop pages redirect to onboarding when not completed +- Marketplace/Letzshop pages render when onboarding is completed +""" + +import uuid +from datetime import UTC, datetime + +import pytest + +from app.api.deps import get_current_store_from_cookie_or_header +from app.modules.marketplace.models import OnboardingStatus, StoreOnboarding +from app.modules.tenancy.models import Merchant, Platform, Store, User +from app.modules.tenancy.models.platform_module import PlatformModule +from app.modules.tenancy.models.store_platform import StorePlatform +from main import app +from models.schema.auth import UserContext + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def mp_admin(db): + """Create an admin user for enabling modules.""" + from middleware.auth import AuthManager + + auth = AuthManager() + user = User( + email=f"mpadmin_{uuid.uuid4().hex[:8]}@test.com", + username=f"mpadmin_{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 mp_owner(db): + """Create a store owner user.""" + from middleware.auth import AuthManager + + auth = AuthManager() + user = User( + email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com", + username=f"mpowner_{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 mp_platform_no_marketplace(db): + """Create a platform without marketplace module enabled.""" + platform = Platform( + code=f"mpnm_{uuid.uuid4().hex[:8]}", + name="No Marketplace Platform", + is_active=True, + ) + db.add(platform) + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def mp_platform_with_marketplace(db, mp_admin): + """Create a platform with marketplace module enabled.""" + platform = Platform( + code=f"mpmk_{uuid.uuid4().hex[:8]}", + name="Marketplace Platform", + is_active=True, + ) + db.add(platform) + db.flush() + + pm = PlatformModule( + platform_id=platform.id, + module_code="marketplace", + is_enabled=True, + enabled_at=datetime.now(UTC), + enabled_by_user_id=mp_admin.id, + config={}, + ) + db.add(pm) + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def mp_merchant(db, mp_owner): + """Create a merchant.""" + merchant = Merchant( + name="MP Test Merchant", + owner_user_id=mp_owner.id, + contact_email=mp_owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + return merchant + + +@pytest.fixture +def mp_store(db, mp_merchant): + """Create a store.""" + uid = uuid.uuid4().hex[:8] + store = Store( + merchant_id=mp_merchant.id, + store_code=f"MPSTORE_{uid.upper()}", + subdomain=f"mpstore{uid.lower()}", + name="MP Test Store", + is_active=True, + is_verified=True, + ) + db.add(store) + db.commit() + db.refresh(store) + return store + + +@pytest.fixture +def mp_store_no_marketplace(db, mp_store, mp_platform_no_marketplace): + """Link store to a platform without marketplace.""" + sp = StorePlatform( + store_id=mp_store.id, + platform_id=mp_platform_no_marketplace.id, + is_active=True, + ) + db.add(sp) + db.commit() + db.refresh(sp) + return sp + + +@pytest.fixture +def mp_store_with_marketplace(db, mp_store, mp_platform_with_marketplace): + """Link store to a platform with marketplace enabled.""" + sp = StorePlatform( + store_id=mp_store.id, + platform_id=mp_platform_with_marketplace.id, + is_active=True, + ) + db.add(sp) + db.commit() + db.refresh(sp) + return sp + + +@pytest.fixture +def mp_onboarding_not_completed(db, mp_store): + """Create an incomplete onboarding record for the store.""" + onboarding = StoreOnboarding( + store_id=mp_store.id, + status=OnboardingStatus.IN_PROGRESS.value, + ) + db.add(onboarding) + db.commit() + db.refresh(onboarding) + return onboarding + + +@pytest.fixture +def mp_onboarding_completed(db, mp_store): + """Create a completed onboarding record for the store.""" + onboarding = StoreOnboarding( + store_id=mp_store.id, + status=OnboardingStatus.COMPLETED.value, + step_merchant_profile_completed=True, + step_letzshop_api_completed=True, + step_product_import_completed=True, + step_order_sync_completed=True, + ) + db.add(onboarding) + db.commit() + db.refresh(onboarding) + return onboarding + + +@pytest.fixture +def mp_auth(mp_owner, mp_store): + """Override auth dependency for store cookie/header auth.""" + user_context = UserContext( + id=mp_owner.id, + email=mp_owner.email, + username=mp_owner.username, + role="merchant_owner", + is_active=True, + token_store_id=mp_store.id, + token_store_code=mp_store.store_code, + ) + + def _override(): + return user_context + + app.dependency_overrides[get_current_store_from_cookie_or_header] = _override + yield {"Authorization": "Bearer fake-token"} + app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None) + + +# ============================================================================ +# Onboarding page tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.marketplace +class TestOnboardingPageRoutes: + """Tests for GET /store/{store_code}/onboarding.""" + + def test_redirects_to_dashboard_on_non_marketplace_platform( + self, client, db, mp_auth, mp_store, mp_store_no_marketplace + ): + """Onboarding page redirects to dashboard on platform without marketplace.""" + response = client.get( + f"/store/{mp_store.subdomain}/onboarding", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 302 + assert f"/store/{mp_store.subdomain}/dashboard" in response.headers["location"] + + def test_redirects_to_dashboard_when_onboarding_completed( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + mp_onboarding_completed, + ): + """Onboarding page redirects to dashboard when already completed.""" + response = client.get( + f"/store/{mp_store.subdomain}/onboarding", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 302 + assert f"/store/{mp_store.subdomain}/dashboard" in response.headers["location"] + + def test_renders_onboarding_when_not_completed( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + mp_onboarding_not_completed, + ): + """Onboarding page renders wizard when marketplace enabled and not completed.""" + response = client.get( + f"/store/{mp_store.subdomain}/onboarding", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 200 + + def test_renders_onboarding_when_no_onboarding_record( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + ): + """Onboarding page renders wizard when marketplace enabled and no record exists.""" + # No mp_onboarding_* fixture — is_completed() returns False for missing record + response = client.get( + f"/store/{mp_store.subdomain}/onboarding", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 200 + + def test_requires_auth(self, client): + """Returns 401 without auth.""" + app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None) + response = client.get( + "/store/anystore/onboarding", + follow_redirects=False, + ) + assert response.status_code == 401 + + +# ============================================================================ +# Marketplace page tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.marketplace +class TestMarketplacePageRoutes: + """Tests for GET /store/{store_code}/marketplace.""" + + def test_redirects_to_onboarding_when_not_completed( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + mp_onboarding_not_completed, + ): + """Marketplace page redirects to onboarding when not completed.""" + response = client.get( + f"/store/{mp_store.subdomain}/marketplace", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 302 + assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"] + + def test_renders_when_onboarding_completed( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + mp_onboarding_completed, + ): + """Marketplace page renders when onboarding is completed.""" + response = client.get( + f"/store/{mp_store.subdomain}/marketplace", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 200 + + def test_redirects_when_no_onboarding_record( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + ): + """Marketplace page redirects when no onboarding record (not completed).""" + response = client.get( + f"/store/{mp_store.subdomain}/marketplace", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 302 + assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"] + + +# ============================================================================ +# Letzshop page tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.marketplace +class TestLetzshopPageRoutes: + """Tests for GET /store/{store_code}/letzshop.""" + + def test_redirects_to_onboarding_when_not_completed( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + mp_onboarding_not_completed, + ): + """Letzshop page redirects to onboarding when not completed.""" + response = client.get( + f"/store/{mp_store.subdomain}/letzshop", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 302 + assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"] + + def test_renders_when_onboarding_completed( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + mp_onboarding_completed, + ): + """Letzshop page renders when onboarding is completed.""" + response = client.get( + f"/store/{mp_store.subdomain}/letzshop", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 200 + + def test_redirects_when_no_onboarding_record( + self, client, db, mp_auth, mp_store, mp_store_with_marketplace, + ): + """Letzshop page redirects when no onboarding record (not completed).""" + response = client.get( + f"/store/{mp_store.subdomain}/letzshop", + headers=mp_auth, + follow_redirects=False, + ) + assert response.status_code == 302 + assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"] diff --git a/app/modules/marketplace/tests/unit/test_store_page_routes.py b/app/modules/marketplace/tests/unit/test_store_page_routes.py new file mode 100644 index 00000000..2e68125f --- /dev/null +++ b/app/modules/marketplace/tests/unit/test_store_page_routes.py @@ -0,0 +1,298 @@ +# app/modules/marketplace/tests/unit/test_store_page_routes.py +""" +Unit tests for marketplace store page routes. + +Tests the onboarding, marketplace, and letzshop page route logic: +- Onboarding page guards (marketplace module check, completion redirect) +- Marketplace page onboarding gate +- Letzshop page onboarding gate +""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import Request +from fastapi.responses import RedirectResponse + +from app.modules.marketplace.routes.pages.store import ( + store_letzshop_page, + store_marketplace_page, + store_onboarding_page, +) +from models.schema.auth import UserContext + + +def _make_user_context(store_id: int = 1, store_code: str = "teststore") -> UserContext: + """Create a UserContext for testing.""" + return UserContext( + id=1, + email="test@test.com", + username="testuser", + role="merchant_owner", + is_active=True, + token_store_id=store_id, + token_store_code=store_code, + ) + + +def _make_request() -> MagicMock: + """Create a mock Request.""" + return MagicMock(spec=Request) + + +# ============================================================================ +# ONBOARDING PAGE +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.marketplace +@pytest.mark.asyncio +class TestOnboardingPageGuard: + """Test that onboarding page redirects to dashboard when marketplace is not enabled.""" + + @patch("app.modules.marketplace.routes.pages.store.module_service") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_redirects_to_dashboard_when_no_store_platform( + self, mock_templates, mock_ctx, mock_module_service, db + ): + """Redirect to dashboard if store has no StorePlatform record.""" + user = _make_user_context(store_id=99999) + response = await store_onboarding_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + assert "/store/teststore/dashboard" in response.headers["location"] + + @patch("app.modules.marketplace.routes.pages.store.module_service") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_redirects_to_dashboard_when_marketplace_not_enabled( + self, mock_templates, mock_ctx, mock_module_service, db, test_store + ): + """Redirect to dashboard if marketplace module is not enabled on platform.""" + from app.modules.tenancy.models import Platform + from app.modules.tenancy.models.store_platform import StorePlatform + + platform = Platform(code="nomarket", name="No Marketplace", is_active=True) + db.add(platform) + db.flush() + + sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True) + db.add(sp) + db.commit() + + mock_module_service.is_module_enabled.return_value = False + + user = _make_user_context(store_id=test_store.id) + response = await store_onboarding_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + assert "/store/teststore/dashboard" in response.headers["location"] + mock_module_service.is_module_enabled.assert_called_once_with( + db, platform.id, "marketplace" + ) + + @patch("app.modules.marketplace.routes.pages.store.OnboardingService") + @patch("app.modules.marketplace.routes.pages.store.module_service") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_redirects_to_dashboard_when_onboarding_completed( + self, mock_templates, mock_ctx, mock_module_service, mock_onboarding_cls, db, test_store + ): + """Redirect to dashboard if onboarding is already completed.""" + from app.modules.tenancy.models import Platform + from app.modules.tenancy.models.store_platform import StorePlatform + + platform = Platform(code="mktplace", name="Marketplace", is_active=True) + db.add(platform) + db.flush() + + sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True) + db.add(sp) + db.commit() + + mock_module_service.is_module_enabled.return_value = True + mock_svc = MagicMock() + mock_svc.is_completed.return_value = True + mock_onboarding_cls.return_value = mock_svc + + user = _make_user_context(store_id=test_store.id) + response = await store_onboarding_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + assert "/store/teststore/dashboard" in response.headers["location"] + + @patch("app.modules.marketplace.routes.pages.store.OnboardingService") + @patch("app.modules.marketplace.routes.pages.store.module_service") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_renders_onboarding_when_marketplace_enabled_and_not_completed( + self, mock_templates, mock_ctx, mock_module_service, mock_onboarding_cls, db, test_store + ): + """Render onboarding wizard when marketplace enabled and onboarding not completed.""" + from app.modules.tenancy.models import Platform + from app.modules.tenancy.models.store_platform import StorePlatform + + platform = Platform(code="mktplace2", name="Marketplace 2", is_active=True) + db.add(platform) + db.flush() + + sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True) + db.add(sp) + db.commit() + + mock_module_service.is_module_enabled.return_value = True + mock_svc = MagicMock() + mock_svc.is_completed.return_value = False + mock_onboarding_cls.return_value = mock_svc + + mock_ctx.return_value = {"request": _make_request()} + mock_templates.TemplateResponse.return_value = "rendered" + + user = _make_user_context(store_id=test_store.id) + response = await store_onboarding_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert response == "rendered" + mock_templates.TemplateResponse.assert_called_once() + template_name = mock_templates.TemplateResponse.call_args[0][0] + assert template_name == "marketplace/store/onboarding.html" + + +# ============================================================================ +# MARKETPLACE PAGE +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.marketplace +@pytest.mark.asyncio +class TestMarketplacePageOnboardingGate: + """Test that marketplace page redirects to onboarding when not completed.""" + + @patch("app.modules.marketplace.routes.pages.store.OnboardingService") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_redirects_to_onboarding_when_not_completed( + self, mock_templates, mock_ctx, mock_onboarding_cls, db + ): + """Redirect to onboarding if onboarding not completed.""" + mock_svc = MagicMock() + mock_svc.is_completed.return_value = False + mock_onboarding_cls.return_value = mock_svc + + user = _make_user_context() + response = await store_marketplace_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + assert "/store/teststore/onboarding" in response.headers["location"] + + @patch("app.modules.marketplace.routes.pages.store.OnboardingService") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_renders_marketplace_when_onboarding_completed( + self, mock_templates, mock_ctx, mock_onboarding_cls, db + ): + """Render marketplace page if onboarding is completed.""" + mock_svc = MagicMock() + mock_svc.is_completed.return_value = True + mock_onboarding_cls.return_value = mock_svc + + mock_ctx.return_value = {"request": _make_request()} + mock_templates.TemplateResponse.return_value = "rendered" + + user = _make_user_context() + response = await store_marketplace_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert response == "rendered" + mock_templates.TemplateResponse.assert_called_once() + template_name = mock_templates.TemplateResponse.call_args[0][0] + assert template_name == "marketplace/store/marketplace.html" + + +# ============================================================================ +# LETZSHOP PAGE +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.marketplace +@pytest.mark.asyncio +class TestLetzshopPageOnboardingGate: + """Test that letzshop page redirects to onboarding when not completed.""" + + @patch("app.modules.marketplace.routes.pages.store.OnboardingService") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_redirects_to_onboarding_when_not_completed( + self, mock_templates, mock_ctx, mock_onboarding_cls, db + ): + """Redirect to onboarding if onboarding not completed.""" + mock_svc = MagicMock() + mock_svc.is_completed.return_value = False + mock_onboarding_cls.return_value = mock_svc + + user = _make_user_context() + response = await store_letzshop_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert isinstance(response, RedirectResponse) + assert response.status_code == 302 + assert "/store/teststore/onboarding" in response.headers["location"] + + @patch("app.modules.marketplace.routes.pages.store.OnboardingService") + @patch("app.modules.marketplace.routes.pages.store.get_store_context") + @patch("app.modules.marketplace.routes.pages.store.templates") + async def test_renders_letzshop_when_onboarding_completed( + self, mock_templates, mock_ctx, mock_onboarding_cls, db + ): + """Render letzshop page if onboarding is completed.""" + mock_svc = MagicMock() + mock_svc.is_completed.return_value = True + mock_onboarding_cls.return_value = mock_svc + + mock_ctx.return_value = {"request": _make_request()} + mock_templates.TemplateResponse.return_value = "rendered" + + user = _make_user_context() + response = await store_letzshop_page( + request=_make_request(), + store_code="teststore", + current_user=user, + db=db, + ) + assert response == "rendered" + mock_templates.TemplateResponse.assert_called_once() + template_name = mock_templates.TemplateResponse.call_args[0][0] + assert template_name == "marketplace/store/letzshop.html" diff --git a/app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html b/app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html index 3e31f079..0cdaf690 100644 --- a/app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html +++ b/app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html @@ -20,7 +20,7 @@

- Configure which menu items are visible for admins and stores on this platform. + Configure which menu items are visible for admins, stores, and merchants on this platform.

@@ -52,6 +52,17 @@ Store Frontend + diff --git a/app/modules/tenancy/templates/tenancy/store/login.html b/app/modules/tenancy/templates/tenancy/store/login.html index 5ebb6303..109a742c 100644 --- a/app/modules/tenancy/templates/tenancy/store/login.html +++ b/app/modules/tenancy/templates/tenancy/store/login.html @@ -181,10 +181,24 @@ - - + + - + diff --git a/app/templates/store/partials/sidebar.html b/app/templates/store/partials/sidebar.html index 4d2cc8d5..8e1df7bd 100644 --- a/app/templates/store/partials/sidebar.html +++ b/app/templates/store/partials/sidebar.html @@ -1,60 +1,5 @@ {# app/templates/store/partials/sidebar.html #} -{# Collapsible sidebar sections with localStorage persistence - matching admin pattern #} - -{# ============================================================================ - REUSABLE MACROS FOR SIDEBAR ITEMS - ============================================================================ #} - -{# Macro for collapsible section header #} -{% macro section_header(title, section_key, icon=none) %} -
-
-
- -{% endmacro %} - -{# Macro for collapsible section content wrapper #} -{% macro section_content(section_key) %} - -{% endmacro %} - -{# Macro for menu item - uses storeCode for dynamic URLs #} -{% macro menu_item(page_id, path, icon, label) %} -
  • - - - - {{ label }} - -
  • -{% endmacro %} +{# Dynamic sidebar driven by menu discovery API - matching merchant/admin pattern #} {# ============================================================================ SIDEBAR CONTENT (shared between desktop and mobile) @@ -69,57 +14,94 @@ - - + +
    +
    +
    +
    +
    - - {{ section_header('Products & Inventory', 'products', 'cube') }} - {% call section_content('products') %} - {{ menu_item('products', 'products', 'shopping-bag', 'All Products') }} - {{ menu_item('inventory', 'inventory', 'clipboard-list', 'Inventory') }} - {{ menu_item('marketplace', 'marketplace', 'download', 'Marketplace Import') }} - {% endcall %} + +
    + +
    - - {{ section_header('Customers', 'customers', 'users') }} - {% call section_content('customers') %} - {{ menu_item('customers', 'customers', 'user-group', 'All Customers') }} - {{ menu_item('messages', 'messages', 'chat-bubble-left-right', 'Messages') }} - {{ menu_item('notifications', 'notifications', 'bell', 'Notifications') }} - {% endcall %} + +
    + +
    - - {{ section_header('Shop & Content', 'shop', 'color-swatch') }} - {% call section_content('shop') %} - {{ menu_item('content-pages', 'content-pages', 'document-text', 'Content Pages') }} - {{ menu_item('media', 'media', 'photograph', 'Media Library') }} - {# Future: Theme customization, if enabled for store tier - {{ menu_item('theme', 'theme', 'paint-brush', 'Theme') }} - #} - {% endcall %} - - - {{ section_header('Account & Settings', 'account', 'cog') }} - {% call section_content('account') %} - {{ menu_item('team', 'team', 'user-group', 'Team') }} - {{ menu_item('profile', 'profile', 'user', 'Profile') }} - {{ menu_item('billing', 'billing', 'credit-card', 'Billing') }} - {{ menu_item('email-templates', 'email-templates', 'mail', 'Email Templates') }} - {{ menu_item('settings', 'settings', 'adjustments', 'Settings') }} - {% endcall %} - - +