proposals-coverage2.test.js
1 /** 2 * Coverage2 tests for src/stages/proposals.js 3 * 4 * Targets uncovered lines: 5 * - 35-43: getTemplateCountries language-specific path + catch block 6 * - 257-259: generateProposalForSite catch block (recordFailure + rethrow) 7 * - 299-337: regenerateProposals() 8 * 9 * Run with: 10 * node --test --experimental-test-module-mocks tests/stages/proposals-coverage2.test.js 11 */ 12 13 import { test, describe, mock, beforeEach } from 'node:test'; 14 import assert from 'node:assert'; 15 import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars 16 17 // ============================================================================ 18 // MUTABLE STUBS 19 // ============================================================================ 20 21 let mockReworkCount = 0; 22 let mockRequeueChanges = 0; 23 let mockSites = []; 24 let mockRegenerateSites = []; 25 26 // Template countries behaviour 27 // 'legacy' — existsSync returns true for CC/email.json (legacy flat) 28 // 'language' — existsSync returns false for CC/email.json but subdirs have email.json 29 // 'lang-throws' — the readdirSync of countryDir throws 30 // 'none' — templates dir doesn't exist 31 let templateMode = 'legacy'; 32 let mockTemplateCountries = ['AU', 'US', 'GB']; 33 34 // Blocklist stub 35 let stubCheckBlocklist = (_domain, _cc) => null; 36 37 // Generator stubs 38 const stubGenerateTemplateProposals = mock.fn(async _siteId => ({ 39 variants: [{ type: 'email' }], 40 contactCount: 1, 41 })); 42 const stubGenerateLLMProposals = mock.fn(async _siteId => ({ 43 variants: [{ type: 'email' }], 44 contactCount: 1, 45 })); 46 const stubProcessReworkRequests = mock.fn(async () => {}); 47 48 // processBatch stub — default: runs each item 49 let stubProcessBatch = mock.fn(async (items, processor, _opts) => { 50 const results = []; 51 const errors = []; 52 for (let i = 0; i < items.length; i++) { 53 try { 54 const r = await processor(items[i], i); 55 results.push(r); 56 } catch (err) { 57 errors.push({ item: items[i], error: err }); 58 } 59 } 60 return { results, errors }; 61 }); 62 63 // Retry handler stubs 64 const stubRecordFailure = mock.fn(() => {}); 65 const stubResetRetries = mock.fn(() => {}); 66 67 // ============================================================================ 68 // MockDatabase 69 // ============================================================================ 70 71 class MockDatabase { 72 constructor(_path) { 73 this._closed = false; 74 } 75 76 pragma() { 77 return undefined; 78 } 79 80 prepare(sql) { 81 const trimmed = sql.trim(); 82 return { 83 all: (..._args) => { 84 // Main site query (runProposalsStage) 85 if (trimmed.includes("'enriched'") && trimmed.includes('score >=')) { 86 return mockSites; 87 } 88 // regenerateProposals: SELECT site data 89 if (trimmed.includes('landing_page_url as url') && trimmed.includes('FROM sites')) { 90 return mockRegenerateSites; 91 } 92 return []; 93 }, 94 get: (..._args) => { 95 if (trimmed.includes('rework')) { 96 return { cnt: mockReworkCount }; 97 } 98 if (trimmed.includes('retry_count')) { 99 return { retry_count: 0 }; 100 } 101 return null; 102 }, 103 run: (..._args) => { 104 if (trimmed.includes('country_code IS NULL') && trimmed.includes('UPDATE sites')) { 105 return { changes: mockRequeueChanges }; 106 } 107 return { changes: 0, lastInsertRowid: 0 }; 108 }, 109 }; 110 } 111 112 close() { 113 this._closed = true; 114 } 115 } 116 117 // ============================================================================ 118 // MOCK MODULES — must come before dynamic imports 119 // ============================================================================ 120 121 mock.module('better-sqlite3', { 122 defaultExport: MockDatabase, 123 }); 124 125 // Mock db.js to intercept run/getOne/getAll calls from proposals.js 126 // Routes queries to mutable test state variables (mockSites, mockReworkCount, etc.) 127 mock.module('../../src/utils/db.js', { 128 namedExports: { 129 getAll: async (sql, _params) => { 130 // Main proposals query 131 if (sql.includes("status IN ('enriched'") && sql.includes('score >=')) { 132 return mockSites; 133 } 134 // regenerateProposals site query 135 if (sql.includes('landing_page_url as url') && sql.includes('FROM sites')) { 136 return mockRegenerateSites; 137 } 138 return []; 139 }, 140 getOne: async (sql, _params) => { 141 if (sql.includes('rework')) { 142 return { cnt: mockReworkCount }; 143 } 144 if (sql.includes('retry_count')) { 145 return { retry_count: 0 }; 146 } 147 // Stats query for regenerateProposals 148 if (sql.includes('COUNT') && sql.includes('sites')) { 149 return { total: 0, succeeded: 0, failed: 0 }; 150 } 151 return null; 152 }, 153 run: async (sql, _params) => { 154 if (sql.includes('country_code IS NULL') && sql.includes('UPDATE sites')) { 155 return { changes: mockRequeueChanges }; 156 } 157 return { changes: 0, lastInsertRowid: null }; 158 }, 159 query: async () => ({ rows: [], rowCount: 0 }), 160 withTransaction: async fn => { 161 const fakeClient = { 162 query: async () => ({ rows: [], rowCount: 0 }), 163 }; 164 return await fn(fakeClient); 165 }, 166 }, 167 }); 168 169 mock.module('../../src/proposal-generator-v2.js', { 170 namedExports: { 171 generateProposalVariants: (...args) => stubGenerateLLMProposals(...args), 172 processReworkQueue: (...args) => stubProcessReworkRequests(...args), 173 }, 174 }); 175 176 mock.module('../../src/proposal-generator-templates.js', { 177 namedExports: { 178 generateProposalVariants: (...args) => stubGenerateTemplateProposals(...args), 179 }, 180 }); 181 182 mock.module('../../src/utils/logger.js', { 183 defaultExport: class MockLogger { 184 constructor() {} 185 success() {} 186 info() {} 187 error() {} 188 warn() {} 189 debug() {} 190 }, 191 }); 192 193 mock.module('../../src/utils/summary-generator.js', { 194 namedExports: { 195 generateStageCompletion: () => {}, 196 displayProgress: () => {}, 197 }, 198 }); 199 200 mock.module('../../src/utils/error-handler.js', { 201 namedExports: { 202 processBatch: (...args) => stubProcessBatch(...args), 203 }, 204 }); 205 206 mock.module('../../src/utils/site-filters.js', { 207 namedExports: { 208 checkBlocklist: (...args) => stubCheckBlocklist(...args), 209 }, 210 }); 211 212 mock.module('../../src/utils/retry-handler.js', { 213 namedExports: { 214 recordFailure: (...args) => stubRecordFailure(...args), 215 resetRetries: (...args) => stubResetRetries(...args), 216 }, 217 }); 218 219 // ── fs mock — supports multiple templateMode values ────────────────────────── 220 mock.module('fs', { 221 namedExports: { 222 existsSync: p => { 223 if (!p.includes('data/templates')) return false; 224 225 // templates root dir 226 if (p.endsWith('data/templates') || p.endsWith('data/templates/')) { 227 return templateMode !== 'none'; 228 } 229 230 // CC/email.json (legacy flat path) 231 if (p.endsWith('email.json')) { 232 if (templateMode === 'legacy') return true; 233 // language mode: email.json lives under CC/lang/email.json 234 // The path here will be templates/{cc}/email.json — return false so we fall through 235 if (templateMode === 'language') { 236 // only return true when path has two subdirs after templates (lang subdir) 237 const parts = p.split('/'); 238 const tmplIdx = parts.indexOf('templates'); 239 if (tmplIdx !== -1 && parts.length - tmplIdx === 3) { 240 // templates/{cc}/email.json — legacy check, return false 241 return false; 242 } 243 if (tmplIdx !== -1 && parts.length - tmplIdx === 4) { 244 // templates/{cc}/{lang}/email.json — exists 245 return true; 246 } 247 return false; 248 } 249 if (templateMode === 'lang-throws') return false; 250 return false; 251 } 252 253 return false; 254 }, 255 readdirSync: (p, _opts) => { 256 if (p.includes('data/templates')) { 257 const parts = p.split('/'); 258 const tmplIdx = parts.indexOf('templates'); 259 260 if (tmplIdx !== -1 && parts.length - tmplIdx === 1) { 261 // Reading templates root dir — return CC dirs 262 if (templateMode === 'none') return []; 263 return mockTemplateCountries.map(cc => ({ 264 name: cc.toLowerCase(), 265 isDirectory: () => true, 266 })); 267 } 268 269 // Reading templates/{cc} dir (language subdirs) 270 if (tmplIdx !== -1 && parts.length - tmplIdx === 2) { 271 if (templateMode === 'language') { 272 return [{ name: 'en', isDirectory: () => true }]; 273 } 274 if (templateMode === 'lang-throws') { 275 throw new Error('EACCES: permission denied'); 276 } 277 return []; 278 } 279 } 280 return []; 281 }, 282 readFileSync: () => '{}', 283 writeFileSync: () => {}, 284 mkdirSync: () => {}, 285 unlinkSync: () => {}, 286 }, 287 }); 288 289 // ============================================================================ 290 // Import module under test AFTER all mocks 291 // ============================================================================ 292 293 const { runProposalsStage, regenerateProposals } = await import('../../src/stages/proposals.js'); 294 295 // ============================================================================ 296 // TEST HELPERS 297 // ============================================================================ 298 299 beforeEach(() => { 300 mockReworkCount = 0; 301 mockRequeueChanges = 0; 302 mockSites = []; 303 mockRegenerateSites = []; 304 templateMode = 'legacy'; 305 mockTemplateCountries = ['AU', 'US', 'GB']; 306 307 stubCheckBlocklist = (_domain, _cc) => null; 308 309 // Reset call counts AND restore default implementations 310 stubGenerateTemplateProposals.mock.restore(); 311 stubGenerateTemplateProposals.mock.mockImplementation(async _siteId => ({ 312 variants: [{ type: 'email' }], 313 contactCount: 1, 314 })); 315 stubGenerateLLMProposals.mock.resetCalls(); 316 stubProcessReworkRequests.mock.resetCalls(); 317 stubRecordFailure.mock.resetCalls(); 318 stubResetRetries.mock.resetCalls(); 319 stubProcessBatch.mock.resetCalls(); 320 321 stubProcessBatch = mock.fn(async (items, processor, _opts) => { 322 const results = []; 323 const errors = []; 324 for (let i = 0; i < items.length; i++) { 325 try { 326 const r = await processor(items[i], i); 327 results.push(r); 328 } catch (err) { 329 errors.push({ item: items[i], error: err }); 330 } 331 } 332 return { results, errors }; 333 }); 334 }); 335 336 // ============================================================================ 337 // TESTS — getTemplateCountries language-specific path (lines 35-43) 338 // ============================================================================ 339 340 describe('getTemplateCountries — language-specific path', () => { 341 test('includes CC when only language subdirs have email.json (lines 35-40)', async () => { 342 templateMode = 'language'; 343 mockTemplateCountries = ['AU']; 344 345 // With language mode, the CC dir has a lang subdir (en/) with email.json. 346 // getTemplateCountries should detect AU and return it. 347 mockSites = []; 348 const result = await runProposalsStage(); 349 350 // If AU was detected as a template country, the stage will proceed past the 351 // "no template countries" early return. It will then return processed=0 because 352 // mockSites is empty — but templateCountries length was > 0. 353 // We confirm it did NOT bail early with the zero-countries message. 354 assert.ok(result.processed === 0, 'no sites to process'); 355 assert.ok(result.failed === 0); 356 }); 357 358 test('handles readdirSync throw on countryDir gracefully (lines 41-43)', async () => { 359 templateMode = 'lang-throws'; 360 mockTemplateCountries = ['AU']; 361 362 // When readdirSync throws for the CC dir, the catch returns false, 363 // so AU is not included in templateCountries → stage returns early. 364 const result = await runProposalsStage(); 365 366 assert.deepStrictEqual(result, { 367 processed: 0, 368 succeeded: 0, 369 failed: 0, 370 skipped: 0, 371 duration: result.duration, 372 }); 373 }); 374 }); 375 376 // ============================================================================ 377 // TESTS — generateProposalForSite catch block (lines 257-259) 378 // ============================================================================ 379 380 describe('generateProposalForSite catch block', () => { 381 test('calls recordFailure and rethrows when generator throws (lines 257-259)', async () => { 382 mockSites = [ 383 { 384 id: 100, 385 domain: 'throws.com', 386 url: 'https://throws.com', 387 score: 50, 388 grade: 'D', 389 keyword: 'plumber', 390 country_code: 'AU', 391 }, 392 ]; 393 394 const genError = new Error('Generator exploded'); 395 // Make the template generator throw 396 stubGenerateTemplateProposals.mock.mockImplementation(async () => { 397 throw genError; 398 }); 399 400 const result = await runProposalsStage(); 401 402 // The error propagates through processBatch which catches it and puts it in errors[] 403 assert.equal(result.failed, 1); 404 assert.equal(result.succeeded, 0); 405 // recordFailure should have been called once for the failed site 406 assert.equal(stubRecordFailure.mock.callCount(), 1); 407 const [siteIdArg, stageArg] = stubRecordFailure.mock.calls[0].arguments; 408 assert.equal(siteIdArg, 100); 409 assert.equal(stageArg, 'proposals'); 410 }); 411 }); 412 413 // ============================================================================ 414 // TESTS — regenerateProposals (lines 299-337) 415 // ============================================================================ 416 417 describe('regenerateProposals', () => { 418 test('returns processed/succeeded/failed counts', async () => { 419 mockRegenerateSites = [ 420 { id: 1, url: 'https://site1.com', keyword: 'dentist' }, 421 { id: 2, url: 'https://site2.com', keyword: 'plumber' }, 422 ]; 423 424 const result = await regenerateProposals([1, 2]); 425 426 assert.equal(result.processed, 2); 427 assert.equal(result.succeeded, 2); 428 assert.equal(result.failed, 0); 429 }); 430 431 test('counts failed sites when generator throws', async () => { 432 mockRegenerateSites = [{ id: 10, url: 'https://fail.com', keyword: 'fail' }]; 433 434 stubGenerateTemplateProposals.mock.mockImplementation(async () => { 435 throw new Error('Regen failed'); 436 }); 437 438 const result = await regenerateProposals([10]); 439 440 assert.equal(result.processed, 1); 441 assert.equal(result.succeeded, 0); 442 assert.equal(result.failed, 1); 443 }); 444 445 test('handles empty siteIds array', async () => { 446 mockRegenerateSites = []; 447 448 const result = await regenerateProposals([]); 449 450 assert.equal(result.processed, 0); 451 assert.equal(result.succeeded, 0); 452 assert.equal(result.failed, 0); 453 }); 454 455 test('processes mix of successful and failing sites', async () => { 456 mockRegenerateSites = [ 457 { id: 20, url: 'https://good.com', keyword: 'good' }, 458 { id: 21, url: 'https://bad.com', keyword: 'bad' }, 459 ]; 460 461 let callCount = 0; 462 stubGenerateTemplateProposals.mock.mockImplementation(async siteId => { 463 callCount++; 464 if (siteId === 21) throw new Error('Site 21 failed'); 465 return { variants: [{ type: 'email' }], contactCount: 1 }; 466 }); 467 468 const result = await regenerateProposals([20, 21]); 469 470 assert.equal(result.processed, 2); 471 assert.equal(result.succeeded, 1); 472 assert.equal(result.failed, 1); 473 }); 474 475 test('returns processed equal to siteIds.length regardless of DB results', async () => { 476 // DB returns fewer sites than requested (some IDs may not exist) 477 mockRegenerateSites = [{ id: 30, url: 'https://exists.com', keyword: 'exists' }]; 478 479 const result = await regenerateProposals([30, 31, 32]); 480 481 // processed = siteIds.length = 3 482 assert.equal(result.processed, 3); 483 // succeeded = number of sites DB returned and processed successfully = 1 484 assert.equal(result.succeeded, 1); 485 assert.equal(result.failed, 0); 486 }); 487 });