Compare commits
5 Commits
42fec5708a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06f0cc3638 | |||
| da78487047 | |||
| 3f8f456eef | |||
| 43ae10d7dd | |||
| 064ae104f7 |
@@ -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
@@ -1,21 +1,34 @@
|
|||||||
|
# Environment Configuration for Ticket Microservice
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3049
|
PORT=3049
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# Event Configuration
|
|
||||||
DEFAULT_TICKETS_PER_EVENT=10000
|
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FILE=logs/app.log
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
# PDF Configuration
|
# PDF Configuration
|
||||||
PDF_OUTPUT_DIR=./tickets
|
PDF_OUTPUT_DIR=tickets
|
||||||
|
PDF_CLEANUP_MAX_AGE_HOURS=24
|
||||||
|
|
||||||
# Metrics Configuration
|
# Load Testing Configuration
|
||||||
METRICS_PORT=9090
|
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
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/dist
|
/dist
|
||||||
/package-lock.json
|
/package-lock.json
|
||||||
|
/tickets
|
||||||
|
/logs
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|||||||
+37
@@ -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"]
|
||||||
@@ -42,34 +42,204 @@ Your task is to extract the high-throughput ticket purchasing component and exte
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (v14+ recommended)
|
- Node.js (v18+ recommended)
|
||||||
- npm
|
- npm or yarn
|
||||||
- Redis (installed locally or via Docker, as per the provided docker-compose configuration)
|
- Docker and Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
### Setup
|
### Environment Variables
|
||||||
|
|
||||||
1. Clone the repository.
|
The following environment variables can be configured in your `.env` file:
|
||||||
2. Install dependencies:
|
|
||||||
|
| 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
|
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.).
|
3. **Set up environment**
|
||||||
5. Start the application:
|
|
||||||
npm start
|
```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
|
### 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
|
## 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.
|
- **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.
|
- **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
|
## Final Challenges
|
||||||
|
|
||||||
- Enhance your docker-compose setup to include a Prometheus container for live monitoring.
|
- Enhance your docker-compose setup to include a Prometheus container for live monitoring.
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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
@@ -1,10 +1,80 @@
|
|||||||
version: "3"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
|
container_name: ticket-redis
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- 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:
|
volumes:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ticket-network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -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
@@ -8,7 +8,17 @@
|
|||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"seed": "node seed.js",
|
"seed": "node seed.js",
|
||||||
"test": "jest",
|
"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: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:up": "docker-compose up -d",
|
||||||
"docker:down": "docker-compose down"
|
"docker:down": "docker-compose down"
|
||||||
},
|
},
|
||||||
@@ -22,7 +32,11 @@
|
|||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"prom-client": "^15.1.0",
|
"prom-client": "^15.1.0",
|
||||||
"uuid": "^9.0.1",
|
"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": {
|
"devDependencies": {
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
const redis = require("redis");
|
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
|
// Configuration for seeding
|
||||||
const config = {
|
const config = {
|
||||||
numEvents: parseInt(process.argv[2]) || 5, // Number of events to create
|
numEvents: parseInt(process.argv[2]) || 5, // Number of events to create
|
||||||
ticketsPerEvent: parseInt(process.argv[3]) || 10000, // Tickets per event
|
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 });
|
const client = redis.createClient({ url: config.redisUrl });
|
||||||
@@ -13,10 +16,12 @@ const client = redis.createClient({ url: config.redisUrl });
|
|||||||
async function seedTickets() {
|
async function seedTickets() {
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
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
|
// Clear existing event data
|
||||||
const existingKeys = await client.keys('event:*');
|
const existingKeys = await client.keys("event:*");
|
||||||
if (existingKeys.length > 0) {
|
if (existingKeys.length > 0) {
|
||||||
await client.del(existingKeys);
|
await client.del(existingKeys);
|
||||||
console.log(`Cleared ${existingKeys.length} existing event keys.`);
|
console.log(`Cleared ${existingKeys.length} existing event keys.`);
|
||||||
@@ -37,27 +42,39 @@ async function seedTickets() {
|
|||||||
await client.rPush(eventKey, tickets);
|
await client.rPush(eventKey, tickets);
|
||||||
|
|
||||||
// Store event metadata
|
// Store event metadata
|
||||||
await client.hSet(metaKey, {
|
const metadata = {
|
||||||
eventId: eventId,
|
eventId: eventId,
|
||||||
totalTickets: config.ticketsPerEvent,
|
totalTickets: config.ticketsPerEvent,
|
||||||
soldTickets: 0,
|
soldTickets: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
name: `Event ${eventId}`,
|
name: `Event ${eventId}`,
|
||||||
description: `Sample event ${eventId} for load testing`
|
description: `Sample event ${eventId} for load testing`,
|
||||||
});
|
};
|
||||||
|
|
||||||
console.log(`✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded`);
|
await client.hSet(metaKey, metadata);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store global stats
|
// Store global stats
|
||||||
await client.hSet('global:stats', {
|
await client.hSet("global:stats", {
|
||||||
totalEvents: config.numEvents,
|
totalEvents: config.numEvents,
|
||||||
totalTickets: config.numEvents * config.ticketsPerEvent,
|
totalTickets: config.numEvents * config.ticketsPerEvent,
|
||||||
totalSold: 0,
|
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);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error during seed:", 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();
|
seedTickets();
|
||||||
|
|||||||
@@ -1,58 +1,64 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require("uuid");
|
||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
|
|
||||||
// Import utilities
|
// Import utilities
|
||||||
const redisClient = require('./src/utils/redis-client');
|
const redisClient = require("./src/utils/redis-client");
|
||||||
const fallbackStore = require('./src/utils/fallback-store');
|
const fallbackStore = require("./src/utils/fallback-store");
|
||||||
const logger = require('./src/utils/logger');
|
const logger = require("./src/utils/logger");
|
||||||
const pdfGenerator = require('./src/utils/pdf-generator');
|
const pdfGenerator = require("./src/utils/pdf-generator");
|
||||||
|
const metrics = require("./src/utils/metrics");
|
||||||
|
const security = require("./src/utils/security");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3049;
|
const port = process.env.PORT || 3049;
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(security.securityHeaders);
|
||||||
|
app.use(security.requestSizeLimit);
|
||||||
|
app.use(security.securityLogging);
|
||||||
|
|
||||||
// Request logging middleware
|
// Request logging middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
res.on('finish', () => {
|
res.on("finish", () => {
|
||||||
const responseTime = Date.now() - start;
|
const responseTime = Date.now() - start;
|
||||||
logger.logRequest(req, res, responseTime);
|
logger.logRequest(req, res, responseTime);
|
||||||
});
|
});
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler
|
// Prometheus metrics middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use(metrics.metricsMiddleware);
|
||||||
logger.error('Unhandled error:', err);
|
|
||||||
res.status(500).json({
|
// Apply general rate limiting to all routes
|
||||||
success: false,
|
app.use(security.generalLimiter);
|
||||||
message: 'Internal Server Error',
|
|
||||||
error: process.env.NODE_ENV === 'development' ? err.message : undefined
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get("/health", async (req, res) => {
|
||||||
const redisHealthy = redisClient.isHealthy();
|
const redisHealthy = redisClient.isHealthy();
|
||||||
const fallbackActive = fallbackStore.isActive;
|
const fallbackActive = fallbackStore.isActive;
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
metrics.updateRedisStatus(redisHealthy);
|
||||||
|
metrics.updateFallbackStatus(fallbackActive);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
redis: {
|
redis: {
|
||||||
connected: redisHealthy,
|
connected: redisHealthy,
|
||||||
fallbackActive: fallbackActive
|
fallbackActive: fallbackActive,
|
||||||
},
|
},
|
||||||
uptime: process.uptime()
|
uptime: process.uptime(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all events endpoint
|
// Get all events endpoint
|
||||||
app.get('/events', async (req, res) => {
|
app.get("/events", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let events;
|
let events;
|
||||||
|
|
||||||
@@ -65,19 +71,19 @@ app.get('/events', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
events,
|
events,
|
||||||
usingFallback: fallbackStore.isActive
|
usingFallback: fallbackStore.isActive,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching events:', error);
|
logger.error("Error fetching events:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to fetch events'
|
message: "Failed to fetch events",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get specific event stats
|
// Get specific event stats
|
||||||
app.get('/events/:eventId', async (req, res) => {
|
app.get("/events/:eventId", security.validateEventId, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const eventId = req.params.eventId;
|
const eventId = req.params.eventId;
|
||||||
let eventStats;
|
let eventStats;
|
||||||
@@ -91,26 +97,30 @@ app.get('/events/:eventId', async (req, res) => {
|
|||||||
if (!eventStats) {
|
if (!eventStats) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Event not found'
|
message: "Event not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
event: eventStats,
|
event: eventStats,
|
||||||
usingFallback: fallbackStore.isActive
|
usingFallback: fallbackStore.isActive,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching event stats:', error);
|
logger.error("Error fetching event stats:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to fetch event stats'
|
message: "Failed to fetch event stats",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Purchase ticket endpoint (multi-event)
|
// Purchase ticket endpoint (multi-event)
|
||||||
app.post('/buy/:eventId', async (req, res) => {
|
app.post(
|
||||||
|
"/buy/:eventId",
|
||||||
|
security.purchaseLimiter,
|
||||||
|
security.validateEventId,
|
||||||
|
async (req, res) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const eventId = req.params.eventId;
|
const eventId = req.params.eventId;
|
||||||
const purchaseId = uuidv4();
|
const purchaseId = uuidv4();
|
||||||
@@ -122,7 +132,11 @@ app.post('/buy/:eventId', async (req, res) => {
|
|||||||
// Try Redis first
|
// Try Redis first
|
||||||
if (redisClient.isHealthy()) {
|
if (redisClient.isHealthy()) {
|
||||||
try {
|
try {
|
||||||
const luaResult = await redisClient.purchaseTicket(eventId, purchaseId, timestamp);
|
const luaResult = await redisClient.purchaseTicket(
|
||||||
|
eventId,
|
||||||
|
purchaseId,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
|
||||||
if (luaResult[0]) {
|
if (luaResult[0]) {
|
||||||
// Success - generate PDF ticket
|
// Success - generate PDF ticket
|
||||||
@@ -130,36 +144,52 @@ app.post('/buy/:eventId', async (req, res) => {
|
|||||||
// Get event details for PDF
|
// Get event details for PDF
|
||||||
const eventStats = await redisClient.getEventStats(eventId);
|
const eventStats = await redisClient.getEventStats(eventId);
|
||||||
|
|
||||||
|
const pdfStartTime = Date.now();
|
||||||
const pdfResult = await pdfGenerator.generateTicketPDF({
|
const pdfResult = await pdfGenerator.generateTicketPDF({
|
||||||
ticketId: luaResult[0],
|
ticketId: luaResult[0],
|
||||||
eventId,
|
eventId,
|
||||||
purchaseId,
|
purchaseId,
|
||||||
eventName: eventStats?.name || `Event ${eventId}`,
|
eventName: eventStats?.name || `Event ${eventId}`,
|
||||||
eventDescription: eventStats?.description || 'Event description not available',
|
eventDescription:
|
||||||
|
eventStats?.description || "Event description not available",
|
||||||
timestamp,
|
timestamp,
|
||||||
soldCount: luaResult[2]
|
soldCount: luaResult[2],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Record PDF generation metrics
|
||||||
|
const pdfDuration = (Date.now() - pdfStartTime) / 1000;
|
||||||
|
metrics.recordPDFGeneration(
|
||||||
|
pdfResult.success ? "success" : "failed",
|
||||||
|
pdfDuration
|
||||||
|
);
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
success: true,
|
success: true,
|
||||||
ticket: luaResult[0],
|
ticket: luaResult[0],
|
||||||
purchaseId,
|
purchaseId,
|
||||||
eventId,
|
eventId,
|
||||||
soldCount: luaResult[2],
|
soldCount: luaResult[2],
|
||||||
message: 'Ticket purchased successfully!',
|
message: "Ticket purchased successfully!",
|
||||||
usingFallback: false,
|
usingFallback: false,
|
||||||
pdf: {
|
pdf: {
|
||||||
generated: pdfResult.success,
|
generated: pdfResult.success,
|
||||||
filename: pdfResult.filename,
|
filename: pdfResult.filename,
|
||||||
downloadUrl: `/tickets/${purchaseId}`
|
downloadUrl: `/tickets/${purchaseId}`,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Record metrics for successful purchase
|
||||||
|
metrics.recordTicketSale(eventId, "success");
|
||||||
|
metrics.updateTicketMetrics(
|
||||||
|
eventId,
|
||||||
|
luaResult[2],
|
||||||
|
luaResult[3] || 0
|
||||||
|
);
|
||||||
|
|
||||||
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
||||||
logger.info(`PDF ticket generated for purchase ${purchaseId}`);
|
logger.info(`PDF ticket generated for purchase ${purchaseId}`);
|
||||||
|
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
logger.error('PDF generation failed:', pdfError);
|
logger.error("PDF generation failed:", pdfError);
|
||||||
|
|
||||||
// Still return success for ticket purchase, but note PDF failure
|
// Still return success for ticket purchase, but note PDF failure
|
||||||
result = {
|
result = {
|
||||||
@@ -168,67 +198,96 @@ app.post('/buy/:eventId', async (req, res) => {
|
|||||||
purchaseId,
|
purchaseId,
|
||||||
eventId,
|
eventId,
|
||||||
soldCount: luaResult[2],
|
soldCount: luaResult[2],
|
||||||
message: 'Ticket purchased successfully! (PDF generation failed)',
|
message:
|
||||||
|
"Ticket purchased successfully! (PDF generation failed)",
|
||||||
usingFallback: false,
|
usingFallback: false,
|
||||||
pdf: {
|
pdf: {
|
||||||
generated: false,
|
generated: false,
|
||||||
error: 'PDF generation failed'
|
error: "PDF generation failed",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Record metrics for successful purchase (even with PDF failure)
|
||||||
|
metrics.recordTicketSale(eventId, "success");
|
||||||
|
metrics.updateTicketMetrics(
|
||||||
|
eventId,
|
||||||
|
luaResult[2],
|
||||||
|
luaResult[3] || 0
|
||||||
|
);
|
||||||
|
|
||||||
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Failed - handle specific error
|
// Failed - handle specific error
|
||||||
const errorCode = luaResult[1];
|
const errorCode = luaResult[1];
|
||||||
let statusCode = 400;
|
let statusCode = 400;
|
||||||
let message = 'Purchase failed';
|
let message = "Purchase failed";
|
||||||
|
|
||||||
switch (errorCode) {
|
switch (errorCode) {
|
||||||
case 'EVENT_NOT_FOUND':
|
case "EVENT_NOT_FOUND":
|
||||||
statusCode = 404;
|
statusCode = 404;
|
||||||
message = 'Event not found';
|
message = "Event not found";
|
||||||
break;
|
break;
|
||||||
case 'NO_TICKETS_AVAILABLE':
|
case "NO_TICKETS_AVAILABLE":
|
||||||
statusCode = 409;
|
statusCode = 409;
|
||||||
message = 'No tickets available for this event';
|
message = "No tickets available for this event";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record metrics for failed purchase
|
||||||
|
metrics.recordTicketSale(eventId, "failed");
|
||||||
|
|
||||||
logger.logPurchase(eventId, null, purchaseId, false, errorCode);
|
logger.logPurchase(eventId, null, purchaseId, false, errorCode);
|
||||||
return res.status(statusCode).json({
|
return res.status(statusCode).json({
|
||||||
success: false,
|
success: false,
|
||||||
message,
|
message,
|
||||||
errorCode,
|
errorCode,
|
||||||
eventId,
|
eventId,
|
||||||
purchaseId
|
purchaseId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (redisError) {
|
} catch (redisError) {
|
||||||
logger.error('Redis purchase failed, attempting fallback:', redisError);
|
logger.error(
|
||||||
|
"Redis purchase failed, attempting fallback:",
|
||||||
|
redisError
|
||||||
|
);
|
||||||
// Activate fallback if not already active
|
// Activate fallback if not already active
|
||||||
if (!fallbackStore.isActive) {
|
if (!fallbackStore.isActive) {
|
||||||
fallbackStore.activate('Redis purchase operation failed');
|
fallbackStore.activate("Redis purchase operation failed");
|
||||||
|
// Try to sync with Redis data if possible
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fallbackStore.isActive && fallbackStore.events.size === 0) {
|
||||||
|
fallbackStore.attemptReseed();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
throw redisError; // Will be caught by outer try-catch for fallback
|
throw redisError; // Will be caught by outer try-catch for fallback
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Redis not available');
|
throw new Error("Redis not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
result.responseTime = `${responseTime}ms`;
|
result.responseTime = `${responseTime}ms`;
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fallback to in-memory store
|
// Fallback to in-memory store
|
||||||
try {
|
try {
|
||||||
if (!fallbackStore.isActive) {
|
if (!fallbackStore.isActive) {
|
||||||
fallbackStore.activate('Redis connection failed during purchase');
|
fallbackStore.activate("Redis connection failed during purchase");
|
||||||
|
// Try to sync with Redis data if possible
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fallbackStore.isActive && fallbackStore.events.size === 0) {
|
||||||
|
fallbackStore.attemptReseed();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackResult = fallbackStore.purchaseTicket(eventId, purchaseId);
|
const fallbackResult = fallbackStore.purchaseTicket(
|
||||||
|
eventId,
|
||||||
|
purchaseId
|
||||||
|
);
|
||||||
|
|
||||||
if (fallbackResult.success) {
|
if (fallbackResult.success) {
|
||||||
// Generate PDF for fallback purchase
|
// Generate PDF for fallback purchase
|
||||||
@@ -240,9 +299,10 @@ app.post('/buy/:eventId', async (req, res) => {
|
|||||||
eventId,
|
eventId,
|
||||||
purchaseId,
|
purchaseId,
|
||||||
eventName: eventStats?.name || `Event ${eventId}`,
|
eventName: eventStats?.name || `Event ${eventId}`,
|
||||||
eventDescription: eventStats?.description || 'Event description not available',
|
eventDescription:
|
||||||
|
eventStats?.description || "Event description not available",
|
||||||
timestamp,
|
timestamp,
|
||||||
soldCount: fallbackResult.soldCount
|
soldCount: fallbackResult.soldCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
@@ -252,20 +312,29 @@ app.post('/buy/:eventId', async (req, res) => {
|
|||||||
purchaseId,
|
purchaseId,
|
||||||
eventId,
|
eventId,
|
||||||
soldCount: fallbackResult.soldCount,
|
soldCount: fallbackResult.soldCount,
|
||||||
message: 'Ticket purchased successfully (fallback mode)!',
|
message: "Ticket purchased successfully (fallback mode)!",
|
||||||
usingFallback: true,
|
usingFallback: true,
|
||||||
responseTime: `${responseTime}ms`,
|
responseTime: `${responseTime}ms`,
|
||||||
pdf: {
|
pdf: {
|
||||||
generated: pdfResult.success,
|
generated: pdfResult.success,
|
||||||
filename: pdfResult.filename,
|
filename: pdfResult.filename,
|
||||||
downloadUrl: `/tickets/${purchaseId}`
|
downloadUrl: `/tickets/${purchaseId}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`PDF ticket generated for fallback purchase ${purchaseId}`);
|
// Record metrics for successful fallback purchase
|
||||||
|
metrics.recordTicketSale(eventId, "success_fallback");
|
||||||
|
metrics.updateTicketMetrics(
|
||||||
|
eventId,
|
||||||
|
fallbackResult.soldCount,
|
||||||
|
fallbackResult.remainingTickets || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`PDF ticket generated for fallback purchase ${purchaseId}`
|
||||||
|
);
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
logger.error('PDF generation failed in fallback mode:', pdfError);
|
logger.error("PDF generation failed in fallback mode:", pdfError);
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
res.json({
|
res.json({
|
||||||
@@ -274,105 +343,138 @@ app.post('/buy/:eventId', async (req, res) => {
|
|||||||
purchaseId,
|
purchaseId,
|
||||||
eventId,
|
eventId,
|
||||||
soldCount: fallbackResult.soldCount,
|
soldCount: fallbackResult.soldCount,
|
||||||
message: 'Ticket purchased successfully (fallback mode, PDF generation failed)!',
|
message:
|
||||||
|
"Ticket purchased successfully (fallback mode, PDF generation failed)!",
|
||||||
usingFallback: true,
|
usingFallback: true,
|
||||||
responseTime: `${responseTime}ms`,
|
responseTime: `${responseTime}ms`,
|
||||||
pdf: {
|
pdf: {
|
||||||
generated: false,
|
generated: false,
|
||||||
error: 'PDF generation failed'
|
error: "PDF generation failed",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Record metrics for successful fallback purchase (even with PDF failure)
|
||||||
|
metrics.recordTicketSale(eventId, "success_fallback");
|
||||||
|
metrics.updateTicketMetrics(
|
||||||
|
eventId,
|
||||||
|
fallbackResult.soldCount,
|
||||||
|
fallbackResult.remainingTickets || 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let statusCode = 400;
|
let statusCode = 400;
|
||||||
let message = 'Purchase failed';
|
let message = "Purchase failed";
|
||||||
|
|
||||||
switch (fallbackResult.error) {
|
switch (fallbackResult.error) {
|
||||||
case 'EVENT_NOT_FOUND':
|
case "EVENT_NOT_FOUND":
|
||||||
statusCode = 404;
|
statusCode = 404;
|
||||||
message = 'Event not found';
|
message = "Event not found";
|
||||||
break;
|
break;
|
||||||
case 'NO_TICKETS_AVAILABLE':
|
case "NO_TICKETS_AVAILABLE":
|
||||||
statusCode = 409;
|
statusCode = 409;
|
||||||
message = 'No tickets available for this event';
|
message = "No tickets available for this event";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.logPurchase(eventId, null, purchaseId, false, fallbackResult.error);
|
// Record metrics for failed fallback purchase
|
||||||
|
metrics.recordTicketSale(eventId, "failed_fallback");
|
||||||
|
|
||||||
|
logger.logPurchase(
|
||||||
|
eventId,
|
||||||
|
null,
|
||||||
|
purchaseId,
|
||||||
|
false,
|
||||||
|
fallbackResult.error
|
||||||
|
);
|
||||||
res.status(statusCode).json({
|
res.status(statusCode).json({
|
||||||
success: false,
|
success: false,
|
||||||
message,
|
message,
|
||||||
errorCode: fallbackResult.error,
|
errorCode: fallbackResult.error,
|
||||||
eventId,
|
eventId,
|
||||||
purchaseId,
|
purchaseId,
|
||||||
usingFallback: true
|
usingFallback: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logger.error('Both Redis and fallback failed:', fallbackError);
|
logger.error("Both Redis and fallback failed:", fallbackError);
|
||||||
|
|
||||||
|
// Record metrics for system failure
|
||||||
|
metrics.recordTicketSale(eventId, "system_error");
|
||||||
|
|
||||||
logger.logPurchase(eventId, null, purchaseId, false, fallbackError);
|
logger.logPurchase(eventId, null, purchaseId, false, fallbackError);
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'System temporarily unavailable',
|
message: "System temporarily unavailable",
|
||||||
eventId,
|
eventId,
|
||||||
purchaseId
|
purchaseId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Download ticket PDF endpoint
|
// Download ticket PDF endpoint
|
||||||
app.get('/tickets/:purchaseId', async (req, res) => {
|
app.get(
|
||||||
|
"/tickets/:purchaseId",
|
||||||
|
security.validatePurchaseId,
|
||||||
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const purchaseId = req.params.purchaseId;
|
const purchaseId = req.params.purchaseId;
|
||||||
|
|
||||||
if (!pdfGenerator.ticketExists(purchaseId)) {
|
if (!pdfGenerator.ticketExists(purchaseId)) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Ticket not found'
|
message: "Ticket not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = pdfGenerator.getTicketPath(purchaseId);
|
const filepath = pdfGenerator.getTicketPath(purchaseId);
|
||||||
const filename = `ticket-${purchaseId}.pdf`;
|
const filename = `ticket-${purchaseId}.pdf`;
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename}"`
|
||||||
|
);
|
||||||
|
|
||||||
const fileStream = require('fs').createReadStream(filepath);
|
const fileStream = require("fs").createReadStream(filepath);
|
||||||
fileStream.pipe(res);
|
fileStream.pipe(res);
|
||||||
|
|
||||||
logger.info(`PDF ticket downloaded: ${purchaseId}`);
|
logger.info(`PDF ticket downloaded: ${purchaseId}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error downloading ticket:', error);
|
logger.error("Error downloading ticket:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to download ticket'
|
message: "Failed to download ticket",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// PDF management endpoint
|
// PDF management endpoint
|
||||||
app.get('/admin/pdf-stats', async (req, res) => {
|
app.get("/admin/pdf-stats", security.adminLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stats = pdfGenerator.getStats();
|
const stats = pdfGenerator.getStats();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
stats
|
stats,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting PDF stats:', error);
|
logger.error("Error getting PDF stats:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to get PDF statistics'
|
message: "Failed to get PDF statistics",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup old tickets endpoint
|
// Cleanup old tickets endpoint
|
||||||
app.post('/admin/cleanup-tickets', async (req, res) => {
|
app.post(
|
||||||
|
"/admin/cleanup-tickets",
|
||||||
|
security.adminLimiter,
|
||||||
|
security.validateCleanupRequest,
|
||||||
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const maxAgeHours = req.body.maxAgeHours || 24;
|
const maxAgeHours = req.body.maxAgeHours || 24;
|
||||||
const deletedCount = await pdfGenerator.cleanupOldTickets(maxAgeHours);
|
const deletedCount = await pdfGenerator.cleanupOldTickets(maxAgeHours);
|
||||||
@@ -380,19 +482,89 @@ app.post('/admin/cleanup-tickets', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Cleaned up ${deletedCount} old tickets`,
|
message: `Cleaned up ${deletedCount} old tickets`,
|
||||||
deletedCount
|
deletedCount,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error cleaning up tickets:', error);
|
logger.error("Error cleaning up tickets:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to cleanup tickets'
|
message: "Failed to cleanup tickets",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed fallback store endpoint
|
||||||
|
app.post("/admin/seed-fallback", security.adminLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (redisClient.isHealthy()) {
|
||||||
|
// Activate fallback store temporarily for seeding
|
||||||
|
fallbackStore.activate("Manual seeding from admin endpoint");
|
||||||
|
|
||||||
|
// Get all events from Redis and seed fallback store
|
||||||
|
const events = await redisClient.getAllEvents();
|
||||||
|
const globalStats = await redisClient.getGlobalStats();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// Get remaining tickets for this event
|
||||||
|
const remainingTickets = await redisClient.getRemainingTickets(
|
||||||
|
event.eventId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create metadata object
|
||||||
|
const metadata = {
|
||||||
|
eventId: event.eventId,
|
||||||
|
totalTickets: event.totalTickets,
|
||||||
|
soldTickets: event.soldTickets,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
name: event.name,
|
||||||
|
description: event.description,
|
||||||
|
lastSoldAt: event.lastSoldAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed the event in fallback store
|
||||||
|
fallbackStore.seedEvent(event.eventId, remainingTickets, metadata);
|
||||||
|
|
||||||
|
// Update sold tickets count
|
||||||
|
const fallbackEvent = fallbackStore.events.get(event.eventId);
|
||||||
|
if (fallbackEvent) {
|
||||||
|
fallbackEvent.soldTickets = event.soldTickets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update global stats
|
||||||
|
if (globalStats) {
|
||||||
|
fallbackStore.globalStats.totalSold = globalStats.totalSold;
|
||||||
|
fallbackStore.globalStats.lastSeeded = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate fallback store (will be activated when needed)
|
||||||
|
fallbackStore.deactivate();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Fallback store seeded with ${events.length} events`,
|
||||||
|
eventsCount: events.length,
|
||||||
|
totalTickets: globalStats?.totalTickets || 0,
|
||||||
|
totalSold: globalStats?.totalSold || 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(503).json({
|
||||||
|
success: false,
|
||||||
|
message: "Redis not available - cannot seed fallback store",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error seeding fallback store:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to seed fallback store",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Metrics endpoint (Prometheus compatible)
|
// Metrics endpoint (Prometheus compatible)
|
||||||
app.get('/metrics', async (req, res) => {
|
app.get("/metrics", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let globalStats, events;
|
let globalStats, events;
|
||||||
|
|
||||||
@@ -416,17 +588,17 @@ app.get('/metrics', async (req, res) => {
|
|||||||
usingFallback: fallbackStore.isActive,
|
usingFallback: fallbackStore.isActive,
|
||||||
redisConnected: redisClient.isHealthy(),
|
redisConnected: redisClient.isHealthy(),
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
memoryUsage: process.memoryUsage()
|
memoryUsage: process.memoryUsage(),
|
||||||
},
|
},
|
||||||
pdf: pdfStats
|
pdf: pdfStats,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(metrics);
|
res.json(metrics);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error generating metrics:', error);
|
logger.error("Error generating metrics:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to generate metrics'
|
message: "Failed to generate metrics",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -436,7 +608,10 @@ async function initializeServer() {
|
|||||||
try {
|
try {
|
||||||
// Connect to Redis
|
// Connect to Redis
|
||||||
await redisClient.connect();
|
await redisClient.connect();
|
||||||
logger.info('Redis connected successfully');
|
logger.info("Redis connected successfully");
|
||||||
|
|
||||||
|
// Ensure fallback store is seeded with current Redis data
|
||||||
|
await ensureFallbackStoreSeeded();
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
@@ -445,39 +620,108 @@ async function initializeServer() {
|
|||||||
logger.info(`📈 Metrics: http://localhost:${port}/metrics`);
|
logger.info(`📈 Metrics: http://localhost:${port}/metrics`);
|
||||||
logger.info(`🎫 Events: http://localhost:${port}/events`);
|
logger.info(`🎫 Events: http://localhost:${port}/events`);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to initialize server:', error);
|
logger.error("Failed to initialize server:", error);
|
||||||
logger.warn('Starting server with fallback store only');
|
logger.warn("Starting server with fallback store only");
|
||||||
|
|
||||||
fallbackStore.activate('Redis connection failed at startup');
|
fallbackStore.activate("Redis connection failed at startup");
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
logger.warn(`⚠️ Server running in FALLBACK MODE on port ${port}`);
|
logger.warn(`⚠️ Server running in FALLBACK MODE on port ${port}`);
|
||||||
logger.warn('Redis connection failed - using in-memory store');
|
logger.warn("Redis connection failed - using in-memory store");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure fallback store is seeded with current Redis data
|
||||||
|
async function ensureFallbackStoreSeeded() {
|
||||||
|
try {
|
||||||
|
// Check if fallback store needs seeding
|
||||||
|
if (fallbackStore.events.size === 0) {
|
||||||
|
logger.info(
|
||||||
|
"Fallback store is empty, seeding with current Redis data..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Temporarily activate fallback store for seeding
|
||||||
|
fallbackStore.activate("Seeding during server initialization");
|
||||||
|
|
||||||
|
// Get all events from Redis and seed fallback store
|
||||||
|
const events = await redisClient.getAllEvents();
|
||||||
|
const globalStats = await redisClient.getGlobalStats();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// Get remaining tickets for this event
|
||||||
|
const remainingTickets = await redisClient.getRemainingTickets(
|
||||||
|
event.eventId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create metadata object
|
||||||
|
const metadata = {
|
||||||
|
eventId: event.eventId,
|
||||||
|
totalTickets: event.totalTickets,
|
||||||
|
soldTickets: event.soldTickets,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
name: event.name,
|
||||||
|
description: event.description,
|
||||||
|
lastSoldAt: event.lastSoldAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed the event in fallback store
|
||||||
|
fallbackStore.seedEvent(event.eventId, remainingTickets, metadata);
|
||||||
|
|
||||||
|
// Update sold tickets count
|
||||||
|
const fallbackEvent = fallbackStore.events.get(event.eventId);
|
||||||
|
if (fallbackEvent) {
|
||||||
|
fallbackEvent.soldTickets = event.soldTickets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update global stats
|
||||||
|
if (globalStats) {
|
||||||
|
fallbackStore.globalStats.totalSold = globalStats.totalSold;
|
||||||
|
fallbackStore.globalStats.lastSeeded = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fallback store seeded with ${events.length} events`);
|
||||||
|
|
||||||
|
// Deactivate fallback store (will be activated when needed)
|
||||||
|
fallbackStore.deactivate();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error seeding fallback store during initialization:", error);
|
||||||
|
// Don't fail server startup if fallback seeding fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error("Unhandled error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Internal Server Error",
|
||||||
|
error: process.env.NODE_ENV === "development" ? err.message : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGINT', async () => {
|
process.on("SIGINT", async () => {
|
||||||
logger.info('Received SIGINT, shutting down gracefully...');
|
logger.info("Received SIGINT, shutting down gracefully...");
|
||||||
try {
|
try {
|
||||||
await redisClient.disconnect();
|
await redisClient.disconnect();
|
||||||
logger.info('Redis disconnected');
|
logger.info("Redis disconnected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error disconnecting Redis:', error);
|
logger.error("Error disconnecting Redis:", error);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on("SIGTERM", async () => {
|
||||||
logger.info('Received SIGTERM, shutting down gracefully...');
|
logger.info("Received SIGTERM, shutting down gracefully...");
|
||||||
try {
|
try {
|
||||||
await redisClient.disconnect();
|
await redisClient.disconnect();
|
||||||
logger.info('Redis disconnected');
|
logger.info("Redis disconnected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error disconnecting Redis:', error);
|
logger.error("Error disconnecting Redis:", error);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ redis.call('HINCRBY', globalKey, 'totalSold', 1)
|
|||||||
|
|
||||||
-- Store purchase record
|
-- Store purchase record
|
||||||
local purchaseKey = 'purchase:' .. purchaseId
|
local purchaseKey = 'purchase:' .. purchaseId
|
||||||
redis.call('HSET', purchaseKey, {
|
local eventIdFromKey = string.match(ticketKey, 'event:(%d+):tickets')
|
||||||
|
redis.call('HSET', purchaseKey,
|
||||||
'ticketId', ticket,
|
'ticketId', ticket,
|
||||||
'eventId', string.match(ticketKey, 'event:(%d+):tickets'),
|
'eventId', eventIdFromKey,
|
||||||
'purchaseId', purchaseId,
|
'purchaseId', purchaseId,
|
||||||
'timestamp', timestamp,
|
'timestamp', timestamp,
|
||||||
'status', 'completed'
|
'status', 'completed'
|
||||||
})
|
)
|
||||||
|
|
||||||
-- Set expiration for purchase record (24 hours)
|
-- Set expiration for purchase record (24 hours)
|
||||||
redis.call('EXPIRE', purchaseKey, 86400)
|
redis.call('EXPIRE', purchaseKey, 86400)
|
||||||
|
|||||||
+87
-14
@@ -1,4 +1,4 @@
|
|||||||
const logger = require('./logger');
|
const logger = require("./logger");
|
||||||
|
|
||||||
class FallbackStore {
|
class FallbackStore {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -7,20 +7,30 @@ class FallbackStore {
|
|||||||
totalEvents: 0,
|
totalEvents: 0,
|
||||||
totalTickets: 0,
|
totalTickets: 0,
|
||||||
totalSold: 0,
|
totalSold: 0,
|
||||||
lastSeeded: null
|
lastSeeded: null,
|
||||||
};
|
};
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
activate(reason) {
|
activate(reason) {
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
logger.logFallback('In-Memory Store Activated', reason);
|
logger.logFallback("In-Memory Store Activated", reason);
|
||||||
logger.warn('⚠️ FALLBACK MODE: Using in-memory store - data will not persist!');
|
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() {
|
deactivate() {
|
||||||
this.isActive = false;
|
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) {
|
seedEvent(eventId, tickets, metadata) {
|
||||||
@@ -29,13 +39,15 @@ class FallbackStore {
|
|||||||
this.events.set(eventId, {
|
this.events.set(eventId, {
|
||||||
tickets: [...tickets], // Create a copy
|
tickets: [...tickets], // Create a copy
|
||||||
metadata: { ...metadata },
|
metadata: { ...metadata },
|
||||||
soldTickets: 0
|
soldTickets: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.globalStats.totalEvents++;
|
this.globalStats.totalEvents++;
|
||||||
this.globalStats.totalTickets += tickets.length;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,11 +56,11 @@ class FallbackStore {
|
|||||||
|
|
||||||
const event = this.events.get(eventId);
|
const event = this.events.get(eventId);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return { success: false, error: 'EVENT_NOT_FOUND' };
|
return { success: false, error: "EVENT_NOT_FOUND" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.tickets.length === 0) {
|
if (event.tickets.length === 0) {
|
||||||
return { success: false, error: 'NO_TICKETS_AVAILABLE' };
|
return { success: false, error: "NO_TICKETS_AVAILABLE" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically remove a ticket
|
// Atomically remove a ticket
|
||||||
@@ -63,7 +75,7 @@ class FallbackStore {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
ticket,
|
ticket,
|
||||||
soldCount: event.soldTickets
|
soldCount: event.soldTickets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +93,7 @@ class FallbackStore {
|
|||||||
soldTickets: event.soldTickets,
|
soldTickets: event.soldTickets,
|
||||||
remainingTickets: event.tickets.length,
|
remainingTickets: event.tickets.length,
|
||||||
createdAt: event.metadata.createdAt,
|
createdAt: event.metadata.createdAt,
|
||||||
lastSoldAt: event.metadata.lastSoldAt || 'never'
|
lastSoldAt: event.metadata.lastSoldAt || "never",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +125,9 @@ class FallbackStore {
|
|||||||
totalEvents: 0,
|
totalEvents: 0,
|
||||||
totalTickets: 0,
|
totalTickets: 0,
|
||||||
totalSold: 0,
|
totalSold: 0,
|
||||||
lastSeeded: null
|
lastSeeded: null,
|
||||||
};
|
};
|
||||||
logger.info('Fallback store cleared');
|
logger.info("Fallback store cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus() {
|
getStatus() {
|
||||||
@@ -123,9 +135,70 @@ class FallbackStore {
|
|||||||
active: this.isActive,
|
active: this.isActive,
|
||||||
eventsCount: this.events.size,
|
eventsCount: this.events.size,
|
||||||
totalTickets: this.globalStats.totalTickets,
|
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();
|
module.exports = new FallbackStore();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+82
-62
@@ -1,11 +1,11 @@
|
|||||||
const PDFDocument = require('pdfkit');
|
const PDFDocument = require("pdfkit");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const logger = require('./logger');
|
const logger = require("./logger");
|
||||||
|
|
||||||
class PDFGenerator {
|
class PDFGenerator {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.outputDir = process.env.PDF_OUTPUT_DIR || './tickets';
|
this.outputDir = process.env.PDF_OUTPUT_DIR || "./tickets";
|
||||||
this.ensureOutputDirectory();
|
this.ensureOutputDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +26,13 @@ class PDFGenerator {
|
|||||||
eventName,
|
eventName,
|
||||||
eventDescription,
|
eventDescription,
|
||||||
timestamp,
|
timestamp,
|
||||||
soldCount
|
soldCount,
|
||||||
} = ticketData;
|
} = ticketData;
|
||||||
|
|
||||||
// Create PDF document
|
// Create PDF document
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: 'A4',
|
size: "A4",
|
||||||
margin: 50
|
margin: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate filename
|
// Generate filename
|
||||||
@@ -44,97 +44,114 @@ class PDFGenerator {
|
|||||||
doc.pipe(stream);
|
doc.pipe(stream);
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
doc.fontSize(24)
|
doc
|
||||||
.fillColor('#2c3e50')
|
.fontSize(24)
|
||||||
.text('🎫 TICKET RECEIPT', 50, 50, { align: 'center' });
|
.fillColor("#2c3e50")
|
||||||
|
.text("TICKET RECEIPT", 50, 50, { align: "center" });
|
||||||
|
|
||||||
// Divider line
|
// Divider line
|
||||||
doc.moveTo(50, 90)
|
doc
|
||||||
|
.moveTo(50, 90)
|
||||||
.lineTo(545, 90)
|
.lineTo(545, 90)
|
||||||
.strokeColor('#3498db')
|
.strokeColor("#3498db")
|
||||||
.lineWidth(2)
|
.lineWidth(2)
|
||||||
.stroke();
|
.stroke();
|
||||||
|
|
||||||
// Event Information
|
// Event Information
|
||||||
doc.fontSize(18)
|
doc
|
||||||
.fillColor('#2c3e50')
|
.fontSize(18)
|
||||||
.text('Event Information', 50, 120);
|
.fillColor("#2c3e50")
|
||||||
|
.text("Event Information", 50, 120);
|
||||||
|
|
||||||
doc.fontSize(12)
|
doc
|
||||||
.fillColor('#34495e')
|
.fontSize(12)
|
||||||
|
.fillColor("#34495e")
|
||||||
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
|
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
|
||||||
.text(`Event ID: ${eventId}`, 50, 170)
|
.text(`Event ID: ${eventId}`, 50, 170)
|
||||||
.text(`Description: ${eventDescription || 'No description available'}`, 50, 190);
|
.text(
|
||||||
|
`Description: ${eventDescription || "No description available"}`,
|
||||||
|
50,
|
||||||
|
190
|
||||||
|
);
|
||||||
|
|
||||||
// Ticket Information
|
// Ticket Information
|
||||||
doc.fontSize(18)
|
doc.fontSize(18).fillColor("#2c3e50").text("Ticket Details", 50, 230);
|
||||||
.fillColor('#2c3e50')
|
|
||||||
.text('Ticket Details', 50, 230);
|
|
||||||
|
|
||||||
doc.fontSize(12)
|
doc
|
||||||
.fillColor('#34495e')
|
.fontSize(12)
|
||||||
|
.fillColor("#34495e")
|
||||||
.text(`Ticket ID: ${ticketId}`, 50, 260)
|
.text(`Ticket ID: ${ticketId}`, 50, 260)
|
||||||
.text(`Purchase ID: ${purchaseId}`, 50, 280)
|
.text(`Purchase ID: ${purchaseId}`, 50, 280)
|
||||||
.text(`Purchase Date: ${new Date(timestamp).toLocaleString()}`, 50, 300)
|
.text(
|
||||||
|
`Purchase Date: ${new Date(timestamp).toLocaleString()}`,
|
||||||
|
50,
|
||||||
|
300
|
||||||
|
)
|
||||||
.text(`Ticket Number: #${soldCount}`, 50, 320);
|
.text(`Ticket Number: #${soldCount}`, 50, 320);
|
||||||
|
|
||||||
// QR Code placeholder (you could integrate a QR code library here)
|
// QR Code placeholder (you could integrate a QR code library here)
|
||||||
doc.rect(400, 250, 100, 100)
|
doc
|
||||||
.strokeColor('#bdc3c7')
|
.rect(400, 250, 100, 100)
|
||||||
|
.strokeColor("#bdc3c7")
|
||||||
.lineWidth(1)
|
.lineWidth(1)
|
||||||
.stroke();
|
.stroke();
|
||||||
|
|
||||||
doc.fontSize(10)
|
doc
|
||||||
.fillColor('#7f8c8d')
|
.fontSize(10)
|
||||||
.text('QR Code', 430, 305, { align: 'center' });
|
.fillColor("#7f8c8d")
|
||||||
|
.text("QR Code", 430, 305, { align: "center" });
|
||||||
|
|
||||||
// Terms and Conditions
|
// Terms and Conditions
|
||||||
doc.fontSize(14)
|
doc
|
||||||
.fillColor('#2c3e50')
|
.fontSize(14)
|
||||||
.text('Terms & Conditions', 50, 380);
|
.fillColor("#2c3e50")
|
||||||
|
.text("Terms & Conditions", 50, 380);
|
||||||
|
|
||||||
doc.fontSize(10)
|
doc
|
||||||
.fillColor('#7f8c8d')
|
.fontSize(10)
|
||||||
.text('• This ticket is non-refundable and non-transferable', 50, 410)
|
.fillColor("#7f8c8d")
|
||||||
.text('• Please arrive 30 minutes before the event starts', 50, 425)
|
.text("• This ticket is non-refundable and non-transferable", 50, 410)
|
||||||
.text('• Valid photo ID required for entry', 50, 440)
|
.text("• Please arrive 30 minutes before the event starts", 50, 425)
|
||||||
.text('• This ticket is valid only for the specified event and date', 50, 455);
|
.text("• Valid photo ID required for entry", 50, 440)
|
||||||
|
.text(
|
||||||
|
"• This ticket is valid only for the specified event and date",
|
||||||
|
50,
|
||||||
|
455
|
||||||
|
);
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
doc.fontSize(8)
|
doc
|
||||||
.fillColor('#95a5a6')
|
.fontSize(8)
|
||||||
|
.fillColor("#95a5a6")
|
||||||
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
|
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
|
||||||
.text('Powered by Ticket Microservice', 50, 535)
|
.text("Powered by Ticket Microservice", 50, 535)
|
||||||
.text(`System ID: ${process.env.NODE_ENV || 'development'}`, 50, 550);
|
.text(`System ID: ${process.env.NODE_ENV || "development"}`, 50, 550);
|
||||||
|
|
||||||
// Security watermark
|
// Security watermark
|
||||||
doc.fontSize(60)
|
doc.fontSize(60).fillColor("#ecf0f1").text("VALID", 200, 300, {
|
||||||
.fillColor('#ecf0f1')
|
|
||||||
.text('VALID', 200, 300, {
|
|
||||||
rotate: -45,
|
rotate: -45,
|
||||||
opacity: 0.1
|
opacity: 0.1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Finalize PDF
|
// Finalize PDF
|
||||||
doc.end();
|
doc.end();
|
||||||
|
|
||||||
stream.on('finish', () => {
|
stream.on("finish", () => {
|
||||||
logger.info(`PDF ticket generated: ${filename}`);
|
logger.info(`PDF ticket generated: ${filename}`);
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
filename,
|
filename,
|
||||||
filepath,
|
filepath,
|
||||||
size: fs.statSync(filepath).size
|
size: fs.statSync(filepath).size,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (error) => {
|
stream.on("error", (error) => {
|
||||||
logger.error('Error writing PDF file:', error);
|
logger.error("Error writing PDF file:", error);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error generating PDF:', error);
|
logger.error("Error generating PDF:", error);
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,11 +165,14 @@ class PDFGenerator {
|
|||||||
const result = await this.generateTicketPDF(ticket);
|
const result = await this.generateTicketPDF(ticket);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
} catch (error) {
|
} 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({
|
results.push({
|
||||||
success: false,
|
success: false,
|
||||||
ticketId: ticket.ticketId,
|
ticketId: ticket.ticketId,
|
||||||
error: error.message
|
error: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +211,7 @@ class PDFGenerator {
|
|||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.pdf')) {
|
if (file.endsWith(".pdf")) {
|
||||||
const filepath = path.join(this.outputDir, file);
|
const filepath = path.join(this.outputDir, file);
|
||||||
const stats = fs.statSync(filepath);
|
const stats = fs.statSync(filepath);
|
||||||
const ageHours = (now - stats.mtime.getTime()) / (1000 * 60 * 60);
|
const ageHours = (now - stats.mtime.getTime()) / (1000 * 60 * 60);
|
||||||
@@ -209,7 +229,7 @@ class PDFGenerator {
|
|||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error cleaning up old tickets:', error);
|
logger.error("Error cleaning up old tickets:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +237,7 @@ class PDFGenerator {
|
|||||||
getStats() {
|
getStats() {
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(this.outputDir);
|
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;
|
let totalSize = 0;
|
||||||
for (const file of pdfFiles) {
|
for (const file of pdfFiles) {
|
||||||
@@ -229,16 +249,16 @@ class PDFGenerator {
|
|||||||
totalTickets: pdfFiles.length,
|
totalTickets: pdfFiles.length,
|
||||||
totalSizeBytes: totalSize,
|
totalSizeBytes: totalSize,
|
||||||
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
||||||
outputDirectory: this.outputDir
|
outputDirectory: this.outputDir,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting PDF stats:', error);
|
logger.error("Error getting PDF stats:", error);
|
||||||
return {
|
return {
|
||||||
totalTickets: 0,
|
totalTickets: 0,
|
||||||
totalSizeBytes: 0,
|
totalSizeBytes: 0,
|
||||||
totalSizeMB: '0.00',
|
totalSizeMB: "0.00",
|
||||||
outputDirectory: this.outputDir,
|
outputDirectory: this.outputDir,
|
||||||
error: error.message
|
error: error.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-50
@@ -1,7 +1,7 @@
|
|||||||
const redis = require('redis');
|
const redis = require("redis");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const logger = require('./logger');
|
const logger = require("./logger");
|
||||||
|
|
||||||
class RedisClient {
|
class RedisClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -13,28 +13,28 @@ class RedisClient {
|
|||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
try {
|
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 = redis.createClient({ url: redisUrl });
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
this.client.on("error", (err) => {
|
||||||
logger.error('Redis Client Error:', err);
|
logger.error("Redis Client Error:", err);
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
this.client.on("connect", () => {
|
||||||
logger.info('Redis client connected');
|
logger.info("Redis client connected");
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('disconnect', () => {
|
this.client.on("disconnect", () => {
|
||||||
logger.warn('Redis client disconnected');
|
logger.warn("Redis client disconnected");
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
return this.client;
|
return this.client;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to connect to Redis:', error);
|
logger.error("Failed to connect to Redis:", error);
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -42,68 +42,74 @@ class RedisClient {
|
|||||||
|
|
||||||
loadLuaScripts() {
|
loadLuaScripts() {
|
||||||
try {
|
try {
|
||||||
const luaDir = path.join(__dirname, '../lua');
|
const luaDir = path.join(__dirname, "../lua");
|
||||||
|
|
||||||
// Load purchase ticket script
|
// Load purchase ticket script
|
||||||
const purchaseScript = fs.readFileSync(
|
const purchaseScript = fs.readFileSync(
|
||||||
path.join(luaDir, 'purchase-ticket.lua'),
|
path.join(luaDir, "purchase-ticket.lua"),
|
||||||
'utf8'
|
"utf8"
|
||||||
);
|
);
|
||||||
this.luaScripts.purchaseTicket = purchaseScript;
|
this.luaScripts.purchaseTicket = purchaseScript;
|
||||||
|
|
||||||
// Load event stats script
|
// Load event stats script
|
||||||
const statsScript = fs.readFileSync(
|
const statsScript = fs.readFileSync(
|
||||||
path.join(luaDir, 'get-event-stats.lua'),
|
path.join(luaDir, "get-event-stats.lua"),
|
||||||
'utf8'
|
"utf8"
|
||||||
);
|
);
|
||||||
this.luaScripts.getEventStats = statsScript;
|
this.luaScripts.getEventStats = statsScript;
|
||||||
|
|
||||||
logger.info('Lua scripts loaded successfully');
|
logger.info("Lua scripts loaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to load Lua scripts:', error);
|
logger.error("Failed to load Lua scripts:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async purchaseTicket(eventId, purchaseId, timestamp) {
|
async purchaseTicket(eventId, purchaseId, timestamp) {
|
||||||
if (!this.isConnected) {
|
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 = [
|
const keys = [
|
||||||
`event:${eventId}:tickets`,
|
`event:${eventId}:tickets`,
|
||||||
`event:${eventId}:meta`,
|
`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 {
|
try {
|
||||||
const result = await this.client.eval(
|
const result = await this.client.eval(this.luaScripts.purchaseTicket, {
|
||||||
this.luaScripts.purchaseTicket,
|
keys,
|
||||||
{ keys, arguments: args }
|
arguments: args,
|
||||||
);
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEventStats(eventId) {
|
async getEventStats(eventId) {
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
throw new Error('Redis not connected');
|
throw new Error("Redis not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = [
|
const keys = [`event:${eventId}:meta`, `event:${eventId}:tickets`];
|
||||||
`event:${eventId}:meta`,
|
|
||||||
`event:${eventId}:tickets`
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.client.eval(
|
const result = await this.client.eval(this.luaScripts.getEventStats, {
|
||||||
this.luaScripts.getEventStats,
|
keys,
|
||||||
{ keys }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
@@ -115,57 +121,117 @@ class RedisClient {
|
|||||||
soldTickets: parseInt(result[4]),
|
soldTickets: parseInt(result[4]),
|
||||||
remainingTickets: parseInt(result[5]),
|
remainingTickets: parseInt(result[5]),
|
||||||
createdAt: result[6],
|
createdAt: result[6],
|
||||||
lastSoldAt: result[7]
|
lastSoldAt: result[7],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error executing get event stats script:', error);
|
logger.error("Error executing get event stats script:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllEvents() {
|
async getAllEvents() {
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
throw new Error('Redis not connected');
|
throw new Error("Redis not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventKeys = await this.client.keys('event:*:meta');
|
// Use SCAN instead of KEYS to avoid blocking Redis
|
||||||
const events = [];
|
const events = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
for (const key of eventKeys) {
|
do {
|
||||||
const eventId = key.match(/event:(\d+):meta/)[1];
|
const result = await this.client.scan(cursor, {
|
||||||
const stats = await this.getEventStats(eventId);
|
MATCH: "event:*:meta",
|
||||||
if (stats) {
|
COUNT: 100, // Process in batches
|
||||||
events.push(stats);
|
});
|
||||||
|
|
||||||
|
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;
|
return events;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting all events:', error);
|
logger.error("Error getting all events:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGlobalStats() {
|
async getGlobalStats() {
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
throw new Error('Redis not connected');
|
throw new Error("Redis not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await this.client.hGetAll('global:stats');
|
const stats = await this.client.hGetAll("global:stats");
|
||||||
return {
|
return {
|
||||||
totalEvents: parseInt(stats.totalEvents) || 0,
|
totalEvents: parseInt(stats.totalEvents) || 0,
|
||||||
totalTickets: parseInt(stats.totalTickets) || 0,
|
totalTickets: parseInt(stats.totalTickets) || 0,
|
||||||
totalSold: parseInt(stats.totalSold) || 0,
|
totalSold: parseInt(stats.totalSold) || 0,
|
||||||
lastSeeded: stats.lastSeeded || null
|
lastSeeded: stats.lastSeeded || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting global stats:', error);
|
logger.error("Error getting global stats:", error);
|
||||||
throw 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() {
|
getClient() {
|
||||||
return this.client;
|
return this.client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+112
-84
@@ -1,9 +1,9 @@
|
|||||||
const autocannon = require('autocannon');
|
const autocannon = require("autocannon");
|
||||||
const { performance } = require('perf_hooks');
|
const { performance } = require("perf_hooks");
|
||||||
|
|
||||||
class LoadTester {
|
class LoadTester {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = process.env.TEST_URL || 'http://localhost:3049';
|
this.baseUrl = process.env.TEST_URL || "http://localhost:3049";
|
||||||
this.results = [];
|
this.results = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,18 +12,18 @@ class LoadTester {
|
|||||||
url: `${this.baseUrl}/buy/${eventId}`,
|
url: `${this.baseUrl}/buy/${eventId}`,
|
||||||
connections: 5000,
|
connections: 5000,
|
||||||
duration: 30,
|
duration: 30,
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
...options
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`\n🚀 Starting load test for Event ${eventId}`);
|
console.log(`\n Starting load test for Event ${eventId}`);
|
||||||
console.log(`📊 Connections: ${defaultOptions.connections}`);
|
console.log(`Connections: ${defaultOptions.connections}`);
|
||||||
console.log(`⏱️ Duration: ${defaultOptions.duration}s`);
|
console.log(`Duration: ${defaultOptions.duration}s`);
|
||||||
console.log(`🎯 Target: ${defaultOptions.url}\n`);
|
console.log(`Target: ${defaultOptions.url}\n`);
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class LoadTester {
|
|||||||
eventId,
|
eventId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
duration: endTime - startTime,
|
duration: endTime - startTime,
|
||||||
...result
|
...result,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.results.push(testResult);
|
this.results.push(testResult);
|
||||||
@@ -43,18 +43,22 @@ class LoadTester {
|
|||||||
|
|
||||||
return testResult;
|
return testResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Load test failed:', error);
|
console.error("❌ Load test failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async runMultiEventLoadTest(eventIds = [1, 2, 3], options = {}) {
|
async runMultiEventLoadTest(eventIds = [1, 2, 3], options = {}) {
|
||||||
console.log(`\n🎯 Running multi-event load test for events: ${eventIds.join(', ')}`);
|
console.log(
|
||||||
|
`\n Running multi-event load test for events: ${eventIds.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
const promises = eventIds.map(eventId =>
|
const promises = eventIds.map((eventId) =>
|
||||||
this.runPurchaseLoadTest(eventId, {
|
this.runPurchaseLoadTest(eventId, {
|
||||||
connections: Math.floor((options.connections || 5000) / eventIds.length),
|
connections: Math.floor(
|
||||||
duration: options.duration || 30
|
(options.connections || 5000) / eventIds.length
|
||||||
|
),
|
||||||
|
duration: options.duration || 30,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ class LoadTester {
|
|||||||
this.printSummary(results);
|
this.printSummary(results);
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Multi-event load test failed:', error);
|
console.error("❌ Multi-event load test failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,20 +77,20 @@ class LoadTester {
|
|||||||
url: `${this.baseUrl}/health`,
|
url: `${this.baseUrl}/health`,
|
||||||
connections: 100,
|
connections: 100,
|
||||||
duration: 10,
|
duration: 10,
|
||||||
...options
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('\n🏥 Running health check load test...');
|
console.log("\n Running health check load test...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await autocannon(defaultOptions);
|
const result = await autocannon(defaultOptions);
|
||||||
console.log(`✅ Health check test completed`);
|
console.log(`✅ Health check test completed`);
|
||||||
console.log(`📈 RPS: ${result.requests.average}`);
|
console.log(`RPS: ${result.requests.average}`);
|
||||||
console.log(`⚡ Latency: ${result.latency.average}ms`);
|
console.log(`Latency: ${result.latency.average}ms`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Health check test failed:', error);
|
console.error("❌ Health check test failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,69 +100,87 @@ class LoadTester {
|
|||||||
url: `${this.baseUrl}/metrics`,
|
url: `${this.baseUrl}/metrics`,
|
||||||
connections: 50,
|
connections: 50,
|
||||||
duration: 10,
|
duration: 10,
|
||||||
...options
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('\n📊 Running metrics endpoint test...');
|
console.log("\nRunning metrics endpoint test...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await autocannon(defaultOptions);
|
const result = await autocannon(defaultOptions);
|
||||||
console.log(`✅ Metrics test completed`);
|
console.log(`✅ Metrics test completed`);
|
||||||
console.log(`📈 RPS: ${result.requests.average}`);
|
console.log(`RPS: ${result.requests.average}`);
|
||||||
console.log(`⚡ Latency: ${result.latency.average}ms`);
|
console.log(`Latency: ${result.latency.average}ms`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Metrics test failed:', error);
|
console.error("Metrics test failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
printResults(result) {
|
printResults(result) {
|
||||||
console.log('📋 LOAD TEST RESULTS');
|
console.log("LOAD TEST RESULTS");
|
||||||
console.log('═'.repeat(50));
|
console.log("═".repeat(50));
|
||||||
console.log(`🎯 Event ID: ${result.eventId}`);
|
console.log(`Event ID: ${result.eventId}`);
|
||||||
console.log(`⏱️ Duration: ${(result.duration / 1000).toFixed(2)}s`);
|
console.log(`Duration: ${(result.duration / 1000).toFixed(2)}s`);
|
||||||
console.log(`📊 Total Requests: ${result.requests.total}`);
|
console.log(`Total Requests: ${result.requests.total}`);
|
||||||
console.log(`📈 Requests/sec: ${result.requests.average.toFixed(2)}`);
|
console.log(`Requests/sec: ${result.requests.average.toFixed(2)}`);
|
||||||
console.log(`⚡ Avg Latency: ${result.latency.average.toFixed(2)}ms`);
|
console.log(`Avg Latency: ${result.latency.average.toFixed(2)}ms`);
|
||||||
console.log(`🔥 Max Latency: ${result.latency.max}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(
|
||||||
|
`✅ Success Rate: ${(
|
||||||
|
((result.requests.total - result.non2xx) / result.requests.total) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}%`
|
||||||
|
);
|
||||||
console.log(`❌ Errors: ${result.non2xx}`);
|
console.log(`❌ Errors: ${result.non2xx}`);
|
||||||
console.log(`🔗 Connections: ${result.connections}`);
|
console.log(`Connections: ${result.connections}`);
|
||||||
console.log(`📦 Throughput: ${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`);
|
console.log(
|
||||||
console.log('═'.repeat(50));
|
`Throughput: ${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`
|
||||||
|
);
|
||||||
|
console.log("═".repeat(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
printSummary(results) {
|
printSummary(results) {
|
||||||
console.log('\n📊 MULTI-EVENT TEST SUMMARY');
|
console.log("\nMULTI-EVENT TEST SUMMARY");
|
||||||
console.log('═'.repeat(60));
|
console.log("═".repeat(60));
|
||||||
|
|
||||||
const totalRequests = results.reduce((sum, r) => sum + r.requests.total, 0);
|
const totalRequests = results.reduce((sum, r) => sum + r.requests.total, 0);
|
||||||
const avgRPS = results.reduce((sum, r) => sum + r.requests.average, 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);
|
const totalErrors = results.reduce((sum, r) => sum + r.non2xx, 0);
|
||||||
|
|
||||||
console.log(`🎯 Events Tested: ${results.length}`);
|
console.log(`Events Tested: ${results.length}`);
|
||||||
console.log(`📊 Total Requests: ${totalRequests}`);
|
console.log(`Total Requests: ${totalRequests}`);
|
||||||
console.log(`📈 Combined RPS: ${avgRPS.toFixed(2)}`);
|
console.log(`Combined RPS: ${avgRPS.toFixed(2)}`);
|
||||||
console.log(`⚡ Avg Latency: ${avgLatency.toFixed(2)}ms`);
|
console.log(`Avg Latency: ${avgLatency.toFixed(2)}ms`);
|
||||||
console.log(`✅ Overall Success Rate: ${((totalRequests - totalErrors) / totalRequests * 100).toFixed(2)}%`);
|
console.log(
|
||||||
|
`✅ Overall Success Rate: ${(
|
||||||
|
((totalRequests - totalErrors) / totalRequests) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}%`
|
||||||
|
);
|
||||||
console.log(`❌ Total Errors: ${totalErrors}`);
|
console.log(`❌ Total Errors: ${totalErrors}`);
|
||||||
console.log('═'.repeat(60));
|
console.log("═".repeat(60));
|
||||||
|
|
||||||
// Individual event breakdown
|
// Individual event breakdown
|
||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
console.log(`\n📋 Event ${result.eventId}:`);
|
console.log(`\nEvent ${result.eventId}:`);
|
||||||
console.log(` 📈 RPS: ${result.requests.average.toFixed(2)}`);
|
console.log(` RPS: ${result.requests.average.toFixed(2)}`);
|
||||||
console.log(` ⚡ Latency: ${result.latency.average.toFixed(2)}ms`);
|
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(
|
||||||
|
` ✅ Success: ${(
|
||||||
|
((result.requests.total - result.non2xx) / result.requests.total) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}%`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async runFullTestSuite() {
|
async runFullTestSuite() {
|
||||||
console.log('🚀 STARTING FULL LOAD TEST SUITE');
|
console.log("STARTING FULL LOAD TEST SUITE");
|
||||||
console.log('═'.repeat(60));
|
console.log("═".repeat(60));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Health check test
|
// 1. Health check test
|
||||||
@@ -170,20 +192,19 @@ class LoadTester {
|
|||||||
// 3. Single event high-load test
|
// 3. Single event high-load test
|
||||||
await this.runPurchaseLoadTest(1, {
|
await this.runPurchaseLoadTest(1, {
|
||||||
connections: 5000,
|
connections: 5000,
|
||||||
duration: 30
|
duration: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Multi-event test
|
// 4. Multi-event test
|
||||||
await this.runMultiEventLoadTest([1, 2, 3], {
|
await this.runMultiEventLoadTest([1, 2, 3], {
|
||||||
connections: 6000,
|
connections: 6000,
|
||||||
duration: 30
|
duration: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\n🎉 FULL TEST SUITE COMPLETED!');
|
console.log("\n FULL TEST SUITE COMPLETED!");
|
||||||
console.log(`📊 Total tests run: ${this.results.length + 2}`);
|
console.log(` Total tests run: ${this.results.length + 2}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Test suite failed:', error);
|
console.error("❌ Test suite failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,12 +213,12 @@ class LoadTester {
|
|||||||
return this.results;
|
return this.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportResults(filename = 'load-test-results.json') {
|
exportResults(filename = "load-test-results.json") {
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const data = {
|
const data = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
testSuite: 'Ticket Microservice Load Test',
|
testSuite: "Ticket Microservice Load Test",
|
||||||
results: this.results
|
results: this.results,
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
|
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
|
||||||
@@ -212,39 +233,46 @@ if (require.main === module) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
if (args.includes('--full')) {
|
if (args.includes("--full")) {
|
||||||
await tester.runFullTestSuite();
|
await tester.runFullTestSuite();
|
||||||
} else if (args.includes('--event')) {
|
} else if (args.includes("--event")) {
|
||||||
const eventId = parseInt(args[args.indexOf('--event') + 1]) || 1;
|
const eventId = parseInt(args[args.indexOf("--event") + 1]) || 1;
|
||||||
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 5000;
|
const connections =
|
||||||
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
|
parseInt(args[args.indexOf("--connections") + 1]) || 5000;
|
||||||
|
const duration = parseInt(args[args.indexOf("--duration") + 1]) || 30;
|
||||||
|
|
||||||
await tester.runPurchaseLoadTest(eventId, { connections, duration });
|
await tester.runPurchaseLoadTest(eventId, { connections, duration });
|
||||||
} else if (args.includes('--multi')) {
|
} else if (args.includes("--multi")) {
|
||||||
const eventIds = args.includes('--events')
|
const eventIds = args.includes("--events")
|
||||||
? args[args.indexOf('--events') + 1].split(',').map(Number)
|
? args[args.indexOf("--events") + 1].split(",").map(Number)
|
||||||
: [1, 2, 3];
|
: [1, 2, 3];
|
||||||
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 6000;
|
const connections =
|
||||||
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
|
parseInt(args[args.indexOf("--connections") + 1]) || 6000;
|
||||||
|
const duration = parseInt(args[args.indexOf("--duration") + 1]) || 30;
|
||||||
|
|
||||||
await tester.runMultiEventLoadTest(eventIds, { connections, duration });
|
await tester.runMultiEventLoadTest(eventIds, { connections, duration });
|
||||||
} else {
|
} else {
|
||||||
console.log('🎯 Ticket Microservice Load Tester');
|
console.log("Ticket Microservice Load Tester");
|
||||||
console.log('Usage:');
|
console.log("Usage:");
|
||||||
console.log(' node tests/load-test.js --full # Run full test suite');
|
console.log(
|
||||||
console.log(' node tests/load-test.js --event 1 --connections 5000 --duration 30');
|
" node tests/load-test.js --full # Run full test suite"
|
||||||
console.log(' node tests/load-test.js --multi --events 1,2,3 --connections 6000');
|
);
|
||||||
console.log('');
|
console.log(
|
||||||
console.log('Running default single event test...');
|
" 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);
|
await tester.runPurchaseLoadTest(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.includes('--export')) {
|
if (args.includes("--export")) {
|
||||||
tester.exportResults();
|
tester.exportResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Test execution failed:', error);
|
console.error("Test execution failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user