147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
getLockInfo,
|
|
acquireLock,
|
|
heartbeatLock,
|
|
releaseLock,
|
|
startHeartbeat,
|
|
} from './document-locks';
|
|
|
|
// ── Supabase mock builder ────────────────────────────────────────────────────
|
|
|
|
function mockChain(resolvedValue: { data: any; error: any }) {
|
|
const chain: any = {};
|
|
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gt', 'lt', 'single'];
|
|
for (const m of methods) {
|
|
chain[m] = vi.fn(() => chain);
|
|
}
|
|
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
|
|
chain.then = (resolve: any) => resolve(resolvedValue);
|
|
return chain;
|
|
}
|
|
|
|
function mockSupabase(resolvedValue: { data: any; error: any }) {
|
|
const chain = mockChain(resolvedValue);
|
|
return { from: vi.fn(() => chain), _chain: chain } as any;
|
|
}
|
|
|
|
// ── getLockInfo ──────────────────────────────────────────────────────────────
|
|
|
|
describe('getLockInfo', () => {
|
|
it('returns unlocked when no lock exists', async () => {
|
|
const sb = mockSupabase({ data: null, error: null });
|
|
const info = await getLockInfo(sb, 'doc1', 'user1');
|
|
expect(info).toEqual({
|
|
isLocked: false,
|
|
lockedBy: null,
|
|
lockedByName: null,
|
|
isOwnLock: false,
|
|
});
|
|
});
|
|
|
|
it('returns locked with own lock', async () => {
|
|
// First call returns lock, second returns profile
|
|
const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user1', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() };
|
|
const profileData = { full_name: 'Alice', email: 'alice@test.com' };
|
|
|
|
const chain: any = {};
|
|
const methods = ['from', 'select', 'eq', 'gt', 'single'];
|
|
for (const m of methods) {
|
|
chain[m] = vi.fn(() => chain);
|
|
}
|
|
let callCount = 0;
|
|
chain.single = vi.fn(() => {
|
|
callCount++;
|
|
if (callCount === 1) return Promise.resolve({ data: lockData, error: null });
|
|
return Promise.resolve({ data: profileData, error: null });
|
|
});
|
|
const sb = { from: vi.fn(() => chain) } as any;
|
|
|
|
const info = await getLockInfo(sb, 'doc1', 'user1');
|
|
expect(info.isLocked).toBe(true);
|
|
expect(info.isOwnLock).toBe(true);
|
|
expect(info.lockedByName).toBe('Alice');
|
|
});
|
|
|
|
it('returns locked with other user lock', async () => {
|
|
const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user2', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() };
|
|
const profileData = { full_name: 'Bob', email: 'bob@test.com' };
|
|
|
|
const chain: any = {};
|
|
const methods = ['from', 'select', 'eq', 'gt', 'single'];
|
|
for (const m of methods) {
|
|
chain[m] = vi.fn(() => chain);
|
|
}
|
|
let callCount = 0;
|
|
chain.single = vi.fn(() => {
|
|
callCount++;
|
|
if (callCount === 1) return Promise.resolve({ data: lockData, error: null });
|
|
return Promise.resolve({ data: profileData, error: null });
|
|
});
|
|
const sb = { from: vi.fn(() => chain) } as any;
|
|
|
|
const info = await getLockInfo(sb, 'doc1', 'user1');
|
|
expect(info.isLocked).toBe(true);
|
|
expect(info.isOwnLock).toBe(false);
|
|
expect(info.lockedByName).toBe('Bob');
|
|
});
|
|
});
|
|
|
|
// ── acquireLock ──────────────────────────────────────────────────────────────
|
|
|
|
describe('acquireLock', () => {
|
|
it('returns true when lock acquired', async () => {
|
|
const sb = mockSupabase({ data: null, error: null });
|
|
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(true);
|
|
});
|
|
|
|
it('returns false on unique constraint violation', async () => {
|
|
const sb = mockSupabase({ data: null, error: { code: '23505', message: 'unique' } });
|
|
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false);
|
|
});
|
|
|
|
it('returns false on other errors', async () => {
|
|
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'other' } });
|
|
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── heartbeatLock ────────────────────────────────────────────────────────────
|
|
|
|
describe('heartbeatLock', () => {
|
|
it('returns true on success', async () => {
|
|
const sb = mockSupabase({ data: null, error: null });
|
|
expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(true);
|
|
});
|
|
|
|
it('returns false on error', async () => {
|
|
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
|
|
expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── releaseLock ──────────────────────────────────────────────────────────────
|
|
|
|
describe('releaseLock', () => {
|
|
it('releases without error', async () => {
|
|
const sb = mockSupabase({ data: null, error: null });
|
|
await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('does not throw on error (just logs)', async () => {
|
|
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
|
|
await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── startHeartbeat ───────────────────────────────────────────────────────────
|
|
|
|
describe('startHeartbeat', () => {
|
|
it('returns a cleanup function', () => {
|
|
const sb = mockSupabase({ data: null, error: null });
|
|
const cleanup = startHeartbeat(sb, 'doc1', 'user1');
|
|
expect(typeof cleanup).toBe('function');
|
|
cleanup(); // should not throw
|
|
});
|
|
});
|