feat: complete phase one and two of task implementation
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3049
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Event Configuration
|
||||||
|
DEFAULT_TICKETS_PER_EVENT=10000
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# PDF Configuration
|
||||||
|
PDF_OUTPUT_DIR=./tickets
|
||||||
|
|
||||||
|
# Metrics Configuration
|
||||||
|
METRICS_PORT=9090
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3049
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Event Configuration
|
||||||
|
DEFAULT_TICKETS_PER_EVENT=10000
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# PDF Configuration
|
||||||
|
PDF_OUTPUT_DIR=./tickets
|
||||||
|
|
||||||
|
# Metrics Configuration
|
||||||
|
METRICS_PORT=9090
|
||||||
+18
-2
@@ -5,13 +5,29 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"seed": "node seed.js"
|
"dev": "nodemon server.js",
|
||||||
|
"seed": "node seed.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:load": "node tests/load-test.js",
|
||||||
|
"docker:up": "docker-compose up -d",
|
||||||
|
"docker:down": "docker-compose down"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"redis": "^4.7.0"
|
"redis": "^4.7.0",
|
||||||
|
"pdfkit": "^0.15.0",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"prom-client": "^15.1.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"autocannon": "^7.12.0",
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,63 @@
|
|||||||
const redis = require("redis");
|
const redis = require("redis");
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
// Number of tickets to seed; default is 100000, or can be passed as a command line argument
|
// Configuration for seeding
|
||||||
const numTickets = parseInt(process.argv[2]) || 100000;
|
const config = {
|
||||||
|
numEvents: parseInt(process.argv[2]) || 5, // Number of events to create
|
||||||
|
ticketsPerEvent: parseInt(process.argv[3]) || 10000, // Tickets per event
|
||||||
|
redisUrl: process.env.REDIS_URL || "redis://localhost:6379"
|
||||||
|
};
|
||||||
|
|
||||||
// Use environment variable for Redis URL if provided; otherwise default to localhost
|
const client = redis.createClient({ url: config.redisUrl });
|
||||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
||||||
const client = redis.createClient({ url: redisUrl });
|
|
||||||
|
|
||||||
async function seedTickets() {
|
async function seedTickets() {
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
// Clear existing tickets if any
|
console.log(`Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...`);
|
||||||
await client.del("tickets");
|
|
||||||
console.log(`Seeding ${numTickets} tickets into Redis...`);
|
// Clear existing event data
|
||||||
let tickets = [];
|
const existingKeys = await client.keys('event:*');
|
||||||
for (let i = 1; i <= numTickets; i++) {
|
if (existingKeys.length > 0) {
|
||||||
tickets.push(`ticket-${i}`);
|
await client.del(existingKeys);
|
||||||
|
console.log(`Cleared ${existingKeys.length} existing event keys.`);
|
||||||
}
|
}
|
||||||
// Push all tickets into the 'tickets' list
|
|
||||||
await client.rPush("tickets", tickets);
|
// Seed multiple events
|
||||||
console.log(`Seeded ${numTickets} tickets.`);
|
for (let eventId = 1; eventId <= config.numEvents; eventId++) {
|
||||||
|
const eventKey = `event:${eventId}:tickets`;
|
||||||
|
const metaKey = `event:${eventId}:meta`;
|
||||||
|
|
||||||
|
// Generate tickets for this event
|
||||||
|
const tickets = [];
|
||||||
|
for (let i = 1; i <= config.ticketsPerEvent; i++) {
|
||||||
|
tickets.push(`ticket-${eventId}-${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tickets in Redis list
|
||||||
|
await client.rPush(eventKey, tickets);
|
||||||
|
|
||||||
|
// Store event metadata
|
||||||
|
await client.hSet(metaKey, {
|
||||||
|
eventId: eventId,
|
||||||
|
totalTickets: config.ticketsPerEvent,
|
||||||
|
soldTickets: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
name: `Event ${eventId}`,
|
||||||
|
description: `Sample event ${eventId} for load testing`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store global stats
|
||||||
|
await client.hSet('global:stats', {
|
||||||
|
totalEvents: config.numEvents,
|
||||||
|
totalTickets: config.numEvents * config.ticketsPerEvent,
|
||||||
|
totalSold: 0,
|
||||||
|
lastSeeded: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n🎉 Successfully seeded ${config.numEvents} events with ${config.numEvents * config.ticketsPerEvent} total tickets!`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error during seed:", err);
|
console.error("Error during seed:", err);
|
||||||
|
|||||||
@@ -1,35 +1,486 @@
|
|||||||
const express = require("express");
|
const express = require('express');
|
||||||
const redis = require("redis");
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// Import utilities
|
||||||
|
const redisClient = require('./src/utils/redis-client');
|
||||||
|
const fallbackStore = require('./src/utils/fallback-store');
|
||||||
|
const logger = require('./src/utils/logger');
|
||||||
|
const pdfGenerator = require('./src/utils/pdf-generator');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3049;
|
const port = process.env.PORT || 3049;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// Create Redis client and connect
|
// Request logging middleware
|
||||||
const client = redis.createClient();
|
app.use((req, res, next) => {
|
||||||
client.connect().catch(console.error);
|
const start = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const responseTime = Date.now() - start;
|
||||||
|
logger.logRequest(req, res, responseTime);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Purchase ticket endpoint
|
// Global error handler
|
||||||
app.post("/buy", async (req, res) => {
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
const redisHealthy = redisClient.isHealthy();
|
||||||
|
const fallbackActive = fallbackStore.isActive;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
redis: {
|
||||||
|
connected: redisHealthy,
|
||||||
|
fallbackActive: fallbackActive
|
||||||
|
},
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all events endpoint
|
||||||
|
app.get('/events', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Atomically pop a ticket from the 'tickets' list
|
let events;
|
||||||
const ticket = await client.lPop("tickets");
|
|
||||||
if (ticket) {
|
if (redisClient.isHealthy()) {
|
||||||
res.json({
|
events = await redisClient.getAllEvents();
|
||||||
success: true,
|
|
||||||
ticket,
|
|
||||||
message: "Ticket purchased successfully!",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ success: false, message: "No tickets available" });
|
events = fallbackStore.getAllEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
events,
|
||||||
|
usingFallback: fallbackStore.isActive
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during ticket purchase:", error);
|
logger.error('Error fetching events:', error);
|
||||||
res.status(500).json({ success: false, message: "Internal Server Error" });
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch events'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
// Get specific event stats
|
||||||
console.log(`Server is running on port ${port}`);
|
app.get('/events/:eventId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const eventId = req.params.eventId;
|
||||||
|
let eventStats;
|
||||||
|
|
||||||
|
if (redisClient.isHealthy()) {
|
||||||
|
eventStats = await redisClient.getEventStats(eventId);
|
||||||
|
} else {
|
||||||
|
eventStats = fallbackStore.getEventStats(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventStats) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Event not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
event: eventStats,
|
||||||
|
usingFallback: fallbackStore.isActive
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching event stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch event stats'
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Purchase ticket endpoint (multi-event)
|
||||||
|
app.post('/buy/:eventId', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const eventId = req.params.eventId;
|
||||||
|
const purchaseId = uuidv4();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
// Try Redis first
|
||||||
|
if (redisClient.isHealthy()) {
|
||||||
|
try {
|
||||||
|
const luaResult = await redisClient.purchaseTicket(eventId, purchaseId, timestamp);
|
||||||
|
|
||||||
|
if (luaResult[0]) {
|
||||||
|
// Success - generate PDF ticket
|
||||||
|
try {
|
||||||
|
// Get event details for PDF
|
||||||
|
const eventStats = await redisClient.getEventStats(eventId);
|
||||||
|
|
||||||
|
const pdfResult = await pdfGenerator.generateTicketPDF({
|
||||||
|
ticketId: luaResult[0],
|
||||||
|
eventId,
|
||||||
|
purchaseId,
|
||||||
|
eventName: eventStats?.name || `Event ${eventId}`,
|
||||||
|
eventDescription: eventStats?.description || 'Event description not available',
|
||||||
|
timestamp,
|
||||||
|
soldCount: luaResult[2]
|
||||||
|
});
|
||||||
|
|
||||||
|
result = {
|
||||||
|
success: true,
|
||||||
|
ticket: luaResult[0],
|
||||||
|
purchaseId,
|
||||||
|
eventId,
|
||||||
|
soldCount: luaResult[2],
|
||||||
|
message: 'Ticket purchased successfully!',
|
||||||
|
usingFallback: false,
|
||||||
|
pdf: {
|
||||||
|
generated: pdfResult.success,
|
||||||
|
filename: pdfResult.filename,
|
||||||
|
downloadUrl: `/tickets/${purchaseId}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
||||||
|
logger.info(`PDF ticket generated for purchase ${purchaseId}`);
|
||||||
|
|
||||||
|
} catch (pdfError) {
|
||||||
|
logger.error('PDF generation failed:', pdfError);
|
||||||
|
|
||||||
|
// Still return success for ticket purchase, but note PDF failure
|
||||||
|
result = {
|
||||||
|
success: true,
|
||||||
|
ticket: luaResult[0],
|
||||||
|
purchaseId,
|
||||||
|
eventId,
|
||||||
|
soldCount: luaResult[2],
|
||||||
|
message: 'Ticket purchased successfully! (PDF generation failed)',
|
||||||
|
usingFallback: false,
|
||||||
|
pdf: {
|
||||||
|
generated: false,
|
||||||
|
error: 'PDF generation failed'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Failed - handle specific error
|
||||||
|
const errorCode = luaResult[1];
|
||||||
|
let statusCode = 400;
|
||||||
|
let message = 'Purchase failed';
|
||||||
|
|
||||||
|
switch (errorCode) {
|
||||||
|
case 'EVENT_NOT_FOUND':
|
||||||
|
statusCode = 404;
|
||||||
|
message = 'Event not found';
|
||||||
|
break;
|
||||||
|
case 'NO_TICKETS_AVAILABLE':
|
||||||
|
statusCode = 409;
|
||||||
|
message = 'No tickets available for this event';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logPurchase(eventId, null, purchaseId, false, errorCode);
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
errorCode,
|
||||||
|
eventId,
|
||||||
|
purchaseId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (redisError) {
|
||||||
|
logger.error('Redis purchase failed, attempting fallback:', redisError);
|
||||||
|
// Activate fallback if not already active
|
||||||
|
if (!fallbackStore.isActive) {
|
||||||
|
fallbackStore.activate('Redis purchase operation failed');
|
||||||
|
}
|
||||||
|
throw redisError; // Will be caught by outer try-catch for fallback
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Redis not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
result.responseTime = `${responseTime}ms`;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to in-memory store
|
||||||
|
try {
|
||||||
|
if (!fallbackStore.isActive) {
|
||||||
|
fallbackStore.activate('Redis connection failed during purchase');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackResult = fallbackStore.purchaseTicket(eventId, purchaseId);
|
||||||
|
|
||||||
|
if (fallbackResult.success) {
|
||||||
|
// Generate PDF for fallback purchase
|
||||||
|
try {
|
||||||
|
const eventStats = fallbackStore.getEventStats(eventId);
|
||||||
|
|
||||||
|
const pdfResult = await pdfGenerator.generateTicketPDF({
|
||||||
|
ticketId: fallbackResult.ticket,
|
||||||
|
eventId,
|
||||||
|
purchaseId,
|
||||||
|
eventName: eventStats?.name || `Event ${eventId}`,
|
||||||
|
eventDescription: eventStats?.description || 'Event description not available',
|
||||||
|
timestamp,
|
||||||
|
soldCount: fallbackResult.soldCount
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
ticket: fallbackResult.ticket,
|
||||||
|
purchaseId,
|
||||||
|
eventId,
|
||||||
|
soldCount: fallbackResult.soldCount,
|
||||||
|
message: 'Ticket purchased successfully (fallback mode)!',
|
||||||
|
usingFallback: true,
|
||||||
|
responseTime: `${responseTime}ms`,
|
||||||
|
pdf: {
|
||||||
|
generated: pdfResult.success,
|
||||||
|
filename: pdfResult.filename,
|
||||||
|
downloadUrl: `/tickets/${purchaseId}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`PDF ticket generated for fallback purchase ${purchaseId}`);
|
||||||
|
|
||||||
|
} catch (pdfError) {
|
||||||
|
logger.error('PDF generation failed in fallback mode:', pdfError);
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
ticket: fallbackResult.ticket,
|
||||||
|
purchaseId,
|
||||||
|
eventId,
|
||||||
|
soldCount: fallbackResult.soldCount,
|
||||||
|
message: 'Ticket purchased successfully (fallback mode, PDF generation failed)!',
|
||||||
|
usingFallback: true,
|
||||||
|
responseTime: `${responseTime}ms`,
|
||||||
|
pdf: {
|
||||||
|
generated: false,
|
||||||
|
error: 'PDF generation failed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let statusCode = 400;
|
||||||
|
let message = 'Purchase failed';
|
||||||
|
|
||||||
|
switch (fallbackResult.error) {
|
||||||
|
case 'EVENT_NOT_FOUND':
|
||||||
|
statusCode = 404;
|
||||||
|
message = 'Event not found';
|
||||||
|
break;
|
||||||
|
case 'NO_TICKETS_AVAILABLE':
|
||||||
|
statusCode = 409;
|
||||||
|
message = 'No tickets available for this event';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logPurchase(eventId, null, purchaseId, false, fallbackResult.error);
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
errorCode: fallbackResult.error,
|
||||||
|
eventId,
|
||||||
|
purchaseId,
|
||||||
|
usingFallback: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fallbackError) {
|
||||||
|
logger.error('Both Redis and fallback failed:', fallbackError);
|
||||||
|
logger.logPurchase(eventId, null, purchaseId, false, fallbackError);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'System temporarily unavailable',
|
||||||
|
eventId,
|
||||||
|
purchaseId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download ticket PDF endpoint
|
||||||
|
app.get('/tickets/:purchaseId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const purchaseId = req.params.purchaseId;
|
||||||
|
|
||||||
|
if (!pdfGenerator.ticketExists(purchaseId)) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Ticket not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filepath = pdfGenerator.getTicketPath(purchaseId);
|
||||||
|
const filename = `ticket-${purchaseId}.pdf`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
|
const fileStream = require('fs').createReadStream(filepath);
|
||||||
|
fileStream.pipe(res);
|
||||||
|
|
||||||
|
logger.info(`PDF ticket downloaded: ${purchaseId}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error downloading ticket:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to download ticket'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PDF management endpoint
|
||||||
|
app.get('/admin/pdf-stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = pdfGenerator.getStats();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting PDF stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to get PDF statistics'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup old tickets endpoint
|
||||||
|
app.post('/admin/cleanup-tickets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const maxAgeHours = req.body.maxAgeHours || 24;
|
||||||
|
const deletedCount = await pdfGenerator.cleanupOldTickets(maxAgeHours);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Cleaned up ${deletedCount} old tickets`,
|
||||||
|
deletedCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error cleaning up tickets:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to cleanup tickets'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Metrics endpoint (Prometheus compatible)
|
||||||
|
app.get('/metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let globalStats, events;
|
||||||
|
|
||||||
|
if (redisClient.isHealthy()) {
|
||||||
|
globalStats = await redisClient.getGlobalStats();
|
||||||
|
events = await redisClient.getAllEvents();
|
||||||
|
} else {
|
||||||
|
globalStats = fallbackStore.getGlobalStats();
|
||||||
|
events = fallbackStore.getAllEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get PDF stats
|
||||||
|
const pdfStats = pdfGenerator.getStats();
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const metrics = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
global: globalStats,
|
||||||
|
events: events,
|
||||||
|
system: {
|
||||||
|
usingFallback: fallbackStore.isActive,
|
||||||
|
redisConnected: redisClient.isHealthy(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
memoryUsage: process.memoryUsage()
|
||||||
|
},
|
||||||
|
pdf: pdfStats
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating metrics:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to generate metrics'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize server
|
||||||
|
async function initializeServer() {
|
||||||
|
try {
|
||||||
|
// Connect to Redis
|
||||||
|
await redisClient.connect();
|
||||||
|
logger.info('Redis connected successfully');
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.info(`🚀 Ticket Microservice running on port ${port}`);
|
||||||
|
logger.info(`📊 Health check: http://localhost:${port}/health`);
|
||||||
|
logger.info(`📈 Metrics: http://localhost:${port}/metrics`);
|
||||||
|
logger.info(`🎫 Events: http://localhost:${port}/events`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize server:', error);
|
||||||
|
logger.warn('Starting server with fallback store only');
|
||||||
|
|
||||||
|
fallbackStore.activate('Redis connection failed at startup');
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.warn(`⚠️ Server running in FALLBACK MODE on port ${port}`);
|
||||||
|
logger.warn('Redis connection failed - using in-memory store');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.info('Received SIGINT, shutting down gracefully...');
|
||||||
|
try {
|
||||||
|
await redisClient.disconnect();
|
||||||
|
logger.info('Redis disconnected');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error disconnecting Redis:', error);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
logger.info('Received SIGTERM, shutting down gracefully...');
|
||||||
|
try {
|
||||||
|
await redisClient.disconnect();
|
||||||
|
logger.info('Redis disconnected');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error disconnecting Redis:', error);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
initializeServer();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Get event statistics script
|
||||||
|
-- KEYS[1]: event metadata key (e.g., "event:1:meta")
|
||||||
|
-- KEYS[2]: event tickets key (e.g., "event:1:tickets")
|
||||||
|
|
||||||
|
local metaKey = KEYS[1]
|
||||||
|
local ticketKey = KEYS[2]
|
||||||
|
|
||||||
|
-- Check if event exists
|
||||||
|
if redis.call('EXISTS', metaKey) == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get event metadata
|
||||||
|
local eventData = redis.call('HGETALL', metaKey)
|
||||||
|
local eventInfo = {}
|
||||||
|
for i = 1, #eventData, 2 do
|
||||||
|
eventInfo[eventData[i]] = eventData[i + 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get remaining tickets count
|
||||||
|
local remainingTickets = redis.call('LLEN', ticketKey)
|
||||||
|
|
||||||
|
-- Return combined stats
|
||||||
|
return {
|
||||||
|
eventInfo.eventId,
|
||||||
|
eventInfo.name,
|
||||||
|
eventInfo.description,
|
||||||
|
eventInfo.totalTickets,
|
||||||
|
eventInfo.soldTickets,
|
||||||
|
remainingTickets,
|
||||||
|
eventInfo.createdAt,
|
||||||
|
eventInfo.lastSoldAt or "never"
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Atomic ticket purchase script
|
||||||
|
-- KEYS[1]: event tickets key (e.g., "event:1:tickets")
|
||||||
|
-- KEYS[2]: event metadata key (e.g., "event:1:meta")
|
||||||
|
-- KEYS[3]: global stats key ("global:stats")
|
||||||
|
-- ARGV[1]: timestamp
|
||||||
|
-- ARGV[2]: purchase ID (UUID)
|
||||||
|
|
||||||
|
local ticketKey = KEYS[1]
|
||||||
|
local metaKey = KEYS[2]
|
||||||
|
local globalKey = KEYS[3]
|
||||||
|
local timestamp = ARGV[1]
|
||||||
|
local purchaseId = ARGV[2]
|
||||||
|
|
||||||
|
-- Check if event exists
|
||||||
|
if redis.call('EXISTS', metaKey) == 0 then
|
||||||
|
return {nil, "EVENT_NOT_FOUND"}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Atomically pop a ticket from the list
|
||||||
|
local ticket = redis.call('LPOP', ticketKey)
|
||||||
|
|
||||||
|
if not ticket then
|
||||||
|
return {nil, "NO_TICKETS_AVAILABLE"}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update event metadata
|
||||||
|
local soldCount = redis.call('HINCRBY', metaKey, 'soldTickets', 1)
|
||||||
|
redis.call('HSET', metaKey, 'lastSoldAt', timestamp)
|
||||||
|
|
||||||
|
-- Update global stats
|
||||||
|
redis.call('HINCRBY', globalKey, 'totalSold', 1)
|
||||||
|
|
||||||
|
-- Store purchase record
|
||||||
|
local purchaseKey = 'purchase:' .. purchaseId
|
||||||
|
redis.call('HSET', purchaseKey, {
|
||||||
|
'ticketId', ticket,
|
||||||
|
'eventId', string.match(ticketKey, 'event:(%d+):tickets'),
|
||||||
|
'purchaseId', purchaseId,
|
||||||
|
'timestamp', timestamp,
|
||||||
|
'status', 'completed'
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Set expiration for purchase record (24 hours)
|
||||||
|
redis.call('EXPIRE', purchaseKey, 86400)
|
||||||
|
|
||||||
|
return {ticket, "SUCCESS", soldCount}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
class FallbackStore {
|
||||||
|
constructor() {
|
||||||
|
this.events = new Map();
|
||||||
|
this.globalStats = {
|
||||||
|
totalEvents: 0,
|
||||||
|
totalTickets: 0,
|
||||||
|
totalSold: 0,
|
||||||
|
lastSeeded: null
|
||||||
|
};
|
||||||
|
this.isActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(reason) {
|
||||||
|
this.isActive = true;
|
||||||
|
logger.logFallback('In-Memory Store Activated', reason);
|
||||||
|
logger.warn('⚠️ FALLBACK MODE: Using in-memory store - data will not persist!');
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
this.isActive = false;
|
||||||
|
logger.info('In-Memory Store Deactivated - Redis connection restored');
|
||||||
|
}
|
||||||
|
|
||||||
|
seedEvent(eventId, tickets, metadata) {
|
||||||
|
if (!this.isActive) return false;
|
||||||
|
|
||||||
|
this.events.set(eventId, {
|
||||||
|
tickets: [...tickets], // Create a copy
|
||||||
|
metadata: { ...metadata },
|
||||||
|
soldTickets: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
this.globalStats.totalEvents++;
|
||||||
|
this.globalStats.totalTickets += tickets.length;
|
||||||
|
|
||||||
|
logger.info(`Fallback: Seeded event ${eventId} with ${tickets.length} tickets`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
purchaseTicket(eventId, purchaseId) {
|
||||||
|
if (!this.isActive) return null;
|
||||||
|
|
||||||
|
const event = this.events.get(eventId);
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, error: 'EVENT_NOT_FOUND' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.tickets.length === 0) {
|
||||||
|
return { success: false, error: 'NO_TICKETS_AVAILABLE' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically remove a ticket
|
||||||
|
const ticket = event.tickets.shift();
|
||||||
|
event.soldTickets++;
|
||||||
|
event.metadata.lastSoldAt = new Date().toISOString();
|
||||||
|
|
||||||
|
this.globalStats.totalSold++;
|
||||||
|
|
||||||
|
logger.logPurchase(eventId, ticket, purchaseId, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
ticket,
|
||||||
|
soldCount: event.soldTickets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventStats(eventId) {
|
||||||
|
if (!this.isActive) return null;
|
||||||
|
|
||||||
|
const event = this.events.get(eventId);
|
||||||
|
if (!event) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: eventId,
|
||||||
|
name: event.metadata.name,
|
||||||
|
description: event.metadata.description,
|
||||||
|
totalTickets: parseInt(event.metadata.totalTickets),
|
||||||
|
soldTickets: event.soldTickets,
|
||||||
|
remainingTickets: event.tickets.length,
|
||||||
|
createdAt: event.metadata.createdAt,
|
||||||
|
lastSoldAt: event.metadata.lastSoldAt || 'never'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEvents() {
|
||||||
|
if (!this.isActive) return [];
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
for (const [eventId, _] of this.events) {
|
||||||
|
const stats = this.getEventStats(eventId);
|
||||||
|
if (stats) {
|
||||||
|
events.push(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGlobalStats() {
|
||||||
|
if (!this.isActive) return null;
|
||||||
|
return { ...this.globalStats };
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy() {
|
||||||
|
return this.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.events.clear();
|
||||||
|
this.globalStats = {
|
||||||
|
totalEvents: 0,
|
||||||
|
totalTickets: 0,
|
||||||
|
totalSold: 0,
|
||||||
|
lastSeeded: null
|
||||||
|
};
|
||||||
|
logger.info('Fallback store cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
active: this.isActive,
|
||||||
|
eventsCount: this.events.size,
|
||||||
|
totalTickets: this.globalStats.totalTickets,
|
||||||
|
totalSold: this.globalStats.totalSold
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new FallbackStore();
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Create logs directory if it doesn't exist
|
||||||
|
const fs = require('fs');
|
||||||
|
const logsDir = path.join(process.cwd(), 'logs');
|
||||||
|
if (!fs.existsSync(logsDir)) {
|
||||||
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom format for console output
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
|
let metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
|
||||||
|
return `${timestamp} [${level}]: ${message} ${metaStr}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom format for file output
|
||||||
|
const fileFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create logger instance
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
defaultMeta: { service: 'ticket-microservice' },
|
||||||
|
transports: [
|
||||||
|
// Console transport
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: consoleFormat
|
||||||
|
}),
|
||||||
|
|
||||||
|
// File transport for all logs
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logsDir, 'app.log'),
|
||||||
|
format: fileFormat,
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
}),
|
||||||
|
|
||||||
|
// File transport for errors only
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logsDir, 'error.log'),
|
||||||
|
level: 'error',
|
||||||
|
format: fileFormat,
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add request logging helper
|
||||||
|
logger.logRequest = (req, res, responseTime) => {
|
||||||
|
const logData = {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
responseTime: `${responseTime}ms`,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
ip: req.ip || req.connection.remoteAddress
|
||||||
|
};
|
||||||
|
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
logger.warn('HTTP Request', logData);
|
||||||
|
} else {
|
||||||
|
logger.info('HTTP Request', logData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add purchase logging helper
|
||||||
|
logger.logPurchase = (eventId, ticketId, purchaseId, success, error = null) => {
|
||||||
|
const logData = {
|
||||||
|
eventId,
|
||||||
|
ticketId,
|
||||||
|
purchaseId,
|
||||||
|
success,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logData.error = error.message || error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logger.info('Ticket Purchase', logData);
|
||||||
|
} else {
|
||||||
|
logger.error('Ticket Purchase Failed', logData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add fallback logging helper
|
||||||
|
logger.logFallback = (operation, reason) => {
|
||||||
|
logger.warn('Fallback Activated', {
|
||||||
|
operation,
|
||||||
|
reason,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
class PDFGenerator {
|
||||||
|
constructor() {
|
||||||
|
this.outputDir = process.env.PDF_OUTPUT_DIR || './tickets';
|
||||||
|
this.ensureOutputDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOutputDirectory() {
|
||||||
|
if (!fs.existsSync(this.outputDir)) {
|
||||||
|
fs.mkdirSync(this.outputDir, { recursive: true });
|
||||||
|
logger.info(`Created PDF output directory: ${this.outputDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTicketPDF(ticketData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
ticketId,
|
||||||
|
eventId,
|
||||||
|
purchaseId,
|
||||||
|
eventName,
|
||||||
|
eventDescription,
|
||||||
|
timestamp,
|
||||||
|
soldCount
|
||||||
|
} = ticketData;
|
||||||
|
|
||||||
|
// Create PDF document
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
margin: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const filename = `ticket-${purchaseId}.pdf`;
|
||||||
|
const filepath = path.join(this.outputDir, filename);
|
||||||
|
|
||||||
|
// Pipe to file
|
||||||
|
const stream = fs.createWriteStream(filepath);
|
||||||
|
doc.pipe(stream);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
doc.fontSize(24)
|
||||||
|
.fillColor('#2c3e50')
|
||||||
|
.text('🎫 TICKET RECEIPT', 50, 50, { align: 'center' });
|
||||||
|
|
||||||
|
// Divider line
|
||||||
|
doc.moveTo(50, 90)
|
||||||
|
.lineTo(545, 90)
|
||||||
|
.strokeColor('#3498db')
|
||||||
|
.lineWidth(2)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
// Event Information
|
||||||
|
doc.fontSize(18)
|
||||||
|
.fillColor('#2c3e50')
|
||||||
|
.text('Event Information', 50, 120);
|
||||||
|
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor('#34495e')
|
||||||
|
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
|
||||||
|
.text(`Event ID: ${eventId}`, 50, 170)
|
||||||
|
.text(`Description: ${eventDescription || 'No description available'}`, 50, 190);
|
||||||
|
|
||||||
|
// Ticket Information
|
||||||
|
doc.fontSize(18)
|
||||||
|
.fillColor('#2c3e50')
|
||||||
|
.text('Ticket Details', 50, 230);
|
||||||
|
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor('#34495e')
|
||||||
|
.text(`Ticket ID: ${ticketId}`, 50, 260)
|
||||||
|
.text(`Purchase ID: ${purchaseId}`, 50, 280)
|
||||||
|
.text(`Purchase Date: ${new Date(timestamp).toLocaleString()}`, 50, 300)
|
||||||
|
.text(`Ticket Number: #${soldCount}`, 50, 320);
|
||||||
|
|
||||||
|
// QR Code placeholder (you could integrate a QR code library here)
|
||||||
|
doc.rect(400, 250, 100, 100)
|
||||||
|
.strokeColor('#bdc3c7')
|
||||||
|
.lineWidth(1)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
doc.fontSize(10)
|
||||||
|
.fillColor('#7f8c8d')
|
||||||
|
.text('QR Code', 430, 305, { align: 'center' });
|
||||||
|
|
||||||
|
// Terms and Conditions
|
||||||
|
doc.fontSize(14)
|
||||||
|
.fillColor('#2c3e50')
|
||||||
|
.text('Terms & Conditions', 50, 380);
|
||||||
|
|
||||||
|
doc.fontSize(10)
|
||||||
|
.fillColor('#7f8c8d')
|
||||||
|
.text('• This ticket is non-refundable and non-transferable', 50, 410)
|
||||||
|
.text('• Please arrive 30 minutes before the event starts', 50, 425)
|
||||||
|
.text('• Valid photo ID required for entry', 50, 440)
|
||||||
|
.text('• This ticket is valid only for the specified event and date', 50, 455);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
doc.fontSize(8)
|
||||||
|
.fillColor('#95a5a6')
|
||||||
|
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
|
||||||
|
.text('Powered by Ticket Microservice', 50, 535)
|
||||||
|
.text(`System ID: ${process.env.NODE_ENV || 'development'}`, 50, 550);
|
||||||
|
|
||||||
|
// Security watermark
|
||||||
|
doc.fontSize(60)
|
||||||
|
.fillColor('#ecf0f1')
|
||||||
|
.text('VALID', 200, 300, {
|
||||||
|
rotate: -45,
|
||||||
|
opacity: 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finalize PDF
|
||||||
|
doc.end();
|
||||||
|
|
||||||
|
stream.on('finish', () => {
|
||||||
|
logger.info(`PDF ticket generated: ${filename}`);
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
filename,
|
||||||
|
filepath,
|
||||||
|
size: fs.statSync(filepath).size
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (error) => {
|
||||||
|
logger.error('Error writing PDF file:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating PDF:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateBulkTicketsPDF(tickets) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
try {
|
||||||
|
const result = await this.generateTicketPDF(ticket);
|
||||||
|
results.push(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to generate PDF for ticket ${ticket.ticketId}:`, error);
|
||||||
|
results.push({
|
||||||
|
success: false,
|
||||||
|
ticketId: ticket.ticketId,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTicketPath(purchaseId) {
|
||||||
|
return path.join(this.outputDir, `ticket-${purchaseId}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ticketExists(purchaseId) {
|
||||||
|
const filepath = this.getTicketPath(purchaseId);
|
||||||
|
return fs.existsSync(filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTicket(purchaseId) {
|
||||||
|
try {
|
||||||
|
const filepath = this.getTicketPath(purchaseId);
|
||||||
|
if (fs.existsSync(filepath)) {
|
||||||
|
fs.unlinkSync(filepath);
|
||||||
|
logger.info(`Deleted ticket PDF: ${purchaseId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error deleting ticket PDF ${purchaseId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupOldTickets(maxAgeHours = 24) {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.outputDir);
|
||||||
|
const now = Date.now();
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.pdf')) {
|
||||||
|
const filepath = path.join(this.outputDir, file);
|
||||||
|
const stats = fs.statSync(filepath);
|
||||||
|
const ageHours = (now - stats.mtime.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (ageHours > maxAgeHours) {
|
||||||
|
fs.unlinkSync(filepath);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
logger.info(`Cleaned up ${deletedCount} old ticket PDFs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error cleaning up old tickets:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.outputDir);
|
||||||
|
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const file of pdfFiles) {
|
||||||
|
const filepath = path.join(this.outputDir, file);
|
||||||
|
totalSize += fs.statSync(filepath).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTickets: pdfFiles.length,
|
||||||
|
totalSizeBytes: totalSize,
|
||||||
|
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
||||||
|
outputDirectory: this.outputDir
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting PDF stats:', error);
|
||||||
|
return {
|
||||||
|
totalTickets: 0,
|
||||||
|
totalSizeBytes: 0,
|
||||||
|
totalSizeMB: '0.00',
|
||||||
|
outputDirectory: this.outputDir,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new PDFGenerator();
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
const redis = require('redis');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
class RedisClient {
|
||||||
|
constructor() {
|
||||||
|
this.client = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.luaScripts = {};
|
||||||
|
this.loadLuaScripts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
try {
|
||||||
|
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
this.client = redis.createClient({ url: redisUrl });
|
||||||
|
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
logger.error('Redis Client Error:', err);
|
||||||
|
this.isConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
logger.info('Redis client connected');
|
||||||
|
this.isConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('disconnect', () => {
|
||||||
|
logger.warn('Redis client disconnected');
|
||||||
|
this.isConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.connect();
|
||||||
|
return this.client;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to connect to Redis:', error);
|
||||||
|
this.isConnected = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLuaScripts() {
|
||||||
|
try {
|
||||||
|
const luaDir = path.join(__dirname, '../lua');
|
||||||
|
|
||||||
|
// Load purchase ticket script
|
||||||
|
const purchaseScript = fs.readFileSync(
|
||||||
|
path.join(luaDir, 'purchase-ticket.lua'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.luaScripts.purchaseTicket = purchaseScript;
|
||||||
|
|
||||||
|
// Load event stats script
|
||||||
|
const statsScript = fs.readFileSync(
|
||||||
|
path.join(luaDir, 'get-event-stats.lua'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.luaScripts.getEventStats = statsScript;
|
||||||
|
|
||||||
|
logger.info('Lua scripts loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load Lua scripts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async purchaseTicket(eventId, purchaseId, timestamp) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Redis not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
`event:${eventId}:tickets`,
|
||||||
|
`event:${eventId}:meta`,
|
||||||
|
'global:stats'
|
||||||
|
];
|
||||||
|
const args = [timestamp, purchaseId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.eval(
|
||||||
|
this.luaScripts.purchaseTicket,
|
||||||
|
{ keys, arguments: args }
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error executing purchase ticket script:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEventStats(eventId) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Redis not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
`event:${eventId}:meta`,
|
||||||
|
`event:${eventId}:tickets`
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.eval(
|
||||||
|
this.luaScripts.getEventStats,
|
||||||
|
{ keys }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: result[0],
|
||||||
|
name: result[1],
|
||||||
|
description: result[2],
|
||||||
|
totalTickets: parseInt(result[3]),
|
||||||
|
soldTickets: parseInt(result[4]),
|
||||||
|
remainingTickets: parseInt(result[5]),
|
||||||
|
createdAt: result[6],
|
||||||
|
lastSoldAt: result[7]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error executing get event stats script:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllEvents() {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Redis not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventKeys = await this.client.keys('event:*:meta');
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
for (const key of eventKeys) {
|
||||||
|
const eventId = key.match(/event:(\d+):meta/)[1];
|
||||||
|
const stats = await this.getEventStats(eventId);
|
||||||
|
if (stats) {
|
||||||
|
events.push(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting all events:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGlobalStats() {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Redis not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await this.client.hGetAll('global:stats');
|
||||||
|
return {
|
||||||
|
totalEvents: parseInt(stats.totalEvents) || 0,
|
||||||
|
totalTickets: parseInt(stats.totalTickets) || 0,
|
||||||
|
totalSold: parseInt(stats.totalSold) || 0,
|
||||||
|
lastSeeded: stats.lastSeeded || null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting global stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy() {
|
||||||
|
return this.isConnected && this.client && this.client.isReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
if (this.client) {
|
||||||
|
await this.client.disconnect();
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new RedisClient();
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const autocannon = require('autocannon');
|
||||||
|
const { performance } = require('perf_hooks');
|
||||||
|
|
||||||
|
class LoadTester {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = process.env.TEST_URL || 'http://localhost:3049';
|
||||||
|
this.results = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async runPurchaseLoadTest(eventId = 1, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
url: `${this.baseUrl}/buy/${eventId}`,
|
||||||
|
connections: 5000,
|
||||||
|
duration: 30,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n🚀 Starting load test for Event ${eventId}`);
|
||||||
|
console.log(`📊 Connections: ${defaultOptions.connections}`);
|
||||||
|
console.log(`⏱️ Duration: ${defaultOptions.duration}s`);
|
||||||
|
console.log(`🎯 Target: ${defaultOptions.url}\n`);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await autocannon(defaultOptions);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
eventId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
duration: endTime - startTime,
|
||||||
|
...result
|
||||||
|
};
|
||||||
|
|
||||||
|
this.results.push(testResult);
|
||||||
|
this.printResults(testResult);
|
||||||
|
|
||||||
|
return testResult;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Load test failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMultiEventLoadTest(eventIds = [1, 2, 3], options = {}) {
|
||||||
|
console.log(`\n🎯 Running multi-event load test for events: ${eventIds.join(', ')}`);
|
||||||
|
|
||||||
|
const promises = eventIds.map(eventId =>
|
||||||
|
this.runPurchaseLoadTest(eventId, {
|
||||||
|
connections: Math.floor((options.connections || 5000) / eventIds.length),
|
||||||
|
duration: options.duration || 30
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
this.printSummary(results);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Multi-event load test failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runHealthCheckTest(options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
url: `${this.baseUrl}/health`,
|
||||||
|
connections: 100,
|
||||||
|
duration: 10,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('\n🏥 Running health check load test...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await autocannon(defaultOptions);
|
||||||
|
console.log(`✅ Health check test completed`);
|
||||||
|
console.log(`📈 RPS: ${result.requests.average}`);
|
||||||
|
console.log(`⚡ Latency: ${result.latency.average}ms`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Health check test failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMetricsTest(options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
url: `${this.baseUrl}/metrics`,
|
||||||
|
connections: 50,
|
||||||
|
duration: 10,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('\n📊 Running metrics endpoint test...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await autocannon(defaultOptions);
|
||||||
|
console.log(`✅ Metrics test completed`);
|
||||||
|
console.log(`📈 RPS: ${result.requests.average}`);
|
||||||
|
console.log(`⚡ Latency: ${result.latency.average}ms`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Metrics test failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printResults(result) {
|
||||||
|
console.log('📋 LOAD TEST RESULTS');
|
||||||
|
console.log('═'.repeat(50));
|
||||||
|
console.log(`🎯 Event ID: ${result.eventId}`);
|
||||||
|
console.log(`⏱️ Duration: ${(result.duration / 1000).toFixed(2)}s`);
|
||||||
|
console.log(`📊 Total Requests: ${result.requests.total}`);
|
||||||
|
console.log(`📈 Requests/sec: ${result.requests.average.toFixed(2)}`);
|
||||||
|
console.log(`⚡ Avg Latency: ${result.latency.average.toFixed(2)}ms`);
|
||||||
|
console.log(`🔥 Max Latency: ${result.latency.max}ms`);
|
||||||
|
console.log(`✅ Success Rate: ${((result.requests.total - result.non2xx) / result.requests.total * 100).toFixed(2)}%`);
|
||||||
|
console.log(`❌ Errors: ${result.non2xx}`);
|
||||||
|
console.log(`🔗 Connections: ${result.connections}`);
|
||||||
|
console.log(`📦 Throughput: ${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`);
|
||||||
|
console.log('═'.repeat(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(results) {
|
||||||
|
console.log('\n📊 MULTI-EVENT TEST SUMMARY');
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
|
||||||
|
const totalRequests = results.reduce((sum, r) => sum + r.requests.total, 0);
|
||||||
|
const avgRPS = results.reduce((sum, r) => sum + r.requests.average, 0);
|
||||||
|
const avgLatency = results.reduce((sum, r) => sum + r.latency.average, 0) / results.length;
|
||||||
|
const totalErrors = results.reduce((sum, r) => sum + r.non2xx, 0);
|
||||||
|
|
||||||
|
console.log(`🎯 Events Tested: ${results.length}`);
|
||||||
|
console.log(`📊 Total Requests: ${totalRequests}`);
|
||||||
|
console.log(`📈 Combined RPS: ${avgRPS.toFixed(2)}`);
|
||||||
|
console.log(`⚡ Avg Latency: ${avgLatency.toFixed(2)}ms`);
|
||||||
|
console.log(`✅ Overall Success Rate: ${((totalRequests - totalErrors) / totalRequests * 100).toFixed(2)}%`);
|
||||||
|
console.log(`❌ Total Errors: ${totalErrors}`);
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
|
||||||
|
// Individual event breakdown
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
console.log(`\n📋 Event ${result.eventId}:`);
|
||||||
|
console.log(` 📈 RPS: ${result.requests.average.toFixed(2)}`);
|
||||||
|
console.log(` ⚡ Latency: ${result.latency.average.toFixed(2)}ms`);
|
||||||
|
console.log(` ✅ Success: ${((result.requests.total - result.non2xx) / result.requests.total * 100).toFixed(2)}%`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runFullTestSuite() {
|
||||||
|
console.log('🚀 STARTING FULL LOAD TEST SUITE');
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Health check test
|
||||||
|
await this.runHealthCheckTest();
|
||||||
|
|
||||||
|
// 2. Metrics test
|
||||||
|
await this.runMetricsTest();
|
||||||
|
|
||||||
|
// 3. Single event high-load test
|
||||||
|
await this.runPurchaseLoadTest(1, {
|
||||||
|
connections: 5000,
|
||||||
|
duration: 30
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Multi-event test
|
||||||
|
await this.runMultiEventLoadTest([1, 2, 3], {
|
||||||
|
connections: 6000,
|
||||||
|
duration: 30
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n🎉 FULL TEST SUITE COMPLETED!');
|
||||||
|
console.log(`📊 Total tests run: ${this.results.length + 2}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Test suite failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getResults() {
|
||||||
|
return this.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportResults(filename = 'load-test-results.json') {
|
||||||
|
const fs = require('fs');
|
||||||
|
const data = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
testSuite: 'Ticket Microservice Load Test',
|
||||||
|
results: this.results
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
|
||||||
|
console.log(`📄 Results exported to: ${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI execution
|
||||||
|
if (require.main === module) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const tester = new LoadTester();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
if (args.includes('--full')) {
|
||||||
|
await tester.runFullTestSuite();
|
||||||
|
} else if (args.includes('--event')) {
|
||||||
|
const eventId = parseInt(args[args.indexOf('--event') + 1]) || 1;
|
||||||
|
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 5000;
|
||||||
|
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
|
||||||
|
|
||||||
|
await tester.runPurchaseLoadTest(eventId, { connections, duration });
|
||||||
|
} else if (args.includes('--multi')) {
|
||||||
|
const eventIds = args.includes('--events')
|
||||||
|
? args[args.indexOf('--events') + 1].split(',').map(Number)
|
||||||
|
: [1, 2, 3];
|
||||||
|
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 6000;
|
||||||
|
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
|
||||||
|
|
||||||
|
await tester.runMultiEventLoadTest(eventIds, { connections, duration });
|
||||||
|
} else {
|
||||||
|
console.log('🎯 Ticket Microservice Load Tester');
|
||||||
|
console.log('Usage:');
|
||||||
|
console.log(' node tests/load-test.js --full # Run full test suite');
|
||||||
|
console.log(' node tests/load-test.js --event 1 --connections 5000 --duration 30');
|
||||||
|
console.log(' node tests/load-test.js --multi --events 1,2,3 --connections 6000');
|
||||||
|
console.log('');
|
||||||
|
console.log('Running default single event test...');
|
||||||
|
await tester.runPurchaseLoadTest(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includes('--export')) {
|
||||||
|
tester.exportResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test execution failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LoadTester;
|
||||||
Reference in New Issue
Block a user