513 lines
15 KiB
JavaScript
513 lines
15 KiB
JavaScript
/**
|
||
* Test Promo Priority Selection Logic
|
||
*
|
||
* Tests that findMatchingPromo correctly selects promos based on:
|
||
* 1. Match level (exact > type-only > catchall)
|
||
* 2. Priority (higher number = higher priority)
|
||
* 3. Eligibility filtering (new_only, renew_only, all)
|
||
* 4. Duration types (validUntil, durationInMonths)
|
||
*
|
||
* Prerequisites:
|
||
* - MongoDB running
|
||
* - Server running on port 4100
|
||
* - Admin user credentials
|
||
*/
|
||
|
||
const path = require('path');
|
||
|
||
// Parse --env argument (default: ./environment.env)
|
||
const args = process.argv.slice(2);
|
||
let envFile = './environment.env';
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--env' && args[i + 1]) {
|
||
envFile = args[i + 1];
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// Load environment before requiring any modules
|
||
const envPath = path.resolve(process.cwd(), envFile);
|
||
require('dotenv').config({ path: envPath });
|
||
|
||
const axios = require('axios');
|
||
const assert = require('assert');
|
||
const { httpsAgent, sleep, requestWithRetry } = require('./test-helpers');
|
||
|
||
// Test configuration
|
||
const BASE_URL = process.env.APP_URL || 'https://localhost:4200';
|
||
const API_URL = BASE_URL.replace('4200', '4100');
|
||
|
||
// Admin credentials for promo management
|
||
const ADMIN_USER = 'admin@agnav.com';
|
||
const ADMIN_PASSWORD = 'admin'; // Update with actual password
|
||
|
||
// Use existing Stripe coupon (must exist in Stripe account)
|
||
const EXISTING_COUPON = '50OFF'; // Update with a valid coupon ID from your Stripe account
|
||
|
||
// Use timestamp for unique promo names to avoid conflicts
|
||
const TEST_RUN_ID = Date.now();
|
||
const createdPromoIds = []; // Track promos we create for cleanup
|
||
|
||
let authToken = null;
|
||
|
||
/**
|
||
* Login as admin
|
||
*/
|
||
async function loginAsAdmin() {
|
||
try {
|
||
console.log('\n--- Login as Admin ---');
|
||
const response = await axios.post(`${API_URL}/api/users/login`, {
|
||
username: ADMIN_USER,
|
||
password: ADMIN_PASSWORD
|
||
}, { httpsAgent });
|
||
|
||
authToken = response.data.token;
|
||
console.log('✅ Admin login successful');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Admin login failed:', error.response?.data || error.message);
|
||
console.error('\n⚠️ Update ADMIN_PASSWORD in script with valid admin credentials');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleanup only the promos we created in this test run
|
||
*/
|
||
async function cleanupCreatedPromos() {
|
||
if (createdPromoIds.length === 0) {
|
||
console.log(' No promos to clean up');
|
||
return;
|
||
}
|
||
|
||
console.log(` Cleaning up ${createdPromoIds.length} promos created in this run`);
|
||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||
|
||
for (const promoId of createdPromoIds) {
|
||
try {
|
||
await requestWithRetry('put', `${API_URL}/api/admin/subscriptionPromos/${promoId}`,
|
||
{ validUntil: yesterday, enabled: false },
|
||
{ headers: { 'Authorization': `Bearer ${authToken}` }, httpsAgent }
|
||
);
|
||
console.log(` ✓ Disabled promo ${promoId}`);
|
||
} catch (error) {
|
||
console.log(` ⚠ Could not disable ${promoId}: ${error.message}`);
|
||
}
|
||
await sleep(100);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a test promo
|
||
* @param {object} promoData - Promo data
|
||
* @returns {Promise<object>} Created promo
|
||
*/
|
||
async function createPromo(promoData) {
|
||
try {
|
||
// Add unique run ID to name to avoid conflicts
|
||
const uniqueData = {
|
||
...promoData,
|
||
name: `${promoData.name}_${TEST_RUN_ID}`
|
||
};
|
||
|
||
const response = await requestWithRetry('post', `${API_URL}/api/admin/subscriptionPromos/add`,
|
||
uniqueData,
|
||
{ headers: { 'Authorization': `Bearer ${authToken}` }, httpsAgent }
|
||
);
|
||
|
||
// Small pause between creations (100ms for safer margin)
|
||
await sleep(100 + Math.random() * 50);
|
||
|
||
// Response is { promos: [...], currentMode: {...} }
|
||
// Find the newly created promo and track its ID
|
||
const createdPromo = response.data.promos.find(p => p.name === uniqueData.name);
|
||
if (createdPromo) {
|
||
createdPromoIds.push(createdPromo._id);
|
||
}
|
||
return createdPromo;
|
||
} catch (error) {
|
||
console.error(`❌ Failed to create promo "${promoData.name}":`, error.response?.data || error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Test invoice preview to see which promo is selected
|
||
*/
|
||
async function testInvoicePreview(targetPackage = 'ess_2') {
|
||
try {
|
||
// Get customer's Stripe customer ID
|
||
const custResponse = await axios.post(`${API_URL}/api/users/login`,
|
||
{
|
||
username: 'trungduyhoang@gmail.com',
|
||
password: 'secret'
|
||
},
|
||
{ httpsAgent }
|
||
);
|
||
const customerId = custResponse.data.membership?.custId;
|
||
if (!customerId) {
|
||
throw new Error('Customer does not have Stripe customer ID');
|
||
}
|
||
|
||
console.log(` DEBUG: Calling invoice preview with custId=${customerId}, package=${targetPackage}`);
|
||
|
||
const response = await axios.post(`${API_URL}/api/subscription/retrieveNextInvoices`,
|
||
{
|
||
custId: customerId,
|
||
package: targetPackage
|
||
},
|
||
{
|
||
headers: { 'Authorization': `Bearer ${custResponse.data.token}` },
|
||
httpsAgent
|
||
}
|
||
);
|
||
|
||
// Extract applied discount from invoice (response is array of invoices)
|
||
const invoice = response.data?.[0];
|
||
const discount = invoice?.discount;
|
||
if (discount) {
|
||
return {
|
||
applied: true,
|
||
couponId: discount.coupon?.id,
|
||
amount: discount.coupon?.amount_off || discount.coupon?.percent_off,
|
||
type: discount.coupon?.amount_off ? 'amount' : 'percent'
|
||
};
|
||
}
|
||
|
||
return { applied: false };
|
||
} catch (error) {
|
||
console.error(`❌ Invoice preview failed:`, error.response?.data || error.message);
|
||
if (error.response?.data) {
|
||
console.error(' Error details:', JSON.stringify(error.response.data, null, 2));
|
||
}
|
||
return { applied: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Test Case 1: Exact match wins over catchall (same priority)
|
||
*/
|
||
async function testCase1_ExactMatchWins() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 1: Exact Match Wins Over Catchall');
|
||
console.log('='.repeat(70));
|
||
|
||
// Create catchall promo (priority 0)
|
||
const catchall = await createPromo({
|
||
name: 'TEST Catchall',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 0,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
console.log(` Created: ${catchall.name}`);
|
||
|
||
// Create exact match promo (same priority, different name to avoid duplicate, use ess_2 for upgrade)
|
||
const exact = await createPromo({
|
||
name: 'TEST Exact Match ESS2',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 0,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
console.log(` Created: ${exact.name}`);
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log(`Expected: ${EXISTING_COUPON} (exact match over catchall)`);
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should apply a promo');
|
||
console.log('✅ PASSED: Exact match selected correctly');
|
||
}
|
||
|
||
/**
|
||
* Test Case 2: Higher priority wins within same match level
|
||
*/
|
||
async function testCase2_HigherPriorityWins() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 2: Higher Priority Wins');
|
||
console.log('='.repeat(70));
|
||
|
||
// Clean up
|
||
|
||
// Create low priority exact match
|
||
await createPromo({
|
||
name: 'Low Priority 15% Off',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 1,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
|
||
// Create high priority exact match
|
||
await createPromo({
|
||
name: 'High Priority 25% Off',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 10,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log('Expected: Promo applied (priority 10 wins)');
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should apply higher priority promo');
|
||
console.log('✅ PASSED: Higher priority selected correctly');
|
||
}
|
||
|
||
/**
|
||
* Test Case 3: Repeating coupon (durationInMonths) is active
|
||
*/
|
||
async function testCase3_RepeatingCouponActive() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 3: Repeating Coupon (durationInMonths) Active');
|
||
console.log('='.repeat(70));
|
||
|
||
// Clean up
|
||
|
||
// Create repeating coupon promo (no validUntil, only durationInMonths)
|
||
await createPromo({
|
||
name: 'First Year Discount',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 5,
|
||
eligibility: 'all',
|
||
durationInMonths: 12
|
||
// Note: NO validUntil
|
||
});
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log('Expected: Promo applied');
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should select promo without validUntil');
|
||
console.log('✅ PASSED: Repeating coupon active without validUntil');
|
||
}
|
||
|
||
/**
|
||
* Test Case 4: Exact match high priority beats catchall low priority
|
||
*/
|
||
async function testCase4_ExactMatchHighPriorityWins() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 4: Exact Match (High Priority) Beats Catchall (Low Priority)');
|
||
console.log('='.repeat(70));
|
||
|
||
// Clean up
|
||
|
||
// Create catchall with low priority
|
||
await createPromo({
|
||
name: 'Catchall 50% Off',
|
||
couponId: '50OFF',
|
||
priority: 0,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
|
||
// Create exact match with high priority and durationInMonths
|
||
await createPromo({
|
||
name: 'Package First Year',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 5,
|
||
eligibility: 'all',
|
||
durationInMonths: 12
|
||
});
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log('Expected: FIRSTYEAR (exact match, priority 5)');
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should select exact match with higher priority over catchall');
|
||
console.log('✅ PASSED: Exact match with high priority wins');
|
||
}
|
||
|
||
/**
|
||
* Test Case 5: Type-only match beats catchall
|
||
*/
|
||
async function testCase5_TypeOnlyMatchWins() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 5: Type-Only Match Beats Catchall');
|
||
console.log('='.repeat(70));
|
||
|
||
// Clean up
|
||
|
||
// Create catchall
|
||
await createPromo({
|
||
name: 'Catchall 5% Off',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 0,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
|
||
// Create type-only match (package, no priceKey)
|
||
await createPromo({
|
||
name: 'All Packages 15% Off',
|
||
type: 'package',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 0,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log('Expected: Promo applied');
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should select type-only match over catchall');
|
||
console.log('✅ PASSED: Type-only match wins over catchall');
|
||
}
|
||
|
||
/**
|
||
* Test Case 6: Expired promo (past validUntil) is not selected
|
||
*/
|
||
async function testCase6_ExpiredPromoIgnored() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 6: Expired Promo Not Selected');
|
||
console.log('='.repeat(70));
|
||
|
||
// Clean up
|
||
|
||
// Create expired promo
|
||
await createPromo({
|
||
name: 'Expired 90% Off',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 100,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString() // Yesterday
|
||
});
|
||
|
||
// Create valid promo
|
||
await createPromo({
|
||
name: 'Valid 10% Off',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 1,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||
});
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log('Expected: Promo applied');
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should ignore expired promo');
|
||
console.log('✅ PASSED: Expired promo correctly ignored');
|
||
}
|
||
|
||
/**
|
||
* Test Case 7: Mix of validUntil and durationInMonths promos
|
||
*/
|
||
async function testCase7_MixedDurationTypes() {
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('TEST CASE 7: Mix of validUntil and durationInMonths');
|
||
console.log('='.repeat(70));
|
||
|
||
// Clean up
|
||
|
||
// Create time-limited promo (validUntil, lower priority)
|
||
await createPromo({
|
||
name: 'Limited Time 20% Off',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 3,
|
||
eligibility: 'all',
|
||
validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days
|
||
});
|
||
|
||
// Create duration-based promo (durationInMonths, higher priority)
|
||
await createPromo({
|
||
name: 'First 6 Months Off',
|
||
type: 'package',
|
||
priceKey: 'ess_2',
|
||
couponId: EXISTING_COUPON,
|
||
priority: 8,
|
||
eligibility: 'all',
|
||
durationInMonths: 6
|
||
});
|
||
|
||
const result = await testInvoicePreview('ess_2');
|
||
|
||
console.log('Expected: Promo applied');
|
||
console.log(`Actual: ${result.couponId}`);
|
||
assert.strictEqual(result.applied, true, 'Should select higher priority repeating coupon');
|
||
console.log('✅ PASSED: Higher priority durationInMonths promo selected');
|
||
}
|
||
|
||
/**
|
||
* Clean up test promos only (don't restore 100+ backups to avoid rate limits)
|
||
*/
|
||
/**
|
||
* Clean up test promos created during test execution
|
||
* Optimized with batching to respect Stripe's 25 ops/sec limit
|
||
*/
|
||
|
||
/**
|
||
* Main test execution
|
||
*/
|
||
async function runTests() {
|
||
console.log('='.repeat(70));
|
||
console.log('PROMO PRIORITY SELECTION TEST SUITE');
|
||
console.log('='.repeat(70));
|
||
console.log(`API URL: ${API_URL}`);
|
||
console.log(`Admin: ${ADMIN_USER}`);
|
||
|
||
try {
|
||
// Login
|
||
const loginSuccess = await loginAsAdmin();
|
||
if (!loginSuccess) {
|
||
process.exit(1);
|
||
}
|
||
|
||
// Note: We don't backup/restore to avoid rate limits (100+ promos = 200+ API calls)
|
||
// Instead, we just clean up test promos at the end
|
||
|
||
// Run test cases
|
||
await testCase1_ExactMatchWins();
|
||
await testCase2_HigherPriorityWins();
|
||
await testCase3_RepeatingCouponActive();
|
||
await testCase4_ExactMatchHighPriorityWins();
|
||
await testCase5_TypeOnlyMatchWins();
|
||
await testCase6_ExpiredPromoIgnored();
|
||
await testCase7_MixedDurationTypes();
|
||
|
||
// Clean up test promos
|
||
await cleanupCreatedPromos();
|
||
|
||
console.log('\n' + '='.repeat(70));
|
||
console.log('✅ ALL TESTS PASSED (7/7)');
|
||
console.log('='.repeat(70));
|
||
|
||
} catch (error) {
|
||
console.error('\n' + '='.repeat(70));
|
||
console.error('❌ TEST FAILED');
|
||
console.error('='.repeat(70));
|
||
console.error(error);
|
||
|
||
// Try to clean up test promos even on failure
|
||
try {
|
||
await cleanupCreatedPromos();
|
||
} catch (cleanupError) {
|
||
console.error('⚠️ Failed to clean up test promos:', cleanupError.message);
|
||
}
|
||
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Run tests
|
||
runTests();
|