feat(loyalty): restructure program CRUD by interface
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 45m49s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

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:
2026-02-25 13:32:20 +01:00
parent d648c921b7
commit 6b46a78e72
22 changed files with 1616 additions and 113 deletions

View File

@@ -23,6 +23,13 @@ def _get_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():
"""Lazy import of store router to avoid circular imports."""
from app.modules.loyalty.routes.api.store import store_router
@@ -119,6 +126,7 @@ loyalty_module = ModuleDefinition(
],
FrontendType.MERCHANT: [
"loyalty-overview", # Merchant loyalty overview
"loyalty-settings", # Merchant loyalty settings
],
},
# New module-driven menu definitions
@@ -192,6 +200,13 @@ loyalty_module = ModuleDefinition(
route="/merchants/loyalty/overview",
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.
"""
loyalty_module.admin_router = _get_admin_router()
loyalty_module.merchant_router = _get_merchant_router()
loyalty_module.store_router = _get_store_router()
loyalty_module.platform_router = _get_platform_router()
loyalty_module.storefront_router = _get_storefront_router()

View File

@@ -77,6 +77,8 @@
"dashboard": "Dashboard",
"terminal": "Terminal",
"customer_cards": "Kundenkarten",
"statistics": "Statistiken"
"statistics": "Statistiken",
"overview": "Übersicht",
"settings": "Einstellungen"
}
}

View File

@@ -77,6 +77,8 @@
"dashboard": "Dashboard",
"terminal": "Terminal",
"customer_cards": "Customer Cards",
"statistics": "Statistics"
"statistics": "Statistics",
"overview": "Overview",
"settings": "Settings"
}
}

View File

@@ -77,6 +77,8 @@
"dashboard": "Tableau de bord",
"terminal": "Terminal",
"customer_cards": "Cartes clients",
"statistics": "Statistiques"
"statistics": "Statistiques",
"overview": "Aperçu",
"settings": "Paramètres"
}
}

View File

@@ -77,6 +77,8 @@
"dashboard": "Dashboard",
"terminal": "Terminal",
"customer_cards": "Clientekaarten",
"statistics": "Statistiken"
"statistics": "Statistiken",
"overview": "Iwwersiicht",
"settings": "Astellungen"
}
}

View File

@@ -20,9 +20,11 @@ from app.modules.loyalty.schemas import (
MerchantSettingsResponse,
MerchantSettingsUpdate,
MerchantStatsResponse,
ProgramCreate,
ProgramListResponse,
ProgramResponse,
ProgramStatsResponse,
ProgramUpdate,
)
from app.modules.loyalty.services import program_service
from app.modules.tenancy.models import User # API-007
@@ -107,6 +109,93 @@ def get_program_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
# =============================================================================

View 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")

View File

@@ -40,10 +40,8 @@ from app.modules.loyalty.schemas import (
PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
ProgramCreate,
ProgramResponse,
ProgramStatsResponse,
ProgramUpdate,
StampRedeemRequest,
StampRedeemResponse,
StampRequest,
@@ -106,46 +104,6 @@ def get_program(
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)
def get_stats(
current_user: User = Depends(get_current_store_api),

View File

@@ -98,3 +98,32 @@ async def merchant_loyalty_overview(
"loyalty/merchant/overview.html",
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,
)

View File

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

View File

@@ -36,6 +36,7 @@ function adminLoyaltyMerchantDetail() {
// State
loading: false,
error: null,
showDeleteModal: false,
// Initialize
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
formatDate(dateString) {
if (!dateString) return 'N/A';

View File

@@ -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
formatDate(dateString) {
if (!dateString) return 'N/A';

View File

@@ -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
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
function storeLoyaltySettings() {
function merchantLoyaltySettings() {
return {
...data(),
currentPage: 'loyalty-settings',
@@ -22,22 +22,18 @@ function storeLoyaltySettings() {
loading: false,
saving: false,
deleting: false,
error: null,
isNewProgram: false,
showDeleteModal: false,
async init() {
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZING ===');
if (window._loyaltySettingsInitialized) return;
window._loyaltySettingsInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ===');
if (window._merchantLoyaltySettingsInitialized) return;
window._merchantLoyaltySettingsInitialized = true;
await this.loadSettings();
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
},
async loadSettings() {
@@ -45,7 +41,7 @@ function storeLoyaltySettings() {
this.error = null;
try {
const response = await apiClient.get('/store/loyalty/program');
const response = await apiClient.get('/merchants/loyalty/program');
if (response) {
this.settings = {
loyalty_type: response.loyalty_type || 'points',
@@ -86,10 +82,10 @@ function storeLoyaltySettings() {
let response;
if (this.isNewProgram) {
response = await apiClient.post('/store/loyalty/program', this.settings);
response = await apiClient.post('/merchants/loyalty/program', this.settings);
this.isNewProgram = false;
} 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');
@@ -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() {
this.settings.points_rewards.push({
id: `reward_${Date.now()}`,
@@ -121,4 +139,4 @@ function storeLoyaltySettings() {
if (!window.LogConfig.loggers.loyaltySettings) {
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
}
loyaltySettingsLog.info('Loyalty settings module loaded');
loyaltySettingsLog.info('Merchant loyalty settings module loaded');

View File

@@ -105,10 +105,27 @@
<!-- Program Configuration -->
<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">
<span x-html="$icon('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<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>
Program Configuration
</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>
<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 -->
<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">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<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>
<div class="flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<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.</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>

View File

@@ -217,14 +217,24 @@
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Settings Button -->
<!-- Edit Button -->
<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"
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>
<!-- 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>
</td>
</tr>

View File

@@ -7,9 +7,18 @@
<div x-data="merchantLoyaltyOverview()">
<!-- Page Header -->
<div class="mb-8 mt-6">
<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>
<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>
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
</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 -->
@@ -18,8 +27,13 @@
<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>
<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>
<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>
</template>

View File

@@ -1,11 +1,11 @@
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
{% extends "store/base.html" %}
{# app/modules/loyalty/templates/loyalty/merchant/settings.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}storeLoyaltySettings(){% endblock %}
{% block alpine_data %}merchantLoyaltySettings(){% endblock %}
{% block content %}
{% 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>
<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">
<p class="mt-1 text-xs text-gray-500">1 EUR = <span x-text="settings.points_per_euro || 1"></span> point(s)</p>
</div>
@@ -142,17 +142,48 @@
</div>
<!-- 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"
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-text="saving ? 'Saving...' : 'Save Settings'"></span>
<span x-text="saving ? 'Saving...' : (isNewProgram ? 'Create Program' : 'Save Settings')"></span>
</button>
</div>
</form>
</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 %}
{% 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 %}

View File

@@ -9,11 +9,14 @@ from datetime import UTC, datetime
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.loyalty_program import LoyaltyType
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store import StoreUser
from app.modules.tenancy.models.store_platform import StorePlatform
from main import app
from models.schema.auth import UserContext
@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
def loyalty_store_headers(client, loyalty_store_setup):
"""

View 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

View 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

View File

@@ -2,11 +2,12 @@
"""
Integration tests for store loyalty API endpoints.
Tests the endpoints fixed today:
Tests:
- GET /cards/lookup (route ordering fix)
- GET /cards/{card_id} (card detail)
- GET /transactions (customer_name in response)
- POST /points/earn (endpoint path rename)
- Store program CRUD endpoints removed (POST/PATCH /program)
Authentication: Uses real JWT tokens via store login endpoint.
"""
@@ -213,3 +214,59 @@ class TestEarnPoints:
)
# Should not be 404 (endpoint exists after rename)
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

View File

@@ -1,8 +1,18 @@
"""Unit tests for ProgramService."""
import uuid
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.tenancy.models import Merchant, User
@pytest.mark.unit
@@ -16,3 +26,329 @@ class TestProgramService:
def test_service_instantiation(self):
"""Service can be instantiated."""
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)