Admin Dashboard Backend (Phases 1-4) #5
File diff suppressed because it is too large
Load Diff
320
routes/admin.js
320
routes/admin.js
@ -8,7 +8,7 @@ const {
|
||||
updateUserRole,
|
||||
isValidObjectId
|
||||
} = require('../models/user');
|
||||
const { logActivity } = require('../services/activityLogger');
|
||||
const { logActivity, ACTIVITY_COLLECTION } = require('../services/activityLogger');
|
||||
|
||||
const router = express.Router({ strict: true });
|
||||
|
||||
@ -204,4 +204,322 @@ router.patch('/users/:id/role', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:id/activity
|
||||
* Returns paginated activity history for a specific user
|
||||
*/
|
||||
router.get('/users/:id/activity', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ObjectId format
|
||||
if (!isValidObjectId(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID format' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await findById(db, id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Build filter for this user
|
||||
const filter = { userId: new ObjectId(id) };
|
||||
|
||||
// Execute queries in parallel
|
||||
const [activities, total] = await Promise.all([
|
||||
collection.find(filter)
|
||||
.sort({ timestamp: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
collection.countDocuments(filter)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
activities,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit) || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user activity:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user activity' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Validate ISO date string
|
||||
*/
|
||||
function isValidDateString(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const date = new Date(dateStr);
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Escape CSV value
|
||||
*/
|
||||
function escapeCSVValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const str = String(value);
|
||||
// If contains comma, newline, or double quote, wrap in quotes and escape quotes
|
||||
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/activity
|
||||
* Returns paginated activity stream with optional filters
|
||||
*/
|
||||
router.get('/activity', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { userId, action, startDate, endDate } = req.query;
|
||||
|
||||
// Parse and validate pagination parameters
|
||||
const page = parseInt(req.query.page);
|
||||
const limit = parseInt(req.query.limit);
|
||||
|
||||
// Validate page
|
||||
if (req.query.page !== undefined && (isNaN(page) || page < 1)) {
|
||||
return res.status(400).json({ error: 'Invalid page parameter. Must be a positive integer.' });
|
||||
}
|
||||
|
||||
// Validate limit
|
||||
if (req.query.limit !== undefined && (isNaN(limit) || limit < 1)) {
|
||||
return res.status(400).json({ error: 'Invalid limit parameter. Must be a positive integer.' });
|
||||
}
|
||||
|
||||
const validatedPage = Math.max(1, page || 1);
|
||||
const validatedLimit = Math.min(200, Math.max(1, limit || 50));
|
||||
const skip = (validatedPage - 1) * validatedLimit;
|
||||
|
||||
// Validate userId if provided
|
||||
if (userId && !isValidObjectId(userId)) {
|
||||
return res.status(400).json({ error: 'Invalid userId format' });
|
||||
}
|
||||
|
||||
// Validate dates if provided
|
||||
if (startDate && !isValidDateString(startDate)) {
|
||||
return res.status(400).json({ error: 'Invalid startDate format. Use ISO date string.' });
|
||||
}
|
||||
if (endDate && !isValidDateString(endDate)) {
|
||||
return res.status(400).json({ error: 'Invalid endDate format. Use ISO date string.' });
|
||||
}
|
||||
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Build match filter
|
||||
const matchFilter = {};
|
||||
if (userId) {
|
||||
matchFilter.userId = new ObjectId(userId);
|
||||
}
|
||||
if (action) {
|
||||
matchFilter.action = action;
|
||||
}
|
||||
if (startDate || endDate) {
|
||||
matchFilter.timestamp = {};
|
||||
if (startDate) {
|
||||
matchFilter.timestamp.$gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
matchFilter.timestamp.$lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Count total matching documents
|
||||
const total = await collection.countDocuments(matchFilter);
|
||||
|
||||
// Aggregate with $lookup to get user info
|
||||
const pipeline = [
|
||||
{ $match: matchFilter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: validatedLimit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo'
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userName: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.name', 0] }, null]
|
||||
},
|
||||
userEmail: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.email', 0] }, null]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
userInfo: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const activities = await collection.aggregate(pipeline).toArray();
|
||||
|
||||
res.json({
|
||||
activities,
|
||||
pagination: {
|
||||
page: validatedPage,
|
||||
limit: validatedLimit,
|
||||
total,
|
||||
pages: Math.ceil(total / validatedLimit) || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/activity/export
|
||||
* Export activity logs as CSV file
|
||||
*/
|
||||
router.get('/activity/export', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { userId, action, startDate, endDate } = req.query;
|
||||
|
||||
// Validate required parameters
|
||||
if (!startDate) {
|
||||
return res.status(400).json({ error: 'startDate is required' });
|
||||
}
|
||||
if (!endDate) {
|
||||
return res.status(400).json({ error: 'endDate is required' });
|
||||
}
|
||||
|
||||
// Validate date formats
|
||||
if (!isValidDateString(startDate)) {
|
||||
return res.status(400).json({ error: 'Invalid startDate format. Use ISO date string.' });
|
||||
}
|
||||
if (!isValidDateString(endDate)) {
|
||||
return res.status(400).json({ error: 'Invalid endDate format. Use ISO date string.' });
|
||||
}
|
||||
|
||||
const parsedStartDate = new Date(startDate);
|
||||
const parsedEndDate = new Date(endDate);
|
||||
|
||||
// Validate startDate is before endDate
|
||||
if (parsedStartDate > parsedEndDate) {
|
||||
return res.status(400).json({ error: 'startDate must be before endDate' });
|
||||
}
|
||||
|
||||
// Validate userId if provided
|
||||
if (userId && !isValidObjectId(userId)) {
|
||||
return res.status(400).json({ error: 'Invalid userId format' });
|
||||
}
|
||||
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
const EXPORT_LIMIT = 10000;
|
||||
|
||||
// Build match filter
|
||||
const matchFilter = {
|
||||
timestamp: {
|
||||
$gte: parsedStartDate,
|
||||
$lte: parsedEndDate
|
||||
}
|
||||
};
|
||||
if (userId) {
|
||||
matchFilter.userId = new ObjectId(userId);
|
||||
}
|
||||
if (action) {
|
||||
matchFilter.action = action;
|
||||
}
|
||||
|
||||
// Get total count for truncation header
|
||||
const totalCount = await collection.countDocuments(matchFilter);
|
||||
|
||||
// Aggregate with $lookup to get user info, limited to EXPORT_LIMIT
|
||||
const pipeline = [
|
||||
{ $match: matchFilter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $limit: EXPORT_LIMIT },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo'
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userName: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.name', 0] }, null]
|
||||
},
|
||||
userEmail: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.email', 0] }, null]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
userInfo: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const activities = await collection.aggregate(pipeline).toArray();
|
||||
|
||||
// Build CSV content
|
||||
const csvHeaders = ['Timestamp', 'User', 'Email', 'Action', 'Details', 'Session ID', 'IP', 'User Agent'];
|
||||
const csvRows = [csvHeaders.join(',')];
|
||||
|
||||
for (const activity of activities) {
|
||||
const row = [
|
||||
escapeCSVValue(activity.timestamp ? activity.timestamp.toISOString() : ''),
|
||||
escapeCSVValue(activity.userName || ''),
|
||||
escapeCSVValue(activity.userEmail || ''),
|
||||
escapeCSVValue(activity.action || ''),
|
||||
escapeCSVValue(activity.metadata ? JSON.stringify(activity.metadata) : ''),
|
||||
escapeCSVValue(activity.sessionId || ''),
|
||||
escapeCSVValue(activity.ip || ''),
|
||||
escapeCSVValue(activity.userAgent || '')
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
|
||||
const csvContent = csvRows.join('\n');
|
||||
|
||||
// Set response headers
|
||||
const filename = `activity-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
// Add truncation headers if limit was exceeded
|
||||
if (totalCount > EXPORT_LIMIT) {
|
||||
res.setHeader('X-Truncated', 'true');
|
||||
res.setHeader('X-Total-Records', totalCount.toString());
|
||||
}
|
||||
|
||||
res.send(csvContent);
|
||||
} catch (error) {
|
||||
console.error('Error exporting activity:', error);
|
||||
res.status(500).json({ error: 'Failed to export activity' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
680
test-endpoints.js
Normal file
680
test-endpoints.js
Normal file
@ -0,0 +1,680 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Comprehensive API Endpoint Test Script
|
||||
* Tests all apartment dashboard endpoints with enhanced redirect handling
|
||||
* Usage: node test-endpoints.js
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
const BASE_URL = 'apartments.maverickapplications.com';
|
||||
const API_PREFIX = '/api';
|
||||
|
||||
// ANSI color codes for console output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
// Test results tracking
|
||||
let testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
details: []
|
||||
};
|
||||
|
||||
// Configuration for different protocols
|
||||
let currentConfig = {
|
||||
protocol: 'http',
|
||||
port: 80,
|
||||
module: http
|
||||
};
|
||||
|
||||
/**
|
||||
* Make HTTP/HTTPS request with redirect handling
|
||||
*/
|
||||
function makeRequest(path, method = 'GET', maxRedirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function attemptRequest(currentPath, redirectCount = 0) {
|
||||
const options = {
|
||||
hostname: BASE_URL,
|
||||
port: currentConfig.port,
|
||||
path: currentPath,
|
||||
method: method,
|
||||
headers: {
|
||||
'User-Agent': 'Apartment-API-Tester/1.0',
|
||||
'Accept': 'application/json',
|
||||
'Host': BASE_URL
|
||||
},
|
||||
timeout: 15000 // 15 second timeout
|
||||
};
|
||||
|
||||
const req = currentConfig.module.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
// Handle redirects
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
if (redirectCount >= maxRedirects) {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
data: null,
|
||||
rawData: `Too many redirects (${redirectCount})`,
|
||||
redirectLocation: res.headers.location
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = res.headers.location;
|
||||
console.log(`${colors.yellow} → Redirect ${res.statusCode} to: ${redirectUrl}${colors.reset}`);
|
||||
|
||||
// Handle relative redirects
|
||||
if (redirectUrl.startsWith('/')) {
|
||||
attemptRequest(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle absolute redirects (change protocol if needed)
|
||||
if (redirectUrl.startsWith('https://') && currentConfig.protocol === 'http') {
|
||||
console.log(`${colors.yellow} → Switching to HTTPS due to redirect${colors.reset}`);
|
||||
currentConfig = { protocol: 'https', port: 443, module: https };
|
||||
const urlPath = redirectUrl.replace(`https://${BASE_URL}`, '');
|
||||
attemptRequest(urlPath, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectUrl.startsWith('http://') && currentConfig.protocol === 'https') {
|
||||
console.log(`${colors.yellow} → Switching to HTTP due to redirect${colors.reset}`);
|
||||
currentConfig = { protocol: 'http', port: 80, module: http };
|
||||
const urlPath = redirectUrl.replace(`http://${BASE_URL}`, '');
|
||||
attemptRequest(urlPath, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other absolute URLs, extract the path
|
||||
try {
|
||||
const url = new URL(redirectUrl);
|
||||
if (url.hostname === BASE_URL) {
|
||||
attemptRequest(url.pathname + url.search, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// If URL parsing fails, treat as relative
|
||||
attemptRequest(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const jsonData = data ? JSON.parse(data) : {};
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
data: jsonData,
|
||||
rawData: data,
|
||||
finalUrl: `${currentConfig.protocol}://${BASE_URL}${currentPath}`
|
||||
});
|
||||
} catch (e) {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
data: null,
|
||||
rawData: data,
|
||||
parseError: e.message,
|
||||
finalUrl: `${currentConfig.protocol}://${BASE_URL}${currentPath}`
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Request timeout after 15s for ${currentConfig.protocol}://${BASE_URL}${currentPath}`));
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(new Error(`${error.message} (${currentConfig.protocol}://${BASE_URL}${currentPath})`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
}
|
||||
|
||||
attemptRequest(path);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection and protocol detection
|
||||
*/
|
||||
async function detectBestProtocol() {
|
||||
console.log(`${colors.magenta}🔍 Detecting best protocol and configuration...${colors.reset}\n`);
|
||||
|
||||
const testConfigs = [
|
||||
{ protocol: 'http', port: 80, module: http, name: 'HTTP (port 80)' },
|
||||
{ protocol: 'https', port: 443, module: https, name: 'HTTPS (port 443)' },
|
||||
{ protocol: 'http', port: 8080, module: http, name: 'HTTP (port 8080)' },
|
||||
{ protocol: 'http', port: 3000, module: http, name: 'HTTP (port 3000)' }
|
||||
];
|
||||
|
||||
for (const config of testConfigs) {
|
||||
currentConfig = config;
|
||||
console.log(`${colors.cyan}Testing ${config.name}...${colors.reset}`);
|
||||
|
||||
try {
|
||||
const response = await makeRequest('/health');
|
||||
console.log(`${colors.green}✅ ${config.name} - Status: ${response.statusCode}${colors.reset}`);
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
console.log(`${colors.green}🎯 Using ${config.name} for all tests${colors.reset}\n`);
|
||||
return true;
|
||||
} else if (response.statusCode >= 300 && response.statusCode < 400) {
|
||||
console.log(`${colors.yellow}⚠️ ${config.name} redirects to: ${response.headers.location || 'unknown'}${colors.reset}`);
|
||||
// Don't return, try other configs first
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${colors.red}❌ ${config.name} - ${error.message}${colors.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If no 200 response found, use the first config that doesn't error
|
||||
console.log(`${colors.yellow}⚠️ No perfect match found, using HTTP as fallback${colors.reset}\n`);
|
||||
currentConfig = testConfigs[0];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a single endpoint with enhanced debugging
|
||||
*/
|
||||
async function testEndpoint(name, path, expectedFields = [], validStatusCodes = [200]) {
|
||||
testResults.total++;
|
||||
console.log(`${colors.cyan}Testing: ${colors.bright}${name}${colors.reset}`);
|
||||
console.log(`${colors.blue} Path: ${currentConfig.protocol}://${BASE_URL}${path}${colors.reset}`);
|
||||
|
||||
try {
|
||||
const response = await makeRequest(path);
|
||||
const { statusCode, data, rawData, parseError, finalUrl, redirectLocation } = response;
|
||||
|
||||
// Show final URL if different from initial
|
||||
if (finalUrl && finalUrl !== `${currentConfig.protocol}://${BASE_URL}${path}`) {
|
||||
console.log(`${colors.yellow} Final URL: ${finalUrl}${colors.reset}`);
|
||||
}
|
||||
|
||||
// Show redirect info for debugging
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
console.log(`${colors.yellow} Redirect Status: ${statusCode}${colors.reset}`);
|
||||
if (redirectLocation) {
|
||||
console.log(`${colors.yellow} Redirect Location: ${redirectLocation}${colors.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if (!validStatusCodes.includes(statusCode)) {
|
||||
// For 301/302 redirects, provide helpful info
|
||||
if (statusCode === 301 || statusCode === 302) {
|
||||
throw new Error(`Redirect ${statusCode} - Server redirecting to: ${redirectLocation || 'unknown'}. This might indicate wrong protocol or path format.`);
|
||||
}
|
||||
throw new Error(`Expected status ${validStatusCodes.join(' or ')}, got ${statusCode}`);
|
||||
}
|
||||
|
||||
// Check if response is valid JSON
|
||||
if (parseError) {
|
||||
console.log(`${colors.yellow} Raw response: ${rawData.substring(0, 200)}...${colors.reset}`);
|
||||
throw new Error(`JSON parse error: ${parseError}`);
|
||||
}
|
||||
|
||||
// Check for expected fields if data is an object
|
||||
if (expectedFields.length > 0 && data && typeof data === 'object') {
|
||||
const missingFields = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// If it's an array, check the first item
|
||||
if (data.length > 0) {
|
||||
expectedFields.forEach(field => {
|
||||
if (!(field in data[0])) {
|
||||
missingFields.push(field);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If it's an object, check directly
|
||||
expectedFields.forEach(field => {
|
||||
if (!(field in data)) {
|
||||
missingFields.push(field);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
console.log(`${colors.yellow} ⚠️ Missing expected fields: ${missingFields.join(', ')}${colors.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
testResults.passed++;
|
||||
console.log(`${colors.green} ✅ PASS${colors.reset}`);
|
||||
console.log(`${colors.green} Status: ${statusCode}${colors.reset}`);
|
||||
console.log(`${colors.green} Protocol: ${currentConfig.protocol.toUpperCase()}${colors.reset}`);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
console.log(`${colors.green} Records: ${data.length}${colors.reset}`);
|
||||
} else if (data && typeof data === 'object') {
|
||||
console.log(`${colors.green} Keys: ${Object.keys(data).length}${colors.reset}`);
|
||||
}
|
||||
|
||||
testResults.details.push({
|
||||
name,
|
||||
path,
|
||||
status: 'PASS',
|
||||
statusCode,
|
||||
protocol: currentConfig.protocol,
|
||||
dataType: Array.isArray(data) ? 'array' : typeof data,
|
||||
recordCount: Array.isArray(data) ? data.length : null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
console.log(`${colors.red} ❌ FAIL${colors.reset}`);
|
||||
console.log(`${colors.red} Error: ${error.message}${colors.reset}`);
|
||||
console.log(`${colors.red} Protocol: ${currentConfig.protocol.toUpperCase()}${colors.reset}`);
|
||||
|
||||
testResults.details.push({
|
||||
name,
|
||||
path,
|
||||
status: 'FAIL',
|
||||
protocol: currentConfig.protocol,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
console.log(''); // Empty line for readability
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with curl-equivalent settings
|
||||
*/
|
||||
async function testWithCurlEquivalent() {
|
||||
console.log(`${colors.yellow}🔄 Testing with curl-equivalent settings...${colors.reset}\n`);
|
||||
|
||||
// Test the exact same way curl would
|
||||
const curlTests = [
|
||||
{ url: 'http://apartments.maverickapplications.com/health', description: 'Direct HTTP health check' },
|
||||
{ url: 'https://apartments.maverickapplications.com/health', description: 'Direct HTTPS health check' },
|
||||
{ url: 'http://apartments.maverickapplications.com/api/daily-summary', description: 'HTTP API endpoint' },
|
||||
{ url: 'https://apartments.maverickapplications.com/api/daily-summary', description: 'HTTPS API endpoint' }
|
||||
];
|
||||
|
||||
for (const test of curlTests) {
|
||||
console.log(`${colors.cyan}Testing: ${test.description}${colors.reset}`);
|
||||
console.log(`${colors.blue}URL: ${test.url}${colors.reset}`);
|
||||
|
||||
try {
|
||||
const isHttps = test.url.startsWith('https');
|
||||
const url = new URL(test.url);
|
||||
|
||||
currentConfig = {
|
||||
protocol: isHttps ? 'https' : 'http',
|
||||
port: isHttps ? 443 : 80,
|
||||
module: isHttps ? https : http
|
||||
};
|
||||
|
||||
const response = await makeRequest(url.pathname + url.search);
|
||||
|
||||
console.log(`${colors.green}✅ Success - Status: ${response.statusCode}${colors.reset}`);
|
||||
if (response.statusCode === 200 && response.data) {
|
||||
console.log(`${colors.green} Data type: ${Array.isArray(response.data) ? 'array' : typeof response.data}${colors.reset}`);
|
||||
if (Array.isArray(response.data)) {
|
||||
console.log(`${colors.green} Records: ${response.data.length}${colors.reset}`);
|
||||
} else if (typeof response.data === 'object') {
|
||||
console.log(`${colors.green} Keys: ${Object.keys(response.data).join(', ')}${colors.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a working endpoint, use this config for remaining tests
|
||||
if (response.statusCode === 200) {
|
||||
console.log(`${colors.green}🎯 Found working configuration: ${currentConfig.protocol.toUpperCase()}${colors.reset}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`${colors.red}❌ Failed: ${error.message}${colors.reset}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all endpoints
|
||||
*/
|
||||
async function runAllTests() {
|
||||
console.log(`${colors.magenta}${colors.bright}🧪 APARTMENT API ENDPOINT TESTS${colors.reset}`);
|
||||
console.log(`${colors.magenta}Base URL: ${BASE_URL}${colors.reset}`);
|
||||
console.log(`${colors.magenta}Testing ${new Date().toISOString()}${colors.reset}\n`);
|
||||
|
||||
// First, detect the best protocol
|
||||
await detectBestProtocol();
|
||||
|
||||
// Test a few different path formats to see which works
|
||||
console.log(`${colors.magenta}🔍 Testing different path formats...${colors.reset}\n`);
|
||||
|
||||
const pathVariations = [
|
||||
'/health',
|
||||
'/api/health',
|
||||
'/',
|
||||
'/api/'
|
||||
];
|
||||
|
||||
for (const testPath of pathVariations) {
|
||||
try {
|
||||
console.log(`${colors.cyan}Testing path: ${testPath}${colors.reset}`);
|
||||
const response = await makeRequest(testPath);
|
||||
console.log(`${colors.green} Status: ${response.statusCode}${colors.reset}`);
|
||||
if (response.statusCode === 200 && response.data) {
|
||||
console.log(`${colors.green} Response preview: ${JSON.stringify(response.data).substring(0, 100)}...${colors.reset}`);
|
||||
}
|
||||
if (response.rawData && response.statusCode !== 200) {
|
||||
console.log(`${colors.yellow} Raw response: ${response.rawData.substring(0, 200)}...${colors.reset}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${colors.red} Error: ${error.message}${colors.reset}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(`${colors.magenta}🚀 Starting full endpoint tests...${colors.reset}\n`);
|
||||
|
||||
// Health check (try both with and without /api prefix)
|
||||
await testEndpoint(
|
||||
'Health Check',
|
||||
'/health',
|
||||
['status', 'timestamp'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
// Try health check with /api prefix if first one failed
|
||||
if (testResults.details[testResults.details.length - 1].status === 'FAIL') {
|
||||
await testEndpoint(
|
||||
'Health Check (with /api)',
|
||||
'/api/health',
|
||||
['status', 'timestamp'],
|
||||
[200, 404]
|
||||
);
|
||||
}
|
||||
|
||||
// Original endpoints
|
||||
await testEndpoint(
|
||||
'Daily Summary',
|
||||
`${API_PREFIX}/daily-summary`,
|
||||
['date'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Price History',
|
||||
`${API_PREFIX}/price-history`,
|
||||
['date'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Available Units',
|
||||
`${API_PREFIX}/available-units`,
|
||||
['unitCode'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Recent Activity',
|
||||
`${API_PREFIX}/recent-activity`,
|
||||
['type'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Plan Statistics',
|
||||
`${API_PREFIX}/plan-stats`,
|
||||
['name'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
// Enhanced endpoints (these might not exist yet)
|
||||
await testEndpoint(
|
||||
'Best Deals',
|
||||
`${API_PREFIX}/best-deals`,
|
||||
['unitCode', 'planName', 'currentPrice'],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Price Drops',
|
||||
`${API_PREFIX}/price-drops`,
|
||||
['unitCode', 'currentPrice'],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Stale Inventory',
|
||||
`${API_PREFIX}/stale-inventory`,
|
||||
['unitCode', 'daysAvailable'],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Market Insights',
|
||||
`${API_PREFIX}/market-insights`,
|
||||
['avgDaysOnMarket'],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Enhanced Available Units',
|
||||
`${API_PREFIX}/available-units-enhanced`,
|
||||
['unitCode', 'amenities'],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
// Test with query parameters
|
||||
await testEndpoint(
|
||||
'Price History (7 days)',
|
||||
`${API_PREFIX}/price-history?days=7`,
|
||||
['date'],
|
||||
[200, 404]
|
||||
);
|
||||
|
||||
await testEndpoint(
|
||||
'Best Deals (limit 3)',
|
||||
`${API_PREFIX}/best-deals?limit=3`,
|
||||
[],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
// Test individual unit endpoint
|
||||
await testEndpoint(
|
||||
'Individual Unit Details',
|
||||
`${API_PREFIX}/unit/A101`,
|
||||
[],
|
||||
[200, 404, 500]
|
||||
);
|
||||
|
||||
// Test edge cases
|
||||
await testEndpoint(
|
||||
'Invalid Endpoint',
|
||||
`${API_PREFIX}/nonexistent`,
|
||||
[],
|
||||
[404, 405] // Should return 404 or 405
|
||||
);
|
||||
|
||||
// Print final results
|
||||
printTestSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Print test summary
|
||||
*/
|
||||
function printTestSummary() {
|
||||
console.log(`${colors.magenta}${colors.bright}📊 TEST SUMMARY${colors.reset}`);
|
||||
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
|
||||
console.log(`${colors.green}✅ Passed: ${testResults.passed}${colors.reset}`);
|
||||
console.log(`${colors.red}❌ Failed: ${testResults.failed}${colors.reset}`);
|
||||
console.log(`${colors.blue}📋 Total: ${testResults.total}${colors.reset}`);
|
||||
console.log(`${colors.yellow}📈 Success Rate: ${((testResults.passed / testResults.total) * 100).toFixed(1)}%${colors.reset}`);
|
||||
console.log('');
|
||||
|
||||
// Detailed results
|
||||
console.log(`${colors.magenta}${colors.bright}📋 DETAILED RESULTS${colors.reset}`);
|
||||
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
|
||||
|
||||
testResults.details.forEach((test, index) => {
|
||||
const status = test.status === 'PASS' ?
|
||||
`${colors.green}✅ PASS${colors.reset}` :
|
||||
`${colors.red}❌ FAIL${colors.reset}`;
|
||||
|
||||
console.log(`${index + 1}. ${test.name}`);
|
||||
console.log(` ${status} - ${test.path}`);
|
||||
|
||||
if (test.status === 'PASS') {
|
||||
if (test.recordCount !== null) {
|
||||
console.log(` 📊 ${test.recordCount} records returned`);
|
||||
}
|
||||
} else {
|
||||
console.log(` 💥 ${test.error}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// Recommendations
|
||||
console.log(`${colors.magenta}${colors.bright}💡 RECOMMENDATIONS${colors.reset}`);
|
||||
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
|
||||
|
||||
const failedTests = testResults.details.filter(t => t.status === 'FAIL');
|
||||
const enhancedEndpoints = failedTests.filter(t =>
|
||||
t.path.includes('best-deals') ||
|
||||
t.path.includes('price-drops') ||
|
||||
t.path.includes('stale-inventory') ||
|
||||
t.path.includes('market-insights') ||
|
||||
t.path.includes('available-units-enhanced')
|
||||
);
|
||||
|
||||
if (enhancedEndpoints.length > 0) {
|
||||
console.log(`${colors.yellow}⚠️ Enhanced endpoints not implemented yet:${colors.reset}`);
|
||||
enhancedEndpoints.forEach(test => {
|
||||
console.log(` - ${test.path}`);
|
||||
});
|
||||
console.log(`${colors.blue} 💡 Add the missing routes from the enhanced server script${colors.reset}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (testResults.failed > enhancedEndpoints.length) {
|
||||
console.log(`${colors.red}🚨 Core endpoints failing - check server configuration${colors.reset}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (testResults.passed === testResults.total) {
|
||||
console.log(`${colors.green}🎉 All tests passed! API is fully functional.${colors.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug curl command generator
|
||||
*/
|
||||
function generateCurlCommands() {
|
||||
console.log(`${colors.magenta}${colors.bright}🔧 EQUIVALENT CURL COMMANDS${colors.reset}`);
|
||||
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
|
||||
console.log(`${colors.yellow}Try these curl commands to debug:${colors.reset}\n`);
|
||||
|
||||
const endpoints = [
|
||||
'/health',
|
||||
'/api/health',
|
||||
'/api/daily-summary',
|
||||
'/api/price-history',
|
||||
'/api/available-units'
|
||||
];
|
||||
|
||||
endpoints.forEach(endpoint => {
|
||||
console.log(`${colors.cyan}# Test ${endpoint}${colors.reset}`);
|
||||
console.log(`curl -v -H "Accept: application/json" http://${BASE_URL}${endpoint}`);
|
||||
console.log(`curl -v -H "Accept: application/json" https://${BASE_URL}${endpoint}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
console.log(`${colors.yellow}Look for:${colors.reset}`);
|
||||
console.log(`${colors.blue}- HTTP status codes (200 = success, 301/302 = redirect, 404 = not found)${colors.reset}`);
|
||||
console.log(`${colors.blue}- Location headers in redirects${colors.reset}`);
|
||||
console.log(`${colors.blue}- Response content-type${colors.reset}`);
|
||||
console.log(`${colors.blue}- Server response headers${colors.reset}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
await runAllTests();
|
||||
|
||||
// If all tests failed with redirects, try curl-equivalent testing
|
||||
const allFailed = testResults.passed === 0 && testResults.failed > 0;
|
||||
const hasRedirectErrors = testResults.details.some(test =>
|
||||
test.error && test.error.includes('Redirect')
|
||||
);
|
||||
|
||||
if (allFailed && hasRedirectErrors) {
|
||||
console.log(`${colors.yellow}🔍 All tests failed with redirects. Trying curl-equivalent approach...${colors.reset}\n`);
|
||||
await testWithCurlEquivalent();
|
||||
}
|
||||
|
||||
// Generate curl commands for manual testing
|
||||
generateCurlCommands();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}💥 Fatal error: ${error.message}${colors.reset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Exit with error code if tests failed
|
||||
process.exit(testResults.failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log(`\n${colors.yellow}🛑 Test interrupted by user${colors.reset}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Add CLI argument parsing
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
console.log(`
|
||||
${colors.bright}Apartment API Endpoint Tester${colors.reset}
|
||||
|
||||
${colors.cyan}Usage:${colors.reset}
|
||||
node test-endpoints.js [options]
|
||||
|
||||
${colors.cyan}Options:${colors.reset}
|
||||
--help, -h Show this help message
|
||||
--https Force HTTPS testing
|
||||
--verbose Show detailed response data
|
||||
|
||||
${colors.cyan}Examples:${colors.reset}
|
||||
node test-endpoints.js
|
||||
node test-endpoints.js --https
|
||||
node test-endpoints.js --verbose
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
main();
|
||||
Reference in New Issue
Block a user