keywords.test.js
1 /** 2 * Tests for keywords pipeline stage 3 * 4 * Tests runKeywordsStage(), addKeyword(), listKeywords(), updateKeyword(), 5 * and getKeywordStats() using pg-mock (in-memory SQLite via db.js mock). 6 */ 7 8 import { test, describe, mock, beforeEach } from 'node:test'; 9 import assert from 'node:assert/strict'; 10 import Database from 'better-sqlite3'; 11 import { createPgMock } from '../helpers/pg-mock.js'; 12 13 // ─── Create in-memory test DB ───────────────────────────────────────────────── 14 15 const db = new Database(':memory:'); 16 17 db.exec(` 18 CREATE TABLE sites ( 19 id INTEGER PRIMARY KEY AUTOINCREMENT, 20 domain TEXT, 21 landing_page_url TEXT, 22 keyword TEXT, 23 score REAL, 24 status TEXT DEFAULT 'found', 25 created_at TEXT DEFAULT CURRENT_TIMESTAMP 26 ); 27 CREATE TABLE keywords ( 28 id INTEGER PRIMARY KEY AUTOINCREMENT, 29 keyword TEXT NOT NULL, 30 status TEXT DEFAULT 'active', 31 priority INTEGER DEFAULT 5, 32 country_code TEXT DEFAULT 'AU', 33 google_domain TEXT DEFAULT 'google.com.au', 34 search_count INTEGER DEFAULT 0, 35 last_searched_at TEXT DEFAULT NULL, 36 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 37 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 38 ); 39 `); 40 41 // ─── Mock db.js BEFORE importing keywords.js ───────────────────────────────── 42 43 mock.module('../../src/utils/db.js', { 44 namedExports: createPgMock(db), 45 }); 46 47 // Also mock logger to suppress output 48 mock.module('../../src/utils/logger.js', { 49 defaultExport: class { 50 info() {} 51 warn() {} 52 error() {} 53 success() {} 54 debug() {} 55 }, 56 }); 57 58 mock.module('../../src/utils/summary-generator.js', { 59 namedExports: { 60 generateStageCompletion: () => {}, 61 displayProgress: () => {}, 62 }, 63 }); 64 65 // Import AFTER mock.module 66 const { runKeywordsStage, addKeyword, listKeywords, updateKeywordPriority } = 67 await import('../../src/stages/keywords.js'); 68 69 // ─── Helpers ───────────────────────────────────────────────────────────────── 70 71 function clearKeywords() { 72 db.prepare('DELETE FROM keywords').run(); 73 } 74 75 function insertKeyword(keyword, status = 'active', priority = 5, countryCode = 'AU') { 76 db.prepare( 77 `INSERT INTO keywords (keyword, status, priority, country_code, google_domain, search_count) 78 VALUES (?, ?, ?, ?, 'google.com.au', 0)` 79 ).run(keyword, status, priority, countryCode); 80 } 81 82 // ─── Tests ─────────────────────────────────────────────────────────────────── 83 84 describe('Keywords Stage', () => { 85 beforeEach(() => clearKeywords()); 86 87 describe('runKeywordsStage', () => { 88 test('returns zero stats when no active keywords', async () => { 89 const result = await runKeywordsStage(); 90 assert.strictEqual(result.processed, 0); 91 assert.strictEqual(result.succeeded, 0); 92 assert.strictEqual(result.failed, 0); 93 assert.strictEqual(result.skipped, 0); 94 }); 95 96 test('returns stats for active keywords', async () => { 97 insertKeyword('plumber sydney', 'active', 8); 98 insertKeyword('electrician melbourne', 'active', 6); 99 100 const result = await runKeywordsStage(); 101 assert.strictEqual(result.processed, 2); 102 assert.strictEqual(result.succeeded, 2); 103 assert.strictEqual(result.failed, 0); 104 }); 105 106 test('ignores inactive keywords (only processes active)', async () => { 107 insertKeyword('active kw', 'active'); 108 insertKeyword('inactive kw', 'inactive'); 109 110 const result = await runKeywordsStage(); 111 assert.strictEqual(result.processed, 1); 112 }); 113 114 test('respects limit option', async () => { 115 for (let i = 1; i <= 5; i++) { 116 insertKeyword(`keyword ${i}`, 'active', i); 117 } 118 119 const result = await runKeywordsStage({ limit: 2 }); 120 assert.strictEqual(result.processed, 2); 121 }); 122 123 test('returns duration in result', async () => { 124 const result = await runKeywordsStage(); 125 assert.ok(typeof result.duration === 'number'); 126 assert.ok(result.duration >= 0); 127 }); 128 }); 129 130 describe('addKeyword', () => { 131 test('adds a keyword and returns its ID', async () => { 132 const id = await addKeyword('roofer brisbane', 7, 'AU'); 133 assert.ok(typeof id === 'number' || typeof id === 'bigint'); 134 assert.ok(id > 0); 135 }); 136 137 test('added keyword appears in database', async () => { 138 await addKeyword('garden maintenance', 5, 'AU'); 139 const row = db.prepare("SELECT * FROM keywords WHERE keyword = 'garden maintenance'").get(); 140 assert.ok(row, 'Keyword should be in DB'); 141 assert.strictEqual(row.status, 'active'); 142 assert.strictEqual(row.priority, 5); 143 }); 144 145 test('sets correct country code and google domain for AU', async () => { 146 await addKeyword('test keyword', 5, 'AU'); 147 const row = db.prepare("SELECT * FROM keywords WHERE keyword='test keyword'").get(); 148 assert.strictEqual(row.country_code, 'AU'); 149 assert.ok(row.google_domain.includes('google')); 150 }); 151 152 test('throws on unknown country code', async () => { 153 await assert.rejects(() => addKeyword('bad keyword', 5, 'XX'), /Cannot read|TypeError|null/); 154 }); 155 }); 156 157 describe('listKeywords', () => { 158 test('returns empty array when no keywords', async () => { 159 const result = await listKeywords(); 160 assert.ok(Array.isArray(result)); 161 assert.strictEqual(result.length, 0); 162 }); 163 164 test('returns all keywords with correct fields', async () => { 165 insertKeyword('plumber', 'active', 8); 166 insertKeyword('electrician', 'inactive', 3); 167 168 const result = await listKeywords(); 169 assert.ok(result.length >= 2); 170 const fields = ['id', 'keyword', 'status', 'priority']; 171 for (const field of fields) { 172 assert.ok(field in result[0], `Should have field: ${field}`); 173 } 174 }); 175 }); 176 177 describe('updateKeywordPriority', () => { 178 test('updates keyword priority in database', async () => { 179 insertKeyword('update test', 'active', 3); 180 const row = db.prepare("SELECT id FROM keywords WHERE keyword='update test'").get(); 181 182 await updateKeywordPriority(row.id, 9); 183 184 const updated = db.prepare('SELECT priority FROM keywords WHERE id=?').get(row.id); 185 assert.strictEqual(updated.priority, 9); 186 }); 187 188 test('updating non-existent ID does not throw', async () => { 189 await assert.doesNotReject(() => updateKeywordPriority(999999, 7)); 190 }); 191 }); 192 });