// Port Lock Manager Tests // Validates atomic port allocation for concurrent Docker deployments jest.mock('proper-lockfile'); jest.mock('fs'); const fs = require('fs'); const lockfile = require('proper-lockfile'); // Setup defaults BEFORE requiring singleton (constructor calls ensureLockDirectory) fs.existsSync.mockReturnValue(true); fs.mkdirSync.mockReturnValue(undefined); fs.writeFileSync.mockReturnValue(undefined); fs.readdirSync.mockReturnValue([]); fs.unlinkSync.mockReturnValue(undefined); lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); lockfile.check.mockResolvedValue(false); const portLockManager = require('../port-lock-manager'); beforeEach(() => { jest.clearAllMocks(); portLockManager.activeLocks.clear(); // Restore defaults fs.existsSync.mockReturnValue(true); fs.mkdirSync.mockReturnValue(undefined); fs.writeFileSync.mockReturnValue(undefined); fs.readdirSync.mockReturnValue([]); fs.unlinkSync.mockReturnValue(undefined); lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); lockfile.check.mockResolvedValue(false); }); describe('PortLockManager — concurrent deploy safety', () => { describe('acquirePorts', () => { it('rejects empty array', async () => { await expect(portLockManager.acquirePorts([])).rejects.toThrow('non-empty array'); }); it('rejects non-array', async () => { await expect(portLockManager.acquirePorts('8080')).rejects.toThrow('non-empty array'); }); it('acquires lock for a single port', async () => { const mockRelease = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(mockRelease); const lockId = await portLockManager.acquirePorts(['8080']); expect(lockId).toMatch(/^lock-/); expect(lockfile.lock).toHaveBeenCalledTimes(1); }); it('acquires locks for multiple ports in sorted order (deadlock prevention)', async () => { const callOrder = []; lockfile.lock.mockImplementation((filePath) => { callOrder.push(filePath); return Promise.resolve(jest.fn().mockResolvedValue()); }); await portLockManager.acquirePorts(['9090', '3001', '8080']); // Ports sorted numerically: 3001, 8080, 9090 expect(callOrder[0]).toContain('port-3001.lock'); expect(callOrder[1]).toContain('port-8080.lock'); expect(callOrder[2]).toContain('port-9090.lock'); }); it('deduplicates ports', async () => { await portLockManager.acquirePorts(['8080', '8080', '8080']); expect(lockfile.lock).toHaveBeenCalledTimes(1); }); it('creates lock file for new ports', async () => { fs.existsSync.mockReturnValue(false); await portLockManager.acquirePorts(['7878']); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.stringContaining('port-7878.lock'), expect.stringContaining('"port"') ); }); it('stores lock in activeLocks map', async () => { const lockId = await portLockManager.acquirePorts(['8080']); const status = portLockManager.getStatus(); expect(status.activeLocks).toBe(1); expect(status.locks[0].lockId).toBe(lockId); expect(status.locks[0].ports).toEqual(['8080']); }); it('rolls back on partial failure — releases acquired locks', async () => { const released = []; let callCount = 0; lockfile.lock.mockImplementation(() => { callCount++; if (callCount === 2) return Promise.reject(new Error('Port in use')); const release = jest.fn().mockImplementation(() => { released.push(callCount); return Promise.resolve(); }); return Promise.resolve(release); }); await expect(portLockManager.acquirePorts(['3001', '8080'])) .rejects.toThrow('Failed to acquire port locks'); // First lock should have been released during rollback expect(released.length).toBe(1); }); }); describe('releasePorts', () => { it('releases all locks for a lock ID', async () => { const mockRelease = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValue(mockRelease); const lockId = await portLockManager.acquirePorts(['8080', '9090']); await portLockManager.releasePorts(lockId); expect(mockRelease).toHaveBeenCalledTimes(2); expect(portLockManager.getStatus().activeLocks).toBe(0); }); it('handles already-released lock ID gracefully', async () => { // Should not throw await portLockManager.releasePorts('nonexistent-lock-id'); }); it('continues releasing remaining locks if one fails', async () => { const releases = [ jest.fn().mockRejectedValue(new Error('release error')), jest.fn().mockResolvedValue(), ]; let callIdx = 0; lockfile.lock.mockImplementation(() => { return Promise.resolve(releases[callIdx++]); }); const lockId = await portLockManager.acquirePorts(['3001', '8080']); await portLockManager.releasePorts(lockId); // Both should have been called despite first failure expect(releases[0]).toHaveBeenCalled(); expect(releases[1]).toHaveBeenCalled(); expect(portLockManager.getStatus().activeLocks).toBe(0); }); }); describe('isPortLocked', () => { it('returns false when lock file does not exist', async () => { fs.existsSync.mockReturnValue(false); const result = await portLockManager.isPortLocked('8080'); expect(result).toBe(false); }); it('returns true when port is actively locked', async () => { fs.existsSync.mockReturnValue(true); lockfile.check.mockResolvedValue(true); const result = await portLockManager.isPortLocked('8080'); expect(result).toBe(true); }); it('returns false when port lock is stale', async () => { fs.existsSync.mockReturnValue(true); lockfile.check.mockResolvedValue(false); const result = await portLockManager.isPortLocked('8080'); expect(result).toBe(false); }); it('returns false on check error (fail-open for deployments)', async () => { fs.existsSync.mockReturnValue(true); lockfile.check.mockRejectedValue(new Error('check error')); const result = await portLockManager.isPortLocked('8080'); expect(result).toBe(false); }); }); describe('getStatus', () => { it('returns empty state when no locks active', () => { const status = portLockManager.getStatus(); expect(status.activeLocks).toBe(0); expect(status.locks).toEqual([]); expect(status.lockDirectory).toContain('.port-locks'); }); it('includes age and timestamp for active locks', async () => { await portLockManager.acquirePorts(['8080']); const status = portLockManager.getStatus(); expect(status.activeLocks).toBe(1); expect(status.locks[0].age).toBeGreaterThanOrEqual(0); expect(status.locks[0].timestamp).toBeDefined(); }); }); describe('cleanupStaleLocks', () => { it('removes stale lock files (not actively locked)', async () => { fs.readdirSync.mockReturnValue(['port-8080.lock', 'port-9090.lock']); lockfile.check.mockResolvedValue(false); // not locked = stale await portLockManager.cleanupStaleLocks(); expect(fs.unlinkSync).toHaveBeenCalledTimes(2); }); it('skips actively locked files', async () => { fs.readdirSync.mockReturnValue(['port-8080.lock']); lockfile.check.mockResolvedValue(true); // actively locked await portLockManager.cleanupStaleLocks(); expect(fs.unlinkSync).not.toHaveBeenCalled(); }); it('skips non-.lock files', async () => { fs.readdirSync.mockReturnValue(['readme.txt', 'port-8080.lock']); lockfile.check.mockResolvedValue(false); await portLockManager.cleanupStaleLocks(); expect(fs.unlinkSync).toHaveBeenCalledTimes(1); }); it('handles ENOENT errors gracefully', async () => { fs.readdirSync.mockReturnValue(['port-8080.lock']); const enoent = new Error('ENOENT'); enoent.code = 'ENOENT'; lockfile.check.mockRejectedValue(enoent); // Should not throw await portLockManager.cleanupStaleLocks(); expect(fs.unlinkSync).not.toHaveBeenCalled(); }); }); describe('DashCaddy deployment scenarios', () => { it('Radarr deploy: locks host port 7878', async () => { await portLockManager.acquirePorts(['7878']); expect(lockfile.lock).toHaveBeenCalledWith( expect.stringContaining('port-7878.lock'), expect.any(Object) ); }); it('Plex deploy: locks multiple ports (32400, 1900, 8324, 32469)', async () => { const plexPorts = ['32400', '1900', '8324', '32469']; await portLockManager.acquirePorts(plexPorts); expect(lockfile.lock).toHaveBeenCalledTimes(4); }); it('concurrent deploys: second deploy gets separate lock ID', async () => { const release1 = jest.fn().mockResolvedValue(); const release2 = jest.fn().mockResolvedValue(); lockfile.lock.mockResolvedValueOnce(release1).mockResolvedValueOnce(release2); const lockId1 = await portLockManager.acquirePorts(['8080']); const lockId2 = await portLockManager.acquirePorts(['9090']); expect(lockId1).not.toBe(lockId2); expect(portLockManager.getStatus().activeLocks).toBe(2); }); it('deploy cleanup: release after container start', async () => { const lockId = await portLockManager.acquirePorts(['7878']); expect(portLockManager.getStatus().activeLocks).toBe(1); // Simulate container started successfully await portLockManager.releasePorts(lockId); expect(portLockManager.getStatus().activeLocks).toBe(0); }); }); });