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