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