Files
dashcaddy/dashcaddy-api/__tests__/port-lock-manager.test.js
Sami ea5acfa9a2 test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core
infrastructure, API routes, and error handling. Final coverage: 86.73%
statements / 80.57% branches / 85.57% functions / 87.42% lines, all above
the 80% threshold enforced by jest.config.js.

Highlights:
- Unit tests for crypto-utils, credential-manager, auth-manager, csrf,
  input-validator, state-manager, health-checker, backup-manager,
  update-manager, resource-monitor, app-templates, platform-paths,
  port-lock-manager, errors, error-handler, pagination, url-resolver
- Route tests for health, services, and containers (supertest + mocked deps)
- Shared test-utils helper for mock factories and Express app builder
- npm scripts for CI: test:ci, test:unit, test:routes, test:security,
  test:changed, test:debug
- jest.config.js: expand coverage targets, add 80% threshold gate
- routes/services.js: import ValidationError and NotFoundError from errors
- .gitignore: exclude coverage/, *.bak, *.log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:36:46 -07:00

273 lines
9.5 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('../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);
});
});
});