Skip to main content
Glama
bulk-operations.test.ts10.1 kB
/** * Unit tests for bulk-operations utility * Sprint 9 BUGFIX: Atomic mode API contract validation */ import { describe, it, expect, jest } from '@jest/globals'; import { bulkUpdateEntities } from '../../../src/domain/utils/bulk-operations'; describe('bulkUpdateEntities utility', () => { describe('API contract validation', () => { it('RED (BUGFIX): should throw error when atomic=true but storage is undefined', async () => { /** * BUG: When atomic=true is requested but storage parameter is undefined, * the function silently falls through to non-atomic mode instead of throwing. * This violates the API contract - callers expect atomic behavior. * * Current code (bulk-operations.ts:67): * if (atomic && storage) { // <-- silently falls back when storage undefined * * Expected behavior: Throw error when atomic mode requested without storage */ const mockUpdateFn = jest.fn().mockResolvedValue(undefined); // Attempt atomic mode without providing storage await expect( // eslint-disable-next-line @typescript-eslint/no-unsafe-call bulkUpdateEntities({ entityType: 'requirements', entityIdField: 'requirementId', updateFn: mockUpdateFn, planId: 'test-plan', updates: [{ requirementId: 'req-1', updates: { title: 'Updated' } }], atomic: true, // storage: undefined - intentionally omitted! }) ).rejects.toThrow(/storage.*required.*atomic/i); // The update function should NOT be called at all expect(mockUpdateFn).not.toHaveBeenCalled(); }); it('should work normally in non-atomic mode without storage', async () => { const mockUpdateFn = jest.fn().mockResolvedValue(undefined); // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await bulkUpdateEntities({ entityType: 'requirements', entityIdField: 'requirementId', updateFn: mockUpdateFn, planId: 'test-plan', updates: [ { requirementId: 'req-1', updates: { title: 'Title 1' } }, { requirementId: 'req-2', updates: { title: 'Title 2' } }, ], atomic: false, // non-atomic mode // storage not needed for non-atomic }); expect(result.updated).toBe(2); expect(result.failed).toBe(0); expect(mockUpdateFn).toHaveBeenCalledTimes(2); }); it('should work in atomic mode when storage is provided', async () => { const mockUpdateFn = jest.fn().mockResolvedValue(undefined); const mockStorage = { loadEntities: jest.fn().mockResolvedValue([ { id: 'req-1', title: 'Original 1' }, { id: 'req-2', title: 'Original 2' }, ]), saveEntities: jest.fn().mockResolvedValue(undefined), }; // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await bulkUpdateEntities({ entityType: 'requirements', entityIdField: 'requirementId', updateFn: mockUpdateFn, planId: 'test-plan', updates: [ { requirementId: 'req-1', updates: { title: 'Updated 1' } }, { requirementId: 'req-2', updates: { title: 'Updated 2' } }, ], atomic: true, storage: mockStorage, }); expect(result.updated).toBe(2); expect(result.failed).toBe(0); expect(mockUpdateFn).toHaveBeenCalledTimes(2); expect(mockStorage.loadEntities).toHaveBeenCalled(); }); it('RED (BUGFIX): atomic rollback should preserve all entity fields, not just ID', async () => { /** * CRITICAL BUG: BulkUpdateConfig.storage interface declares loadEntities returning * { id: string }[] (line 14), which is too narrow. In atomic mode, the snapshot * created on line 75 will only contain `id` fields for each entity. * * When rollback occurs on line 99, saveEntities is called with this incomplete snapshot, * causing ALL entity fields except `id` to be permanently lost. * * Current type (bulk-operations.ts:14): * loadEntities: (planId: string, entityType: string) => Promise<{ id: string }[]>; * * This is catastrophic - rollback should restore FULL entity state, not destroy data! * * Expected: loadEntities should return full entities (any[]) so snapshot preserves all fields */ const fullEntities = [ { id: 'req-1', type: 'requirement', title: 'Original Title 1', description: 'Original Description 1', priority: 'high', category: 'functional', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01', version: 1, }, { id: 'req-2', type: 'requirement', title: 'Original Title 2', description: 'Original Description 2', priority: 'medium', category: 'technical', status: 'active', createdAt: '2024-01-02', updatedAt: '2024-01-02', version: 1, }, ]; const mockStorage = { loadEntities: jest.fn().mockResolvedValue(fullEntities), saveEntities: jest.fn().mockResolvedValue(undefined), }; // First update succeeds, second fails const mockUpdateFn = jest .fn() .mockResolvedValueOnce(undefined) // req-1 succeeds .mockRejectedValueOnce(new Error('Validation failed')); // req-2 fails // Attempt atomic bulk update that will fail and rollback await expect( // eslint-disable-next-line @typescript-eslint/no-unsafe-call bulkUpdateEntities({ entityType: 'requirements', entityIdField: 'requirementId', updateFn: mockUpdateFn, planId: 'test-plan', updates: [ { requirementId: 'req-1', updates: { title: 'Updated 1' } }, { requirementId: 'req-2', updates: { title: 'Updated 2' } }, ], atomic: true, storage: mockStorage, }) ).rejects.toThrow(/atomic bulk update failed/i); // Verify rollback was called expect(mockStorage.saveEntities).toHaveBeenCalledTimes(1); // CRITICAL CHECK: Verify rollback snapshot contains ALL fields, not just { id } const rollbackSnapshot = mockStorage.saveEntities.mock.calls[0][2]; expect(rollbackSnapshot).toHaveLength(2); // Check first entity has ALL original fields preserved expect(rollbackSnapshot[0]).toEqual(fullEntities[0]); expect(rollbackSnapshot[0].title).toBe('Original Title 1'); expect(rollbackSnapshot[0].description).toBe('Original Description 1'); expect(rollbackSnapshot[0].priority).toBe('high'); expect(rollbackSnapshot[0].category).toBe('functional'); // Check second entity has ALL original fields preserved expect(rollbackSnapshot[1]).toEqual(fullEntities[1]); expect(rollbackSnapshot[1].title).toBe('Original Title 2'); expect(rollbackSnapshot[1].description).toBe('Original Description 2'); expect(rollbackSnapshot[1].priority).toBe('medium'); expect(rollbackSnapshot[1].category).toBe('technical'); }); it('RED (BUGFIX): should capture both original error and rollback failure', async () => { /** * BUG: In atomic mode, if an update fails and then the rollback saveEntities also throws, * the original update error is lost. The caller only sees the rollback error, making it * impossible to diagnose why the update actually failed. * * Current code (bulk-operations.ts:99-102): * } catch (error: unknown) { * await storage.saveEntities(planId, entityType, snapshot); // <-- if this throws * throw new Error(`Atomic bulk update failed: ${(error as Error).message}...`); // original error is lost! * } * * If saveEntities throws during rollback, the original error is masked. * * Expected: Error handling should capture BOTH the original error and any rollback failure * to provide complete diagnostic information. */ const mockStorage = { loadEntities: jest.fn().mockResolvedValue([ { id: 'req-1', title: 'Original 1' }, { id: 'req-2', title: 'Original 2' }, ]), // Rollback will also fail! saveEntities: jest.fn().mockRejectedValue(new Error('Rollback failed: disk full')), }; const mockUpdateFn = jest .fn() .mockResolvedValueOnce(undefined) // req-1 succeeds .mockRejectedValueOnce(new Error('Validation error: invalid title format')); // req-2 fails // Attempt atomic bulk update where both update AND rollback fail // The thrown error should contain information about BOTH failures: // 1. The original validation error // 2. The rollback failure try { // eslint-disable-next-line @typescript-eslint/no-unsafe-call await bulkUpdateEntities({ entityType: 'requirements', entityIdField: 'requirementId', updateFn: mockUpdateFn, planId: 'test-plan', updates: [ { requirementId: 'req-1', updates: { title: 'Updated 1' } }, { requirementId: 'req-2', updates: { title: 'Updated 2' } }, ], atomic: true, storage: mockStorage, }); throw new Error('Expected bulkUpdateEntities to throw'); } catch (error: unknown) { // Skip if it's our own assertion error if ((error as Error).message === 'Expected bulkUpdateEntities to throw') { throw error; } // CRITICAL: Error message should mention BOTH the original error and rollback failure expect((error as Error).message).toMatch(/validation error.*invalid title format/i); expect((error as Error).message).toMatch(/rollback.*failed.*disk full/i); } }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server