import { describe, it, expect, vi, beforeEach } from 'vitest'; import RedisMock from 'ioredis-mock'; const mocks = vi.hoisted(() => ({ redis: null as any })); vi.mock('../redis.js', () => ({ isRedisEnabled: () => true, getRedis: () => mocks.redis, })); import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js'; describe('MemoryLock', () => { it('grants the lock, blocks contenders, and frees on release', async () => { const lock = new MemoryLock(); const token = await lock.acquire('a', 60_000); expect(token).toBeTruthy(); expect(await lock.acquire('a', 60_000)).toBeNull(); await lock.release('a', token!); expect(await lock.acquire('a', 60_000)).toBeTruthy(); }); it('ignores release with the wrong token', async () => { const lock = new MemoryLock(); const token = await lock.acquire('a', 60_000); await lock.release('a', 'not-the-token'); expect(await lock.acquire('a', 60_000)).toBeNull(); await lock.release('a', token!); }); it('withLock runs fn while held and returns null under contention', async () => { const lock = new MemoryLock(); const held = await lock.acquire('a', 60_000); expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull(); await lock.release('a', held!); expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran'); // Released after withLock completes. expect(await lock.acquire('a', 60_000)).toBeTruthy(); }); }); describe('RedisLock', () => { beforeEach(async () => { mocks.redis = new RedisMock(); // ioredis-mock shares data between instances by connection string. await mocks.redis.flushall(); }); it('grants the lock and blocks contenders', async () => { const lock = new RedisLock(); const token = await lock.acquire('a', 60_000); expect(token).toBeTruthy(); expect(await lock.acquire('a', 60_000)).toBeNull(); }); it('release is compare-and-delete: wrong token does not free the lock', async () => { const lock = new RedisLock(); const token = await lock.acquire('a', 60_000); await lock.release('a', 'not-the-token'); expect(await lock.acquire('a', 60_000)).toBeNull(); await lock.release('a', token!); expect(await lock.acquire('a', 60_000)).toBeTruthy(); }); it('throws LockUnavailableError when the client is not initialized', async () => { mocks.redis = null; const lock = new RedisLock(); await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError); }); it('throws LockUnavailableError when the backend errors', async () => { mocks.redis = { set: () => Promise.reject(new Error('connection refused')) }; const lock = new RedisLock(); await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError); }); it('withLock skips (returns null, fn not called) when unavailable by default', async () => { mocks.redis = null; const lock = new RedisLock(); const fn = vi.fn(async () => 'ran'); expect(await lock.withLock('a', 60_000, fn)).toBeNull(); expect(fn).not.toHaveBeenCalled(); }); it('withLock runs fn without the lock when onUnavailable is "run"', async () => { mocks.redis = null; const lock = new RedisLock(); const fn = vi.fn(async () => 'ran'); expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran'); expect(fn).toHaveBeenCalledOnce(); }); it('withLock returns fn result and releases the lock afterwards', async () => { const lock = new RedisLock(); expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42); expect(await lock.acquire('a', 60_000)).toBeTruthy(); }); });