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 });