/ tests / agents / human-review-queue.test.js
human-review-queue.test.js
  1  /**
  2   * Tests for Human Review Queue Manager
  3   */
  4  
  5  import { test, before, after, mock } from 'node:test';
  6  import assert from 'node:assert/strict';
  7  import Database from 'better-sqlite3';
  8  import { createPgMock } from '../helpers/pg-mock.js';
  9  
 10  // Shared in-memory database
 11  const db = new Database(':memory:');
 12  db.exec(`
 13    CREATE TABLE IF NOT EXISTS human_review_queue (
 14      id INTEGER PRIMARY KEY AUTOINCREMENT,
 15      file TEXT,
 16      reason TEXT,
 17      type TEXT,
 18      priority TEXT DEFAULT 'medium',
 19      status TEXT DEFAULT 'pending',
 20      created_at TEXT DEFAULT (datetime('now')),
 21      reviewed_at TEXT,
 22      reviewed_by TEXT,
 23      notes TEXT
 24    );
 25    CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_pending_reviews
 26      ON human_review_queue(file, type)
 27      WHERE status = 'pending';
 28  `);
 29  
 30  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 31  
 32  const {
 33    initializeQueue,
 34    addReviewItem,
 35    addReviewItems,
 36    getPendingReviews,
 37    markReviewed,
 38    dismissReview,
 39    getQueueStats,
 40    cleanupReviewedItems,
 41  } = await import('../../src/utils/human-review-queue.js');
 42  
 43  // Initialize queue before all tests
 44  before(async () => {
 45    await initializeQueue();
 46  });
 47  
 48  // Clean up database before each test
 49  test.beforeEach(() => {
 50    db.exec('DELETE FROM human_review_queue');
 51  });
 52  
 53  test('addReviewItem should add new item', async () => {
 54    await addReviewItem({
 55      file: 'test.js',
 56      reason: 'Test reason',
 57      type: 'test',
 58      priority: 'high',
 59    });
 60  
 61    const pending = await getPendingReviews();
 62    assert.equal(pending.length, 1);
 63    assert.equal(pending[0].file, 'test.js');
 64    assert.equal(pending[0].reason, 'Test reason');
 65    assert.equal(pending[0].type, 'test');
 66    assert.equal(pending[0].priority, 'high');
 67  });
 68  
 69  test('addReviewItem should update existing pending item with same file+type', async () => {
 70    // Add first item
 71    await addReviewItem({
 72      file: 'test.js',
 73      reason: 'Original reason',
 74      type: 'test',
 75      priority: 'medium',
 76    });
 77  
 78    // Try to add duplicate
 79    await addReviewItem({
 80      file: 'test.js',
 81      reason: 'Updated reason',
 82      type: 'test',
 83      priority: 'high',
 84    });
 85  
 86    const pending = await getPendingReviews();
 87    assert.equal(pending.length, 1, 'Should only have 1 pending item');
 88    assert.equal(pending[0].reason, 'Updated reason', 'Should update reason');
 89    assert.equal(pending[0].priority, 'high', 'Should update priority');
 90  });
 91  
 92  test('addReviewItem should allow different types for same file', async () => {
 93    await addReviewItem({
 94      file: 'test.js',
 95      reason: 'Test reason',
 96      type: 'test',
 97      priority: 'high',
 98    });
 99  
100    await addReviewItem({
101      file: 'test.js',
102      reason: 'Security reason',
103      type: 'security',
104      priority: 'critical',
105    });
106  
107    const pending = await getPendingReviews();
108    assert.equal(pending.length, 2, 'Should allow different types for same file');
109  });
110  
111  test('addReviewItem should allow new item after previous is reviewed', async () => {
112    // Add and review first item
113    await addReviewItem({
114      file: 'test.js',
115      reason: 'Original reason',
116      type: 'test',
117      priority: 'medium',
118    });
119  
120    const pending = await getPendingReviews();
121    await markReviewed(pending[0].id, 'Tester', 'Reviewed');
122  
123    // Add new item with same file+type
124    await addReviewItem({
125      file: 'test.js',
126      reason: 'New reason',
127      type: 'test',
128      priority: 'high',
129    });
130  
131    const newPending = await getPendingReviews();
132    assert.equal(newPending.length, 1, 'Should allow new item after review');
133    assert.equal(newPending[0].reason, 'New reason');
134  });
135  
136  test('addReviewItems should deduplicate multiple items', async () => {
137    await addReviewItems([
138      { file: 'test1.js', reason: 'Reason 1', type: 'test', priority: 'high' },
139      { file: 'test2.js', reason: 'Reason 2', type: 'test', priority: 'medium' },
140      { file: 'test1.js', reason: 'Updated Reason 1', type: 'test', priority: 'critical' },
141    ]);
142  
143    const pending = await getPendingReviews();
144    assert.equal(pending.length, 2, 'Should deduplicate items');
145  
146    const test1 = pending.find(item => item.file === 'test1.js');
147    assert.equal(test1.reason, 'Updated Reason 1', 'Should use latest reason');
148    assert.equal(test1.priority, 'critical', 'Should use latest priority');
149  });
150  
151  test('getPendingReviews should filter by type', async () => {
152    await addReviewItems([
153      { file: 'test1.js', reason: 'Test reason', type: 'test', priority: 'high' },
154      { file: 'test2.js', reason: 'Security reason', type: 'security', priority: 'critical' },
155      { file: 'test3.js', reason: 'Doc reason', type: 'documentation', priority: 'low' },
156    ]);
157  
158    const securityItems = await getPendingReviews({ type: 'security' });
159    assert.equal(securityItems.length, 1);
160    assert.equal(securityItems[0].type, 'security');
161  });
162  
163  test('getPendingReviews should filter by priority', async () => {
164    await addReviewItems([
165      { file: 'test1.js', reason: 'Test reason', type: 'test', priority: 'high' },
166      { file: 'test2.js', reason: 'Security reason', type: 'security', priority: 'critical' },
167      { file: 'test3.js', reason: 'Doc reason', type: 'documentation', priority: 'low' },
168    ]);
169  
170    const criticalItems = await getPendingReviews({ priority: 'critical' });
171    assert.equal(criticalItems.length, 1);
172    assert.equal(criticalItems[0].priority, 'critical');
173  });
174  
175  test('getPendingReviews should sort by priority then created_at', async () => {
176    await addReviewItems([
177      { file: 'test1.js', reason: 'Test reason', type: 'test', priority: 'low' },
178      { file: 'test2.js', reason: 'Security reason', type: 'security', priority: 'critical' },
179      { file: 'test3.js', reason: 'Doc reason', type: 'documentation', priority: 'high' },
180    ]);
181  
182    const pending = await getPendingReviews();
183    assert.equal(pending[0].priority, 'critical', 'Critical should be first');
184    assert.equal(pending[1].priority, 'high', 'High should be second');
185    assert.equal(pending[2].priority, 'low', 'Low should be last');
186  });
187  
188  test('markReviewed should update status', async () => {
189    await addReviewItem({
190      file: 'test.js',
191      reason: 'Test reason',
192      type: 'test',
193      priority: 'high',
194    });
195  
196    const pending = await getPendingReviews();
197    assert.equal(pending.length, 1);
198  
199    await markReviewed(pending[0].id, 'Tester', 'Looks good');
200  
201    const afterReview = await getPendingReviews();
202    assert.equal(afterReview.length, 0, 'Should have no pending items');
203  
204    const stats = await getQueueStats();
205    assert.equal(stats.reviewed, 1, 'Should have 1 reviewed item');
206  });
207  
208  test('dismissReview should update status', async () => {
209    await addReviewItem({
210      file: 'test.js',
211      reason: 'Test reason',
212      type: 'test',
213      priority: 'high',
214    });
215  
216    const pending = await getPendingReviews();
217    await dismissReview(pending[0].id, 'Not needed');
218  
219    const afterDismiss = await getPendingReviews();
220    assert.equal(afterDismiss.length, 0, 'Should have no pending items');
221  });
222  
223  test('getQueueStats should return correct counts', async () => {
224    await addReviewItems([
225      { file: 'test1.js', reason: 'Test reason', type: 'test', priority: 'high' },
226      { file: 'test2.js', reason: 'Security reason', type: 'security', priority: 'critical' },
227      { file: 'test3.js', reason: 'Doc reason', type: 'documentation', priority: 'low' },
228    ]);
229  
230    const stats = await getQueueStats();
231    assert.equal(stats.total, 3);
232    assert.equal(stats.pending, 3);
233    assert.equal(stats.critical, 1);
234    assert.equal(stats.high, 1);
235  
236    const pending = await getPendingReviews();
237    await markReviewed(pending[0].id, 'Tester', 'Done');
238  
239    const newStats = await getQueueStats();
240    assert.equal(newStats.pending, 2);
241    assert.equal(newStats.reviewed, 1);
242  });
243  
244  test('getPendingReviews should apply limit', async () => {
245    await addReviewItems([
246      { file: 'test1.js', reason: 'Reason 1', type: 'test', priority: 'high' },
247      { file: 'test2.js', reason: 'Reason 2', type: 'test', priority: 'medium' },
248      { file: 'test3.js', reason: 'Reason 3', type: 'test', priority: 'low' },
249    ]);
250  
251    const limited = await getPendingReviews({ limit: 2 });
252    assert.equal(limited.length, 2, 'Should return only 2 items when limit is 2');
253  });
254  
255  test('cleanup test placeholder', () => {
256    const x = 1;
257  });
258  
259  test('cleanupReviewedItems should return 0 when no old items exist', async () => {
260    await addReviewItem({
261      file: 'recent.js',
262      reason: 'Recent item',
263      type: 'test',
264      priority: 'low',
265    });
266  
267    const pending = await getPendingReviews();
268    await markReviewed(pending[0].id, 'Tester', 'Done');
269  
270    const deleted = await cleanupReviewedItems();
271    assert.equal(deleted, 0, 'Should not delete recently reviewed items');
272  });
273  
274  test('cleanupReviewedItems should delete items reviewed more than 30 days ago', async () => {
275    db.prepare(
276      `
277      INSERT INTO human_review_queue (file, reason, type, priority, status, reviewed_at)
278      VALUES (?, ?, ?, ?, 'reviewed', datetime('now', '-31 days'))
279    `
280    ).run('old-reviewed.js', 'Old item', 'test', 'low');
281  
282    db.prepare(
283      `
284      INSERT INTO human_review_queue (file, reason, type, priority, status, reviewed_at)
285      VALUES (?, ?, ?, ?, 'dismissed', datetime('now', '-35 days'))
286    `
287    ).run('old-dismissed.js', 'Old dismissed item', 'test', 'medium');
288  
289    const deleted = await cleanupReviewedItems();
290    assert.equal(deleted, 2, 'Should delete 2 old reviewed/dismissed items');
291  
292    const stats = await getQueueStats();
293    assert.equal(stats.total, 0, 'Should have no items remaining');
294  });
295  
296  test('cleanupReviewedItems should not delete pending items', async () => {
297    db.prepare(
298      `
299      INSERT INTO human_review_queue (file, reason, type, priority, status, created_at)
300      VALUES (?, ?, ?, ?, 'pending', datetime('now', '-45 days'))
301    `
302    ).run('old-pending.js', 'Old pending item', 'test', 'high');
303  
304    const deleted = await cleanupReviewedItems();
305    assert.equal(deleted, 0, 'Should not delete pending items');
306  
307    const pending = await getPendingReviews();
308    assert.equal(pending.length, 1, 'Pending item should still exist');
309  });
310  
311  test('cleanupReviewedItems should keep recent reviewed items and delete old ones', async () => {
312    db.prepare(
313      `
314      INSERT INTO human_review_queue (file, reason, type, priority, status, reviewed_at)
315      VALUES (?, ?, ?, ?, 'reviewed', datetime('now', '-31 days'))
316    `
317    ).run('old.js', 'Old reviewed', 'test', 'low');
318  
319    await addReviewItem({ file: 'new.js', reason: 'New item', type: 'security', priority: 'high' });
320    const pending = await getPendingReviews();
321    await markReviewed(pending[0].id, 'Tester', 'Reviewed recently');
322  
323    const deleted = await cleanupReviewedItems();
324    assert.equal(deleted, 1, 'Should delete only the old reviewed item');
325  
326    const stats = await getQueueStats();
327    assert.equal(stats.total, 1, 'Recent reviewed item should remain');
328    assert.equal(stats.reviewed, 1);
329  });
330  
331  test('markReviewed should store notes and reviewer in database', async () => {
332    await addReviewItem({
333      file: 'test.js',
334      reason: 'Needs review',
335      type: 'architecture',
336      priority: 'high',
337    });
338  
339    const pending = await getPendingReviews();
340    await markReviewed(pending[0].id, 'Alice', 'Approved after careful review');
341  
342    const item = db.prepare('SELECT * FROM human_review_queue WHERE id = ?').get(pending[0].id);
343  
344    assert.equal(item.reviewed_by, 'Alice');
345    assert.equal(item.notes, 'Approved after careful review');
346    assert.equal(item.status, 'reviewed');
347    assert.ok(item.reviewed_at, 'Should have reviewed_at timestamp');
348  });
349  
350  test('markReviewed should work with null notes', async () => {
351    await addReviewItem({
352      file: 'test.js',
353      reason: 'Needs review',
354      type: 'test',
355      priority: 'medium',
356    });
357  
358    const pending = await getPendingReviews();
359    await markReviewed(pending[0].id, 'Bob');
360  
361    const item = db.prepare('SELECT * FROM human_review_queue WHERE id = ?').get(pending[0].id);
362  
363    assert.equal(item.reviewed_by, 'Bob');
364    assert.equal(item.notes, null);
365    assert.equal(item.status, 'reviewed');
366  });
367  
368  test('dismissReview should store reason in notes and set dismissed status', async () => {
369    await addReviewItem({
370      file: 'test.js',
371      reason: 'Needs review',
372      type: 'documentation',
373      priority: 'low',
374    });
375  
376    const pending = await getPendingReviews();
377    await dismissReview(pending[0].id, 'Not relevant anymore');
378  
379    const item = db.prepare('SELECT * FROM human_review_queue WHERE id = ?').get(pending[0].id);
380  
381    assert.equal(item.status, 'dismissed');
382    assert.equal(item.notes, 'Not relevant anymore');
383    assert.ok(item.reviewed_at, 'Should have reviewed_at timestamp');
384  });
385  
386  test('dismissReview should work with null reason', async () => {
387    await addReviewItem({
388      file: 'test.js',
389      reason: 'Needs review',
390      type: 'test',
391      priority: 'medium',
392    });
393  
394    const pending = await getPendingReviews();
395    await dismissReview(pending[0].id);
396  
397    const item = db.prepare('SELECT * FROM human_review_queue WHERE id = ?').get(pending[0].id);
398  
399    assert.equal(item.status, 'dismissed');
400    assert.equal(item.notes, null);
401  });
402  
403  test('addReviewItem should use medium as default priority', async () => {
404    await addReviewItem({
405      file: 'test.js',
406      reason: 'Test reason',
407      type: 'test',
408    });
409  
410    const pending = await getPendingReviews();
411    assert.equal(pending[0].priority, 'medium', 'Should default to medium priority');
412  });
413  
414  test('getPendingReviews should return empty array when queue is empty', async () => {
415    const pending = await getPendingReviews();
416    assert.deepEqual(pending, []);
417  });
418  
419  test('getQueueStats should return zeros for empty queue', async () => {
420    const stats = await getQueueStats();
421    // COUNT(*) returns 0, but SUM() on empty table returns null in SQLite
422    assert.equal(stats.total, 0);
423    assert.ok(!stats.pending, 'pending should be 0 or null for empty queue');
424    assert.ok(!stats.reviewed, 'reviewed should be 0 or null for empty queue');
425    assert.ok(!stats.critical, 'critical should be 0 or null for empty queue');
426    assert.ok(!stats.high, 'high should be 0 or null for empty queue');
427  });
428  
429  test('getPendingReviews should combine type and limit filters', async () => {
430    await addReviewItems([
431      { file: 'a.js', reason: 'Reason', type: 'security', priority: 'critical' },
432      { file: 'b.js', reason: 'Reason', type: 'security', priority: 'high' },
433      { file: 'c.js', reason: 'Reason', type: 'security', priority: 'medium' },
434      { file: 'd.js', reason: 'Reason', type: 'test', priority: 'critical' },
435    ]);
436    const filtered = await getPendingReviews({ type: 'security', limit: 2 });
437    assert.equal(filtered.length, 2);
438    assert.equal(filtered[0].type, 'security');
439  });