/**
* 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: '
Result 1
' },
{ success: true, operation_id: 'op-2', html: 'Result 2
' }
],
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': '5' } }],
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: 'Success
' },
{ 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: 'Updated 1
' },
{ success: true, componentId: 'comp:2', html: 'Updated 2
' }
];
await LiveComponent.applyBatchResults(results);
expect(element1.innerHTML).toBe('Updated 1
');
expect(element2.innerHTML).toBe('Updated 2
');
});
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': 'New Header
', 'footer': 'New Footer
' }
}
];
await LiveComponent.applyBatchResults(results);
const config = LiveComponent.components.get('comp:1');
expect(config.lastFragments).toEqual({ 'header': 'New Header
', 'footer': 'New Footer
' });
});
it('updates component state', async () => {
const element = document.createElement('div');
LiveComponent.components.set('comp:1', { element });
const results = [
{
success: true,
componentId: 'comp:1',
html: 'Updated
',
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 = 'Original
';
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('Original
');
});
it('skips unknown components', async () => {
const results = [
{ success: true, componentId: 'unknown:1', html: 'Test
' }
];
// 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)
});
});
});