/ tests / utils / human-review-queue-supplement.test.js
human-review-queue-supplement.test.js
  1  /**
  2   * Supplement tests for src/utils/human-review-queue.js
  3   *
  4   * Existing tests cover the happy path for all functions.
  5   * This supplement focuses on:
  6   *   - getPendingReviews: combined type+priority filter, type+limit, priority+limit
  7   *   - addReviewItem: dedup does NOT update a reviewed item (only pending)
  8   *   - addReviewItems: batch with mixed priorities and types
  9   *   - getQueueStats: empty table, all dismissed, mixed states
 10   *   - cleanupReviewedItems: dismissed items older than 30 days
 11   *   - markReviewed/dismissReview: non-existent ID (no-op, no throw)
 12   *   - getPendingReviews: priority ordering across all four levels
 13   */
 14  
 15  import { test, describe, before, mock } from 'node:test';
 16  import assert from 'node:assert/strict';
 17  import Database from 'better-sqlite3';
 18  import { createPgMock } from '../helpers/pg-mock.js';
 19  
 20  const db = new Database(':memory:');
 21  db.exec(`
 22    CREATE TABLE IF NOT EXISTS human_review_queue (
 23      id INTEGER PRIMARY KEY AUTOINCREMENT,
 24      file TEXT,
 25      reason TEXT,
 26      type TEXT,
 27      priority TEXT DEFAULT 'medium',
 28      status TEXT DEFAULT 'pending',
 29      created_at TEXT DEFAULT (datetime('now')),
 30      reviewed_at TEXT,
 31      reviewed_by TEXT,
 32      notes TEXT
 33    );
 34    CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_pending_reviews
 35      ON human_review_queue(file, type)
 36      WHERE status = 'pending';
 37  `);
 38  
 39  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 40  
 41  const {
 42    initializeQueue,
 43    addReviewItem,
 44    addReviewItems,
 45    getPendingReviews,
 46    markReviewed,
 47    dismissReview,
 48    getQueueStats,
 49    cleanupReviewedItems,
 50  } = await import('../../src/utils/human-review-queue.js');
 51  
 52  before(() => {
 53    initializeQueue();
 54  });
 55  
 56  function clearTable() {
 57    db.exec('DELETE FROM human_review_queue');
 58  }
 59  
 60  // ── getPendingReviews — combined filters ─────────────────────────────────────
 61  
 62  describe('getPendingReviews — combined filters', () => {
 63    before(async () => {
 64      clearTable();
 65      await addReviewItems([
 66        { file: 'src/a.js', reason: 'r1', type: 'architecture', priority: 'high' },
 67        { file: 'src/b.js', reason: 'r2', type: 'architecture', priority: 'low' },
 68        { file: 'src/c.js', reason: 'r3', type: 'security', priority: 'high' },
 69        { file: 'src/d.js', reason: 'r4', type: 'security', priority: 'critical' },
 70        { file: 'src/e.js', reason: 'r5', type: 'test', priority: 'medium' },
 71      ]);
 72    });
 73  
 74    test('filters by type AND priority together', async () => {
 75      const items = await getPendingReviews({ type: 'architecture', priority: 'high' });
 76      assert.equal(items.length, 1);
 77      assert.equal(items[0].file, 'src/a.js');
 78    });
 79  
 80    test('filters by type AND limit', async () => {
 81      const items = await getPendingReviews({ type: 'security', limit: 1 });
 82      assert.equal(items.length, 1);
 83      // Critical should come first
 84      assert.equal(items[0].priority, 'critical');
 85    });
 86  
 87    test('filters by priority AND limit', async () => {
 88      const items = await getPendingReviews({ priority: 'high', limit: 1 });
 89      assert.equal(items.length, 1);
 90    });
 91  
 92    test('returns empty array when no match', async () => {
 93      const items = await getPendingReviews({ type: 'nonexistent' });
 94      assert.equal(items.length, 0);
 95    });
 96  
 97    test('returns empty array for matching type but wrong priority', async () => {
 98      const items = await getPendingReviews({ type: 'test', priority: 'critical' });
 99      assert.equal(items.length, 0);
100    });
101  });
102  
103  // ── getPendingReviews — full priority ordering ──────────────────────────────
104  
105  describe('getPendingReviews — priority ordering', () => {
106    before(async () => {
107      clearTable();
108      // Insert in reverse priority order to verify sort
109      await addReviewItems([
110        { file: 'low.js', reason: 'r', type: 'test', priority: 'low' },
111        { file: 'medium.js', reason: 'r', type: 'test', priority: 'medium' },
112        { file: 'high.js', reason: 'r', type: 'test', priority: 'high' },
113        { file: 'critical.js', reason: 'r', type: 'test', priority: 'critical' },
114      ]);
115    });
116  
117    test('returns items in correct priority order: critical, high, medium, low', async () => {
118      const items = await getPendingReviews();
119      assert.equal(items.length, 4);
120      assert.equal(items[0].file, 'critical.js');
121      assert.equal(items[1].file, 'high.js');
122      assert.equal(items[2].file, 'medium.js');
123      assert.equal(items[3].file, 'low.js');
124    });
125  });
126  
127  // ── addReviewItem — dedup only applies to pending items ─────────────────────
128  
129  describe('addReviewItem — dedup boundary cases', () => {
130    test('does NOT update a reviewed item (creates new pending instead)', async () => {
131      clearTable();
132      await addReviewItem({ file: 'src/reviewed.js', reason: 'original', type: 'test' });
133  
134      const row = db.prepare("SELECT id FROM human_review_queue WHERE file = 'src/reviewed.js'").get();
135  
136      // Mark as reviewed
137      await markReviewed(row.id, 'tester', 'done');
138  
139      // Now add again — should create a new pending item, not update the reviewed one
140      await addReviewItem({ file: 'src/reviewed.js', reason: 'new reason', type: 'test' });
141  
142      const rows = db.prepare("SELECT * FROM human_review_queue WHERE file = 'src/reviewed.js'").all();
143  
144      assert.equal(rows.length, 2, 'should have both reviewed and new pending');
145      const reviewed = rows.find(r => r.status === 'reviewed');
146      const pending = rows.find(r => r.status === 'pending');
147      assert.ok(reviewed, 'reviewed item should still exist');
148      assert.ok(pending, 'new pending item should exist');
149      assert.equal(pending.reason, 'new reason');
150    });
151  
152    test('does NOT update a dismissed item (creates new pending instead)', async () => {
153      clearTable();
154      await addReviewItem({ file: 'src/dismissed.js', reason: 'original', type: 'security' });
155  
156      const row = db.prepare("SELECT id FROM human_review_queue WHERE file = 'src/dismissed.js'").get();
157  
158      await dismissReview(row.id, 'not needed');
159  
160      await addReviewItem({ file: 'src/dismissed.js', reason: 'needs second look', type: 'security' });
161  
162      const rows = db.prepare("SELECT * FROM human_review_queue WHERE file = 'src/dismissed.js'").all();
163  
164      assert.equal(rows.length, 2);
165      assert.ok(rows.some(r => r.status === 'dismissed'));
166      assert.ok(rows.some(r => r.status === 'pending'));
167    });
168  });
169  
170  // ── addReviewItems — batch edge cases ───────────────────────────────────────
171  
172  describe('addReviewItems — batch edge cases', () => {
173    test('handles batch with duplicate file+type within same call', async () => {
174      clearTable();
175      // Second item with same file+type should update the first
176      await addReviewItems([
177        { file: 'src/dup.js', reason: 'first', type: 'architecture', priority: 'low' },
178        { file: 'src/dup.js', reason: 'second', type: 'architecture', priority: 'high' },
179      ]);
180  
181      const rows = db.prepare("SELECT * FROM human_review_queue WHERE file = 'src/dup.js'").all();
182  
183      // The second call updates the first insert since they share file+type+pending
184      assert.equal(rows.length, 1);
185      assert.equal(rows[0].reason, 'second');
186      assert.equal(rows[0].priority, 'high');
187    });
188  
189    test('batch with multiple types for same file creates separate entries', async () => {
190      clearTable();
191      await addReviewItems([
192        { file: 'src/multi.js', reason: 'arch reason', type: 'architecture' },
193        { file: 'src/multi.js', reason: 'sec reason', type: 'security' },
194        { file: 'src/multi.js', reason: 'doc reason', type: 'documentation' },
195      ]);
196  
197      const rows = db.prepare("SELECT * FROM human_review_queue WHERE file = 'src/multi.js'").all();
198  
199      assert.equal(rows.length, 3);
200    });
201  });
202  
203  // ── getQueueStats — edge cases ──────────────────────────────────────────────
204  
205  describe('getQueueStats — edge cases', () => {
206    test('returns all zeros/nulls on empty table', async () => {
207      clearTable();
208      const stats = await getQueueStats();
209      // SQLite SUM on empty set returns null, COUNT returns 0
210      assert.equal(stats.total, 0);
211      // pending/reviewed/critical/high are SUM-based → null on empty table
212      assert.equal(stats.pending ?? 0, 0);
213      assert.equal(stats.reviewed ?? 0, 0);
214      assert.equal(stats.critical ?? 0, 0);
215      assert.equal(stats.high ?? 0, 0);
216    });
217  
218    test('counts only pending critical and high (not reviewed ones)', async () => {
219      clearTable();
220      await addReviewItem({ file: 'crit.js', reason: 'r', type: 'security', priority: 'critical' });
221      await addReviewItem({ file: 'high.js', reason: 'r', type: 'architecture', priority: 'high' });
222  
223      // Mark the critical one as reviewed
224      const critRow = db.prepare("SELECT id FROM human_review_queue WHERE file = 'crit.js'").get();
225      await markReviewed(critRow.id, 'tester');
226  
227      const stats = await getQueueStats();
228      assert.equal(stats.total, 2);
229      assert.equal(stats.pending, 1);
230      assert.equal(stats.reviewed, 1);
231      assert.equal(stats.critical, 0, 'critical count should only count pending');
232      assert.equal(stats.high, 1);
233    });
234  
235    test('stats include dismissed in total but not in pending', async () => {
236      clearTable();
237      await addReviewItem({ file: 'dis.js', reason: 'r', type: 'test', priority: 'low' });
238  
239      const row = db.prepare("SELECT id FROM human_review_queue WHERE file = 'dis.js'").get();
240      await dismissReview(row.id, 'not needed');
241  
242      const stats = await getQueueStats();
243      assert.equal(stats.total, 1);
244      assert.equal(stats.pending, 0);
245    });
246  });
247  
248  // ── markReviewed / dismissReview — non-existent IDs ─────────────────────────
249  
250  describe('markReviewed / dismissReview — non-existent IDs', () => {
251    test('markReviewed with non-existent ID does not throw', async () => {
252      await assert.doesNotReject(() => markReviewed(99999, 'nobody', 'no item'));
253    });
254  
255    test('dismissReview with non-existent ID does not throw', async () => {
256      await assert.doesNotReject(() => dismissReview(99999, 'no reason'));
257    });
258  });
259  
260  // ── cleanupReviewedItems — dismissed items ──────────────────────────────────
261  
262  describe('cleanupReviewedItems — dismissed items', () => {
263    test('deletes old dismissed items (>30 days)', async () => {
264      clearTable();
265      db.prepare(`
266        INSERT INTO human_review_queue (file, reason, type, status, reviewed_at)
267        VALUES ('src/old-dismissed.js', 'old', 'test', 'dismissed', datetime('now', '-31 days'))
268      `).run();
269  
270      const deleted = await cleanupReviewedItems();
271      assert.equal(deleted, 1);
272    });
273  
274    test('does NOT delete pending items regardless of age', async () => {
275      clearTable();
276      // Insert a very old pending item
277      db.prepare(`
278        INSERT INTO human_review_queue (file, reason, type, status, created_at)
279        VALUES ('src/old-pending.js', 'old', 'test', 'pending', datetime('now', '-90 days'))
280      `).run();
281  
282      const deleted = await cleanupReviewedItems();
283      assert.equal(deleted, 0, 'pending items should not be deleted');
284  
285      const row = db.prepare("SELECT * FROM human_review_queue WHERE file = 'src/old-pending.js'").get();
286      assert.ok(row, 'pending item should still exist');
287    });
288  
289    test('does NOT delete recent reviewed items (<30 days)', async () => {
290      clearTable();
291      db.prepare(`
292        INSERT INTO human_review_queue (file, reason, type, status, reviewed_at)
293        VALUES ('src/recent.js', 'recent', 'test', 'reviewed', datetime('now', '-5 days'))
294      `).run();
295  
296      const deleted = await cleanupReviewedItems();
297      assert.equal(deleted, 0);
298    });
299  
300    test('deletes mix of old reviewed and old dismissed, keeps recent', async () => {
301      clearTable();
302      db.prepare(`
303        INSERT INTO human_review_queue (file, reason, type, status, reviewed_at)
304        VALUES ('old-rev.js', 'r', 'test', 'reviewed', datetime('now', '-35 days'))
305      `).run();
306      db.prepare(`
307        INSERT INTO human_review_queue (file, reason, type, status, reviewed_at)
308        VALUES ('old-dis.js', 'r', 'test', 'dismissed', datetime('now', '-40 days'))
309      `).run();
310      db.prepare(`
311        INSERT INTO human_review_queue (file, reason, type, status, reviewed_at)
312        VALUES ('recent-rev.js', 'r', 'test', 'reviewed', datetime('now', '-2 days'))
313      `).run();
314  
315      const deleted = await cleanupReviewedItems();
316      assert.equal(deleted, 2, 'should delete both old items');
317  
318      const remaining = db.prepare('SELECT file FROM human_review_queue').all();
319      assert.equal(remaining.length, 1);
320      assert.equal(remaining[0].file, 'recent-rev.js');
321    });
322  });