/ tests / utils / keyword-validator.test.js
keyword-validator.test.js
  1  /**
  2   * Keyword Validator Tests
  3   */
  4  
  5  import { test } from 'node:test';
  6  import assert from 'node:assert/strict';
  7  import fs from 'fs';
  8  import path from 'path';
  9  import {
 10    getLocationCode,
 11    getLanguageCode,
 12    expandKeyword,
 13    getSearchVolumes,
 14    generateSearchVolumeCSV,
 15    filterKeywordsByVolume,
 16    analyzeSearchVolumes,
 17  } from '../../src/utils/keyword-validator.js';
 18  
 19  test('getLocationCode returns correct DataForSEO location codes', () => {
 20    assert.strictEqual(getLocationCode('AU'), 2036);
 21    assert.strictEqual(getLocationCode('US'), 2840);
 22    assert.strictEqual(getLocationCode('UK'), 2826);
 23    assert.strictEqual(getLocationCode('DE'), 2276);
 24    assert.strictEqual(getLocationCode('JP'), 2392);
 25    assert.strictEqual(getLocationCode('au'), 2036); // lowercase
 26    assert.strictEqual(getLocationCode('INVALID'), 2036); // default to AU
 27  });
 28  
 29  test('getLanguageCode returns correct language codes', () => {
 30    assert.strictEqual(getLanguageCode('AU'), 'en');
 31    assert.strictEqual(getLanguageCode('US'), 'en');
 32    assert.strictEqual(getLanguageCode('DE'), 'de');
 33    assert.strictEqual(getLanguageCode('FR'), 'fr');
 34    assert.strictEqual(getLanguageCode('JP'), 'ja');
 35    assert.strictEqual(getLanguageCode('KR'), 'ko');
 36    assert.strictEqual(getLanguageCode('CN'), 'zh-CN');
 37    assert.strictEqual(getLanguageCode('au'), 'en'); // lowercase
 38    assert.strictEqual(getLanguageCode('INVALID'), 'en'); // default to English
 39  });
 40  
 41  test('expandKeyword throws error if DataForSEO credentials missing', async () => {
 42    // Save original env vars
 43    const originalLogin = process.env.DATAFORSEO_LOGIN;
 44    const originalPassword = process.env.DATAFORSEO_PASSWORD;
 45  
 46    // Remove credentials
 47    delete process.env.DATAFORSEO_LOGIN;
 48    delete process.env.DATAFORSEO_PASSWORD;
 49  
 50    await assert.rejects(
 51      async () => {
 52        await expandKeyword('plumber', 'AU');
 53      },
 54      {
 55        name: 'Error',
 56        message: /DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD must be set/,
 57      }
 58    );
 59  
 60    // Restore env vars
 61    if (originalLogin) process.env.DATAFORSEO_LOGIN = originalLogin;
 62    if (originalPassword) process.env.DATAFORSEO_PASSWORD = originalPassword;
 63  });
 64  
 65  test('getSearchVolumes throws error if DataForSEO credentials missing', async () => {
 66    // Save original env vars
 67    const originalLogin = process.env.DATAFORSEO_LOGIN;
 68    const originalPassword = process.env.DATAFORSEO_PASSWORD;
 69  
 70    // Remove credentials
 71    delete process.env.DATAFORSEO_LOGIN;
 72    delete process.env.DATAFORSEO_PASSWORD;
 73  
 74    await assert.rejects(
 75      async () => {
 76        await getSearchVolumes(['plumber', 'electrician'], 'AU');
 77      },
 78      {
 79        name: 'Error',
 80        message: /DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD must be set/,
 81      }
 82    );
 83  
 84    // Restore env vars
 85    if (originalLogin) process.env.DATAFORSEO_LOGIN = originalLogin;
 86    if (originalPassword) process.env.DATAFORSEO_PASSWORD = originalPassword;
 87  });
 88  
 89  test('generateSearchVolumeCSV throws error for non-existent file', async () => {
 90    const nonExistentPath = '/tmp/nonexistent-keyword-file-xyz123.txt';
 91  
 92    await assert.rejects(
 93      async () => {
 94        await generateSearchVolumeCSV(nonExistentPath, 'AU', '/tmp/output-xyz123.csv');
 95      },
 96      {
 97        name: 'Error',
 98        message: /File not found/,
 99      }
100    );
101  });
102  
103  test('filterKeywordsByVolume filters keywords by minimum search volume', async () => {
104    // Create test CSV file
105    const testCsvPath = '/tmp/test-keywords-filter.csv';
106    const testOutputPath = '/tmp/test-keywords-filtered.txt';
107  
108    const csvContent = `keyword,search_volume,competition,cpc_low,cpc_high,related_to,country_code
109  plumber,450000,78,12.50,45.00,plumber,AU
110  plumber near me,250000,65,8.00,35.00,plumber,AU
111  emergency plumber,180000,88,20.00,75.00,plumber,AU
112  cheap plumber,45000,45,3.50,12.00,plumber,AU
113  24 hour plumber,120000,75,18.00,60.00,plumber,AU`;
114  
115    fs.writeFileSync(testCsvPath, csvContent);
116  
117    try {
118      // Filter with threshold 100000
119      const stats = await filterKeywordsByVolume(testCsvPath, 100000, testOutputPath);
120  
121      // Should keep 3 keywords (450K, 250K, 180K, 120K) and remove 1 (45K)
122      assert.strictEqual(stats.totalKeywords, 5);
123      assert.strictEqual(stats.filteredKeywords, 4);
124      assert.strictEqual(stats.removedKeywords, 1);
125  
126      // Verify output file
127      const outputContent = fs.readFileSync(testOutputPath, 'utf-8');
128      const lines = outputContent.trim().split('\n').filter(Boolean);
129  
130      assert.strictEqual(lines.length, 4);
131      assert.ok(lines.includes('plumber'));
132      assert.ok(lines.includes('plumber near me'));
133      assert.ok(lines.includes('emergency plumber'));
134      assert.ok(lines.includes('24 hour plumber'));
135      assert.ok(!lines.includes('cheap plumber')); // Should be removed
136    } finally {
137      // Cleanup
138      if (fs.existsSync(testCsvPath)) fs.unlinkSync(testCsvPath);
139      if (fs.existsSync(testOutputPath)) fs.unlinkSync(testOutputPath);
140    }
141  });
142  
143  test('filterKeywordsByVolume handles empty CSV', async () => {
144    const testCsvPath = '/tmp/test-keywords-empty.csv';
145    const testOutputPath = '/tmp/test-keywords-empty-output.txt';
146  
147    // Create CSV with only header
148    fs.writeFileSync(
149      testCsvPath,
150      'keyword,search_volume,competition,cpc_low,cpc_high,related_to,country_code\n'
151    );
152  
153    try {
154      const stats = await filterKeywordsByVolume(testCsvPath, 100000, testOutputPath);
155  
156      assert.strictEqual(stats.totalKeywords, 0);
157      assert.strictEqual(stats.filteredKeywords, 0);
158      assert.strictEqual(stats.removedKeywords, 0);
159    } finally {
160      // Cleanup
161      if (fs.existsSync(testCsvPath)) fs.unlinkSync(testCsvPath);
162      if (fs.existsSync(testOutputPath)) fs.unlinkSync(testOutputPath);
163    }
164  });
165  
166  test('analyzeSearchVolumes calculates correct statistics', async () => {
167    // Create test CSV file
168    const testCsvPath = '/tmp/test-keywords-analyze.csv';
169  
170    const csvContent = `keyword,search_volume,competition,cpc_low,cpc_high,related_to,country_code
171  keyword1,500000,70,10.00,40.00,seed1,AU
172  keyword2,400000,65,9.00,35.00,seed1,AU
173  keyword3,300000,80,12.00,45.00,seed2,AU
174  keyword4,200000,60,8.00,30.00,seed2,AU
175  keyword5,100000,55,7.00,25.00,seed3,AU
176  keyword6,50000,50,5.00,20.00,seed3,AU
177  keyword7,25000,45,4.00,15.00,seed4,AU
178  keyword8,10000,40,3.00,10.00,seed4,AU`;
179  
180    fs.writeFileSync(testCsvPath, csvContent);
181  
182    try {
183      const stats = await analyzeSearchVolumes(testCsvPath);
184  
185      assert.strictEqual(stats.total_keywords, 8);
186      assert.strictEqual(stats.statistics.min, 10000);
187      assert.strictEqual(stats.statistics.max, 500000);
188  
189      // Check mean: (500000 + 400000 + 300000 + 200000 + 100000 + 50000 + 25000 + 10000) / 8 = 198125
190      assert.strictEqual(stats.statistics.mean, 198125);
191  
192      // Check median: (100000 + 200000) / 2 = 150000 (sorted: [10k, 25k, 50k, 100k, 200k, 300k, 400k, 500k])
193      assert.strictEqual(stats.statistics.median, 150000);
194  
195      // Check p25: pos = 0.25 * 7 = 1.75, base=1, rest=0.75 → 25000 + 0.75 * 25000 = 43750
196      assert.strictEqual(stats.statistics.p25, 43750);
197  
198      // Check p75: pos = 0.75 * 7 = 5.25, base=5, rest=0.25 → 300000 + 0.25 * 100000 = 325000
199      assert.strictEqual(stats.statistics.p75, 325000);
200  
201      // Check distribution
202      assert.strictEqual(stats.distribution['0-10k'], 0);
203      assert.strictEqual(stats.distribution['10k-50k'], 2); // 10k, 25k (50k excluded: sv < 50000)
204      assert.strictEqual(stats.distribution['50k-100k'], 1); // 50k only (100k excluded: sv < 100000)
205      assert.strictEqual(stats.distribution['100k-200k'], 1); // 100k only (200k excluded)
206      assert.strictEqual(stats.distribution['200k-500k'], 3); // 200k, 300k, 400k (500k excluded)
207      assert.strictEqual(stats.distribution['500k+'], 1); // 500k
208  
209      // Check recommendations
210      assert.ok(stats.recommendations.conservative > 0);
211      assert.ok(stats.recommendations.balanced > 0);
212      assert.ok(stats.recommendations.inclusive > 0);
213    } finally {
214      // Cleanup
215      if (fs.existsSync(testCsvPath)) fs.unlinkSync(testCsvPath);
216    }
217  });
218  
219  test('analyzeSearchVolumes handles single keyword', async () => {
220    const testCsvPath = '/tmp/test-keywords-single.csv';
221  
222    const csvContent = `keyword,search_volume,competition,cpc_low,cpc_high,related_to,country_code
223  plumber,450000,78,12.50,45.00,plumber,AU`;
224  
225    fs.writeFileSync(testCsvPath, csvContent);
226  
227    try {
228      const stats = await analyzeSearchVolumes(testCsvPath);
229  
230      assert.strictEqual(stats.total_keywords, 1);
231      assert.strictEqual(stats.statistics.min, 450000);
232      assert.strictEqual(stats.statistics.max, 450000);
233      assert.strictEqual(stats.statistics.mean, 450000);
234      assert.strictEqual(stats.statistics.median, 450000);
235      assert.strictEqual(stats.statistics.p25, 450000);
236      assert.strictEqual(stats.statistics.p75, 450000);
237    } finally {
238      // Cleanup
239      if (fs.existsSync(testCsvPath)) fs.unlinkSync(testCsvPath);
240    }
241  });
242  
243  test('generateSearchVolumeCSV reads keywords from file', async () => {
244    // Create test keywords file
245    const testKeywordsPath = '/tmp/test-keywords-input.txt';
246    const testCsvPath = '/tmp/test-keywords-output.csv';
247  
248    const keywordsContent = `# Test keywords
249  plumber
250  electrician
251  # Comment line should be ignored
252  
253  roofer`;
254  
255    fs.writeFileSync(testKeywordsPath, keywordsContent);
256  
257    // Mock API calls by setting fake credentials (will fail later, but tests file reading)
258    const originalLogin = process.env.DATAFORSEO_LOGIN;
259    const originalPassword = process.env.DATAFORSEO_PASSWORD;
260    process.env.DATAFORSEO_LOGIN = 'test@example.com';
261    process.env.DATAFORSEO_PASSWORD = 'test_password';
262  
263    try {
264      // The function reads keywords from file and attempts API calls.
265      // With fake credentials, individual keyword expansions may fail gracefully,
266      // resulting in 0 expanded keywords and a successful (empty) CSV output.
267      // We just verify the function accepts a file path and runs without a crash.
268      try {
269        await generateSearchVolumeCSV(testKeywordsPath, 'AU', testCsvPath);
270        // If it resolves, the file was read and function handled API errors gracefully
271      } catch (err) {
272        // If it rejects, verify it's an API error not a file-reading error
273        const isApiError =
274          err.message.includes('API') ||
275          err.message.includes('Request failed') ||
276          err.message.includes('401') ||
277          err.message.includes('403') ||
278          err.message.includes('ENOTFOUND') ||
279          err.message.includes('Unauthorized');
280        assert.ok(isApiError, `Expected API error, got: ${err.message}`);
281      }
282    } finally {
283      // Cleanup
284      if (fs.existsSync(testKeywordsPath)) fs.unlinkSync(testKeywordsPath);
285      if (fs.existsSync(testCsvPath)) fs.unlinkSync(testCsvPath);
286  
287      // Restore env vars
288      if (originalLogin) {
289        process.env.DATAFORSEO_LOGIN = originalLogin;
290      } else {
291        delete process.env.DATAFORSEO_LOGIN;
292      }
293      if (originalPassword) {
294        process.env.DATAFORSEO_PASSWORD = originalPassword;
295      } else {
296        delete process.env.DATAFORSEO_PASSWORD;
297      }
298    }
299  });