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