- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
475 lines
17 KiB
JavaScript
475 lines
17 KiB
JavaScript
/**
|
|
* LiveComponent Batching Tests
|
|
*
|
|
* Tests for request batching functionality that reduces HTTP overhead
|
|
* by combining multiple component actions into a single request.
|
|
*/
|
|
|
|
describe('LiveComponent Batching', () => {
|
|
let LiveComponent;
|
|
let fetchMock;
|
|
|
|
beforeEach(() => {
|
|
// Reset DOM
|
|
document.body.innerHTML = '';
|
|
|
|
// Mock fetch
|
|
fetchMock = jest.fn();
|
|
global.fetch = fetchMock;
|
|
|
|
// Create minimal LiveComponent manager for testing
|
|
LiveComponent = {
|
|
components: new Map(),
|
|
batchQueue: [],
|
|
batchQueueTimer: null,
|
|
|
|
async executeBatch(operations, options = {}) {
|
|
if (!Array.isArray(operations) || operations.length === 0) {
|
|
throw new Error('Operations must be a non-empty array');
|
|
}
|
|
|
|
const batchRequest = {
|
|
operations: operations.map(op => ({
|
|
componentId: op.componentId,
|
|
method: op.method,
|
|
params: op.params || {},
|
|
fragments: op.fragments || null,
|
|
operationId: op.operationId || null
|
|
}))
|
|
};
|
|
|
|
const response = await fetch('/live-component/batch', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify(batchRequest)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Batch request failed');
|
|
}
|
|
|
|
const batchResponse = await response.json();
|
|
|
|
if (options.autoApply !== false) {
|
|
await this.applyBatchResults(batchResponse.results);
|
|
}
|
|
|
|
return batchResponse;
|
|
},
|
|
|
|
async applyBatchResults(results) {
|
|
for (const result of results) {
|
|
if (!result.success) continue;
|
|
|
|
const componentId = result.componentId;
|
|
const config = this.components.get(componentId);
|
|
if (!config) continue;
|
|
|
|
if (result.fragments) {
|
|
// Mock fragment update
|
|
config.lastFragments = result.fragments;
|
|
} else if (result.html) {
|
|
config.element.innerHTML = result.html;
|
|
}
|
|
|
|
if (result.state) {
|
|
config.element.dataset.state = JSON.stringify(result.state);
|
|
}
|
|
}
|
|
},
|
|
|
|
queueBatchOperation(operation, options = {}) {
|
|
this.batchQueue.push(operation);
|
|
|
|
if (this.batchQueueTimer) {
|
|
clearTimeout(this.batchQueueTimer);
|
|
}
|
|
|
|
const flushDelay = options.flushDelay || 50;
|
|
this.batchQueueTimer = setTimeout(() => {
|
|
this.flushBatchQueue();
|
|
}, flushDelay);
|
|
},
|
|
|
|
async flushBatchQueue() {
|
|
if (this.batchQueue.length === 0) return;
|
|
|
|
const operations = [...this.batchQueue];
|
|
this.batchQueue = [];
|
|
this.batchQueueTimer = null;
|
|
|
|
await this.executeBatch(operations);
|
|
}
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
if (LiveComponent.batchQueueTimer) {
|
|
clearTimeout(LiveComponent.batchQueueTimer);
|
|
}
|
|
});
|
|
|
|
describe('executeBatch', () => {
|
|
it('executes batch request with multiple operations', async () => {
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: [
|
|
{ success: true, operation_id: 'op-1', html: '<div>Result 1</div>' },
|
|
{ success: true, operation_id: 'op-2', html: '<div>Result 2</div>' }
|
|
],
|
|
total_operations: 2,
|
|
success_count: 2,
|
|
failure_count: 0
|
|
})
|
|
});
|
|
|
|
const operations = [
|
|
{ componentId: 'counter:1', method: 'increment', operationId: 'op-1' },
|
|
{ componentId: 'counter:2', method: 'increment', operationId: 'op-2' }
|
|
];
|
|
|
|
const response = await LiveComponent.executeBatch(operations, { autoApply: false });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith('/live-component/batch', expect.objectContaining({
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
}
|
|
}));
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(requestBody.operations).toHaveLength(2);
|
|
expect(response.success_count).toBe(2);
|
|
});
|
|
|
|
it('throws error for empty operations array', async () => {
|
|
await expect(LiveComponent.executeBatch([])).rejects.toThrow('Operations must be a non-empty array');
|
|
});
|
|
|
|
it('throws error for non-array operations', async () => {
|
|
await expect(LiveComponent.executeBatch(null)).rejects.toThrow('Operations must be a non-empty array');
|
|
});
|
|
|
|
it('includes fragments in batch request', async () => {
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: [{ success: true, operation_id: 'op-1', fragments: { 'display': '<span>5</span>' } }],
|
|
total_operations: 1,
|
|
success_count: 1,
|
|
failure_count: 0
|
|
})
|
|
});
|
|
|
|
const operations = [
|
|
{
|
|
componentId: 'counter:1',
|
|
method: 'increment',
|
|
params: { amount: 5 },
|
|
fragments: ['display'],
|
|
operationId: 'op-1'
|
|
}
|
|
];
|
|
|
|
await LiveComponent.executeBatch(operations, { autoApply: false });
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(requestBody.operations[0].fragments).toEqual(['display']);
|
|
expect(requestBody.operations[0].params).toEqual({ amount: 5 });
|
|
});
|
|
|
|
it('handles server error response', async () => {
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
json: async () => ({ error: 'Batch validation failed' })
|
|
});
|
|
|
|
const operations = [{ componentId: 'test', method: 'action' }];
|
|
|
|
await expect(LiveComponent.executeBatch(operations)).rejects.toThrow('Batch validation failed');
|
|
});
|
|
|
|
it('handles partial failures in batch', async () => {
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: [
|
|
{ success: true, operation_id: 'op-1', html: '<div>Success</div>' },
|
|
{ success: false, operation_id: 'op-2', error: 'Action not found', error_code: 'ACTION_NOT_FOUND' }
|
|
],
|
|
total_operations: 2,
|
|
success_count: 1,
|
|
failure_count: 1
|
|
})
|
|
});
|
|
|
|
const operations = [
|
|
{ componentId: 'component:1', method: 'validAction', operationId: 'op-1' },
|
|
{ componentId: 'component:2', method: 'invalidAction', operationId: 'op-2' }
|
|
];
|
|
|
|
const response = await LiveComponent.executeBatch(operations, { autoApply: false });
|
|
|
|
expect(response.success_count).toBe(1);
|
|
expect(response.failure_count).toBe(1);
|
|
expect(response.results[1].success).toBe(false);
|
|
expect(response.results[1].error_code).toBe('ACTION_NOT_FOUND');
|
|
});
|
|
});
|
|
|
|
describe('applyBatchResults', () => {
|
|
it('applies HTML results to components', async () => {
|
|
const element1 = document.createElement('div');
|
|
const element2 = document.createElement('div');
|
|
|
|
LiveComponent.components.set('comp:1', { element: element1 });
|
|
LiveComponent.components.set('comp:2', { element: element2 });
|
|
|
|
const results = [
|
|
{ success: true, componentId: 'comp:1', html: '<p>Updated 1</p>' },
|
|
{ success: true, componentId: 'comp:2', html: '<p>Updated 2</p>' }
|
|
];
|
|
|
|
await LiveComponent.applyBatchResults(results);
|
|
|
|
expect(element1.innerHTML).toBe('<p>Updated 1</p>');
|
|
expect(element2.innerHTML).toBe('<p>Updated 2</p>');
|
|
});
|
|
|
|
it('applies fragment results to components', async () => {
|
|
const element = document.createElement('div');
|
|
LiveComponent.components.set('comp:1', { element });
|
|
|
|
const results = [
|
|
{
|
|
success: true,
|
|
componentId: 'comp:1',
|
|
fragments: { 'header': '<h1>New Header</h1>', 'footer': '<p>New Footer</p>' }
|
|
}
|
|
];
|
|
|
|
await LiveComponent.applyBatchResults(results);
|
|
|
|
const config = LiveComponent.components.get('comp:1');
|
|
expect(config.lastFragments).toEqual({ 'header': '<h1>New Header</h1>', 'footer': '<p>New Footer</p>' });
|
|
});
|
|
|
|
it('updates component state', async () => {
|
|
const element = document.createElement('div');
|
|
LiveComponent.components.set('comp:1', { element });
|
|
|
|
const results = [
|
|
{
|
|
success: true,
|
|
componentId: 'comp:1',
|
|
html: '<div>Updated</div>',
|
|
state: { count: 10, active: true }
|
|
}
|
|
];
|
|
|
|
await LiveComponent.applyBatchResults(results);
|
|
|
|
const state = JSON.parse(element.dataset.state);
|
|
expect(state.count).toBe(10);
|
|
expect(state.active).toBe(true);
|
|
});
|
|
|
|
it('skips failed operations', async () => {
|
|
const element = document.createElement('div');
|
|
element.innerHTML = '<div>Original</div>';
|
|
LiveComponent.components.set('comp:1', { element });
|
|
|
|
const results = [
|
|
{ success: false, componentId: 'comp:1', error: 'Action failed' }
|
|
];
|
|
|
|
await LiveComponent.applyBatchResults(results);
|
|
|
|
// Original content should be unchanged
|
|
expect(element.innerHTML).toBe('<div>Original</div>');
|
|
});
|
|
|
|
it('skips unknown components', async () => {
|
|
const results = [
|
|
{ success: true, componentId: 'unknown:1', html: '<div>Test</div>' }
|
|
];
|
|
|
|
// Should not throw
|
|
await expect(LiveComponent.applyBatchResults(results)).resolves.not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('queueBatchOperation', () => {
|
|
it('queues operation for batching', () => {
|
|
const operation = { componentId: 'test:1', method: 'action' };
|
|
|
|
LiveComponent.queueBatchOperation(operation);
|
|
|
|
expect(LiveComponent.batchQueue).toHaveLength(1);
|
|
expect(LiveComponent.batchQueue[0]).toEqual(operation);
|
|
});
|
|
|
|
it('sets timer for auto-flush', () => {
|
|
const operation = { componentId: 'test:1', method: 'action' };
|
|
|
|
LiveComponent.queueBatchOperation(operation);
|
|
|
|
expect(LiveComponent.batchQueueTimer).not.toBeNull();
|
|
});
|
|
|
|
it('resets timer on subsequent operations', () => {
|
|
LiveComponent.queueBatchOperation({ componentId: 'test:1', method: 'action1' });
|
|
const firstTimer = LiveComponent.batchQueueTimer;
|
|
|
|
LiveComponent.queueBatchOperation({ componentId: 'test:2', method: 'action2' });
|
|
const secondTimer = LiveComponent.batchQueueTimer;
|
|
|
|
expect(firstTimer).not.toBe(secondTimer);
|
|
expect(LiveComponent.batchQueue).toHaveLength(2);
|
|
});
|
|
|
|
it('uses custom flush delay', () => {
|
|
jest.useFakeTimers();
|
|
|
|
LiveComponent.queueBatchOperation(
|
|
{ componentId: 'test:1', method: 'action' },
|
|
{ flushDelay: 100 }
|
|
);
|
|
|
|
expect(LiveComponent.batchQueue).toHaveLength(1);
|
|
|
|
jest.advanceTimersByTime(50);
|
|
expect(LiveComponent.batchQueue).toHaveLength(1); // Not flushed yet
|
|
|
|
jest.advanceTimersByTime(50);
|
|
// Timer should have fired (but executeBatch is mocked so queue won't clear in test)
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('flushBatchQueue', () => {
|
|
it('executes all queued operations', async () => {
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: [
|
|
{ success: true, operation_id: 'op-1' },
|
|
{ success: true, operation_id: 'op-2' },
|
|
{ success: true, operation_id: 'op-3' }
|
|
],
|
|
total_operations: 3,
|
|
success_count: 3,
|
|
failure_count: 0
|
|
})
|
|
});
|
|
|
|
LiveComponent.batchQueue = [
|
|
{ componentId: 'test:1', method: 'action1' },
|
|
{ componentId: 'test:2', method: 'action2' },
|
|
{ componentId: 'test:3', method: 'action3' }
|
|
];
|
|
|
|
await LiveComponent.flushBatchQueue();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(LiveComponent.batchQueue).toHaveLength(0);
|
|
expect(LiveComponent.batchQueueTimer).toBeNull();
|
|
});
|
|
|
|
it('does nothing when queue is empty', async () => {
|
|
LiveComponent.batchQueue = [];
|
|
|
|
await LiveComponent.flushBatchQueue();
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears queue even on error', async () => {
|
|
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
LiveComponent.batchQueue = [
|
|
{ componentId: 'test:1', method: 'action1' }
|
|
];
|
|
|
|
await LiveComponent.flushBatchQueue();
|
|
|
|
expect(LiveComponent.batchQueue).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('automatic batching integration', () => {
|
|
it('batches multiple operations within time window', async () => {
|
|
jest.useFakeTimers();
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: [
|
|
{ success: true, operation_id: 'op-1' },
|
|
{ success: true, operation_id: 'op-2' }
|
|
],
|
|
total_operations: 2,
|
|
success_count: 2,
|
|
failure_count: 0
|
|
})
|
|
});
|
|
|
|
// Queue multiple operations within 50ms window
|
|
LiveComponent.queueBatchOperation({ componentId: 'test:1', method: 'action1' });
|
|
LiveComponent.queueBatchOperation({ componentId: 'test:2', method: 'action2' });
|
|
|
|
expect(LiveComponent.batchQueue).toHaveLength(2);
|
|
|
|
// Fast-forward timer to trigger flush
|
|
jest.advanceTimersByTime(50);
|
|
|
|
// Wait for async execution
|
|
await Promise.resolve();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(requestBody.operations).toHaveLength(2);
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('performance benefits', () => {
|
|
it('reduces HTTP requests compared to individual actions', async () => {
|
|
// Batch scenario
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: Array(10).fill({ success: true }),
|
|
total_operations: 10,
|
|
success_count: 10,
|
|
failure_count: 0
|
|
})
|
|
});
|
|
|
|
const operations = Array.from({ length: 10 }, (_, i) => ({
|
|
componentId: `test:${i}`,
|
|
method: 'action'
|
|
}));
|
|
|
|
await LiveComponent.executeBatch(operations, { autoApply: false });
|
|
|
|
// Only 1 HTTP request for 10 operations
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Without batching, would require 10 separate HTTP requests
|
|
// Reduction: 90% (10 requests → 1 request)
|
|
});
|
|
});
|
|
});
|