/ tests / helpers / mock-db.js
mock-db.js
  1  /**
  2   * mock-db.js — SQLite-backed mock for src/utils/db.js
  3   *
  4   * Provides a test helper that creates an in-memory SQLite database and
  5   * mocks the pg-based db.js exports so that source files under test
  6   * transparently use SQLite instead of PostgreSQL.
  7   *
  8   * Usage with node:test mock.module():
  9   *
 10   *   import { mock } from 'node:test';
 11   *   import { createMockDb, teardownMockDb } from '../helpers/mock-db.js';
 12   *
 13   *   // Before tests — register the mock BEFORE importing source modules
 14   *   const mockDb = createMockDb();
 15   *   mock.module('../../src/utils/db.js', { namedExports: mockDb.exports });
 16   *
 17   *   // Now import the module under test (it will use the mocked db.js)
 18   *   const { myFunction } = await import('../../src/some-module.js');
 19   *
 20   *   // Access the raw SQLite db for setup/assertions:
 21   *   mockDb.db.exec('INSERT INTO sites ...');
 22   *
 23   *   // After tests
 24   *   teardownMockDb(mockDb);
 25   *
 26   * The mock converts PG-style $1, $2 params to SQLite ? params automatically.
 27   */
 28  
 29  import Database from 'better-sqlite3';
 30  
 31  /**
 32   * Convert PostgreSQL $N placeholders to SQLite ? placeholders.
 33   * Also handles some PG-specific SQL that doesn't work in SQLite.
 34   */
 35  function pgToSqlite(sql) {
 36    // Replace $1, $2, ... with ?
 37    let converted = sql.replace(/\$\d+/g, '?');
 38  
 39    // NOW() → datetime('now')
 40    converted = converted.replace(/\bNOW\(\)/gi, "datetime('now')");
 41  
 42    // INTERVAL 'N days' → '+N days' (within datetime context — crude but works for tests)
 43    converted = converted.replace(
 44      /NOW\(\)\s*-\s*INTERVAL\s+'(\d+)\s+days'/gi,
 45      "datetime('now', '-$1 days')"
 46    );
 47    converted = converted.replace(
 48      /NOW\(\)\s*\+\s*INTERVAL\s+'(\d+)\s+days'/gi,
 49      "datetime('now', '+$1 days')"
 50    );
 51  
 52    // ::jsonb, ::text, ::integer casts — strip them (SQLite ignores types)
 53    converted = converted.replace(/::\w+/g, '');
 54  
 55    // ON CONFLICT (...) DO NOTHING → OR IGNORE (crude but functional)
 56    // Only handle the simple case
 57    converted = converted.replace(
 58      /INSERT\s+INTO\s+(.+?)\s*\((.+?)\)\s*VALUES\s*\((.+?)\)\s*ON\s+CONFLICT\s*(?:\([^)]*\))?\s*DO\s+NOTHING/gi,
 59      'INSERT OR IGNORE INTO $1 ($2) VALUES ($3)'
 60    );
 61  
 62    // ON CONFLICT (...) DO UPDATE SET ... → OR REPLACE (crude approximation)
 63    converted = converted.replace(
 64      /ON\s+CONFLICT\s*\([^)]*\)\s*DO\s+UPDATE\s+SET\s+[^;]*/gi,
 65      ''
 66    );
 67  
 68    // RETURNING id — SQLite doesn't support this; strip it
 69    // The mock run() uses lastInsertRowid instead
 70    converted = converted.replace(/\s+RETURNING\s+\w+/gi, '');
 71  
 72    // lower() for CITEXT comparison — keep as-is, SQLite handles it
 73    // STRING_AGG → GROUP_CONCAT
 74    converted = converted.replace(/\bSTRING_AGG\s*\(/gi, 'GROUP_CONCAT(');
 75  
 76    // Schema prefixes: m333., ops., tel., msgs. — strip them for SQLite
 77    converted = converted.replace(/\b(m333|ops|tel|msgs)\./g, '');
 78  
 79    return converted;
 80  }
 81  
 82  /**
 83   * Create a mock database backed by in-memory SQLite.
 84   * @param {string} [initSql] — optional SQL to create tables
 85   * @returns {{ db: Database, exports: object, addSchema: function }}
 86   */
 87  export function createMockDb(initSql) {
 88    const db = new Database(':memory:');
 89    db.pragma('foreign_keys = ON');
 90  
 91    if (initSql) {
 92      db.exec(initSql);
 93    }
 94  
 95    // The mock exports match the async API of src/utils/db.js
 96    const exports = {
 97      getPool: () => ({ query: async (text, params) => exports.query(text, params) }),
 98  
 99      query: async (text, params) => {
100        const sql = pgToSqlite(text);
101        const paramArray = params || [];
102        try {
103          // Detect if it's a SELECT or RETURNING query
104          const isSelect = /^\s*(SELECT|WITH)\b/i.test(sql.trim());
105          if (isSelect) {
106            const rows = db.prepare(sql).all(...paramArray);
107            return { rows, rowCount: rows.length };
108          } else {
109            const info = db.prepare(sql).run(...paramArray);
110            return {
111              rows: info.lastInsertRowid ? [{ id: Number(info.lastInsertRowid) }] : [],
112              rowCount: info.changes,
113            };
114          }
115        } catch (e) {
116          // Convert SQLite UNIQUE constraint errors to PG-style error codes
117          if (e.message.includes('UNIQUE constraint')) {
118            const pgError = new Error(e.message);
119            pgError.code = '23505'; // unique_violation
120            throw pgError;
121          }
122          if (e.message.includes('FOREIGN KEY constraint')) {
123            const pgError = new Error(e.message);
124            pgError.code = '23503'; // foreign_key_violation
125            throw pgError;
126          }
127          throw e;
128        }
129      },
130  
131      getOne: async (text, params) => {
132        const { rows } = await exports.query(text, params);
133        return rows[0] || null;
134      },
135  
136      getAll: async (text, params) => {
137        const { rows } = await exports.query(text, params);
138        return rows;
139      },
140  
141      run: async (text, params) => {
142        const { rowCount, rows } = await exports.query(text, params);
143        return { changes: rowCount, lastInsertRowid: rows[0]?.id };
144      },
145  
146      withTransaction: async (fn) => {
147        // Use a simple transaction wrapper with the mock client
148        db.exec('BEGIN');
149        const client = {
150          query: async (sql, p) => exports.query(sql, p),
151        };
152        try {
153          const result = await fn(client);
154          db.exec('COMMIT');
155          return result;
156        } catch (e) {
157          db.exec('ROLLBACK');
158          throw e;
159        }
160      },
161  
162      closePool: async () => {},
163      createDatabaseConnection: () => exports.getPool(),
164      closeDatabaseConnection: async () => {},
165    };
166  
167    /**
168     * Execute raw SQL on the underlying SQLite db (for test setup).
169     * Strips schema prefixes for convenience.
170     */
171    function execSetup(sql) {
172      const cleaned = sql.replace(/\b(m333|ops|tel|msgs)\./g, '');
173      db.exec(cleaned);
174    }
175  
176    return { db, exports, execSetup };
177  }
178  
179  /**
180   * Tear down the mock database.
181   */
182  export function teardownMockDb(mockDb) {
183    if (mockDb && mockDb.db) {
184      try { mockDb.db.close(); } catch { /* ignore */ }
185    }
186  }