165 lines
5.8 KiB
TypeScript
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.
|
|
});
|
|
});
|