/ tests / utils / keyword-counters.test.js
keyword-counters.test.js
  1  /**
  2   * Keyword Counter Tests
  3   *
  4   * Tests for the keyword analytics counters that track pipeline progress.
  5   * Each stage increments a counter (assets_scraped, low_scoring, rescored)
  6   * to measure keyword performance.
  7   *
  8   * Uses a spy mock for db.js run() to capture SQL and params without needing
  9   * a real database.
 10   */
 11  
 12  import { test, describe, mock } from 'node:test';
 13  import assert from 'node:assert/strict';
 14  
 15  // ─── Spy on db.js run() calls ─────────────────────────────────────────────────
 16  
 17  const runCalls = [];
 18  let runThrows = false;
 19  
 20  const mockRun = mock.fn(async (sql, params) => {
 21    if (runThrows) throw new Error('DB write failed');
 22    runCalls.push({ sql, params: params || [] });
 23    return { changes: 1 };
 24  });
 25  
 26  mock.module('../../src/utils/db.js', {
 27    namedExports: {
 28      run: mockRun,
 29      getOne: mock.fn(async () => null),
 30      getAll: mock.fn(async () => []),
 31      query: mock.fn(async () => ({ rows: [], rowCount: 0 })),
 32      withTransaction: mock.fn(async fn => fn({ query: async () => ({ rows: [] }) })),
 33      closePool: mock.fn(async () => {}),
 34      getPool: () => ({}),
 35      createDatabaseConnection: () => ({}),
 36      closeDatabaseConnection: mock.fn(async () => {}),
 37    },
 38  });
 39  
 40  mock.module('../../src/utils/logger.js', {
 41    defaultExport: class {
 42      info() {}
 43      warn() {}
 44      error() {}
 45      success() {}
 46      debug() {}
 47    },
 48  });
 49  
 50  const {
 51    incrementAssetsScraped,
 52    incrementLowScoring,
 53    incrementRescored,
 54    batchIncrementAssetsScraped,
 55    batchIncrementLowScoring,
 56    batchIncrementRescored,
 57  } = await import('../../src/utils/keyword-counters.js');
 58  
 59  function resetCalls() {
 60    runCalls.length = 0;
 61    runThrows = false;
 62    mockRun.mock.resetCalls();
 63  }
 64  
 65  // ─── Single increments ────────────────────────────────────────────────────────
 66  
 67  describe('incrementAssetsScraped', () => {
 68    test('runs UPDATE with correct keyword and country_code', async () => {
 69      resetCalls();
 70      await incrementAssetsScraped('plumber', 'AU');
 71  
 72      assert.equal(runCalls.length, 1);
 73      assert.ok(runCalls[0].sql.includes('assets_scraped_count = assets_scraped_count + 1'));
 74      assert.deepEqual(runCalls[0].params, ['plumber', 'AU']);
 75    });
 76  
 77    test('does not throw when DB write fails (logs warning)', async () => {
 78      resetCalls();
 79      runThrows = true;
 80      await assert.doesNotReject(() => incrementAssetsScraped('plumber', 'AU'));
 81    });
 82  });
 83  
 84  describe('incrementLowScoring', () => {
 85    test('runs UPDATE with correct keyword and country_code', async () => {
 86      resetCalls();
 87      await incrementLowScoring('electrician', 'US');
 88  
 89      assert.equal(runCalls.length, 1);
 90      assert.ok(runCalls[0].sql.includes('low_scoring_count = low_scoring_count + 1'));
 91      assert.deepEqual(runCalls[0].params, ['electrician', 'US']);
 92    });
 93  
 94    test('does not throw when DB write fails', async () => {
 95      resetCalls();
 96      runThrows = true;
 97      await assert.doesNotReject(() => incrementLowScoring('electrician', 'US'));
 98    });
 99  });
