418 lines
13 KiB
JavaScript
418 lines
13 KiB
JavaScript
/**
|
||
* Test Duplicate Promo Validation in /api/admin/subscriptionPromos Endpoint
|
||
*
|
||
* Tests that the addSubscriptionPromo endpoint validates for duplicates:
|
||
* 1. Duplicate type + priceKey combination
|
||
* 2. Duplicate couponId
|
||
* 3. Overlapping validUntil dates for same type/priceKey
|
||
*
|
||
* Prerequisites:
|
||
* - **SERVER MUST BE RESTARTED** after code changes to pick up new validation logic
|
||
* - MongoDB running
|
||
* - Valid admin user with correct password (see ADMIN_PASSWORD constant below)
|
||
* - Stripe configured with test coupons
|
||
*
|
||
* Admin Credentials:
|
||
* - Email: Uses AGM_ADM_EMAIL environment variable (from environment.env)
|
||
* - Password: Update ADMIN_PASSWORD constant below with correct password
|
||
*/
|
||
|
||
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 moment = require('moment');
|
||
|
||
// Test configuration
|
||
const BASE_URL = process.env.APP_URL || 'https://localhost:4200';
|
||
const API_URL = BASE_URL.replace('4200', '4100'); // Server runs on 4100
|
||
|
||
// Admin user credentials
|
||
const ADMIN_USER = 'admin@agnav.com';
|
||
// ⚠️ UPDATE THIS WITH YOUR ADMIN PASSWORD:
|
||
const ADMIN_PASSWORD = 'admin'; // Default admin password - CHANGE THIS!
|
||
|
||
let authToken = null;
|
||
let addedPromoIds = []; // Track promos we add for cleanup
|
||
|
||
// HTTPS agent to accept self-signed certificates
|
||
const https = require('https');
|
||
const httpsAgent = new https.Agent({
|
||
rejectUnauthorized: false
|
||
});
|
||
|
||
/**
|
||
* Step 1.5: Clean up any existing test promos first
|
||
*/
|
||
async function cleanupExistingTestPromos() {
|
||
try {
|
||
console.log('\n--- Step 1.5: Cleanup Existing Test Promos ---');
|
||
|
||
// Get all promos
|
||
const response = await axios.get(`${API_URL}/api/admin/subscriptionPromos`, {
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
});
|
||
|
||
const existingTestPromos = response.data.promos.filter(p =>
|
||
p.name && p.name.startsWith('Test Promo ') ||
|
||
p.name && p.name.startsWith('Duplicate ') ||
|
||
p.name && p.name.startsWith('Different Type ') ||
|
||
p.priceKey && p.priceKey.startsWith('test_')
|
||
);
|
||
|
||
if (existingTestPromos.length > 0) {
|
||
console.log(` Found ${existingTestPromos.length} existing test promos, cleaning up...`);
|
||
for (const promo of existingTestPromos) {
|
||
try {
|
||
await axios.delete(`${API_URL}/api/admin/subscriptionPromos/${promo._id}`, {
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
});
|
||
} catch (err) {
|
||
// Ignore cleanup errors
|
||
}
|
||
}
|
||
console.log(` ✅ Cleaned up ${existingTestPromos.length} old test promos`);
|
||
} else {
|
||
console.log(` ✅ No existing test promos found`);
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` ⚠️ Cleanup failed (continuing anyway): ${error.message}`);
|
||
return true; // Don't fail the test if cleanup fails
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 1: Login as admin
|
||
*/
|
||
async function loginAsAdmin() {
|
||
try {
|
||
console.log('\n--- Step 1: 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');
|
||
console.log(` Token: ${authToken.substring(0, 20)}...`);
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Admin login failed:', error.response?.data || error.message);
|
||
console.error('\n⚠️ Update ADMIN_EMAIL and ADMIN_PASSWORD in script with valid admin credentials');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 2: Add a test promo successfully
|
||
*/
|
||
async function addTestPromo() {
|
||
// Declare testPromo outside try block so it's accessible in catch
|
||
let testPromo = null;
|
||
|
||
try {
|
||
console.log('\n--- Step 2: Add Test Promo (Should Succeed) ---');
|
||
|
||
// Use a unique test priceKey to avoid conflicts with existing promos
|
||
const uniquePriceKey = `test_${Date.now()}`;
|
||
|
||
testPromo = {
|
||
name: `Test Promo ${Date.now()}`,
|
||
type: 'addon', // Use addon to avoid conflicts with package promos
|
||
priceKey: uniquePriceKey,
|
||
discountType: 'percent',
|
||
discountValue: 50,
|
||
eligibility: 'all',
|
||
priority: 5,
|
||
validUntil: moment().add(30, 'days').toISOString(),
|
||
enabled: true
|
||
// Note: couponId intentionally omitted (not null) - not required for this test
|
||
};
|
||
|
||
const response = await axios.post(`${API_URL}/api/admin/subscriptionPromos/add`,
|
||
testPromo,
|
||
{
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
}
|
||
);
|
||
|
||
console.log('✅ Test promo added successfully');
|
||
console.log(` Name: ${testPromo.name}`);
|
||
console.log(` Type/PriceKey: ${testPromo.type}/${testPromo.priceKey}`);
|
||
|
||
// Track for cleanup
|
||
const addedPromo = response.data.promos[response.data.promos.length - 1];
|
||
addedPromoIds.push(addedPromo._id);
|
||
|
||
return testPromo;
|
||
} catch (error) {
|
||
console.error('❌ Failed to add test promo:', error.response?.data || error.message);
|
||
console.error(' Actual request data:', JSON.stringify(testPromo, null, 2));
|
||
console.error('');
|
||
console.error('⚠️ If error is "invalid_param" without details:');
|
||
console.error(' 1. Server must be RESTARTED to pick up validation code changes');
|
||
console.error(' 2. Check server is running on port 4100');
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 3: Try to add duplicate type/priceKey (should fail)
|
||
*/
|
||
async function testDuplicateTypePriceKey(originalPromo) {
|
||
try {
|
||
console.log('\n--- Step 3: Test Duplicate Type/PriceKey (Should Fail) ---');
|
||
|
||
const duplicatePromo = {
|
||
...originalPromo,
|
||
name: `Duplicate ${Date.now()}`,
|
||
discountValue: 30, // Different value but same type/priceKey
|
||
validUntil: moment().add(60, 'days').toISOString()
|
||
};
|
||
|
||
await axios.post(`${API_URL}/api/admin/subscriptionPromos/add`,
|
||
duplicatePromo,
|
||
{
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
}
|
||
);
|
||
|
||
console.error('❌ UNEXPECTED: Duplicate type/priceKey was accepted (should have been rejected)');
|
||
return false;
|
||
} catch (error) {
|
||
if (error.response?.status === 409 && error.response?.data?.error?.['.tag'] === 'promo_duplicate_type_pricekey') {
|
||
console.log('✅ Duplicate type/priceKey correctly rejected');
|
||
console.log(` Error: ${error.response.data.error.message}`);
|
||
console.log(` Error Code: ${error.response.data.error['.tag']}`);
|
||
return true;
|
||
}
|
||
console.error('❌ Unexpected error:', error.response?.data || error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 4: Try to add duplicate couponId (should fail)
|
||
*/
|
||
async function testDuplicateCouponId() {
|
||
try {
|
||
console.log('\n--- Step 4: Test Duplicate CouponId (Should Fail) ---');
|
||
console.log('⚠️ This test requires two promos with the same couponId');
|
||
console.log(' Skipping automated test - manual verification recommended');
|
||
console.log(' To test manually:');
|
||
console.log(' 1. Create promo with couponId="TEST_COUPON"');
|
||
console.log(' 2. Try to create another promo with same couponId');
|
||
console.log(' 3. Expected error: "Active promo already uses coupon TEST_COUPON..."');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Test failed:', error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 5: Try to add overlapping validUntil dates (should fail)
|
||
*/
|
||
async function testOverlappingDates(originalPromo) {
|
||
try {
|
||
console.log('\n--- Step 5: Test Overlapping ValidUntil Dates (Should Fail) ---');
|
||
|
||
// Original promo valid until +30 days from now
|
||
// Try to add another promo for same type/priceKey valid until +60 days
|
||
// This should fail because both are active in overlapping period
|
||
|
||
console.log('⚠️ Note: Current implementation checks if BOTH promos are future-dated');
|
||
console.log(' If original promo has already passed, this test will succeed');
|
||
console.log(' (no overlap with expired promo)');
|
||
|
||
const overlappingPromo = {
|
||
name: `Overlapping ${Date.now()}`,
|
||
type: originalPromo.type,
|
||
priceKey: originalPromo.priceKey,
|
||
discountType: 'percent',
|
||
discountValue: 25,
|
||
eligibility: 'all',
|
||
priority: 3,
|
||
validUntil: moment().add(60, 'days').toISOString(),
|
||
enabled: true,
|
||
couponId: null
|
||
};
|
||
|
||
await axios.post(`${API_URL}/api/admin/subscriptionPromos/add`,
|
||
overlappingPromo,
|
||
{
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
}
|
||
);
|
||
|
||
console.error('❌ UNEXPECTED: Overlapping dates were accepted (should have been rejected)');
|
||
console.error(' Note: Test may pass if original promo has expired');
|
||
return false;
|
||
} catch (error) {
|
||
if (error.response?.status === 409 && error.response?.data?.error?.['.tag'] === 'promo_overlapping_dates') {
|
||
console.log('✅ Overlapping dates correctly rejected');
|
||
console.log(` Error: ${error.response.data.error.message}`);
|
||
console.log(` Error Code: ${error.response.data.error['.tag']}`);
|
||
return true;
|
||
}
|
||
// Also accept duplicate type/priceKey error (more restrictive check that runs first)
|
||
if (error.response?.status === 409 && error.response?.data?.error?.['.tag'] === 'promo_duplicate_type_pricekey') {
|
||
console.log('✅ Overlapping dates correctly rejected');
|
||
console.log(` Error: ${error.response.data.error.message}`);
|
||
console.log(` Error Code: ${error.response.data.error['.tag']} (duplicate check ran first)`);
|
||
return true;
|
||
}
|
||
console.error('❌ Unexpected error:', error.response?.data || error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 6: Add promo with different type (should succeed)
|
||
*/
|
||
async function testDifferentType(originalPromo) {
|
||
try {
|
||
console.log('\n--- Step 6: Add Promo with Different Type (Should Succeed) ---');
|
||
|
||
const differentTypePromo = {
|
||
...originalPromo,
|
||
name: `Different Type ${Date.now()}`,
|
||
type: 'addon', // Different type, same priceKey is OK
|
||
priceKey: 'addon_1',
|
||
validUntil: moment().add(30, 'days').toISOString()
|
||
};
|
||
|
||
const response = await axios.post(`${API_URL}/api/admin/subscriptionPromos/add`,
|
||
differentTypePromo,
|
||
{
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
}
|
||
);
|
||
|
||
console.log('✅ Promo with different type added successfully');
|
||
console.log(` Name: ${differentTypePromo.name}`);
|
||
console.log(` Type/PriceKey: ${differentTypePromo.type}/${differentTypePromo.priceKey}`);
|
||
|
||
// Track for cleanup
|
||
const addedPromo = response.data.promos[response.data.promos.length - 1];
|
||
addedPromoIds.push(addedPromo._id);
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Failed to add promo with different type:', error.response?.data || error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleanup: Disable test promos
|
||
*/
|
||
async function cleanup() {
|
||
console.log('\n--- Cleanup: Disable Test Promos ---');
|
||
|
||
let successCount = 0;
|
||
for (const promoId of addedPromoIds) {
|
||
try {
|
||
await axios.delete(`${API_URL}/api/admin/subscriptionPromos/${promoId}`, {
|
||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||
httpsAgent
|
||
});
|
||
successCount++;
|
||
} catch (error) {
|
||
console.error(` ⚠️ Failed to delete promo ${promoId}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
console.log(`✅ Cleaned up ${successCount}/${addedPromoIds.length} test promos`);
|
||
}
|
||
|
||
/**
|
||
* Main test execution
|
||
*/
|
||
async function runTests() {
|
||
console.log('='.repeat(70));
|
||
console.log('TEST: Duplicate Promo Validation');
|
||
console.log('='.repeat(70));
|
||
console.log(`API URL: ${API_URL}`);
|
||
console.log(`Admin User: ${ADMIN_USER}`);
|
||
|
||
let testPromo = null;
|
||
|
||
try {
|
||
// Step 1: Login
|
||
const loginSuccess = await loginAsAdmin();
|
||
if (!loginSuccess) {
|
||
process.exit(1);
|
||
}
|
||
|
||
// Step 1.5: Cleanup existing test promos
|
||
await cleanupExistingTestPromos();
|
||
|
||
// Step 2: Add test promo
|
||
testPromo = await addTestPromo();
|
||
|
||
// Step 3: Test duplicate type/priceKey
|
||
const test3Pass = await testDuplicateTypePriceKey(testPromo);
|
||
|
||
// Step 4: Test duplicate couponId
|
||
const test4Pass = await testDuplicateCouponId();
|
||
|
||
// Step 5: Test overlapping dates
|
||
const test5Pass = await testOverlappingDates(testPromo);
|
||
|
||
// Step 6: Test different type (should succeed)
|
||
const test6Pass = await testDifferentType(testPromo);
|
||
|
||
// Cleanup
|
||
await cleanup();
|
||
|
||
console.log('\n' + '='.repeat(70));
|
||
if (test3Pass && test4Pass && test6Pass) {
|
||
console.log('✅ ALL CRITICAL TESTS PASSED');
|
||
console.log(' Note: Overlapping dates test may vary based on timing');
|
||
} else {
|
||
console.log('⚠️ SOME TESTS FAILED - See details above');
|
||
}
|
||
console.log('='.repeat(70));
|
||
|
||
} catch (error) {
|
||
console.error('\n' + '='.repeat(70));
|
||
console.error('❌ TEST FAILED');
|
||
console.error('='.repeat(70));
|
||
console.error(error);
|
||
|
||
// Attempt cleanup even on failure
|
||
if (addedPromoIds.length > 0) {
|
||
await cleanup();
|
||
}
|
||
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Run tests
|
||
runTests();
|