/ tests / cron / sonnet-overseer.test.js
sonnet-overseer.test.js
  1  /**
  2   * Tests for src/cron/sonnet-overseer.js (stub version)
  3   *
  4   * The overseer is now a stub — LLM analysis is delegated to claude-orchestrator.sh.
  5   * Tests cover the remaining functionality:
  6   * - collectServiceStatus: checks systemctl for each service
  7   * - collectRecentErrors: reads log file, filters by ERROR/WARN, deduplicates
  8   * - runSonnetOverseer: stub that returns { summary: 'Delegated to orchestrator', ... }
  9   *
 10   * NOTE: requires --experimental-test-module-mocks
 11   */
 12  
 13  import { test, describe, mock, before, after } from 'node:test';
 14  import assert from 'node:assert/strict';
 15  import { join, dirname } from 'path';
 16  import { fileURLToPath } from 'url';
 17  import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'fs';
 18  import Database from 'better-sqlite3';
 19  import { createPgMock } from '../helpers/pg-mock.js';
 20  
 21  const __filename = fileURLToPath(import.meta.url);
 22  const __dirname = dirname(__filename);
 23  // PROJECT_ROOT in sonnet-overseer.js = join(__dirname, '../..') from src/cron/
 24  const PROJECT_ROOT = join(__dirname, '../..'); // tests/cron/ → 333Method/
 25  const PROJECT_LOGS = join(PROJECT_ROOT, 'logs');
 26  
 27  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 28  
 29  const db = new Database(':memory:');
 30  
 31  db.exec(`
 32    CREATE TABLE IF NOT EXISTS sites (
 33      id INTEGER PRIMARY KEY AUTOINCREMENT,
 34      domain TEXT NOT NULL DEFAULT 'test.com',
 35      status TEXT DEFAULT 'found',
 36      score REAL,
 37      error_message TEXT,
 38      updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
 39      rescored_at DATETIME
 40    );
 41  `);
 42  
 43  // ── Mutable stub for execSync only ────────────────────────────────────────────
 44  
 45  let execSyncFn = _cmd => 'active\n';
 46  
 47  // ─── Mock db.js BEFORE importing module under test ────────────────────────────
 48  
 49  mock.module('../../src/utils/db.js', {
 50    namedExports: createPgMock(db),
 51  });
 52  
 53  // Only mock child_process (not fs — better-sqlite3 needs real fs)
 54  mock.module('child_process', {
 55    namedExports: {
 56      execSync: (...args) => execSyncFn(...args),
 57    },
 58  });
 59  
 60  mock.module('../../src/utils/load-env.js', {
 61    namedExports: {},
 62  });
 63  
 64  // ─── Import AFTER mock.module ─────────────────────────────────────────────────
 65  
 66  const { collectServiceStatus, collectRecentErrors, runSonnetOverseer } =
 67    await import('../../src/cron/sonnet-overseer.js');
 68  
 69  // ── Temp log dir setup ────────────────────────────────────────────────────────
 70  
 71  const today = new Date().toISOString().slice(0, 10);
 72  const testLogFile = join(PROJECT_LOGS, `pipeline-${today}.log`);
 73  
 74  before(() => {
 75    // Ensure project logs directory exists
 76    mkdirSync(PROJECT_LOGS, { recursive: true });
 77  });
 78  
 79  after(() => {
 80    // Clean up test log file if we created it
 81    if (existsSync(testLogFile)) {
 82      try {
 83        unlinkSync(testLogFile);
 84      } catch {
 85        /* ignore */
 86      }
 87    }
 88  });
 89  
 90  // ── Tests ─────────────────────────────────────────────────────────────────────
 91  
 92  describe('collectServiceStatus', () => {
 93    test('returns active status for all services when all active', () => {
 94      execSyncFn = () => 'active\n';
 95      const result = collectServiceStatus();
 96      assert.ok(typeof result === 'object', 'should return an object');
 97      assert.ok('333method-pipeline' in result, 'should have pipeline key');
 98      assert.ok('mmo-cron.timer' in result, 'should have cron.timer key');
 99      assert.ok('333method-dashboard' in result, 'should have dashboard key');
100      assert.equal(result['333method-pipeline'], 'active');
101    });
102  
103    test('returns inactive when execSync throws with stdout', () => {
104      execSyncFn = cmd => {
105        if (cmd.includes('pipeline')) {
106          const err = new Error('inactive');
107          err.stdout = 'inactive\n';
108          throw err;
109        }
110        return 'active\n';
111      };
112      const result = collectServiceStatus();
113      assert.equal(result['333method-pipeline'], 'inactive');
114      assert.equal(result['mmo-cron.timer'], 'active');
115    });
116  
117    test('returns inactive when execSync throws with no stdout', () => {
118      execSyncFn = () => {
119        throw new Error('connection refused');
120      };
121      const result = collectServiceStatus();
122      for (const val of Object.values(result)) {
123        assert.ok(val === 'inactive' || val === '', `unexpected status: ${val}`);
124      }
125    });
126  });
127  
128  describe('collectRecentErrors — log file does not exist', () => {
129    test('returns empty array when log file missing', () => {
130      // Remove test log file if it exists from prior test
131      if (existsSync(testLogFile)) unlinkSync(testLogFile);
132      const result = collectRecentErrors();
133      assert.deepEqual(result, []);
134    });
135  });
136  
137  describe('collectRecentErrors — log file with errors', () => {
138    test('returns recent ERROR/WARN lines and filters out INFO and old errors', () => {
139      const recentTs = new Date().toISOString();
140      const oldTs = new Date(Date.now() - 60 * 60 * 1000).toISOString();
141  
142      writeFileSync(
143        testLogFile,
144        [
145          `[${recentTs}] [Pipeline] INFO: Normal message`,
146          `[${recentTs}] [Pipeline] [ERROR] Something went wrong`,
147          `[${recentTs}] [Scoring] [WARN] Rate limit hit`,
148          `[${oldTs}] [Pipeline] [ERROR] Old error (should be filtered out)`,
149          `[${recentTs}] [Pipeline] [ERROR] Another error`,
150        ].join('\n')
151      );
152  
153      const result = collectRecentErrors();
154      assert.ok(Array.isArray(result), 'should return array');
155      assert.ok(result.length >= 2, `should have ≥2 errors, got: ${result.length}`);
156      assert.ok(
157        result.some(l => l.includes('Something went wrong')),
158        'should include recent ERROR'
159      );
160      assert.ok(
161        result.some(l => l.includes('Rate limit hit')),
162        'should include recent WARN'
163      );
164      assert.ok(!result.some(l => l.includes('Normal message')), 'should exclude INFO lines');
165      assert.ok(
166        !result.some(l => l.includes('Old error')),
167        'should exclude old errors outside 30min window'
168      );
169    });
170  
171    test('deduplicates repeated error lines', () => {
172      const recentTs = new Date().toISOString();
173      const repeated = `[${recentTs}] [Scoring] [ERROR] Timeout connecting to API`;
174      writeFileSync(testLogFile, [repeated, repeated, repeated, repeated].join('\n'));
175  
176      const result = collectRecentErrors();
177      assert.ok(result.length <= 3, `should deduplicate: ${result.length} entries`);
178    });
179  
180    test('handles lines without timestamp bracket', () => {
181      const recentTs = new Date().toISOString();
182  
183      writeFileSync(
184        testLogFile,
185        [
186          'No timestamp [ERROR] This line has no timestamp bracket',
187          `[${recentTs}] [Pipeline] [ERROR] With timestamp`,
188        ].join('\n')
189      );
190  
191      assert.doesNotThrow(() => collectRecentErrors());
192    });
193  });
194  
195  describe('runSonnetOverseer — deprecated stub', () => {
196    test('returns summary: Delegated to orchestrator', async () => {
197      execSyncFn = () => 'active\n';
198      const result = await runSonnetOverseer();
199      assert.ok(typeof result === 'object', 'should return object');
200      assert.equal(result.summary, 'Delegated to orchestrator');
201      assert.equal(result.severity, 'ok');
202      assert.equal(result.actions_taken, 0);
203    });
204  
205    test('does not throw even when services are inactive', async () => {
206      execSyncFn = () => {
207        throw new Error('service not found');
208      };
209      await assert.doesNotReject(() => runSonnetOverseer());
210    });
211  });