/ tests / scripts / claude-batch-code-review.test.js
claude-batch-code-review.test.js
  1  /**
  2   * Tests for fetchCodeReview in scripts/claude-batch.js
  3   *
  4   * The function uses the module-level `db` variable, so we extract the logic
  5   * into a testable form and inject an in-memory DB.
  6   *
  7   * Covers:
  8   *   - queue file missing → returns []
  9   *   - queue exhausted (next_index >= files.length) → returns []
 10   *   - normal case: returns file content, advances pointer
 11   *   - file has been deleted from disk → returns [], skips it
 12   *   - skip files with pending fix tasks, advance to next valid file
 13   *   - skip files with running fix tasks (not just pending)
 14   *   - all remaining files have active tasks → returns []
 15   *   - queue pointer written back correctly
 16   */
 17  
 18  import { test, describe, beforeEach, afterEach } from 'node:test';
 19  import assert from 'node:assert/strict';
 20  import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
 21  import { join } from 'node:path';
 22  import { tmpdir } from 'node:os';
 23  import { createTestDb } from '../helpers/test-data-generator.js';
 24  
 25  // ── Inline the function for isolation ────────────────────────────────────────
 26  // fetchCodeReview() uses module-level `db` from claude-batch.js, which opens the
 27  // production DB at load time. We replicate the pure logic here for testability.
 28  // Any change to the production function's skip/pointer logic must be mirrored here.
 29  
 30  function makeFetchCodeReview(db, cwd) {
 31    // Return a fetchCodeReview bound to a given cwd (queue file location) and db
 32    return function fetchCodeReview() {
 33      const queuePath = join(cwd, 'logs/code-review-queue.json');
 34      if (!existsSync(queuePath)) return [];
 35  
 36      const queue = JSON.parse(readFileSync(queuePath, 'utf8'));
 37      const { files, next_index: nextIndex } = queue;
 38  
 39      if (!Array.isArray(files) || nextIndex >= files.length) return [];
 40  
 41      let idx = nextIndex;
 42      let filePath = null;
 43      while (idx < files.length) {
 44        const candidate = files[idx];
 45        const existing = db
 46          .prepare(
 47            `SELECT id FROM agent_tasks
 48             WHERE task_type = 'code_review_fix'
 49               AND json_extract(context_json, '$.file_path') = ?
 50               AND status IN ('pending', 'running')
 51             LIMIT 1`
 52          )
 53          .get(candidate);
 54        if (!existing) {
 55          filePath = candidate;
 56          break;
 57        }
 58        idx++;
 59      }
 60  
 61      if (!filePath) return [];
 62  
 63      queue.next_index = idx + 1;
 64      queue.reviewed = (queue.reviewed || 0) + 1;
 65      writeFileSync(queuePath, JSON.stringify(queue, null, 2));
 66  
 67      const fullPath = join(cwd, filePath);
 68      if (!existsSync(fullPath)) return [];
 69  
 70      const content = readFileSync(fullPath, 'utf8');
 71      const lineCount = content.split('\n').length;
 72      return [{ file_path: filePath, line_count: lineCount, content }];
 73    };
 74  }
 75  
 76  // ── Test helpers ─────────────────────────────────────────────────────────────
 77  
 78  function makeWorkdir() {
 79    const dir = mkdtempSync(join(tmpdir(), 'cr-test-'));
 80    mkdirSync(join(dir, 'logs'));
 81    return dir;
 82  }
 83  
 84  function writeQueue(dir, files, nextIndex = 0, reviewed = 0) {
 85    writeFileSync(
 86      join(dir, 'logs/code-review-queue.json'),
 87      JSON.stringify(
 88        { files, next_index: nextIndex, reviewed, created_at: new Date().toISOString() },
 89        null,
 90        2
 91      )
 92    );
 93  }
 94  
 95  function readQueue(dir) {
 96    return JSON.parse(readFileSync(join(dir, 'logs/code-review-queue.json'), 'utf8'));
 97  }
 98  
 99  function writeSrcFile(dir, relPath, content = '// test\n') {
100    const fullPath = join(dir, relPath);
101    mkdirSync(join(fullPath, '..'), { recursive: true });
102    writeFileSync(fullPath, content);
103  }
104  
105  function addFixTask(db, filePath, status = 'pending') {
106    db.prepare(
107      `INSERT INTO agent_tasks (task_type, assigned_to, created_by, priority, status, context_json)
108       VALUES ('code_review_fix', 'developer', 'code_reviewer', 7, ?, ?)`
109    ).run(status, JSON.stringify({ file_path: filePath, severity: 7 }));
110  }
111  
112  // ── Tests ────────────────────────────────────────────────────────────────────
113  
114  describe('fetchCodeReview', () => {
115    let dir;
116    let db;
117    let fetch;
118  
119    beforeEach(() => {
120      dir = makeWorkdir();
121      db = createTestDb();
122      fetch = makeFetchCodeReview(db, dir);
123    });
124  
125    afterEach(() => {
126      try {
127        rmSync(dir, { recursive: true, force: true });
128      } catch {
129        /* ignore */
130      }
131    });
132  
133    test('returns [] when queue file is missing', () => {
134      const result = fetch();
135      assert.deepEqual(result, []);
136    });
137  
138    test('returns [] when queue is exhausted', () => {
139      writeQueue(dir, ['src/a.js', 'src/b.js'], 2);
140      const result = fetch();
141      assert.deepEqual(result, []);
142    });
143  
144    test('returns file content and advances pointer', () => {
145      writeSrcFile(dir, 'src/payment/paypal.js', '// paypal code\n');
146      writeQueue(dir, ['src/payment/paypal.js'], 0);
147  
148      const result = fetch();
149      assert.equal(result.length, 1);
150      assert.equal(result[0].file_path, 'src/payment/paypal.js');
151      assert.equal(result[0].content, '// paypal code\n');
152      assert.equal(result[0].line_count, 2);
153  
154      // Pointer advanced
155      const q = readQueue(dir);
156      assert.equal(q.next_index, 1);
157      assert.equal(q.reviewed, 1);
158    });
159  
160    test('returns [] when target file has been deleted from disk', () => {
161      writeQueue(dir, ['src/deleted-file.js'], 0);
162      // Don't create the file
163      const result = fetch();
164      assert.deepEqual(result, []);
165    });
166  
167    test('skips file with pending fix task, returns next file', () => {
168      writeSrcFile(dir, 'src/a.js', '// a\n');
169      writeSrcFile(dir, 'src/b.js', '// b\n');
170      writeQueue(dir, ['src/a.js', 'src/b.js'], 0);
171      addFixTask(db, 'src/a.js', 'pending');
172  
173      const result = fetch();
174      assert.equal(result.length, 1);
175      assert.equal(result[0].file_path, 'src/b.js');
176  
177      // Pointer is at idx+1 = 2 (past b.js)
178      const q = readQueue(dir);
179      assert.equal(q.next_index, 2);
180    });
181  
182    test('skips file with running fix task, not just pending', () => {
183      writeSrcFile(dir, 'src/a.js', '// a\n');
184      writeSrcFile(dir, 'src/b.js', '// b\n');
185      writeQueue(dir, ['src/a.js', 'src/b.js'], 0);
186      addFixTask(db, 'src/a.js', 'running');
187  
188      const result = fetch();
189      assert.equal(result[0].file_path, 'src/b.js');
190    });
191  
192    test('completed fix tasks do not cause skip', () => {
193      writeSrcFile(dir, 'src/a.js', '// a\n');
194      writeQueue(dir, ['src/a.js'], 0);
195      // Add a completed task — should NOT block
196      db.prepare(
197        `INSERT INTO agent_tasks (task_type, assigned_to, created_by, priority, status, context_json)
198         VALUES ('code_review_fix', 'developer', 'code_reviewer', 7, 'completed', ?)`
199      ).run(JSON.stringify({ file_path: 'src/a.js', severity: 7 }));
200  
201      const result = fetch();
202      assert.equal(result.length, 1);
203      assert.equal(result[0].file_path, 'src/a.js');
204    });
205  
206    test('returns [] when all remaining files have active tasks', () => {
207      writeSrcFile(dir, 'src/a.js', '// a\n');
208      writeSrcFile(dir, 'src/b.js', '// b\n');
209      writeQueue(dir, ['src/a.js', 'src/b.js'], 0);
210      addFixTask(db, 'src/a.js', 'pending');
211      addFixTask(db, 'src/b.js', 'running');
212  
213      const result = fetch();
214      assert.deepEqual(result, []);
215    });
216  
217    test('reviewed counter increments correctly', () => {
218      writeSrcFile(dir, 'src/a.js', '// a\n');
219      writeSrcFile(dir, 'src/b.js', '// b\n');
220      writeQueue(dir, ['src/a.js', 'src/b.js'], 0, 5); // 5 already reviewed
221  
222      fetch(); // reviews a.js
223      const q = readQueue(dir);
224      assert.equal(q.reviewed, 6);
225    });
226  });
227  
228  // ── Source constant validation ────────────────────────────────────────────────
229  
230  describe('fetchCodeReview source constants', () => {
231    test("status filter includes both 'pending' and 'running'", async () => {
232      const { readFileSync: rf } = await import('node:fs');
233      const src = rf('scripts/claude-batch.js', 'utf8');
234      assert.match(
235        src,
236        /'pending'.*'running'|"pending".*"running"/,
237        "Must skip both 'pending' and 'running' fix tasks"
238      );
239    });
240  
241    test('queue file path is logs/code-review-queue.json', async () => {
242      const { readFileSync: rf } = await import('node:fs');
243      const src = rf('scripts/claude-batch.js', 'utf8');
244      assert.match(src, /code-review-queue\.json/);
245    });
246  });