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 });