feat: complete phase one and two of task implementation

This commit is contained in:
Ayobami
2025-07-29 21:48:34 +01:00
parent b8fc924e7e
commit 42fec5708a
12 changed files with 1585 additions and 36 deletions
+21
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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"
} }
} }
+52 -14
View File
@@ -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);
+470 -19
View File
@@ -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()) {
events = await redisClient.getAllEvents();
} else {
events = fallbackStore.getAllEvents();
}
res.json({ res.json({
success: true, success: true,
ticket, events,
message: "Ticket purchased successfully!", usingFallback: fallbackStore.isActive
}); });
} else {
res.status(404).json({ success: false, message: "No tickets available" });
}
} 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();
+33
View File
@@ -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"
}
+46
View File
@@ -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}
+131
View File
@@ -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();
+105
View File
@@ -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;
+247
View File
@@ -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();
+185
View File
@@ -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();
+255
View File
@@ -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;