/** * 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. }); });