Implement Phase 2 activity monitoring API

- Add GET /api/admin/activity with pagination and filters
- Add GET /api/admin/users/:id/activity for user-specific history
- Add GET /api/admin/activity/export for CSV export
- Implement $lookup aggregation for user info (name, email)
- Add date range filtering with validation
- Export limited to 10,000 records with truncation headers
- All 50 Phase 2 tests passing
This commit is contained in:
2026-01-22 13:01:48 -07:00
parent dbd8d81668
commit c23afdf0b7
3 changed files with 1477 additions and 652 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ const {
updateUserRole, updateUserRole,
isValidObjectId isValidObjectId
} = require('../models/user'); } = require('../models/user');
const { logActivity } = require('../services/activityLogger'); const { logActivity, ACTIVITY_COLLECTION } = require('../services/activityLogger');
const router = express.Router({ strict: true }); 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; module.exports = router;

680
test-endpoints.js Normal file
View 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();