// tests/kb-update/test-lock-file.test.mjs // Unit tests for scripts/kb-update/lib/lock-file.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, utimesSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { acquireLock, isPidAlive, } from '../../scripts/kb-update/lib/lock-file.mjs'; const DEAD_PID = 99999999; // far above typical PID_MAX; reliably non-existent function withTmp(fn) { const dir = mkdtempSync(join(tmpdir(), 'lf-test-')); try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } function writeFakeLock(path, { pid, started, host = 'test-host', ageMs = 0 }) { writeFileSync( path, JSON.stringify({ pid, started: started ?? Date.now() - ageMs, host, version: 1, }), 'utf8' ); if (ageMs > 0) { const past = new Date(Date.now() - ageMs); utimesSync(path, past, past); } } test('isPidAlive — current process is alive', () => { assert.equal(isPidAlive(process.pid), true); }); test('isPidAlive — non-existent PID is dead', () => { assert.equal(isPidAlive(DEAD_PID), false); }); test('isPidAlive — invalid input is dead', () => { assert.equal(isPidAlive(0), false); assert.equal(isPidAlive(-1), false); assert.equal(isPidAlive(NaN), false); assert.equal(isPidAlive(undefined), false); }); test('acquireLock — creates lock file with current PID metadata', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); const lock = acquireLock(path, { registerCleanup: false }); try { assert.equal(lock.lockPath, path); assert.equal(existsSync(path), true); const data = JSON.parse(readFileSync(path, 'utf8')); assert.equal(data.pid, process.pid); assert.equal(data.version, 1); assert.equal(typeof data.started, 'number'); assert.equal(typeof data.host, 'string'); } finally { lock.release(); } }); }); test('acquireLock — second call same process throws ELOCKED', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); const lock = acquireLock(path, { registerCleanup: false }); try { assert.throws( () => acquireLock(path, { registerCleanup: false }), (err) => err.code === 'ELOCKED' && err.holderPid === process.pid ); } finally { lock.release(); } }); }); test('acquireLock — concurrent live holder (fixture lock-fil) throws ELOCKED', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); // Pre-write a lock as if held by another live process (we use process.pid // as a stand-in for "guaranteed alive" without forking). writeFakeLock(path, { pid: process.pid, ageMs: 0 }); assert.throws( () => acquireLock(path, { registerCleanup: false }), (err) => err.code === 'ELOCKED' ); }); }); test('acquireLock — release deletes the lock file', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); const lock = acquireLock(path, { registerCleanup: false }); assert.equal(existsSync(path), true); lock.release(); assert.equal(existsSync(path), false); }); }); test('acquireLock — release on already-released lock is a no-op', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); const lock = acquireLock(path, { registerCleanup: false }); lock.release(); // Second release must not throw. lock.release(); assert.equal(existsSync(path), false); }); }); test('acquireLock — stale lock with dead PID + old mtime is cleaned', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); writeFakeLock(path, { pid: DEAD_PID, ageMs: 2 * 60 * 60 * 1000 }); const lock = acquireLock(path, { registerCleanup: false }); try { const data = JSON.parse(readFileSync(path, 'utf8')); assert.equal(data.pid, process.pid); } finally { lock.release(); } }); }); test('acquireLock — stale lock with live PID but old mtime is also cleaned', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); // Live PID (us) but mtime older than default 1h threshold. writeFakeLock(path, { pid: process.pid, ageMs: 2 * 60 * 60 * 1000 }); const lock = acquireLock(path, { registerCleanup: false }); try { const data = JSON.parse(readFileSync(path, 'utf8')); assert.equal(data.pid, process.pid); // started is rewritten to fresh wallclock assert.ok(Date.now() - data.started < 5000); } finally { lock.release(); } }); }); test('acquireLock — fresh lock with live PID is NOT cleaned', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); writeFakeLock(path, { pid: process.pid, ageMs: 0 }); assert.throws( () => acquireLock(path, { registerCleanup: false }), (err) => err.code === 'ELOCKED' && err.holderPid === process.pid ); }); }); test('acquireLock — staleThresholdMs is honored', () => { withTmp((dir) => { const path = join(dir, 'test.lock'); // 5s-old, live PID. Default 1h threshold → not stale → ELOCKED. writeFakeLock(path, { pid: process.pid, ageMs: 5_000 }); assert.throws( () => acquireLock(path, { registerCleanup: false }), (err) => err.code === 'ELOCKED' ); // Same fixture but threshold 1s → stale → cleaned. writeFakeLock(path, { pid: process.pid, ageMs: 5_000 }); const lock = acquireLock(path, { registerCleanup: false, staleThresholdMs: 1_000, }); lock.release(); assert.equal(existsSync(path), false); }); });