/ tests / competitor-analysis.test.js
competitor-analysis.test.js
  1  /**
  2   * Tests for src/competitor-analysis.js
  3   *
  4   * Covers:
  5   * - analyzeCompetitors (database-backed)
  6   * - getCompetitorInfo
  7   * - getLowScoringSitesWithCompetitors
  8   *
  9   * Uses pg-mock (in-memory SQLite via db.js mock) for PG compatibility.
 10   */
 11  
 12  import { test, describe, beforeEach, mock } from 'node:test';
 13  import assert from 'node:assert/strict';
 14  import Database from 'better-sqlite3';
 15  import { createPgMock } from './helpers/pg-mock.js';
 16  
 17  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 18  
 19  const db = new Database(':memory:');
 20  
 21  db.exec(`
 22    CREATE TABLE sites (
 23      id INTEGER PRIMARY KEY AUTOINCREMENT,
 24      domain TEXT NOT NULL,
 25      landing_page_url TEXT NOT NULL DEFAULT 'https://example.com',
 26      keyword TEXT NOT NULL,
 27      status TEXT DEFAULT 'found',
 28      score REAL,
 29      grade TEXT,
 30      competitor_domain TEXT,
 31      updated_at TEXT DEFAULT (datetime('now')),
 32      rescored_at DATETIME
 33    );
 34  `);
 35  
 36  // ─── Mock db.js BEFORE importing competitor-analysis.js ──────────────────────
 37  
 38  mock.module('../src/utils/db.js', {
 39    namedExports: createPgMock(db),
 40  });
 41  
 42  mock.module('../src/utils/logger.js', {
 43    defaultExport: class {
 44      info() {}
 45      warn() {}
 46      error() {}
 47      success() {}
 48      debug() {}
 49    },
 50  });
 51  
 52  mock.module('../src/utils/load-env.js', {
 53    defaultExport: {},
 54  });
 55  
 56  // Import AFTER mock.module
 57  const { analyzeCompetitors, getCompetitorInfo, getLowScoringSitesWithCompetitors } =
 58    await import('../src/competitor-analysis.js');
 59  
 60  // ─── Helpers ──────────────────────────────────────────────────────────────────
 61  
 62  function clearSites() {
 63    db.prepare('DELETE FROM sites').run();
 64  }
 65  
 66  function insertSite({
 67    domain,
 68    keyword,
 69    status = 'enriched',
 70    score = null,
 71    grade = null,
 72    url = null,
 73  }) {
 74    db.prepare(
 75      `INSERT INTO sites (domain, landing_page_url, keyword, status, score, grade)
 76       VALUES (?, ?, ?, ?, ?, ?)`
 77    ).run(domain, url || `https://${domain}`, keyword, status, score, grade);
 78  }
 79  
 80  function getSite(domain, keyword) {
 81    return db.prepare('SELECT * FROM sites WHERE domain = ? AND keyword = ?').get(domain, keyword);
 82  }
 83  
 84  // ─── analyzeCompetitors ───────────────────────────────────────────────────────
 85  
 86  describe('analyzeCompetitors', () => {
 87    beforeEach(() => clearSites());
 88  
 89    test('returns 0 when no sites exist', async () => {
 90      const result = await analyzeCompetitors();
 91      assert.equal(result, 0);
 92    });
 93  
 94    test('returns 0 when no scored sites exist', async () => {
 95      insertSite({ domain: 'example.com', keyword: 'plumbing', status: 'found' });
 96      const result = await analyzeCompetitors();
 97      assert.equal(result, 0);
 98    });
 99  
100    test('assigns competitor to low-scoring sites for a keyword', async () => {
101      // Top competitor (high score = A grade)
102      insertSite({
103        domain: 'topsite.com',
104        keyword: 'plumbing sydney',
105        status: 'enriched',
106        score: 95,
107        grade: 'A+',
108      });
109      // Low-scoring site
110      insertSite({
111        domain: 'lowsite.com',
112        keyword: 'plumbing sydney',
113        status: 'enriched',
114        score: 45,
115        grade: 'F',
116      });
117      // Another low-scoring site
118      insertSite({
119        domain: 'medsite.com',
120        keyword: 'plumbing sydney',
121        status: 'enriched',
122        score: 68,
123        grade: 'D+',
124      });
125  
126      const result = await analyzeCompetitors();
127      assert.ok(result >= 2, `should update at least 2 low-scoring sites (got ${result})`);
128  
129      const low = getSite('lowsite.com', 'plumbing sydney');
130      assert.equal(
131        low.competitor_domain,
132        'topsite.com',
133        'low-scoring site should have topsite as competitor'
134      );
135  
136      const med = getSite('medsite.com', 'plumbing sydney');
137      assert.equal(
138        med.competitor_domain,
139        'topsite.com',
140        'medium site should also have topsite as competitor'
141      );
142    });
143  
144    test('does not assign competitor to the top site itself', async () => {
145      insertSite({
146        domain: 'topsite.com',
147        keyword: 'web design',
148        status: 'semantic_scored',
149        score: 92,
150        grade: 'A-',
151      });
152      insertSite({
153        domain: 'lowsite.com',
154        keyword: 'web design',
155        status: 'semantic_scored',
156        score: 50,
157        grade: 'F',
158      });
159  
160      await analyzeCompetitors();
161  
162      const top = getSite('topsite.com', 'web design');
163      assert.equal(
164        top.competitor_domain,
165        null,
166        'top site should not be assigned as its own competitor'
167      );
168    });
169  
170    test('does not assign competitor to high-scoring sites (B- and above)', async () => {
171      insertSite({
172        domain: 'topsite.com',
173        keyword: 'accounting',
174        status: 'enriched',
175        score: 95,
176        grade: 'A',
177      });
178      insertSite({
179        domain: 'bsite.com',
180        keyword: 'accounting',
181        status: 'enriched',
182        score: 84,
183        grade: 'B',
184      });
185      insertSite({
186        domain: 'lowsite.com',
187        keyword: 'accounting',
188        status: 'enriched',
189        score: 40,
190        grade: 'F',
191      });
192  
193      await analyzeCompetitors();
194  
195      // B-grade sites should not get competitor (they're above the C+ threshold)
196      const bsite = getSite('bsite.com', 'accounting');
197      assert.equal(
198        bsite.competitor_domain,
199        null,
200        'B-grade site should not receive competitor assignment'
201      );
202    });
203  
204    test('handles multiple keywords independently', async () => {
205      insertSite({
206        domain: 'plumbertop.com',
207        keyword: 'plumber',
208        status: 'enriched',
209        score: 93,
210        grade: 'A-',
211      });
212      insertSite({
213        domain: 'plumberlow.com',
214        keyword: 'plumber',
215        status: 'enriched',
216        score: 45,
217        grade: 'F',
218      });
219      insertSite({
220        domain: 'dentisttop.com',
221        keyword: 'dentist',
222        status: 'enriched',
223        score: 90,
224        grade: 'A-',
225      });
226      insertSite({
227        domain: 'dentistlow.com',
228        keyword: 'dentist',
229        status: 'enriched',
230        score: 60,
231        grade: 'D',
232      });
233  
234      const result = await analyzeCompetitors();
235      assert.ok(result >= 2, 'should update sites across multiple keywords');
236  
237      const plow = getSite('plumberlow.com', 'plumber');
238      assert.equal(plow.competitor_domain, 'plumbertop.com');
239  
240      const dlow = getSite('dentistlow.com', 'dentist');
241      assert.equal(dlow.competitor_domain, 'dentisttop.com');
242    });
243  });
244  
245  // ─── getCompetitorInfo ────────────────────────────────────────────────────────
246  
247  describe('getCompetitorInfo', () => {
248    beforeEach(() => clearSites());
249  
250    test('returns null for non-existent site', async () => {
251      const result = await getCompetitorInfo('nonexistent.com', 'plumbing');
252      assert.equal(result, null);
253    });
254  
255    test('returns site info without competitor when not assigned', async () => {
256      insertSite({
257        domain: 'mysite.com',
258        keyword: 'painting',
259        status: 'enriched',
260        score: 70,
261        grade: 'C-',
262      });
263  
264      const result = await getCompetitorInfo('mysite.com', 'painting');
265      assert.ok(result, 'should return a result');
266      assert.equal(result.site_domain, 'mysite.com');
267      assert.equal(result.competitor_domain, null);
268    });
269  
270    test('returns competitor info after assignment', async () => {
271      insertSite({
272        domain: 'topcomp.com',
273        keyword: 'roofing',
274        status: 'enriched',
275        score: 95,
276        grade: 'A+',
277      });
278      insertSite({
279        domain: 'myroofsite.com',
280        keyword: 'roofing',
281        status: 'enriched',
282        score: 55,
283        grade: 'F',
284      });
285  
286      await analyzeCompetitors();
287  
288      const result = await getCompetitorInfo('myroofsite.com', 'roofing');
289      assert.ok(result, 'should return result');
290      assert.equal(result.competitor_domain, 'topcomp.com');
291      assert.ok(typeof result.competitor_score === 'number' || result.competitor_score === null);
292    });
293  });
294  
295  // ─── getLowScoringSitesWithCompetitors ────────────────────────────────────────
296  
297  describe('getLowScoringSitesWithCompetitors', () => {
298    beforeEach(() => clearSites());
299  
300    test('returns empty array when no low-scoring sites', async () => {
301      const result = await getLowScoringSitesWithCompetitors();
302      assert.ok(Array.isArray(result));
303      assert.equal(result.length, 0);
304    });
305  
306    test('returns low-scoring sites (C+ and below)', async () => {
307      insertSite({
308        domain: 'asite.com',
309        keyword: 'dentist',
310        status: 'enriched',
311        score: 95,
312        grade: 'A',
313      });
314      insertSite({
315        domain: 'fsite.com',
316        keyword: 'dentist',
317        status: 'enriched',
318        score: 40,
319        grade: 'F',
320      });
321      insertSite({
322        domain: 'csite.com',
323        keyword: 'dentist',
324        status: 'enriched',
325        score: 73,
326        grade: 'C',
327      });
328  
329      const result = await getLowScoringSitesWithCompetitors();
330      assert.ok(Array.isArray(result));
331      // Only F and C are low-scoring (C+ and below)
332      const domains = result.map(r => r.domain);
333      assert.ok(domains.includes('fsite.com'), 'F-grade should be in results');
334      assert.ok(domains.includes('csite.com'), 'C-grade should be in results');
335      assert.ok(!domains.includes('asite.com'), 'A-grade should NOT be in results');
336    });
337  
338    test('filters by keyword when provided', async () => {
339      insertSite({ domain: 'p1.com', keyword: 'plumber', status: 'enriched', score: 45, grade: 'F' });
340      insertSite({ domain: 'd1.com', keyword: 'dentist', status: 'enriched', score: 50, grade: 'F' });
341  
342      const plumberResult = await getLowScoringSitesWithCompetitors('plumber');
343      const dentistResult = await getLowScoringSitesWithCompetitors('dentist');
344  
345      assert.ok(
346        plumberResult.every(r => r.keyword === 'plumber'),
347        'should only include plumber sites'
348      );
349      assert.ok(
350        dentistResult.every(r => r.keyword === 'dentist'),
351        'should only include dentist sites'
352      );
353    });
354  
355    test('returns all keywords when no filter provided', async () => {
356      insertSite({ domain: 'p1.com', keyword: 'plumber', status: 'enriched', score: 45, grade: 'F' });
357      insertSite({ domain: 'd1.com', keyword: 'dentist', status: 'enriched', score: 50, grade: 'F' });
358  
359      const result = await getLowScoringSitesWithCompetitors();
360      const keywords = new Set(result.map(r => r.keyword));
361      assert.ok(keywords.has('plumber'));
362      assert.ok(keywords.has('dentist'));
363    });
364  
365    test('includes competitor info in results when assigned', async () => {
366      insertSite({
367        domain: 'topcomp.com',
368        keyword: 'electrician',
369        status: 'enriched',
370        score: 93,
371        grade: 'A-',
372      });
373      insertSite({
374        domain: 'lowcomp.com',
375        keyword: 'electrician',
376        status: 'enriched',
377        score: 48,
378        grade: 'F',
379      });
380  
381      await analyzeCompetitors();
382  
383      const result = await getLowScoringSitesWithCompetitors('electrician');
384      assert.ok(result.length > 0);
385      const low = result.find(r => r.domain === 'lowcomp.com');
386      assert.ok(low, 'should include lowcomp.com');
387      assert.equal(low.competitor_domain, 'topcomp.com');
388    });
389  
390    test('includes sites with status outreach_sent', async () => {
391      insertSite({
392        domain: 'sent.com',
393        keyword: 'locksmith',
394        status: 'outreach_sent',
395        score: 55,
396        grade: 'F',
397      });
398  
399      const result = await getLowScoringSitesWithCompetitors();
400      const domains = result.map(r => r.domain);
401      assert.ok(domains.includes('sent.com'), 'outreach_sent status should be included');
402    });
403  });