/** * lock-manager.test.ts — Unit tests for the LockManager core class. * * Tests cover: * - Construction and initialization * - Atomic file operations (write-to-temp-then-rename) * - Cross-process coordination (O_EXCL locks, stale detection) * - Registry CRUD: save, load, delete claims and lock entries * - TTL-based expiration (isExpired, findExpired, cleanupExpired) * - Age-based sweep (sweepOlderThan) * - Disk sync and merge operations * - Statistics and lifecycle * * @module file-claiming/lock-manager.test */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { assert, assertRejects, createTempDir, cleanupTempDir, TEST_SESSION_ID, TEST_FILE_A, TEST_FILE_B, } from "./test-utils.ts"; // --------------------------------------------------------------------------- // Lazy-import the module under test // --------------------------------------------------------------------------- function getLockManager() { return require("../src/lock-manager"); } function getTypes() { return require("../src/lock-types"); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- let tempDir: string; function setup(): string { tempDir = createTempDir("lock-manager-test-"); return tempDir; } function teardown(): void { if (tempDir) { cleanupTempDir(tempDir); } } // --------------------------------------------------------------------------- // Test: Construction and initialization // --------------------------------------------------------------------------- async function testConstruction() { const dir = setup(); const { LockManager } = getLockManager(); const mgr = new LockManager(dir); assert( mgr.getLockDir() === dir, `Lock directory matches: ${mgr.getLockDir()} === ${dir}`, ); assert( mgr.getRegistryFilePath().endsWith("registry.json"), "Registry file path ends with registry.json", ); assert( mgr.getCoordLockFilePath().endsWith("coord.lock"), "Coord lock file path ends with coord.lock", ); // The directory already exists (createTempDir created it) assert(existsSync(dir), "Temp directory exists before init"); await mgr.init(); assert(existsSync(dir), "Directory still exists after init"); // init is idempotent await mgr.init(); assert(existsSync(dir), "Directory still exists after second init"); await mgr.destroy(); teardown(); console.log("✅ LockManager: construction and initialization works"); } // --------------------------------------------------------------------------- // Test: Atomic file operations // --------------------------------------------------------------------------- async function testAtomicFileOperations() { const dir = setup(); const { atomicWriteJson, atomicReadJson, fileExists } = getLockManager(); const testFile = join(dir, "test.json"); // Write and read const data = { key: "value", number: 42 }; await atomicWriteJson(testFile, data); assert(existsSync(testFile), "File exists after write"); assert( existsSync(testFile + ".tmp") === false, "Temp file was cleaned up after rename", ); const loaded = await atomicReadJson(testFile); assert(loaded !== null, "File content is not null"); assert(loaded!.key === "value", "Content matches: key"); assert(loaded!.number === 42, "Content matches: number"); // Read non-existent file const missing = await atomicReadJson(join(dir, "missing.json")); assert(missing === null, "Missing file returns null"); // fileExists assert(fileExists(testFile) === true, "fileExists returns true for existing file"); assert( fileExists(join(dir, "ghost.json")) === false, "fileExists returns false for non-existing file", ); teardown(); console.log("✅ LockManager: atomic file operations work"); } // --------------------------------------------------------------------------- // Test: Registry save/load // --------------------------------------------------------------------------- async function testRegistrySaveLoad() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); // Empty registry on first load const empty = await mgr.loadRegistry(); assert( Object.keys(empty.claims).length === 0, "Empty registry has no claims", ); assert( Object.keys(empty.locks).length === 0, "Empty registry has no locks", ); // Save and load with claims and locks const claims: Record = { "claim-1": { id: "claim-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "test"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, }; const locks: Record = { [TEST_FILE_A]: [ { path: TEST_FILE_A, lockType: "write", claimId: "claim-1", owner: mockOwner("agent", "test"), acquiredAt: new Date().toISOString(), }, ], }; await mgr.saveRegistry(claims, locks); const loaded = await mgr.loadRegistry(); assert( loaded.claims["claim-1"] !== undefined, "Saved claim is loadable", ); assert( loaded.claims["claim-1"].path === TEST_FILE_A, "Loaded claim path matches", ); assert( loaded.locks[TEST_FILE_A].length === 1, "Loaded lock entries match", ); await mgr.destroy(); teardown(); console.log("✅ LockManager: registry save/load works"); } // --------------------------------------------------------------------------- // Test: Individual claim CRUD // --------------------------------------------------------------------------- async function testClaimCRUD() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); const claim: any = { id: "crud-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "test"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 300_000).toISOString(), }; // Save await mgr.saveClaim(claim); let loaded = await mgr.loadClaim("crud-1"); assert(loaded !== undefined, "Claim loaded by ID"); assert(loaded!.id === "crud-1", "Loaded claim ID matches"); assert(loaded!.path === TEST_FILE_A, "Loaded claim path matches"); // Load all const all = await mgr.loadAllClaims(); assert(all.length === 1, "loadAllClaims returns 1 claim"); // Load by path const byPath = await mgr.loadClaimsByPath(TEST_FILE_A); assert(byPath.length === 1, "loadClaimsByPath returns 1 claim"); // Load by status const byStatus = await mgr.loadClaimsByStatus("active"); assert(byStatus.length === 1, "loadClaimsByStatus('active') returns 1 claim"); // Delete const deleted = await mgr.deleteClaim("crud-1"); assert(deleted === true, "deleteClaim returns true"); loaded = await mgr.loadClaim("crud-1"); assert(loaded === undefined, "Deleted claim is undefined"); // Delete non-existent const missing = await mgr.deleteClaim("nonexistent"); assert(missing === false, "deleteClaim on non-existent returns false"); await mgr.destroy(); teardown(); console.log("✅ LockManager: individual claim CRUD works"); } // --------------------------------------------------------------------------- // Test: Lock entry CRUD // --------------------------------------------------------------------------- async function testLockEntryCRUD() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); const entry: any = { path: TEST_FILE_A, lockType: "write", claimId: "entry-1", owner: mockOwner("agent", "test"), acquiredAt: new Date().toISOString(), }; // Save await mgr.saveLockEntry(entry); const loaded = await mgr.loadLockEntries(TEST_FILE_A); assert(loaded.length === 1, "lock entry saved and loaded"); assert(loaded[0].claimId === "entry-1", "Loaded entry claimId matches"); // Load all entries const all = await mgr.loadAllLockEntries(); assert(Object.keys(all).length === 1, "All lock entries loaded"); // Remove await mgr.removeLockEntry("entry-1", TEST_FILE_A); const afterRemove = await mgr.loadLockEntries(TEST_FILE_A); assert(afterRemove.length === 0, "Lock entry removed"); await mgr.destroy(); teardown(); console.log("✅ LockManager: lock entry CRUD works"); } // --------------------------------------------------------------------------- // Test: TTL-based expiration // --------------------------------------------------------------------------- async function testExpiration() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); const now = new Date().toISOString(); const future = new Date(Date.now() + 300_000).toISOString(); const past = new Date(Date.now() - 60_000).toISOString(); // Active claim with future expiry — not expired const activeClaim: any = { id: "active-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "test"), createdAt: now, updatedAt: now, expiresAt: future, }; // Active claim with past expiry — expired const expiredClaim: any = { id: "expired-1", path: TEST_FILE_B, lockType: "write", status: "active", owner: mockOwner("agent", "test"), createdAt: now, updatedAt: now, expiresAt: past, }; // Released claim (should not be expired) const releasedClaim: any = { id: "released-1", path: TEST_FILE_B, lockType: "read", status: "released", owner: mockOwner("agent", "test"), createdAt: now, updatedAt: now, expiresAt: past, }; await mgr.saveClaim(activeClaim); await mgr.saveClaim(expiredClaim); await mgr.saveClaim(releasedClaim); // isExpired assert(mgr.isExpired(activeClaim) === false, "Active claim is not expired"); assert(mgr.isExpired(expiredClaim) === true, "Expired claim is expired"); assert(mgr.isExpired(releasedClaim) === false, "Released claim is not expired"); // findExpired const expired = await mgr.findExpired(); assert(expired.length === 1, "findExpired finds 1 expired claim"); assert(expired[0].id === "expired-1", "findExpired found the right claim"); // cleanupExpired const result = await mgr.cleanupExpired(); assert(result.expiredCount === 1, "cleanupExpired reports 1 expired"); assert(result.expiredClaims[0].id === "expired-1", "Cleanup expired right claim"); // Verify status on disk const loaded = await mgr.loadClaim("expired-1"); assert(loaded!.status === "expired", "Expired claim status updated on disk"); await mgr.destroy(); teardown(); console.log("✅ LockManager: TTL-based expiration works"); } // --------------------------------------------------------------------------- // Test: Age-based sweep // --------------------------------------------------------------------------- async function testAgeBasedSweep() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); const recentTime = new Date().toISOString(); // Old claim (no expiresAt — relies on age-based sweep) const oldClaim: any = { id: "old-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "old-agent"), createdAt: oldTime, updatedAt: oldTime, }; // Recent claim const recentClaim: any = { id: "recent-1", path: TEST_FILE_B, lockType: "read", status: "active", owner: mockOwner("agent", "recent-agent"), createdAt: recentTime, updatedAt: recentTime, }; await mgr.saveClaim(oldClaim); await mgr.saveClaim(recentClaim); // Sweep older than 1 hour const swept = await mgr.sweepOlderThan(3_600_000); assert(swept === 1, "sweepOlderThan removes 1 old claim"); // Verify old claim removed const loaded = await mgr.loadClaim("old-1"); assert(loaded === undefined, "Old claim removed from disk"); // Recent claim still present const recent = await mgr.loadClaim("recent-1"); assert(recent !== undefined, "Recent claim still present"); await mgr.destroy(); teardown(); console.log("✅ LockManager: age-based sweep works"); } // --------------------------------------------------------------------------- // Test: Cross-process coordination (coordination lock) // --------------------------------------------------------------------------- async function testCoordLock() { const dir = setup(); const { LockManager } = getLockManager(); const mgr1 = new LockManager(dir); const mgr2 = new LockManager(dir); await mgr1.init(); await mgr2.init(); // Acquire lock on mgr1 const acquired1 = await mgr1.acquireCoordLock("test-1"); assert(acquired1 === true, "mgr1 acquires coordination lock"); // mgr2 should not be able to acquire (short timeout) const acquired2 = await mgr2.acquireCoordLock("test-2", 100); assert(acquired2 === false, "mgr2 cannot acquire while mgr1 holds it"); // Release on mgr1 await mgr1.releaseCoordLock(); // Now mgr2 can acquire const acquired3 = await mgr2.acquireCoordLock("test-2", 1000); assert(acquired3 === true, "mgr2 acquires after mgr1 releases"); await mgr2.releaseCoordLock(); await mgr1.destroy(); await mgr2.destroy(); teardown(); console.log("✅ LockManager: cross-process coordination works"); } // --------------------------------------------------------------------------- // Test: withCoordLock convenience // --------------------------------------------------------------------------- async function testWithCoordLock() { const dir = setup(); const { LockManager } = getLockManager(); const mgr = new LockManager(dir); await mgr.init(); const result = await mgr.withCoordLock("test", async () => { return "hello from locked section"; }); assert(result === "hello from locked section", "withCoordLock returns function result"); // Verify lock is released after execution const acquired = await mgr.acquireCoordLock("test-2", 100); assert(acquired === true, "Lock released after withCoordLock"); await mgr.releaseCoordLock(); await mgr.destroy(); teardown(); console.log("✅ LockManager: withCoordLock convenience works"); } // --------------------------------------------------------------------------- // Test: Sync from/to disk // --------------------------------------------------------------------------- async function testSync() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); // syncFromDisk on empty registry const syncResult = await mgr.syncFromDisk(); assert( syncResult.diskClaims.length === 0, "syncFromDisk returns empty on fresh registry", ); assert(syncResult.wasUpdated === false, "wasUpdated is false on empty"); // Save a claim const claim: any = { id: "sync-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "test"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await mgr.saveClaim(claim); // syncFromDisk now has data const syncResult2 = await mgr.syncFromDisk(); assert(syncResult2.diskClaims.length === 1, "syncFromDisk finds 1 claim"); assert(syncResult2.wasUpdated === true, "wasUpdated is true"); // syncToDisk const newClaims: Record = { ["sync-2"]: { id: "sync-2", path: TEST_FILE_B, lockType: "read", status: "active", owner: mockOwner("agent", "test"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, }; await mgr.syncToDisk(newClaims, {}); const loaded = await mgr.loadClaim("sync-2"); assert(loaded !== undefined, "syncToDisk persisted claim"); await mgr.destroy(); teardown(); console.log("✅ LockManager: sync from/to disk works"); } // --------------------------------------------------------------------------- // Test: Merge from disk // --------------------------------------------------------------------------- async function testMerge() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); // Save a disk claim const diskClaim: any = { id: "disk-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "disk"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await mgr.saveClaim(diskClaim); // Merge with memory claims (memory wins on collision) const memoryClaims: Record = { ["memory-1"]: { id: "memory-1", path: TEST_FILE_B, lockType: "read", status: "active", owner: mockOwner("agent", "memory"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, }; const merged = await mgr.mergeFromDisk(memoryClaims); assert(merged["disk-1"] !== undefined, "Disk claim present in merge"); assert(merged["memory-1"] !== undefined, "Memory claim present in merge"); assert(Object.keys(merged).length === 2, "Merge contains both claims"); await mgr.destroy(); teardown(); console.log("✅ LockManager: merge from disk works"); } // --------------------------------------------------------------------------- // Test: Statistics // --------------------------------------------------------------------------- async function testStats() { const dir = setup(); const { LockManager } = getLockManager(); const { mockOwner } = await import("./test-utils.ts"); const mgr = new LockManager(dir); await mgr.init(); const statsBefore = await mgr.getStats(); assert(statsBefore.totalClaims === 0, "Stats: no claims initially"); assert(statsBefore.registryExists === false, "Stats: registry doesn't exist yet"); // Add claims const claim: any = { id: "stats-1", path: TEST_FILE_A, lockType: "write", status: "active", owner: mockOwner("agent", "test"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await mgr.saveClaim(claim); const statsAfter = await mgr.getStats(); assert(statsAfter.totalClaims === 1, "Stats: 1 claim"); assert(statsAfter.activeClaims === 1, "Stats: 1 active"); assert(statsAfter.registryExists === true, "Stats: registry exists"); await mgr.destroy(); teardown(); console.log("✅ LockManager: statistics work"); } // --------------------------------------------------------------------------- // Test: Lifecycle (destroy) // --------------------------------------------------------------------------- async function testLifecycle() { const dir = setup(); const { LockManager } = getLockManager(); const mgr = new LockManager(dir); await mgr.init(); // Acquire coord lock await mgr.acquireCoordLock("lifecycle-test"); assert(mgr["hasCoordLock"] === true, "Coord lock held"); // Destroy releases it await mgr.destroy(); assert(mgr["hasCoordLock"] === false, "Coord lock released after destroy"); console.log("✅ LockManager: lifecycle (destroy) works"); } // --------------------------------------------------------------------------- // Test: File locking via LockManager // --------------------------------------------------------------------------- async function testAcquireLockFile() { const dir = setup(); const { acquireLockFile, releaseLockFile } = getLockManager(); const lockFile = join(dir, "coord.lock"); // Acquire const acquired = await acquireLockFile(lockFile, "test-owner"); assert(acquired === true, "acquireLockFile returns true"); assert(existsSync(lockFile), "Lock file exists after acquisition"); // Release await releaseLockFile(lockFile); assert(existsSync(lockFile) === false, "Lock file removed after release"); // Acquire again const reacquired = await acquireLockFile(lockFile, "test-owner", 100); assert(reacquired === true, "Re-acquire succeeds after release"); await releaseLockFile(lockFile); teardown(); console.log("✅ LockManager: acquire/release lock file works"); } // --------------------------------------------------------------------------- // Test: withLockFile convenience // --------------------------------------------------------------------------- async function testWithLockFile() { const dir = setup(); const { withLockFile } = getLockManager(); const lockFile = join(dir, "coord.lock"); const result = await withLockFile(lockFile, "test", async () => { return 42; }); assert(result === 42, "withLockFile returns function result"); // Lock file should be cleaned up const exists = await import("node:fs").then((fs) => fs.existsSync(lockFile)); assert(exists === false, "Lock file cleaned up after withLockFile"); teardown(); console.log("✅ LockManager: withLockFile convenience works"); } // --------------------------------------------------------------------------- // Test: createLockManager factory // --------------------------------------------------------------------------- async function testCreateLockManager() { const dir = setup(); // We need to set config to use our dir const config = require("../src/config"); config.setConfig({ lockDir: dir }); config.getConfig(); // force read const { createLockManager } = getLockManager(); // Override by passing explicit dir const mgr = await createLockManager(dir); assert(mgr.getLockDir() === dir, "Lock manager created with correct dir"); await mgr.init(); // should be idempotent assert(mgr.getLockDir() === dir, "Lock manager retains dir after init"); await mgr.destroy(); config.resetConfig(); teardown(); console.log("✅ LockManager: createLockManager factory works"); } // --------------------------------------------------------------------------- // Test: Lock timeout // --------------------------------------------------------------------------- async function testLockTimeout() { const dir = setup(); const { acquireLockFile, releaseLockFile } = getLockManager(); const lockFile = join(dir, "coord.lock"); // Hold lock from first owner const acquired = await acquireLockFile(lockFile, "owner-1"); assert(acquired === true, "First owner acquires lock"); // Second owner with very short timeout should fail const failed = await acquireLockFile(lockFile, "owner-2", 50); assert(failed === false, "Second owner times out"); // Release await releaseLockFile(lockFile); // Second owner should succeed now const success = await acquireLockFile(lockFile, "owner-2", 100); assert(success === true, "Second owner acquires after release"); await releaseLockFile(lockFile); teardown(); console.log("✅ LockManager: lock timeout works"); } // --------------------------------------------------------------------------- // Test runner // --------------------------------------------------------------------------- async function runTests() { console.log("Running LockManager Unit Tests\n"); const tests = [ testConstruction, testAtomicFileOperations, testRegistrySaveLoad, testClaimCRUD, testLockEntryCRUD, testExpiration, testAgeBasedSweep, testCoordLock, testWithCoordLock, testSync, testMerge, testStats, testLifecycle, testAcquireLockFile, testWithLockFile, testCreateLockManager, testLockTimeout, ]; try { for (const test of tests) { try { await test(); } catch (err) { console.error(`\n❌ Test ${test.name} failed: ${err}`); throw err; } } console.log("\n✅ All LockManager unit tests passed!"); } catch (err) { console.error(`\n❌ Test suite failed: ${err}`); process.exit(1); } } runTests();