jest.mock('proper-lockfile'); jest.mock('fs', () => ({ existsSync: jest.fn().mockReturnValue(true), mkdirSync: jest.fn(), writeFileSync: jest.fn(), promises: { readFile: jest.fn().mockResolvedValue('[]'), writeFile: jest.fn().mockResolvedValue(), }, })); const lockfile = require('proper-lockfile'); const fs = require('fs'); const StateManager = require('../state-manager'); describe('StateManager', () => { let sm; const TEST_PATH = '/tmp/test-state.json'; beforeEach(() => { jest.clearAllMocks(); fs.existsSync.mockReturnValue(true); fs.promises.readFile.mockResolvedValue('[]'); fs.promises.writeFile.mockResolvedValue(); lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); lockfile.check.mockResolvedValue(false); lockfile.unlock.mockResolvedValue(); sm = new StateManager(TEST_PATH); }); describe('constructor', () => { it('creates file with [] if it does not exist', () => { fs.existsSync.mockReturnValue(false); new StateManager('/tmp/new-state.json'); expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/new-state.json', '[]', 'utf8'); }); it('creates directory recursively if needed', () => { fs.existsSync.mockReturnValue(false); new StateManager('/tmp/deep/nested/state.json'); expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); }); it('does not create file if it exists', () => { fs.existsSync.mockReturnValue(true); fs.writeFileSync.mockClear(); new StateManager(TEST_PATH); expect(fs.writeFileSync).not.toHaveBeenCalled(); }); }); describe('read', () => { it('returns parsed JSON from file', async () => { fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'svc1' }])); const data = await sm.read(); expect(data).toEqual([{ id: 'svc1' }]); }); it('returns [] and recreates file on ENOENT', async () => { const err = new Error('ENOENT'); err.code = 'ENOENT'; fs.promises.readFile.mockRejectedValue(err); fs.existsSync.mockReturnValue(false); const data = await sm.read(); expect(data).toEqual([]); }); it('throws on invalid JSON', async () => { fs.promises.readFile.mockResolvedValue('{bad json}'); await expect(sm.read()).rejects.toThrow('Failed to read state file'); }); }); describe('write', () => { it('acquires lock, writes JSON, releases lock', async () => { const releaseFn = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(releaseFn); await sm.write([{ id: 'new' }]); expect(lockfile.lock).toHaveBeenCalledWith(TEST_PATH, expect.any(Object)); expect(fs.promises.writeFile).toHaveBeenCalledWith( TEST_PATH, JSON.stringify([{ id: 'new' }], null, 2), 'utf8' ); expect(releaseFn).toHaveBeenCalled(); }); it('throws on ELOCKED', async () => { const err = new Error('locked'); err.code = 'ELOCKED'; lockfile.lock.mockRejectedValue(err); await expect(sm.write([])).rejects.toThrow('locked by another process'); }); it('releases lock even on write error', async () => { const releaseFn = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(releaseFn); fs.promises.writeFile.mockRejectedValue(new Error('disk full')); await expect(sm.write([])).rejects.toThrow(); expect(releaseFn).toHaveBeenCalled(); }); }); describe('update', () => { it('atomic read-modify-write cycle', async () => { const releaseFn = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(releaseFn); fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }])); const result = await sm.update(items => { items.push({ id: '2' }); return items; }); expect(result).toEqual([{ id: '1' }, { id: '2' }]); expect(fs.promises.writeFile).toHaveBeenCalled(); expect(releaseFn).toHaveBeenCalled(); }); it('passes current data to updateFn', async () => { const releaseFn = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(releaseFn); fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'existing' }])); const updateFn = jest.fn(data => data); await sm.update(updateFn); expect(updateFn).toHaveBeenCalledWith([{ id: 'existing' }]); }); it('throws on ELOCKED', async () => { const err = new Error('locked'); err.code = 'ELOCKED'; lockfile.lock.mockRejectedValue(err); await expect(sm.update(d => d)).rejects.toThrow('locked by another process'); }); }); describe('convenience methods', () => { beforeEach(() => { const releaseFn = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(releaseFn); }); it('addItem appends to array', async () => { fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }])); const result = await sm.addItem({ id: '2', name: 'New' }); expect(result).toEqual([{ id: '1' }, { id: '2', name: 'New' }]); }); it('removeItem filters by id', async () => { fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }, { id: '2' }])); const result = await sm.removeItem('1'); expect(result).toEqual([{ id: '2' }]); }); it('updateItem merges updates for matching id', async () => { fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Old' }])); const result = await sm.updateItem('1', { name: 'New', port: 8080 }); expect(result).toEqual([{ id: '1', name: 'New', port: 8080 }]); }); it('findItem returns matching item or null', async () => { fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Found' }])); const found = await sm.findItem('1'); expect(found).toEqual({ id: '1', name: 'Found' }); const missing = await sm.findItem('999'); expect(missing).toBeNull(); }); }); describe('isLocked', () => { it('returns lockfile.check result', async () => { lockfile.check.mockResolvedValue(true); expect(await sm.isLocked()).toBe(true); lockfile.check.mockResolvedValue(false); expect(await sm.isLocked()).toBe(false); }); it('returns false on error', async () => { lockfile.check.mockRejectedValue(new Error('fail')); expect(await sm.isLocked()).toBe(false); }); }); describe('forceUnlock', () => { it('calls lockfile.unlock', async () => { await sm.forceUnlock(); expect(lockfile.unlock).toHaveBeenCalledWith(TEST_PATH); }); it('ignores ENOTACQUIRED error', async () => { const err = new Error('not locked'); err.code = 'ENOTACQUIRED'; lockfile.unlock.mockRejectedValue(err); await expect(sm.forceUnlock()).resolves.toBeUndefined(); }); it('throws other errors', async () => { lockfile.unlock.mockRejectedValue(new Error('other')); await expect(sm.forceUnlock()).rejects.toThrow('other'); }); }); });