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