811 lines
24 KiB
TypeScript
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();
|