100  
101  describe('incrementRescored', () => {
102    test('runs UPDATE with correct keyword and country_code', async () => {
103      resetCalls();
104      await incrementRescored('dentist', 'GB');
105  
106      assert.equal(runCalls.length, 1);
107      assert.ok(runCalls[0].sql.includes('rescored_count = rescored_count + 1'));
108      assert.deepEqual(runCalls[0].params, ['dentist', 'GB']);
109    });
110  
111    test('does not throw when DB write fails', async () => {
112      resetCalls();
113      runThrows = true;
114      await assert.doesNotReject(() => incrementRescored('dentist', 'GB'));
115    });
116  });
117  
118  // ─── Batch increments ─────────────────────────────────────────────────────────
119  
120  describe('batchIncrementAssetsScraped', () => {
121    test('increments once per site with keyword and country_code', async () => {
122      resetCalls();
123      const sites = [
124        { keyword: 'plumber', country_code: 'AU' },
125        { keyword: 'electrician', country_code: 'US' },
126        { keyword: 'dentist', country_code: 'GB' },
127      ];
128  
129      await batchIncrementAssetsScraped(sites);
130  
131      assert.equal(runCalls.length, 3);
132      assert.deepEqual(runCalls[0].params, ['plumber', 'AU']);
133      assert.deepEqual(runCalls[1].params, ['electrician', 'US']);
134      assert.deepEqual(runCalls[2].params, ['dentist', 'GB']);
135    });
136  
137    test('skips sites without keyword', async () => {
138      resetCalls();
139      const sites = [
140        { country_code: 'AU' }, // no keyword
141        { keyword: 'plumber', country_code: 'AU' },
142      ];
143  
144      await batchIncrementAssetsScraped(sites);
145      assert.equal(runCalls.length, 1, 'Should only update site with keyword');
146      assert.deepEqual(runCalls[0].params, ['plumber', 'AU']);
147    });
148  
149    test('skips sites without country_code', async () => {
150      resetCalls();
151      const sites = [
152        { keyword: 'plumber' }, // no country_code
153        { keyword: 'dentist', country_code: 'NZ' },
154      ];
155  
156      await batchIncrementAssetsScraped(sites);
157      assert.equal(runCalls.length, 1);
158      assert.deepEqual(runCalls[0].params, ['dentist', 'NZ']);
159    });
160  
161    test('handles empty array without error', async () => {
162      resetCalls();
163      await assert.doesNotReject(() => batchIncrementAssetsScraped([]));
164      assert.equal(runCalls.length, 0);
165    });
166  
167    test('handles null input without error', async () => {
168      resetCalls();
169      await assert.doesNotReject(() => batchIncrementAssetsScraped(null));
170      assert.equal(runCalls.length, 0);
171    });
172  
173    test('continues processing remaining sites when one update fails', async () => {
174      resetCalls();
175      let callCount = 0;
176      mockRun.mock.mockImplementation(async (sql, params) => {
177        callCount++;
178        if (callCount === 1) throw new Error('First update failed');
179        runCalls.push({ sql, params: params || [] });
180        return { changes: 1 };
181      });
182  
183      const sites = [
184        { keyword: 'fail', country_code: 'AU' },
185        { keyword: 'ok', country_code: 'US' },
186      ];
187  
188      await assert.doesNotReject(() => batchIncrementAssetsScraped(sites));
189      assert.equal(callCount, 2, 'Both sites should be attempted');
190  
191      // Restore normal mock
192      mockRun.mock.mockImplementation(async (sql, params) => {
193        if (runThrows) throw new Error('DB write failed');
194        runCalls.push({ sql, params: params || [] });
195        return { changes: 1 };
196      });
197    });
198  
199    test('uses assets_scraped_count column', async () => {
200      resetCalls();
201      await batchIncrementAssetsScraped([{ keyword: 'x', country_code: 'AU' }]);
202      assert.ok(runCalls[0].sql.includes('assets_scraped_count'));
203    });
204  });
205  
206  describe('batchIncrementLowScoring', () => {
207    test('increments once per site', async () => {
208      resetCalls();
209      const sites = [
210        { keyword: 'plumber', country_code: 'AU' },
211        { keyword: 'plumber', country_code: 'AU' }, // same keyword twice (separate sites)
212      ];
213  
214      await batchIncrementLowScoring(sites);
215  
216      assert.equal(runCalls.length, 2);
217      assert.ok(runCalls[0].sql.includes('low_scoring_count'));
218    });
219  
220    test('skips sites missing keyword or country_code', async () => {
221      resetCalls();
222      await batchIncrementLowScoring([{ keyword: 'x' }, { country_code: 'AU' }]);
223      assert.equal(runCalls.length, 0);
224    });
225  
226    test('handles empty array', async () => {
227      resetCalls();
228      await assert.doesNotReject(() => batchIncrementLowScoring([]));
229    });
230  });
231  
232  describe('batchIncrementRescored', () => {
233    test('increments rescored_count for each valid site', async () => {
234      resetCalls();
235      await batchIncrementRescored([
236        { keyword: 'dentist', country_code: 'CA' },
237        { keyword: 'lawyer', country_code: 'IE' },
238      ]);
239  
240      assert.equal(runCalls.length, 2);
241      assert.ok(runCalls[0].sql.includes('rescored_count'));
242      assert.deepEqual(runCalls[0].params, ['dentist', 'CA']);
243      assert.deepEqual(runCalls[1].params, ['lawyer', 'IE']);
244    });
245  
246    test('handles null input gracefully', async () => {
247      resetCalls();
248      await assert.doesNotReject(() => batchIncrementRescored(null));
249    });
250  });