/** * State Manager Tests * * Tests the thread-safe state management with file locking */ const StateManager = require('../state-manager'); const fs = require('fs').promises; const path = require('path'); const os = require('os'); // Dedicated temp subdirectory avoids cross-test file collisions const testDir = path.join(os.tmpdir(), `state-manager-test-${Date.now()}`); const testFile = path.join(testDir, 'test-state.json'); describe('StateManager', () => { let stateManager; beforeAll(async () => { await fs.mkdir(testDir, { recursive: true }); }); beforeEach(async () => { // Clean up test file + stale lockfiles for (const f of [testFile, `${testFile}.lock`]) { try { await fs.unlink(f); } catch (e) { /* ignore */ } } stateManager = new StateManager(testFile, { lockRetries: 20, lockRetryInterval: 50, lockTimeout: 15000, }); }); afterEach(async () => { for (const f of [testFile, `${testFile}.lock`]) { try { await fs.unlink(f); } catch (e) { /* ignore */ } } }); afterAll(async () => { try { await fs.rm(testDir, { recursive: true }); } catch (e) { /* ignore */ } }); describe('Basic Operations', () => { test('creates file with empty array if not exists', async () => { const data = await stateManager.read(); expect(Array.isArray(data)).toBe(true); expect(data.length).toBe(0); }); test('write and read roundtrip', async () => { const testData = [ { id: '1', name: 'Test Service 1' }, { id: '2', name: 'Test Service 2' }, ]; await stateManager.write(testData); const data = await stateManager.read(); expect(data).toEqual(testData); }); test('update with callback function', async () => { await stateManager.write([{ id: '1', name: 'Service 1' }]); const updated = await stateManager.update(items => { items.push({ id: '2', name: 'Service 2' }); return items; }); expect(updated.length).toBe(2); expect(updated[1].name).toBe('Service 2'); }); }); describe('Convenience Methods', () => { test('addItem adds to array', async () => { await stateManager.addItem({ id: '1', name: 'Service 1' }); await stateManager.addItem({ id: '2', name: 'Service 2' }); const items = await stateManager.read(); expect(items.length).toBe(2); }); test('removeItem removes by ID', async () => { await stateManager.write([ { id: '1', name: 'Service 1' }, { id: '2', name: 'Service 2' }, { id: '3', name: 'Service 3' }, ]); await stateManager.removeItem('2'); const items = await stateManager.read(); expect(items.length).toBe(2); expect(items.find(i => i.id === '2')).toBeUndefined(); }); test('updateItem updates by ID', async () => { await stateManager.write([ { id: '1', name: 'Service 1', status: 'offline' }, ]); await stateManager.updateItem('1', { status: 'online' }); const item = await stateManager.findItem('1'); expect(item.status).toBe('online'); expect(item.name).toBe('Service 1'); // Unchanged }); test('findItem returns null for non-existent ID', async () => { await stateManager.write([{ id: '1', name: 'Service 1' }]); const item = await stateManager.findItem('999'); expect(item).toBeNull(); }); }); describe('Concurrent Access', () => { test('concurrent writes do not corrupt data', async () => { // Start with empty array await stateManager.write([]); // Simulate 10 concurrent writes const promises = []; for (let i = 0; i < 10; i++) { promises.push( stateManager.update(items => { items.push({ id: `service-${i}`, name: `Service ${i}` }); return items; }), ); } await Promise.all(promises); // Verify all items were added const items = await stateManager.read(); expect(items.length).toBe(10); // Verify JSON is valid (not corrupted) const fileContent = await fs.readFile(testFile, 'utf8'); expect(() => JSON.parse(fileContent)).not.toThrow(); }); test('concurrent reads while writing', async () => { await stateManager.write([{ id: '1', name: 'Initial' }]); const writePromise = stateManager.update(async items => { // Simulate slow operation await new Promise(resolve => setTimeout(resolve, 100)); items.push({ id: '2', name: 'New' }); return items; }); const readPromises = []; for (let i = 0; i < 5; i++) { readPromises.push(stateManager.read()); } await Promise.all([writePromise, ...readPromises]); // Should complete without errors const final = await stateManager.read(); expect(final.length).toBe(2); }); }); describe('Error Handling', () => { test('throws error on invalid JSON', async () => { // Write invalid JSON directly await fs.writeFile(testFile, '{invalid json', 'utf8'); await expect(stateManager.read()).rejects.toThrow(); }); test('handles missing file gracefully', async () => { await fs.unlink(testFile); const data = await stateManager.read(); expect(Array.isArray(data)).toBe(true); }); test('update callback errors are caught', async () => { await expect( stateManager.update(() => { throw new Error('Test error'); }), ).rejects.toThrow('Test error'); }); }); describe('Lock Management', () => { test('isLocked detects locked state', async () => { const lockfile = require('proper-lockfile'); // Manually lock the file const release = await lockfile.lock(testFile); const locked = await stateManager.isLocked(); expect(locked).toBe(true); await release(); const unlocked = await stateManager.isLocked(); expect(unlocked).toBe(false); }); test('forceUnlock removes stuck lock', async () => { const lockfile = require('proper-lockfile'); // Create a stuck lock await lockfile.lock(testFile); await stateManager.forceUnlock(); // Should be able to write now await expect(stateManager.write([])).resolves.not.toThrow(); }); }); describe('Performance', () => { test('handles large datasets efficiently', async () => { const largeDataset = []; for (let i = 0; i < 1000; i++) { largeDataset.push({ id: `service-${i}`, name: `Service ${i}`, url: `https://service-${i}.example.com`, status: 'online', }); } const startTime = Date.now(); await stateManager.write(largeDataset); const writeTime = Date.now() - startTime; const readStart = Date.now(); const data = await stateManager.read(); const readTime = Date.now() - readStart; expect(data.length).toBe(1000); expect(writeTime).toBeLessThan(1000); // Should write in <1s expect(readTime).toBeLessThan(100); // Should read in <100ms }); }); });