503 lines
18 KiB
JavaScript
503 lines
18 KiB
JavaScript
const request = require('supertest');
|
|
const app = require('../server');
|
|
const database = require('../models/database');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
describe('Concurrency and Locking Tests', () => {
|
|
let testDbPath;
|
|
|
|
beforeAll(async () => {
|
|
// Set up test database
|
|
testDbPath = path.join(__dirname, '..', 'test_concurrency.db');
|
|
if (fs.existsSync(testDbPath)) {
|
|
fs.unlinkSync(testDbPath);
|
|
}
|
|
database.dbPath = testDbPath;
|
|
await database.initialize();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
database.close();
|
|
if (fs.existsSync(testDbPath)) {
|
|
fs.unlinkSync(testDbPath);
|
|
}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean up database before each test
|
|
const db = database.getDatabase();
|
|
db.exec('DELETE FROM inventory_history');
|
|
db.exec('DELETE FROM inventory');
|
|
db.exec('DELETE FROM products');
|
|
db.exec('DELETE FROM import_sessions');
|
|
});
|
|
|
|
describe('Optimistic Locking for Inventory Updates', () => {
|
|
let productId;
|
|
|
|
beforeEach(async () => {
|
|
// Create a test product with inventory
|
|
const createResponse = await request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
product_code: 'LOCK_TEST_001',
|
|
description: 'Locking Test Product',
|
|
category: 'Test'
|
|
})
|
|
.expect(201);
|
|
|
|
productId = createResponse.body.data.id;
|
|
|
|
await request(app)
|
|
.post(`/api/inventory/product/${productId}`)
|
|
.send({
|
|
initialLevel: 100,
|
|
minimumLevel: 10,
|
|
maximumLevel: 200,
|
|
updatedBy: 'test-setup'
|
|
})
|
|
.expect(201);
|
|
});
|
|
|
|
test('should handle concurrent updates with optimistic locking', async () => {
|
|
const concurrentUpdates = 10;
|
|
const promises = [];
|
|
|
|
// Create multiple concurrent update requests
|
|
for (let i = 0; i < concurrentUpdates; i++) {
|
|
const promise = request(app)
|
|
.put(`/api/inventory/product/${productId}/level`)
|
|
.send({
|
|
newLevel: 100 + i,
|
|
changeReason: `Concurrent update ${i}`,
|
|
updatedBy: `user-${i}`
|
|
});
|
|
promises.push(promise);
|
|
}
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
// Analyze results
|
|
const successful = results.filter(r => r.status === 'fulfilled' && r.value.status === 200);
|
|
const conflicts = results.filter(r => r.status === 'fulfilled' && r.value.status === 409);
|
|
const errors = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value.status >= 500));
|
|
|
|
console.log(`Concurrent updates: ${concurrentUpdates}`);
|
|
console.log(`Successful: ${successful.length}, Conflicts: ${conflicts.length}, Errors: ${errors.length}`);
|
|
|
|
// Expectations
|
|
expect(successful.length).toBeGreaterThan(0); // At least one should succeed
|
|
expect(successful.length + conflicts.length).toBe(concurrentUpdates); // All should either succeed or conflict
|
|
expect(errors.length).toBe(0); // No server errors
|
|
|
|
// Verify final state is consistent
|
|
const finalResponse = await request(app)
|
|
.get(`/api/inventory/product/${productId}`)
|
|
.expect(200);
|
|
|
|
expect(finalResponse.body.data.current_level).toBeGreaterThanOrEqual(100);
|
|
expect(finalResponse.body.data.current_level).toBeLessThan(100 + concurrentUpdates);
|
|
|
|
// Verify history records match successful updates
|
|
const historyResponse = await request(app)
|
|
.get(`/api/inventory/product/${productId}/history`)
|
|
.expect(200);
|
|
|
|
// Should have initial record + successful updates
|
|
expect(historyResponse.body.data.length).toBe(successful.length + 1);
|
|
});
|
|
|
|
test('should maintain data consistency under high concurrency', async () => {
|
|
const highConcurrency = 50;
|
|
const batchSize = 10;
|
|
|
|
// Run multiple batches of concurrent updates
|
|
for (let batch = 0; batch < Math.ceil(highConcurrency / batchSize); batch++) {
|
|
const batchPromises = [];
|
|
|
|
for (let i = 0; i < batchSize && (batch * batchSize + i) < highConcurrency; i++) {
|
|
const updateIndex = batch * batchSize + i;
|
|
const promise = request(app)
|
|
.put(`/api/inventory/product/${productId}/level`)
|
|
.send({
|
|
newLevel: 50 + updateIndex,
|
|
changeReason: `Batch ${batch} update ${i}`,
|
|
updatedBy: `batch-user-${updateIndex}`
|
|
});
|
|
batchPromises.push(promise);
|
|
}
|
|
|
|
const batchResults = await Promise.allSettled(batchPromises);
|
|
|
|
// Small delay between batches to allow processing
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
console.log(`Batch ${batch + 1} completed`);
|
|
}
|
|
|
|
// Verify final consistency
|
|
const finalResponse = await request(app)
|
|
.get(`/api/inventory/product/${productId}`)
|
|
.expect(200);
|
|
|
|
const historyResponse = await request(app)
|
|
.get(`/api/inventory/product/${productId}/history?limit=100`)
|
|
.expect(200);
|
|
|
|
// Verify no data corruption
|
|
expect(finalResponse.body.data.current_level).toBeGreaterThanOrEqual(50);
|
|
expect(finalResponse.body.data.current_level).toBeLessThan(50 + highConcurrency);
|
|
|
|
// Verify history integrity
|
|
const historyLevels = historyResponse.body.data.map(h => h.new_level);
|
|
const uniqueLevels = [...new Set(historyLevels)];
|
|
expect(uniqueLevels.length).toBe(historyLevels.length); // No duplicate levels
|
|
});
|
|
});
|
|
|
|
describe('Database Transaction Integrity', () => {
|
|
test('should maintain transaction integrity during bulk operations', async () => {
|
|
// Create multiple products for bulk testing
|
|
const productCount = 20;
|
|
const products = [];
|
|
|
|
for (let i = 1; i <= productCount; i++) {
|
|
const response = await request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
product_code: `BULK_TX_${i.toString().padStart(3, '0')}`,
|
|
description: `Bulk Transaction Test Product ${i}`,
|
|
category: 'BulkTest'
|
|
})
|
|
.expect(201);
|
|
products.push(response.body.data);
|
|
}
|
|
|
|
// Create inventory for all products
|
|
const inventoryPromises = products.map(product =>
|
|
request(app)
|
|
.post(`/api/inventory/product/${product.id}`)
|
|
.send({
|
|
initialLevel: 100,
|
|
minimumLevel: 10,
|
|
maximumLevel: 200,
|
|
updatedBy: 'bulk-setup'
|
|
})
|
|
);
|
|
|
|
await Promise.all(inventoryPromises);
|
|
|
|
// Perform bulk updates with some that should fail
|
|
const bulkUpdates = products.map((product, index) => ({
|
|
productId: product.id,
|
|
newLevel: index % 5 === 0 ? -10 : 50 + index, // Every 5th update is invalid (negative)
|
|
changeReason: `Bulk update ${index}`,
|
|
updatedBy: 'bulk-user'
|
|
}));
|
|
|
|
const bulkResponse = await request(app)
|
|
.post('/api/inventory/bulk-update')
|
|
.send({ updates: bulkUpdates });
|
|
|
|
// Should handle partial failures gracefully
|
|
if (bulkResponse.status === 200) {
|
|
// If bulk update succeeded, verify only valid updates were applied
|
|
expect(bulkResponse.body.count).toBeLessThan(productCount);
|
|
} else {
|
|
// If bulk update failed, verify no partial updates were applied
|
|
expect(bulkResponse.status).toBe(400);
|
|
}
|
|
|
|
// Verify database consistency
|
|
const inventoryResponse = await request(app)
|
|
.get('/api/inventory')
|
|
.expect(200);
|
|
|
|
inventoryResponse.body.data.forEach(item => {
|
|
expect(item.current_level).toBeGreaterThanOrEqual(0); // No negative levels
|
|
});
|
|
});
|
|
|
|
test('should handle database deadlocks gracefully', async () => {
|
|
// Create products for deadlock testing
|
|
const product1Response = await request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
name: 'DEADLOCK_001',
|
|
description: 'Deadlock Test Product 1',
|
|
category: 'DeadlockTest'
|
|
})
|
|
.expect(201);
|
|
|
|
const product2Response = await request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
name: 'DEADLOCK_002',
|
|
description: 'Deadlock Test Product 2',
|
|
category: 'DeadlockTest'
|
|
})
|
|
.expect(201);
|
|
|
|
const product1Id = product1Response.body.data.id;
|
|
const product2Id = product2Response.body.data.id;
|
|
|
|
// Create inventory for both products
|
|
await request(app)
|
|
.post(`/api/inventory/product/${product1Id}`)
|
|
.send({ initialLevel: 100, minimumLevel: 10, maximumLevel: 200, updatedBy: 'deadlock-setup' })
|
|
.expect(201);
|
|
|
|
await request(app)
|
|
.post(`/api/inventory/product/${product2Id}`)
|
|
.send({ initialLevel: 100, minimumLevel: 10, maximumLevel: 200, updatedBy: 'deadlock-setup' })
|
|
.expect(201);
|
|
|
|
// Create potential deadlock scenario with cross-updates
|
|
const deadlockPromises = [];
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
// Alternate between updating product1 and product2
|
|
const productId = i % 2 === 0 ? product1Id : product2Id;
|
|
const otherProductId = i % 2 === 0 ? product2Id : product1Id;
|
|
|
|
// Create updates that might cause deadlocks
|
|
deadlockPromises.push(
|
|
request(app)
|
|
.put(`/api/inventory/product/${productId}/level`)
|
|
.send({
|
|
newLevel: 80 + i,
|
|
changeReason: `Deadlock test ${i}`,
|
|
updatedBy: `deadlock-user-${i}`
|
|
})
|
|
);
|
|
}
|
|
|
|
const results = await Promise.allSettled(deadlockPromises);
|
|
|
|
// Analyze results - should handle deadlocks without hanging
|
|
const successful = results.filter(r => r.status === 'fulfilled' && r.value.status === 200);
|
|
const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value.status >= 400));
|
|
|
|
console.log(`Deadlock test: ${successful.length} successful, ${failed.length} failed`);
|
|
|
|
// Should complete without hanging (test timeout would catch hanging)
|
|
expect(successful.length + failed.length).toBe(deadlockPromises.length);
|
|
expect(successful.length).toBeGreaterThan(0); // At least some should succeed
|
|
|
|
// Verify final state is consistent
|
|
const final1Response = await request(app)
|
|
.get(`/api/inventory/product/${product1Id}`)
|
|
.expect(200);
|
|
const final2Response = await request(app)
|
|
.get(`/api/inventory/product/${product2Id}`)
|
|
.expect(200);
|
|
|
|
expect(final1Response.body.data.current_level).toBeGreaterThanOrEqual(80);
|
|
expect(final2Response.body.data.current_level).toBeGreaterThanOrEqual(80);
|
|
});
|
|
});
|
|
|
|
describe('Connection Pool and Resource Management', () => {
|
|
test('should handle multiple simultaneous connections efficiently', async () => {
|
|
// Create test data
|
|
const products = [];
|
|
for (let i = 1; i <= 10; i++) {
|
|
const response = await request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
name: `CONN_TEST_${i.toString().padStart(3, '0')}`,
|
|
description: `Connection Test Product ${i}`,
|
|
category: 'ConnectionTest'
|
|
})
|
|
.expect(201);
|
|
products.push(response.body.data);
|
|
}
|
|
|
|
// Simulate many simultaneous read operations
|
|
const readOperations = [];
|
|
const operationCount = 100;
|
|
|
|
for (let i = 0; i < operationCount; i++) {
|
|
const operations = [
|
|
request(app).get('/api/products'),
|
|
request(app).get('/api/inventory'),
|
|
request(app).get(`/api/products/${products[i % products.length].id}`),
|
|
request(app).get('/api/inventory/low-stock')
|
|
];
|
|
readOperations.push(...operations);
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const results = await Promise.allSettled(readOperations);
|
|
const duration = Date.now() - startTime;
|
|
|
|
const successful = results.filter(r => r.status === 'fulfilled' && r.value.status === 200);
|
|
const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value.status >= 400));
|
|
|
|
console.log(`${readOperations.length} simultaneous operations completed in ${duration}ms`);
|
|
console.log(`Successful: ${successful.length}, Failed: ${failed.length}`);
|
|
console.log(`Average time per operation: ${(duration / readOperations.length).toFixed(2)}ms`);
|
|
|
|
expect(successful.length).toBe(readOperations.length); // All reads should succeed
|
|
expect(failed.length).toBe(0);
|
|
expect(duration).toBeLessThan(10000); // Should complete within 10 seconds
|
|
});
|
|
|
|
test('should handle mixed read/write operations under load', async () => {
|
|
// Create test products
|
|
const products = [];
|
|
for (let i = 1; i <= 5; i++) {
|
|
const response = await request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
name: `MIXED_TEST_${i.toString().padStart(3, '0')}`,
|
|
description: `Mixed Operations Test Product ${i}`,
|
|
category: 'MixedTest'
|
|
})
|
|
.expect(201);
|
|
products.push(response.body.data);
|
|
|
|
// Create inventory
|
|
await request(app)
|
|
.post(`/api/inventory/product/${response.body.data.id}`)
|
|
.send({
|
|
initialLevel: 100,
|
|
minimumLevel: 10,
|
|
maximumLevel: 200,
|
|
updatedBy: 'mixed-setup'
|
|
})
|
|
.expect(201);
|
|
}
|
|
|
|
// Mix of read and write operations
|
|
const mixedOperations = [];
|
|
const totalOperations = 50;
|
|
|
|
for (let i = 0; i < totalOperations; i++) {
|
|
const productId = products[i % products.length].id;
|
|
|
|
if (i % 3 === 0) {
|
|
// Write operation (update inventory)
|
|
mixedOperations.push(
|
|
request(app)
|
|
.put(`/api/inventory/product/${productId}/level`)
|
|
.send({
|
|
newLevel: 80 + (i % 20),
|
|
changeReason: `Mixed test update ${i}`,
|
|
updatedBy: `mixed-user-${i}`
|
|
})
|
|
);
|
|
} else {
|
|
// Read operations
|
|
const readOps = [
|
|
request(app).get(`/api/products/${productId}`),
|
|
request(app).get(`/api/inventory/product/${productId}`),
|
|
request(app).get('/api/inventory?limit=10')
|
|
];
|
|
mixedOperations.push(readOps[i % readOps.length]);
|
|
}
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const results = await Promise.allSettled(mixedOperations);
|
|
const duration = Date.now() - startTime;
|
|
|
|
const successful = results.filter(r => r.status === 'fulfilled' && r.value.status === 200);
|
|
const conflicts = results.filter(r => r.status === 'fulfilled' && r.value.status === 409);
|
|
const errors = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value.status >= 500));
|
|
|
|
console.log(`${mixedOperations.length} mixed operations completed in ${duration}ms`);
|
|
console.log(`Successful: ${successful.length}, Conflicts: ${conflicts.length}, Errors: ${errors.length}`);
|
|
|
|
expect(successful.length + conflicts.length).toBe(mixedOperations.length);
|
|
expect(errors.length).toBe(0); // No server errors
|
|
expect(duration).toBeLessThan(15000); // Should complete within 15 seconds
|
|
});
|
|
});
|
|
|
|
describe('Data Consistency Under Stress', () => {
|
|
test('should maintain referential integrity under concurrent operations', async () => {
|
|
const productCount = 10;
|
|
const operationsPerProduct = 10;
|
|
|
|
// Create products concurrently
|
|
const productPromises = [];
|
|
for (let i = 1; i <= productCount; i++) {
|
|
productPromises.push(
|
|
request(app)
|
|
.post('/api/products')
|
|
.send({
|
|
name: `INTEGRITY_${i.toString().padStart(3, '0')}`,
|
|
description: `Integrity Test Product ${i}`,
|
|
category: 'IntegrityTest'
|
|
})
|
|
);
|
|
}
|
|
|
|
const productResults = await Promise.all(productPromises);
|
|
const products = productResults.map(r => r.body.data);
|
|
|
|
// Create inventory and perform operations concurrently
|
|
const allOperations = [];
|
|
|
|
products.forEach((product, productIndex) => {
|
|
// Create inventory
|
|
allOperations.push(
|
|
request(app)
|
|
.post(`/api/inventory/product/${product.id}`)
|
|
.send({
|
|
initialLevel: 100,
|
|
minimumLevel: 10,
|
|
maximumLevel: 200,
|
|
updatedBy: 'integrity-setup'
|
|
})
|
|
);
|
|
|
|
// Add multiple operations per product
|
|
for (let i = 0; i < operationsPerProduct; i++) {
|
|
allOperations.push(
|
|
request(app)
|
|
.put(`/api/inventory/product/${product.id}/level`)
|
|
.send({
|
|
newLevel: 50 + i + productIndex,
|
|
changeReason: `Integrity test ${productIndex}-${i}`,
|
|
updatedBy: `integrity-user-${productIndex}-${i}`
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
// Execute all operations
|
|
const results = await Promise.allSettled(allOperations);
|
|
|
|
// Verify referential integrity
|
|
const inventoryResponse = await request(app)
|
|
.get('/api/inventory')
|
|
.expect(200);
|
|
|
|
expect(inventoryResponse.body.data).toHaveLength(productCount);
|
|
|
|
// Verify each inventory record has a corresponding product
|
|
for (const inventoryItem of inventoryResponse.body.data) {
|
|
const productExists = products.some(p => p.id === inventoryItem.product_id);
|
|
expect(productExists).toBe(true);
|
|
}
|
|
|
|
// Verify history records maintain referential integrity
|
|
for (const product of products) {
|
|
const historyResponse = await request(app)
|
|
.get(`/api/inventory/product/${product.id}/history`)
|
|
.expect(200);
|
|
|
|
// Should have at least the initial inventory creation
|
|
expect(historyResponse.body.data.length).toBeGreaterThan(0);
|
|
|
|
// All history records should reference the correct product
|
|
historyResponse.body.data.forEach(historyItem => {
|
|
expect(historyItem.product_id).toBe(product.id);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}); |