feat(loyalty): restructure program CRUD by interface
Some checks failed
Some checks failed
Move program CRUD from store to merchant/admin interfaces. Store becomes view-only for program config while merchant gets full CRUD and admin gets override capabilities. Merchant portal: - New API endpoints (GET/POST/PATCH/DELETE /program) - New settings page with create/edit/delete form - Overview page now has Create/Edit Program buttons - Settings menu item added to sidebar Admin portal: - New CRUD endpoints (create for merchant, update, delete) - New activate/deactivate program endpoints - Programs list has edit and toggle buttons per row - Merchant detail has create/delete/toggle program actions Store portal: - Removed POST/PATCH /program endpoints (now read-only) - Removed settings page route and template - Terminal, cards, stats, enroll unchanged Tests: 112 passed (58 new) covering merchant API, admin CRUD, store endpoint removal, and program service unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,13 @@ def _get_admin_router():
|
|||||||
return admin_router
|
return admin_router
|
||||||
|
|
||||||
|
|
||||||
|
def _get_merchant_router():
|
||||||
|
"""Lazy import of merchant router to avoid circular imports."""
|
||||||
|
from app.modules.loyalty.routes.api.merchant import router as merchant_router
|
||||||
|
|
||||||
|
return merchant_router
|
||||||
|
|
||||||
|
|
||||||
def _get_store_router():
|
def _get_store_router():
|
||||||
"""Lazy import of store router to avoid circular imports."""
|
"""Lazy import of store router to avoid circular imports."""
|
||||||
from app.modules.loyalty.routes.api.store import store_router
|
from app.modules.loyalty.routes.api.store import store_router
|
||||||
@@ -119,6 +126,7 @@ loyalty_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
FrontendType.MERCHANT: [
|
FrontendType.MERCHANT: [
|
||||||
"loyalty-overview", # Merchant loyalty overview
|
"loyalty-overview", # Merchant loyalty overview
|
||||||
|
"loyalty-settings", # Merchant loyalty settings
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
# New module-driven menu definitions
|
# New module-driven menu definitions
|
||||||
@@ -192,6 +200,13 @@ loyalty_module = ModuleDefinition(
|
|||||||
route="/merchants/loyalty/overview",
|
route="/merchants/loyalty/overview",
|
||||||
order=10,
|
order=10,
|
||||||
),
|
),
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="loyalty-settings",
|
||||||
|
label_key="loyalty.menu.settings",
|
||||||
|
icon="cog",
|
||||||
|
route="/merchants/loyalty/settings",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -253,6 +268,7 @@ def get_loyalty_module_with_routers() -> ModuleDefinition:
|
|||||||
during module initialization.
|
during module initialization.
|
||||||
"""
|
"""
|
||||||
loyalty_module.admin_router = _get_admin_router()
|
loyalty_module.admin_router = _get_admin_router()
|
||||||
|
loyalty_module.merchant_router = _get_merchant_router()
|
||||||
loyalty_module.store_router = _get_store_router()
|
loyalty_module.store_router = _get_store_router()
|
||||||
loyalty_module.platform_router = _get_platform_router()
|
loyalty_module.platform_router = _get_platform_router()
|
||||||
loyalty_module.storefront_router = _get_storefront_router()
|
loyalty_module.storefront_router = _get_storefront_router()
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"customer_cards": "Kundenkarten",
|
"customer_cards": "Kundenkarten",
|
||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"settings": "Einstellungen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"customer_cards": "Customer Cards",
|
"customer_cards": "Customer Cards",
|
||||||
"statistics": "Statistics"
|
"statistics": "Statistics",
|
||||||
|
"overview": "Overview",
|
||||||
|
"settings": "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"customer_cards": "Cartes clients",
|
"customer_cards": "Cartes clients",
|
||||||
"statistics": "Statistiques"
|
"statistics": "Statistiques",
|
||||||
|
"overview": "Aperçu",
|
||||||
|
"settings": "Paramètres"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"customer_cards": "Clientekaarten",
|
"customer_cards": "Clientekaarten",
|
||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken",
|
||||||
|
"overview": "Iwwersiicht",
|
||||||
|
"settings": "Astellungen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ from app.modules.loyalty.schemas import (
|
|||||||
MerchantSettingsResponse,
|
MerchantSettingsResponse,
|
||||||
MerchantSettingsUpdate,
|
MerchantSettingsUpdate,
|
||||||
MerchantStatsResponse,
|
MerchantStatsResponse,
|
||||||
|
ProgramCreate,
|
||||||
ProgramListResponse,
|
ProgramListResponse,
|
||||||
ProgramResponse,
|
ProgramResponse,
|
||||||
ProgramStatsResponse,
|
ProgramStatsResponse,
|
||||||
|
ProgramUpdate,
|
||||||
)
|
)
|
||||||
from app.modules.loyalty.services import program_service
|
from app.modules.loyalty.services import program_service
|
||||||
from app.modules.tenancy.models import User # API-007
|
from app.modules.tenancy.models import User # API-007
|
||||||
@@ -107,6 +109,93 @@ def get_program_stats(
|
|||||||
return ProgramStatsResponse(**stats)
|
return ProgramStatsResponse(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post(
|
||||||
|
"/merchants/{merchant_id}/program", response_model=ProgramResponse, status_code=201
|
||||||
|
)
|
||||||
|
def create_program_for_merchant(
|
||||||
|
data: ProgramCreate,
|
||||||
|
merchant_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a loyalty program for a merchant (admin override)."""
|
||||||
|
program = program_service.create_program(db, merchant_id, data)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
logger.info(f"Admin created loyalty program for merchant {merchant_id}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.patch("/programs/{program_id}", response_model=ProgramResponse)
|
||||||
|
def update_program(
|
||||||
|
data: ProgramUpdate,
|
||||||
|
program_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a loyalty program (admin override)."""
|
||||||
|
program = program_service.update_program(db, program_id, data)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
logger.info(f"Admin updated loyalty program {program_id}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.delete("/programs/{program_id}", status_code=204)
|
||||||
|
def delete_program(
|
||||||
|
program_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a loyalty program (admin override)."""
|
||||||
|
program_service.delete_program(db, program_id)
|
||||||
|
logger.info(f"Admin deleted loyalty program {program_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/programs/{program_id}/activate", response_model=ProgramResponse)
|
||||||
|
def activate_program(
|
||||||
|
program_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Activate a loyalty program."""
|
||||||
|
program = program_service.activate_program(db, program_id)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
logger.info(f"Admin activated loyalty program {program_id}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/programs/{program_id}/deactivate", response_model=ProgramResponse)
|
||||||
|
def deactivate_program(
|
||||||
|
program_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Deactivate a loyalty program."""
|
||||||
|
program = program_service.deactivate_program(db, program_id)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
logger.info(f"Admin deactivated loyalty program {program_id}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Merchant Management
|
# Merchant Management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
98
app/modules/loyalty/routes/api/merchant.py
Normal file
98
app/modules/loyalty/routes/api/merchant.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# app/modules/loyalty/routes/api/merchant.py
|
||||||
|
"""
|
||||||
|
Loyalty module merchant routes.
|
||||||
|
|
||||||
|
Merchant portal endpoints for full program CRUD:
|
||||||
|
- Get merchant's loyalty program
|
||||||
|
- Create a loyalty program
|
||||||
|
- Update the loyalty program
|
||||||
|
- Delete the loyalty program
|
||||||
|
|
||||||
|
Authentication: Authorization header (API-only, no cookies for CSRF safety).
|
||||||
|
The user must own at least one active merchant (validated by
|
||||||
|
get_merchant_for_current_user).
|
||||||
|
|
||||||
|
Auto-discovered by the route system (merchant.py in routes/api/ triggers
|
||||||
|
registration under /api/v1/merchants/loyalty/*).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_merchant_for_current_user
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.loyalty.schemas import (
|
||||||
|
ProgramCreate,
|
||||||
|
ProgramResponse,
|
||||||
|
ProgramUpdate,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services import program_service
|
||||||
|
from app.modules.tenancy.models import Merchant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "/loyalty",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_program_response(program) -> ProgramResponse:
|
||||||
|
"""Build a ProgramResponse from a program ORM object."""
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Program CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/program", response_model=ProgramResponse)
|
||||||
|
def get_program(
|
||||||
|
merchant: Merchant = Depends(get_merchant_for_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get the merchant's loyalty program."""
|
||||||
|
program = program_service.require_program_by_merchant(db, merchant.id)
|
||||||
|
return _build_program_response(program)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/program", response_model=ProgramResponse, status_code=201)
|
||||||
|
def create_program(
|
||||||
|
data: ProgramCreate,
|
||||||
|
merchant: Merchant = Depends(get_merchant_for_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a loyalty program for the merchant."""
|
||||||
|
program = program_service.create_program(db, merchant.id, data)
|
||||||
|
return _build_program_response(program)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/program", response_model=ProgramResponse)
|
||||||
|
def update_program(
|
||||||
|
data: ProgramUpdate,
|
||||||
|
merchant: Merchant = Depends(get_merchant_for_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update the merchant's loyalty program."""
|
||||||
|
program = program_service.require_program_by_merchant(db, merchant.id)
|
||||||
|
program = program_service.update_program(db, program.id, data)
|
||||||
|
return _build_program_response(program)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/program", status_code=204)
|
||||||
|
def delete_program(
|
||||||
|
merchant: Merchant = Depends(get_merchant_for_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete the merchant's loyalty program."""
|
||||||
|
program = program_service.require_program_by_merchant(db, merchant.id)
|
||||||
|
program_service.delete_program(db, program.id)
|
||||||
|
logger.info(f"Merchant {merchant.id} ({merchant.name}) deleted loyalty program")
|
||||||
@@ -40,10 +40,8 @@ from app.modules.loyalty.schemas import (
|
|||||||
PointsRedeemResponse,
|
PointsRedeemResponse,
|
||||||
PointsVoidRequest,
|
PointsVoidRequest,
|
||||||
PointsVoidResponse,
|
PointsVoidResponse,
|
||||||
ProgramCreate,
|
|
||||||
ProgramResponse,
|
ProgramResponse,
|
||||||
ProgramStatsResponse,
|
ProgramStatsResponse,
|
||||||
ProgramUpdate,
|
|
||||||
StampRedeemRequest,
|
StampRedeemRequest,
|
||||||
StampRedeemResponse,
|
StampRedeemResponse,
|
||||||
StampRequest,
|
StampRequest,
|
||||||
@@ -106,46 +104,6 @@ def get_program(
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@store_router.post("/program", response_model=ProgramResponse, status_code=201)
|
|
||||||
def create_program(
|
|
||||||
data: ProgramCreate,
|
|
||||||
current_user: User = Depends(get_current_store_api),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Create a loyalty program for the merchant."""
|
|
||||||
store_id = current_user.token_store_id
|
|
||||||
merchant_id = get_store_merchant_id(db, store_id)
|
|
||||||
|
|
||||||
program = program_service.create_program(db, merchant_id, data)
|
|
||||||
|
|
||||||
response = ProgramResponse.model_validate(program)
|
|
||||||
response.is_stamps_enabled = program.is_stamps_enabled
|
|
||||||
response.is_points_enabled = program.is_points_enabled
|
|
||||||
response.display_name = program.display_name
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@store_router.patch("/program", response_model=ProgramResponse)
|
|
||||||
def update_program(
|
|
||||||
data: ProgramUpdate,
|
|
||||||
current_user: User = Depends(get_current_store_api),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Update the merchant's loyalty program."""
|
|
||||||
store_id = current_user.token_store_id
|
|
||||||
|
|
||||||
program = program_service.require_program_by_store(db, store_id)
|
|
||||||
program = program_service.update_program(db, program.id, data)
|
|
||||||
|
|
||||||
response = ProgramResponse.model_validate(program)
|
|
||||||
response.is_stamps_enabled = program.is_stamps_enabled
|
|
||||||
response.is_points_enabled = program.is_points_enabled
|
|
||||||
response.display_name = program.display_name
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/stats", response_model=ProgramStatsResponse)
|
@store_router.get("/stats", response_model=ProgramStatsResponse)
|
||||||
def get_stats(
|
def get_stats(
|
||||||
current_user: User = Depends(get_current_store_api),
|
current_user: User = Depends(get_current_store_api),
|
||||||
|
|||||||
@@ -98,3 +98,32 @@ async def merchant_loyalty_overview(
|
|||||||
"loyalty/merchant/overview.html",
|
"loyalty/merchant/overview.html",
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LOYALTY SETTINGS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def merchant_loyalty_settings(
|
||||||
|
request: Request,
|
||||||
|
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||||
|
merchant: Merchant = Depends(get_merchant_for_current_user_page),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render merchant loyalty settings page.
|
||||||
|
|
||||||
|
Allows merchant to create, configure, and manage their loyalty program.
|
||||||
|
"""
|
||||||
|
context = _get_merchant_context(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"loyalty/merchant/settings.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|||||||
@@ -178,32 +178,6 @@ async def store_loyalty_card_detail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# PROGRAM SETTINGS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{store_code}/loyalty/settings",
|
|
||||||
response_class=HTMLResponse,
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
async def store_loyalty_settings(
|
|
||||||
request: Request,
|
|
||||||
store_code: str = Path(..., description="Store code"),
|
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Render loyalty program settings page.
|
|
||||||
Allows store to configure points rate, rewards, branding, etc.
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"loyalty/store/settings.html",
|
|
||||||
get_store_context(request, db, current_user, store_code),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STATS DASHBOARD
|
# STATS DASHBOARD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function adminLoyaltyMerchantDetail() {
|
|||||||
// State
|
// State
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
showDeleteModal: false,
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
@@ -176,6 +177,67 @@ function adminLoyaltyMerchantDetail() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Create a default program for this merchant
|
||||||
|
async createProgram() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
loyalty_type: 'points',
|
||||||
|
points_per_euro: 1,
|
||||||
|
welcome_bonus_points: 0,
|
||||||
|
minimum_redemption_points: 100,
|
||||||
|
card_name: this.merchant?.name ? this.merchant.name + ' Loyalty' : 'Loyalty Program',
|
||||||
|
is_active: true
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`/admin/loyalty/merchants/${this.merchantId}/program`, data);
|
||||||
|
this.program = response;
|
||||||
|
Utils.showToast('Loyalty program created', 'success');
|
||||||
|
loyaltyMerchantDetailLog.info('Program created for merchant', this.merchantId);
|
||||||
|
// Reload stats
|
||||||
|
await this.loadStats();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(`Failed to create program: ${error.message}`, 'error');
|
||||||
|
loyaltyMerchantDetailLog.error('Failed to create program:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle program active/inactive
|
||||||
|
async toggleActive() {
|
||||||
|
if (!this.program) return;
|
||||||
|
const action = this.program.is_active ? 'deactivate' : 'activate';
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/admin/loyalty/programs/${this.program.id}/${action}`);
|
||||||
|
this.program.is_active = response.is_active;
|
||||||
|
Utils.showToast(`Program ${action}d successfully`, 'success');
|
||||||
|
loyaltyMerchantDetailLog.info(`Program ${action}d`);
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(`Failed to ${action} program: ${error.message}`, 'error');
|
||||||
|
loyaltyMerchantDetailLog.error(`Failed to ${action} program:`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show delete confirmation
|
||||||
|
confirmDeleteProgram() {
|
||||||
|
this.showDeleteModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete the program
|
||||||
|
async deleteProgram() {
|
||||||
|
if (!this.program) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/admin/loyalty/programs/${this.program.id}`);
|
||||||
|
this.program = null;
|
||||||
|
this.showDeleteModal = false;
|
||||||
|
Utils.showToast('Loyalty program deleted', 'success');
|
||||||
|
loyaltyMerchantDetailLog.info('Program deleted');
|
||||||
|
// Reload stats
|
||||||
|
await this.loadStats();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(`Failed to delete program: ${error.message}`, 'error');
|
||||||
|
loyaltyMerchantDetailLog.error('Failed to delete program:', error);
|
||||||
|
this.showDeleteModal = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Format date for display
|
// Format date for display
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ function adminLoyaltyPrograms() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Toggle program active/inactive
|
||||||
|
async toggleProgramActive(program) {
|
||||||
|
const action = program.is_active ? 'deactivate' : 'activate';
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/admin/loyalty/programs/${program.id}/${action}`);
|
||||||
|
program.is_active = response.is_active;
|
||||||
|
Utils.showToast(`Program ${action}d successfully`, 'success');
|
||||||
|
loyaltyProgramsLog.info(`Program ${program.id} ${action}d`);
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(`Failed to ${action} program: ${error.message}`, 'error');
|
||||||
|
loyaltyProgramsLog.error(`Failed to ${action} program:`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Format date for display
|
// Format date for display
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// app/modules/loyalty/static/store/js/loyalty-settings.js
|
// app/modules/loyalty/static/merchant/js/loyalty-settings.js
|
||||||
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
||||||
|
|
||||||
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
|
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
|
||||||
|
|
||||||
function storeLoyaltySettings() {
|
function merchantLoyaltySettings() {
|
||||||
return {
|
return {
|
||||||
...data(),
|
...data(),
|
||||||
currentPage: 'loyalty-settings',
|
currentPage: 'loyalty-settings',
|
||||||
@@ -22,22 +22,18 @@ function storeLoyaltySettings() {
|
|||||||
|
|
||||||
loading: false,
|
loading: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
deleting: false,
|
||||||
error: null,
|
error: null,
|
||||||
isNewProgram: false,
|
isNewProgram: false,
|
||||||
|
showDeleteModal: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZING ===');
|
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ===');
|
||||||
if (window._loyaltySettingsInitialized) return;
|
if (window._merchantLoyaltySettingsInitialized) return;
|
||||||
window._loyaltySettingsInitialized = true;
|
window._merchantLoyaltySettingsInitialized = true;
|
||||||
|
|
||||||
// IMPORTANT: Call parent init first to set storeCode from URL
|
|
||||||
const parentInit = data().init;
|
|
||||||
if (parentInit) {
|
|
||||||
await parentInit.call(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
|
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
@@ -45,7 +41,7 @@ function storeLoyaltySettings() {
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/store/loyalty/program');
|
const response = await apiClient.get('/merchants/loyalty/program');
|
||||||
if (response) {
|
if (response) {
|
||||||
this.settings = {
|
this.settings = {
|
||||||
loyalty_type: response.loyalty_type || 'points',
|
loyalty_type: response.loyalty_type || 'points',
|
||||||
@@ -86,10 +82,10 @@ function storeLoyaltySettings() {
|
|||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (this.isNewProgram) {
|
if (this.isNewProgram) {
|
||||||
response = await apiClient.post('/store/loyalty/program', this.settings);
|
response = await apiClient.post('/merchants/loyalty/program', this.settings);
|
||||||
this.isNewProgram = false;
|
this.isNewProgram = false;
|
||||||
} else {
|
} else {
|
||||||
response = await apiClient.patch('/store/loyalty/program', this.settings);
|
response = await apiClient.patch('/merchants/loyalty/program', this.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
Utils.showToast('Settings saved successfully', 'success');
|
Utils.showToast('Settings saved successfully', 'success');
|
||||||
@@ -102,6 +98,28 @@ function storeLoyaltySettings() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirmDelete() {
|
||||||
|
this.showDeleteModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProgram() {
|
||||||
|
this.deleting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete('/merchants/loyalty/program');
|
||||||
|
Utils.showToast('Loyalty program deleted', 'success');
|
||||||
|
loyaltySettingsLog.info('Program deleted');
|
||||||
|
// Redirect to overview
|
||||||
|
window.location.href = '/merchants/loyalty/overview';
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(`Failed to delete: ${error.message}`, 'error');
|
||||||
|
loyaltySettingsLog.error('Delete failed:', error);
|
||||||
|
} finally {
|
||||||
|
this.deleting = false;
|
||||||
|
this.showDeleteModal = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
addReward() {
|
addReward() {
|
||||||
this.settings.points_rewards.push({
|
this.settings.points_rewards.push({
|
||||||
id: `reward_${Date.now()}`,
|
id: `reward_${Date.now()}`,
|
||||||
@@ -121,4 +139,4 @@ function storeLoyaltySettings() {
|
|||||||
if (!window.LogConfig.loggers.loyaltySettings) {
|
if (!window.LogConfig.loggers.loyaltySettings) {
|
||||||
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
|
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
|
||||||
}
|
}
|
||||||
loyaltySettingsLog.info('Loyalty settings module loaded');
|
loyaltySettingsLog.info('Merchant loyalty settings module loaded');
|
||||||
@@ -105,10 +105,27 @@
|
|||||||
|
|
||||||
<!-- Program Configuration -->
|
<!-- Program Configuration -->
|
||||||
<div x-show="program" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<div x-show="program" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
<span x-html="$icon('cog', 'inline w-5 h-5 mr-2')"></span>
|
<span x-html="$icon('cog', 'inline w-5 h-5 mr-2')"></span>
|
||||||
Program Configuration
|
Program Configuration
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="toggleActive()"
|
||||||
|
class="flex items-center px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors"
|
||||||
|
:class="program?.is_active
|
||||||
|
? 'text-orange-600 border-orange-300 hover:bg-orange-50 dark:text-orange-400 dark:border-orange-700 dark:hover:bg-orange-900/20'
|
||||||
|
: 'text-green-600 border-green-300 hover:bg-green-50 dark:text-green-400 dark:border-green-700 dark:hover:bg-green-900/20'">
|
||||||
|
<span x-html="$icon(program?.is_active ? 'pause' : 'play', 'w-4 h-4 mr-1')"></span>
|
||||||
|
<span x-text="program?.is_active ? 'Deactivate' : 'Activate'"></span>
|
||||||
|
</button>
|
||||||
|
<button @click="confirmDeleteProgram()"
|
||||||
|
class="flex items-center px-3 py-1.5 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/20">
|
||||||
|
<span x-html="$icon('trash', 'w-4 h-4 mr-1')"></span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Program Name</p>
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Program Name</p>
|
||||||
@@ -142,11 +159,39 @@
|
|||||||
|
|
||||||
<!-- No Program Notice -->
|
<!-- No Program Notice -->
|
||||||
<div x-show="!program" class="px-4 py-3 mb-8 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
<div x-show="!program" class="px-4 py-3 mb-8 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
|
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">No Loyalty Program</h3>
|
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">No Loyalty Program</h3>
|
||||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">This merchant has not set up a loyalty program yet. Stores can set up the program from their dashboard.</p>
|
<p class="text-sm text-yellow-700 dark:text-yellow-300">This merchant has not set up a loyalty program yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="createProgram()"
|
||||||
|
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||||
|
Create Program
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div x-show="showDeleteModal" x-cloak
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Loyalty Program</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
This will permanently delete the loyalty program and all associated data. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="showDeleteModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button @click="deleteProgram()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||||
|
Delete Program
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,14 +217,24 @@
|
|||||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Settings Button -->
|
<!-- Edit Button -->
|
||||||
<a
|
<a
|
||||||
:href="'/admin/loyalty/merchants/' + program.merchant_id + '/settings'"
|
:href="'/admin/loyalty/merchants/' + program.merchant_id"
|
||||||
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||||
title="Merchant loyalty settings"
|
title="Edit program"
|
||||||
>
|
>
|
||||||
<span x-html="$icon('cog', 'w-5 h-5')"></span>
|
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Activate/Deactivate Toggle -->
|
||||||
|
<button
|
||||||
|
@click="toggleProgramActive(program)"
|
||||||
|
class="flex items-center justify-center p-2 rounded-lg focus:outline-none transition-colors"
|
||||||
|
:class="program.is_active ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700' : 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700'"
|
||||||
|
:title="program.is_active ? 'Deactivate program' : 'Activate program'"
|
||||||
|
>
|
||||||
|
<span x-html="$icon(program.is_active ? 'pause' : 'play', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -7,10 +7,19 @@
|
|||||||
<div x-data="merchantLoyaltyOverview()">
|
<div x-data="merchantLoyaltyOverview()">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8 mt-6">
|
<div class="mb-8 mt-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
|
||||||
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
|
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<template x-if="stats.program_id">
|
||||||
|
<a href="/merchants/loyalty/settings"
|
||||||
|
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||||
|
Edit Program
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- No Program State -->
|
<!-- No Program State -->
|
||||||
<template x-if="!stats.program_id && !loading">
|
<template x-if="!stats.program_id && !loading">
|
||||||
@@ -18,8 +27,13 @@
|
|||||||
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
|
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
|
||||||
<p class="mt-2 text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-gray-500 dark:text-gray-400">
|
||||||
Your loyalty program hasn't been set up yet. Contact the platform administrator or set it up from your store dashboard.
|
Your loyalty program hasn't been set up yet. Create one to start rewarding your customers.
|
||||||
</p>
|
</p>
|
||||||
|
<a href="/merchants/loyalty/settings"
|
||||||
|
class="inline-flex items-center mt-4 px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||||
|
Create Program
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
|
{# app/modules/loyalty/templates/loyalty/merchant/settings.html #}
|
||||||
{% extends "store/base.html" %}
|
{% extends "merchant/base.html" %}
|
||||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
|
|
||||||
{% block title %}Loyalty Settings{% endblock %}
|
{% block title %}Loyalty Settings{% endblock %}
|
||||||
|
|
||||||
{% block alpine_data %}storeLoyaltySettings(){% endblock %}
|
{% block alpine_data %}merchantLoyaltySettings(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %}
|
{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points per EUR spent</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points per EUR spent</label>
|
||||||
<input type="number" min="1" max="100" x-model.number="settings.points_per_euro" {# noqa: FE-008 #}
|
<input type="number" min="1" max="100" x-model.number="settings.points_per_euro"
|
||||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||||
<p class="mt-1 text-xs text-gray-500">1 EUR = <span x-text="settings.points_per_euro || 1"></span> point(s)</p>
|
<p class="mt-1 text-xs text-gray-500">1 EUR = <span x-text="settings.points_per_euro || 1"></span> point(s)</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,17 +142,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center justify-end gap-4">
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<template x-if="!isNewProgram">
|
||||||
|
<button type="button" @click="confirmDelete()"
|
||||||
|
class="flex items-center px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/20">
|
||||||
|
<span x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
|
||||||
|
Delete Program
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<button type="submit" :disabled="saving"
|
<button type="submit" :disabled="saving"
|
||||||
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
|
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
|
||||||
<span x-text="saving ? 'Saving...' : 'Save Settings'"></span>
|
<span x-text="saving ? 'Saving...' : (isNewProgram ? 'Create Program' : 'Save Settings')"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div x-show="showDeleteModal" x-cloak
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Loyalty Program</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
This will permanently delete your loyalty program and all associated data (cards, transactions, rewards).
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="showDeleteModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button @click="deleteProgram()" :disabled="deleting"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||||
|
<span x-text="deleting ? 'Deleting...' : 'Delete Program'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
|
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-settings.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -9,11 +9,14 @@ from datetime import UTC, datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
|
||||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
||||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||||
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||||
from app.modules.tenancy.models.store import StoreUser
|
from app.modules.tenancy.models.store import StoreUser
|
||||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
|
from main import app
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -166,6 +169,95 @@ def loyalty_store_setup(db, loyalty_platform):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loyalty_merchant_setup(db, loyalty_platform):
|
||||||
|
"""
|
||||||
|
Merchant-only setup for loyalty integration tests (no program).
|
||||||
|
|
||||||
|
Creates: User -> Merchant (no program yet).
|
||||||
|
Use this for testing program creation via merchant API.
|
||||||
|
"""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
owner = User(
|
||||||
|
email=f"merchowner_{uid}@test.com",
|
||||||
|
username=f"merchowner_{uid}",
|
||||||
|
hashed_password=auth.hash_password("merchpass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
is_email_verified=True,
|
||||||
|
)
|
||||||
|
db.add(owner)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(owner)
|
||||||
|
|
||||||
|
merchant = Merchant(
|
||||||
|
name=f"Loyalty Merchant {uid}",
|
||||||
|
owner_user_id=owner.id,
|
||||||
|
contact_email=owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": owner,
|
||||||
|
"merchant": merchant,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loyalty_merchant_headers(loyalty_store_setup):
|
||||||
|
"""
|
||||||
|
Override auth dependencies to return merchant/user for the merchant owner.
|
||||||
|
Uses the full loyalty_store_setup which includes a program.
|
||||||
|
"""
|
||||||
|
owner = loyalty_store_setup["owner"]
|
||||||
|
merchant = loyalty_store_setup["merchant"]
|
||||||
|
|
||||||
|
user_context = UserContext(
|
||||||
|
id=owner.id,
|
||||||
|
email=owner.email,
|
||||||
|
username=owner.username,
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_merchant_for_current_user] = lambda: merchant
|
||||||
|
app.dependency_overrides[get_current_merchant_api] = lambda: user_context
|
||||||
|
yield {"Authorization": "Bearer fake-token"}
|
||||||
|
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||||
|
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loyalty_merchant_headers_no_program(loyalty_merchant_setup):
|
||||||
|
"""
|
||||||
|
Override auth dependencies for a merchant that has no program yet.
|
||||||
|
"""
|
||||||
|
owner = loyalty_merchant_setup["owner"]
|
||||||
|
merchant = loyalty_merchant_setup["merchant"]
|
||||||
|
|
||||||
|
user_context = UserContext(
|
||||||
|
id=owner.id,
|
||||||
|
email=owner.email,
|
||||||
|
username=owner.username,
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_merchant_for_current_user] = lambda: merchant
|
||||||
|
app.dependency_overrides[get_current_merchant_api] = lambda: user_context
|
||||||
|
yield {"Authorization": "Bearer fake-token"}
|
||||||
|
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||||
|
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def loyalty_store_headers(client, loyalty_store_setup):
|
def loyalty_store_headers(client, loyalty_store_setup):
|
||||||
"""
|
"""
|
||||||
|
|||||||
385
app/modules/loyalty/tests/integration/test_admin_api.py
Normal file
385
app/modules/loyalty/tests/integration/test_admin_api.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
# app/modules/loyalty/tests/integration/test_admin_api.py
|
||||||
|
"""
|
||||||
|
Integration tests for admin loyalty CRUD API endpoints.
|
||||||
|
|
||||||
|
Tests the admin program management endpoints at:
|
||||||
|
/api/v1/admin/loyalty/*
|
||||||
|
|
||||||
|
Authentication: Uses super_admin_headers fixture (real JWT login).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyProgram
|
||||||
|
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||||
|
from app.modules.tenancy.models import Merchant, User
|
||||||
|
|
||||||
|
BASE = "/api/v1/admin/loyalty"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_merchant(db):
|
||||||
|
"""Create a merchant for admin CRUD tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
owner = User(
|
||||||
|
email=f"adminmerchowner_{uid}@test.com",
|
||||||
|
username=f"adminmerchowner_{uid}",
|
||||||
|
hashed_password=auth.hash_password("testpass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
is_email_verified=True,
|
||||||
|
)
|
||||||
|
db.add(owner)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(owner)
|
||||||
|
|
||||||
|
merchant = Merchant(
|
||||||
|
name=f"Admin Test Merchant {uid}",
|
||||||
|
owner_user_id=owner.id,
|
||||||
|
contact_email=owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_program(db, admin_merchant):
|
||||||
|
"""Create a loyalty program for admin CRUD tests."""
|
||||||
|
program = LoyaltyProgram(
|
||||||
|
merchant_id=admin_merchant.id,
|
||||||
|
loyalty_type=LoyaltyType.POINTS.value,
|
||||||
|
points_per_euro=5,
|
||||||
|
welcome_bonus_points=25,
|
||||||
|
minimum_redemption_points=50,
|
||||||
|
minimum_purchase_cents=0,
|
||||||
|
cooldown_minutes=0,
|
||||||
|
max_daily_stamps=10,
|
||||||
|
require_staff_pin=False,
|
||||||
|
card_name="Admin Test Rewards",
|
||||||
|
card_color="#1E90FF",
|
||||||
|
is_active=True,
|
||||||
|
points_rewards=[
|
||||||
|
{"id": "reward_1", "name": "5 EUR off", "points_required": 50, "is_active": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(program)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(program)
|
||||||
|
return program
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# POST /merchants/{merchant_id}/program
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestAdminCreateProgram:
|
||||||
|
"""Tests for POST /api/v1/admin/loyalty/merchants/{merchant_id}/program."""
|
||||||
|
|
||||||
|
def test_create_program_for_merchant(
|
||||||
|
self, client, super_admin_headers, admin_merchant
|
||||||
|
):
|
||||||
|
"""Admin can create a program for any merchant."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/merchants/{admin_merchant.id}/program",
|
||||||
|
json={
|
||||||
|
"loyalty_type": "points",
|
||||||
|
"points_per_euro": 8,
|
||||||
|
"card_name": "Admin Created",
|
||||||
|
"card_color": "#00FF00",
|
||||||
|
},
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["merchant_id"] == admin_merchant.id
|
||||||
|
assert data["points_per_euro"] == 8
|
||||||
|
assert data["card_name"] == "Admin Created"
|
||||||
|
|
||||||
|
def test_create_program_with_stamps(
|
||||||
|
self, client, super_admin_headers, admin_merchant
|
||||||
|
):
|
||||||
|
"""Admin can create a stamps-type program."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/merchants/{admin_merchant.id}/program",
|
||||||
|
json={
|
||||||
|
"loyalty_type": "stamps",
|
||||||
|
"stamps_target": 10,
|
||||||
|
"stamps_reward_description": "Free coffee",
|
||||||
|
},
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["loyalty_type"] == "stamps"
|
||||||
|
assert data["stamps_target"] == 10
|
||||||
|
|
||||||
|
def test_create_program_requires_auth(
|
||||||
|
self, client, admin_merchant
|
||||||
|
):
|
||||||
|
"""Unauthenticated request is rejected."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/merchants/{admin_merchant.id}/program",
|
||||||
|
json={"loyalty_type": "points"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PATCH /programs/{program_id}
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestAdminUpdateProgram:
|
||||||
|
"""Tests for PATCH /api/v1/admin/loyalty/programs/{program_id}."""
|
||||||
|
|
||||||
|
def test_update_program(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""Admin can update any program."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/programs/{admin_program.id}",
|
||||||
|
json={
|
||||||
|
"points_per_euro": 15,
|
||||||
|
"card_name": "Updated by Admin",
|
||||||
|
},
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["points_per_euro"] == 15
|
||||||
|
assert data["card_name"] == "Updated by Admin"
|
||||||
|
|
||||||
|
def test_update_program_partial(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""Partial update only changes specified fields."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/programs/{admin_program.id}",
|
||||||
|
json={"card_name": "Only Name Changed"},
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["card_name"] == "Only Name Changed"
|
||||||
|
assert data["points_per_euro"] == 5 # unchanged
|
||||||
|
|
||||||
|
def test_update_nonexistent_program(
|
||||||
|
self, client, super_admin_headers
|
||||||
|
):
|
||||||
|
"""Updating non-existent program returns 404."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/programs/999999",
|
||||||
|
json={"card_name": "Ghost"},
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DELETE /programs/{program_id}
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestAdminDeleteProgram:
|
||||||
|
"""Tests for DELETE /api/v1/admin/loyalty/programs/{program_id}."""
|
||||||
|
|
||||||
|
def test_delete_program(
|
||||||
|
self, client, super_admin_headers, admin_program, db
|
||||||
|
):
|
||||||
|
"""Admin can delete any program."""
|
||||||
|
program_id = admin_program.id
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"{BASE}/programs/{program_id}",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
deleted = db.get(LoyaltyProgram, program_id)
|
||||||
|
assert deleted is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent_program(
|
||||||
|
self, client, super_admin_headers
|
||||||
|
):
|
||||||
|
"""Deleting non-existent program returns 404."""
|
||||||
|
response = client.delete(
|
||||||
|
f"{BASE}/programs/999999",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# POST /programs/{program_id}/activate
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestAdminActivateProgram:
|
||||||
|
"""Tests for POST /api/v1/admin/loyalty/programs/{program_id}/activate."""
|
||||||
|
|
||||||
|
def test_activate_inactive_program(
|
||||||
|
self, client, super_admin_headers, admin_program, db
|
||||||
|
):
|
||||||
|
"""Admin can activate an inactive program."""
|
||||||
|
# First deactivate
|
||||||
|
admin_program.is_active = False
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin_program)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/programs/{admin_program.id}/activate",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_active"] is True
|
||||||
|
|
||||||
|
def test_activate_already_active_program(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""Activating an already active program succeeds (idempotent)."""
|
||||||
|
assert admin_program.is_active is True
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/programs/{admin_program.id}/activate",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["is_active"] is True
|
||||||
|
|
||||||
|
def test_activate_nonexistent_program(
|
||||||
|
self, client, super_admin_headers
|
||||||
|
):
|
||||||
|
"""Activating non-existent program returns 404."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/programs/999999/activate",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# POST /programs/{program_id}/deactivate
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestAdminDeactivateProgram:
|
||||||
|
"""Tests for POST /api/v1/admin/loyalty/programs/{program_id}/deactivate."""
|
||||||
|
|
||||||
|
def test_deactivate_active_program(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""Admin can deactivate an active program."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/programs/{admin_program.id}/deactivate",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_active"] is False
|
||||||
|
|
||||||
|
def test_deactivate_already_inactive_program(
|
||||||
|
self, client, super_admin_headers, admin_program, db
|
||||||
|
):
|
||||||
|
"""Deactivating an already inactive program succeeds (idempotent)."""
|
||||||
|
admin_program.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/programs/{admin_program.id}/deactivate",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["is_active"] is False
|
||||||
|
|
||||||
|
def test_deactivate_nonexistent_program(
|
||||||
|
self, client, super_admin_headers
|
||||||
|
):
|
||||||
|
"""Deactivating non-existent program returns 404."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/programs/999999/deactivate",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Existing Admin Endpoints Still Work
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestAdminExistingEndpoints:
|
||||||
|
"""Verify existing admin endpoints still work after CRUD additions."""
|
||||||
|
|
||||||
|
def test_list_programs(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""GET /programs returns list including created program."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/programs",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "programs" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert data["total"] >= 1
|
||||||
|
|
||||||
|
def test_get_program_by_id(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""GET /programs/{id} returns specific program."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/programs/{admin_program.id}",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == admin_program.id
|
||||||
|
|
||||||
|
def test_get_program_stats(
|
||||||
|
self, client, super_admin_headers, admin_program
|
||||||
|
):
|
||||||
|
"""GET /programs/{id}/stats returns statistics."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/programs/{admin_program.id}/stats",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
267
app/modules/loyalty/tests/integration/test_merchant_api.py
Normal file
267
app/modules/loyalty/tests/integration/test_merchant_api.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# app/modules/loyalty/tests/integration/test_merchant_api.py
|
||||||
|
"""
|
||||||
|
Integration tests for merchant loyalty API endpoints.
|
||||||
|
|
||||||
|
Tests the merchant program CRUD endpoints at:
|
||||||
|
/api/v1/merchants/loyalty/*
|
||||||
|
|
||||||
|
Authentication: Uses dependency overrides (merch_auth_headers pattern).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
BASE = "/api/v1/merchants/loyalty"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET /program
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestMerchantGetProgram:
|
||||||
|
"""Tests for GET /api/v1/merchants/loyalty/program."""
|
||||||
|
|
||||||
|
def test_get_program_success(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""Returns the merchant's loyalty program."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/program", headers=loyalty_merchant_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == loyalty_store_setup["program"].id
|
||||||
|
assert data["merchant_id"] == loyalty_store_setup["merchant"].id
|
||||||
|
assert data["points_per_euro"] == 10
|
||||||
|
assert data["is_active"] is True
|
||||||
|
|
||||||
|
def test_get_program_includes_display_fields(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""Response includes computed display fields."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/program", headers=loyalty_merchant_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "display_name" in data
|
||||||
|
assert "is_stamps_enabled" in data
|
||||||
|
assert "is_points_enabled" in data
|
||||||
|
|
||||||
|
def test_get_program_not_found(
|
||||||
|
self, client, loyalty_merchant_headers_no_program
|
||||||
|
):
|
||||||
|
"""Returns 404 when merchant has no program."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/program", headers=loyalty_merchant_headers_no_program
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# POST /program
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestMerchantCreateProgram:
|
||||||
|
"""Tests for POST /api/v1/merchants/loyalty/program."""
|
||||||
|
|
||||||
|
def test_create_program_success(
|
||||||
|
self, client, loyalty_merchant_headers_no_program, loyalty_merchant_setup
|
||||||
|
):
|
||||||
|
"""Create a new loyalty program for the merchant."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={
|
||||||
|
"loyalty_type": "points",
|
||||||
|
"points_per_euro": 5,
|
||||||
|
"card_name": "My Rewards",
|
||||||
|
"card_color": "#FF5733",
|
||||||
|
},
|
||||||
|
headers=loyalty_merchant_headers_no_program,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["loyalty_type"] == "points"
|
||||||
|
assert data["points_per_euro"] == 5
|
||||||
|
assert data["card_name"] == "My Rewards"
|
||||||
|
assert data["card_color"] == "#FF5733"
|
||||||
|
assert data["merchant_id"] == loyalty_merchant_setup["merchant"].id
|
||||||
|
|
||||||
|
def test_create_program_with_rewards(
|
||||||
|
self, client, loyalty_merchant_headers_no_program
|
||||||
|
):
|
||||||
|
"""Create program with point rewards configured."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={
|
||||||
|
"loyalty_type": "points",
|
||||||
|
"points_per_euro": 10,
|
||||||
|
"points_rewards": [
|
||||||
|
{
|
||||||
|
"id": "r1",
|
||||||
|
"name": "5 EUR off",
|
||||||
|
"points_required": 100,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "r2",
|
||||||
|
"name": "10 EUR off",
|
||||||
|
"points_required": 200,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=loyalty_merchant_headers_no_program,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["points_rewards"]) == 2
|
||||||
|
assert data["points_rewards"][0]["name"] == "5 EUR off"
|
||||||
|
|
||||||
|
def test_create_program_defaults(
|
||||||
|
self, client, loyalty_merchant_headers_no_program
|
||||||
|
):
|
||||||
|
"""Creating with minimal data uses sensible defaults."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"loyalty_type": "points"},
|
||||||
|
headers=loyalty_merchant_headers_no_program,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["points_per_euro"] == 1
|
||||||
|
assert data["is_active"] is True
|
||||||
|
|
||||||
|
def test_create_program_duplicate_fails(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""Cannot create a second program for the same merchant."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"loyalty_type": "points"},
|
||||||
|
headers=loyalty_merchant_headers,
|
||||||
|
)
|
||||||
|
# Should fail - merchant already has a program
|
||||||
|
assert response.status_code in (400, 409, 422)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PATCH /program
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestMerchantUpdateProgram:
|
||||||
|
"""Tests for PATCH /api/v1/merchants/loyalty/program."""
|
||||||
|
|
||||||
|
def test_update_program_success(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""Update program fields."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={
|
||||||
|
"points_per_euro": 20,
|
||||||
|
"card_name": "Updated Rewards",
|
||||||
|
},
|
||||||
|
headers=loyalty_merchant_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["points_per_euro"] == 20
|
||||||
|
assert data["card_name"] == "Updated Rewards"
|
||||||
|
|
||||||
|
def test_update_program_partial(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""Partial update only modifies specified fields."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"card_name": "New Name Only"},
|
||||||
|
headers=loyalty_merchant_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["card_name"] == "New Name Only"
|
||||||
|
# Other fields should be unchanged
|
||||||
|
assert data["points_per_euro"] == 10
|
||||||
|
|
||||||
|
def test_update_program_deactivate(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""Can deactivate program via update."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"is_active": False},
|
||||||
|
headers=loyalty_merchant_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["is_active"] is False
|
||||||
|
|
||||||
|
def test_update_program_not_found(
|
||||||
|
self, client, loyalty_merchant_headers_no_program
|
||||||
|
):
|
||||||
|
"""Update returns 404 when no program exists."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"card_name": "No Program"},
|
||||||
|
headers=loyalty_merchant_headers_no_program,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DELETE /program
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestMerchantDeleteProgram:
|
||||||
|
"""Tests for DELETE /api/v1/merchants/loyalty/program."""
|
||||||
|
|
||||||
|
def test_delete_program_success(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup, db
|
||||||
|
):
|
||||||
|
"""Delete the merchant's loyalty program."""
|
||||||
|
program_id = loyalty_store_setup["program"].id
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"{BASE}/program", headers=loyalty_merchant_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify program is deleted
|
||||||
|
from app.modules.loyalty.models import LoyaltyProgram
|
||||||
|
|
||||||
|
deleted = db.get(LoyaltyProgram, program_id)
|
||||||
|
assert deleted is None
|
||||||
|
|
||||||
|
def test_delete_program_not_found(
|
||||||
|
self, client, loyalty_merchant_headers_no_program
|
||||||
|
):
|
||||||
|
"""Delete returns 404 when no program exists."""
|
||||||
|
response = client.delete(
|
||||||
|
f"{BASE}/program", headers=loyalty_merchant_headers_no_program
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_then_get_returns_404(
|
||||||
|
self, client, loyalty_merchant_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""After deletion, GET returns 404."""
|
||||||
|
client.delete(f"{BASE}/program", headers=loyalty_merchant_headers)
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/program", headers=loyalty_merchant_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for store loyalty API endpoints.
|
Integration tests for store loyalty API endpoints.
|
||||||
|
|
||||||
Tests the endpoints fixed today:
|
Tests:
|
||||||
- GET /cards/lookup (route ordering fix)
|
- GET /cards/lookup (route ordering fix)
|
||||||
- GET /cards/{card_id} (card detail)
|
- GET /cards/{card_id} (card detail)
|
||||||
- GET /transactions (customer_name in response)
|
- GET /transactions (customer_name in response)
|
||||||
- POST /points/earn (endpoint path rename)
|
- POST /points/earn (endpoint path rename)
|
||||||
|
- Store program CRUD endpoints removed (POST/PATCH /program)
|
||||||
|
|
||||||
Authentication: Uses real JWT tokens via store login endpoint.
|
Authentication: Uses real JWT tokens via store login endpoint.
|
||||||
"""
|
"""
|
||||||
@@ -213,3 +214,59 @@ class TestEarnPoints:
|
|||||||
)
|
)
|
||||||
# Should not be 404 (endpoint exists after rename)
|
# Should not be 404 (endpoint exists after rename)
|
||||||
assert response.status_code != 404
|
assert response.status_code != 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Store Program CRUD Removed
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestStoreProgramCrudRemoved:
|
||||||
|
"""Verify POST/PATCH /program endpoints are removed from store API."""
|
||||||
|
|
||||||
|
def test_create_program_removed(
|
||||||
|
self, client, loyalty_store_headers
|
||||||
|
):
|
||||||
|
"""POST /program no longer exists on store API."""
|
||||||
|
response = client.post(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"loyalty_type": "points"},
|
||||||
|
headers=loyalty_store_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 405 # Method Not Allowed
|
||||||
|
|
||||||
|
def test_update_program_removed(
|
||||||
|
self, client, loyalty_store_headers
|
||||||
|
):
|
||||||
|
"""PATCH /program no longer exists on store API."""
|
||||||
|
response = client.patch(
|
||||||
|
f"{BASE}/program",
|
||||||
|
json={"card_name": "Should Not Work"},
|
||||||
|
headers=loyalty_store_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 405 # Method Not Allowed
|
||||||
|
|
||||||
|
def test_get_program_still_works(
|
||||||
|
self, client, loyalty_store_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""GET /program still works (read-only)."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/program",
|
||||||
|
headers=loyalty_store_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == loyalty_store_setup["program"].id
|
||||||
|
|
||||||
|
def test_stats_still_works(
|
||||||
|
self, client, loyalty_store_headers, loyalty_store_setup
|
||||||
|
):
|
||||||
|
"""GET /stats still works."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/stats",
|
||||||
|
headers=loyalty_store_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
"""Unit tests for ProgramService."""
|
"""Unit tests for ProgramService."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
LoyaltyProgramAlreadyExistsException,
|
||||||
|
LoyaltyProgramNotFoundException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyProgram
|
||||||
|
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||||
|
from app.modules.loyalty.schemas.program import ProgramCreate, ProgramUpdate
|
||||||
from app.modules.loyalty.services.program_service import ProgramService
|
from app.modules.loyalty.services.program_service import ProgramService
|
||||||
|
from app.modules.tenancy.models import Merchant, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -16,3 +26,329 @@ class TestProgramService:
|
|||||||
def test_service_instantiation(self):
|
def test_service_instantiation(self):
|
||||||
"""Service can be instantiated."""
|
"""Service can be instantiated."""
|
||||||
assert self.service is not None
|
assert self.service is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ps_merchant(db):
|
||||||
|
"""Create a merchant for program service tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
owner = User(
|
||||||
|
email=f"psowner_{uid}@test.com",
|
||||||
|
username=f"psowner_{uid}",
|
||||||
|
hashed_password=auth.hash_password("testpass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
is_email_verified=True,
|
||||||
|
)
|
||||||
|
db.add(owner)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(owner)
|
||||||
|
|
||||||
|
merchant = Merchant(
|
||||||
|
name=f"PS Test Merchant {uid}",
|
||||||
|
owner_user_id=owner.id,
|
||||||
|
contact_email=owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ps_program(db, ps_merchant):
|
||||||
|
"""Create a program for program service tests."""
|
||||||
|
program = LoyaltyProgram(
|
||||||
|
merchant_id=ps_merchant.id,
|
||||||
|
loyalty_type=LoyaltyType.POINTS.value,
|
||||||
|
points_per_euro=10,
|
||||||
|
welcome_bonus_points=50,
|
||||||
|
minimum_redemption_points=100,
|
||||||
|
minimum_purchase_cents=0,
|
||||||
|
cooldown_minutes=0,
|
||||||
|
max_daily_stamps=10,
|
||||||
|
require_staff_pin=False,
|
||||||
|
card_name="PS Test Rewards",
|
||||||
|
card_color="#4F46E5",
|
||||||
|
is_active=True,
|
||||||
|
points_rewards=[
|
||||||
|
{"id": "reward_1", "name": "5 EUR off", "points_required": 100, "is_active": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(program)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(program)
|
||||||
|
return program
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Read Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestGetProgram:
|
||||||
|
"""Tests for get_program and get_program_by_merchant."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProgramService()
|
||||||
|
|
||||||
|
def test_get_program_by_id(self, db, ps_program):
|
||||||
|
"""Get a program by its ID."""
|
||||||
|
result = self.service.get_program(db, ps_program.id)
|
||||||
|
assert result is not None
|
||||||
|
assert result.id == ps_program.id
|
||||||
|
|
||||||
|
def test_get_program_not_found(self, db):
|
||||||
|
"""Returns None for non-existent program."""
|
||||||
|
result = self.service.get_program(db, 999999)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_program_by_merchant(self, db, ps_program, ps_merchant):
|
||||||
|
"""Get a program by merchant ID."""
|
||||||
|
result = self.service.get_program_by_merchant(db, ps_merchant.id)
|
||||||
|
assert result is not None
|
||||||
|
assert result.merchant_id == ps_merchant.id
|
||||||
|
|
||||||
|
def test_get_program_by_merchant_not_found(self, db):
|
||||||
|
"""Returns None when merchant has no program."""
|
||||||
|
result = self.service.get_program_by_merchant(db, 999999)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestRequireProgram:
|
||||||
|
"""Tests for require_program and require_program_by_merchant."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProgramService()
|
||||||
|
|
||||||
|
def test_require_program_found(self, db, ps_program):
|
||||||
|
"""Returns program when it exists."""
|
||||||
|
result = self.service.require_program(db, ps_program.id)
|
||||||
|
assert result.id == ps_program.id
|
||||||
|
|
||||||
|
def test_require_program_raises_not_found(self, db):
|
||||||
|
"""Raises exception when program doesn't exist."""
|
||||||
|
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||||
|
self.service.require_program(db, 999999)
|
||||||
|
|
||||||
|
def test_require_program_by_merchant_found(self, db, ps_program, ps_merchant):
|
||||||
|
"""Returns program for merchant."""
|
||||||
|
result = self.service.require_program_by_merchant(db, ps_merchant.id)
|
||||||
|
assert result.merchant_id == ps_merchant.id
|
||||||
|
|
||||||
|
def test_require_program_by_merchant_raises_not_found(self, db):
|
||||||
|
"""Raises exception when merchant has no program."""
|
||||||
|
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||||
|
self.service.require_program_by_merchant(db, 999999)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Create Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestCreateProgram:
|
||||||
|
"""Tests for create_program."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProgramService()
|
||||||
|
|
||||||
|
def test_create_program_points(self, db, ps_merchant):
|
||||||
|
"""Create a points-based program."""
|
||||||
|
data = ProgramCreate(
|
||||||
|
loyalty_type="points",
|
||||||
|
points_per_euro=5,
|
||||||
|
card_name="Unit Test Rewards",
|
||||||
|
card_color="#FF0000",
|
||||||
|
)
|
||||||
|
program = self.service.create_program(db, ps_merchant.id, data)
|
||||||
|
|
||||||
|
assert program.id is not None
|
||||||
|
assert program.merchant_id == ps_merchant.id
|
||||||
|
assert program.loyalty_type == "points"
|
||||||
|
assert program.points_per_euro == 5
|
||||||
|
assert program.card_name == "Unit Test Rewards"
|
||||||
|
assert program.is_active is True
|
||||||
|
|
||||||
|
def test_create_program_stamps(self, db, ps_merchant):
|
||||||
|
"""Create a stamps-based program."""
|
||||||
|
data = ProgramCreate(
|
||||||
|
loyalty_type="stamps",
|
||||||
|
stamps_target=8,
|
||||||
|
stamps_reward_description="Free coffee",
|
||||||
|
)
|
||||||
|
program = self.service.create_program(db, ps_merchant.id, data)
|
||||||
|
|
||||||
|
assert program.loyalty_type == "stamps"
|
||||||
|
assert program.stamps_target == 8
|
||||||
|
assert program.stamps_reward_description == "Free coffee"
|
||||||
|
|
||||||
|
def test_create_program_with_rewards(self, db, ps_merchant):
|
||||||
|
"""Create program with configured rewards."""
|
||||||
|
from app.modules.loyalty.schemas.program import PointsRewardConfig
|
||||||
|
|
||||||
|
data = ProgramCreate(
|
||||||
|
loyalty_type="points",
|
||||||
|
points_per_euro=10,
|
||||||
|
points_rewards=[
|
||||||
|
PointsRewardConfig(
|
||||||
|
id="r1",
|
||||||
|
name="5 EUR off",
|
||||||
|
points_required=100,
|
||||||
|
is_active=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
program = self.service.create_program(db, ps_merchant.id, data)
|
||||||
|
|
||||||
|
assert len(program.points_rewards) == 1
|
||||||
|
assert program.points_rewards[0]["name"] == "5 EUR off"
|
||||||
|
|
||||||
|
def test_create_program_duplicate_raises(self, db, ps_program, ps_merchant):
|
||||||
|
"""Cannot create two programs for the same merchant."""
|
||||||
|
data = ProgramCreate(loyalty_type="points")
|
||||||
|
with pytest.raises(LoyaltyProgramAlreadyExistsException):
|
||||||
|
self.service.create_program(db, ps_merchant.id, data)
|
||||||
|
|
||||||
|
def test_create_program_creates_merchant_settings(self, db, ps_merchant):
|
||||||
|
"""Creating a program also creates merchant loyalty settings."""
|
||||||
|
data = ProgramCreate(loyalty_type="points")
|
||||||
|
self.service.create_program(db, ps_merchant.id, data)
|
||||||
|
|
||||||
|
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||||
|
assert settings is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Update Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestUpdateProgram:
|
||||||
|
"""Tests for update_program."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProgramService()
|
||||||
|
|
||||||
|
def test_update_program_fields(self, db, ps_program):
|
||||||
|
"""Update specific fields."""
|
||||||
|
data = ProgramUpdate(points_per_euro=20, card_name="Updated Name")
|
||||||
|
result = self.service.update_program(db, ps_program.id, data)
|
||||||
|
|
||||||
|
assert result.points_per_euro == 20
|
||||||
|
assert result.card_name == "Updated Name"
|
||||||
|
|
||||||
|
def test_update_program_partial(self, db, ps_program):
|
||||||
|
"""Partial update preserves unchanged fields."""
|
||||||
|
original_points = ps_program.points_per_euro
|
||||||
|
data = ProgramUpdate(card_name="Only Name")
|
||||||
|
result = self.service.update_program(db, ps_program.id, data)
|
||||||
|
|
||||||
|
assert result.card_name == "Only Name"
|
||||||
|
assert result.points_per_euro == original_points
|
||||||
|
|
||||||
|
def test_update_nonexistent_raises(self, db):
|
||||||
|
"""Updating non-existent program raises exception."""
|
||||||
|
data = ProgramUpdate(card_name="Ghost")
|
||||||
|
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||||
|
self.service.update_program(db, 999999, data)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Activate / Deactivate
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestActivateDeactivate:
|
||||||
|
"""Tests for activate_program and deactivate_program."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProgramService()
|
||||||
|
|
||||||
|
def test_deactivate_program(self, db, ps_program):
|
||||||
|
"""Deactivate an active program."""
|
||||||
|
assert ps_program.is_active is True
|
||||||
|
result = self.service.deactivate_program(db, ps_program.id)
|
||||||
|
assert result.is_active is False
|
||||||
|
|
||||||
|
def test_activate_program(self, db, ps_program):
|
||||||
|
"""Activate an inactive program."""
|
||||||
|
ps_program.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.service.activate_program(db, ps_program.id)
|
||||||
|
assert result.is_active is True
|
||||||
|
|
||||||
|
def test_activate_nonexistent_raises(self, db):
|
||||||
|
"""Activating non-existent program raises exception."""
|
||||||
|
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||||
|
self.service.activate_program(db, 999999)
|
||||||
|
|
||||||
|
def test_deactivate_nonexistent_raises(self, db):
|
||||||
|
"""Deactivating non-existent program raises exception."""
|
||||||
|
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||||
|
self.service.deactivate_program(db, 999999)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Delete Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestDeleteProgram:
|
||||||
|
"""Tests for delete_program."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProgramService()
|
||||||
|
|
||||||
|
def test_delete_program(self, db, ps_program):
|
||||||
|
"""Delete a program removes it from DB."""
|
||||||
|
program_id = ps_program.id
|
||||||
|
self.service.delete_program(db, program_id)
|
||||||
|
|
||||||
|
result = self.service.get_program(db, program_id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_delete_program_also_deletes_settings(self, db, ps_merchant):
|
||||||
|
"""Deleting a program also removes merchant settings."""
|
||||||
|
data = ProgramCreate(loyalty_type="points")
|
||||||
|
program = self.service.create_program(db, ps_merchant.id, data)
|
||||||
|
|
||||||
|
# Verify settings exist
|
||||||
|
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||||
|
assert settings is not None
|
||||||
|
|
||||||
|
self.service.delete_program(db, program.id)
|
||||||
|
|
||||||
|
# Settings should be gone too
|
||||||
|
settings = self.service.get_merchant_settings(db, ps_merchant.id)
|
||||||
|
assert settings is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent_raises(self, db):
|
||||||
|
"""Deleting non-existent program raises exception."""
|
||||||
|
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||||
|
self.service.delete_program(db, 999999)
|
||||||
|
|||||||
Reference in New Issue
Block a user