Compare commits

...

3 Commits

23 changed files with 3782 additions and 532 deletions
+34
View File
@@ -0,0 +1,34 @@
# Environment Configuration for Ticket Microservice
# Server Configuration
PORT=3049
NODE_ENV=development
# Redis Configuration
REDIS_URL=redis://localhost:6379
# Logging Configuration
LOG_LEVEL=info
LOG_FILE=logs/app.log
# PDF Configuration
PDF_OUTPUT_DIR=tickets
PDF_CLEANUP_MAX_AGE_HOURS=24
# Load Testing Configuration
TEST_URL=http://localhost:3049
# Prometheus Configuration (if using monitoring profile)
PROMETHEUS_PORT=9090
GRAFANA_PORT=3000
# Optional: Custom Redis Configuration
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=
# REDIS_DB=0
# Optional: Performance Tuning
# MAX_CONCURRENT_REQUESTS=1000
# REQUEST_TIMEOUT_MS=30000
# PDF_GENERATION_TIMEOUT_MS=10000
+2 -1
View File
@@ -3,4 +3,5 @@
/package-lock.json
/tickets
/logs
.env
.env
*.log
+211 -17
View File
@@ -47,39 +47,69 @@ Your task is to extract the high-throughput ticket purchasing component and exte
- Docker and Docker Compose
- Git
### Environment Variables
The following environment variables can be configured in your `.env` file:
| Variable | Default | Description |
| --------------------------- | ------------------------ | ---------------------------------------- |
| `PORT` | `3049` | Server port number |
| `NODE_ENV` | `development` | Environment mode |
| `REDIS_URL` | `redis://localhost:6379` | Redis connection string |
| `LOG_LEVEL` | `info` | Logging level (error, warn, info, debug) |
| `LOG_FILE` | `logs/app.log` | Log file path |
| `PDF_OUTPUT_DIR` | `tickets` | Directory for generated PDF tickets |
| `PDF_CLEANUP_MAX_AGE_HOURS` | `24` | Maximum age for PDF cleanup |
| `TEST_URL` | `http://localhost:3049` | Base URL for load testing |
| `ALLOWED_ORIGINS` | `localhost:3000,3049` | CORS allowed origins |
| `RATE_LIMIT_ENABLED` | `true` | Enable rate limiting |
| `SECURITY_HEADERS_ENABLED` | `true` | Enable security headers |
| `REDIS_SCAN_BATCH_SIZE` | `100` | Redis SCAN batch size for performance |
### Quick Start
1. **Clone the repository**
```bash
git clone <repository-url>
cd module4_backend_project
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment**
```bash
cp .env.example .env
cp env.example .env
# Edit .env file if needed
```
> **Note**: The `env.example` file contains default configuration values. Copy it to `.env` and modify as needed for your environment.
> **Important**: Create the `logs` directory if you want to use file logging: `mkdir logs`
4. **Start with Docker (Recommended)**
```bash
# Start core services (Redis + App)
docker-compose up -d
# Or start with monitoring (Prometheus + Grafana)
docker-compose --profile monitoring up -d
```
> **Note**: For Docker deployment, you can also set environment variables directly in `docker-compose.yml` or use the `.env` file for local development.
5. **Seed the database**
```bash
# Seed 5 events with 10,000 tickets each
npm run seed
# Custom seeding: 3 events with 5,000 tickets each
npm run seed 3 5000
```
@@ -88,12 +118,19 @@ Your task is to extract the high-throughput ticket purchasing component and exte
If you prefer to run components separately:
1. **Start Redis**
1. **Create necessary directories**
```bash
mkdir -p logs tickets
```
2. **Start Redis**
```bash
docker-compose up -d redis
```
2. **Start the application**
3. **Start the application**
```bash
npm run dev # Development with auto-reload
# or
@@ -104,22 +141,23 @@ If you prefer to run components separately:
Once running, the following endpoints are available:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/health` | System health check |
| GET | `/events` | List all events with statistics |
| GET | `/events/:eventId` | Get specific event details |
| POST | `/buy/:eventId` | Purchase a ticket for an event |
| GET | `/tickets/:purchaseId` | Download ticket PDF |
| GET | `/metrics` | Real-time system metrics |
| GET | `/admin/pdf-stats` | PDF management statistics |
| POST | `/admin/cleanup-tickets` | Cleanup old ticket files |
| Method | Endpoint | Description |
| ------ | ------------------------ | --------------------------------------- |
| GET | `/health` | System health check |
| GET | `/events` | List all events with statistics |
| GET | `/events/:eventId` | Get specific event details |
| POST | `/buy/:eventId` | Purchase a ticket for an event |
| GET | `/tickets/:purchaseId` | Download ticket PDF |
| GET | `/metrics` | Real-time system metrics |
| GET | `/admin/pdf-stats` | PDF management statistics |
| POST | `/admin/cleanup-tickets` | Cleanup old ticket files |
| POST | `/admin/seed-fallback` | Manually seed fallback store from Redis |
### Load Testing
The system includes a comprehensive load testing framework:
```bash
````bash
# Run full test suite (5000+ concurrent connections)
npm run test:load -- --full
@@ -131,7 +169,29 @@ npm run test:load -- --multi --events 1,2,3 --connections 6000
# Custom load test
node tests/load-test.js --event 2 --connections 1000 --duration 10
```
# Test fallback store functionality
npm run test:fallback
# Test security features
npm run test:security
# Run comprehensive test suite
npm test
# Run specific test categories
npm run test:unit # Unit tests only
npm run test:integration # Integration tests only
npm run test:performance # Performance tests only
# Run critical duplicate prevention tests
npm run test:duplicate-prevention
# Run with coverage report
npm run test:coverage
# Run tests in watch mode (development)
npm run test:watch
### Monitoring & Metrics
@@ -146,6 +206,22 @@ Grafana dashboard: http://localhost:3000
- Username: `admin`
- Password: `admin`
### Fallback Store Management
The system includes a robust fallback mechanism that automatically activates when Redis is unavailable:
- **Automatic Seeding**: The fallback store is automatically seeded during server startup and when activated
- **Data Synchronization**: When Redis becomes available again, the fallback store can be manually synced
- **Manual Seeding**: Use `/admin/seed-fallback` to manually populate the fallback store from Redis data
```bash
# Manually seed fallback store from Redis
curl -X POST http://localhost:3049/admin/seed-fallback
# Check fallback store status
curl http://localhost:3049/health
````
### Docker Commands
```bash
@@ -174,6 +250,124 @@ docker-compose up -d --build
- **Logging & Metrics:** Proper logging of operations and a functional metrics endpoint suitable for Prometheus scraping.
- **Design Rationale:** The design document (`design.md`) should clearly articulate your architectural decisions, potential bottlenecks, and design solutions.
## Testing Suite
The project includes a comprehensive testing framework to ensure reliability and prevent critical issues:
### Test Categories
- **Unit Tests** (`tests/unit/`): Test individual components in isolation
- **Integration Tests** (`tests/integration/`): Test component interactions and API endpoints
- **Performance Tests** (`tests/performance/`): Verify system behavior under high load
### Critical Test Coverage
- **Duplicate Prevention**: Automated verification that no ticket is sold more than once
- **High Concurrency**: Tests with 100+ concurrent requests to ensure data integrity
- **Fallback Mode**: Comprehensive testing of Redis failure scenarios
- **API Endpoints**: Full coverage of all REST endpoints with edge case handling
- **Security Features**: Validation of rate limiting, input validation, and security headers
### Running Tests
```bash
# Run all tests
npm test
# Run specific test categories
npm run test:unit # Unit tests only
npm run test:integration # Integration tests only
npm run test:performance # Performance tests only
# Run critical duplicate prevention tests
npm run test:duplicate-prevention
# Generate coverage report
npm run test:coverage
# Run tests in watch mode (development)
npm run test:watch
# Use the test runner script for easier test execution
node run-tests.js all # Run all tests
node run-tests.js validate # Run core requirement validation
node run-tests.js duplicate # Run duplicate prevention tests only
node run-tests.js quick # Run quick test suite
```
### Test Requirements
- **No Duplicate Tickets**: Core requirement verified by automated tests
- **High Concurrency**: System tested with 100+ concurrent requests
- **Data Consistency**: Redis and fallback store synchronization verified
- **Performance**: Response times and memory usage monitored under load
- **Security**: All security features validated with comprehensive tests
## Security Features
The system includes comprehensive security measures to protect against common threats:
### Rate Limiting
- **General API**: 100 requests per 15 minutes
- **Purchase Endpoints**: 10 requests per minute
- **Admin Endpoints**: 20 requests per 5 minutes
### Input Validation
- **Event IDs**: Must be positive integers
- **Purchase IDs**: Must be valid UUIDs
- **Request Parameters**: Validated and sanitized
### Security Headers
- **Content Security Policy**: Prevents XSS attacks
- **HSTS**: Enforces HTTPS connections
- **XSS Protection**: Additional XSS prevention
- **Frame Guard**: Prevents clickjacking
### Request Security
- **Size Limits**: Maximum 1MB request size
- **CORS Protection**: Configurable allowed origins
- **Security Logging**: Suspicious request monitoring
## Troubleshooting
### Common Issues
1. **Redis Connection Failed**
- Ensure Redis is running: `docker-compose up -d redis`
- Check Redis URL in `.env` file
- Verify Redis port (default: 6379) is not blocked
2. **Port Already in Use**
- Change `PORT` in `.env` file
- Kill process using the port: `lsof -ti:3049 | xargs kill -9`
3. **Missing Dependencies**
- Run `npm install` to install all dependencies
- Ensure Node.js version is 18+ (check with `node --version`)
4. **Permission Denied for Logs/Tickets**
- Create directories with proper permissions: `mkdir -p logs tickets`
- Check file permissions: `chmod 755 logs tickets`
5. **Fallback Store Not Working**
- Ensure Redis is seeded: `npm run seed`
- Check fallback store status: `npm run test:fallback`
- Manually seed fallback: `curl -X POST http://localhost:3049/admin/seed-fallback`
### Getting Help
- Check application logs in the `logs` directory
- Verify Redis connection: `curl http://localhost:3049/health`
- Test fallback store: `npm run test:fallback`
## Final Challenges
- Enhance your docker-compose setup to include a Prometheus container for live monitoring.
+27 -28
View File
@@ -1,34 +1,34 @@
const redis = require('redis');
require('dotenv').config();
const redis = require("redis");
require("dotenv").config();
async function debugEvents() {
const client = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
const client = redis.createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
try {
await client.connect();
console.log('✅ Connected to Redis');
console.log("✅ Connected to Redis");
// Check what event keys exist
const eventKeys = await client.keys('event:*');
console.log('\n📋 Found Redis keys:', eventKeys);
const eventKeys = await client.keys("event:*");
console.log("\n Found Redis keys:", eventKeys);
// Check global stats
const globalStats = await client.hGetAll('global:stats');
console.log('\n🌍 Global stats:', globalStats);
const globalStats = await client.hGetAll("global:stats");
console.log("\n Global stats:", globalStats);
// Check each event
const metaKeys = eventKeys.filter(key => key.includes(':meta'));
console.log('\n🎫 Event Details:');
const metaKeys = eventKeys.filter((key) => key.includes(":meta"));
console.log("\nEvent Details:");
for (const metaKey of metaKeys) {
const eventId = metaKey.match(/event:(\d+):meta/)[1];
const ticketKey = `event:${eventId}:tickets`;
const meta = await client.hGetAll(metaKey);
const ticketCount = await client.lLen(ticketKey);
console.log(`\n Event ${eventId}:`);
console.log(` Name: ${meta.name}`);
console.log(` Total Tickets: ${meta.totalTickets}`);
@@ -36,20 +36,19 @@ async function debugEvents() {
console.log(` Remaining: ${ticketCount}`);
console.log(` Created: ${meta.createdAt}`);
}
// Test if we can check existence of event 5
const event5Exists = await client.exists('event:5:meta');
console.log(`\n🔍 Event 5 exists: ${event5Exists ? 'YES' : 'NO'}`);
const event5Exists = await client.exists("event:5:meta");
console.log(`\n Event 5 exists: ${event5Exists ? "YES" : "NO"}`);
if (event5Exists) {
const event5Meta = await client.hGetAll('event:5:meta');
const event5Tickets = await client.lLen('event:5:tickets');
console.log('📊 Event 5 details:', event5Meta);
console.log(`🎫 Event 5 remaining tickets: ${event5Tickets}`);
const event5Meta = await client.hGetAll("event:5:meta");
const event5Tickets = await client.lLen("event:5:tickets");
console.log("Event 5 details:", event5Meta);
console.log(`Event 5 remaining tickets: ${event5Tickets}`);
}
} catch (error) {
console.error('❌ Error:', error);
console.error("❌ Error:", error);
} finally {
await client.disconnect();
}
+55 -3
View File
@@ -1,6 +1,7 @@
# Ticket Scaling Microservice - Design Document
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [System Components](#system-components)
3. [Scalability Strategies](#scalability-strategies)
@@ -15,6 +16,7 @@
## Architecture Overview
### High-Level Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Load Balancer │ │ Prometheus │ │ Grafana │
@@ -38,6 +40,7 @@
```
### Design Principles
1. **High Availability**: Fallback mechanisms ensure service continuity
2. **Atomic Operations**: Redis Lua scripts prevent race conditions
3. **Horizontal Scalability**: Stateless design enables easy scaling
@@ -47,6 +50,7 @@
## System Components
### 1. Core Application (server.js)
- **Technology**: Node.js with Express framework
- **Responsibilities**:
- HTTP request handling
@@ -55,6 +59,7 @@
- PDF generation coordination
### 2. Redis Client (redis-client.js)
- **Technology**: Redis with Lua scripting
- **Responsibilities**:
- Atomic ticket operations
@@ -63,6 +68,7 @@
- Script execution
### 3. Fallback Store (fallback-store.js)
- **Technology**: In-memory JavaScript Map
- **Responsibilities**:
- Emergency ticket storage
@@ -70,6 +76,7 @@
- Graceful degradation
### 4. PDF Generator (pdf-generator.js)
- **Technology**: PDFKit library
- **Responsibilities**:
- Professional ticket generation
@@ -77,6 +84,7 @@
- Cleanup operations
### 5. Logging System (logger.js)
- **Technology**: Winston logging framework
- **Responsibilities**:
- Structured logging
@@ -87,6 +95,7 @@
## Scalability Strategies
### Horizontal Scaling
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Instance 1 │ │ Instance 2 │ │ Instance N │
@@ -102,17 +111,20 @@
```
**Key Features**:
- Stateless application design
- Shared Redis backend
- Load balancer distribution
- Independent scaling
### Vertical Scaling
- **CPU**: Multi-core utilization through Node.js cluster mode
- **Memory**: Configurable heap sizes for high-throughput
- **I/O**: Async operations prevent blocking
### Database Scaling
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Redis Master │ │ Redis Replica │ │ Redis Replica │
@@ -121,6 +133,7 @@
```
**Strategies**:
- Redis clustering for horizontal scaling
- Read replicas for metrics/stats queries
- Sharding by event ID for massive scale
@@ -128,6 +141,7 @@
## Atomic Operations
### Lua Script Design
Our core purchase operation uses a Redis Lua script to ensure atomicity:
```lua
@@ -145,12 +159,14 @@ local globalKey = KEYS[3] -- global:stats
```
**Benefits**:
- **Race Condition Prevention**: All operations execute atomically
- **Consistency**: No partial state updates
- **Performance**: Single round-trip to Redis
- **Reliability**: All-or-nothing execution
### Concurrency Handling
- **Optimistic Locking**: Lua scripts handle concurrent access
- **Queue Management**: Redis lists provide FIFO ticket distribution
- **Connection Pooling**: Efficient Redis connection reuse
@@ -158,11 +174,13 @@ local globalKey = KEYS[3] -- global:stats
## Fallback Mechanisms
### Activation Triggers
1. **Redis Connection Failure**: Network issues or Redis downtime
2. **Script Execution Errors**: Lua script failures
3. **Timeout Scenarios**: Slow Redis responses
### Fallback Architecture
```
┌─────────────────┐
│ Request Comes │
@@ -181,8 +199,16 @@ local globalKey = KEYS[3] -- global:stats
└─────────────────┘ └─────────────────┘
```
### Fallback Store Improvements
- **Automatic Seeding**: Fallback store is seeded during server startup and when activated
- **Data Synchronization**: Automatic attempt to sync with Redis data when activated
- **Manual Seeding**: Admin endpoint to manually populate fallback store from Redis
- **Resilient Operation**: Continues functioning even when Redis is completely unavailable
### Fallback Limitations
- **Non-Persistent**: Data lost on restart
- **Non-Persistent**: Data lost on restart (mitigated by automatic reseeding)
- **Single Instance**: No cross-instance synchronization
- **Capacity Limited**: Memory constraints
- **Warning Logs**: Clear indication of degraded mode
@@ -190,18 +216,21 @@ local globalKey = KEYS[3] -- global:stats
## Performance Optimizations
### Application Level
1. **Async Operations**: Non-blocking I/O throughout
2. **Connection Pooling**: Reuse Redis connections
3. **Batch Operations**: Bulk ticket seeding
4. **Caching**: Event metadata caching
### Redis Optimizations
1. **Lua Scripts**: Reduced network round-trips
2. **Pipeline Operations**: Batch commands
3. **Memory Management**: Efficient data structures
4. **Persistence**: AOF for durability
### PDF Generation
1. **Async Generation**: Non-blocking PDF creation
2. **Stream Processing**: Memory-efficient file handling
3. **Cleanup Jobs**: Automatic old file removal
@@ -210,6 +239,7 @@ local globalKey = KEYS[3] -- global:stats
## Monitoring & Observability
### Metrics Collection
```json
{
"global": {
@@ -238,12 +268,14 @@ local globalKey = KEYS[3] -- global:stats
```
### Logging Strategy
- **Structured Logging**: JSON format for parsing
- **Request Tracking**: Unique IDs for tracing
- **Performance Metrics**: Response times and throughput
- **Error Categorization**: Different log levels
### Health Checks
- **Application Health**: `/health` endpoint
- **Redis Connectivity**: Connection status
- **Fallback Status**: Degraded mode indication
@@ -252,23 +284,37 @@ local globalKey = KEYS[3] -- global:stats
## Security Considerations
### Input Validation
- **Event ID Validation**: Numeric constraints
- **Request Rate Limiting**: DDoS protection
- **Event ID Validation**: Numeric constraints with range checking
- **Purchase ID Validation**: UUID format validation
- **Request Rate Limiting**: Multi-tier DDoS protection
- **Parameter Sanitization**: Injection prevention
- **Request Size Limits**: Prevents large payload attacks
### Container Security
- **Non-Root User**: Principle of least privilege
- **Minimal Base Image**: Alpine Linux for smaller attack surface
- **Health Checks**: Container monitoring
### Data Protection
- **No Sensitive Data**: Tickets are identifiers only
- **Audit Logging**: Purchase tracking
- **Secure Defaults**: Production-ready configuration
### Security Headers & Middleware
- **Helmet.js**: Comprehensive security headers
- **Content Security Policy**: XSS prevention
- **HSTS**: HTTPS enforcement
- **Frame Guard**: Clickjacking protection
- **Security Logging**: Suspicious request monitoring
## Deployment Strategy
### Development Environment
```bash
# Local development
npm install
@@ -278,6 +324,7 @@ npm run dev # Start with nodemon
```
### Production Environment
```bash
# Docker deployment
docker-compose up -d # Core services
@@ -285,6 +332,7 @@ docker-compose --profile monitoring up # With monitoring
```
### Container Orchestration
- **Docker Compose**: Local and small deployments
- **Kubernetes**: Large-scale deployments
- **Health Checks**: Automatic restart on failure
@@ -293,24 +341,28 @@ docker-compose --profile monitoring up # With monitoring
## Future Enhancements
### Performance Improvements
1. **Redis Clustering**: Horizontal database scaling
2. **CDN Integration**: PDF delivery optimization
3. **Caching Layer**: Application-level caching
4. **Connection Optimization**: Advanced pooling
### Feature Additions
1. **QR Code Generation**: Enhanced ticket security
2. **Email Integration**: Automatic ticket delivery
3. **Payment Processing**: Complete purchase flow
4. **Event Management**: Dynamic event creation
### Monitoring Enhancements
1. **Distributed Tracing**: Request flow tracking
2. **Custom Dashboards**: Business metrics visualization
3. **Alerting**: Proactive issue detection
4. **Performance Profiling**: Bottleneck identification
### Security Hardening
1. **Authentication**: API key management
2. **Rate Limiting**: Advanced throttling
3. **Encryption**: Data in transit protection
+19
View File
@@ -0,0 +1,19 @@
module.exports = {
testEnvironment: "node",
testMatch: ["**/tests/**/*.test.js", "**/*.test.js"],
collectCoverageFrom: [
"src/**/*.js",
"server.js",
"!src/**/*.test.js",
"!**/node_modules/**",
],
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "html"],
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
testTimeout: 30000,
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
};
+15 -1
View File
@@ -8,7 +8,17 @@
"dev": "nodemon server.js",
"seed": "node seed.js",
"test": "jest",
"test:unit": "jest tests/unit",
"test:integration": "jest tests/integration",
"test:performance": "jest tests/performance",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"test:load": "node tests/load-test.js",
"test:fallback": "node test-fallback.js",
"test:security": "node test-security.js",
"test:duplicate-prevention": "jest tests/integration/duplicate-prevention.test.js",
"test:api": "jest tests/integration/api-endpoints.test.js",
"test:load-performance": "jest tests/performance/load-testing.test.js",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down"
},
@@ -22,7 +32,11 @@
"winston": "^3.11.0",
"prom-client": "^15.1.0",
"uuid": "^9.0.1",
"dotenv": "^16.3.1"
"dotenv": "^16.3.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"express-validator": "^7.0.1",
"axios": "^1.6.0"
},
"devDependencies": {
"jest": "^29.7.0",
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env node
const { spawn } = require("child_process");
const path = require("path");
// ANSI color codes for better 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",
};
function log(message, color = "reset") {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logHeader(message) {
console.log("\n" + "=".repeat(60));
log(` ${message}`, "bright");
console.log("=".repeat(60));
}
function runCommand(command, args = [], description = "") {
return new Promise((resolve, reject) => {
if (description) {
log(`\n🚀 ${description}`, "cyan");
log(` Command: ${command} ${args.join(" ")}`, "yellow");
}
const child = spawn(command, args, {
stdio: "inherit",
shell: true,
});
child.on("close", (code) => {
if (code === 0) {
if (description) {
log(`${description} completed successfully`, "green");
}
resolve();
} else {
if (description) {
log(`${description} failed with code ${code}`, "red");
}
reject(new Error(`Command failed with code ${code}`));
}
});
child.on("error", (error) => {
log(`❌ Error running ${description}: ${error.message}`, "red");
reject(error);
});
});
}
async function runTestSuite() {
const args = process.argv.slice(2);
const command = args[0];
logHeader("Ticket Microservice Test Runner");
log("Comprehensive testing suite for the ticket microservice", "blue");
try {
switch (command) {
case "all":
logHeader("Running Complete Test Suite");
await runCommand("npm", ["test"], "Complete test suite");
break;
case "unit":
logHeader("Running Unit Tests");
await runCommand("npm", ["run", "test:unit"], "Unit tests");
break;
case "integration":
logHeader("Running Integration Tests");
await runCommand(
"npm",
["run", "test:integration"],
"Integration tests"
);
break;
case "performance":
logHeader("Running Performance Tests");
await runCommand(
"npm",
["run", "test:performance"],
"Performance tests"
);
break;
case "duplicate":
logHeader("Running Critical Duplicate Prevention Tests");
await runCommand(
"npm",
["run", "test:duplicate-prevention"],
"Duplicate prevention tests"
);
break;
case "api":
logHeader("Running API Endpoint Tests");
await runCommand("npm", ["run", "test:api"], "API endpoint tests");
break;
case "security":
logHeader("Running Security Tests");
await runCommand("npm", ["run", "test:security"], "Security tests");
break;
case "fallback":
logHeader("Running Fallback Store Tests");
await runCommand(
"npm",
["run", "test:fallback"],
"Fallback store tests"
);
break;
case "coverage":
logHeader("Running Tests with Coverage Report");
await runCommand("npm", ["run", "test:coverage"], "Coverage tests");
break;
case "load":
logHeader("Running Load Tests");
await runCommand("npm", ["run", "test:load"], "Load tests");
break;
case "quick":
logHeader("Running Quick Test Suite (Critical Paths Only)");
log("Running duplicate prevention tests...", "yellow");
await runCommand(
"npm",
["run", "test:duplicate-prevention"],
"Duplicate prevention tests"
);
log("Running API endpoint tests...", "yellow");
await runCommand("npm", ["run", "test:api"], "API endpoint tests");
log("Running security tests...", "yellow");
await runCommand("npm", ["run", "test:security"], "Security tests");
break;
case "validate":
logHeader("Running Validation Tests (Core Requirements)");
log("1. Duplicate Prevention Tests", "cyan");
await runCommand(
"npm",
["run", "test:duplicate-prevention"],
"Duplicate prevention validation"
);
log("2. High Concurrency Tests", "cyan");
await runCommand(
"npm",
["run", "test:load-performance"],
"High concurrency validation"
);
log("3. API Endpoint Tests", "cyan");
await runCommand("npm", ["run", "test:api"], "API endpoint validation");
log("4. Security Tests", "cyan");
await runCommand(
"npm",
["run", "test:security"],
"Security validation"
);
break;
default:
logHeader("Available Test Commands");
log("Usage: node run-tests.js <command>", "bright");
console.log("");
log("Commands:", "bright");
log(" all - Run complete test suite", "green");
log(" unit - Run unit tests only", "green");
log(" integration- Run integration tests only", "green");
log(" performance- Run performance tests only", "green");
log(" duplicate - Run duplicate prevention tests", "green");
log(" api - Run API endpoint tests", "green");
log(" security - Run security tests", "green");
log(" fallback - Run fallback store tests", "green");
log(" coverage - Run tests with coverage report", "green");
log(" load - Run load tests", "green");
log(" quick - Run quick test suite (critical paths)", "green");
log(" validate - Run validation tests (core requirements)", "green");
console.log("");
log("Examples:", "bright");
log(" node run-tests.js all", "yellow");
log(" node run-tests.js duplicate", "yellow");
log(" node run-tests.js validate", "yellow");
console.log("");
log(
"Note: Make sure Redis is running and the application is properly configured.",
"cyan"
);
break;
}
if (command && command !== "help") {
logHeader("Test Suite Completed Successfully");
log("🎉 All tests passed! The system is working correctly.", "green");
}
} catch (error) {
logHeader("Test Suite Failed");
log(`❌ Error: ${error.message}`, "red");
log("Please check the test output above for details.", "yellow");
process.exit(1);
}
}
// Handle process termination
process.on("SIGINT", () => {
log("\n\n⚠️ Test execution interrupted by user", "yellow");
process.exit(0);
});
process.on("SIGTERM", () => {
log("\n\n⚠️ Test execution terminated", "yellow");
process.exit(0);
});
// Run the test suite
runTestSuite().catch((error) => {
log(`\n💥 Fatal error: ${error.message}`, "red");
process.exit(1);
});
+75 -19
View File
@@ -1,11 +1,14 @@
const redis = require("redis");
require('dotenv').config();
require("dotenv").config();
// Import fallback store for seeding
const fallbackStore = require("./src/utils/fallback-store");
// Configuration for seeding
const config = {
numEvents: parseInt(process.argv[2]) || 5, // Number of events to create
ticketsPerEvent: parseInt(process.argv[3]) || 10000, // Tickets per event
redisUrl: process.env.REDIS_URL || "redis://localhost:6379"
redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
};
const client = redis.createClient({ url: config.redisUrl });
@@ -13,51 +16,65 @@ const client = redis.createClient({ url: config.redisUrl });
async function seedTickets() {
try {
await client.connect();
console.log(`Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...`);
console.log(
`Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...`
);
// Clear existing event data
const existingKeys = await client.keys('event:*');
const existingKeys = await client.keys("event:*");
if (existingKeys.length > 0) {
await client.del(existingKeys);
console.log(`Cleared ${existingKeys.length} existing event keys.`);
}
// Seed multiple events
for (let eventId = 1; eventId <= config.numEvents; eventId++) {
const eventKey = `event:${eventId}:tickets`;
const metaKey = `event:${eventId}:meta`;
// Generate tickets for this event
const tickets = [];
for (let i = 1; i <= config.ticketsPerEvent; i++) {
tickets.push(`ticket-${eventId}-${i}`);
}
// Store tickets in Redis list
await client.rPush(eventKey, tickets);
// Store event metadata
await client.hSet(metaKey, {
const metadata = {
eventId: eventId,
totalTickets: config.ticketsPerEvent,
soldTickets: 0,
createdAt: new Date().toISOString(),
name: `Event ${eventId}`,
description: `Sample event ${eventId} for load testing`
});
console.log(`✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded`);
description: `Sample event ${eventId} for load testing`,
};
await client.hSet(metaKey, metadata);
console.log(
`✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded`
);
}
// Store global stats
await client.hSet('global:stats', {
await client.hSet("global:stats", {
totalEvents: config.numEvents,
totalTickets: config.numEvents * config.ticketsPerEvent,
totalSold: 0,
lastSeeded: new Date().toISOString()
lastSeeded: new Date().toISOString(),
});
console.log(`\n🎉 Successfully seeded ${config.numEvents} events with ${config.numEvents * config.ticketsPerEvent} total tickets!`);
// Also seed the fallback store
await seedFallbackStore();
console.log(
`\n🎉 Successfully seeded ${config.numEvents} events with ${
config.numEvents * config.ticketsPerEvent
} total tickets!`
);
console.log(`📦 Fallback store also seeded for resilience`);
process.exit(0);
} catch (err) {
console.error("Error during seed:", err);
@@ -65,4 +82,43 @@ async function seedTickets() {
}
}
async function seedFallbackStore() {
try {
console.log("\n🌐 Seeding fallback store...");
// Activate fallback store temporarily for seeding
fallbackStore.activate("Seeding fallback store during initialization");
// Seed the same events in fallback store
for (let eventId = 1; eventId <= config.numEvents; eventId++) {
const tickets = [];
for (let i = 1; i <= config.ticketsPerEvent; i++) {
tickets.push(`ticket-${eventId}-${i}`);
}
const metadata = {
eventId: eventId,
totalTickets: config.ticketsPerEvent,
soldTickets: 0,
createdAt: new Date().toISOString(),
name: `Event ${eventId}`,
description: `Sample event ${eventId} for load testing`,
};
fallbackStore.seedEvent(eventId, tickets, metadata);
}
// Update global stats in fallback store
fallbackStore.globalStats.lastSeeded = new Date().toISOString();
console.log(`✓ Fallback store seeded with ${config.numEvents} events`);
// Deactivate fallback store after seeding (will be activated when needed)
fallbackStore.deactivate();
} catch (error) {
console.error("Error seeding fallback store:", error);
// Don't fail the entire seeding process if fallback seeding fails
}
}
seedTickets();
+527 -283
View File
File diff suppressed because it is too large Load Diff
+90 -17
View File
@@ -1,4 +1,4 @@
const logger = require('./logger');
const logger = require("./logger");
class FallbackStore {
constructor() {
@@ -7,20 +7,30 @@ class FallbackStore {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null
lastSeeded: null,
};
this.isActive = false;
}
activate(reason) {
this.isActive = true;
logger.logFallback('In-Memory Store Activated', reason);
logger.warn('⚠️ FALLBACK MODE: Using in-memory store - data will not persist!');
logger.logFallback("In-Memory Store Activated", reason);
logger.warn(
"⚠️ FALLBACK MODE: Using in-memory store - data will not persist!"
);
// Check if fallback store needs seeding
if (this.events.size === 0) {
logger.warn(
"⚠️ Fallback store is empty - attempting to seed from Redis..."
);
this.attemptReseed();
}
}
deactivate() {
this.isActive = false;
logger.info('In-Memory Store Deactivated - Redis connection restored');
logger.info("In-Memory Store Deactivated - Redis connection restored");
}
seedEvent(eventId, tickets, metadata) {
@@ -29,13 +39,15 @@ class FallbackStore {
this.events.set(eventId, {
tickets: [...tickets], // Create a copy
metadata: { ...metadata },
soldTickets: 0
soldTickets: 0,
});
this.globalStats.totalEvents++;
this.globalStats.totalTickets += tickets.length;
logger.info(`Fallback: Seeded event ${eventId} with ${tickets.length} tickets`);
logger.info(
`Fallback: Seeded event ${eventId} with ${tickets.length} tickets`
);
return true;
}
@@ -44,26 +56,26 @@ class FallbackStore {
const event = this.events.get(eventId);
if (!event) {
return { success: false, error: 'EVENT_NOT_FOUND' };
return { success: false, error: "EVENT_NOT_FOUND" };
}
if (event.tickets.length === 0) {
return { success: false, error: 'NO_TICKETS_AVAILABLE' };
return { success: false, error: "NO_TICKETS_AVAILABLE" };
}
// Atomically remove a ticket
const ticket = event.tickets.shift();
event.soldTickets++;
event.metadata.lastSoldAt = new Date().toISOString();
this.globalStats.totalSold++;
logger.logPurchase(eventId, ticket, purchaseId, true);
return {
success: true,
ticket,
soldCount: event.soldTickets
soldCount: event.soldTickets,
};
}
@@ -81,7 +93,7 @@ class FallbackStore {
soldTickets: event.soldTickets,
remainingTickets: event.tickets.length,
createdAt: event.metadata.createdAt,
lastSoldAt: event.metadata.lastSoldAt || 'never'
lastSoldAt: event.metadata.lastSoldAt || "never",
};
}
@@ -113,9 +125,9 @@ class FallbackStore {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null
lastSeeded: null,
};
logger.info('Fallback store cleared');
logger.info("Fallback store cleared");
}
getStatus() {
@@ -123,9 +135,70 @@ class FallbackStore {
active: this.isActive,
eventsCount: this.events.size,
totalTickets: this.globalStats.totalTickets,
totalSold: this.globalStats.totalSold
totalSold: this.globalStats.totalSold,
};
}
async attemptReseed() {
try {
logger.info("Attempting to reseed fallback store from Redis...");
// Try to connect to Redis temporarily to get data
const redis = require("redis");
const client = redis.createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
await client.connect();
// Get all event keys
const eventKeys = await client.keys("event:*:meta");
for (const metaKey of eventKeys) {
const eventId = metaKey.split(":")[1];
const ticketKey = `event:${eventId}:tickets`;
try {
// Get event metadata
const metadata = await client.hGetAll(metaKey);
if (!metadata.eventId) continue;
// Get remaining tickets
const remainingTickets = await client.lRange(ticketKey, 0, -1);
// Get sold tickets count
const soldTickets = parseInt(metadata.soldTickets) || 0;
// Seed the event in fallback store
this.seedEvent(eventId, remainingTickets, metadata);
// Update sold tickets count
const event = this.events.get(eventId);
if (event) {
event.soldTickets = soldTickets;
this.globalStats.totalSold += soldTickets;
}
logger.info(
`Fallback: Reseeded event ${eventId} with ${remainingTickets.length} tickets (${soldTickets} already sold)`
);
} catch (eventError) {
logger.error(`Error reseeding event ${eventId}:`, eventError);
}
}
// Update global stats
this.globalStats.lastSeeded = new Date().toISOString();
await client.disconnect();
logger.info("Fallback store reseed completed");
} catch (error) {
logger.error("Failed to reseed fallback store from Redis:", error);
logger.warn(
"Fallback store will operate with existing data or empty state"
);
}
}
}
module.exports = new FallbackStore();
+134
View File
@@ -0,0 +1,134 @@
const promClient = require("prom-client");
// Create a Registry to register the metrics
const register = new promClient.Registry();
// Enable the collection of default metrics
promClient.collectDefaultMetrics({ register });
// Custom metrics for the ticket service
const httpRequestDurationMicroseconds = new promClient.Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.1, 0.5, 1, 2, 5],
});
const httpRequestsTotal = new promClient.Counter({
name: "http_requests_total",
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status_code"],
});
const ticketsSoldTotal = new promClient.Counter({
name: "tickets_sold_total",
help: "Total number of tickets sold",
labelNames: ["event_id", "status"],
});
const ticketsAvailable = new promClient.Gauge({
name: "tickets_available",
help: "Number of tickets available per event",
labelNames: ["event_id"],
});
const redisConnectionStatus = new promClient.Gauge({
name: "redis_connection_status",
help: "Redis connection status (1 = connected, 0 = disconnected)",
});
const fallbackStoreActive = new promClient.Gauge({
name: "fallback_store_active",
help: "Fallback store activation status (1 = active, 0 = inactive)",
});
const pdfGenerationTotal = new promClient.Counter({
name: "pdf_generation_total",
help: "Total number of PDFs generated",
labelNames: ["status"],
});
const pdfGenerationDuration = new promClient.Histogram({
name: "pdf_generation_duration_seconds",
help: "Duration of PDF generation in seconds",
buckets: [0.1, 0.5, 1, 2, 5],
});
// Register all metrics
register.registerMetric(httpRequestDurationMicroseconds);
register.registerMetric(httpRequestsTotal);
register.registerMetric(ticketsSoldTotal);
register.registerMetric(ticketsAvailable);
register.registerMetric(redisConnectionStatus);
register.registerMetric(fallbackStoreActive);
register.registerMetric(pdfGenerationTotal);
register.registerMetric(pdfGenerationDuration);
// Middleware to collect HTTP metrics
const metricsMiddleware = (req, res, next) => {
const start = Date.now();
// Override res.end to capture response status
const originalEnd = res.end;
res.end = function (...args) {
const duration = (Date.now() - start) / 1000; // Convert to seconds
const route = req.route ? req.route.path : req.path;
// Record metrics
httpRequestDurationMicroseconds
.labels(req.method, route, res.statusCode.toString())
.observe(duration);
httpRequestsTotal
.labels(req.method, route, res.statusCode.toString())
.inc();
originalEnd.apply(this, args);
};
next();
};
// Function to update ticket metrics
const updateTicketMetrics = (eventId, soldTickets, remainingTickets) => {
ticketsAvailable.labels(eventId.toString()).set(remainingTickets);
};
// Function to record ticket sale
const recordTicketSale = (eventId, status = "success") => {
ticketsSoldTotal.labels(eventId.toString(), status).inc();
};
// Function to update Redis connection status
const updateRedisStatus = (isConnected) => {
redisConnectionStatus.set(isConnected ? 1 : 0);
};
// Function to update fallback store status
const updateFallbackStatus = (isActive) => {
fallbackStoreActive.set(isActive ? 1 : 0);
};
// Function to record PDF generation
const recordPDFGeneration = (status = "success", duration = null) => {
pdfGenerationTotal.labels(status).inc();
if (duration !== null) {
pdfGenerationDuration.observe(duration);
}
};
// Function to get metrics in Prometheus format
const getMetrics = async () => {
return await register.metrics();
};
module.exports = {
register,
metricsMiddleware,
updateTicketMetrics,
recordTicketSale,
updateRedisStatus,
updateFallbackStatus,
recordPDFGeneration,
getMetrics,
};
+112 -56
View File
@@ -1,7 +1,7 @@
const redis = require('redis');
const fs = require('fs');
const path = require('path');
const logger = require('./logger');
const redis = require("redis");
const fs = require("fs");
const path = require("path");
const logger = require("./logger");
class RedisClient {
constructor() {
@@ -13,28 +13,28 @@ class RedisClient {
async connect() {
try {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
this.client = redis.createClient({ url: redisUrl });
this.client.on('error', (err) => {
logger.error('Redis Client Error:', err);
this.client.on("error", (err) => {
logger.error("Redis Client Error:", err);
this.isConnected = false;
});
this.client.on('connect', () => {
logger.info('Redis client connected');
this.client.on("connect", () => {
logger.info("Redis client connected");
this.isConnected = true;
});
this.client.on('disconnect', () => {
logger.warn('Redis client disconnected');
this.client.on("disconnect", () => {
logger.warn("Redis client disconnected");
this.isConnected = false;
});
await this.client.connect();
return this.client;
} catch (error) {
logger.error('Failed to connect to Redis:', error);
logger.error("Failed to connect to Redis:", error);
this.isConnected = false;
throw error;
}
@@ -42,79 +42,75 @@ class RedisClient {
loadLuaScripts() {
try {
const luaDir = path.join(__dirname, '../lua');
const luaDir = path.join(__dirname, "../lua");
// Load purchase ticket script
const purchaseScript = fs.readFileSync(
path.join(luaDir, 'purchase-ticket.lua'),
'utf8'
path.join(luaDir, "purchase-ticket.lua"),
"utf8"
);
this.luaScripts.purchaseTicket = purchaseScript;
// Load event stats script
const statsScript = fs.readFileSync(
path.join(luaDir, 'get-event-stats.lua'),
'utf8'
path.join(luaDir, "get-event-stats.lua"),
"utf8"
);
this.luaScripts.getEventStats = statsScript;
logger.info('Lua scripts loaded successfully');
logger.info("Lua scripts loaded successfully");
} catch (error) {
logger.error('Failed to load Lua scripts:', error);
logger.error("Failed to load Lua scripts:", error);
throw error;
}
}
async purchaseTicket(eventId, purchaseId, timestamp) {
if (!this.isConnected) {
throw new Error('Redis not connected');
throw new Error("Redis not connected");
}
// Validate event exists before attempting purchase
const eventExists = await this.client.exists(`event:${eventId}:meta`);
if (!eventExists) {
logger.warn(`Event ${eventId} does not exist`);
return [null, 'EVENT_NOT_FOUND'];
return [null, "EVENT_NOT_FOUND"];
}
const keys = [
`event:${eventId}:tickets`,
`event:${eventId}:meta`,
'global:stats'
"global:stats",
];
// Ensure all arguments are strings as required by Redis Lua
const args = [String(timestamp), String(purchaseId)];
try {
const result = await this.client.eval(
this.luaScripts.purchaseTicket,
{ keys, arguments: args }
);
const result = await this.client.eval(this.luaScripts.purchaseTicket, {
keys,
arguments: args,
});
return result;
} catch (error) {
logger.error('Error executing purchase ticket script:', error);
logger.error('Script keys:', keys);
logger.error('Script args:', args);
logger.error("Error executing purchase ticket script:", error);
logger.error("Script keys:", keys);
logger.error("Script args:", args);
throw error;
}
}
async getEventStats(eventId) {
if (!this.isConnected) {
throw new Error('Redis not connected');
throw new Error("Redis not connected");
}
const keys = [
`event:${eventId}:meta`,
`event:${eventId}:tickets`
];
const keys = [`event:${eventId}:meta`, `event:${eventId}:tickets`];
try {
const result = await this.client.eval(
this.luaScripts.getEventStats,
{ keys }
);
const result = await this.client.eval(this.luaScripts.getEventStats, {
keys,
});
if (!result) return null;
return {
@@ -125,57 +121,117 @@ class RedisClient {
soldTickets: parseInt(result[4]),
remainingTickets: parseInt(result[5]),
createdAt: result[6],
lastSoldAt: result[7]
lastSoldAt: result[7],
};
} catch (error) {
logger.error('Error executing get event stats script:', error);
logger.error("Error executing get event stats script:", error);
throw error;
}
}
async getAllEvents() {
if (!this.isConnected) {
throw new Error('Redis not connected');
throw new Error("Redis not connected");
}
try {
const eventKeys = await this.client.keys('event:*:meta');
// Use SCAN instead of KEYS to avoid blocking Redis
const events = [];
let cursor = 0;
for (const key of eventKeys) {
const eventId = key.match(/event:(\d+):meta/)[1];
const stats = await this.getEventStats(eventId);
if (stats) {
events.push(stats);
do {
const result = await this.client.scan(cursor, {
MATCH: "event:*:meta",
COUNT: 100, // Process in batches
});
cursor = result.cursor;
// Process batch of keys
if (result.keys.length > 0) {
// Use pipeline to batch multiple operations
const pipeline = this.client.multi();
for (const key of result.keys) {
pipeline.hGetAll(key);
}
const batchResults = await pipeline.exec();
// Process results and get ticket counts
for (let i = 0; i < result.keys.length; i++) {
const key = result.keys[i];
const metadata = batchResults[i];
if (metadata && metadata.eventId) {
const eventId = metadata.eventId;
const ticketKey = `event:${eventId}:tickets`;
// Get remaining tickets count efficiently
const remainingTickets = await this.client.lLen(ticketKey);
events.push({
eventId: eventId,
name: metadata.name,
description: metadata.description,
totalTickets: parseInt(metadata.totalTickets),
soldTickets: parseInt(metadata.soldTickets),
remainingTickets: remainingTickets,
createdAt: metadata.createdAt,
lastSoldAt: metadata.lastSoldAt || "never",
});
}
}
}
}
} while (cursor !== 0);
// Sort events by ID for consistent ordering
events.sort((a, b) => parseInt(a.eventId) - parseInt(b.eventId));
return events;
} catch (error) {
logger.error('Error getting all events:', error);
logger.error("Error getting all events:", error);
throw error;
}
}
async getGlobalStats() {
if (!this.isConnected) {
throw new Error('Redis not connected');
throw new Error("Redis not connected");
}
try {
const stats = await this.client.hGetAll('global:stats');
const stats = await this.client.hGetAll("global:stats");
return {
totalEvents: parseInt(stats.totalEvents) || 0,
totalTickets: parseInt(stats.totalTickets) || 0,
totalSold: parseInt(stats.totalSold) || 0,
lastSeeded: stats.lastSeeded || null
lastSeeded: stats.lastSeeded || null,
};
} catch (error) {
logger.error('Error getting global stats:', error);
logger.error("Error getting global stats:", error);
throw error;
}
}
async getRemainingTickets(eventId) {
if (!this.isConnected) {
throw new Error("Redis not connected");
}
try {
const ticketKey = `event:${eventId}:tickets`;
const remainingTickets = await this.client.lRange(ticketKey, 0, -1);
return remainingTickets;
} catch (error) {
logger.error(
`Error getting remaining tickets for event ${eventId}:`,
error
);
return [];
}
}
getClient() {
return this.client;
}
+203
View File
@@ -0,0 +1,203 @@
const rateLimit = require("express-rate-limit");
const helmet = require("helmet");
const { body, param, validationResult } = require("express-validator");
// Rate limiting configuration
const createRateLimiter = (windowMs, max, message) => {
return rateLimit({
windowMs,
max,
message: {
success: false,
message: message || "Too many requests, please try again later.",
retryAfter: Math.ceil(windowMs / 1000),
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
success: false,
message: message || "Too many requests, please try again later.",
retryAfter: Math.ceil(windowMs / 1000),
});
},
});
};
// General API rate limiting (100 requests per 15 minutes)
const generalLimiter = createRateLimiter(
15 * 60 * 1000, // 15 minutes
100,
"API rate limit exceeded. Please try again later."
);
// Purchase endpoint rate limiting (10 requests per minute)
const purchaseLimiter = createRateLimiter(
60 * 1000, // 1 minute
10,
"Too many purchase attempts. Please wait before trying again."
);
// Admin endpoints rate limiting (20 requests per 5 minutes)
const adminLimiter = createRateLimiter(
5 * 60 * 1000, // 5 minutes
20,
"Too many admin requests. Please wait before trying again."
);
// Input validation middleware
const validateEventId = [
param("eventId")
.isInt({ min: 1 })
.withMessage("Event ID must be a positive integer")
.toInt(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Invalid event ID",
errors: errors.array(),
});
}
next();
},
];
const validatePurchaseId = [
param("purchaseId").isUUID(4).withMessage("Purchase ID must be a valid UUID"),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Invalid purchase ID",
errors: errors.array(),
});
}
next();
},
];
const validateCleanupRequest = [
body("maxAgeHours")
.optional()
.isInt({ min: 1, max: 8760 }) // 1 hour to 1 year
.withMessage("Max age must be between 1 and 8760 hours")
.toInt(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Invalid cleanup parameters",
errors: errors.array(),
});
}
next();
},
];
// Security headers middleware
const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
noSniff: true,
xssFilter: true,
frameguard: { action: "deny" },
});
// Request size limiting
const requestSizeLimit = (req, res, next) => {
const contentLength = parseInt(req.headers["content-length"] || "0");
const maxSize = 1024 * 1024; // 1MB
if (contentLength > maxSize) {
return res.status(413).json({
success: false,
message: "Request entity too large. Maximum size is 1MB.",
});
}
next();
};
// CORS configuration
const corsOptions = {
origin: process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",")
: ["http://localhost:3000", "http://localhost:3049"],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 86400, // 24 hours
};
// IP address extraction (for rate limiting)
const getClientIP = (req) => {
return (
req.ip ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.socket?.remoteAddress ||
"unknown"
);
};
// Request logging for security monitoring
const securityLogging = (req, res, next) => {
const clientIP = getClientIP(req);
const userAgent = req.get("User-Agent") || "unknown";
// Log suspicious requests
if (
req.path.includes("admin") ||
req.path.includes("..") ||
req.path.includes("//")
) {
console.warn(
`Security Warning: Suspicious request from ${clientIP} to ${req.path}`,
{
ip: clientIP,
userAgent,
path: req.path,
method: req.method,
timestamp: new Date().toISOString(),
}
);
}
next();
};
module.exports = {
// Rate limiters
generalLimiter,
purchaseLimiter,
adminLimiter,
// Input validation
validateEventId,
validatePurchaseId,
validateCleanupRequest,
// Security middleware
securityHeaders,
requestSizeLimit,
corsOptions,
// Utilities
getClientIP,
securityLogging,
};
+65
View File
@@ -0,0 +1,65 @@
const fallbackStore = require("./src/utils/fallback-store");
async function testFallbackStore() {
console.log("🧪 Testing Fallback Store Functionality\n");
// Test 1: Check initial state
console.log("1. Initial State:");
console.log(` - Active: ${fallbackStore.isActive}`);
console.log(` - Events Count: ${fallbackStore.events.size}`);
console.log(` - Total Tickets: ${fallbackStore.globalStats.totalTickets}`);
console.log(` - Total Sold: ${fallbackStore.globalStats.totalSold}\n`);
// Test 2: Activate fallback store
console.log("2. Activating Fallback Store...");
fallbackStore.activate("Test activation");
console.log(` - Active: ${fallbackStore.isActive}\n`);
// Test 3: Check if seeding is needed
console.log("3. Checking Seeding Status:");
if (fallbackStore.events.size === 0) {
console.log(" - Fallback store is empty, attempting to seed...");
await fallbackStore.attemptReseed();
} else {
console.log(" - Fallback store already has data");
}
console.log(` - Events Count: ${fallbackStore.events.size}\n`);
// Test 4: Test ticket purchase if events exist
if (fallbackStore.events.size > 0) {
console.log("4. Testing Ticket Purchase:");
const eventId = "1";
const purchaseId = "test-purchase-123";
const result = fallbackStore.purchaseTicket(eventId, purchaseId);
if (result.success) {
console.log(` - Purchase successful: ${result.ticket}`);
console.log(` - Sold count: ${result.soldCount}`);
} else {
console.log(` - Purchase failed: ${result.error}`);
}
console.log(
` - Remaining tickets: ${
fallbackStore.getEventStats(eventId)?.remainingTickets || 0
}\n`
);
}
// Test 5: Get status
console.log("5. Fallback Store Status:");
const status = fallbackStore.getStatus();
console.log(` - Active: ${status.active}`);
console.log(` - Events: ${status.eventsCount}`);
console.log(` - Total Tickets: ${status.totalTickets}`);
console.log(` - Total Sold: ${status.totalSold}\n`);
// Test 6: Deactivate
console.log("6. Deactivating Fallback Store...");
fallbackStore.deactivate();
console.log(` - Active: ${fallbackStore.isActive}\n`);
console.log("✅ Fallback Store Test Completed!");
}
// Run the test
testFallbackStore().catch(console.error);
+120
View File
@@ -0,0 +1,120 @@
const axios = require("axios");
const BASE_URL = process.env.TEST_URL || "http://localhost:3049";
async function testSecurityFeatures() {
console.log("🔒 Testing Security Features\n");
try {
// Test 1: Rate Limiting
console.log("1. Testing Rate Limiting...");
const promises = [];
for (let i = 0; i < 15; i++) {
promises.push(axios.get(`${BASE_URL}/health`));
}
try {
await Promise.all(promises);
console.log(" ❌ Rate limiting not working (all requests succeeded)");
} catch (error) {
if (error.response?.status === 429) {
console.log(" ✅ Rate limiting working correctly");
} else {
console.log(" ❌ Unexpected error:", error.message);
}
}
// Test 2: Input Validation - Invalid Event ID
console.log("\n2. Testing Input Validation...");
try {
await axios.get(`${BASE_URL}/events/invalid`);
console.log(" ❌ Invalid event ID accepted");
} catch (error) {
if (error.response?.status === 400) {
console.log(" ✅ Invalid event ID properly rejected");
} else {
console.log(" ❌ Unexpected error:", error.message);
}
}
// Test 3: Input Validation - Invalid Purchase ID
try {
await axios.get(`${BASE_URL}/tickets/invalid-uuid`);
console.log(" ❌ Invalid purchase ID accepted");
} catch (error) {
if (error.response?.status === 400) {
console.log(" ✅ Invalid purchase ID properly rejected");
} else {
console.log(" ❌ Unexpected error:", error.message);
}
}
// Test 4: Security Headers
console.log("\n3. Testing Security Headers...");
try {
const response = await axios.get(`${BASE_URL}/health`);
const headers = response.headers;
const securityHeaders = {
"X-Content-Type-Options": headers["x-content-type-options"],
"X-Frame-Options": headers["x-frame-options"],
"X-XSS-Protection": headers["x-xss-protection"],
"Strict-Transport-Security": headers["strict-transport-security"],
};
console.log(" Security Headers:");
Object.entries(securityHeaders).forEach(([header, value]) => {
if (value) {
console.log(`${header}: ${value}`);
} else {
console.log(`${header}: Missing`);
}
});
} catch (error) {
console.log(" ❌ Error checking security headers:", error.message);
}
// Test 5: Admin Rate Limiting
console.log("\n4. Testing Admin Rate Limiting...");
const adminPromises = [];
for (let i = 0; i < 25; i++) {
adminPromises.push(axios.get(`${BASE_URL}/admin/pdf-stats`));
}
try {
await Promise.all(adminPromises);
console.log(" ❌ Admin rate limiting not working");
} catch (error) {
if (error.response?.status === 429) {
console.log(" ✅ Admin rate limiting working correctly");
} else {
console.log(" ❌ Unexpected error:", error.message);
}
}
// Test 6: Purchase Rate Limiting
console.log("\n5. Testing Purchase Rate Limiting...");
const purchasePromises = [];
for (let i = 0; i < 15; i++) {
purchasePromises.push(axios.post(`${BASE_URL}/buy/1`));
}
try {
await Promise.all(purchasePromises);
console.log(" ❌ Purchase rate limiting not working");
} catch (error) {
if (error.response?.status === 429) {
console.log(" ✅ Purchase rate limiting working correctly");
} else {
console.log(" ❌ Unexpected error:", error.message);
}
}
console.log("\n✅ Security Tests Completed!");
} catch (error) {
console.error("❌ Test failed:", error.message);
}
}
// Run the security tests
testSecurityFeatures();
+374
View File
@@ -0,0 +1,374 @@
const request = require("supertest");
const app = require("../../server");
const redisClient = require("../../src/utils/redis-client");
const fallbackStore = require("../../src/utils/fallback-store");
describe("API Endpoints - Integration Tests", () => {
let server;
let testEventId = "888"; // Use a unique event ID for testing
beforeAll(async () => {
// Start the server
server = app.listen(0); // Use random port
// Wait for server to be ready
await new Promise((resolve) => setTimeout(resolve, 1000));
// Ensure Redis is connected
if (!redisClient.isConnected) {
await redisClient.connect();
}
});
afterAll(async () => {
// Clean up test data
try {
if (redisClient.isConnected) {
const testEventKey = `event:${testEventId}:meta`;
const testTicketsKey = `event:${testEventId}:tickets`;
await redisClient.client.del(testEventKey);
await redisClient.client.del(testTicketsKey);
}
} catch (error) {
console.warn("Failed to cleanup test data:", error.message);
}
// Close server
if (server) {
await new Promise((resolve) => server.close(resolve));
}
// Disconnect Redis
if (redisClient.isConnected) {
await redisClient.disconnect();
}
});
beforeEach(async () => {
// Reset fallback store
fallbackStore.deactivate();
fallbackStore.events.clear();
fallbackStore.globalStats = {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null,
};
// Create test event with 5 tickets in Redis
if (redisClient.isConnected) {
const testEventKey = `event:${testEventId}:meta`;
const testTicketsKey = `event:${testEventId}:tickets`;
// Create event metadata
await redisClient.client.hSet(testEventKey, {
eventId: testEventId,
name: "Test Event for API Testing",
description: "Test event to verify API endpoints",
totalTickets: "5",
soldTickets: "0",
createdAt: new Date().toISOString(),
lastSoldAt: "never",
});
// Create 5 test tickets
const testTickets = Array.from(
{ length: 5 },
(_, i) => `api-test-ticket-${i + 1}`
);
await redisClient.client.lPush(testTicketsKey, testTickets);
}
});
describe("Health Check Endpoint", () => {
test("GET /health should return system status", async () => {
const response = await request(server).get("/health").expect(200);
expect(response.body).toHaveProperty("status", "ok");
expect(response.body).toHaveProperty("timestamp");
expect(response.body).toHaveProperty("redis");
expect(response.body).toHaveProperty("uptime");
expect(response.body.redis).toHaveProperty("connected");
expect(response.body.redis).toHaveProperty("fallbackActive");
});
});
describe("Events Endpoints", () => {
test("GET /events should return all events", async () => {
const response = await request(server).get("/events").expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("events");
expect(response.body).toHaveProperty("usingFallback");
expect(Array.isArray(response.body.events)).toBe(true);
});
test("GET /events/:eventId should return specific event", async () => {
const response = await request(server)
.get(`/events/${testEventId}`)
.expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("event");
expect(response.body.event).toHaveProperty("eventId", testEventId);
expect(response.body.event).toHaveProperty("name");
expect(response.body.event).toHaveProperty("totalTickets", 5);
expect(response.body.event).toHaveProperty("remainingTickets", 5);
});
test("GET /events/:eventId should return 404 for non-existent event", async () => {
const response = await request(server).get("/events/99999").expect(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("message", "Event not found");
});
test("GET /events/:eventId should validate event ID format", async () => {
const response = await request(server).get("/events/invalid").expect(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("message", "Invalid event ID");
});
});
describe("Ticket Purchase Endpoint", () => {
test("POST /buy/:eventId should purchase ticket successfully", async () => {
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("ticket");
expect(response.body).toHaveProperty("purchaseId");
expect(response.body).toHaveProperty("eventId", testEventId);
expect(response.body).toHaveProperty("soldCount", 1);
expect(response.body).toHaveProperty("usingFallback", false);
expect(response.body).toHaveProperty("pdf");
expect(response.body.pdf).toHaveProperty("generated");
});
test("POST /buy/:eventId should fail for non-existent event", async () => {
const response = await request(server)
.post("/buy/99999")
.set("Content-Type", "application/json")
.expect(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("message", "Event not found");
});
test("POST /buy/:eventId should fail when no tickets available", async () => {
// Purchase all available tickets first
for (let i = 0; i < 5; i++) {
await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.expect(200);
}
// Try to purchase one more
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.expect(409);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"message",
"No tickets available for this event"
);
});
test("POST /buy/:eventId should validate event ID format", async () => {
const response = await request(server)
.post("/buy/invalid")
.set("Content-Type", "application/json")
.expect(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("message", "Invalid event ID");
});
});
describe("Ticket Download Endpoint", () => {
let purchaseId;
beforeEach(async () => {
// Purchase a ticket first
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
purchaseId = response.body.purchaseId;
});
test("GET /tickets/:purchaseId should download ticket PDF", async () => {
const response = await request(server)
.get(`/tickets/${purchaseId}`)
.expect(200);
expect(response.headers["content-type"]).toBe("application/pdf");
expect(response.headers["content-disposition"]).toContain(
`filename="ticket-${purchaseId}.pdf"`
);
expect(response.body).toBeDefined();
});
test("GET /tickets/:purchaseId should return 404 for non-existent ticket", async () => {
const response = await request(server)
.get("/tickets/non-existent-uuid")
.expect(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("message", "Ticket not found");
});
test("GET /tickets/:purchaseId should validate purchase ID format", async () => {
const response = await request(server)
.get("/tickets/invalid-uuid")
.expect(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("message", "Invalid purchase ID");
});
});
describe("Admin Endpoints", () => {
test("GET /admin/pdf-stats should return PDF statistics", async () => {
const response = await request(server)
.get("/admin/pdf-stats")
.expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("stats");
expect(response.body.stats).toHaveProperty("totalFiles");
expect(response.body.stats).toHaveProperty("totalSize");
});
test("POST /admin/cleanup-tickets should cleanup old tickets", async () => {
const response = await request(server)
.post("/admin/cleanup-tickets")
.set("Content-Type", "application/json")
.send({ maxAgeHours: 24 })
.expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("message");
expect(response.body).toHaveProperty("deletedCount");
});
test("POST /admin/cleanup-tickets should validate maxAgeHours parameter", async () => {
const response = await request(server)
.post("/admin/cleanup-tickets")
.set("Content-Type", "application/json")
.send({ maxAgeHours: -1 })
.expect(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"message",
"Invalid cleanup parameters"
);
});
test("POST /admin/seed-fallback should seed fallback store", async () => {
const response = await request(server)
.post("/admin/seed-fallback")
.set("Content-Type", "application/json")
.expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("message");
expect(response.body).toHaveProperty("eventsCount");
expect(response.body).toHaveProperty("totalTickets");
expect(response.body).toHaveProperty("totalSold");
});
});
describe("Metrics Endpoint", () => {
test("GET /metrics should return system metrics", async () => {
const response = await request(server).get("/metrics").expect(200);
expect(response.body).toHaveProperty("timestamp");
expect(response.body).toHaveProperty("global");
expect(response.body).toHaveProperty("events");
expect(response.body).toHaveProperty("system");
expect(response.body).toHaveProperty("pdf");
expect(response.body.system).toHaveProperty("usingFallback");
expect(response.body.system).toHaveProperty("redisConnected");
expect(response.body.system).toHaveProperty("uptime");
expect(response.body.system).toHaveProperty("memoryUsage");
});
});
describe("Fallback Mode Operation", () => {
test("should operate in fallback mode when Redis is unavailable", async () => {
// Disconnect Redis to simulate failure
if (redisClient.isConnected) {
await redisClient.disconnect();
}
// Seed fallback store
const metadata = {
eventId: testEventId,
name: "Test Event for Fallback Testing",
description: "Test event in fallback mode",
totalTickets: 3,
soldTickets: 0,
createdAt: new Date().toISOString(),
lastSoldAt: "never",
};
const testTickets = [
"fallback-ticket-1",
"fallback-ticket-2",
"fallback-ticket-3",
];
fallbackStore.seedEvent(testEventId, testTickets, metadata);
fallbackStore.activate("Test fallback mode");
// Test events endpoint in fallback mode
const eventsResponse = await request(server).get("/events").expect(200);
expect(eventsResponse.body.usingFallback).toBe(true);
// Test ticket purchase in fallback mode
const purchaseResponse = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.expect(200);
expect(purchaseResponse.body.success).toBe(true);
expect(purchaseResponse.body.usingFallback).toBe(true);
expect(purchaseResponse.body.ticket).toBeDefined();
// Verify ticket was removed from fallback store
const event = fallbackStore.events.get(testEventId);
expect(event.tickets).toHaveLength(2);
});
});
describe("Error Handling", () => {
test("should handle malformed JSON requests gracefully", async () => {
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.send("invalid json")
.expect(400);
expect(response.body).toHaveProperty("success", false);
});
test("should handle missing required parameters", async () => {
const response = await request(server)
.post("/admin/cleanup-tickets")
.set("Content-Type", "application/json")
.send({})
.expect(200); // Should use default value
expect(response.body).toHaveProperty("success", true);
});
});
});
@@ -0,0 +1,368 @@
const request = require("supertest");
const app = require("../../server");
const redisClient = require("../../src/utils/redis-client");
const fallbackStore = require("../../src/utils/fallback-store");
describe("Duplicate Ticket Prevention - Integration Tests", () => {
let server;
let testEventId = "999"; // Use a unique event ID for testing
beforeAll(async () => {
// Start the server
server = app.listen(0); // Use random port
// Wait for server to be ready
await new Promise((resolve) => setTimeout(resolve, 1000));
// Ensure Redis is connected
if (!redisClient.isConnected) {
await redisClient.connect();
}
});
afterAll(async () => {
// Clean up test data
try {
if (redisClient.isConnected) {
const testEventKey = `event:${testEventId}:meta`;
const testTicketsKey = `event:${testEventId}:tickets`;
await redisClient.client.del(testEventKey);
await redisClient.client.del(testTicketsKey);
}
} catch (error) {
console.warn("Failed to cleanup test data:", error.message);
}
// Close server
if (server) {
await new Promise((resolve) => server.close(resolve));
}
// Disconnect Redis
if (redisClient.isConnected) {
await redisClient.disconnect();
}
});
beforeEach(async () => {
// Reset fallback store
fallbackStore.deactivate();
fallbackStore.events.clear();
fallbackStore.globalStats = {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null,
};
// Create test event with 10 tickets in Redis
if (redisClient.isConnected) {
const testEventKey = `event:${testEventId}:meta`;
const testTicketsKey = `event:${testEventId}:tickets`;
// Create event metadata
await redisClient.client.hSet(testEventKey, {
eventId: testEventId,
name: "Test Event for Duplicate Prevention",
description: "Test event to verify no duplicate tickets",
totalTickets: "10",
soldTickets: "0",
createdAt: new Date().toISOString(),
lastSoldAt: "never",
});
// Create 10 test tickets
const testTickets = Array.from(
{ length: 10 },
(_, i) => `test-ticket-${i + 1}`
);
await redisClient.client.lPush(testTicketsKey, testTickets);
// Update global stats
await redisClient.client.hSet("global:stats", {
totalEvents: "1",
totalTickets: "10",
totalSold: "0",
lastSeeded: new Date().toISOString(),
});
}
});
describe("Redis Ticket Purchase - Duplicate Prevention", () => {
test("should prevent duplicate ticket sales under normal conditions", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
const purchaseIds = [];
const soldTickets = new Set();
let successCount = 0;
let failureCount = 0;
// Attempt to purchase 15 tickets (more than available)
for (let i = 0; i < 15; i++) {
const purchaseId = `test-purchase-${Date.now()}-${i}`;
purchaseIds.push(purchaseId);
try {
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
if (response.status === 200 && response.body.success) {
successCount++;
const ticket = response.body.ticket;
// Verify ticket is unique
expect(soldTickets.has(ticket)).toBe(false);
soldTickets.add(ticket);
expect(response.body.usingFallback).toBe(false);
} else {
failureCount++;
expect(response.status).toBe(409); // No tickets available
expect(response.body.message).toContain("No tickets available");
}
} catch (error) {
failureCount++;
}
}
// Should have sold exactly 10 tickets (no duplicates)
expect(successCount).toBe(10);
expect(failureCount).toBe(5);
expect(soldTickets.size).toBe(10);
// Verify no tickets remain
const remainingTickets = await redisClient.client.lLen(
`event:${testEventId}:tickets`
);
expect(remainingTickets).toBe(0);
});
test("should prevent duplicate tickets under concurrent load", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
// Reset test event with 5 tickets
const testTicketsKey = `event:${testEventId}:tickets`;
await redisClient.client.del(testTicketsKey);
const testTickets = Array.from(
{ length: 5 },
(_, i) => `concurrent-ticket-${i + 1}`
);
await redisClient.client.lPush(testTicketsKey, testTickets);
const soldTickets = new Set();
const purchasePromises = [];
// Create 10 concurrent purchase requests
for (let i = 0; i < 10; i++) {
const purchaseId = `concurrent-purchase-${Date.now()}-${i}`;
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.then((response) => ({ success: true, response, purchaseId }))
.catch((error) => ({ success: false, error, purchaseId }));
purchasePromises.push(promise);
}
// Wait for all requests to complete
const results = await Promise.all(purchasePromises);
let successCount = 0;
let failureCount = 0;
results.forEach((result) => {
if (
result.success &&
result.response.status === 200 &&
result.response.body.success
) {
successCount++;
const ticket = result.response.body.ticket;
// Verify ticket is unique
expect(soldTickets.has(ticket)).toBe(false);
soldTickets.add(ticket);
} else {
failureCount++;
}
});
// Should have sold exactly 5 tickets (no duplicates)
expect(successCount).toBe(5);
expect(failureCount).toBe(5);
expect(soldTickets.size).toBe(5);
// Verify no tickets remain
const remainingTickets = await redisClient.client.lLen(testTicketsKey);
expect(remainingTickets).toBe(0);
});
});
describe("Fallback Store - Duplicate Prevention", () => {
test("should prevent duplicate tickets in fallback mode", async () => {
// Seed fallback store with test event
const metadata = {
eventId: testEventId,
name: "Test Event for Duplicate Prevention",
description: "Test event to verify no duplicate tickets",
totalTickets: 5,
soldTickets: 0,
createdAt: new Date().toISOString(),
lastSoldAt: "never",
};
const testTickets = Array.from(
{ length: 5 },
(_, i) => `fallback-ticket-${i + 1}`
);
fallbackStore.seedEvent(testEventId, testTickets, metadata);
fallbackStore.activate("Test activation");
const soldTickets = new Set();
let successCount = 0;
let failureCount = 0;
// Attempt to purchase 8 tickets (more than available)
for (let i = 0; i < 8; i++) {
const purchaseId = `fallback-purchase-${Date.now()}-${i}`;
try {
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
if (response.status === 200 && response.body.success) {
successCount++;
const ticket = response.body.ticket;
// Verify ticket is unique
expect(soldTickets.has(ticket)).toBe(false);
soldTickets.add(ticket);
expect(response.body.usingFallback).toBe(true);
} else {
failureCount++;
expect(response.status).toBe(409); // No tickets available
expect(response.body.message).toContain("No tickets available");
}
} catch (error) {
failureCount++;
}
}
// Should have sold exactly 5 tickets (no duplicates)
expect(successCount).toBe(5);
expect(failureCount).toBe(3);
expect(soldTickets.size).toBe(5);
// Verify no tickets remain in fallback store
const event = fallbackStore.events.get(testEventId);
expect(event.tickets).toHaveLength(0);
});
});
describe("Mixed Mode - Redis + Fallback", () => {
test("should prevent duplicates when switching between Redis and fallback", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
// Start with Redis
const soldTickets = new Set();
// Purchase 3 tickets from Redis
for (let i = 0; i < 3; i++) {
const purchaseId = `mixed-purchase-redis-${Date.now()}-${i}`;
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.usingFallback).toBe(false);
const ticket = response.body.ticket;
expect(soldTickets.has(ticket)).toBe(false);
soldTickets.add(ticket);
}
// Simulate Redis failure by disconnecting
await redisClient.disconnect();
// Purchase remaining tickets from fallback
for (let i = 0; i < 7; i++) {
const purchaseId = `mixed-purchase-fallback-${Date.now()}-${i}`;
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.usingFallback).toBe(true);
const ticket = response.body.ticket;
expect(soldTickets.has(ticket)).toBe(false);
soldTickets.add(ticket);
}
// Verify total unique tickets sold
expect(soldTickets.size).toBe(10);
// Try to purchase one more (should fail)
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
expect(response.status).toBe(409);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain("No tickets available");
});
});
describe("Data Integrity Verification", () => {
test("should maintain consistent ticket counts across Redis and fallback", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
// Purchase 3 tickets
for (let i = 0; i < 3; i++) {
const purchaseId = `integrity-purchase-${Date.now()}-${i}`;
const response = await request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
}
// Check Redis stats
const redisStats = await redisClient.getEventStats(testEventId);
expect(redisStats.remainingTickets).toBe(7);
expect(redisStats.soldTickets).toBe(3);
// Check fallback store stats (should be synced)
fallbackStore.activate("Test activation");
const fallbackStats = fallbackStore.getEventStats(testEventId);
expect(fallbackStats.remainingTickets).toBe(7);
expect(fallbackStats.soldTickets).toBe(3);
// Verify global stats
const globalStats = await redisClient.getGlobalStats();
expect(globalStats.totalSold).toBe(3);
});
});
});
+135 -107
View File
@@ -1,9 +1,9 @@
const autocannon = require('autocannon');
const { performance } = require('perf_hooks');
const autocannon = require("autocannon");
const { performance } = require("perf_hooks");
class LoadTester {
constructor() {
this.baseUrl = process.env.TEST_URL || 'http://localhost:3049';
this.baseUrl = process.env.TEST_URL || "http://localhost:3049";
this.results = [];
}
@@ -12,49 +12,53 @@ class LoadTester {
url: `${this.baseUrl}/buy/${eventId}`,
connections: 5000,
duration: 30,
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify({}),
...options
...options,
};
console.log(`\n🚀 Starting load test for Event ${eventId}`);
console.log(`📊 Connections: ${defaultOptions.connections}`);
console.log(`⏱️ Duration: ${defaultOptions.duration}s`);
console.log(`🎯 Target: ${defaultOptions.url}\n`);
console.log(`\n Starting load test for Event ${eventId}`);
console.log(`Connections: ${defaultOptions.connections}`);
console.log(`Duration: ${defaultOptions.duration}s`);
console.log(`Target: ${defaultOptions.url}\n`);
const startTime = performance.now();
try {
const result = await autocannon(defaultOptions);
const endTime = performance.now();
const testResult = {
eventId,
timestamp: new Date().toISOString(),
duration: endTime - startTime,
...result
...result,
};
this.results.push(testResult);
this.printResults(testResult);
return testResult;
} catch (error) {
console.error('❌ Load test failed:', error);
console.error("❌ Load test failed:", error);
throw error;
}
}
async runMultiEventLoadTest(eventIds = [1, 2, 3], options = {}) {
console.log(`\n🎯 Running multi-event load test for events: ${eventIds.join(', ')}`);
const promises = eventIds.map(eventId =>
console.log(
`\n Running multi-event load test for events: ${eventIds.join(", ")}`
);
const promises = eventIds.map((eventId) =>
this.runPurchaseLoadTest(eventId, {
connections: Math.floor((options.connections || 5000) / eventIds.length),
duration: options.duration || 30
connections: Math.floor(
(options.connections || 5000) / eventIds.length
),
duration: options.duration || 30,
})
);
@@ -63,7 +67,7 @@ class LoadTester {
this.printSummary(results);
return results;
} catch (error) {
console.error('❌ Multi-event load test failed:', error);
console.error("❌ Multi-event load test failed:", error);
throw error;
}
}
@@ -73,20 +77,20 @@ class LoadTester {
url: `${this.baseUrl}/health`,
connections: 100,
duration: 10,
...options
...options,
};
console.log('\n🏥 Running health check load test...');
console.log("\n Running health check load test...");
try {
const result = await autocannon(defaultOptions);
console.log(`✅ Health check test completed`);
console.log(`📈 RPS: ${result.requests.average}`);
console.log(`Latency: ${result.latency.average}ms`);
console.log(`RPS: ${result.requests.average}`);
console.log(`Latency: ${result.latency.average}ms`);
return result;
} catch (error) {
console.error('❌ Health check test failed:', error);
console.error("❌ Health check test failed:", error);
throw error;
}
}
@@ -96,94 +100,111 @@ class LoadTester {
url: `${this.baseUrl}/metrics`,
connections: 50,
duration: 10,
...options
...options,
};
console.log('\n📊 Running metrics endpoint test...');
console.log("\nRunning metrics endpoint test...");
try {
const result = await autocannon(defaultOptions);
console.log(`✅ Metrics test completed`);
console.log(`📈 RPS: ${result.requests.average}`);
console.log(`Latency: ${result.latency.average}ms`);
console.log(`RPS: ${result.requests.average}`);
console.log(`Latency: ${result.latency.average}ms`);
return result;
} catch (error) {
console.error('❌ Metrics test failed:', error);
console.error("Metrics test failed:", error);
throw error;
}
}
printResults(result) {
console.log('📋 LOAD TEST RESULTS');
console.log('═'.repeat(50));
console.log(`🎯 Event ID: ${result.eventId}`);
console.log(`⏱️ Duration: ${(result.duration / 1000).toFixed(2)}s`);
console.log(`📊 Total Requests: ${result.requests.total}`);
console.log(`📈 Requests/sec: ${result.requests.average.toFixed(2)}`);
console.log(`Avg Latency: ${result.latency.average.toFixed(2)}ms`);
console.log(`🔥 Max Latency: ${result.latency.max}ms`);
console.log(`✅ Success Rate: ${((result.requests.total - result.non2xx) / result.requests.total * 100).toFixed(2)}%`);
console.log("LOAD TEST RESULTS");
console.log("═".repeat(50));
console.log(`Event ID: ${result.eventId}`);
console.log(`Duration: ${(result.duration / 1000).toFixed(2)}s`);
console.log(`Total Requests: ${result.requests.total}`);
console.log(`Requests/sec: ${result.requests.average.toFixed(2)}`);
console.log(`Avg Latency: ${result.latency.average.toFixed(2)}ms`);
console.log(`Max Latency: ${result.latency.max}ms`);
console.log(
`✅ Success Rate: ${(
((result.requests.total - result.non2xx) / result.requests.total) *
100
).toFixed(2)}%`
);
console.log(`❌ Errors: ${result.non2xx}`);
console.log(`🔗 Connections: ${result.connections}`);
console.log(`📦 Throughput: ${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`);
console.log('═'.repeat(50));
console.log(`Connections: ${result.connections}`);
console.log(
`Throughput: ${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`
);
console.log("═".repeat(50));
}
printSummary(results) {
console.log('\n📊 MULTI-EVENT TEST SUMMARY');
console.log('═'.repeat(60));
console.log("\nMULTI-EVENT TEST SUMMARY");
console.log("═".repeat(60));
const totalRequests = results.reduce((sum, r) => sum + r.requests.total, 0);
const avgRPS = results.reduce((sum, r) => sum + r.requests.average, 0);
const avgLatency = results.reduce((sum, r) => sum + r.latency.average, 0) / results.length;
const avgLatency =
results.reduce((sum, r) => sum + r.latency.average, 0) / results.length;
const totalErrors = results.reduce((sum, r) => sum + r.non2xx, 0);
console.log(`🎯 Events Tested: ${results.length}`);
console.log(`📊 Total Requests: ${totalRequests}`);
console.log(`📈 Combined RPS: ${avgRPS.toFixed(2)}`);
console.log(`Avg Latency: ${avgLatency.toFixed(2)}ms`);
console.log(`✅ Overall Success Rate: ${((totalRequests - totalErrors) / totalRequests * 100).toFixed(2)}%`);
console.log(`Events Tested: ${results.length}`);
console.log(`Total Requests: ${totalRequests}`);
console.log(`Combined RPS: ${avgRPS.toFixed(2)}`);
console.log(`Avg Latency: ${avgLatency.toFixed(2)}ms`);
console.log(
`✅ Overall Success Rate: ${(
((totalRequests - totalErrors) / totalRequests) *
100
).toFixed(2)}%`
);
console.log(`❌ Total Errors: ${totalErrors}`);
console.log('═'.repeat(60));
console.log("═".repeat(60));
// Individual event breakdown
results.forEach((result, index) => {
console.log(`\n📋 Event ${result.eventId}:`);
console.log(` 📈 RPS: ${result.requests.average.toFixed(2)}`);
console.log(` Latency: ${result.latency.average.toFixed(2)}ms`);
console.log(` ✅ Success: ${((result.requests.total - result.non2xx) / result.requests.total * 100).toFixed(2)}%`);
console.log(`\nEvent ${result.eventId}:`);
console.log(` RPS: ${result.requests.average.toFixed(2)}`);
console.log(` Latency: ${result.latency.average.toFixed(2)}ms`);
console.log(
` ✅ Success: ${(
((result.requests.total - result.non2xx) / result.requests.total) *
100
).toFixed(2)}%`
);
});
}
async runFullTestSuite() {
console.log('🚀 STARTING FULL LOAD TEST SUITE');
console.log('═'.repeat(60));
console.log("STARTING FULL LOAD TEST SUITE");
console.log("═".repeat(60));
try {
// 1. Health check test
await this.runHealthCheckTest();
// 2. Metrics test
await this.runMetricsTest();
// 3. Single event high-load test
await this.runPurchaseLoadTest(1, {
connections: 5000,
duration: 30
await this.runPurchaseLoadTest(1, {
connections: 5000,
duration: 30,
});
// 4. Multi-event test
await this.runMultiEventLoadTest([1, 2, 3], {
connections: 6000,
duration: 30
duration: 30,
});
console.log('\n🎉 FULL TEST SUITE COMPLETED!');
console.log(`📊 Total tests run: ${this.results.length + 2}`);
console.log("\n FULL TEST SUITE COMPLETED!");
console.log(` Total tests run: ${this.results.length + 2}`);
} catch (error) {
console.error('❌ Test suite failed:', error);
console.error("❌ Test suite failed:", error);
process.exit(1);
}
}
@@ -192,14 +213,14 @@ class LoadTester {
return this.results;
}
exportResults(filename = 'load-test-results.json') {
const fs = require('fs');
exportResults(filename = "load-test-results.json") {
const fs = require("fs");
const data = {
timestamp: new Date().toISOString(),
testSuite: 'Ticket Microservice Load Test',
results: this.results
testSuite: "Ticket Microservice Load Test",
results: this.results,
};
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
console.log(`📄 Results exported to: ${filename}`);
}
@@ -209,46 +230,53 @@ class LoadTester {
if (require.main === module) {
const args = process.argv.slice(2);
const tester = new LoadTester();
async function main() {
try {
if (args.includes('--full')) {
if (args.includes("--full")) {
await tester.runFullTestSuite();
} else if (args.includes('--event')) {
const eventId = parseInt(args[args.indexOf('--event') + 1]) || 1;
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 5000;
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
} else if (args.includes("--event")) {
const eventId = parseInt(args[args.indexOf("--event") + 1]) || 1;
const connections =
parseInt(args[args.indexOf("--connections") + 1]) || 5000;
const duration = parseInt(args[args.indexOf("--duration") + 1]) || 30;
await tester.runPurchaseLoadTest(eventId, { connections, duration });
} else if (args.includes('--multi')) {
const eventIds = args.includes('--events')
? args[args.indexOf('--events') + 1].split(',').map(Number)
} else if (args.includes("--multi")) {
const eventIds = args.includes("--events")
? args[args.indexOf("--events") + 1].split(",").map(Number)
: [1, 2, 3];
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 6000;
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
const connections =
parseInt(args[args.indexOf("--connections") + 1]) || 6000;
const duration = parseInt(args[args.indexOf("--duration") + 1]) || 30;
await tester.runMultiEventLoadTest(eventIds, { connections, duration });
} else {
console.log('🎯 Ticket Microservice Load Tester');
console.log('Usage:');
console.log(' node tests/load-test.js --full # Run full test suite');
console.log(' node tests/load-test.js --event 1 --connections 5000 --duration 30');
console.log(' node tests/load-test.js --multi --events 1,2,3 --connections 6000');
console.log('');
console.log('Running default single event test...');
console.log("Ticket Microservice Load Tester");
console.log("Usage:");
console.log(
" node tests/load-test.js --full # Run full test suite"
);
console.log(
" node tests/load-test.js --event 1 --connections 5000 --duration 30"
);
console.log(
" node tests/load-test.js --multi --events 1,2,3 --connections 6000"
);
console.log("");
console.log("Running default single event test...");
await tester.runPurchaseLoadTest(1);
}
if (args.includes('--export')) {
if (args.includes("--export")) {
tester.exportResults();
}
} catch (error) {
console.error('Test execution failed:', error);
console.error("Test execution failed:", error);
process.exit(1);
}
}
main();
}
+416
View File
@@ -0,0 +1,416 @@
const request = require("supertest");
const app = require("../../server");
const redisClient = require("../../src/utils/redis-client");
const fallbackStore = require("../../src/utils/fallback-store");
describe("Performance and Load Testing", () => {
let server;
let testEventId = "777"; // Use a unique event ID for testing
const CONCURRENT_REQUESTS = 100; // Test with 100 concurrent requests
const TICKET_COUNT = 50; // Start with 50 tickets
beforeAll(async () => {
// Start the server
server = app.listen(0); // Use random port
// Wait for server to be ready
await new Promise((resolve) => setTimeout(resolve, 1000));
// Ensure Redis is connected
if (!redisClient.isConnected) {
await redisClient.connect();
}
});
afterAll(async () => {
// Clean up test data
try {
if (redisClient.isConnected) {
const testEventKey = `event:${testEventId}:meta`;
const testTicketsKey = `event:${testEventId}:tickets`;
await redisClient.client.del(testEventKey);
await redisClient.client.del(testTicketsKey);
}
} catch (error) {
console.warn("Failed to cleanup test data:", error.message);
}
// Close server
if (server) {
await new Promise((resolve) => server.close(resolve));
}
// Disconnect Redis
if (redisClient.isConnected) {
await redisClient.disconnect();
}
});
beforeEach(async () => {
// Reset fallback store
fallbackStore.deactivate();
fallbackStore.events.clear();
fallbackStore.globalStats = {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null,
};
// Create test event with tickets in Redis
if (redisClient.isConnected) {
const testEventKey = `event:${testEventId}:meta`;
const testTicketsKey = `event:${testEventId}:tickets`;
// Create event metadata
await redisClient.client.hSet(testEventKey, {
eventId: testEventId,
name: "Performance Test Event",
description: "Test event for load testing",
totalTickets: TICKET_COUNT.toString(),
soldTickets: "0",
createdAt: new Date().toISOString(),
lastSoldAt: "never",
});
// Create test tickets
const testTickets = Array.from(
{ length: TICKET_COUNT },
(_, i) => `perf-ticket-${i + 1}`
);
await redisClient.client.lPush(testTicketsKey, testTickets);
}
});
describe("High Concurrency Ticket Purchase", () => {
test("should handle 100 concurrent requests without duplicate tickets", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
const soldTickets = new Set();
const purchasePromises = [];
const startTime = Date.now();
// Create concurrent purchase requests
for (let i = 0; i < CONCURRENT_REQUESTS; i++) {
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.then((response) => ({ success: true, response, index: i }))
.catch((error) => ({ success: false, error, index: i }));
purchasePromises.push(promise);
}
// Wait for all requests to complete
const results = await Promise.all(purchasePromises);
const endTime = Date.now();
const totalTime = endTime - startTime;
let successCount = 0;
let failureCount = 0;
let duplicateCount = 0;
results.forEach((result) => {
if (
result.success &&
result.response.status === 200 &&
result.response.body.success
) {
successCount++;
const ticket = result.response.body.ticket;
// Check for duplicates
if (soldTickets.has(ticket)) {
duplicateCount++;
} else {
soldTickets.add(ticket);
}
} else {
failureCount++;
}
});
// Performance metrics
const avgResponseTime = totalTime / CONCURRENT_REQUESTS;
const requestsPerSecond = (CONCURRENT_REQUESTS / totalTime) * 1000;
console.log(`\n📊 Performance Test Results:`);
console.log(` Total Time: ${totalTime}ms`);
console.log(` Average Response Time: ${avgResponseTime.toFixed(2)}ms`);
console.log(` Requests per Second: ${requestsPerSecond.toFixed(2)}`);
console.log(` Success Count: ${successCount}`);
console.log(` Failure Count: ${failureCount}`);
console.log(` Duplicate Tickets: ${duplicateCount}`);
// Critical assertions
expect(duplicateCount).toBe(0); // No duplicate tickets allowed
expect(successCount).toBe(TICKET_COUNT); // Should sell exactly available tickets
expect(failureCount).toBe(CONCURRENT_REQUESTS - TICKET_COUNT); // Remaining should fail
expect(soldTickets.size).toBe(TICKET_COUNT); // All sold tickets should be unique
// Performance requirements
expect(avgResponseTime).toBeLessThan(1000); // Should respond within 1 second on average
expect(requestsPerSecond).toBeGreaterThan(10); // Should handle at least 10 RPS
});
test("should maintain data consistency under high load", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
// Purchase 20 tickets under load
const purchasePromises = [];
for (let i = 0; i < 20; i++) {
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
purchasePromises.push(promise);
}
await Promise.all(purchasePromises);
// Verify Redis consistency
const remainingTickets = await redisClient.client.lLen(
`event:${testEventId}:tickets`
);
const eventStats = await redisClient.getEventStats(testEventId);
expect(remainingTickets).toBe(TICKET_COUNT - 20);
expect(eventStats.soldTickets).toBe(20);
expect(eventStats.remainingTickets).toBe(TICKET_COUNT - 20);
// Verify no tickets remain after exhausting supply
const finalPurchasePromises = [];
for (let i = 0; i < TICKET_COUNT; i++) {
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
finalPurchasePromises.push(promise);
}
const finalResults = await Promise.all(finalPurchasePromises);
const finalSuccessCount = finalResults.filter(
(r) =>
r.response && r.response.status === 200 && r.response.body.success
).length;
expect(finalSuccessCount).toBe(TICKET_COUNT - 20); // Should only sell remaining tickets
});
});
describe("Fallback Store Performance", () => {
test("should handle high concurrency in fallback mode", async () => {
// Disconnect Redis to simulate failure
if (redisClient.isConnected) {
await redisClient.disconnect();
}
// Seed fallback store with test event
const metadata = {
eventId: testEventId,
name: "Fallback Performance Test Event",
description: "Test event for fallback performance",
totalTickets: 30,
soldTickets: 0,
createdAt: new Date().toISOString(),
lastSoldAt: "never",
};
const testTickets = Array.from(
{ length: 30 },
(_, i) => `fallback-perf-ticket-${i + 1}`
);
fallbackStore.seedEvent(testEventId, testTickets, metadata);
fallbackStore.activate("Performance test fallback mode");
const soldTickets = new Set();
const purchasePromises = [];
const startTime = Date.now();
// Create 50 concurrent purchase requests (more than available)
for (let i = 0; i < 50; i++) {
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json")
.then((response) => ({ success: true, response, index: i }))
.catch((error) => ({ success: false, error, index: i }));
purchasePromises.push(promise);
}
const results = await Promise.all(purchasePromises);
const endTime = Date.now();
const totalTime = endTime - startTime;
let successCount = 0;
let failureCount = 0;
let duplicateCount = 0;
results.forEach((result) => {
if (
result.success &&
result.response.status === 200 &&
result.response.body.success
) {
successCount++;
const ticket = result.response.body.ticket;
if (soldTickets.has(ticket)) {
duplicateCount++;
} else {
soldTickets.add(ticket);
}
} else {
failureCount++;
}
});
console.log(`\n📊 Fallback Performance Test Results:`);
console.log(` Total Time: ${totalTime}ms`);
console.log(` Success Count: ${successCount}`);
console.log(` Failure Count: ${failureCount}`);
console.log(` Duplicate Tickets: ${duplicateCount}`);
// Critical assertions for fallback mode
expect(duplicateCount).toBe(0); // No duplicate tickets
expect(successCount).toBe(30); // Should sell exactly available tickets
expect(failureCount).toBe(20); // Remaining should fail
expect(soldTickets.size).toBe(30); // All sold tickets should be unique
// Verify fallback store consistency
const event = fallbackStore.events.get(testEventId);
expect(event.tickets).toHaveLength(0); // No tickets should remain
expect(event.soldTickets).toBe(30); // All tickets should be marked as sold
});
});
describe("Memory and Resource Usage", () => {
test("should maintain stable memory usage under load", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
const initialMemory = process.memoryUsage();
console.log(`\n💾 Initial Memory Usage:`);
console.log(` RSS: ${(initialMemory.rss / 1024 / 1024).toFixed(2)} MB`);
console.log(
` Heap Used: ${(initialMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`
);
// Perform multiple rounds of purchases
for (let round = 0; round < 3; round++) {
// Reset test event
const testTicketsKey = `event:${testEventId}:tickets`;
await redisClient.client.del(testTicketsKey);
const testTickets = Array.from(
{ length: 20 },
(_, i) => `round-${round}-ticket-${i + 1}`
);
await redisClient.client.lPush(testTicketsKey, testTickets);
// Purchase all tickets
const purchasePromises = [];
for (let i = 0; i < 20; i++) {
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
purchasePromises.push(promise);
}
await Promise.all(purchasePromises);
}
const finalMemory = process.memoryUsage();
console.log(`\n💾 Final Memory Usage:`);
console.log(` RSS: ${(finalMemory.rss / 1024 / 1024).toFixed(2)} MB`);
console.log(
` Heap Used: ${(finalMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`
);
const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
const memoryIncreaseMB = memoryIncrease / 1024 / 1024;
console.log(`\n📈 Memory Change: ${memoryIncreaseMB.toFixed(2)} MB`);
// Memory should not increase excessively (less than 50MB increase)
expect(memoryIncreaseMB).toBeLessThan(50);
});
});
describe("Response Time Consistency", () => {
test("should maintain consistent response times under varying load", async () => {
if (!redisClient.isConnected) {
console.warn("Skipping Redis test - Redis not connected");
return;
}
const responseTimes = [];
const loadLevels = [10, 25, 50, 75, 100];
for (const loadLevel of loadLevels) {
// Reset test event
const testTicketsKey = `event:${testEventId}:tickets`;
await redisClient.client.del(testTicketsKey);
const testTickets = Array.from(
{ length: loadLevel },
(_, i) => `load-${loadLevel}-ticket-${i + 1}`
);
await redisClient.client.lPush(testTicketsKey, testTickets);
const startTime = Date.now();
const purchasePromises = [];
for (let i = 0; i < loadLevel; i++) {
const promise = request(server)
.post(`/buy/${testEventId}`)
.set("Content-Type", "application/json");
purchasePromises.push(promise);
}
await Promise.all(purchasePromises);
const endTime = Date.now();
const responseTime = endTime - startTime;
responseTimes.push({
loadLevel,
responseTime,
avgResponseTime: responseTime / loadLevel,
});
console.log(`\n⚡ Load Level ${loadLevel}:`);
console.log(` Total Time: ${responseTime}ms`);
console.log(
` Average Response: ${(responseTime / loadLevel).toFixed(2)}ms`
);
}
// Calculate consistency metrics
const avgResponseTimes = responseTimes.map((r) => r.avgResponseTime);
const minResponseTime = Math.min(...avgResponseTimes);
const maxResponseTime = Math.max(...avgResponseTimes);
const responseTimeVariance = maxResponseTime - minResponseTime;
console.log(`\n📊 Response Time Consistency:`);
console.log(` Min Avg Response: ${minResponseTime.toFixed(2)}ms`);
console.log(` Max Avg Response: ${maxResponseTime.toFixed(2)}ms`);
console.log(` Variance: ${responseTimeVariance.toFixed(2)}ms`);
// Response times should be reasonably consistent (variance < 200ms)
expect(responseTimeVariance).toBeLessThan(200);
// All response times should be reasonable (< 2 seconds average)
avgResponseTimes.forEach((time) => {
expect(time).toBeLessThan(2000);
});
});
});
});
+48
View File
@@ -0,0 +1,48 @@
// Test setup and configuration
process.env.NODE_ENV = "test";
process.env.PORT = "0"; // Use random port for tests
process.env.REDIS_URL = "redis://localhost:6379";
process.env.LOG_LEVEL = "error"; // Reduce log noise during tests
process.env.PDF_OUTPUT_DIR = "test-tickets";
// Global test utilities
global.testUtils = {
// Generate unique test IDs
generateTestId: () =>
`test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
// Wait for a specified time
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
// Generate test event data
createTestEvent: (eventId = 1, ticketCount = 100) => ({
eventId: eventId.toString(),
name: `Test Event ${eventId}`,
description: `Test event description ${eventId}`,
totalTickets: ticketCount,
soldTickets: 0,
remainingTickets: ticketCount,
createdAt: new Date().toISOString(),
lastSoldAt: "never",
}),
// Generate test ticket data
createTestTicket: (eventId = 1, ticketId = "test-ticket-1") => ({
ticketId,
eventId: eventId.toString(),
purchaseId: `test-purchase-${Date.now()}`,
timestamp: new Date().toISOString(),
}),
};
// Mock console methods to reduce noise during tests
global.console = {
...console,
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
// Increase timeout for integration tests
jest.setTimeout(30000);
+240
View File
@@ -0,0 +1,240 @@
const fallbackStore = require("../../src/utils/fallback-store");
describe("Fallback Store", () => {
beforeEach(() => {
// Reset fallback store state before each test
fallbackStore.deactivate();
fallbackStore.events.clear();
fallbackStore.globalStats = {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null,
};
});
describe("Initialization", () => {
test("should start in inactive state", () => {
expect(fallbackStore.isActive).toBe(false);
expect(fallbackStore.events.size).toBe(0);
});
test("should have empty global stats", () => {
const stats = fallbackStore.getGlobalStats();
expect(stats.totalEvents).toBe(0);
expect(stats.totalTickets).toBe(0);
expect(stats.totalSold).toBe(0);
});
});
describe("Activation/Deactivation", () => {
test("should activate and deactivate correctly", () => {
fallbackStore.activate("Test activation");
expect(fallbackStore.isActive).toBe(true);
expect(fallbackStore.activationReason).toBe("Test activation");
fallbackStore.deactivate();
expect(fallbackStore.isActive).toBe(false);
expect(fallbackStore.activationReason).toBe(null);
});
test("should log activation and deactivation", () => {
const consoleSpy = jest.spyOn(console, "warn");
fallbackStore.activate("Test");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Fallback store activated")
);
fallbackStore.deactivate();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Fallback store deactivated")
);
});
});
describe("Event Seeding", () => {
test("should seed events correctly", () => {
const eventId = "1";
const tickets = ["ticket1", "ticket2", "ticket3"];
const metadata = {
eventId: "1",
name: "Test Event",
description: "Test Description",
totalTickets: 3,
soldTickets: 0,
createdAt: "2024-01-01T00:00:00.000Z",
lastSoldAt: "never",
};
fallbackStore.seedEvent(eventId, tickets, metadata);
expect(fallbackStore.events.has(eventId)).toBe(true);
const event = fallbackStore.events.get(eventId);
expect(event.tickets).toEqual(tickets);
expect(event.metadata).toEqual(metadata);
expect(fallbackStore.globalStats.totalEvents).toBe(1);
expect(fallbackStore.globalStats.totalTickets).toBe(3);
});
test("should update global stats when seeding multiple events", () => {
const event1 = { eventId: "1", totalTickets: 5 };
const event2 = { eventId: "2", totalTickets: 3 };
fallbackStore.seedEvent("1", ["t1", "t2", "t3", "t4", "t5"], event1);
fallbackStore.seedEvent("2", ["t6", "t7", "t8"], event2);
expect(fallbackStore.globalStats.totalEvents).toBe(2);
expect(fallbackStore.globalStats.totalTickets).toBe(8);
});
});
describe("Ticket Purchase", () => {
beforeEach(() => {
// Seed a test event
const metadata = {
eventId: "1",
name: "Test Event",
description: "Test Description",
totalTickets: 3,
soldTickets: 0,
createdAt: "2024-01-01T00:00:00.000Z",
lastSoldAt: "never",
};
fallbackStore.seedEvent("1", ["ticket1", "ticket2", "ticket3"], metadata);
});
test("should purchase ticket successfully", () => {
const purchaseId = "test-purchase-123";
const result = fallbackStore.purchaseTicket("1", purchaseId);
expect(result.success).toBe(true);
expect(result.ticket).toBeDefined();
expect(result.soldCount).toBe(1);
expect(result.remainingTickets).toBe(2);
// Verify ticket was removed
const event = fallbackStore.events.get("1");
expect(event.tickets).toHaveLength(2);
expect(event.tickets).not.toContain(result.ticket);
});
test("should prevent duplicate ticket sales", () => {
const purchaseId1 = "test-purchase-1";
const purchaseId2 = "test-purchase-2";
// First purchase should succeed
const result1 = fallbackStore.purchaseTicket("1", purchaseId1);
expect(result1.success).toBe(true);
// Second purchase should succeed (different purchase ID)
const result2 = fallbackStore.purchaseTicket("1", purchaseId2);
expect(result2.success).toBe(true);
// Verify different tickets were sold
expect(result1.ticket).not.toBe(result2.ticket);
expect(result1.ticket).toBeDefined();
expect(result2.ticket).toBeDefined();
});
test("should fail when no tickets available", () => {
// Purchase all available tickets
fallbackStore.purchaseTicket("1", "purchase1");
fallbackStore.purchaseTicket("1", "purchase2");
fallbackStore.purchaseTicket("1", "purchase3");
// Try to purchase when no tickets left
const result = fallbackStore.purchaseTicket("1", "purchase4");
expect(result.success).toBe(false);
expect(result.error).toBe("NO_TICKETS_AVAILABLE");
});
test("should fail for non-existent event", () => {
const result = fallbackStore.purchaseTicket("999", "test-purchase");
expect(result.success).toBe(false);
expect(result.error).toBe("EVENT_NOT_FOUND");
});
});
describe("Event Statistics", () => {
beforeEach(() => {
const metadata = {
eventId: "1",
name: "Test Event",
description: "Test Description",
totalTickets: 5,
soldTickets: 0,
createdAt: "2024-01-01T00:00:00.000Z",
lastSoldAt: "never",
};
fallbackStore.seedEvent("1", ["t1", "t2", "t3", "t4", "t5"], metadata);
});
test("should return correct event stats", () => {
const stats = fallbackStore.getEventStats("1");
expect(stats).toBeDefined();
expect(stats.eventId).toBe("1");
expect(stats.name).toBe("Test Event");
expect(stats.totalTickets).toBe(5);
expect(stats.remainingTickets).toBe(5);
expect(stats.soldTickets).toBe(0);
});
test("should return null for non-existent event", () => {
const stats = fallbackStore.getEventStats("999");
expect(stats).toBeNull();
});
test("should update stats after ticket purchase", () => {
fallbackStore.purchaseTicket("1", "test-purchase");
const stats = fallbackStore.getEventStats("1");
expect(stats.remainingTickets).toBe(4);
expect(stats.soldTickets).toBe(1);
});
});
describe("Global Statistics", () => {
test("should return correct global stats", () => {
const stats = fallbackStore.getGlobalStats();
expect(stats.totalEvents).toBe(0);
expect(stats.totalTickets).toBe(0);
expect(stats.totalSold).toBe(0);
expect(stats.lastSeeded).toBeNull();
});
test("should update global stats after seeding", () => {
const metadata = {
eventId: "1",
name: "Test Event",
totalTickets: 3,
soldTickets: 0,
createdAt: "2024-01-01T00:00:00.000Z",
lastSoldAt: "never",
};
fallbackStore.seedEvent("1", ["t1", "t2", "t3"], metadata);
const stats = fallbackStore.getGlobalStats();
expect(stats.totalEvents).toBe(1);
expect(stats.totalTickets).toBe(3);
});
});
describe("Status Information", () => {
test("should return correct status", () => {
const status = fallbackStore.getStatus();
expect(status.active).toBe(false);
expect(status.eventsCount).toBe(0);
expect(status.totalTickets).toBe(0);
expect(status.totalSold).toBe(0);
expect(status.activationReason).toBeNull();
});
test("should return correct status when active", () => {
fallbackStore.activate("Test reason");
const status = fallbackStore.getStatus();
expect(status.active).toBe(true);
expect(status.activationReason).toBe("Test reason");
});
});
});
+281
View File
@@ -0,0 +1,281 @@
const security = require("../../src/utils/security");
describe("Security Middleware", () => {
let mockReq;
let mockRes;
let mockNext;
beforeEach(() => {
mockReq = {
method: "GET",
path: "/test",
headers: {},
params: {},
body: {},
ip: "127.0.0.1",
connection: { remoteAddress: "127.0.0.1" },
socket: { remoteAddress: "127.0.0.1" },
get: jest.fn(),
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
};
mockNext = jest.fn();
});
describe("Rate Limiters", () => {
test("should create general rate limiter", () => {
expect(security.generalLimiter).toBeDefined();
expect(typeof security.generalLimiter).toBe("function");
});
test("should create purchase rate limiter", () => {
expect(security.purchaseLimiter).toBeDefined();
expect(typeof security.purchaseLimiter).toBe("function");
});
test("should create admin rate limiter", () => {
expect(security.adminLimiter).toBeDefined();
expect(typeof security.adminLimiter).toBe("function");
});
});
describe("Input Validation", () => {
describe("validateEventId", () => {
test("should pass valid event ID", () => {
mockReq.params.eventId = "123";
security.validateEventId[0](mockReq, mockRes, mockNext);
security.validateEventId[1](mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockReq.params.eventId).toBe(123); // Should be converted to number
});
test("should reject invalid event ID", () => {
mockReq.params.eventId = "invalid";
security.validateEventId[0](mockReq, mockRes, mockNext);
security.validateEventId[1](mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
message: "Invalid event ID",
errors: expect.any(Array),
});
});
test("should reject negative event ID", () => {
mockReq.params.eventId = "-1";
security.validateEventId[0](mockReq, mockRes, mockNext);
security.validateEventId[1](mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
});
test("should reject zero event ID", () => {
mockReq.params.eventId = "0";
security.validateEventId[0](mockReq, mockRes, mockNext);
security.validateEventId[1](mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
});
});
describe("validatePurchaseId", () => {
test("should pass valid UUID", () => {
mockReq.params.purchaseId = "123e4567-e89b-12d3-a456-426614174000";
security.validatePurchaseId[0](mockReq, mockRes, mockNext);
security.validatePurchaseId[1](mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
test("should reject invalid UUID", () => {
mockReq.params.purchaseId = "invalid-uuid";
security.validatePurchaseId[0](mockReq, mockRes, mockNext);
security.validatePurchaseId[1](mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
message: "Invalid purchase ID",
errors: expect.any(Array),
});
});
});
describe("validateCleanupRequest", () => {
test("should pass valid maxAgeHours", () => {
mockReq.body.maxAgeHours = "48";
security.validateCleanupRequest[0](mockReq, mockRes, mockNext);
security.validateCleanupRequest[1](mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockReq.body.maxAgeHours).toBe(48); // Should be converted to number
});
test("should pass without maxAgeHours", () => {
delete mockReq.body.maxAgeHours;
security.validateCleanupRequest[0](mockReq, mockRes, mockNext);
security.validateCleanupRequest[1](mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
test("should reject invalid maxAgeHours", () => {
mockReq.body.maxAgeHours = "99999"; // Too high
security.validateCleanupRequest[0](mockReq, mockRes, mockNext);
security.validateCleanupRequest[1](mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
});
test("should reject negative maxAgeHours", () => {
mockReq.body.maxAgeHours = "-1";
security.validateCleanupRequest[0](mockReq, mockRes, mockNext);
security.validateCleanupRequest[1](mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
});
});
});
describe("Security Headers", () => {
test("should be defined", () => {
expect(security.securityHeaders).toBeDefined();
expect(typeof security.securityHeaders).toBe("function");
});
});
describe("Request Size Limit", () => {
test("should pass requests within size limit", () => {
mockReq.headers["content-length"] = "1024"; // 1KB
security.requestSizeLimit(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
test("should reject requests exceeding size limit", () => {
mockReq.headers["content-length"] = "2097152"; // 2MB
security.requestSizeLimit(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(413);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
message: "Request entity too large. Maximum size is 1MB.",
});
});
test("should handle missing content-length", () => {
delete mockReq.headers["content-length"];
security.requestSizeLimit(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
});
describe("CORS Options", () => {
test("should have correct structure", () => {
expect(security.corsOptions).toBeDefined();
expect(security.corsOptions.origin).toBeDefined();
expect(security.corsOptions.methods).toBeDefined();
expect(security.corsOptions.allowedHeaders).toBeDefined();
expect(security.corsOptions.credentials).toBeDefined();
expect(security.corsOptions.maxAge).toBeDefined();
});
test("should have default origins", () => {
expect(security.corsOptions.origin).toContain("http://localhost:3000");
expect(security.corsOptions.origin).toContain("http://localhost:3049");
});
});
describe("IP Address Extraction", () => {
test("should extract IP from req.ip", () => {
mockReq.ip = "192.168.1.1";
const ip = security.getClientIP(mockReq);
expect(ip).toBe("192.168.1.1");
});
test("should fallback to connection.remoteAddress", () => {
delete mockReq.ip;
mockReq.connection.remoteAddress = "192.168.1.2";
const ip = security.getClientIP(mockReq);
expect(ip).toBe("192.168.1.2");
});
test("should fallback to socket.remoteAddress", () => {
delete mockReq.ip;
delete mockReq.connection.remoteAddress;
mockReq.socket.remoteAddress = "192.168.1.3";
const ip = security.getClientIP(mockReq);
expect(ip).toBe("192.168.1.3");
});
test("should return unknown if no IP found", () => {
delete mockReq.ip;
delete mockReq.connection.remoteAddress;
delete mockReq.socket.remoteAddress;
const ip = security.getClientIP(mockReq);
expect(ip).toBe("unknown");
});
});
describe("Security Logging", () => {
test("should log suspicious admin requests", () => {
const consoleSpy = jest.spyOn(console, "warn");
mockReq.path = "/admin/test";
security.securityLogging(mockReq, mockRes, mockNext);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Security Warning"),
expect.objectContaining({
ip: "127.0.0.1",
path: "/admin/test",
method: "GET",
})
);
expect(mockNext).toHaveBeenCalled();
});
test("should log path traversal attempts", () => {
const consoleSpy = jest.spyOn(console, "warn");
mockReq.path = "/test/../admin";
security.securityLogging(mockReq, mockRes, mockNext);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Security Warning"),
expect.objectContaining({
path: "/test/../admin",
})
);
});
test("should not log normal requests", () => {
const consoleSpy = jest.spyOn(console, "warn");
mockReq.path = "/events/1";
security.securityLogging(mockReq, mockRes, mockNext);
expect(consoleSpy).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalled();
});
});
});