Files
connectai/tests/approvalQueue.test.ts
T

165 lines
5.8 KiB
TypeScript

/**
* Unit tests for ApprovalQueue.
*
* Strategy: drive enqueue → approve / reject / clear / pre-empt directly,
* confirm the onChange event fires at the right moments and callbacks fire
* exactly once.
*/
import { ApprovalQueue, Approval } from '../src/features/approval/approvalQueue';
function makeApproval(id: string = 'txn-1'): Approval {
return {
id,
kind: 'transaction',
title: 'Pending file changes',
summary: '2 files',
files: ['/tmp/a.ts', '/tmp/b.ts'],
createdAt: Date.now(),
};
}
describe('ApprovalQueue', () => {
test('starts empty', () => {
const q = new ApprovalQueue();
expect(q.current()).toBeNull();
expect(q.pendingCount()).toBe(0);
});
test('enqueue sets current and fires onChange', () => {
const q = new ApprovalQueue();
let fired = 0;
q.onChange(() => fired++);
q.enqueue(makeApproval(), { approve: () => {}, reject: () => {} });
expect(q.pendingCount()).toBe(1);
expect(q.current()?.id).toBe('txn-1');
expect(fired).toBe(1);
});
test('approve invokes the approve callback exactly once and clears state', async () => {
const q = new ApprovalQueue();
let approveCount = 0;
let rejectCount = 0;
q.enqueue(makeApproval(), {
approve: () => { approveCount++; },
reject: () => { rejectCount++; },
});
await q.approve('txn-1');
expect(approveCount).toBe(1);
expect(rejectCount).toBe(0);
expect(q.current()).toBeNull();
// Idempotent — second approve does nothing.
await q.approve('txn-1');
expect(approveCount).toBe(1);
});
test('reject invokes the reject callback exactly once', async () => {
const q = new ApprovalQueue();
let approveCount = 0;
let rejectCount = 0;
q.enqueue(makeApproval(), {
approve: () => { approveCount++; },
reject: () => { rejectCount++; },
});
await q.reject('txn-1');
expect(rejectCount).toBe(1);
expect(approveCount).toBe(0);
expect(q.current()).toBeNull();
});
test('mismatched id is ignored — protects against stale webview button clicks', async () => {
const q = new ApprovalQueue();
let count = 0;
q.enqueue(makeApproval('txn-1'), {
approve: () => { count++; },
reject: () => { count++; },
});
await q.approve('txn-OLD');
expect(count).toBe(0);
expect(q.current()?.id).toBe('txn-1');
});
test('approve/reject without id picks current', async () => {
const q = new ApprovalQueue();
let approveCount = 0;
q.enqueue(makeApproval(), { approve: () => { approveCount++; }, reject: () => {} });
await q.approve();
expect(approveCount).toBe(1);
});
test('enqueue while pending pre-empts the previous one without firing its callbacks', () => {
const q = new ApprovalQueue();
let oldApprove = 0, oldReject = 0;
q.enqueue(makeApproval('old'), {
approve: () => { oldApprove++; },
reject: () => { oldReject++; },
});
let newApprove = 0;
q.enqueue(makeApproval('new'), {
approve: () => { newApprove++; },
reject: () => {},
});
expect(q.current()?.id).toBe('new');
expect(oldApprove).toBe(0);
expect(oldReject).toBe(0);
// Approving "new" must hit only the new callback.
return q.approve('new').then(() => {
expect(newApprove).toBe(1);
expect(oldApprove).toBe(0);
});
});
test('clear() resets without firing callbacks', () => {
const q = new ApprovalQueue();
let cb = 0;
q.enqueue(makeApproval(), { approve: () => { cb++; }, reject: () => { cb++; } });
q.clear();
expect(q.current()).toBeNull();
expect(cb).toBe(0);
});
test('onChange fires on enqueue, approve, reject, clear', async () => {
const q = new ApprovalQueue();
const events: string[] = [];
q.onChange(() => events.push(`change-${q.pendingCount()}`));
q.enqueue(makeApproval('a'), { approve: () => {}, reject: () => {} });
await q.approve('a');
q.enqueue(makeApproval('b'), { approve: () => {}, reject: () => {} });
await q.reject('b');
q.enqueue(makeApproval('c'), { approve: () => {}, reject: () => {} });
q.clear();
expect(events).toEqual([
'change-1', 'change-0', // enqueue a, approve a
'change-1', 'change-0', // enqueue b, reject b
'change-1', 'change-0', // enqueue c, clear
]);
});
test('callback exception is swallowed (next enqueue still works)', async () => {
const q = new ApprovalQueue();
q.enqueue(makeApproval('boom'), {
approve: () => { throw new Error('callback boom'); },
reject: () => {},
});
await q.approve('boom');
expect(q.current()).toBeNull();
// Verify we can still operate the queue afterwards.
let next = 0;
q.enqueue(makeApproval('next'), { approve: () => { next++; }, reject: () => {} });
await q.approve('next');
expect(next).toBe(1);
});
test('dispose drops state and prevents further events', () => {
const q = new ApprovalQueue();
let fired = 0;
q.onChange(() => fired++);
q.enqueue(makeApproval(), { approve: () => {}, reject: () => {} });
expect(fired).toBe(1);
q.dispose();
expect(q.current()).toBeNull();
// Re-enqueueing after dispose: the emitter is disposed but should not crash.
// The contract is "dispose terminates the queue" — callers shouldn't reuse it.
});
});