/ tests / stages / proposals-coverage2.test.js
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  });