/** * 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} 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();