417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
|
|
const request = require("supertest");
|
||
|
|
const app = require("../../server");
|
||
|
|
const redisClient = require("../../src/utils/redis-client");
|
||
|
|
const fallbackStore = require("../../src/utils/fallback-store");
|
||
|
|
|
||
|
|
describe("Performance and Load Testing", () => {
|
||
|
|
let server;
|
||
|
|
let testEventId = "777"; // Use a unique event ID for testing
|
||
|
|
const CONCURRENT_REQUESTS = 100; // Test with 100 concurrent requests
|
||
|
|
const TICKET_COUNT = 50; // Start with 50 tickets
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
// Start the server
|
||
|
|
server = app.listen(0); // Use random port
|
||
|
|
|
||
|
|
// Wait for server to be ready
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
|
|
|
||
|
|
// Ensure Redis is connected
|
||
|
|
if (!redisClient.isConnected) {
|
||
|
|
await redisClient.connect();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
// Clean up test data
|
||
|
|
try {
|
||
|
|
if (redisClient.isConnected) {
|
||
|
|
const testEventKey = `event:${testEventId}:meta`;
|
||
|
|
const testTicketsKey = `event:${testEventId}:tickets`;
|
||
|
|
await redisClient.client.del(testEventKey);
|
||
|
|
await redisClient.client.del(testTicketsKey);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.warn("Failed to cleanup test data:", error.message);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Close server
|
||
|
|
if (server) {
|
||
|
|
await new Promise((resolve) => server.close(resolve));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Disconnect Redis
|
||
|
|
if (redisClient.isConnected) {
|
||
|
|
await redisClient.disconnect();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
// Reset fallback store
|
||
|
|
fallbackStore.deactivate();
|
||
|
|
fallbackStore.events.clear();
|
||
|
|
fallbackStore.globalStats = {
|
||
|
|
totalEvents: 0,
|
||
|
|
totalTickets: 0,
|
||
|
|
totalSold: 0,
|
||
|
|
lastSeeded: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Create test event with tickets in Redis
|
||
|
|
if (redisClient.isConnected) {
|
||
|
|
const testEventKey = `event:${testEventId}:meta`;
|
||
|
|
const testTicketsKey = `event:${testEventId}:tickets`;
|
||
|
|
|
||
|
|
// Create event metadata
|
||
|
|
await redisClient.client.hSet(testEventKey, {
|
||
|
|
eventId: testEventId,
|
||
|
|
name: "Performance Test Event",
|
||
|
|
description: "Test event for load testing",
|
||
|
|
totalTickets: TICKET_COUNT.toString(),
|
||
|
|
soldTickets: "0",
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
lastSoldAt: "never",
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create test tickets
|
||
|
|
const testTickets = Array.from(
|
||
|
|
{ length: TICKET_COUNT },
|
||
|
|
(_, i) => `perf-ticket-${i + 1}`
|
||
|
|
);
|
||
|
|
await redisClient.client.lPush(testTicketsKey, testTickets);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("High Concurrency Ticket Purchase", () => {
|
||
|
|
test("should handle 100 concurrent requests without duplicate tickets", async () => {
|
||
|
|
if (!redisClient.isConnected) {
|
||
|
|
console.warn("Skipping Redis test - Redis not connected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const soldTickets = new Set();
|
||
|
|
const purchasePromises = [];
|
||
|
|
const startTime = Date.now();
|
||
|
|
|
||
|
|
// Create concurrent purchase requests
|
||
|
|
for (let i = 0; i < CONCURRENT_REQUESTS; i++) {
|
||
|
|
const promise = request(server)
|
||
|
|
.post(`/buy/${testEventId}`)
|
||
|
|
.set("Content-Type", "application/json")
|
||
|
|
.then((response) => ({ success: true, response, index: i }))
|
||
|
|
.catch((error) => ({ success: false, error, index: i }));
|
||
|
|
|
||
|
|
purchasePromises.push(promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wait for all requests to complete
|
||
|
|
const results = await Promise.all(purchasePromises);
|
||
|
|
const endTime = Date.now();
|
||
|
|
const totalTime = endTime - startTime;
|
||
|
|
|
||
|
|
let successCount = 0;
|
||
|
|
let failureCount = 0;
|
||
|
|
let duplicateCount = 0;
|
||
|
|
|
||
|
|
results.forEach((result) => {
|
||
|
|
if (
|
||
|
|
result.success &&
|
||
|
|
result.response.status === 200 &&
|
||
|
|
result.response.body.success
|
||
|
|
) {
|
||
|
|
successCount++;
|
||
|
|
const ticket = result.response.body.ticket;
|
||
|
|
|
||
|
|
// Check for duplicates
|
||
|
|
if (soldTickets.has(ticket)) {
|
||
|
|
duplicateCount++;
|
||
|
|
} else {
|
||
|
|
soldTickets.add(ticket);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
failureCount++;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Performance metrics
|
||
|
|
const avgResponseTime = totalTime / CONCURRENT_REQUESTS;
|
||
|
|
const requestsPerSecond = (CONCURRENT_REQUESTS / totalTime) * 1000;
|
||
|
|
|
||
|
|
console.log(`\n📊 Performance Test Results:`);
|
||
|
|
console.log(` Total Time: ${totalTime}ms`);
|
||
|
|
console.log(` Average Response Time: ${avgResponseTime.toFixed(2)}ms`);
|
||
|
|
console.log(` Requests per Second: ${requestsPerSecond.toFixed(2)}`);
|
||
|
|
console.log(` Success Count: ${successCount}`);
|
||
|
|
console.log(` Failure Count: ${failureCount}`);
|
||
|
|
console.log(` Duplicate Tickets: ${duplicateCount}`);
|
||
|
|
|
||
|
|
// Critical assertions
|
||
|
|
expect(duplicateCount).toBe(0); // No duplicate tickets allowed
|
||
|
|
expect(successCount).toBe(TICKET_COUNT); // Should sell exactly available tickets
|
||
|
|
expect(failureCount).toBe(CONCURRENT_REQUESTS - TICKET_COUNT); // Remaining should fail
|
||
|
|
expect(soldTickets.size).toBe(TICKET_COUNT); // All sold tickets should be unique
|
||
|
|
|
||
|
|
// Performance requirements
|
||
|
|
expect(avgResponseTime).toBeLessThan(1000); // Should respond within 1 second on average
|
||
|
|
expect(requestsPerSecond).toBeGreaterThan(10); // Should handle at least 10 RPS
|
||
|
|
});
|
||
|
|
|
||
|
|
test("should maintain data consistency under high load", async () => {
|
||
|
|
if (!redisClient.isConnected) {
|
||
|
|
console.warn("Skipping Redis test - Redis not connected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Purchase 20 tickets under load
|
||
|
|
const purchasePromises = [];
|
||
|
|
for (let i = 0; i < 20; i++) {
|
||
|
|
const promise = request(server)
|
||
|
|
.post(`/buy/${testEventId}`)
|
||
|
|
.set("Content-Type", "application/json");
|
||
|
|
|
||
|
|
purchasePromises.push(promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
await Promise.all(purchasePromises);
|
||
|
|
|
||
|
|
// Verify Redis consistency
|
||
|
|
const remainingTickets = await redisClient.client.lLen(
|
||
|
|
`event:${testEventId}:tickets`
|
||
|
|
);
|
||
|
|
const eventStats = await redisClient.getEventStats(testEventId);
|
||
|
|
|
||
|
|
expect(remainingTickets).toBe(TICKET_COUNT - 20);
|
||
|
|
expect(eventStats.soldTickets).toBe(20);
|
||
|
|
expect(eventStats.remainingTickets).toBe(TICKET_COUNT - 20);
|
||
|
|
|
||
|
|
// Verify no tickets remain after exhausting supply
|
||
|
|
const finalPurchasePromises = [];
|
||
|
|
for (let i = 0; i < TICKET_COUNT; i++) {
|
||
|
|
const promise = request(server)
|
||
|
|
.post(`/buy/${testEventId}`)
|
||
|
|
.set("Content-Type", "application/json");
|
||
|
|
|
||
|
|
finalPurchasePromises.push(promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
const finalResults = await Promise.all(finalPurchasePromises);
|
||
|
|
const finalSuccessCount = finalResults.filter(
|
||
|
|
(r) =>
|
||
|
|
r.response && r.response.status === 200 && r.response.body.success
|
||
|
|
).length;
|
||
|
|
|
||
|
|
expect(finalSuccessCount).toBe(TICKET_COUNT - 20); // Should only sell remaining tickets
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Fallback Store Performance", () => {
|
||
|
|
test("should handle high concurrency in fallback mode", async () => {
|
||
|
|
// Disconnect Redis to simulate failure
|
||
|
|
if (redisClient.isConnected) {
|
||
|
|
await redisClient.disconnect();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Seed fallback store with test event
|
||
|
|
const metadata = {
|
||
|
|
eventId: testEventId,
|
||
|
|
name: "Fallback Performance Test Event",
|
||
|
|
description: "Test event for fallback performance",
|
||
|
|
totalTickets: 30,
|
||
|
|
soldTickets: 0,
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
lastSoldAt: "never",
|
||
|
|
};
|
||
|
|
|
||
|
|
const testTickets = Array.from(
|
||
|
|
{ length: 30 },
|
||
|
|
(_, i) => `fallback-perf-ticket-${i + 1}`
|
||
|
|
);
|
||
|
|
fallbackStore.seedEvent(testEventId, testTickets, metadata);
|
||
|
|
fallbackStore.activate("Performance test fallback mode");
|
||
|
|
|
||
|
|
const soldTickets = new Set();
|
||
|
|
const purchasePromises = [];
|
||
|
|
const startTime = Date.now();
|
||
|
|
|
||
|
|
// Create 50 concurrent purchase requests (more than available)
|
||
|
|
for (let i = 0; i < 50; i++) {
|
||
|
|
const promise = request(server)
|
||
|
|
.post(`/buy/${testEventId}`)
|
||
|
|
.set("Content-Type", "application/json")
|
||
|
|
.then((response) => ({ success: true, response, index: i }))
|
||
|
|
.catch((error) => ({ success: false, error, index: i }));
|
||
|
|
|
||
|
|
purchasePromises.push(promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
const results = await Promise.all(purchasePromises);
|
||
|
|
const endTime = Date.now();
|
||
|
|
const totalTime = endTime - startTime;
|
||
|
|
|
||
|
|
let successCount = 0;
|
||
|
|
let failureCount = 0;
|
||
|
|
let duplicateCount = 0;
|
||
|
|
|
||
|
|
results.forEach((result) => {
|
||
|
|
if (
|
||
|
|
result.success &&
|
||
|
|
result.response.status === 200 &&
|
||
|
|
result.response.body.success
|
||
|
|
) {
|
||
|
|
successCount++;
|
||
|
|
const ticket = result.response.body.ticket;
|
||
|
|
|
||
|
|
if (soldTickets.has(ticket)) {
|
||
|
|
duplicateCount++;
|
||
|
|
} else {
|
||
|
|
soldTickets.add(ticket);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
failureCount++;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log(`\n📊 Fallback Performance Test Results:`);
|
||
|
|
console.log(` Total Time: ${totalTime}ms`);
|
||
|
|
console.log(` Success Count: ${successCount}`);
|
||
|
|
console.log(` Failure Count: ${failureCount}`);
|
||
|
|
console.log(` Duplicate Tickets: ${duplicateCount}`);
|
||
|
|
|
||
|
|
// Critical assertions for fallback mode
|
||
|
|
expect(duplicateCount).toBe(0); // No duplicate tickets
|
||
|
|
expect(successCount).toBe(30); // Should sell exactly available tickets
|
||
|
|
expect(failureCount).toBe(20); // Remaining should fail
|
||
|
|
expect(soldTickets.size).toBe(30); // All sold tickets should be unique
|
||
|
|
|
||
|
|
// Verify fallback store consistency
|
||
|
|
const event = fallbackStore.events.get(testEventId);
|
||
|
|
expect(event.tickets).toHaveLength(0); // No tickets should remain
|
||
|
|
expect(event.soldTickets).toBe(30); // All tickets should be marked as sold
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Memory and Resource Usage", () => {
|
||
|
|
test("should maintain stable memory usage under load", async () => {
|
||
|
|
if (!redisClient.isConnected) {
|
||
|
|
console.warn("Skipping Redis test - Redis not connected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const initialMemory = process.memoryUsage();
|
||
|
|
console.log(`\n💾 Initial Memory Usage:`);
|
||
|
|
console.log(` RSS: ${(initialMemory.rss / 1024 / 1024).toFixed(2)} MB`);
|
||
|
|
console.log(
|
||
|
|
` Heap Used: ${(initialMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`
|
||
|
|
);
|
||
|
|
|
||
|
|
// Perform multiple rounds of purchases
|
||
|
|
for (let round = 0; round < 3; round++) {
|
||
|
|
// Reset test event
|
||
|
|
const testTicketsKey = `event:${testEventId}:tickets`;
|
||
|
|
await redisClient.client.del(testTicketsKey);
|
||
|
|
const testTickets = Array.from(
|
||
|
|
{ length: 20 },
|
||
|
|
(_, i) => `round-${round}-ticket-${i + 1}`
|
||
|
|
);
|
||
|
|
await redisClient.client.lPush(testTicketsKey, testTickets);
|
||
|
|
|
||
|
|
// Purchase all tickets
|
||
|
|
const purchasePromises = [];
|
||
|
|
for (let i = 0; i < 20; i++) {
|
||
|
|
const promise = request(server)
|
||
|
|
.post(`/buy/${testEventId}`)
|
||
|
|
.set("Content-Type", "application/json");
|
||
|
|
|
||
|
|
purchasePromises.push(promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
await Promise.all(purchasePromises);
|
||
|
|
}
|
||
|
|
|
||
|
|
const finalMemory = process.memoryUsage();
|
||
|
|
console.log(`\n💾 Final Memory Usage:`);
|
||
|
|
console.log(` RSS: ${(finalMemory.rss / 1024 / 1024).toFixed(2)} MB`);
|
||
|
|
console.log(
|
||
|
|
` Heap Used: ${(finalMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`
|
||
|
|
);
|
||
|
|
|
||
|
|
const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
|
||
|
|
const memoryIncreaseMB = memoryIncrease / 1024 / 1024;
|
||
|
|
|
||
|
|
console.log(`\n📈 Memory Change: ${memoryIncreaseMB.toFixed(2)} MB`);
|
||
|
|
|
||
|
|
// Memory should not increase excessively (less than 50MB increase)
|
||
|
|
expect(memoryIncreaseMB).toBeLessThan(50);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Response Time Consistency", () => {
|
||
|
|
test("should maintain consistent response times under varying load", async () => {
|
||
|
|
if (!redisClient.isConnected) {
|
||
|
|
console.warn("Skipping Redis test - Redis not connected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const responseTimes = [];
|
||
|
|
const loadLevels = [10, 25, 50, 75, 100];
|
||
|
|
|
||
|
|
for (const loadLevel of loadLevels) {
|
||
|
|
// Reset test event
|
||
|
|
const testTicketsKey = `event:${testEventId}:tickets`;
|
||
|
|
await redisClient.client.del(testTicketsKey);
|
||
|
|
const testTickets = Array.from(
|
||
|
|
{ length: loadLevel },
|
||
|
|
(_, i) => `load-${loadLevel}-ticket-${i + 1}`
|
||
|
|
);
|
||
|
|
await redisClient.client.lPush(testTicketsKey, testTickets);
|
||
|
|
|
||
|
|
const startTime = Date.now();
|
||
|
|
const purchasePromises = [];
|
||
|
|
|
||
|
|
for (let i = 0; i < loadLevel; i++) {
|
||
|
|
const promise = request(server)
|
||
|
|
.post(`/buy/${testEventId}`)
|
||
|
|
.set("Content-Type", "application/json");
|
||
|
|
|
||
|
|
purchasePromises.push(promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
await Promise.all(purchasePromises);
|
||
|
|
const endTime = Date.now();
|
||
|
|
const responseTime = endTime - startTime;
|
||
|
|
|
||
|
|
responseTimes.push({
|
||
|
|
loadLevel,
|
||
|
|
responseTime,
|
||
|
|
avgResponseTime: responseTime / loadLevel,
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log(`\n⚡ Load Level ${loadLevel}:`);
|
||
|
|
console.log(` Total Time: ${responseTime}ms`);
|
||
|
|
console.log(
|
||
|
|
` Average Response: ${(responseTime / loadLevel).toFixed(2)}ms`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate consistency metrics
|
||
|
|
const avgResponseTimes = responseTimes.map((r) => r.avgResponseTime);
|
||
|
|
const minResponseTime = Math.min(...avgResponseTimes);
|
||
|
|
const maxResponseTime = Math.max(...avgResponseTimes);
|
||
|
|
const responseTimeVariance = maxResponseTime - minResponseTime;
|
||
|
|
|
||
|
|
console.log(`\n📊 Response Time Consistency:`);
|
||
|
|
console.log(` Min Avg Response: ${minResponseTime.toFixed(2)}ms`);
|
||
|
|
console.log(` Max Avg Response: ${maxResponseTime.toFixed(2)}ms`);
|
||
|
|
console.log(` Variance: ${responseTimeVariance.toFixed(2)}ms`);
|
||
|
|
|
||
|
|
// Response times should be reasonably consistent (variance < 200ms)
|
||
|
|
expect(responseTimeVariance).toBeLessThan(200);
|
||
|
|
|
||
|
|
// All response times should be reasonable (< 2 seconds average)
|
||
|
|
avgResponseTimes.forEach((time) => {
|
||
|
|
expect(time).toBeLessThan(2000);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|