After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
273 lines
9.6 KiB
JavaScript
273 lines
9.6 KiB
JavaScript
// 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('../src/managers/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);
|
|
});
|
|
});
|
|
});
|