refactor: rename public routes and templates to platform

Complete the public -> platform naming migration across the codebase.
This aligns with the naming convention where "platform" refers to
the marketing/public-facing pages of the platform itself.

Changes:
- Update all imports from public to platform modules
- Update template references from public/ to platform/
- Update route registrations to use platform prefix
- Update documentation to reflect new naming
- Update test files for platform API endpoints

Files affected:
- app/api/main.py - router imports
- app/modules/*/routes/*/platform.py - route definitions
- app/modules/*/templates/*/platform/ - template files
- app/modules/routes.py - route discovery
- docs/* - documentation updates
- tests/integration/api/v1/platform/ - test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 18:49:39 +01:00
parent 967f08e4ba
commit fb8cb14506
44 changed files with 980 additions and 327 deletions

View File

@@ -10,7 +10,7 @@ This module provides:
from fastapi import APIRouter
from app.api.v1 import admin, public, storefront, vendor, webhooks
from app.api.v1 import admin, platform, storefront, vendor, webhooks
api_router = APIRouter()
@@ -37,12 +37,12 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"])
api_router.include_router(storefront.router, prefix="/v1/storefront", tags=["storefront"])
# ============================================================================
# PUBLIC ROUTES (Unauthenticated endpoints)
# Prefix: /api/v1/public
# PLATFORM ROUTES (Unauthenticated endpoints)
# Prefix: /api/v1/platform
# Includes: /signup, /pricing, /letzshop-vendors, /language
# ============================================================================
api_router.include_router(public.router, prefix="/v1/public", tags=["public"])
api_router.include_router(platform.router, prefix="/v1/platform", tags=["platform"])
# ============================================================================
# WEBHOOK ROUTES (External service callbacks via auto-discovery)

View File

@@ -1,11 +1,11 @@
# app/api/v1/public/__init__.py
# app/api/v1/platform/__init__.py
"""
Public API endpoints (no authentication required).
Platform API endpoints (no authentication required).
Includes:
- signup: /signup/* (multi-step signup flow - cross-cutting)
Auto-discovers and aggregates public routes from self-contained modules:
Auto-discovers and aggregates platform routes from self-contained modules:
- billing: /pricing/* (subscription tiers and add-ons)
- marketplace: /letzshop-vendors/* (vendor lookup for signup)
- core: /language/* (language preferences)
@@ -15,16 +15,16 @@ These endpoints serve the marketing homepage, pricing pages, and signup flows.
from fastapi import APIRouter
from app.api.v1.public import signup
from app.modules.routes import get_public_api_routes
from app.api.v1.platform import signup
from app.modules.routes import get_platform_api_routes
router = APIRouter()
# Cross-cutting signup flow (spans auth, vendors, billing, payments)
router.include_router(signup.router, tags=["public-signup"])
router.include_router(signup.router, tags=["platform-signup"])
# Auto-discover public routes from modules
for route_info in get_public_api_routes():
# Auto-discover platform routes from modules
for route_info in get_platform_api_routes():
if route_info.custom_prefix:
router.include_router(
route_info.router,

View File

@@ -1,4 +1,4 @@
# app/api/v1/public/signup.py
# app/api/v1/platform/signup.py
"""
Platform signup API endpoints.

View File

@@ -1,11 +1,11 @@
# app/modules/billing/routes/api/public.py
# app/modules/billing/routes/api/platform.py
"""
Public pricing API endpoints.
Platform pricing API endpoints.
Provides subscription tier and add-on product information
for the marketing homepage and signup flow.
All endpoints are public (no authentication required).
All endpoints are unauthenticated (no authentication required).
"""
from fastapi import APIRouter, Depends

View File

@@ -1,8 +1,8 @@
# app/modules/billing/routes/pages/public.py
# app/modules/billing/routes/pages/platform.py
"""
Billing Public Page Routes (HTML rendering).
Billing Platform Page Routes (HTML rendering).
Public (unauthenticated) pages for pricing and signup:
Platform (unauthenticated) pages for pricing and signup:
- Pricing page
- Signup wizard
- Signup success
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.core.utils.page_context import get_public_context
from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates
router = APIRouter()
@@ -57,12 +57,12 @@ async def pricing_page(
"""
Standalone pricing page with detailed tier comparison.
"""
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data()
context["page_title"] = "Pricing"
return templates.TemplateResponse(
"billing/public/pricing.html",
"billing/platform/pricing.html",
context,
)
@@ -86,14 +86,14 @@ async def signup_page(
- tier: Pre-selected tier code
- annual: Pre-select annual billing
"""
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["page_title"] = "Start Your Free Trial"
context["selected_tier"] = tier
context["is_annual"] = annual
context["tiers"] = _get_tiers_data()
return templates.TemplateResponse(
"billing/public/signup.html",
"billing/platform/signup.html",
context,
)
@@ -111,11 +111,11 @@ async def signup_success_page(
Shown after successful account creation.
"""
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["page_title"] = "Welcome to Wizamart!"
context["vendor_code"] = vendor_code
return templates.TemplateResponse(
"billing/public/signup-success.html",
"billing/platform/signup-success.html",
context,
)

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/pricing.html #}
{# Standalone Pricing Page #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}{{ _("cms.platform.pricing.title") }} - Wizamart{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/signup-success.html #}
{# Signup Success Page #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}{{ _("cms.platform.success.title") }}{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/signup.html #}
{# Multi-step Signup Wizard #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}Start Your Free Trial - Wizamart{% endblock %}
@@ -321,7 +321,7 @@ function signupWizard() {
async startSignup() {
this.loading = true;
try {
const response = await fetch('/api/v1/public/signup/start', {
const response = await fetch('/api/v1/platform/signup/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -352,7 +352,7 @@ function signupWizard() {
try {
// First lookup the vendor
const lookupResponse = await fetch('/api/v1/public/letzshop-vendors/lookup', {
const lookupResponse = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.letzshopUrl })
@@ -364,7 +364,7 @@ function signupWizard() {
this.letzshopVendor = lookupData.vendor;
// Claim the vendor
const claimResponse = await fetch('/api/v1/public/signup/claim-vendor', {
const claimResponse = await fetch('/api/v1/platform/signup/claim-vendor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -411,7 +411,7 @@ function signupWizard() {
this.accountError = null;
try {
const response = await fetch('/api/v1/public/signup/create-account', {
const response = await fetch('/api/v1/platform/signup/create-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -461,7 +461,7 @@ function signupWizard() {
// Get SetupIntent
try {
const response = await fetch('/api/v1/public/signup/setup-payment', {
const response = await fetch('/api/v1/platform/signup/setup-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId })
@@ -500,7 +500,7 @@ function signupWizard() {
}
// Complete signup
const response = await fetch('/api/v1/public/signup/complete', {
const response = await fetch('/api/v1/platform/signup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -1,8 +1,8 @@
# app/modules/cms/routes/pages/public.py
# app/modules/cms/routes/pages/platform.py
"""
CMS Public Page Routes (HTML rendering).
CMS Platform Page Routes (HTML rendering).
Public (unauthenticated) pages for platform content:
Platform (unauthenticated) pages for platform content:
- Homepage
- Generic content pages (/{slug} catch-all)
"""
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.cms.services import content_page_service
from app.modules.core.utils.page_context import get_public_context
from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates
logger = logging.getLogger(__name__)
@@ -147,19 +147,19 @@ async def homepage(
if cms_homepage:
# Use CMS-based homepage with template selection
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["page"] = cms_homepage
context["tiers"] = _get_tiers_data()
template_name = cms_homepage.template or "default"
template_path = f"cms/public/homepage-{template_name}.html"
template_path = f"cms/platform/homepage-{template_name}.html"
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
return templates.TemplateResponse(template_path, context)
# Fallback: Default wizamart homepage (no CMS content)
logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template")
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data()
# Add-ons (hardcoded for now, will come from DB)
@@ -196,7 +196,7 @@ async def homepage(
]
return templates.TemplateResponse(
"cms/public/homepage-wizamart.html",
"cms/platform/homepage-wizamart.html",
context,
)
@@ -231,11 +231,11 @@ async def content_page(
if not page:
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["page"] = page
context["page_title"] = page.title
return templates.TemplateResponse(
"cms/public/content-page.html",
"cms/platform/content-page.html",
context,
)

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/content-page.html #}
{# Generic template for platform content pages (About, FAQ, Terms, Contact, etc.) #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}{{ page.title }} - Marketplace{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-default.html #}
{# Default platform homepage template with section-based rendering #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{# Import section partials #}
{% from 'platform/sections/_hero.html' import render_hero %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-minimal.html #}
{# Minimal/clean platform homepage template #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-modern.html #}
{# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}
Wizamart - The Back-Office for Letzshop Sellers

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-wizamart.html #}
{# Wizamart Marketing Homepage - Letzshop OMS Platform #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% from 'shared/macros/inputs.html' import toggle_switch %}
{% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %}
@@ -407,7 +407,7 @@ function homepageData() {
this.vendorResult = null;
try {
const response = await fetch('/api/v1/public/letzshop-vendors/lookup', {
const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.shopUrl })

View File

@@ -1,13 +1,13 @@
# app/modules/core/routes/api/public.py
# app/modules/core/routes/api/platform.py
"""
Public language API endpoints.
Platform language API endpoints.
Handles:
- Setting language preference via cookie
- Getting current language info
- Listing available languages
All endpoints are public (no authentication required).
All endpoints are unauthenticated (no authentication required).
"""
import logging

View File

@@ -5,12 +5,12 @@ from .page_context import (
get_admin_context,
get_vendor_context,
get_storefront_context,
get_public_context,
get_platform_context,
)
__all__ = [
"get_admin_context",
"get_vendor_context",
"get_storefront_context",
"get_public_context",
"get_platform_context",
]

View File

@@ -253,13 +253,13 @@ def get_storefront_context(
return context
def get_public_context(
def get_platform_context(
request: Request,
db: Session,
**extra_context,
) -> dict:
"""
Build context for public/marketing pages.
Build context for platform/marketing pages.
Includes platform info, i18n globals, and CMS navigation pages.

View File

@@ -1,8 +1,8 @@
# app/modules/loyalty/routes/api/public.py
# app/modules/loyalty/routes/api/platform.py
"""
Loyalty module public routes.
Loyalty module platform routes.
Public endpoints for:
Platform endpoints for:
- Customer enrollment (by vendor code)
- Apple Wallet pass download
- Apple Web Service endpoints for device registration/updates
@@ -29,8 +29,8 @@ from app.modules.loyalty.services import (
logger = logging.getLogger(__name__)
# Public router (no auth required for some endpoints)
public_router = APIRouter(prefix="/loyalty")
# Platform router (no auth required for some endpoints)
platform_router = APIRouter(prefix="/loyalty")
# =============================================================================
@@ -38,7 +38,7 @@ public_router = APIRouter(prefix="/loyalty")
# =============================================================================
@public_router.get("/programs/{vendor_code}")
@platform_router.get("/programs/{vendor_code}")
def get_program_by_vendor_code(
vendor_code: str = Path(..., min_length=1, max_length=50),
db: Session = Depends(get_db),
@@ -85,7 +85,7 @@ def get_program_by_vendor_code(
# =============================================================================
@public_router.get("/passes/apple/{serial_number}.pkpass")
@platform_router.get("/passes/apple/{serial_number}.pkpass")
def download_apple_pass(
serial_number: str = Path(...),
db: Session = Depends(get_db),
@@ -122,7 +122,7 @@ def download_apple_pass(
# =============================================================================
@public_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
@platform_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
def register_device(
device_id: str = Path(...),
pass_type_id: str = Path(...),
@@ -165,7 +165,7 @@ def register_device(
raise HTTPException(status_code=500)
@public_router.delete("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
@platform_router.delete("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
def unregister_device(
device_id: str = Path(...),
pass_type_id: str = Path(...),
@@ -205,7 +205,7 @@ def unregister_device(
raise HTTPException(status_code=500)
@public_router.get("/apple/v1/devices/{device_id}/registrations/{pass_type_id}")
@platform_router.get("/apple/v1/devices/{device_id}/registrations/{pass_type_id}")
def get_serial_numbers(
device_id: str = Path(...),
pass_type_id: str = Path(...),
@@ -256,7 +256,7 @@ def get_serial_numbers(
}
@public_router.get("/apple/v1/passes/{pass_type_id}/{serial_number}")
@platform_router.get("/apple/v1/passes/{pass_type_id}/{serial_number}")
def get_latest_pass(
pass_type_id: str = Path(...),
serial_number: str = Path(...),
@@ -302,7 +302,7 @@ def get_latest_pass(
)
@public_router.post("/apple/v1/log")
@platform_router.post("/apple/v1/log")
def log_errors():
"""
Receive error logs from Apple.

View File

@@ -1,11 +1,11 @@
# app/modules/marketplace/routes/api/public.py
# app/modules/marketplace/routes/api/platform.py
"""
Public Letzshop vendor lookup API endpoints.
Platform Letzshop vendor lookup API endpoints.
Allows potential vendors to find themselves in the Letzshop marketplace
and claim their shop during signup.
All endpoints are public (no authentication required).
All endpoints are unauthenticated (no authentication required).
"""
import logging

View File

@@ -1,8 +1,8 @@
# app/modules/marketplace/routes/pages/public.py
# app/modules/marketplace/routes/pages/platform.py
"""
Marketplace Public Page Routes (HTML rendering).
Marketplace Platform Page Routes (HTML rendering).
Public (unauthenticated) pages:
Platform (unauthenticated) pages:
- Find shop (Letzshop vendor browser)
"""
@@ -11,7 +11,7 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.core.utils.page_context import get_public_context
from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates
router = APIRouter()
@@ -32,10 +32,10 @@ async def find_shop_page(
Allows vendors to search for and claim their Letzshop shop.
"""
context = get_public_context(request, db)
context = get_platform_context(request, db)
context["page_title"] = "Find Your Letzshop Shop"
return templates.TemplateResponse(
"marketplace/public/find-shop.html",
"marketplace/platform/find-shop.html",
context,
)

View File

@@ -1,6 +1,6 @@
{# app/modules/marketplace/templates/marketplace/public/find-shop.html #}
{# Letzshop Vendor Finder Page #}
{% extends "public/base.html" %}
{% extends "platform/base.html" %}
{% block title %}{{ _("cms.platform.find_shop.title") }} - Wizamart{% endblock %}
@@ -151,7 +151,7 @@ function vendorFinderData() {
this.result = null;
try {
const response = await fetch('/api/v1/public/letzshop-vendors/lookup', {
const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.searchQuery })

View File

@@ -183,9 +183,9 @@ def _discover_routes_in_dir(
"pages_prefix": "/storefront",
"include_in_schema": True if route_type == "api" else False,
},
"public": {
"api_prefix": "/api/v1/public",
"pages_prefix": "/public",
"platform": {
"api_prefix": "/api/v1/platform",
"pages_prefix": "/platform",
"include_in_schema": True,
},
"webhooks": {
@@ -335,16 +335,16 @@ def get_storefront_api_routes() -> list[RouteInfo]:
return sorted(routes, key=lambda r: r.priority)
def get_public_api_routes() -> list[RouteInfo]:
def get_platform_api_routes() -> list[RouteInfo]:
"""
Get public API routes from modules, sorted by priority.
Get platform API routes from modules, sorted by priority.
Public routes are unauthenticated endpoints for marketing pages,
Platform routes are unauthenticated endpoints for marketing pages,
pricing info, and other public-facing features.
"""
routes = [
r for r in discover_module_routes()
if r.route_type == "api" and r.frontend == "public"
if r.route_type == "api" and r.frontend == "platform"
]
return sorted(routes, key=lambda r: r.priority)
@@ -363,11 +363,11 @@ def get_webhooks_api_routes() -> list[RouteInfo]:
return sorted(routes, key=lambda r: r.priority)
def get_public_page_routes() -> list[RouteInfo]:
def get_platform_page_routes() -> list[RouteInfo]:
"""
Get public (marketing) page routes from modules, sorted by priority.
Get platform (marketing) page routes from modules, sorted by priority.
Public pages are unauthenticated marketing pages like:
Platform pages are unauthenticated marketing pages like:
- Homepage (/)
- Pricing (/pricing)
- Signup (/signup)
@@ -379,7 +379,7 @@ def get_public_page_routes() -> list[RouteInfo]:
"""
routes = [
r for r in discover_module_routes()
if r.route_type == "pages" and r.frontend == "public"
if r.route_type == "pages" and r.frontend == "platform"
]
return sorted(routes, key=lambda r: r.priority)
@@ -414,8 +414,8 @@ __all__ = [
"get_admin_api_routes",
"get_vendor_api_routes",
"get_storefront_api_routes",
"get_public_api_routes",
"get_platform_api_routes",
"get_webhooks_api_routes",
"get_public_page_routes",
"get_platform_page_routes",
"get_storefront_page_routes",
]

View File

@@ -135,6 +135,6 @@
</script>
<!-- 6. Login Logic -->
<script src="{{ url_for('tenancy_static', path='admin/js/login.js') }}"></script>
<script src="{{ url_for('core_static', path='admin/js/login.js') }}"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
{# app/templates/public/base.html #}
{# Base template for public pages (homepage, about, faq, pricing, signup, etc.) #}
{# app/templates/platform/base.html #}
{# Base template for platform pages (homepage, about, faq, pricing, signup, etc.) #}
<!DOCTYPE html>
<html lang="{{ current_language|default('en') }}" x-data="platformLayoutData()" x-bind:class="{ 'dark': dark }">
<head>
@@ -42,7 +42,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='public/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
{# Flag icons for language selector #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"/>

View File

@@ -93,7 +93,7 @@ POST /api/v1/vendor/auth/login
Body: {"username": "...", "password": "..."}
# Customer
POST /api/v1/public/vendors/{vendor_id}/customers/login
POST /api/v1/platform/vendors/{vendor_id}/customers/login
Body: {"username": "...", "password": "..."}
```

View File

@@ -229,12 +229,12 @@ In path-based development mode, the full URL includes the vendor code (e.g., `/v
**Login Endpoint:**
```
POST /api/v1/public/vendors/{vendor_id}/customers/login
POST /api/v1/platform/vendors/{vendor_id}/customers/login
```
**Example Request:**
```bash
curl -X POST http://localhost:8000/api/v1/public/vendors/1/customers/login \
curl -X POST http://localhost:8000/api/v1/platform/vendors/1/customers/login \
-H "Content-Type: application/json" \
-d '{"username":"customer","password":"customer123"}'
```
@@ -950,7 +950,7 @@ curl http://localhost:8000/api/v1/admin/vendors \
```bash
# Login
curl -X POST http://localhost:8000/api/v1/public/vendors/1/customers/login \
curl -X POST http://localhost:8000/api/v1/platform/vendors/1/customers/login \
-H "Content-Type: application/json" \
-d '{"username":"customer","password":"customer123"}'

View File

@@ -748,7 +748,7 @@ role = Role(
│ Client │
└──────┬──────┘
│ POST /api/v1/public/vendors/{id}/customers/login
│ POST /api/v1/platform/vendors/{id}/customers/login
│ { username, password }
┌─────────────────────────────┐

View File

@@ -814,8 +814,8 @@ X-RateLimit-Reset: 1700000000
**Old Pattern (Deprecated):**
```http
GET /api/v1/public/vendors/{vendor_id}/products
POST /api/v1/public/vendors/auth/{vendor_id}/customers/login
GET /api/v1/platform/vendors/{vendor_id}/products
POST /api/v1/platform/vendors/auth/{vendor_id}/customers/login
```
**New Pattern (Current):**

View File

@@ -7,7 +7,7 @@
## Executive Summary
The platform currently has **two parallel API structures** for shop/customer-facing endpoints:
1. **Original:** `/api/v1/public/vendors/{vendor_id}/*`
1. **Original:** `/api/v1/platform/vendors/{vendor_id}/*`
2. **New:** `/api/v1/shop/*`
This divergence creates confusion, maintenance overhead, and potential bugs. This document analyzes the situation and proposes a consolidation strategy.
@@ -16,19 +16,19 @@ This divergence creates confusion, maintenance overhead, and potential bugs. Thi
## Current State Analysis
### 1. Original Architecture (`/api/v1/public/vendors/`)
### 1. Original Architecture (`/api/v1/platform/vendors/`)
**Location:** `app/api/v1/public/vendors/`
**Location:** `app/api/v1/platform/vendors/`
**Endpoints:**
```
GET /api/v1/public/vendors → List active vendors
GET /api/v1/public/vendors/{vendor_id}/products → Product catalog
GET /api/v1/public/vendors/{vendor_id}/products/{product_id} → Product detail
POST /api/v1/public/vendors/{vendor_id}/cart → Cart operations
GET /api/v1/public/vendors/{vendor_id}/orders → Customer orders
POST /api/v1/public/vendors/auth/login → Customer authentication
POST /api/v1/public/vendors/auth/register → Customer registration
GET /api/v1/platform/vendors → List active vendors
GET /api/v1/platform/vendors/{vendor_id}/products → Product catalog
GET /api/v1/platform/vendors/{vendor_id}/products/{product_id} → Product detail
POST /api/v1/platform/vendors/{vendor_id}/cart → Cart operations
GET /api/v1/platform/vendors/{vendor_id}/orders → Customer orders
POST /api/v1/platform/vendors/auth/login → Customer authentication
POST /api/v1/platform/vendors/auth/register → Customer registration
```
**Characteristics:**
@@ -60,7 +60,7 @@ GET /api/v1/shop/content-pages/{slug} → CMS page content
**Characteristics:**
-**Vendor-agnostic URLs:** Clean paths without vendor_id
-**Middleware-driven:** Relies on `VendorContextMiddleware` to inject vendor
-**Simpler URLs:** `/api/v1/shop/products` vs `/api/v1/public/vendors/123/products`
-**Simpler URLs:** `/api/v1/shop/products` vs `/api/v1/platform/vendors/123/products`
-**Incomplete:** Only CMS endpoints implemented
-**Divergent:** Not consistent with existing public API
@@ -80,7 +80,7 @@ GET /api/v1/shop/content-pages/{slug} → CMS page content
fetch('/api/v1/shop/content-pages/about')
// Products use old pattern
fetch('/api/v1/public/vendors/123/products')
fetch('/api/v1/platform/vendors/123/products')
```
### Confusion
@@ -143,7 +143,7 @@ Developers must remember:
**Implementation:**
- Vendor extracted by `VendorContextMiddleware` from request
- All endpoints use `request.state.vendor` instead of path parameter
- URLs are cleaner: `/api/v1/shop/products` instead of `/api/v1/public/vendors/123/products`
- URLs are cleaner: `/api/v1/shop/products` instead of `/api/v1/platform/vendors/123/products`
**Pros:**
- ✅ Clean, consistent API structure
@@ -162,18 +162,18 @@ Developers must remember:
---
### Option 2: Keep `/api/v1/public/vendors/*` and Deprecate `/api/v1/shop/*`
### Option 2: Keep `/api/v1/platform/vendors/*` and Deprecate `/api/v1/shop/*`
**Approach:** Move CMS endpoints to `/api/v1/public/vendors/{vendor_id}/content-pages/*`
**Approach:** Move CMS endpoints to `/api/v1/platform/vendors/{vendor_id}/content-pages/*`
**Proposed Changes:**
```
# Move CMS endpoints
FROM: /api/v1/shop/content-pages/navigation
TO: /api/v1/public/vendors/{vendor_id}/content-pages/navigation
TO: /api/v1/platform/vendors/{vendor_id}/content-pages/navigation
FROM: /api/v1/shop/content-pages/{slug}
TO: /api/v1/public/vendors/{vendor_id}/content-pages/{slug}
TO: /api/v1/platform/vendors/{vendor_id}/content-pages/{slug}
```
**Pros:**
@@ -240,7 +240,7 @@ async def get_products_legacy(vendor_id: int, db: Session = Depends(get_db)):
fetch('/api/v1/shop/products')
// ❌ BAD
fetch('/api/v1/public/vendors/123/products')
fetch('/api/v1/platform/vendors/123/products')
```
3. **Multi-Tenant Best Practice**: Vendor context should be implicit (from domain/path), not explicit in every API call.
@@ -258,7 +258,7 @@ async def get_products_legacy(vendor_id: int, db: Session = Depends(get_db)):
**Day 1-2: Move Products**
```bash
# Copy and adapt
app/api/v1/public/vendors/products.py → app/api/v1/shop/products.py
app/api/v1/platform/vendors/products.py → app/api/v1/shop/products.py
# Changes:
- Remove vendor_id path parameter
@@ -268,24 +268,24 @@ app/api/v1/public/vendors/products.py → app/api/v1/shop/products.py
**Day 3: Move Cart**
```bash
app/api/v1/public/vendors/cart.py → app/api/v1/shop/cart.py
app/api/v1/platform/vendors/cart.py → app/api/v1/shop/cart.py
```
**Day 4: Move Orders**
```bash
app/api/v1/public/vendors/orders.py → app/api/v1/shop/orders.py
app/api/v1/platform/vendors/orders.py → app/api/v1/shop/orders.py
```
**Day 5: Move Auth**
```bash
app/api/v1/public/vendors/auth.py → app/api/v1/shop/auth.py
app/api/v1/platform/vendors/auth.py → app/api/v1/shop/auth.py
```
### Phase 2: Update Frontend (Week 1)
**Templates:**
- Update all `fetch()` calls in shop templates
- Change from `/api/v1/public/vendors/${vendorId}/...` to `/api/v1/shop/...`
- Change from `/api/v1/platform/vendors/${vendorId}/...` to `/api/v1/shop/...`
**JavaScript:**
- Update any shop-related API client code
@@ -316,10 +316,10 @@ app/api/v1/public/vendors/auth.py → app/api/v1/shop/auth.py
## Code Examples
### Before (Current - `/api/v1/public/vendors`)
### Before (Current - `/api/v1/platform/vendors`)
```python
# app/api/v1/public/vendors/products.py
# app/api/v1/platform/vendors/products.py
@router.get("/{vendor_id}/products")
def get_public_product_catalog(
vendor_id: int = Path(...),
@@ -332,7 +332,7 @@ def get_public_product_catalog(
```javascript
// Frontend
const vendorId = 123;
fetch(`/api/v1/public/vendors/${vendorId}/products`)
fetch(`/api/v1/platform/vendors/${vendorId}/products`)
```
### After (Proposed - `/api/v1/shop`)
@@ -358,14 +358,14 @@ fetch('/api/v1/shop/products') // Vendor context automatic
## Impact Assessment
### Breaking Changes
- All frontend code calling `/api/v1/public/vendors/*` must update
- All frontend code calling `/api/v1/platform/vendors/*` must update
- Mobile apps (if any) must update
- Third-party integrations (if any) must update
### Non-Breaking
- Admin APIs: `/api/v1/admin/*` → No changes
- Vendor APIs: `/api/v1/vendor/*` → No changes
- Vendor listing: Keep `/api/v1/public/vendors` (list all vendors for marketplace)
- Vendor listing: Keep `/api/v1/platform/vendors` (list all vendors for marketplace)
### Risk Mitigation
1. **Deprecation Period**: Keep old endpoints for 2-4 weeks
@@ -383,7 +383,7 @@ If full migration is not approved immediately, we can do a **minimal fix** for t
```python
# Move: app/api/v1/shop/content_pages.py
# To: app/api/v1/public/vendors/content_pages.py
# To: app/api/v1/platform/vendors/content_pages.py
# Update routes:
@router.get("/{vendor_id}/content-pages/navigation")
@@ -402,7 +402,7 @@ If full migration is not approved immediately, we can do a **minimal fix** for t
Should we:
1. ✅ **Consolidate to `/api/v1/shop/*`** (Recommended)
2. ❌ **Keep `/api/v1/public/vendors/*`** and move CMS there
2. ❌ **Keep `/api/v1/platform/vendors/*`** and move CMS there
3. ❌ **Hybrid approach** with both patterns
4. ❌ **Quick fix only** - move CMS, address later
@@ -412,7 +412,7 @@ Should we:
## Appendix: Current Endpoint Inventory
### `/api/v1/public/vendors/*`
### `/api/v1/platform/vendors/*`
- ✅ `vendors.py` - Vendor listing
- ✅ `auth.py` - Customer authentication
- ✅ `products.py` - Product catalog

View File

@@ -174,15 +174,15 @@ Updated all shop templates to use new API endpoints:
| Template | Old Endpoint | New Endpoint | Status |
|----------|-------------|--------------|---------|
| `shop/account/login.html` | `/api/v1/public/vendors/${id}/customers/login` | `/api/v1/shop/auth/login` | ✅ Complete |
| `shop/account/register.html` | `/api/v1/public/vendors/${id}/customers/register` | `/api/v1/shop/auth/register` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/products/${pid}` | `/api/v1/shop/products/${pid}` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/products?limit=4` | `/api/v1/shop/products?limit=4` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/cart/${sid}/items` | `/api/v1/shop/cart/${sid}/items` | ✅ Complete |
| `shop/cart.html` | `/api/v1/public/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete |
| `shop/cart.html` | `/api/v1/public/vendors/${id}/cart/${sid}/items/${pid}` (PUT) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete |
| `shop/cart.html` | `/api/v1/public/vendors/${id}/cart/${sid}/items/${pid}` (DELETE) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete |
| `shop/account/login.html` | `/api/v1/platform/vendors/${id}/customers/login` | `/api/v1/shop/auth/login` | ✅ Complete |
| `shop/account/register.html` | `/api/v1/platform/vendors/${id}/customers/register` | `/api/v1/shop/auth/register` | ✅ Complete |
| `shop/product.html` | `/api/v1/platform/vendors/${id}/products/${pid}` | `/api/v1/shop/products/${pid}` | ✅ Complete |
| `shop/product.html` | `/api/v1/platform/vendors/${id}/products?limit=4` | `/api/v1/shop/products?limit=4` | ✅ Complete |
| `shop/product.html` | `/api/v1/platform/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete |
| `shop/product.html` | `/api/v1/platform/vendors/${id}/cart/${sid}/items` | `/api/v1/shop/cart/${sid}/items` | ✅ Complete |
| `shop/cart.html` | `/api/v1/platform/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete |
| `shop/cart.html` | `/api/v1/platform/vendors/${id}/cart/${sid}/items/${pid}` (PUT) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete |
| `shop/cart.html` | `/api/v1/platform/vendors/${id}/cart/${sid}/items/${pid}` (DELETE) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete |
| `shop/products.html` | Already using `/api/v1/shop/products` | (No change needed) | ✅ Already Updated |
| `shop/home.html` | Already using `/api/v1/shop/products?featured=true` | (No change needed) | ✅ Already Updated |
@@ -196,7 +196,7 @@ grep -r "api/v1/public/vendors" app/templates/shop --include="*.html"
### ✅ Phase 3: Old Endpoint Cleanup (COMPLETE)
Cleaned up old `/api/v1/public/vendors/*` endpoints:
Cleaned up old `/api/v1/platform/vendors/*` endpoints:
**Files Removed:**
-`auth.py` - Migrated to `/api/v1/shop/auth.py`
@@ -209,14 +209,14 @@ Cleaned up old `/api/v1/public/vendors/*` endpoints:
**Files Kept:**
-`vendors.py` - Vendor lookup endpoints (truly public, not shop-specific)
- `GET /api/v1/public/vendors/by-code/{vendor_code}`
- `GET /api/v1/public/vendors/by-subdomain/{subdomain}`
- `GET /api/v1/public/vendors/{vendor_id}/info`
- `GET /api/v1/platform/vendors/by-code/{vendor_code}`
- `GET /api/v1/platform/vendors/by-subdomain/{subdomain}`
- `GET /api/v1/platform/vendors/{vendor_id}/info`
**Updated:**
-`/app/api/v1/public/__init__.py` - Now only includes vendor lookup endpoints
-`/app/api/v1/platform/__init__.py` - Now only includes vendor lookup endpoints
**Result:** Old shop endpoints completely removed, only vendor lookup remains in `/api/v1/public/vendors/*`
**Result:** Old shop endpoints completely removed, only vendor lookup remains in `/api/v1/platform/vendors/*`
### ⚠️ Phase 4: Deprecation Warnings (SKIPPED - Not Needed)
@@ -252,16 +252,16 @@ Old endpoint cleanup completed immediately (no gradual migration needed):
1. ✅ Removed old endpoint files:
```bash
rm app/api/v1/public/vendors/products.py
rm app/api/v1/public/vendors/cart.py
rm app/api/v1/public/vendors/orders.py
rm app/api/v1/public/vendors/auth.py
rm app/api/v1/public/vendors/payments.py
rm app/api/v1/public/vendors/search.py
rm app/api/v1/public/vendors/shop.py
rm app/api/v1/platform/vendors/products.py
rm app/api/v1/platform/vendors/cart.py
rm app/api/v1/platform/vendors/orders.py
rm app/api/v1/platform/vendors/auth.py
rm app/api/v1/platform/vendors/payments.py
rm app/api/v1/platform/vendors/search.py
rm app/api/v1/platform/vendors/shop.py
```
2. ✅ Updated `/api/v1/public/__init__.py`:
2. ✅ Updated `/api/v1/platform/__init__.py`:
```python
# Only import vendor lookup endpoints
from .vendors import vendors
@@ -280,12 +280,12 @@ Old endpoint cleanup completed immediately (no gradual migration needed):
### Before (Old Pattern)
```
# Verbose - requires vendor_id everywhere
/api/v1/public/vendors/123/products
/api/v1/public/vendors/123/products/456
/api/v1/public/vendors/123/cart/abc-session-id
/api/v1/public/vendors/123/cart/abc-session-id/items
/api/v1/public/vendors/123/customers/789/orders
/api/v1/public/vendors/auth/123/customers/login
/api/v1/platform/vendors/123/products
/api/v1/platform/vendors/123/products/456
/api/v1/platform/vendors/123/cart/abc-session-id
/api/v1/platform/vendors/123/cart/abc-session-id/items
/api/v1/platform/vendors/123/customers/789/orders
/api/v1/platform/vendors/auth/123/customers/login
```
### After (New Pattern)

View File

@@ -452,7 +452,7 @@ All frontends communicate with backend via APIs:
- `/api/v1/admin/*` - Admin APIs
- `/api/v1/vendor/*` - Vendor APIs
- `/api/v1/storefront/*` - Storefront APIs
- `/api/v1/public/*` - Platform APIs
- `/api/v1/platform/*` - Platform APIs
**Benefits:**
- Clear backend contracts

View File

@@ -472,7 +472,7 @@ async def setup_payments(
}
# app/api/v1/public/vendors/payments.py
# app/api/v1/platform/vendors/payments.py
@router.post("/{vendor_id}/payments/create-intent")
async def create_payment_intent(
vendor_id: int,
@@ -535,7 +535,7 @@ class CheckoutManager {
async initializePayment(orderData) {
// Create payment intent
const response = await fetch(`/api/v1/public/vendors/${this.vendorId}/payments/create-intent`, {
const response = await fetch(`/api/v1/platform/vendors/${this.vendorId}/payments/create-intent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -585,7 +585,7 @@ Customer proceeds to checkout
System creates Order (payment_status: pending)
Frontend calls POST /api/v1/public/vendors/{vendor_id}/payments/create-intent
Frontend calls POST /api/v1/platform/vendors/{vendor_id}/payments/create-intent
PaymentService creates Stripe PaymentIntent with vendor destination

View File

@@ -53,7 +53,7 @@ app/api/v1/vendor/
├── inventory.py # Handles inventory items
└── settings.py # Exception: not a resource collection
app/api/v1/public/vendors/
app/api/v1/platform/vendors/
├── products.py # Public product catalog
├── orders.py # Order placement
└── auth.py # Exception: authentication service

View File

@@ -132,24 +132,24 @@ Standalone page with:
## API Endpoints
All endpoints under `/api/v1/public/`:
All endpoints under `/api/v1/platform/`:
### Pricing Endpoints
```
GET /api/v1/public/tiers
GET /api/v1/platform/tiers
Returns all public subscription tiers
Response: TierResponse[]
GET /api/v1/public/tiers/{tier_code}
GET /api/v1/platform/tiers/{tier_code}
Returns specific tier by code
Response: TierResponse
GET /api/v1/public/addons
GET /api/v1/platform/addons
Returns all active add-on products
Response: AddOnResponse[]
GET /api/v1/public/pricing
GET /api/v1/platform/pricing
Returns complete pricing info (tiers + addons + trial_days)
Response: PricingResponse
```
@@ -157,17 +157,17 @@ GET /api/v1/public/pricing
### Letzshop Vendor Endpoints
```
GET /api/v1/public/letzshop-vendors
GET /api/v1/platform/letzshop-vendors
Query params: ?search=&category=&city=&page=1&limit=20
Returns paginated vendor list (placeholder for future)
Response: LetzshopVendorListResponse
POST /api/v1/public/letzshop-vendors/lookup
POST /api/v1/platform/letzshop-vendors/lookup
Body: { "url": "letzshop.lu/vendors/my-shop" }
Returns vendor info from URL lookup
Response: LetzshopLookupResponse
GET /api/v1/public/letzshop-vendors/{slug}
GET /api/v1/platform/letzshop-vendors/{slug}
Returns vendor info by slug
Response: LetzshopVendorInfo
```
@@ -175,17 +175,17 @@ GET /api/v1/public/letzshop-vendors/{slug}
### Signup Endpoints
```
POST /api/v1/public/signup/start
POST /api/v1/platform/signup/start
Body: { "tier_code": "professional", "is_annual": false }
Creates signup session
Response: { "session_id": "...", "tier_code": "...", "is_annual": false }
POST /api/v1/public/signup/claim-vendor
POST /api/v1/platform/signup/claim-vendor
Body: { "session_id": "...", "letzshop_slug": "my-shop" }
Claims Letzshop vendor for session
Response: { "session_id": "...", "letzshop_slug": "...", "vendor_name": "..." }
POST /api/v1/public/signup/create-account
POST /api/v1/platform/signup/create-account
Body: {
"session_id": "...",
"email": "user@example.com",
@@ -197,17 +197,17 @@ POST /api/v1/public/signup/create-account
Creates User, Company, Vendor, Stripe Customer
Response: { "session_id": "...", "user_id": 1, "vendor_id": 1, "stripe_customer_id": "cus_..." }
POST /api/v1/public/signup/setup-payment
POST /api/v1/platform/signup/setup-payment
Body: { "session_id": "..." }
Creates Stripe SetupIntent
Response: { "session_id": "...", "client_secret": "seti_...", "stripe_customer_id": "cus_..." }
POST /api/v1/public/signup/complete
POST /api/v1/platform/signup/complete
Body: { "session_id": "...", "setup_intent_id": "seti_..." }
Completes signup, attaches payment method
Response: { "success": true, "vendor_code": "...", "vendor_id": 1, "redirect_url": "...", "trial_ends_at": "..." }
GET /api/v1/public/signup/session/{session_id}
GET /api/v1/platform/signup/session/{session_id}
Returns session status for resuming signup
Response: { "session_id": "...", "step": "...", ... }
```
@@ -430,7 +430,7 @@ STRIPE_TRIAL_DAYS=30
### Automated Tests
Test files located in `tests/integration/api/v1/public/`:
Test files located in `tests/integration/api/v1/platform/`:
| File | Tests | Description |
|------|-------|-------------|
@@ -440,7 +440,7 @@ Test files located in `tests/integration/api/v1/public/`:
**Run tests:**
```bash
pytest tests/integration/api/v1/public/ -v
pytest tests/integration/api/v1/platform/ -v
```
**Test categories:**
@@ -479,15 +479,15 @@ pytest tests/integration/api/v1/public/ -v
```bash
# Get pricing
curl http://localhost:8000/api/v1/public/pricing
curl http://localhost:8000/api/v1/platform/pricing
# Lookup vendor
curl -X POST http://localhost:8000/api/v1/public/letzshop-vendors/lookup \
curl -X POST http://localhost:8000/api/v1/platform/letzshop-vendors/lookup \
-H "Content-Type: application/json" \
-d '{"url": "letzshop.lu/vendors/test-shop"}'
# Start signup
curl -X POST http://localhost:8000/api/v1/public/signup/start \
curl -X POST http://localhost:8000/api/v1/platform/signup/start \
-H "Content-Type: application/json" \
-d '{"tier_code": "professional", "is_annual": false}'
```

View File

@@ -0,0 +1,653 @@
# Plan: Flexible Role & Permission Management with Platform Controls
## Status: READY FOR APPROVAL
## Summary
Design a flexible role/permission management system that:
1. **Modules define permissions** - Each module declares its available permissions
2. **Platforms control availability** - Platforms can restrict which permissions vendors can use
3. **Vendors customize roles** - Vendors create custom roles within platform constraints
4. **Multi-tier hierarchy** - Platform → Vendor → User permission inheritance
---
## Current State Analysis
### What Exists Today
| Component | Location | Description |
|-----------|----------|-------------|
| **Role Model** | `app/modules/tenancy/models/vendor.py` | `vendor_id`, `name`, `permissions` (JSON array) |
| **VendorUser Model** | Same file | Links user → vendor with `role_id` |
| **PermissionDiscoveryService** | `app/modules/tenancy/services/permission_discovery_service.py` | Discovers permissions from modules |
| **VendorTeamService** | `app/modules/tenancy/services/vendor_team_service.py` | Manages team invitations, role assignment |
| **Role Presets** | In discovery service code | Hardcoded `ROLE_PRESETS` dict |
| **Platform Model** | `models/database/platform.py` | Multi-platform support |
| **PlatformModule** | `models/database/platform_module.py` | Controls which modules are enabled per platform |
| **VendorPlatform** | `models/database/vendor_platform.py` | Vendor-platform relationship with `tier_id` |
### Current Gaps
1. **No platform-level permission control** - Platforms cannot restrict which permissions vendors can assign
2. **No custom role CRUD API** - Roles are created implicitly when inviting team members
3. **Presets are code-only** - Cannot customize role templates per platform
4. **No role templates table** - Platform admins cannot define default roles for their vendors
---
## Proposed Architecture
### Tier 1: Module-Defined Permissions (Exists)
Each module declares permissions in `definition.py`:
```python
permissions=[
PermissionDefinition(
id="products.view",
label_key="catalog.permissions.products_view",
category="products",
),
PermissionDefinition(
id="products.create",
label_key="catalog.permissions.products_create",
category="products",
),
]
```
**Discovery Service** aggregates all permissions at runtime.
### Tier 2: Platform Permission Control (New)
New `PlatformPermissionConfig` model to control:
- Which permissions are available to vendors on this platform
- Default role templates for vendor onboarding
- Permission bundles based on subscription tier
```
┌─────────────────────────────────────────────────────────────────┐
│ PLATFORM │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PlatformPermissionConfig │ │
│ │ - platform_id │ │
│ │ - allowed_permissions: ["products.*", "orders.*"] │ │
│ │ - blocked_permissions: ["team.manage"] │ │
│ │ - tier_restrictions: {free: [...], pro: [...]} │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PlatformRoleTemplate │ │
│ │ - platform_id │ │
│ │ - name: "Manager", "Staff", etc. │ │
│ │ - permissions: [...] │ │
│ │ - is_default: bool (create for new vendors) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Tier 3: Vendor Role Customization (Enhanced)
Vendors can:
- View roles available (from platform templates or custom)
- Create custom roles (within platform constraints)
- Edit role permissions (within allowed set)
- Assign roles to team members
```
┌─────────────────────────────────────────────────────────────────┐
│ VENDOR │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Role (existing model, enhanced) │ │
│ │ - vendor_id │ │
│ │ - name │ │
│ │ - permissions: [...] (validated against platform) │ │
│ │ - is_from_template: bool │ │
│ │ - source_template_id: FK (nullable) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ VendorUser (existing, unchanged) │ │
│ │ - user_id │ │
│ │ - vendor_id │ │
│ │ - role_id │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Data Model Changes
### New Models
#### 1. PlatformPermissionConfig
```python
# app/modules/tenancy/models/platform_permission_config.py
class PlatformPermissionConfig(Base):
"""Platform-level permission configuration"""
__tablename__ = "platform_permission_configs"
id: Mapped[int] = mapped_column(primary_key=True)
platform_id: Mapped[int] = mapped_column(ForeignKey("platforms.id"), unique=True)
# Permissions this platform allows vendors to use
# Empty = all discovered permissions allowed
allowed_permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
# Explicit blocklist (takes precedence over allowed)
blocked_permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
# Tier-based restrictions: {"free": ["products.view"], "pro": ["products.*"]}
tier_permissions: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
# Relationships
platform: Mapped["Platform"] = relationship(back_populates="permission_config")
```
#### 2. PlatformRoleTemplate
```python
# app/modules/tenancy/models/platform_role_template.py
class PlatformRoleTemplate(Base):
"""Role templates defined at platform level"""
__tablename__ = "platform_role_templates"
id: Mapped[int] = mapped_column(primary_key=True)
platform_id: Mapped[int] = mapped_column(ForeignKey("platforms.id"))
name: Mapped[str] = mapped_column(String(50)) # "Manager", "Staff", etc.
display_name: Mapped[str] = mapped_column(String(100)) # i18n key or display name
description: Mapped[str | None] = mapped_column(String(255))
# Permissions for this template
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
# Configuration
is_default: Mapped[bool] = mapped_column(default=False) # Auto-create for new vendors
is_system: Mapped[bool] = mapped_column(default=False) # Cannot be deleted
order: Mapped[int] = mapped_column(default=100) # Display order
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
# Relationships
platform: Mapped["Platform"] = relationship(back_populates="role_templates")
__table_args__ = (
UniqueConstraint("platform_id", "name", name="uq_platform_role_template"),
)
```
### Enhanced Existing Models
#### Role Model Enhancement
```python
# Add to existing Role model in app/modules/tenancy/models/vendor.py
class Role(Base):
# ... existing fields ...
# NEW: Track template origin
source_template_id: Mapped[int | None] = mapped_column(
ForeignKey("platform_role_templates.id"),
nullable=True
)
is_custom: Mapped[bool] = mapped_column(default=False) # Vendor-created custom role
# Relationship
source_template: Mapped["PlatformRoleTemplate"] = relationship()
```
---
## Service Layer Changes
### 1. PlatformPermissionService (New)
```python
# app/modules/tenancy/services/platform_permission_service.py
class PlatformPermissionService:
"""Manages platform-level permission configuration"""
def get_allowed_permissions(
self,
db: Session,
platform_id: int,
tier_id: int | None = None
) -> set[str]:
"""
Get permissions allowed for a platform/tier combination.
1. Start with all discovered permissions
2. Filter by platform's allowed_permissions (if set)
3. Remove blocked_permissions
4. Apply tier restrictions (if tier_id provided)
"""
pass
def validate_permissions(
self,
db: Session,
platform_id: int,
tier_id: int | None,
permissions: list[str]
) -> tuple[list[str], list[str]]:
"""
Validate permissions against platform constraints.
Returns (valid_permissions, invalid_permissions)
"""
pass
def update_platform_config(
self,
db: Session,
platform_id: int,
allowed_permissions: list[str] | None = None,
blocked_permissions: list[str] | None = None,
tier_permissions: dict | None = None
) -> PlatformPermissionConfig:
"""Update platform permission configuration"""
pass
```
### 2. PlatformRoleTemplateService (New)
```python
# app/modules/tenancy/services/platform_role_template_service.py
class PlatformRoleTemplateService:
"""Manages platform role templates"""
def get_templates(self, db: Session, platform_id: int) -> list[PlatformRoleTemplate]:
"""Get all role templates for a platform"""
pass
def create_template(
self,
db: Session,
platform_id: int,
name: str,
permissions: list[str],
is_default: bool = False
) -> PlatformRoleTemplate:
"""Create a new role template (validates permissions)"""
pass
def create_default_roles_for_vendor(
self,
db: Session,
vendor: Vendor
) -> list[Role]:
"""
Create vendor roles from platform's default templates.
Called during vendor onboarding.
"""
pass
def seed_default_templates(self, db: Session, platform_id: int):
"""Seed platform with standard role templates (Manager, Staff, etc.)"""
pass
```
### 3. Enhanced VendorTeamService
```python
# Updates to app/modules/tenancy/services/vendor_team_service.py
class VendorTeamService:
def get_available_permissions(
self,
db: Session,
vendor: Vendor
) -> list[PermissionDefinition]:
"""
Get permissions available to this vendor based on:
1. Platform constraints
2. Vendor's subscription tier
"""
platform_perm_service = PlatformPermissionService()
vendor_platform = db.query(VendorPlatform).filter(...).first()
allowed = platform_perm_service.get_allowed_permissions(
db,
vendor_platform.platform_id,
vendor_platform.tier_id
)
# Return PermissionDefinitions filtered to allowed set
all_perms = permission_discovery_service.get_all_permissions()
return [p for p in all_perms if p.id in allowed]
def create_custom_role(
self,
db: Session,
vendor: Vendor,
name: str,
permissions: list[str]
) -> Role:
"""
Create a custom role for the vendor.
Validates permissions against platform constraints.
"""
# Validate permissions
valid, invalid = self.platform_permission_service.validate_permissions(
db, vendor.platform_id, vendor.tier_id, permissions
)
if invalid:
raise InvalidPermissionsException(invalid)
role = Role(
vendor_id=vendor.id,
name=name,
permissions=valid,
is_custom=True
)
db.add(role)
return role
def update_role(
self,
db: Session,
vendor: Vendor,
role_id: int,
name: str | None = None,
permissions: list[str] | None = None
) -> Role:
"""Update an existing role (validates permissions)"""
pass
def delete_role(
self,
db: Session,
vendor: Vendor,
role_id: int
) -> bool:
"""Delete a custom role (cannot delete if in use)"""
pass
```
---
## API Endpoints
### Platform Admin Endpoints (Admin Panel)
```python
# app/modules/tenancy/routes/admin/platform_permissions.py
@router.get("/platforms/{platform_id}/permissions")
def get_platform_permission_config(platform_id: int):
"""Get platform permission configuration"""
@router.put("/platforms/{platform_id}/permissions")
def update_platform_permission_config(platform_id: int, config: PermissionConfigUpdate):
"""Update platform permission configuration"""
@router.get("/platforms/{platform_id}/role-templates")
def list_role_templates(platform_id: int):
"""List role templates for a platform"""
@router.post("/platforms/{platform_id}/role-templates")
def create_role_template(platform_id: int, template: RoleTemplateCreate):
"""Create a new role template"""
@router.put("/platforms/{platform_id}/role-templates/{template_id}")
def update_role_template(platform_id: int, template_id: int, template: RoleTemplateUpdate):
"""Update a role template"""
@router.delete("/platforms/{platform_id}/role-templates/{template_id}")
def delete_role_template(platform_id: int, template_id: int):
"""Delete a role template"""
```
### Vendor Dashboard Endpoints
```python
# app/modules/tenancy/routes/api/vendor_roles.py
@router.get("/roles")
def list_vendor_roles():
"""List all roles for current vendor"""
@router.post("/roles")
def create_custom_role(role: RoleCreate):
"""Create a custom role (validates permissions against platform)"""
@router.put("/roles/{role_id}")
def update_role(role_id: int, role: RoleUpdate):
"""Update a role"""
@router.delete("/roles/{role_id}")
def delete_role(role_id: int):
"""Delete a custom role"""
@router.get("/available-permissions")
def get_available_permissions():
"""Get permissions available to this vendor (filtered by platform/tier)"""
```
---
## Permission Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PERMISSION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. MODULE DEFINES PERMISSIONS
┌──────────────┐
│ catalog │ → products.view, products.create, products.edit, ...
│ orders │ → orders.view, orders.manage, orders.refund, ...
│ team │ → team.view, team.manage, team.invite, ...
└──────────────┘
2. DISCOVERY SERVICE AGGREGATES
┌────────────────────────────────────────────────┐
│ PermissionDiscoveryService │
│ get_all_permissions() → 50+ permissions │
└────────────────────────────────────────────────┘
3. PLATFORM FILTERS PERMISSIONS
┌────────────────────────────────────────────────┐
│ PlatformPermissionConfig │
│ allowed: ["products.*", "orders.view"] │
│ blocked: ["orders.refund"] │
│ tier_permissions: │
│ free: ["products.view", "orders.view"] │
│ pro: ["products.*", "orders.*"] │
└────────────────────────────────────────────────┘
4. VENDOR CREATES/USES ROLES
┌────────────────────────────────────────────────┐
│ Role (vendor-specific) │
│ Manager: [products.*, orders.view] │
│ Staff: [products.view, orders.view] │
└────────────────────────────────────────────────┘
5. USER GETS PERMISSIONS VIA ROLE
┌────────────────────────────────────────────────┐
│ VendorUser │
│ user_id: 123 │
│ role_id: 5 (Staff) │
│ → permissions: [products.view, orders.view] │
└────────────────────────────────────────────────┘
```
---
## Database Migrations
### Migration 1: Add Platform Permission Config
```sql
CREATE TABLE platform_permission_configs (
id SERIAL PRIMARY KEY,
platform_id INTEGER NOT NULL UNIQUE REFERENCES platforms(id),
allowed_permissions JSONB DEFAULT '[]',
blocked_permissions JSONB DEFAULT '[]',
tier_permissions JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP
);
```
### Migration 2: Add Platform Role Templates
```sql
CREATE TABLE platform_role_templates (
id SERIAL PRIMARY KEY,
platform_id INTEGER NOT NULL REFERENCES platforms(id),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100) NOT NULL,
description VARCHAR(255),
permissions JSONB DEFAULT '[]',
is_default BOOLEAN DEFAULT FALSE,
is_system BOOLEAN DEFAULT FALSE,
"order" INTEGER DEFAULT 100,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (platform_id, name)
);
```
### Migration 3: Enhance Roles Table
```sql
ALTER TABLE roles
ADD COLUMN source_template_id INTEGER REFERENCES platform_role_templates(id),
ADD COLUMN is_custom BOOLEAN DEFAULT FALSE;
```
---
## Implementation Phases
### Phase 1: Data Models (Foundation)
**Files to create:**
- `app/modules/tenancy/models/platform_permission_config.py`
- `app/modules/tenancy/models/platform_role_template.py`
- `migrations/versions/xxx_add_platform_permission_tables.py`
- `migrations/versions/xxx_enhance_roles_table.py`
**Files to modify:**
- `app/modules/tenancy/models/__init__.py` - Export new models
- `app/modules/tenancy/models/vendor.py` - Add Role enhancement
### Phase 2: Service Layer
**Files to create:**
- `app/modules/tenancy/services/platform_permission_service.py`
- `app/modules/tenancy/services/platform_role_template_service.py`
**Files to modify:**
- `app/modules/tenancy/services/vendor_team_service.py` - Add role CRUD, permission validation
### Phase 3: API Endpoints
**Files to create:**
- `app/modules/tenancy/routes/admin/platform_permissions.py`
- `app/modules/tenancy/routes/api/vendor_roles.py`
- `app/modules/tenancy/schemas/platform_permissions.py`
- `app/modules/tenancy/schemas/roles.py`
**Files to modify:**
- `app/modules/tenancy/routes/__init__.py` - Register new routers
### Phase 4: Vendor Onboarding Integration
**Files to modify:**
- `app/modules/tenancy/services/vendor_service.py` - Create default roles from templates during vendor creation
### Phase 5: Admin UI (Optional, Future)
**Files to create/modify:**
- Admin panel for platform permission configuration
- Admin panel for role template management
- Vendor dashboard for custom role management
---
## Verification
1. **App loads:** `python -c "from main import app; print('OK')"`
2. **Migrations run:** `make migrate-up`
3. **Architecture validation:** `python scripts/validate_architecture.py -v`
4. **Unit tests:** Test permission filtering logic
- Platform with no config → all permissions allowed
- Platform with allowed list → only those permissions
- Platform with blocked list → all except blocked
- Tier restrictions → correct subset per tier
5. **Integration tests:**
- Create vendor → gets default roles from platform templates
- Create custom role → validates against platform constraints
- Assign role → user gets correct permissions
- Change tier → available permissions update
6. **API tests:**
- Platform admin can configure permissions
- Vendor owner can create/edit custom roles
- Invalid permissions are rejected
---
## Files Summary
### New Files (9)
| File | Purpose |
|------|---------|
| `app/modules/tenancy/models/platform_permission_config.py` | Platform permission config model |
| `app/modules/tenancy/models/platform_role_template.py` | Platform role template model |
| `app/modules/tenancy/services/platform_permission_service.py` | Platform permission logic |
| `app/modules/tenancy/services/platform_role_template_service.py` | Role template logic |
| `app/modules/tenancy/routes/admin/platform_permissions.py` | Admin API endpoints |
| `app/modules/tenancy/routes/api/vendor_roles.py` | Vendor API endpoints |
| `app/modules/tenancy/schemas/platform_permissions.py` | Pydantic schemas |
| `app/modules/tenancy/schemas/roles.py` | Role schemas |
| `migrations/versions/xxx_platform_permission_tables.py` | Database migration |
### Modified Files (5)
| File | Changes |
|------|---------|
| `app/modules/tenancy/models/__init__.py` | Export new models |
| `app/modules/tenancy/models/vendor.py` | Enhance Role model |
| `app/modules/tenancy/services/vendor_team_service.py` | Add role CRUD, validation |
| `app/modules/tenancy/services/vendor_service.py` | Create default roles on vendor creation |
| `app/modules/tenancy/routes/__init__.py` | Register new routers |
---
## Key Design Decisions
1. **Wildcard support in permissions** - `products.*` matches `products.view`, `products.create`, etc.
2. **Tier inheritance** - Higher tiers include all permissions of lower tiers
3. **Template-based vendor roles** - Default roles created from platform templates, but vendor can customize
4. **Soft validation** - Invalid permissions in existing roles are not automatically removed (audit trail)
5. **Backward compatible** - Existing roles without `source_template_id` continue to work
cl

View File

@@ -31,7 +31,7 @@ The test structure directly mirrors the API code structure:
```
app/api/v1/admin/ → tests/integration/api/v1/admin/
app/api/v1/vendor/ → tests/integration/api/v1/vendor/
app/api/v1/public/ → tests/integration/api/v1/public/
app/api/v1/platform/ → tests/integration/api/v1/platform/
app/api/v1/shared/ → tests/integration/api/v1/shared/
```
@@ -83,7 +83,7 @@ Different teams can work in parallel with fewer conflicts:
```
Admin Team: works in tests/integration/api/v1/admin/
Vendor Team: works in tests/integration/api/v1/vendor/
Public Team: works in tests/integration/api/v1/public/
Public Team: works in tests/integration/api/v1/platform/
```
## Running Tests
@@ -98,7 +98,7 @@ pytest tests/integration/api/v1/vendor/ -v
pytest tests/integration/api/v1/admin/ -v
# All public tests
pytest tests/integration/api/v1/public/ -v
pytest tests/integration/api/v1/platform/ -v
# All shared tests
pytest tests/integration/api/v1/shared/ -v
@@ -277,7 +277,7 @@ See [Vendor API Testing Guide](vendor-api-testing.md) for details.
### Public Tests (`public/`)
Tests for public endpoints at `/api/v1/public/*`:
Tests for public endpoints at `/api/v1/platform/*`:
- Product catalog browsing
- Public vendor profiles

18
main.py
View File

@@ -64,7 +64,7 @@ from app.exceptions.handler import setup_exception_handlers
# Module route auto-discovery - all page routes now come from modules
from app.modules.routes import (
get_admin_page_routes,
get_public_page_routes,
get_platform_page_routes,
get_storefront_page_routes,
get_vendor_page_routes,
)
@@ -323,18 +323,18 @@ logger.info("ROUTE REGISTRATION (AUTO-DISCOVERY)")
logger.info("=" * 80)
# =============================================================================
# PUBLIC PAGES (Marketing pages - homepage, pricing, signup, etc.)
# PLATFORM PAGES (Marketing pages - homepage, pricing, signup, etc.)
# =============================================================================
# Public pages are served at root level (/) for platform marketing
logger.info("Auto-discovering public (marketing) page routes...")
public_page_routes = get_public_page_routes()
logger.info(f" Found {len(public_page_routes)} public page route modules")
# Platform pages are served at root level (/) for platform marketing
logger.info("Auto-discovering platform (marketing) page routes...")
platform_page_routes = get_platform_page_routes()
logger.info(f" Found {len(platform_page_routes)} platform page route modules")
for route_info in public_page_routes:
logger.info(f" Registering {route_info.module_code} public pages (priority={route_info.priority})")
for route_info in platform_page_routes:
logger.info(f" Registering {route_info.module_code} platform pages (priority={route_info.priority})")
app.include_router(
route_info.router,
prefix="", # Public pages at root
prefix="", # Platform pages at root
tags=route_info.tags,
include_in_schema=route_info.include_in_schema,
)

View File

@@ -273,7 +273,7 @@ def test_customer_login() -> dict | None:
try:
response = requests.post(
f"{BASE_URL}/api/v1/public/vendors/1/customers/login",
f"{BASE_URL}/api/v1/platform/vendors/1/customers/login",
json={"username": "customer", "password": "customer123"},
)

View File

@@ -15,7 +15,7 @@ pytest tests/integration/api/v1/ -v
# Run specific area
pytest tests/integration/api/v1/vendor/ -v
pytest tests/integration/api/v1/admin/ -v
pytest tests/integration/api/v1/public/ -v
pytest tests/integration/api/v1/platform/ -v
pytest tests/integration/api/v1/shared/ -v
```

View File

@@ -1,4 +1,4 @@
# Public API Integration Tests
# Platform API Integration Tests
## Documentation
@@ -9,8 +9,8 @@ For comprehensive testing documentation, see:
## Quick Start
```bash
# Run all public tests
pytest tests/integration/api/v1/public/ -v
# Run all platform tests
pytest tests/integration/api/v1/platform/ -v
```
## Status

View File

@@ -1,8 +1,8 @@
# tests/integration/api/v1/public/__init__.py
"""Public API integration tests.
# tests/integration/api/v1/platform/__init__.py
"""Platform API integration tests.
Tests for unauthenticated public endpoints:
- /api/v1/public/signup/* - Multi-step signup flow
- /api/v1/public/pricing/* - Subscription tiers and pricing
- /api/v1/public/letzshop-vendors/* - Vendor lookup for signup
Tests for unauthenticated platform endpoints:
- /api/v1/platform/signup/* - Multi-step signup flow
- /api/v1/platform/pricing/* - Subscription tiers and pricing
- /api/v1/platform/letzshop-vendors/* - Vendor lookup for signup
"""

View File

@@ -1,7 +1,7 @@
# tests/integration/api/v1/public/test_letzshop_vendors.py
# tests/integration/api/v1/platform/test_letzshop_vendors.py
"""Integration tests for platform Letzshop vendor lookup API endpoints.
Tests the /api/v1/public/letzshop-vendors/* endpoints.
Tests the /api/v1/platform/letzshop-vendors/* endpoints.
"""
import pytest
@@ -62,15 +62,15 @@ def claimed_vendor(db, test_company):
@pytest.mark.api
@pytest.mark.platform
class TestLetzshopVendorLookupAPI:
"""Test Letzshop vendor lookup endpoints at /api/v1/public/letzshop-vendors/*."""
"""Test Letzshop vendor lookup endpoints at /api/v1/platform/letzshop-vendors/*."""
# =========================================================================
# GET /api/v1/public/letzshop-vendors
# GET /api/v1/platform/letzshop-vendors
# =========================================================================
def test_list_vendors_returns_empty_list(self, client):
"""Test listing vendors returns empty list (placeholder)."""
response = client.get("/api/v1/public/letzshop-vendors")
response = client.get("/api/v1/platform/letzshop-vendors")
assert response.status_code == 200
data = response.json()
@@ -83,7 +83,7 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_with_pagination(self, client):
"""Test listing vendors with pagination parameters."""
response = client.get("/api/v1/public/letzshop-vendors?page=2&limit=10")
response = client.get("/api/v1/platform/letzshop-vendors?page=2&limit=10")
assert response.status_code == 200
data = response.json()
@@ -92,7 +92,7 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_with_search(self, client):
"""Test listing vendors with search parameter."""
response = client.get("/api/v1/public/letzshop-vendors?search=my-shop")
response = client.get("/api/v1/platform/letzshop-vendors?search=my-shop")
assert response.status_code == 200
data = response.json()
@@ -101,7 +101,7 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_with_filters(self, client):
"""Test listing vendors with category and city filters."""
response = client.get(
"/api/v1/public/letzshop-vendors?category=fashion&city=luxembourg"
"/api/v1/platform/letzshop-vendors?category=fashion&city=luxembourg"
)
assert response.status_code == 200
@@ -111,17 +111,17 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_limit_validation(self, client):
"""Test that limit parameter is validated."""
# Maximum limit is 50
response = client.get("/api/v1/public/letzshop-vendors?limit=100")
response = client.get("/api/v1/platform/letzshop-vendors?limit=100")
assert response.status_code == 422
# =========================================================================
# POST /api/v1/public/letzshop-vendors/lookup
# POST /api/v1/platform/letzshop-vendors/lookup
# =========================================================================
def test_lookup_vendor_by_full_url(self, client):
"""Test looking up vendor by full Letzshop URL."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/my-test-shop"},
)
@@ -134,7 +134,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_by_url_with_language(self, client):
"""Test looking up vendor by URL with language prefix."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/en/vendors/my-shop"},
)
@@ -146,7 +146,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_by_url_without_protocol(self, client):
"""Test looking up vendor by URL without https://."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "letzshop.lu/vendors/test-shop"},
)
@@ -158,7 +158,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_by_slug_only(self, client):
"""Test looking up vendor by slug alone."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "my-shop-name"},
)
@@ -170,7 +170,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_normalizes_slug(self, client):
"""Test that slug is normalized to lowercase."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/MY-SHOP-NAME"},
)
@@ -181,7 +181,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_shows_claimed_status(self, client, claimed_vendor):
"""Test that lookup shows if vendor is already claimed."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "claimed-shop"},
)
@@ -193,7 +193,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_shows_unclaimed_status(self, client):
"""Test that lookup shows if vendor is not claimed."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "unclaimed-new-shop"},
)
@@ -205,7 +205,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_empty_url(self, client):
"""Test lookup with empty URL."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": ""},
)
@@ -217,7 +217,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_response_has_expected_fields(self, client):
"""Test that vendor lookup response has all expected fields."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "test-vendor"},
)
@@ -230,12 +230,12 @@ class TestLetzshopVendorLookupAPI:
assert "is_claimed" in vendor
# =========================================================================
# GET /api/v1/public/letzshop-vendors/{slug}
# GET /api/v1/platform/letzshop-vendors/{slug}
# =========================================================================
def test_get_vendor_by_slug(self, client):
"""Test getting vendor by slug."""
response = client.get("/api/v1/public/letzshop-vendors/my-shop")
response = client.get("/api/v1/platform/letzshop-vendors/my-shop")
assert response.status_code == 200
data = response.json()
@@ -246,7 +246,7 @@ class TestLetzshopVendorLookupAPI:
def test_get_vendor_normalizes_slug(self, client):
"""Test that get vendor normalizes slug to lowercase."""
response = client.get("/api/v1/public/letzshop-vendors/MY-SHOP")
response = client.get("/api/v1/platform/letzshop-vendors/MY-SHOP")
assert response.status_code == 200
data = response.json()
@@ -254,7 +254,7 @@ class TestLetzshopVendorLookupAPI:
def test_get_claimed_vendor_shows_status(self, client, claimed_vendor):
"""Test that get vendor shows claimed status correctly."""
response = client.get("/api/v1/public/letzshop-vendors/claimed-shop")
response = client.get("/api/v1/platform/letzshop-vendors/claimed-shop")
assert response.status_code == 200
data = response.json()
@@ -262,7 +262,7 @@ class TestLetzshopVendorLookupAPI:
def test_get_unclaimed_vendor_shows_status(self, client):
"""Test that get vendor shows unclaimed status correctly."""
response = client.get("/api/v1/public/letzshop-vendors/new-unclaimed-shop")
response = client.get("/api/v1/platform/letzshop-vendors/new-unclaimed-shop")
assert response.status_code == 200
data = response.json()
@@ -278,7 +278,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_full_https_url(self, client):
"""Test extraction from https://letzshop.lu/vendors/slug."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/cafe-luxembourg"},
)
assert response.json()["vendor"]["slug"] == "cafe-luxembourg"
@@ -286,7 +286,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_http_url(self, client):
"""Test extraction from http://letzshop.lu/vendors/slug."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "http://letzshop.lu/vendors/my-shop"},
)
assert response.json()["vendor"]["slug"] == "my-shop"
@@ -294,7 +294,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_url_with_trailing_slash(self, client):
"""Test extraction from URL with trailing slash."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/my-shop/"},
)
assert response.json()["vendor"]["slug"] == "my-shop"
@@ -302,7 +302,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_url_with_query_params(self, client):
"""Test extraction from URL with query parameters."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/my-shop?ref=google"},
)
assert response.json()["vendor"]["slug"] == "my-shop"
@@ -310,7 +310,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_french_url(self, client):
"""Test extraction from French language URL."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/fr/vendors/boulangerie-paul"},
)
assert response.json()["vendor"]["slug"] == "boulangerie-paul"
@@ -318,7 +318,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_german_url(self, client):
"""Test extraction from German language URL."""
response = client.post(
"/api/v1/public/letzshop-vendors/lookup",
"/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/de/vendors/backerei-muller"},
)
assert response.json()["vendor"]["slug"] == "backerei-muller"

View File

@@ -1,7 +1,7 @@
# tests/integration/api/v1/public/test_pricing.py
# tests/integration/api/v1/platform/test_pricing.py
"""Integration tests for platform pricing API endpoints.
Tests the /api/v1/public/pricing/* endpoints.
Tests the /api/v1/platform/pricing/* endpoints.
"""
import pytest
@@ -18,15 +18,15 @@ from app.modules.billing.models import (
@pytest.mark.api
@pytest.mark.platform
class TestPlatformPricingAPI:
"""Test platform pricing endpoints at /api/v1/public/*."""
"""Test platform pricing endpoints at /api/v1/platform/*."""
# =========================================================================
# GET /api/v1/public/tiers
# GET /api/v1/platform/pricing/tiers
# =========================================================================
def test_get_tiers_returns_all_public_tiers(self, client):
"""Test getting all subscription tiers."""
response = client.get("/api/v1/public/tiers")
response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200
data = response.json()
@@ -35,7 +35,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_has_expected_fields(self, client):
"""Test that tier response has all expected fields."""
response = client.get("/api/v1/public/tiers")
response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200
data = response.json()
@@ -55,7 +55,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_includes_essential(self, client):
"""Test that Essential tier is included."""
response = client.get("/api/v1/public/tiers")
response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200
data = response.json()
@@ -64,7 +64,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_includes_professional(self, client):
"""Test that Professional tier is included and marked as popular."""
response = client.get("/api/v1/public/tiers")
response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200
data = response.json()
@@ -77,7 +77,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_includes_enterprise(self, client):
"""Test that Enterprise tier is included and marked appropriately."""
response = client.get("/api/v1/public/tiers")
response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200
data = response.json()
@@ -108,7 +108,7 @@ class TestPlatformPricingAPI:
db.add(tier)
db.commit()
response = client.get("/api/v1/public/tiers")
response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200
data = response.json()
@@ -116,12 +116,12 @@ class TestPlatformPricingAPI:
assert "test_tier" in tier_codes
# =========================================================================
# GET /api/v1/public/tiers/{tier_code}
# GET /api/v1/platform/pricing/tiers/{tier_code}
# =========================================================================
def test_get_tier_by_code_success(self, client):
"""Test getting a specific tier by code."""
response = client.get(f"/api/v1/public/tiers/{TierCode.PROFESSIONAL.value}")
response = client.get(f"/api/v1/platform/pricing/tiers/{TierCode.PROFESSIONAL.value}")
assert response.status_code == 200
data = response.json()
@@ -130,7 +130,7 @@ class TestPlatformPricingAPI:
def test_get_tier_by_code_essential(self, client):
"""Test getting Essential tier details."""
response = client.get(f"/api/v1/public/tiers/{TierCode.ESSENTIAL.value}")
response = client.get(f"/api/v1/platform/pricing/tiers/{TierCode.ESSENTIAL.value}")
assert response.status_code == 200
data = response.json()
@@ -139,19 +139,19 @@ class TestPlatformPricingAPI:
def test_get_tier_by_code_not_found(self, client):
"""Test getting a non-existent tier returns 404."""
response = client.get("/api/v1/public/tiers/nonexistent_tier")
response = client.get("/api/v1/platform/pricing/tiers/nonexistent_tier")
assert response.status_code == 404
data = response.json()
assert "not found" in data["message"].lower()
# =========================================================================
# GET /api/v1/public/addons
# GET /api/v1/platform/pricing/addons
# =========================================================================
def test_get_addons_empty_when_none_configured(self, client):
"""Test getting add-ons when none are configured."""
response = client.get("/api/v1/public/addons")
response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200
data = response.json()
@@ -173,7 +173,7 @@ class TestPlatformPricingAPI:
db.add(addon)
db.commit()
response = client.get("/api/v1/public/addons")
response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200
data = response.json()
@@ -197,7 +197,7 @@ class TestPlatformPricingAPI:
db.add(addon)
db.commit()
response = client.get("/api/v1/public/addons")
response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200
data = response.json()
@@ -236,7 +236,7 @@ class TestPlatformPricingAPI:
db.add_all([active_addon, inactive_addon])
db.commit()
response = client.get("/api/v1/public/addons")
response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200
data = response.json()
@@ -245,12 +245,12 @@ class TestPlatformPricingAPI:
assert "inactive_addon" not in addon_codes
# =========================================================================
# GET /api/v1/public/pricing
# GET /api/v1/platform/pricing
# =========================================================================
def test_get_pricing_returns_complete_info(self, client):
"""Test getting complete pricing information."""
response = client.get("/api/v1/public/pricing")
response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200
data = response.json()
@@ -262,7 +262,7 @@ class TestPlatformPricingAPI:
def test_get_pricing_includes_trial_days(self, client):
"""Test that pricing includes correct trial period."""
response = client.get("/api/v1/public/pricing")
response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200
data = response.json()
@@ -270,7 +270,7 @@ class TestPlatformPricingAPI:
def test_get_pricing_includes_annual_discount(self, client):
"""Test that pricing includes annual discount info."""
response = client.get("/api/v1/public/pricing")
response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200
data = response.json()
@@ -278,7 +278,7 @@ class TestPlatformPricingAPI:
def test_get_pricing_tiers_not_empty(self, client):
"""Test that pricing always includes tiers."""
response = client.get("/api/v1/public/pricing")
response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200
data = response.json()

View File

@@ -1,7 +1,7 @@
# tests/integration/api/v1/public/test_signup.py
# tests/integration/api/v1/platform/test_signup.py
"""Integration tests for platform signup API endpoints.
Tests the /api/v1/public/signup/* endpoints.
Tests the /api/v1/platform/signup/* endpoints.
"""
from unittest.mock import MagicMock, patch
@@ -37,7 +37,7 @@ def mock_stripe_service():
def signup_session(client):
"""Create a signup session for testing."""
response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False},
)
return response.json()["session_id"]
@@ -104,12 +104,12 @@ def claimed_letzshop_vendor(db, claimed_owner_user):
@pytest.mark.api
@pytest.mark.platform
class TestSignupStartAPI:
"""Test signup start endpoint at /api/v1/public/signup/start."""
"""Test signup start endpoint at /api/v1/platform/signup/start."""
def test_start_signup_success(self, client):
"""Test starting a signup session."""
response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
)
@@ -122,7 +122,7 @@ class TestSignupStartAPI:
def test_start_signup_with_annual_billing(self, client):
"""Test starting signup with annual billing."""
response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": True},
)
@@ -134,7 +134,7 @@ class TestSignupStartAPI:
"""Test starting signup for all valid tiers."""
for tier in [TierCode.ESSENTIAL, TierCode.PROFESSIONAL, TierCode.BUSINESS, TierCode.ENTERPRISE]:
response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": tier.value, "is_annual": False},
)
assert response.status_code == 200
@@ -143,7 +143,7 @@ class TestSignupStartAPI:
def test_start_signup_invalid_tier(self, client):
"""Test starting signup with invalid tier code."""
response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": "invalid_tier", "is_annual": False},
)
@@ -154,11 +154,11 @@ class TestSignupStartAPI:
def test_start_signup_session_id_is_unique(self, client):
"""Test that each signup session gets a unique ID."""
response1 = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
)
response2 = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
)
@@ -169,12 +169,12 @@ class TestSignupStartAPI:
@pytest.mark.api
@pytest.mark.platform
class TestClaimVendorAPI:
"""Test claim vendor endpoint at /api/v1/public/signup/claim-vendor."""
"""Test claim vendor endpoint at /api/v1/platform/signup/claim-vendor."""
def test_claim_vendor_success(self, client, signup_session):
"""Test claiming a Letzshop vendor."""
response = client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": signup_session,
"letzshop_slug": "my-new-shop",
@@ -190,7 +190,7 @@ class TestClaimVendorAPI:
def test_claim_vendor_with_vendor_id(self, client, signup_session):
"""Test claiming vendor with Letzshop vendor ID."""
response = client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": signup_session,
"letzshop_slug": "my-shop",
@@ -205,7 +205,7 @@ class TestClaimVendorAPI:
def test_claim_vendor_invalid_session(self, client):
"""Test claiming vendor with invalid session."""
response = client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": "invalid_session_id",
"letzshop_slug": "my-shop",
@@ -219,7 +219,7 @@ class TestClaimVendorAPI:
def test_claim_vendor_already_claimed(self, client, signup_session, claimed_letzshop_vendor):
"""Test claiming a vendor that's already claimed."""
response = client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": signup_session,
"letzshop_slug": "already-claimed-shop",
@@ -235,12 +235,12 @@ class TestClaimVendorAPI:
@pytest.mark.api
@pytest.mark.platform
class TestCreateAccountAPI:
"""Test create account endpoint at /api/v1/public/signup/create-account."""
"""Test create account endpoint at /api/v1/platform/signup/create-account."""
def test_create_account_success(self, client, signup_session, mock_stripe_service):
"""Test creating an account."""
response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "newuser@example.com",
@@ -261,7 +261,7 @@ class TestCreateAccountAPI:
def test_create_account_with_phone(self, client, signup_session, mock_stripe_service):
"""Test creating an account with phone number."""
response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "user2@example.com",
@@ -280,7 +280,7 @@ class TestCreateAccountAPI:
def test_create_account_invalid_session(self, client, mock_stripe_service):
"""Test creating account with invalid session."""
response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": "invalid_session",
"email": "test@example.com",
@@ -298,7 +298,7 @@ class TestCreateAccountAPI:
):
"""Test creating account with existing email."""
response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "existing@example.com",
@@ -316,7 +316,7 @@ class TestCreateAccountAPI:
def test_create_account_invalid_email(self, client, signup_session):
"""Test creating account with invalid email format."""
response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "not-an-email",
@@ -333,14 +333,14 @@ class TestCreateAccountAPI:
"""Test creating account after claiming Letzshop vendor."""
# Start signup
start_response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False},
)
session_id = start_response.json()["session_id"]
# Claim vendor
client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": session_id,
"letzshop_slug": "my-shop-claim",
@@ -349,7 +349,7 @@ class TestCreateAccountAPI:
# Create account
response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": session_id,
"email": "shop@example.com",
@@ -369,13 +369,13 @@ class TestCreateAccountAPI:
@pytest.mark.api
@pytest.mark.platform
class TestSetupPaymentAPI:
"""Test setup payment endpoint at /api/v1/public/signup/setup-payment."""
"""Test setup payment endpoint at /api/v1/platform/signup/setup-payment."""
def test_setup_payment_success(self, client, signup_session, mock_stripe_service):
"""Test setting up payment after account creation."""
# Create account first
client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "payment@example.com",
@@ -388,7 +388,7 @@ class TestSetupPaymentAPI:
# Setup payment
response = client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
@@ -401,7 +401,7 @@ class TestSetupPaymentAPI:
def test_setup_payment_invalid_session(self, client, mock_stripe_service):
"""Test setup payment with invalid session."""
response = client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": "invalid_session"},
)
@@ -410,7 +410,7 @@ class TestSetupPaymentAPI:
def test_setup_payment_without_account(self, client, signup_session, mock_stripe_service):
"""Test setup payment without creating account first."""
response = client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
@@ -423,13 +423,13 @@ class TestSetupPaymentAPI:
@pytest.mark.api
@pytest.mark.platform
class TestCompleteSignupAPI:
"""Test complete signup endpoint at /api/v1/public/signup/complete."""
"""Test complete signup endpoint at /api/v1/platform/signup/complete."""
def test_complete_signup_success(self, client, signup_session, mock_stripe_service, db):
"""Test completing signup after payment setup."""
# Create account
client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "complete@example.com",
@@ -442,13 +442,13 @@ class TestCompleteSignupAPI:
# Setup payment
client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
# Complete signup
response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": signup_session,
"setup_intent_id": "seti_test_123",
@@ -469,7 +469,7 @@ class TestCompleteSignupAPI:
"""Test that completing signup returns a valid JWT access token for auto-login."""
# Create account
client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "token_test@example.com",
@@ -482,13 +482,13 @@ class TestCompleteSignupAPI:
# Setup payment
client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
# Complete signup
response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": signup_session,
"setup_intent_id": "seti_test_123",
@@ -509,7 +509,7 @@ class TestCompleteSignupAPI:
"""Test that the returned access token can be used to authenticate API calls."""
# Create account
client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "auth_test@example.com",
@@ -522,13 +522,13 @@ class TestCompleteSignupAPI:
# Setup payment
client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
# Complete signup
complete_response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": signup_session,
"setup_intent_id": "seti_test_123",
@@ -553,7 +553,7 @@ class TestCompleteSignupAPI:
"""Test that completing signup sets the vendor_token HTTP-only cookie."""
# Create account
client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "cookie_test@example.com",
@@ -566,13 +566,13 @@ class TestCompleteSignupAPI:
# Setup payment
client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
# Complete signup
response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": signup_session,
"setup_intent_id": "seti_test_123",
@@ -588,7 +588,7 @@ class TestCompleteSignupAPI:
def test_complete_signup_invalid_session(self, client, mock_stripe_service):
"""Test completing signup with invalid session."""
response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": "invalid_session",
"setup_intent_id": "seti_test_123",
@@ -603,7 +603,7 @@ class TestCompleteSignupAPI:
"""Test completing signup when payment setup failed."""
# Create account
client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": signup_session,
"email": "fail@example.com",
@@ -616,7 +616,7 @@ class TestCompleteSignupAPI:
# Setup payment
client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session},
)
@@ -629,7 +629,7 @@ class TestCompleteSignupAPI:
# Complete signup
response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": signup_session,
"setup_intent_id": "seti_failed",
@@ -645,11 +645,11 @@ class TestCompleteSignupAPI:
@pytest.mark.api
@pytest.mark.platform
class TestGetSignupSessionAPI:
"""Test get signup session endpoint at /api/v1/public/signup/session/{session_id}."""
"""Test get signup session endpoint at /api/v1/platform/signup/session/{session_id}."""
def test_get_session_after_start(self, client, signup_session):
"""Test getting session after starting signup."""
response = client.get(f"/api/v1/public/signup/session/{signup_session}")
response = client.get(f"/api/v1/platform/signup/session/{signup_session}")
assert response.status_code == 200
data = response.json()
@@ -661,14 +661,14 @@ class TestGetSignupSessionAPI:
def test_get_session_after_claim(self, client, signup_session):
"""Test getting session after claiming vendor."""
client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": signup_session,
"letzshop_slug": "my-session-shop",
},
)
response = client.get(f"/api/v1/public/signup/session/{signup_session}")
response = client.get(f"/api/v1/platform/signup/session/{signup_session}")
assert response.status_code == 200
data = response.json()
@@ -677,7 +677,7 @@ class TestGetSignupSessionAPI:
def test_get_session_invalid_id(self, client):
"""Test getting non-existent session."""
response = client.get("/api/v1/public/signup/session/invalid_id")
response = client.get("/api/v1/platform/signup/session/invalid_id")
assert response.status_code == 404
@@ -692,7 +692,7 @@ class TestSignupFullFlow:
"""Test the complete signup flow from start to finish."""
# Step 1: Start signup
start_response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.BUSINESS.value, "is_annual": True},
)
assert start_response.status_code == 200
@@ -700,7 +700,7 @@ class TestSignupFullFlow:
# Step 2: Claim Letzshop vendor (optional)
claim_response = client.post(
"/api/v1/public/signup/claim-vendor",
"/api/v1/platform/signup/claim-vendor",
json={
"session_id": session_id,
"letzshop_slug": "full-flow-shop",
@@ -710,7 +710,7 @@ class TestSignupFullFlow:
# Step 3: Create account
account_response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": session_id,
"email": "fullflow@example.com",
@@ -726,7 +726,7 @@ class TestSignupFullFlow:
# Step 4: Setup payment
payment_response = client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": session_id},
)
assert payment_response.status_code == 200
@@ -734,7 +734,7 @@ class TestSignupFullFlow:
# Step 5: Complete signup
complete_response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": session_id,
"setup_intent_id": "seti_test_123",
@@ -753,14 +753,14 @@ class TestSignupFullFlow:
"""Test signup flow skipping Letzshop claim step."""
# Step 1: Start signup
start_response = client.post(
"/api/v1/public/signup/start",
"/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
)
session_id = start_response.json()["session_id"]
# Skip Step 2, go directly to Step 3
account_response = client.post(
"/api/v1/public/signup/create-account",
"/api/v1/platform/signup/create-account",
json={
"session_id": session_id,
"email": "noletzshop@example.com",
@@ -775,12 +775,12 @@ class TestSignupFullFlow:
# Step 4 & 5: Payment and complete
client.post(
"/api/v1/public/signup/setup-payment",
"/api/v1/platform/signup/setup-payment",
json={"session_id": session_id},
)
complete_response = client.post(
"/api/v1/public/signup/complete",
"/api/v1/platform/signup/complete",
json={
"session_id": session_id,
"setup_intent_id": "seti_test_123",