proposals.test.js
1 /** 2 * Unit Tests for Proposals Stage 3 * 4 * Tests runProposalsStage() covering: 5 * - No template countries → early return (lines 120-129) 6 * - Blocklist filtering marks sites as 'ignore' (lines 164-176) 7 * - Batch processing succeeds (lines 182-217) 8 * - Error logging for failed sites (lines 208-212) 9 * - Stage-level catch block (lines 218-220) 10 * - No sites needing proposals → early return (lines 154-162) 11 * - Rework processing when rework count > 0 (lines 91-94) 12 * - Re-queue enriched sites with unknown country (lines 100-114) 13 * 14 * Run with: 15 * NODE_ENV=test LOGS_DIR=/tmp/test-logs DATABASE_PATH=/tmp/test-sites.db \ 16 * node --experimental-test-module-mocks --test tests/stages/proposals.test.js 17 */ 18 19 import { test, describe, mock, beforeEach } from 'node:test'; 20 import assert from 'node:assert'; 21 import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars 22 23 // ============================================================================ 24 // MUTABLE STUBS — mutated per-test via beforeEach 25 // ============================================================================ 26 27 // DB query results 28 let mockReworkCount = 0; 29 let mockRequeueChanges = 0; 30 let mockSites = []; 31 32 // Track DB calls 33 const dbCalls = { updates: [], runs: [] }; 34 35 // Template countries — controls whether proposals can proceed 36 let mockTemplateCountries = ['AU', 'US', 'GB']; 37 38 // Blocklist stub 39 let stubCheckBlocklist = (_domain, _cc) => null; 40 41 // Generator stubs 42 const stubGenerateTemplateProposals = mock.fn(async _siteId => ({ 43 variants: [{ type: 'email' }], 44 contactCount: 1, 45 })); 46 const stubGenerateLLMProposals = mock.fn(async _siteId => ({ 47 variants: [{ type: 'email' }], 48 contactCount: 1, 49 })); 50 const stubProcessReworkRequests = mock.fn(async () => {}); 51 52 // processBatch stub — by default succeeds for all items 53 let stubProcessBatch = mock.fn(async (items, processor, _opts) => { 54 const results = []; 55 const errors = []; 56 for (let i = 0; i < items.length; i++) { 57 try { 58 const r = await processor(items[i], i); 59 results.push(r); 60 } catch (err) { 61 errors.push(err); 62 } 63 } 64 return { results, errors }; 65 }); 66 67 // Retry handler stubs 68 const stubRecordFailure = mock.fn(() => {}); 69 const stubResetRetries = mock.fn(() => {}); 70 71 // ============================================================================ 72 // MockDatabase 73 // ============================================================================ 74 75 class MockDatabase { 76 constructor(_path) { 77 this._closed = false; 78 } 79 80 pragma() { 81 return undefined; 82 } 83 84 prepare(sql) { 85 const trimmed = sql.trim(); 86 return { 87 all: (...args) => { 88 // Main site query 89 if (trimmed.includes("'enriched'") && trimmed.includes('score >=')) { 90 return mockSites; 91 } 92 return []; 93 }, 94 get: (...args) => { 95 // Rework count 96 if (trimmed.includes('rework')) { 97 return { cnt: mockReworkCount }; 98 } 99 // Retry count for recordFailure 100 if (trimmed.includes('retry_count')) { 101 return { retry_count: 0 }; 102 } 103 return null; 104 }, 105 run: (...args) => { 106 // Re-queue enriched sites with unknown country 107 if (trimmed.includes('country_code IS NULL') && trimmed.includes('UPDATE sites')) { 108 dbCalls.runs.push({ sql: trimmed, args }); 109 return { changes: mockRequeueChanges }; 110 } 111 // Blocklist ignore update 112 if (trimmed.includes("status = 'ignored'")) { 113 dbCalls.updates.push({ sql: trimmed, args }); 114 return { changes: 1 }; 115 } 116 dbCalls.runs.push({ sql: trimmed, args }); 117 return { changes: 0, lastInsertRowid: 0 }; 118 }, 119 }; 120 } 121 122 close() { 123 this._closed = true; 124 } 125 } 126 127 // ============================================================================ 128 // MOCK MODULES — must come before dynamic imports 129 // ============================================================================ 130 131 mock.module('better-sqlite3', { 132 defaultExport: MockDatabase, 133 }); 134 135 mock.module('../../src/proposal-generator-v2.js', { 136 namedExports: { 137 generateProposalVariants: (...args) => stubGenerateLLMProposals(...args), 138 processReworkQueue: (...args) => stubProcessReworkRequests(...args), 139 }, 140 }); 141 142 mock.module('../../src/proposal-generator-templates.js', { 143 namedExports: { 144 generateProposalVariants: (...args) => stubGenerateTemplateProposals(...args), 145 }, 146 }); 147 148 mock.module('../../src/utils/logger.js', { 149 defaultExport: class MockLogger { 150 constructor() {} 151 success() {} 152 info() {} 153 error() {} 154 warn() {} 155 debug() {} 156 }, 157 }); 158 159 mock.module('../../src/utils/summary-generator.js', { 160 namedExports: { 161 generateStageCompletion: () => {}, 162 displayProgress: () => {}, 163 }, 164 }); 165 166 mock.module('../../src/utils/error-handler.js', { 167 namedExports: { 168 processBatch: (...args) => stubProcessBatch(...args), 169 }, 170 defaultExport: { 171 processBatch: (...args) => stubProcessBatch(...args), 172 }, 173 }); 174 175 mock.module('../../src/utils/site-filters.js', { 176 namedExports: { 177 checkBlocklist: (...args) => stubCheckBlocklist(...args), 178 }, 179 }); 180 181 mock.module('../../src/utils/retry-handler.js', { 182 namedExports: { 183 recordFailure: (...args) => stubRecordFailure(...args), 184 resetRetries: (...args) => stubResetRetries(...args), 185 }, 186 }); 187 188 // Mock db.js — proposals.js uses run/getOne/getAll from db.js (not better-sqlite3 directly) 189 mock.module('../../src/utils/db.js', { 190 namedExports: { 191 getAll: async (sql, _params) => { 192 const trimmed = sql.trim(); 193 if (trimmed.includes("'enriched'") && trimmed.includes('score >=')) { 194 return mockSites; 195 } 196 return []; 197 }, 198 getOne: async (sql, _params) => { 199 const trimmed = sql.trim(); 200 if (trimmed.includes('rework')) { 201 return { cnt: mockReworkCount }; 202 } 203 if (trimmed.includes('retry_count')) { 204 return { retry_count: 0 }; 205 } 206 return null; 207 }, 208 run: async (sql, params) => { 209 const trimmed = sql.trim(); 210 if (trimmed.includes('country_code IS NULL') && trimmed.includes('UPDATE sites')) { 211 dbCalls.runs.push({ sql: trimmed, args: params }); 212 return { changes: mockRequeueChanges }; 213 } 214 if (trimmed.includes("status = 'ignored'")) { 215 dbCalls.updates.push({ sql: trimmed, args: params }); 216 return { changes: 1 }; 217 } 218 dbCalls.runs.push({ sql: trimmed, args: params }); 219 return { changes: 0, lastInsertRowid: null }; 220 }, 221 query: async () => ({ rows: [], rowCount: 0 }), 222 withTransaction: async fn => { 223 const fakeClient = { query: async () => ({ rows: [], rowCount: 0 }) }; 224 return await fn(fakeClient); 225 }, 226 }, 227 }); 228 229 // Mock fs functions used by getTemplateCountries 230 mock.module('fs', { 231 namedExports: { 232 existsSync: p => { 233 // Templates dir exists if we have template countries 234 if (p.includes('data/templates')) { 235 return mockTemplateCountries.length > 0; 236 } 237 return false; 238 }, 239 readdirSync: (p, _opts) => { 240 if (p.includes('data/templates')) { 241 return mockTemplateCountries.map(cc => ({ 242 name: cc.toLowerCase(), 243 isDirectory: () => true, 244 })); 245 } 246 return []; 247 }, 248 readFileSync: () => '{}', 249 writeFileSync: () => {}, 250 mkdirSync: () => {}, 251 unlinkSync: () => {}, 252 }, 253 }); 254 255 // ============================================================================ 256 // Import module under test AFTER all mocks 257 // ============================================================================ 258 259 const { runProposalsStage } = await import('../../src/stages/proposals.js'); 260 261 // ============================================================================ 262 // TEST HELPERS 263 // ============================================================================ 264 265 beforeEach(() => { 266 mockReworkCount = 0; 267 mockRequeueChanges = 0; 268 mockSites = []; 269 mockTemplateCountries = ['AU', 'US', 'GB']; 270 dbCalls.updates = []; 271 dbCalls.runs = []; 272 273 stubCheckBlocklist = (_domain, _cc) => null; 274 275 stubGenerateTemplateProposals.mock.resetCalls(); 276 stubGenerateLLMProposals.mock.resetCalls(); 277 stubProcessReworkRequests.mock.resetCalls(); 278 stubRecordFailure.mock.resetCalls(); 279 stubResetRetries.mock.resetCalls(); 280 stubProcessBatch.mock.resetCalls(); 281 282 // Reset processBatch to default success behavior 283 stubProcessBatch = mock.fn(async (items, processor, _opts) => { 284 const results = []; 285 const errors = []; 286 for (let i = 0; i < items.length; i++) { 287 try { 288 const r = await processor(items[i], i); 289 results.push(r); 290 } catch (err) { 291 errors.push(err); 292 } 293 } 294 return { results, errors }; 295 }); 296 }); 297 298 // ============================================================================ 299 // TESTS 300 // ============================================================================ 301 302 describe('runProposalsStage', () => { 303 // ── Lines 120-129: No template countries → early return ────────────────── 304 test('returns early with zero counts when no template countries exist', async () => { 305 mockTemplateCountries = []; 306 307 const result = await runProposalsStage(); 308 309 assert.deepStrictEqual(result, { 310 processed: 0, 311 succeeded: 0, 312 failed: 0, 313 skipped: 0, 314 duration: result.duration, // dynamic 315 }); 316 assert.ok(result.duration >= 0); 317 }); 318 319 // ── Lines 154-162: No sites needing proposals ──────────────────────────── 320 test('returns early when no sites need proposals', async () => { 321 mockSites = []; // No enriched sites 322 323 const result = await runProposalsStage(); 324 325 assert.equal(result.processed, 0); 326 assert.equal(result.succeeded, 0); 327 assert.equal(result.failed, 0); 328 assert.equal(result.skipped, 0); 329 }); 330 331 // ── Lines 91-94: Rework processing ────────────────────────────────────── 332 test('calls processReworkRequests when rework count > 0', async () => { 333 mockReworkCount = 3; 334 mockSites = []; // No new sites, but rework should still run 335 336 await runProposalsStage(); 337 338 assert.equal(stubProcessReworkRequests.mock.callCount(), 1); 339 }); 340 341 test('does not call processReworkRequests when rework count is 0', async () => { 342 mockReworkCount = 0; 343 mockSites = []; 344 345 await runProposalsStage(); 346 347 assert.equal(stubProcessReworkRequests.mock.callCount(), 0); 348 }); 349 350 // ── Lines 100-114: Re-queue enriched sites with unknown country ───────── 351 test('logs info when re-queuing sites with unknown country', async () => { 352 mockRequeueChanges = 5; 353 mockSites = []; 354 355 const result = await runProposalsStage(); 356 357 // Should still return zero processed (no sites matched main query) 358 assert.equal(result.processed, 0); 359 }); 360 361 // ── Lines 164-176: Blocklist filtering ────────────────────────────────── 362 test('marks blocklisted sites as ignore in database', async () => { 363 mockSites = [ 364 { 365 id: 1, 366 domain: 'yelp.com', 367 url: 'https://yelp.com', 368 score: 50, 369 grade: 'D', 370 keyword: 'plumber', 371 country_code: 'US', 372 }, 373 { 374 id: 2, 375 domain: 'good-site.com', 376 url: 'https://good-site.com', 377 score: 55, 378 grade: 'D', 379 keyword: 'plumber', 380 country_code: 'AU', 381 }, 382 ]; 383 384 stubCheckBlocklist = (domain, _cc) => { 385 if (domain === 'yelp.com') return { reason: 'Business directory' }; 386 return null; 387 }; 388 389 const result = await runProposalsStage(); 390 391 // Blocklist update should have been called for yelp.com 392 const ignoreUpdates = dbCalls.updates.filter(u => u.sql.includes("status = 'ignored'")); 393 assert.ok( 394 ignoreUpdates.length >= 1, 395 'Should have at least one ignore update for blocklisted site' 396 ); 397 }); 398 399 // ── Lines 178-179: Ignored count logging ──────────────────────────────── 400 test('reports count of ignored blocklisted sites', async () => { 401 mockSites = [ 402 { 403 id: 1, 404 domain: 'directory.com', 405 url: 'https://directory.com', 406 score: 40, 407 grade: 'F', 408 keyword: 'test', 409 country_code: 'AU', 410 }, 411 { 412 id: 2, 413 domain: 'another-dir.com', 414 url: 'https://another-dir.com', 415 score: 45, 416 grade: 'F', 417 keyword: 'test', 418 country_code: 'US', 419 }, 420 ]; 421 422 stubCheckBlocklist = () => ({ reason: 'Franchise site' }); 423 424 const result = await runProposalsStage(); 425 426 // Both sites blocked but still processed through the batch 427 assert.equal(result.processed, 2); 428 }); 429 430 // ── Lines 192-200: Successful batch processing ────────────────────────── 431 test('processes sites and counts successes', async () => { 432 mockSites = [ 433 { 434 id: 10, 435 domain: 'site-a.com', 436 url: 'https://site-a.com', 437 score: 60, 438 grade: 'D', 439 keyword: 'dentist', 440 country_code: 'AU', 441 }, 442 { 443 id: 11, 444 domain: 'site-b.com', 445 url: 'https://site-b.com', 446 score: 70, 447 grade: 'C', 448 keyword: 'dentist', 449 country_code: 'AU', 450 }, 451 ]; 452 453 const result = await runProposalsStage(); 454 455 assert.equal(result.processed, 2); 456 assert.equal(result.succeeded, 2); 457 assert.equal(result.failed, 0); 458 }); 459 460 // ── Lines 202-212: Error logging for failed sites ─────────────────────── 461 test('counts errors from failed batch processing', async () => { 462 mockSites = [ 463 { 464 id: 20, 465 domain: 'fail-site.com', 466 url: 'https://fail-site.com', 467 score: 50, 468 grade: 'D', 469 keyword: 'plumber', 470 country_code: 'AU', 471 }, 472 ]; 473 474 // Make processBatch return an error 475 stubProcessBatch = mock.fn(async (items, _processor, _opts) => { 476 return { 477 results: [], 478 errors: [{ item: { url: 'https://fail-site.com' }, error: { message: 'LLM timeout' } }], 479 }; 480 }); 481 482 const result = await runProposalsStage(); 483 484 assert.equal(result.processed, 1); 485 assert.equal(result.succeeded, 0); 486 assert.equal(result.failed, 1); 487 }); 488 489 test('handles errors without item or error properties gracefully', async () => { 490 mockSites = [ 491 { 492 id: 21, 493 domain: 'fail2.com', 494 url: 'https://fail2.com', 495 score: 55, 496 grade: 'D', 497 keyword: 'test', 498 country_code: 'US', 499 }, 500 ]; 501 502 // Error object without item/error fields — tests toString() fallback on line 210 503 stubProcessBatch = mock.fn(async () => ({ 504 results: [], 505 errors: ['raw string error'], 506 })); 507 508 const result = await runProposalsStage(); 509 510 assert.equal(result.failed, 1); 511 assert.equal(result.succeeded, 0); 512 }); 513 514 // ── Lines 214-216: Duration and stage completion ──────────────────────── 515 test('returns duration in stats', async () => { 516 mockSites = [ 517 { 518 id: 30, 519 domain: 'fast.com', 520 url: 'https://fast.com', 521 score: 65, 522 grade: 'D', 523 keyword: 'test', 524 country_code: 'GB', 525 }, 526 ]; 527 528 const result = await runProposalsStage(); 529 530 assert.ok('duration' in result, 'Should have duration field'); 531 assert.ok(result.duration >= 0, 'Duration should be non-negative'); 532 }); 533 534 // ── Lines 218-220: Stage-level catch block ────────────────────────────── 535 test('throws and propagates error when stage fails catastrophically', async () => { 536 mockSites = [ 537 { 538 id: 40, 539 domain: 'crash.com', 540 url: 'https://crash.com', 541 score: 40, 542 grade: 'F', 543 keyword: 'test', 544 country_code: 'AU', 545 }, 546 ]; 547 548 // Make processBatch throw (not return errors, but actually throw) 549 stubProcessBatch = mock.fn(async () => { 550 throw new Error('Database connection lost'); 551 }); 552 553 await assert.rejects( 554 () => runProposalsStage(), 555 err => { 556 assert.ok(err.message.includes('Database connection lost')); 557 return true; 558 } 559 ); 560 }); 561 562 // ── Options: concurrency and limit ────────────────────────────────────── 563 test('passes concurrency option to processBatch', async () => { 564 mockSites = [ 565 { 566 id: 50, 567 domain: 'conc.com', 568 url: 'https://conc.com', 569 score: 60, 570 grade: 'D', 571 keyword: 'test', 572 country_code: 'AU', 573 }, 574 ]; 575 576 await runProposalsStage({ concurrency: 5 }); 577 578 assert.equal(stubProcessBatch.mock.callCount(), 1); 579 const callArgs = stubProcessBatch.mock.calls[0].arguments; 580 assert.equal(callArgs[2].concurrency, 5); 581 }); 582 583 test('uses default concurrency of 2 when not specified', async () => { 584 mockSites = [ 585 { 586 id: 51, 587 domain: 'default-conc.com', 588 url: 'https://default-conc.com', 589 score: 60, 590 grade: 'D', 591 keyword: 'test', 592 country_code: 'AU', 593 }, 594 ]; 595 596 await runProposalsStage(); 597 598 const callArgs = stubProcessBatch.mock.calls[0].arguments; 599 assert.equal(callArgs[2].concurrency, 2); 600 }); 601 602 // ── Score range options ───────────────────────────────────────────────── 603 test('respects minScore and maxScore options', async () => { 604 mockSites = []; 605 606 const result = await runProposalsStage({ minScore: 10, maxScore: 50 }); 607 608 assert.equal(result.processed, 0); // No sites, but the query ran with the filters 609 }); 610 });