/ tests / helpers / pg-mock.js
pg-mock.js
  1  /**
  2   * pg-mock.js
  3   *
  4   * Provides `createPgMock(db)` — takes a better-sqlite3 Database instance and returns
  5   * a namedExports object suitable for `mock.module('../../src/utils/db.js', { namedExports })`.
  6   *
  7   * This lets tests stay fast (SQLite in-memory, no real PG needed) while the module under
  8   * test receives its db.js imports via the mock.
  9   *
 10   * SQL translation applied automatically:
 11   *   - $1, $2, ...  →  ?  (positional params stripped of number)
 12   *   - NOW()        →  datetime('now')
 13   *   - CURRENT_DATE →  date('now')
 14   *   - RETURNING id (stripped — better-sqlite3 uses lastInsertRowid)
 15   *   - ON CONFLICT (...) DO UPDATE SET ... (converted to ON CONFLICT DO REPLACE or stripped)
 16   *
 17   * Usage:
 18   *   import { mock } from 'node:test';
 19   *   import Database from 'better-sqlite3';
 20   *   import { createPgMock } from '../helpers/pg-mock.js';
 21   *
 22   *   const db = new Database(':memory:');
 23   *   db.exec(`CREATE TABLE opt_outs (...)`);
 24   *
 25   *   mock.module('../../src/utils/db.js', {
 26   *     namedExports: createPgMock(db),
 27   *   });
 28   *
 29   *   // AFTER mock.module, import the module under test:
 30   *   const { addOptOut } = await import('../../src/utils/compliance.js');
 31   */
 32  
 33  /**
 34   * Convert PostgreSQL-style SQL to SQLite-compatible SQL.
 35   *
 36   * @param {string} sql
 37   * @returns {string}
 38   */
 39  export function pgToSqlite(sql) {
 40    const translated = sql
 41      // $1, $2, ... → ?
 42      .replace(/\$\d+/g, '?')
 43      // Strip schema-qualified table names: tel.table → table, ops.table → table
 44      // Covers schemas used by process-guardian (tel., ops.) and weekly-learning-report (ops.)
 45      .replace(/\b(?:tel|ops)\./g, '')
 46      // CURRENT_TIMESTAMP in DEFAULT clause → (datetime('now')) — SQLite requires parens for function defaults
 47      .replace(/\bDEFAULT\s+CURRENT_TIMESTAMP\b/gi, "DEFAULT (datetime('now'))")
 48      // NOW() in DEFAULT clause → (datetime('now')) — must wrap in parens for SQLite DDL
 49      .replace(/\bDEFAULT\s+NOW\(\)/gi, "DEFAULT (datetime('now'))")
 50      // NOW() elsewhere → datetime('now')
 51      .replace(/\bNOW\(\)/gi, "datetime('now')")
 52      // CURRENT_DATE → date('now')
 53      .replace(/\bCURRENT_DATE\b/gi, "date('now')")
 54      // EXTRACT(EPOCH FROM (expr1 - expr2)) → (julianday(expr1) - julianday(expr2)) * 86400
 55      // Handles timestamp difference in seconds (PostgreSQL-specific)
 56      // Uses a two-pass approach: first translate NOW() inside EXTRACT, then translate EXTRACT
 57      .replace(
 58        /EXTRACT\s*\(\s*EPOCH\s+FROM\s*\((.+?)\)\s*\)/gi,
 59        (_, inner) => {
 60          // inner is "expr1 - expr2"; split on last occurrence of ' - '
 61          const dashIdx = inner.lastIndexOf(' - ');
 62          if (dashIdx === -1) return `((julianday(${inner.trim()}) - julianday('1970-01-01')) * 86400)`;
 63          const e1 = inner.slice(0, dashIdx).trim().replace(/\bNOW\(\)/gi, "datetime('now')");
 64          const e2 = inner.slice(dashIdx + 3).trim().replace(/\bNOW\(\)/gi, "datetime('now')");
 65          return `((julianday(${e1}) - julianday(${e2})) * 86400)`;
 66        }
 67      )
 68      // EXTRACT(EPOCH FROM expr) without leading explicit parens
 69      // (e.g. EXTRACT(EPOCH FROM AVG(l.finished_at - l.started_at)))
 70      // Replace with 0 using a manual balanced-paren scan on the full string.
 71      // INTERVAL arithmetic: expr ± INTERVAL 'N unit' → datetime(expr, '±N unit')
 72      // Handles: NOW() - INTERVAL '3 minutes', created_at > NOW() - INTERVAL '7 days',
 73      //          NOW() + INTERVAL '30 days', etc.
 74      .replace(
 75        /datetime\('now'\)\s*-\s*INTERVAL\s*'([^']+)'/gi,
 76        (_, interval) => `datetime('now', '-${interval}')`
 77      )
 78      .replace(
 79        /NOW\(\)\s*-\s*INTERVAL\s*'([^']+)'/gi,
 80        (_, interval) => `datetime('now', '-${interval}')`
 81      )
 82      .replace(
 83        /datetime\('now'\)\s*\+\s*INTERVAL\s*'([^']+)'/gi,
 84        (_, interval) => `datetime('now', '+${interval}')`
 85      )
 86      .replace(
 87        /NOW\(\)\s*\+\s*INTERVAL\s*'([^']+)'/gi,
 88        (_, interval) => `datetime('now', '+${interval}')`
 89      )
 90      // Dynamic interval: (? || ' hours')::interval, (? || ' minutes')::interval, etc.
 91      // Translate by keeping only the ? value; the datetime() modifier will be applied around it.
 92      // Pattern: datetime('now') - (? || ' hours')::interval → datetime('now', '-' || ? || ' hours')
 93      // We match: datetime('now') - (? || ' unit')::interval (after NOW() was already translated)
 94      .replace(
 95        /datetime\('now'\)\s*-\s*\(\?\s*\|\|\s*'(\s*\w+)'\)::interval/gi,
 96        (_, unit) => `datetime('now', '-' || ? || '${unit}')`
 97      )
 98      .replace(
 99        /datetime\('now'\)\s*\+\s*\(\?\s*\|\|\s*'(\s*\w+)'\)::interval/gi,
100        (_, unit) => `datetime('now', '+' || ? || '${unit}')`
101      )
102      // DDL type translations (for CREATE TABLE statements passed through pg-mock)
103      // SERIAL / BIGSERIAL → INTEGER (SQLite auto-increments INTEGER PRIMARY KEY)
104      .replace(/\bBIGSERIAL\b/gi, 'INTEGER')
105      .replace(/\bSERIAL\b/gi, 'INTEGER')
106      // TIMESTAMPTZ, TIMESTAMP WITH TIME ZONE → DATETIME
107      .replace(/\bTIMESTAMP\s+WITH\s+TIME\s+ZONE\b/gi, 'DATETIME')
108      .replace(/\bTIMESTAMPTZ\b/gi, 'DATETIME')
109      // NOTE: bare TIMESTAMP → DATETIME replacement is handled AFTER the chain
110      // because it needs to be context-aware (DDL only, not DML column names).
111      // BOOLEAN → INTEGER (SQLite uses 0/1)
112      .replace(/\bBOOLEAN\b/gi, 'INTEGER')
113      // VARCHAR(n) → TEXT
114      .replace(/\bVARCHAR\s*\(\d+\)/gi, 'TEXT')
115      // Generic ::type cast removal (e.g., ::interval, ::text, ::integer, ::jsonb)
116      // Must come AFTER specific patterns that use :: for meaningful translation
117      .replace(/::\w+(?:\[\])?/g, '')
118      // Param/column ± INTERVAL 'N unit' — must come AFTER ::type cast removal
119      // e.g., $3::timestamptz - INTERVAL '5 minutes' → ? - INTERVAL '5 minutes' → datetime(?, '-5 minutes')
120      // Handles both ? (parameter) and column_name (identifier)
121      .replace(
122        /(\?|[\w.]+)\s*-\s*INTERVAL\s*'([^']+)'/gi,
123        (_, expr, interval) => `datetime(${expr}, '-${interval}')`
124      )
125      .replace(
126        /(\?|[\w.]+)\s*\+\s*INTERVAL\s*'([^']+)'/gi,
127        (_, expr, interval) => `datetime(${expr}, '+${interval}')`
128      )
129      // Standalone INTERVAL 'N unit' (e.g. in CASE THEN INTERVAL '1 day') →
130      // SQLite datetime modifier string '-N unit' so date arithmetic still compiles.
131      // This covers patterns like: expr >= NOW() - (CASE WHEN ... THEN INTERVAL '1 day' END)
132      // We convert the whole `expr >= datetime('now') - (CASE ...)` to a date modifier string
133      // by replacing bare INTERVAL literals with their negative modifier.
134      // Pattern: INTERVAL 'N unit' (not preceded by - or + which are already handled above)
135      .replace(/\bINTERVAL\s+'([^']+)'/gi, (_, interval) => `'-${interval}'`)
136      // STRING_AGG(expr, sep) → GROUP_CONCAT(expr, sep)
137      .replace(/\bSTRING_AGG\s*\(/gi, 'GROUP_CONCAT(')
138      // col::jsonb->>'key' → json_extract(col, '$.key')
139      // Simple case only — complex JSONB queries should be mocked at a higher level
140      .replace(/(\w+)::jsonb->>'(\w+)'/g, "json_extract($1, '\\$$2')")
141      // col = ANY($1::text[]) / col = ANY($1::int[]) → col ANY_EXPAND(?)
142      // The execution layer will expand array params into IN (?, ?, ...) at runtime.
143      // The leading `=` is consumed here so the expansion yields valid `col IN (...)` not `col = IN (...)`.
144      .replace(/=\s*ANY\(\?::(?:text|int|bigint|uuid)\[\]\)/gi, 'ANY_EXPAND(?)')
145      .replace(/=\s*ANY\(\?\)/gi, 'ANY_EXPAND(?)')
146      // Strip RETURNING clause entirely (SQLite uses lastInsertRowid)
147      .replace(/\bRETURNING\s+\w+(\s*,\s*\w+)*/gi, '')
148      // ON CONFLICT (...) WHERE condition DO UPDATE SET ... — conditional conflict (PG partial index)
149      // SQLite doesn't support WHERE on the conflict target; strip the entire ON CONFLICT block.
150      // Must run AFTER RETURNING is already stripped so we can match to end-of-string or semicolon.
151      .replace(/\s*ON CONFLICT\s*\([^)]+\)\s*WHERE\s+[\s\S]+?(?=\s*RETURNING\b|\s*;|\s*$)/gi, '')
152      // ON CONFLICT (...) DO NOTHING — strip the clause but convert INSERT to INSERT OR IGNORE
153      // so SQLite honours the "skip duplicate" intent without a UNIQUE constraint error.
154      // We do a two-step: first mark the INSERT, then strip the conflict clause.
155      .replace(/\bINSERT\b(?=[\s\S]*?ON CONFLICT\s*\([^)]+\)\s*DO NOTHING)/gi, 'INSERT OR IGNORE')
156      .replace(/ON CONFLICT\s*\([^)]+\)\s*DO NOTHING/gi, '')
157      // ON CONFLICT (...) DO UPDATE SET col = excluded.col
158      // Convert to ON CONFLICT DO REPLACE when it's a simple upsert
159      .replace(
160        /ON CONFLICT\s*\([^)]+\)\s*DO UPDATE SET\s+([^;]+?)(?=\s*(WHERE|RETURNING|;|$))/gi,
161        (match, setClauses) => {
162          // If the SET clauses only reference EXCLUDED.*, convert to INSERT OR REPLACE
163          // Otherwise keep partial — SQLite 3.24+ supports upsert natively with ON CONFLICT
164          return `ON CONFLICT DO UPDATE SET ${setClauses}`;
165        }
166      )
167      // ILIKE → LIKE (SQLite is case-insensitive for ASCII by default)
168      .replace(/\bILIKE\b/gi, 'LIKE')
169      // Boolean literals in WHERE/SET: = true → = 1, = false → = 0, IS true → IS 1, etc.
170      .replace(/=\s*\btrue\b/gi, '= 1')
171      .replace(/=\s*\bfalse\b/gi, '= 0')
172      .replace(/\bIS\s+true\b/gi, '= 1')
173      .replace(/\bIS\s+false\b/gi, '= 0')
174      // lower(col) = lower(?) is fine in both — no translation needed
175      .trim();
176  
177    // Post-process: replace any remaining EXTRACT(EPOCH FROM ...) that has a function call
178    // as the argument (e.g. EXTRACT(EPOCH FROM AVG(...))). The regex chain above handles the
179    // `(expr1 - expr2)` form; this handles the bare function-call form.
180    // We use a character-scan approach to find balanced parens.
181    let result = replaceExtractEpochFunctions(translated);
182  
183    // Post-process: TIMESTAMP → DATETIME only in DDL context (CREATE TABLE).
184    // In DML queries, "timestamp" is a column name and must be left alone.
185    // We detect DDL by checking if the SQL starts with CREATE.
186    if (/^\s*CREATE\b/i.test(result)) {
187      result = result.replace(/\bTIMESTAMP\b/gi, 'DATETIME');
188    }
189  
190    return result;
191  }
192  
193  /**
194   * Replace EXTRACT(EPOCH FROM funcCall(...)) with 0 using a balanced-paren scan.
195   * Only fires when the pattern is still present after the regex chain above.
196   */
197  function replaceExtractEpochFunctions(sql) {
198    const pattern = /EXTRACT\s*\(\s*EPOCH\s+FROM\s+/gi;
199    let result = '';
200    let lastIdx = 0;
201    let match;
202    pattern.lastIndex = 0;
203    while ((match = pattern.exec(sql)) !== null) {
204      result += sql.slice(lastIdx, match.index);
205      // Find the matching close paren of EXTRACT(
206      let depth = 1;
207      let i = match.index + match[0].length;
208      while (i < sql.length && depth > 0) {
209        if (sql[i] === '(') depth++;
210        else if (sql[i] === ')') depth--;
211        i++;
212      }
213      // i now points to char after the closing paren of EXTRACT(...)
214      result += '0';
215      lastIdx = i;
216      pattern.lastIndex = i; // skip past the replaced span
217    }
218    result += sql.slice(lastIdx);
219    return result;
220  }
221  
222  /**
223   * Create a pg-mock namedExports object backed by a real SQLite database.
224   *
225   * All async functions (run, getOne, getAll, query) translate PG SQL to SQLite
226   * and execute against the provided `db` instance.
227   *
228   * withTransaction provides a fake client that also routes to SQLite.
229   *
230   * @param {import('better-sqlite3').Database} db
231   * @returns {object} namedExports for mock.module('../../src/utils/db.js', ...)
232   */
233  export function createPgMock(db) {
234  
235    /**
236     * SQLite does not accept JavaScript booleans — coerce true/false to 1/0.
237     * All other types (string, number, bigint, Buffer, null) pass through unchanged.
238     */
239    function coerceParams(params) {
240      return params.map(p => (typeof p === 'boolean' ? (p ? 1 : 0) : p));
241    }
242  
243    /**
244     * Expand ANY_EXPAND(?) placeholders by replacing them with IN (?, ?, ...) and
245     * flattening array params at those positions.
246     * Returns [expandedSql, expandedParams].
247     */
248    function expandAnyPlaceholders(sql, params) {
249      if (!sql.includes('ANY_EXPAND(?)')) {
250        return [sql, params];
251      }
252      let paramIdx = 0;
253      const expandedParams = [];
254      const expandedSql = sql.replace(/\?|ANY_EXPAND\(\?\)/g, match => {
255        const param = params[paramIdx++];
256        if (match === 'ANY_EXPAND(?)') {
257          if (Array.isArray(param)) {
258            expandedParams.push(...param);
259            return `IN (${param.map(() => '?').join(', ')})`;
260          } else {
261            // Scalar — treat as single-element IN
262            expandedParams.push(param);
263            return 'IN (?)';
264          }
265        } else {
266          expandedParams.push(param);
267          return '?';
268        }
269      });
270      return [expandedSql, expandedParams];
271    }
272  
273    async function query(sql, params = []) {
274      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
275      const stmt = db.prepare(expanded);
276      const trimmed = expanded.trim().toUpperCase();
277      const bound = coerceParams(expandedParams);
278  
279      if (
280        trimmed.startsWith('SELECT') ||
281        trimmed.startsWith('WITH') ||
282        trimmed.startsWith('PRAGMA')
283      ) {
284        const rows = stmt.all(...bound);
285        return { rows, rowCount: rows.length };
286      } else {
287        const result = stmt.run(...bound);
288        const rows = result.lastInsertRowid !== null && result.lastInsertRowid !== undefined
289          ? [{ id: result.lastInsertRowid }]
290          : [];
291        return { rows, rowCount: result.changes };
292      }
293    }
294  
295    async function getOne(sql, params = []) {
296      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
297      const stmt = db.prepare(expanded);
298      return stmt.get(...coerceParams(expandedParams)) || null;
299    }
300  
301    async function getAll(sql, params = []) {
302      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
303      const stmt = db.prepare(expanded);
304      return stmt.all(...coerceParams(expandedParams));
305    }
306  
307    async function run(sql, params = []) {
308      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
309      const stmt = db.prepare(expanded);
310      const result = stmt.run(...coerceParams(expandedParams));
311      return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
312    }
313  
314    async function withTransaction(fn) {
315      // Wrap in a SQLite transaction
316      let result;
317      db.transaction(() => {
318        // Provide a fake pg client-like object with a query method
319        const fakeClient = {
320          query: async (sql, params = []) => {
321            const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
322            const stmt = db.prepare(expanded);
323            const trimmed = expanded.trim().toUpperCase();
324            const bound = coerceParams(expandedParams);
325            if (trimmed.startsWith('SELECT') || trimmed.startsWith('WITH')) {
326              const rows = stmt.all(...bound);
327              return { rows, rowCount: rows.length };
328            } else {
329              const r = stmt.run(...bound);
330              const hasId = r.lastInsertRowid !== null && r.lastInsertRowid !== undefined;
331              return { rows: hasId ? [{ id: r.lastInsertRowid }] : [], rowCount: r.changes };
332            }
333          },
334        };
335        result = fn(fakeClient);
336      })();
337      // fn may return a promise — await it
338      return result;
339    }
340  
341    return {
342      getPool: () => ({}),
343      query,
344      getOne,
345      getAll,
346      run,
347      withTransaction,
348      closePool: async () => {},
349      createDatabaseConnection: () => ({}),
350      closeDatabaseConnection: async () => {},
351    };
352  }
353  
354  /**
355   * Create a pg-mock namedExports object backed by a *lazily-resolved* SQLite database.
356   *
357   * Unlike `createPgMock(db)`, this variant accepts a getter function `getDb` that is
358   * called on every SQL operation.  This lets tests swap their per-test database in a
359   * `beforeEach` hook while keeping a single `mock.module('db.js', ...)` call at the
360   * top of the test file.
361   *
362   * Usage:
363   *   let currentDb = null;
364   *   mock.module('../../src/utils/db.js', {
365   *     namedExports: createLazyPgMock(() => currentDb),
366   *   });
367   *
368   *   beforeEach(() => {
369   *     currentDb = new Database(':memory:');
370   *     currentDb.exec(schema);
371   *   });
372   *   afterEach(() => { currentDb.close(); currentDb = null; });
373   *
374   * @param {() => import('better-sqlite3').Database} getDb  Getter that returns the current DB.
375   * @returns {object} namedExports for mock.module('../../src/utils/db.js', ...)
376   */
377  export function createLazyPgMock(getDb) {
378    function coerceParams(params) {
379      return params.map(p => (typeof p === 'boolean' ? (p ? 1 : 0) : p));
380    }
381  
382    function expandAnyPlaceholders(sql, params) {
383      if (!sql.includes('ANY_EXPAND(?)')) return [sql, params];
384      let paramIdx = 0;
385      const expandedParams = [];
386      const expandedSql = sql.replace(/\?|ANY_EXPAND\(\?\)/g, match => {
387        const param = params[paramIdx++];
388        if (match === 'ANY_EXPAND(?)') {
389          if (Array.isArray(param)) {
390            expandedParams.push(...param);
391            return `IN (${param.map(() => '?').join(', ')})`;
392          }
393          expandedParams.push(param);
394          return 'IN (?)';
395        }
396        expandedParams.push(param);
397        return '?';
398      });
399      return [expandedSql, expandedParams];
400    }
401  
402    async function query(sql, params = []) {
403      const db = getDb();
404      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
405      const stmt = db.prepare(expanded);
406      const trimmed = expanded.trim().toUpperCase();
407      const bound = coerceParams(expandedParams);
408      if (trimmed.startsWith('SELECT') || trimmed.startsWith('WITH') || trimmed.startsWith('PRAGMA')) {
409        const rows = stmt.all(...bound);
410        return { rows, rowCount: rows.length };
411      }
412      const result = stmt.run(...bound);
413      const rows =
414        result.lastInsertRowid !== null && result.lastInsertRowid !== undefined
415          ? [{ id: result.lastInsertRowid }]
416          : [];
417      return { rows, rowCount: result.changes };
418    }
419  
420    async function getOne(sql, params = []) {
421      const db = getDb();
422      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
423      return db.prepare(expanded).get(...coerceParams(expandedParams)) || null;
424    }
425  
426    async function getAll(sql, params = []) {
427      const db = getDb();
428      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
429      return db.prepare(expanded).all(...coerceParams(expandedParams));
430    }
431  
432    async function run(sql, params = []) {
433      const db = getDb();
434      const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql), params);
435      const stmt = db.prepare(expanded);
436      const result = stmt.run(...coerceParams(expandedParams));
437      return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
438    }
439  
440    async function withTransaction(fn) {
441      const db = getDb();
442      let result;
443      db.transaction(() => {
444        const fakeClient = {
445          query: async (sql2, params2 = []) => {
446            const [expanded, expandedParams] = expandAnyPlaceholders(pgToSqlite(sql2), params2);
447            const stmt = db.prepare(expanded);
448            const trimmed = expanded.trim().toUpperCase();
449            const bound = coerceParams(expandedParams);
450            if (trimmed.startsWith('SELECT') || trimmed.startsWith('WITH')) {
451              const rows = stmt.all(...bound);
452              return { rows, rowCount: rows.length };
453            }
454            const r = stmt.run(...bound);
455            const hasId = r.lastInsertRowid !== null && r.lastInsertRowid !== undefined;
456            return { rows: hasId ? [{ id: r.lastInsertRowid }] : [], rowCount: r.changes };
457          },
458        };
459        result = fn(fakeClient);
460      })();
461      return result;
462    }
463  
464    return {
465      getPool: () => ({}),
466      query,
467      getOne,
468      getAll,
469      run,
470      withTransaction,
471      closePool: async () => {},
472      createDatabaseConnection: () => ({}),
473      closeDatabaseConnection: async () => {},
474    };
475  }