Compare commits

...

5 Commits

Author SHA1 Message Date
Ayobami 06f0cc3638 feat: add integration and setup tests and complete code review fixes 2025-08-14 22:41:48 +01:00
Ayobami da78487047 Feat: add prometheus compatible endpoint, seed fallback store and add env.example 2025-08-13 22:41:37 +01:00
Ayobami 3f8f456eef feat: general clean up and testing 2025-08-04 13:16:53 +01:00
Ayobami 43ae10d7dd feat: add project documentation 2025-07-31 21:38:18 +01:00
Ayobami 064ae104f7 feat: add autocannon and fix purchase ticket flow 2025-07-30 22:31:34 +01:00
29 changed files with 4477 additions and 588 deletions
+19
View File
@@ -0,0 +1,19 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.nyc_output
coverage
.DS_Store
*.log
logs/*
tickets/*
.vscode
.idea
*.md
!design.md
Dockerfile
docker-compose.yml
prometheus.yml
+21 -8
View File
@@ -1,21 +1,34 @@
# Environment Configuration for Ticket Microservice
# Server Configuration
PORT=3049
NODE_ENV=development
# Redis Configuration
REDIS_URL=redis://localhost:6379
REDIS_HOST=localhost
REDIS_PORT=6379
# Event Configuration
DEFAULT_TICKETS_PER_EVENT=10000
# Logging Configuration
LOG_LEVEL=info
LOG_FILE=logs/app.log
# PDF Configuration
PDF_OUTPUT_DIR=./tickets
PDF_OUTPUT_DIR=tickets
PDF_CLEANUP_MAX_AGE_HOURS=24
# Metrics Configuration
METRICS_PORT=9090
# 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
+5 -1
View File
@@ -1,3 +1,7 @@
/node_modules
/dist
/package-lock.json
/package-lock.json
/tickets
/logs
.env
*.log
+37
View File
@@ -0,0 +1,37 @@
# Use official Node.js runtime as base image
FROM node:18-alpine
# Set working directory in container
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p logs tickets
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Change ownership of app directory
RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3049
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3049/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
# Start the application
CMD ["npm", "start"]
+305 -17
View File
@@ -42,34 +42,204 @@ Your task is to extract the high-throughput ticket purchasing component and exte
### Prerequisites
- Node.js (v14+ recommended)
- npm
- Redis (installed locally or via Docker, as per the provided docker-compose configuration)
- Node.js (v18+ recommended)
- npm or yarn
- Docker and Docker Compose
- Git
### Setup
### Environment Variables
1. Clone the repository.
2. Install dependencies:
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. (Optional) Copy the environment variable template:
cp .env.example .env
4. Seed the Redis store with tickets for multiple events. You might modify the seeding script to handle multiple event keys (e.g., `event:1:tickets`, `event:2:tickets`, etc.).
5. Start the application:
npm start
```
3. **Set up environment**
```bash
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
```
### Manual Setup (Development)
If you prefer to run components separately:
1. **Create necessary directories**
```bash
mkdir -p logs tickets
```
2. **Start Redis**
```bash
docker-compose up -d redis
```
3. **Start the application**
```bash
npm run dev # Development with auto-reload
# or
npm start # Production mode
```
### API Endpoints
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 |
| POST | `/admin/seed-fallback` | Manually seed fallback store from Redis |
### Load Testing
Simulate high load using a tool like [autocannon](https://github.com/mcollina/autocannon) or [wrk](https://github.com/wg/wrk). For example, to simulate 5000 concurrent connections on event 1:
The system includes a comprehensive load testing framework:
npx autocannon -c 5000 -d 30 http://localhost:3049/buy/1
````bash
# Run full test suite (5000+ concurrent connections)
npm run test:load -- --full
### Metrics
# Test specific event
npm run test:load -- --event 1 --connections 5000 --duration 30
Access real-time service metrics at:
# Multi-event concurrent testing
npm run test:load -- --multi --events 1,2,3 --connections 6000
http://localhost:3049/metrics
# Custom load test
node tests/load-test.js --event 2 --connections 1000 --duration 10
These metrics should include data on tickets sold, remaining tickets per event, and any instances where the fallback mechanism was activated.
# 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
#### Application Metrics
Access real-time service metrics at: http://localhost:3049/metrics
#### Prometheus (if enabled)
Prometheus dashboard: http://localhost:9090
#### Grafana (if enabled)
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
# Start core services
docker-compose up -d
# Start with monitoring
docker-compose --profile monitoring up -d
# View logs
docker-compose logs -f app
# Stop services
docker-compose down
# Rebuild and restart
docker-compose up -d --build
```
## Evaluation Criteria
@@ -80,6 +250,124 @@ These metrics should include data on tickets sold, remaining tickets per event,
- **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.
+57
View File
@@ -0,0 +1,57 @@
const redis = require("redis");
require("dotenv").config();
async function debugEvents() {
const client = redis.createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
try {
await client.connect();
console.log("✅ Connected to Redis");
// Check what event keys exist
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);
// Check each event
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}`);
console.log(` Sold Tickets: ${meta.soldTickets}`);
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"}`);
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}`);
}
} catch (error) {
console.error("❌ Error:", error);
} finally {
await client.disconnect();
}
}
debugEvents();
+381
View File
@@ -0,0 +1,381 @@
# Ticket Scaling Microservice - Design Document
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [System Components](#system-components)
3. [Scalability Strategies](#scalability-strategies)
4. [Atomic Operations](#atomic-operations)
5. [Fallback Mechanisms](#fallback-mechanisms)
6. [Performance Optimizations](#performance-optimizations)
7. [Monitoring & Observability](#monitoring--observability)
8. [Security Considerations](#security-considerations)
9. [Deployment Strategy](#deployment-strategy)
10. [Future Enhancements](#future-enhancements)
## Architecture Overview
### High-Level Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Load Balancer │ │ Prometheus │ │ Grafana │
│ (Optional) │ │ Monitoring │ │ Dashboard │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Ticket Service │◄───┤ Redis │ │ In-Memory │
│ (Node.js/ │ │ Primary Store │ │ Fallback Store │
│ Express) │ │ │ │ │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ PDF Generator │
│ (PDFKit) │
└─────────────────┘
```
### 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
4. **Observability**: Comprehensive logging and metrics
5. **Performance**: Optimized for high-throughput scenarios
## System Components
### 1. Core Application (server.js)
- **Technology**: Node.js with Express framework
- **Responsibilities**:
- HTTP request handling
- Business logic orchestration
- Error handling and logging
- PDF generation coordination
### 2. Redis Client (redis-client.js)
- **Technology**: Redis with Lua scripting
- **Responsibilities**:
- Atomic ticket operations
- Event metadata management
- Connection health monitoring
- Script execution
### 3. Fallback Store (fallback-store.js)
- **Technology**: In-memory JavaScript Map
- **Responsibilities**:
- Emergency ticket storage
- Temporary operation continuity
- Graceful degradation
### 4. PDF Generator (pdf-generator.js)
- **Technology**: PDFKit library
- **Responsibilities**:
- Professional ticket generation
- File management
- Cleanup operations
### 5. Logging System (logger.js)
- **Technology**: Winston logging framework
- **Responsibilities**:
- Structured logging
- Request tracking
- Error reporting
- Performance metrics
## Scalability Strategies
### Horizontal Scaling
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Instance 1 │ │ Instance 2 │ │ Instance N │
│ Port: 3049 │ │ Port: 3050 │ │ Port: 305X │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌─────────────────┐
│ Shared Redis │
│ Cluster │
└─────────────────┘
```
**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 │
│ (Read/Write) │───▶│ (Read Only) │ │ (Read Only) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**Strategies**:
- Redis clustering for horizontal scaling
- Read replicas for metrics/stats queries
- Sharding by event ID for massive scale
## Atomic Operations
### Lua Script Design
Our core purchase operation uses a Redis Lua script to ensure atomicity:
```lua
-- Atomic ticket purchase script
local ticketKey = KEYS[1] -- event:X:tickets
local metaKey = KEYS[2] -- event:X:meta
local globalKey = KEYS[3] -- global:stats
-- Atomic operations:
1. Check event exists
2. Pop ticket from list
3. Update sold count
4. Update global stats
5. Store purchase record
```
**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
## 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 │
└─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ Try Redis │───▶│ Redis Success │
│ Operation │ │ Return Result │
└─────────────────┘ └─────────────────┘
▼ (On Failure)
┌─────────────────┐ ┌─────────────────┐
│ Activate │───▶│ In-Memory │
│ Fallback Store │ │ Operation │
└─────────────────┘ └─────────────────┘
```
### 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 (mitigated by automatic reseeding)
- **Single Instance**: No cross-instance synchronization
- **Capacity Limited**: Memory constraints
- **Warning Logs**: Clear indication of degraded mode
## 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
4. **Error Isolation**: PDF failures don't affect purchases
## Monitoring & Observability
### Metrics Collection
```json
{
"global": {
"totalEvents": 5,
"totalTickets": 50000,
"totalSold": 1250
},
"events": [
{
"eventId": "1",
"soldTickets": 250,
"remainingTickets": 9750
}
],
"system": {
"usingFallback": false,
"redisConnected": true,
"uptime": 3600,
"memoryUsage": {...}
},
"pdf": {
"totalTickets": 1250,
"totalSizeMB": "15.6"
}
}
```
### 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
- **Resource Usage**: Memory and CPU monitoring
## Security Considerations
### Input Validation
- **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
npm run docker:up # Start Redis
npm run seed # Seed events
npm run dev # Start with nodemon
```
### Production Environment
```bash
# Docker deployment
docker-compose up -d # Core services
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
- **Resource Limits**: CPU and memory constraints
## 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
4. **Audit Trails**: Comprehensive logging
## Conclusion
This design provides a robust, scalable foundation for high-volume ticket sales with the following key strengths:
- **Atomic Operations**: Guaranteed consistency under load
- **High Availability**: Graceful degradation capabilities
- **Observability**: Comprehensive monitoring and logging
- **Scalability**: Horizontal and vertical scaling support
- **Performance**: Optimized for high-throughput scenarios
The architecture successfully handles the challenge requirements of processing thousands of concurrent requests while maintaining data integrity and system reliability.
+71 -1
View File
@@ -1,10 +1,80 @@
version: "3"
version: "3.8"
services:
redis:
image: redis:alpine
container_name: ticket-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
- ticket-network
app:
build: .
container_name: ticket-microservice
ports:
- "3049:3049"
environment:
- NODE_ENV=production
- REDIS_URL=redis://redis:6379
- PORT=3049
- LOG_LEVEL=info
depends_on:
redis:
condition: service_healthy
volumes:
- ./logs:/app/logs
- ./tickets:/app/tickets
networks:
- ticket-network
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: ticket-prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--storage.tsdb.retention.time=200h'
- '--web.enable-lifecycle'
networks:
- ticket-network
profiles:
- monitoring
grafana:
image: grafana/grafana:latest
container_name: ticket-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana_data:/var/lib/grafana
networks:
- ticket-network
profiles:
- monitoring
volumes:
redis_data:
prometheus_data:
grafana_data:
networks:
ticket-network:
driver: bridge
+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",
+19
View File
@@ -0,0 +1,19 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
# - "first_rules.yml"
# - "second_rules.yml"
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'ticket-microservice'
static_configs:
- targets: ['app:3049']
metrics_path: '/metrics'
scrape_interval: 5s
scrape_timeout: 3s
+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
+4 -3
View File
@@ -32,13 +32,14 @@ redis.call('HINCRBY', globalKey, 'totalSold', 1)
-- Store purchase record
local purchaseKey = 'purchase:' .. purchaseId
redis.call('HSET', purchaseKey, {
local eventIdFromKey = string.match(ticketKey, 'event:(%d+):tickets')
redis.call('HSET', purchaseKey,
'ticketId', ticket,
'eventId', string.match(ticketKey, 'event:(%d+):tickets'),
'eventId', eventIdFromKey,
'purchaseId', purchaseId,
'timestamp', timestamp,
'status', 'completed'
})
)
-- Set expiration for purchase record (24 hours)
redis.call('EXPIRE', purchaseKey, 86400)
+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,
};
+97 -77
View File
@@ -1,11 +1,11 @@
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');
const logger = require('./logger');
const PDFDocument = require("pdfkit");
const fs = require("fs");
const path = require("path");
const logger = require("./logger");
class PDFGenerator {
constructor() {
this.outputDir = process.env.PDF_OUTPUT_DIR || './tickets';
this.outputDir = process.env.PDF_OUTPUT_DIR || "./tickets";
this.ensureOutputDirectory();
}
@@ -26,13 +26,13 @@ class PDFGenerator {
eventName,
eventDescription,
timestamp,
soldCount
soldCount,
} = ticketData;
// Create PDF document
const doc = new PDFDocument({
size: 'A4',
margin: 50
size: "A4",
margin: 50,
});
// Generate filename
@@ -44,97 +44,114 @@ class PDFGenerator {
doc.pipe(stream);
// Header
doc.fontSize(24)
.fillColor('#2c3e50')
.text('🎫 TICKET RECEIPT', 50, 50, { align: 'center' });
doc
.fontSize(24)
.fillColor("#2c3e50")
.text("TICKET RECEIPT", 50, 50, { align: "center" });
// Divider line
doc.moveTo(50, 90)
.lineTo(545, 90)
.strokeColor('#3498db')
.lineWidth(2)
.stroke();
doc
.moveTo(50, 90)
.lineTo(545, 90)
.strokeColor("#3498db")
.lineWidth(2)
.stroke();
// Event Information
doc.fontSize(18)
.fillColor('#2c3e50')
.text('Event Information', 50, 120);
doc
.fontSize(18)
.fillColor("#2c3e50")
.text("Event Information", 50, 120);
doc.fontSize(12)
.fillColor('#34495e')
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
.text(`Event ID: ${eventId}`, 50, 170)
.text(`Description: ${eventDescription || 'No description available'}`, 50, 190);
doc
.fontSize(12)
.fillColor("#34495e")
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
.text(`Event ID: ${eventId}`, 50, 170)
.text(
`Description: ${eventDescription || "No description available"}`,
50,
190
);
// Ticket Information
doc.fontSize(18)
.fillColor('#2c3e50')
.text('Ticket Details', 50, 230);
doc.fontSize(18).fillColor("#2c3e50").text("Ticket Details", 50, 230);
doc.fontSize(12)
.fillColor('#34495e')
.text(`Ticket ID: ${ticketId}`, 50, 260)
.text(`Purchase ID: ${purchaseId}`, 50, 280)
.text(`Purchase Date: ${new Date(timestamp).toLocaleString()}`, 50, 300)
.text(`Ticket Number: #${soldCount}`, 50, 320);
doc
.fontSize(12)
.fillColor("#34495e")
.text(`Ticket ID: ${ticketId}`, 50, 260)
.text(`Purchase ID: ${purchaseId}`, 50, 280)
.text(
`Purchase Date: ${new Date(timestamp).toLocaleString()}`,
50,
300
)
.text(`Ticket Number: #${soldCount}`, 50, 320);
// QR Code placeholder (you could integrate a QR code library here)
doc.rect(400, 250, 100, 100)
.strokeColor('#bdc3c7')
.lineWidth(1)
.stroke();
doc
.rect(400, 250, 100, 100)
.strokeColor("#bdc3c7")
.lineWidth(1)
.stroke();
doc.fontSize(10)
.fillColor('#7f8c8d')
.text('QR Code', 430, 305, { align: 'center' });
doc
.fontSize(10)
.fillColor("#7f8c8d")
.text("QR Code", 430, 305, { align: "center" });
// Terms and Conditions
doc.fontSize(14)
.fillColor('#2c3e50')
.text('Terms & Conditions', 50, 380);
doc
.fontSize(14)
.fillColor("#2c3e50")
.text("Terms & Conditions", 50, 380);
doc.fontSize(10)
.fillColor('#7f8c8d')
.text('• This ticket is non-refundable and non-transferable', 50, 410)
.text('• Please arrive 30 minutes before the event starts', 50, 425)
.text('• Valid photo ID required for entry', 50, 440)
.text('• This ticket is valid only for the specified event and date', 50, 455);
doc
.fontSize(10)
.fillColor("#7f8c8d")
.text("• This ticket is non-refundable and non-transferable", 50, 410)
.text("• Please arrive 30 minutes before the event starts", 50, 425)
.text("• Valid photo ID required for entry", 50, 440)
.text(
"• This ticket is valid only for the specified event and date",
50,
455
);
// Footer
doc.fontSize(8)
.fillColor('#95a5a6')
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
.text('Powered by Ticket Microservice', 50, 535)
.text(`System ID: ${process.env.NODE_ENV || 'development'}`, 50, 550);
doc
.fontSize(8)
.fillColor("#95a5a6")
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
.text("Powered by Ticket Microservice", 50, 535)
.text(`System ID: ${process.env.NODE_ENV || "development"}`, 50, 550);
// Security watermark
doc.fontSize(60)
.fillColor('#ecf0f1')
.text('VALID', 200, 300, {
rotate: -45,
opacity: 0.1
});
doc.fontSize(60).fillColor("#ecf0f1").text("VALID", 200, 300, {
rotate: -45,
opacity: 0.1,
});
// Finalize PDF
doc.end();
stream.on('finish', () => {
stream.on("finish", () => {
logger.info(`PDF ticket generated: ${filename}`);
resolve({
success: true,
filename,
filepath,
size: fs.statSync(filepath).size
size: fs.statSync(filepath).size,
});
});
stream.on('error', (error) => {
logger.error('Error writing PDF file:', error);
stream.on("error", (error) => {
logger.error("Error writing PDF file:", error);
reject(error);
});
} catch (error) {
logger.error('Error generating PDF:', error);
logger.error("Error generating PDF:", error);
reject(error);
}
});
@@ -142,17 +159,20 @@ class PDFGenerator {
async generateBulkTicketsPDF(tickets) {
const results = [];
for (const ticket of tickets) {
try {
const result = await this.generateTicketPDF(ticket);
results.push(result);
} catch (error) {
logger.error(`Failed to generate PDF for ticket ${ticket.ticketId}:`, error);
logger.error(
`Failed to generate PDF for ticket ${ticket.ticketId}:`,
error
);
results.push({
success: false,
ticketId: ticket.ticketId,
error: error.message
error: error.message,
});
}
}
@@ -191,7 +211,7 @@ class PDFGenerator {
let deletedCount = 0;
for (const file of files) {
if (file.endsWith('.pdf')) {
if (file.endsWith(".pdf")) {
const filepath = path.join(this.outputDir, file);
const stats = fs.statSync(filepath);
const ageHours = (now - stats.mtime.getTime()) / (1000 * 60 * 60);
@@ -209,7 +229,7 @@ class PDFGenerator {
return deletedCount;
} catch (error) {
logger.error('Error cleaning up old tickets:', error);
logger.error("Error cleaning up old tickets:", error);
throw error;
}
}
@@ -217,8 +237,8 @@ class PDFGenerator {
getStats() {
try {
const files = fs.readdirSync(this.outputDir);
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
const pdfFiles = files.filter((f) => f.endsWith(".pdf"));
let totalSize = 0;
for (const file of pdfFiles) {
const filepath = path.join(this.outputDir, file);
@@ -229,16 +249,16 @@ class PDFGenerator {
totalTickets: pdfFiles.length,
totalSizeBytes: totalSize,
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
outputDirectory: this.outputDir
outputDirectory: this.outputDir,
};
} catch (error) {
logger.error('Error getting PDF stats:', error);
logger.error("Error getting PDF stats:", error);
return {
totalTickets: 0,
totalSizeBytes: 0,
totalSizeMB: '0.00',
totalSizeMB: "0.00",
outputDirectory: this.outputDir,
error: error.message
error: error.message,
};
}
}
+120 -54
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,69 +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"];
}
const keys = [
`event:${eventId}:tickets`,
`event:${eventId}:meta`,
'global:stats'
"global:stats",
];
const args = [timestamp, purchaseId];
// 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("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 {
@@ -115,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();
});
});
});