/** * 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) }); }); });