/ tests / utils / keywords.test.js
keywords.test.js
  1  /**
  2   * Tests for Keywords Stage
  3   *
  4   * Migrated to use db.js mock (pg-mock) — keywords.js now uses db.js (PostgreSQL).
  5   */
  6  
  7  import { test, describe, mock, beforeEach } from 'node:test';
  8  import assert from 'node:assert';
  9  import Database from 'better-sqlite3';
 10  import { createPgMock } from '../helpers/pg-mock.js';
 11  
 12  // Minimal schema for keywords and sites tables
 13  const SCHEMA_SQL = `
 14    CREATE TABLE IF NOT EXISTS keywords (
 15      id INTEGER PRIMARY KEY AUTOINCREMENT,
 16      keyword TEXT NOT NULL,
 17      priority INTEGER NOT NULL DEFAULT 5 CHECK(priority >= 1 AND priority <= 10),
 18      status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'paused')),
 19      country_code TEXT NOT NULL DEFAULT 'AU',
 20      google_domain TEXT NOT NULL DEFAULT 'google.com.au',
 21      search_count INTEGER NOT NULL DEFAULT 0,
 22      zenrows_count INTEGER NOT NULL DEFAULT 0,
 23      low_scoring_count INTEGER NOT NULL DEFAULT 0,
 24      last_scraped_at DATETIME,
 25      last_searched_at DATETIME,
 26      created_at DATETIME DEFAULT (datetime('now')),
 27      updated_at DATETIME DEFAULT (datetime('now')),
 28      UNIQUE (keyword, country_code)
 29    );
 30  
 31    CREATE TABLE IF NOT EXISTS sites (
 32      id INTEGER PRIMARY KEY AUTOINCREMENT,
 33      domain TEXT NOT NULL,
 34      landing_page_url TEXT NOT NULL DEFAULT '',
 35      keyword TEXT,
 36      score REAL,
 37      status TEXT DEFAULT 'found',
 38      created_at DATETIME DEFAULT (datetime('now'))
 39    );
 40  `;
 41  
 42  // Shared in-memory SQLite DB — mock.module can only be called once
 43  const db = new Database(':memory:');
 44  db.exec(SCHEMA_SQL);
 45  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 46  
 47  // Dynamic import AFTER mock.module
 48  const { runKeywordsStage, addKeyword, listKeywords, updateKeywordPriority } =
 49    await import('../../src/stages/keywords.js');
 50  
 51  describe('Keywords Stage', () => {
 52    beforeEach(() => {
 53      db.exec('DELETE FROM keywords; DELETE FROM sites;');
 54    });
 55  
 56    describe('addKeyword', () => {
 57      test('adds keyword with default priority', async () => {
 58        const id = await addKeyword('plumber sydney');
 59        assert.ok(id > 0, 'Should return valid ID');
 60  
 61        const keyword = db.prepare('SELECT * FROM keywords WHERE id = ?').get(id);
 62  
 63        assert.strictEqual(keyword.keyword, 'plumber sydney');
 64        assert.strictEqual(keyword.priority, 5);
 65        assert.strictEqual(keyword.status, 'active');
 66        assert.strictEqual(keyword.search_count, 0);
 67      });
 68  
 69      test('adds keyword with custom priority', async () => {
 70        const id = await addKeyword('electrician melbourne', 8);
 71        assert.ok(id > 0, 'Should return valid ID');
 72  
 73        const keyword = db.prepare('SELECT * FROM keywords WHERE id = ?').get(id);
 74  
 75        assert.strictEqual(keyword.keyword, 'electrician melbourne');
 76        assert.strictEqual(keyword.priority, 8);
 77        assert.strictEqual(keyword.status, 'active');
 78      });
 79  
 80      test('enforces unique constraint on keyword', async () => {
 81        await addKeyword('plumber sydney', 5);
 82  
 83        await assert.rejects(
 84          async () => { await addKeyword('plumber sydney', 7); },
 85          /UNIQUE constraint failed/
 86        );
 87      });
 88  
 89      test('enforces priority bounds (1-10)', () => {
 90        // Priority < 1 should fail
 91        assert.throws(() => {
 92          db
 93            .prepare(
 94              'INSERT INTO keywords (keyword, priority, status, country_code, google_domain) VALUES (?, ?, ?, ?, ?)'
 95            )
 96            .run('test keyword', 0, 'active', 'AU', 'google.com.au');
 97        }, /CHECK constraint failed/);
 98  
 99        // Priority > 10 should fail
100        assert.throws(() => {
101          db
102            .prepare(
103              'INSERT INTO keywords (keyword, priority, status, country_code, google_domain) VALUES (?, ?, ?, ?, ?)'
104            )
105            .run('test keyword 2', 11, 'active', 'AU', 'google.com.au');
106        }, /CHECK constraint failed/);
107      });
108    });
109  
110    describe('updateKeywordPriority', () => {
111      test('updates keyword priority', async () => {
112        const id = await addKeyword('plumber sydney', 5);
113        await updateKeywordPriority(id, 9);
114  
115        const keyword = db.prepare('SELECT priority FROM keywords WHERE id = ?').get(id);
116  
117        assert.strictEqual(keyword.priority, 9);
118      });
119  
120      test('enforces priority bounds via CHECK constraint', async () => {
121        const id = await addKeyword('plumber sydney', 5);
122  
123        // Attempting to set priority < 1 should fail
124        assert.throws(() => {
125          db.prepare('UPDATE keywords SET priority = ? WHERE id = ?').run(0, id);
126        }, /CHECK constraint failed/);
127  
128        // Attempting to set priority > 10 should fail
129        assert.throws(() => {
130          db.prepare('UPDATE keywords SET priority = ? WHERE id = ?').run(11, id);
131        }, /CHECK constraint failed/);
132      });
133    });
134  
135    describe('listKeywords', () => {
136      test('returns empty array when no keywords exist', async () => {
137        const keywords = await listKeywords();
138        assert.strictEqual(keywords.length, 0);
139      });
140  
141      test('lists keywords with stats', async () => {
142        const id1 = await addKeyword('plumber sydney', 8);
143        const id2 = await addKeyword('electrician melbourne', 5);
144  
145        const keywords = await listKeywords();
146  
147        assert.strictEqual(keywords.length, 2);
148        assert.strictEqual(keywords[0].id, id1); // Higher priority first
149        assert.strictEqual(keywords[0].keyword, 'plumber sydney');
150        assert.strictEqual(keywords[0].priority, 8);
151        assert.strictEqual(keywords[0].total_sites, 0);
152        assert.strictEqual(keywords[0].low_score_sites, 0);
153  
154        assert.strictEqual(keywords[1].id, id2);
155        assert.strictEqual(keywords[1].keyword, 'electrician melbourne');
156        assert.strictEqual(keywords[1].priority, 5);
157      });
158  
159      test('orders by priority DESC, then search_count ASC', async () => {
160        // Insert keywords with different priorities and search counts
161        db
162          .prepare(
163            'INSERT INTO keywords (keyword, priority, status, search_count, country_code, google_domain) VALUES (?, ?, ?, ?, ?, ?)'
164          )
165          .run('keyword-high-priority', 9, 'active', 5, 'AU', 'google.com.au');
166        db
167          .prepare(
168            'INSERT INTO keywords (keyword, priority, status, search_count, country_code, google_domain) VALUES (?, ?, ?, ?, ?, ?)'
169          )
170          .run('keyword-low-priority-low-count', 3, 'active', 1, 'AU', 'google.com.au');
171        db
172          .prepare(
173            'INSERT INTO keywords (keyword, priority, status, search_count, country_code, google_domain) VALUES (?, ?, ?, ?, ?, ?)'
174          )
175          .run('keyword-low-priority-high-count', 3, 'active', 10, 'AU', 'google.com.au');
176  
177        const keywords = await listKeywords();
178  
179        // Should be ordered: high priority (9), then low priority sorted by search_count
180        assert.strictEqual(keywords[0].keyword, 'keyword-high-priority');
181        assert.strictEqual(keywords[1].keyword, 'keyword-low-priority-low-count');
182        assert.strictEqual(keywords[2].keyword, 'keyword-low-priority-high-count');
183      });
184  
185      test('joins with sites table correctly', async () => {
186        // Add keyword
187        await addKeyword('plumber sydney', 5);
188  
189        // Add sites with matching keyword
190        db
191          .prepare('INSERT INTO sites (keyword, domain, landing_page_url, score) VALUES (?, ?, ?, ?)')
192          .run('plumber sydney', 'example1.com', 'https://example1.com', 95);
193        db
194          .prepare('INSERT INTO sites (keyword, domain, landing_page_url, score) VALUES (?, ?, ?, ?)')
195          .run('plumber sydney', 'example2.com', 'https://example2.com', 72);
196        db
197          .prepare('INSERT INTO sites (keyword, domain, landing_page_url, score) VALUES (?, ?, ?, ?)')
198          .run('plumber sydney', 'example3.com', 'https://example3.com', 80);
199  
200        const keywords = await listKeywords();
201  
202        assert.strictEqual(keywords[0].total_sites, 3);
203        assert.strictEqual(keywords[0].low_score_sites, 2); // Scores < 83 (72 and 80)
204      });
205    });
206  
207    describe('runKeywordsStage', () => {
208      test('returns empty stats when no active keywords exist', async () => {
209        const stats = await runKeywordsStage();
210  
211        assert.strictEqual(stats.processed, 0);
212        assert.strictEqual(stats.succeeded, 0);
213        assert.strictEqual(stats.failed, 0);
214        assert.ok(stats.duration >= 0);
215      });
216  
217      test('processes active keywords', async () => {
218        await addKeyword('plumber sydney', 8);
219        await addKeyword('electrician melbourne', 5);
220  
221        const stats = await runKeywordsStage();
222  
223        assert.strictEqual(stats.processed, 2);
224        assert.strictEqual(stats.succeeded, 2);
225        assert.strictEqual(stats.failed, 0);
226      });
227  
228      test('respects limit parameter', async () => {
229        await addKeyword('plumber sydney', 8);
230        await addKeyword('electrician melbourne', 7);
231        await addKeyword('carpenter brisbane', 6);
232  
233        const stats = await runKeywordsStage({ limit: 2 });
234  
235        assert.strictEqual(stats.processed, 2);
236        assert.strictEqual(stats.succeeded, 2);
237      });
238  
239      test('only selects active keywords', async () => {
240        db
241          .prepare(
242            'INSERT INTO keywords (keyword, priority, status, country_code, google_domain) VALUES (?, ?, ?, ?, ?)'
243          )
244          .run('active-keyword', 8, 'active', 'AU', 'google.com.au');
245        db
246          .prepare(
247            'INSERT INTO keywords (keyword, priority, status, country_code, google_domain) VALUES (?, ?, ?, ?, ?)'
248          )
249          .run('inactive-keyword', 9, 'inactive', 'AU', 'google.com.au');
250  
251        const stats = await runKeywordsStage();
252  
253        assert.strictEqual(stats.processed, 1);
254        assert.strictEqual(stats.succeeded, 1);
255      });
256    });
257  
258    describe('Schema Validation', () => {
259      test('keywords table has all required columns', () => {
260        const tableInfo = db.prepare("PRAGMA table_info('keywords')").all();
261  
262        const columnNames = tableInfo.map(col => col.name);
263  
264        assert.ok(columnNames.includes('id'));
265        assert.ok(columnNames.includes('keyword'));
266        assert.ok(columnNames.includes('priority'));
267        assert.ok(columnNames.includes('status'));
268        assert.ok(columnNames.includes('search_count'));
269        assert.ok(columnNames.includes('zenrows_count'));
270        assert.ok(columnNames.includes('low_scoring_count'));
271        assert.ok(columnNames.includes('last_scraped_at'));
272        assert.ok(columnNames.includes('last_searched_at'));
273        assert.ok(columnNames.includes('created_at'));
274        assert.ok(columnNames.includes('updated_at'));
275      });
276  
277      test('status column has correct CHECK constraint', () => {
278        // Valid status should work
279        db
280          .prepare(
281            'INSERT INTO keywords (keyword, status, country_code, google_domain) VALUES (?, ?, ?, ?)'
282          )
283          .run('test-active', 'active', 'AU', 'google.com.au');
284        db
285          .prepare(
286            'INSERT INTO keywords (keyword, status, country_code, google_domain) VALUES (?, ?, ?, ?)'
287          )
288          .run('test-inactive', 'inactive', 'AU', 'google.com.au');
289  
290        // Invalid status should fail
291        assert.throws(() => {
292          db
293            .prepare(
294              'INSERT INTO keywords (keyword, status, country_code, google_domain) VALUES (?, ?, ?, ?)'
295            )
296            .run('test-invalid', 'invalid', 'AU', 'google.com.au');
297        }, /CHECK constraint failed/);
298      });
299    });
300  });