diff --git a/.env b/.env new file mode 100644 index 0000000..d1af061 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1af061 --- /dev/null +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index f101951..e68c3a9 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,29 @@ "main": "server.js", "scripts": { "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": [], "author": "", "license": "ISC", "dependencies": { "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" } } diff --git a/seed.js b/seed.js index 97327ce..dd152fe 100644 --- a/seed.js +++ b/seed.js @@ -1,25 +1,63 @@ const redis = require("redis"); +require('dotenv').config(); -// Number of tickets to seed; default is 100000, or can be passed as a command line argument -const numTickets = parseInt(process.argv[2]) || 100000; +// Configuration for seeding +const config = { + numEvents: parseInt(process.argv[2]) || 5, // Number of events to create + ticketsPerEvent: parseInt(process.argv[3]) || 10000, // Tickets per event + redisUrl: process.env.REDIS_URL || "redis://localhost:6379" +}; -// Use environment variable for Redis URL if provided; otherwise default to localhost -const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"; -const client = redis.createClient({ url: redisUrl }); +const client = redis.createClient({ url: config.redisUrl }); async function seedTickets() { try { await client.connect(); - // Clear existing tickets if any - await client.del("tickets"); - console.log(`Seeding ${numTickets} tickets into Redis...`); - let tickets = []; - for (let i = 1; i <= numTickets; i++) { - tickets.push(`ticket-${i}`); + console.log(`Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...`); + + // Clear existing event data + const existingKeys = await client.keys('event:*'); + if (existingKeys.length > 0) { + await client.del(existingKeys); + console.log(`Cleared ${existingKeys.length} existing event keys.`); } - // Push all tickets into the 'tickets' list - await client.rPush("tickets", tickets); - console.log(`Seeded ${numTickets} tickets.`); + + // Seed multiple events + for (let eventId = 1; eventId <= config.numEvents; eventId++) { + const eventKey = `event:${eventId}:tickets`; + const metaKey = `event:${eventId}:meta`; + + // Generate tickets for this event + const tickets = []; + for (let i = 1; i <= config.ticketsPerEvent; i++) { + tickets.push(`ticket-${eventId}-${i}`); + } + + // Store tickets in Redis list + await client.rPush(eventKey, tickets); + + // Store event metadata + await client.hSet(metaKey, { + 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); } catch (err) { console.error("Error during seed:", err); diff --git a/server.js b/server.js index 258c74a..b4d7549 100644 --- a/server.js +++ b/server.js @@ -1,35 +1,486 @@ -const express = require("express"); -const redis = require("redis"); +const express = require('express'); +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 port = process.env.PORT || 3049; +// Middleware app.use(express.json()); +app.use(express.static('public')); -// Create Redis client and connect -const client = redis.createClient(); -client.connect().catch(console.error); +// Request logging middleware +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const responseTime = Date.now() - start; + logger.logRequest(req, res, responseTime); + }); + next(); +}); -// Purchase ticket endpoint -app.post("/buy", async (req, res) => { +// Global error handler +app.use((err, req, res, next) => { + logger.error('Unhandled error:', err); + res.status(500).json({ + success: false, + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? err.message : undefined + }); +}); + +// 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 { - // Atomically pop a ticket from the 'tickets' list - const ticket = await client.lPop("tickets"); - if (ticket) { - res.json({ - success: true, - ticket, - message: "Ticket purchased successfully!", - }); + let events; + + if (redisClient.isHealthy()) { + events = await redisClient.getAllEvents(); } else { - res.status(404).json({ success: false, message: "No tickets available" }); + events = fallbackStore.getAllEvents(); } + + res.json({ + success: true, + events, + usingFallback: fallbackStore.isActive + }); } catch (error) { - console.error("Error during ticket purchase:", error); - res.status(500).json({ success: false, message: "Internal Server Error" }); + logger.error('Error fetching events:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch events' + }); } }); -app.listen(port, () => { - console.log(`Server is running on port ${port}`); +// Get specific event stats +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(); diff --git a/src/lua/get-event-stats.lua b/src/lua/get-event-stats.lua new file mode 100644 index 0000000..fe3ca86 --- /dev/null +++ b/src/lua/get-event-stats.lua @@ -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" +} diff --git a/src/lua/purchase-ticket.lua b/src/lua/purchase-ticket.lua new file mode 100644 index 0000000..c5dcde8 --- /dev/null +++ b/src/lua/purchase-ticket.lua @@ -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} diff --git a/src/utils/fallback-store.js b/src/utils/fallback-store.js new file mode 100644 index 0000000..ccda022 --- /dev/null +++ b/src/utils/fallback-store.js @@ -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(); diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..9653043 --- /dev/null +++ b/src/utils/logger.js @@ -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; diff --git a/src/utils/pdf-generator.js b/src/utils/pdf-generator.js new file mode 100644 index 0000000..3f5f894 --- /dev/null +++ b/src/utils/pdf-generator.js @@ -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(); diff --git a/src/utils/redis-client.js b/src/utils/redis-client.js new file mode 100644 index 0000000..bcb6830 --- /dev/null +++ b/src/utils/redis-client.js @@ -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(); diff --git a/tests/load-test.js b/tests/load-test.js new file mode 100644 index 0000000..17991b3 --- /dev/null +++ b/tests/load-test.js @@ -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;