Files
pi-file-claiming/tests/lock-manager.test.ts
2026-06-19 12:46:02 -04:00

811 lines
24 KiB
TypeScript

/**
* 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<typeof data>(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<unknown>(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<string, any> = {
"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<string, any[]> = {
[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<string, any> = {
["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<string, any> = {
["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();