/ tests / cron / precompute-dashboard.test.js
precompute-dashboard.test.js
  1  /**
  2   * Tests for src/cron/precompute-dashboard.js
  3   *
  4   * precomputeDashboard() runs all cache-building queries and stores results
  5   * in the dashboard_cache table. Tests use an in-memory SQLite with the full schema
  6   * via pg-mock so writes are captured in the same db.
  7   */
  8  
  9  import { test, describe, mock } from 'node:test';
 10  import assert from 'node:assert/strict';
 11  import Database from 'better-sqlite3';
 12  import { join } from 'path';
 13  import { readFileSync } from 'fs';
 14  import { createPgMock } from '../helpers/pg-mock.js';
 15  
 16  // Create in-memory DB with full schema
 17  const db = new Database(':memory:');
 18  const schemaPath = join(import.meta.dirname, '..', '..', 'db', 'schema.sql');
 19  const schema = readFileSync(schemaPath, 'utf-8');
 20  // schema.sql may have PG-specific syntax; strip it down to just table definitions
 21  // that SQLite can handle. If exec fails on the full schema, try just creating core tables.
 22  try {
 23    db.exec(schema);
 24  } catch {
 25    // If full schema fails (PG-specific DDL), create minimal tables needed
 26    db.exec(`
 27      CREATE TABLE IF NOT EXISTS sites (id INTEGER PRIMARY KEY, domain TEXT, status TEXT, score REAL, grade TEXT, country_code TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME, rescored_at DATETIME, keyword TEXT, landing_page_url TEXT);
 28      CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, site_id INTEGER, contact_method TEXT, delivery_status TEXT, approval_status TEXT, sent_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, direction TEXT DEFAULT 'outbound', message_type TEXT DEFAULT 'outreach');
 29      CREATE TABLE IF NOT EXISTS cron_jobs (id INTEGER PRIMARY KEY, task_key TEXT UNIQUE, interval_value INTEGER, interval_unit TEXT, last_run_at DATETIME, enabled INTEGER DEFAULT 1, status TEXT DEFAULT 'idle');
 30      CREATE TABLE IF NOT EXISTS cron_job_logs (id INTEGER PRIMARY KEY, task_key TEXT, status TEXT, duration_ms INTEGER, summary TEXT, metrics TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
 31      CREATE TABLE IF NOT EXISTS llm_usage (id INTEGER PRIMARY KEY, stage TEXT, tokens_in INTEGER DEFAULT 0, tokens_out INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0, model TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
 32      CREATE TABLE IF NOT EXISTS agent_tasks (id INTEGER PRIMARY KEY, assigned_to TEXT, status TEXT, task_type TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
 33      CREATE TABLE IF NOT EXISTS dashboard_cache (cache_key TEXT PRIMARY KEY, cache_value TEXT, computed_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME);
 34    `);
 35  }
 36  
 37  // Ensure dashboard_cache exists (may not be in older schema)
 38  try {
 39    db.exec(`CREATE TABLE IF NOT EXISTS dashboard_cache (cache_key TEXT PRIMARY KEY, cache_value TEXT, computed_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME)`);
 40  } catch { /* already exists */ }
 41  
 42  // Custom db.js mock that handles PG-specific queries
 43  // precompute-dashboard.js uses many PG-only SQL constructs:
 44  //   - pg_database_size(current_database()), pg_tables
 45  //   - date_trunc(), to_char(), EXTRACT(EPOCH FROM AVG(...))
 46  //   - ::date, ::numeric casts (stripped by pg-mock, but date_trunc/to_char still fail)
 47  // Strategy: intercept any query with unsupported PG syntax and return empty/default values.
 48  // The dashboard_cache upserts go through run() which does NOT contain PG-specific syntax,
 49  // so cache writes still succeed.
 50  
 51  function hasPgOnlySyntax(sql) {
 52    return (
 53      sql.includes('pg_database_size') ||
 54      sql.includes('current_database()') ||
 55      sql.includes('pg_tables') ||
 56      sql.includes('information_schema') ||
 57      sql.includes('date_trunc') ||
 58      sql.includes('to_char') ||
 59      // EXTRACT(EPOCH FROM avg/sum/col) — not a simple column difference
 60      /EXTRACT\s*\(\s*EPOCH\s+FROM\s+(?:AVG|SUM|MIN|MAX)\s*\(/i.test(sql) ||
 61      // ROUND(...::numeric ...) — can appear after :: cast strip still leaves ROUND issues
 62      /ROUND\s*\([^)]*::numeric/i.test(sql)
 63    );
 64  }
 65  
 66  const pgMock = createPgMock(db);
 67  const origGetOne = pgMock.getOne;
 68  const origGetAll = pgMock.getAll;
 69  mock.module('../../src/utils/db.js', {
 70    namedExports: {
 71      ...pgMock,
 72      getOne: async (sql, params = []) => {
 73        if (hasPgOnlySyntax(sql)) {
 74          // Return sensible defaults so callers don't crash on null access
 75          if (sql.includes('pg_database_size')) return { size_bytes: 1024 * 1024 * 50 };
 76          if (sql.includes('avg_cost') || sql.includes('daily_cost')) return { avg_cost: 0 };
 77          if (sql.includes('avg_hours') || sql.includes('avg_duration')) return null;
 78          if (sql.includes('expires_at')) return null; // cost_forecast cache check
 79          return null;
 80        }
 81        return origGetOne(sql, params);
 82      },
 83      getAll: async (sql, params = []) => {
 84        if (hasPgOnlySyntax(sql)) {
 85          return [];
 86        }
 87        return origGetAll(sql, params);
 88      },
 89    },
 90  });
 91  
 92  const { precomputeDashboard } = await import('../../src/cron/precompute-dashboard.js');
 93  
 94  describe('precomputeDashboard', () => {
 95    test('runs without error on empty database', async () => {
 96      await assert.doesNotReject(precomputeDashboard());
 97    });
 98  
 99    test('populates dashboard_cache table with keys', async () => {
100      await precomputeDashboard();
101  
102      const rows = db.prepare('SELECT cache_key FROM dashboard_cache').all();
103  
104      assert.ok(rows.length > 0, 'should have cached some metrics');
105      const keys = rows.map(r => r.cache_key);
106      assert.ok(
107        keys.some(
108          k =>
109            k.includes('pipeline') || k.includes('funnel') || k.includes('llm') || k.includes('cron')
110        ),
111        `should have at least one recognizable key, got: ${keys.slice(0, 5).join(', ')}`
112      );
113    });
114  
115    test('cache values are valid JSON', async () => {
116      await precomputeDashboard();
117  
118      const rows = db.prepare('SELECT cache_key, cache_value FROM dashboard_cache').all();
119  
120      for (const row of rows) {
121        assert.doesNotThrow(
122          () => JSON.parse(row.cache_value),
123          `cache_value for key "${row.cache_key}" should be valid JSON`
124        );
125      }
126    });
127  
128    test('cache entries have expires_at set', async () => {
129      await precomputeDashboard();
130  
131      const rows = db.prepare('SELECT cache_key, expires_at FROM dashboard_cache').all();
132  
133      for (const row of rows) {
134        assert.ok(row.expires_at, `expires_at should be set for key "${row.cache_key}"`);
135      }
136    });
137  
138    test('can run twice without error (upsert behavior)', async () => {
139      await precomputeDashboard();
140      await assert.doesNotReject(precomputeDashboard());
141    });
142  
143    test('returns a result object', async () => {
144      const result = await precomputeDashboard();
145      // precomputeDashboard may return undefined or a result object — just shouldn't throw
146      assert.ok(result === undefined || typeof result === 'object');
147    });
148  });