keyword-validator-supplement2.test.js
1 /** 2 * keyword-validator-supplement2.test.js 3 * 4 * Covers API paths in src/utils/keyword-validator.js that require 5 * mocking fetch and DATAFORSEO credentials: 6 * - getTopSearches (lines 204-238) 7 * - expandKeywordsLabs (lines 250-295) 8 * - getSearchVolumesLabs (lines 314-370) 9 * - expandKeyword (deprecated API, lines 393-417) 10 * - getSearchVolumes (deprecated API, lines 437-477) 11 * 12 * Strategy: set DATAFORSEO_LOGIN/PASSWORD env vars and mock global fetch. 13 */ 14 15 import { test, describe, before, after } from 'node:test'; 16 import assert from 'node:assert/strict'; 17 import { writeFileSync, unlinkSync, existsSync } from 'fs'; 18 import { 19 getTopSearches, 20 expandKeywordsLabs, 21 getSearchVolumesLabs, 22 expandKeyword, 23 getSearchVolumes, 24 generateSearchVolumeCSV, 25 } from '../../src/utils/keyword-validator.js'; 26 27 // ─── Setup credentials for all tests ───────────────────────────────────────── 28 29 let savedLogin; 30 let savedPassword; 31 let originalFetch; 32 33 before(() => { 34 savedLogin = process.env.DATAFORSEO_LOGIN; 35 savedPassword = process.env.DATAFORSEO_PASSWORD; 36 process.env.DATAFORSEO_LOGIN = 'test-login'; 37 process.env.DATAFORSEO_PASSWORD = 'test-password'; 38 originalFetch = globalThis.fetch; 39 }); 40 41 after(() => { 42 if (savedLogin === undefined) delete process.env.DATAFORSEO_LOGIN; 43 else process.env.DATAFORSEO_LOGIN = savedLogin; 44 if (savedPassword === undefined) delete process.env.DATAFORSEO_PASSWORD; 45 else process.env.DATAFORSEO_PASSWORD = savedPassword; 46 globalThis.fetch = originalFetch; 47 }); 48 49 // ─── Helper: mock fetch to return controlled response ───────────────────────── 50 51 function mockFetch(responseBody, { ok = true, status = 200, statusText = 'OK' } = {}) { 52 globalThis.fetch = async () => ({ 53 ok, 54 status, 55 statusText, 56 json: async () => responseBody, 57 }); 58 } 59 60 // ─── getTopSearches ──────────────────────────────────────────────────────────── 61 62 describe('getTopSearches', () => { 63 test('returns mapped keyword array on success', async () => { 64 mockFetch({ 65 tasks: [ 66 { 67 status_code: 20000, 68 result: [ 69 { 70 items: [ 71 { 72 keyword: 'plumber near me', 73 keyword_info: { search_volume: 1000, competition: 0.5, cpc: 3.5 }, 74 }, 75 { 76 keyword: 'emergency plumber', 77 keyword_info: { search_volume: 500, competition: 0.8, cpc: 5.0 }, 78 }, 79 ], 80 }, 81 ], 82 }, 83 ], 84 }); 85 86 const result = await getTopSearches('AU', 10); 87 88 assert.strictEqual(result.length, 2); 89 assert.strictEqual(result[0].keyword, 'plumber near me'); 90 assert.strictEqual(result[0].searchVolume, 1000); 91 assert.strictEqual(result[0].competition, 50); // 0.5 * 100 92 assert.strictEqual(result[0].cpcLow, 3.5); 93 assert.strictEqual(result[0].cpcHigh, 3.5); 94 }); 95 96 test('returns empty array when status_code is not 20000', async () => { 97 mockFetch({ 98 tasks: [ 99 { 100 status_code: 40000, 101 status_message: 'Rate limit exceeded', 102 result: null, 103 }, 104 ], 105 }); 106 107 const result = await getTopSearches('US', 10); 108 assert.deepStrictEqual(result, []); 109 }); 110 111 test('throws on empty tasks array', async () => { 112 mockFetch({ tasks: [] }); 113 114 await assert.rejects( 115 () => getTopSearches('AU', 10), 116 /Invalid response from DataForSEO Top Searches API/ 117 ); 118 }); 119 120 test('throws on null response', async () => { 121 mockFetch(null); 122 123 await assert.rejects( 124 () => getTopSearches('AU', 10), 125 /Invalid response from DataForSEO Top Searches API/ 126 ); 127 }); 128 129 test('throws when fetch fails (non-ok response)', async () => { 130 mockFetch(null, { ok: false, status: 500, statusText: 'Internal Server Error' }); 131 132 await assert.rejects( 133 () => getTopSearches('AU', 10), 134 /DataForSEO API error: 500 Internal Server Error/ 135 ); 136 }); 137 138 test('handles items with missing keyword_info gracefully', async () => { 139 mockFetch({ 140 tasks: [ 141 { 142 status_code: 20000, 143 result: [ 144 { 145 items: [{ keyword: 'no info keyword' }], 146 }, 147 ], 148 }, 149 ], 150 }); 151 152 const result = await getTopSearches('AU', 10); 153 assert.strictEqual(result.length, 1); 154 assert.strictEqual(result[0].searchVolume, 0); 155 assert.strictEqual(result[0].competition, 0); 156 assert.strictEqual(result[0].cpcLow, 0); 157 }); 158 }); 159 160 // ─── expandKeywordsLabs ──────────────────────────────────────────────────────── 161 162 describe('expandKeywordsLabs', () => { 163 test('returns map with expanded keywords for each seed', async () => { 164 mockFetch({ 165 tasks: [ 166 { 167 status_code: 20000, 168 result: [ 169 { 170 items: [ 171 { 172 keyword: 'sydney plumber', 173 keyword_data: { 174 keyword_info: { search_volume: 200, competition: 0.4, cpc: 2.5 }, 175 }, 176 }, 177 { 178 keyword: 'plumber cost', 179 keyword_data: { 180 keyword_info: { search_volume: 150, competition: 0.3, cpc: 2.0 }, 181 }, 182 }, 183 ], 184 }, 185 ], 186 }, 187 ], 188 }); 189 190 const result = await expandKeywordsLabs(['plumber'], 'AU', 50); 191 192 assert.ok(result instanceof Map); 193 assert.ok(result.has('plumber')); 194 const expanded = result.get('plumber'); 195 assert.strictEqual(expanded.length, 2); 196 assert.strictEqual(expanded[0].keyword, 'sydney plumber'); 197 assert.strictEqual(expanded[0].searchVolume, 200); 198 }); 199 200 test('handles status_code != 20000 by setting empty array for seed', async () => { 201 mockFetch({ 202 tasks: [ 203 { 204 status_code: 40001, 205 status_message: 'Not enough credits', 206 }, 207 ], 208 }); 209 210 const result = await expandKeywordsLabs(['electrician'], 'US', 10); 211 assert.ok(result instanceof Map); 212 assert.ok(result.has('electrician')); 213 assert.deepStrictEqual(result.get('electrician'), []); 214 }); 215 216 test('handles fetch error gracefully and sets empty array', async () => { 217 globalThis.fetch = async () => { 218 throw new Error('Network failure'); 219 }; 220 221 const result = await expandKeywordsLabs(['dentist'], 'AU', 10); 222 assert.ok(result instanceof Map); 223 assert.ok(result.has('dentist')); 224 assert.deepStrictEqual(result.get('dentist'), []); 225 }); 226 227 test('handles empty items array with debug log path', async () => { 228 mockFetch({ 229 tasks: [ 230 { 231 status_code: 20000, 232 result: [{ items: [] }], 233 }, 234 ], 235 }); 236 237 const result = await expandKeywordsLabs(['rare keyword'], 'AU', 10); 238 assert.ok(result.has('rare keyword')); 239 assert.deepStrictEqual(result.get('rare keyword'), []); 240 }); 241 242 test('processes multiple seeds in sequence', async () => { 243 let callCount = 0; 244 globalThis.fetch = async () => { 245 callCount++; 246 return { 247 ok: true, 248 json: async () => ({ 249 tasks: [ 250 { 251 status_code: 20000, 252 result: [ 253 { 254 items: [ 255 { 256 keyword: `result-${callCount}`, 257 keyword_data: { 258 keyword_info: { 259 search_volume: callCount * 100, 260 competition: 0.1, 261 cpc: 1.0, 262 }, 263 }, 264 }, 265 ], 266 }, 267 ], 268 }, 269 ], 270 }), 271 }; 272 }; 273 274 const result = await expandKeywordsLabs(['seed1', 'seed2'], 'AU', 10); 275 assert.ok(result.has('seed1')); 276 assert.ok(result.has('seed2')); 277 assert.strictEqual(result.get('seed1').length, 1); 278 assert.strictEqual(result.get('seed2').length, 1); 279 }); 280 }); 281 282 // ─── getSearchVolumesLabs ────────────────────────────────────────────────────── 283 // getSearchVolumesLabs uses language_name and result[0].items with keyword_info.* 284 // On error/invalid response, it logs a warning and continues (does NOT throw) 285 286 describe('getSearchVolumesLabs', () => { 287 test('returns empty array for empty keywords input', async () => { 288 const result = await getSearchVolumesLabs([], 'AU'); 289 assert.deepStrictEqual(result, []); 290 }); 291 292 test('returns mapped keywords with search volumes on success', async () => { 293 mockFetch({ 294 tasks: [ 295 { 296 status_code: 20000, 297 result: [ 298 { 299 items: [ 300 { 301 keyword: 'plumber', 302 keyword_info: { search_volume: 1000, competition: 0.5, cpc: 3.0 }, 303 }, 304 { 305 keyword: 'electrician', 306 keyword_info: { search_volume: 800, competition: 0.4, cpc: 2.5 }, 307 }, 308 ], 309 }, 310 ], 311 }, 312 ], 313 }); 314 315 const result = await getSearchVolumesLabs(['plumber', 'electrician'], 'AU'); 316 assert.strictEqual(result.length, 2); 317 assert.strictEqual(result[0].keyword, 'plumber'); 318 assert.strictEqual(result[0].searchVolume, 1000); 319 }); 320 321 test('returns empty array when response is empty (warns and continues)', async () => { 322 // getSearchVolumesLabs warns and continues on bad response (no throw) 323 mockFetch({ tasks: [] }); 324 325 const result = await getSearchVolumesLabs(['plumber'], 'AU'); 326 assert.deepStrictEqual(result, []); 327 }); 328 329 test('returns empty array when status_code is not 20000 (warns and continues)', async () => { 330 mockFetch({ 331 tasks: [ 332 { 333 status_code: 40000, 334 status_message: 'Quota exceeded', 335 }, 336 ], 337 }); 338 339 const result = await getSearchVolumesLabs(['plumber'], 'AU'); 340 assert.deepStrictEqual(result, []); 341 }); 342 343 test('handles large keyword lists by batching (>700 keywords)', async () => { 344 // Track how many fetch calls are made (should be 2 for 701 keywords) 345 let fetchCallCount = 0; 346 globalThis.fetch = async () => { 347 fetchCallCount++; 348 return { 349 ok: true, 350 json: async () => ({ 351 tasks: [ 352 { 353 status_code: 20000, 354 result: [ 355 { 356 items: [ 357 { 358 keyword: `kw-${fetchCallCount}`, 359 keyword_info: { search_volume: 100, competition: 0.1, cpc: 1.0 }, 360 }, 361 ], 362 }, 363 ], 364 }, 365 ], 366 }), 367 }; 368 }; 369 370 const keywords = Array.from({ length: 701 }, (_, i) => `keyword-${i}`); 371 const result = await getSearchVolumesLabs(keywords, 'AU'); 372 373 // Should have made 2 fetch calls (700 + 1) 374 assert.strictEqual(fetchCallCount, 2); 375 // Should have 2 results (one from each batch) 376 assert.strictEqual(result.length, 2); 377 }); 378 379 test('accepts optional language parameter', async () => { 380 mockFetch({ 381 tasks: [ 382 { 383 status_code: 20000, 384 result: [ 385 { 386 items: [ 387 { 388 keyword: 'mot clé', 389 keyword_info: { search_volume: 500, competition: 0.3, cpc: 1.5 }, 390 }, 391 ], 392 }, 393 ], 394 }, 395 ], 396 }); 397 398 const result = await getSearchVolumesLabs(['mot clé'], 'FR', 'fr'); 399 assert.strictEqual(result.length, 1); 400 assert.strictEqual(result[0].keyword, 'mot clé'); 401 }); 402 }); 403 404 // ─── expandKeyword (deprecated API) ────────────────────────────────────────── 405 406 describe('expandKeyword (deprecated single API)', () => { 407 test('returns mapped keywords on success', async () => { 408 mockFetch({ 409 tasks: [ 410 { 411 status_code: 20000, 412 result: [ 413 { keyword: 'related plumber', search_volume: 300, competition: 0.4, cpc: 2.0 }, 414 { keyword: 'another plumber term', search_volume: 200, competition: 0.3, cpc: 1.5 }, 415 ], 416 }, 417 ], 418 }); 419 420 const result = await expandKeyword('plumber', 'AU', 10); 421 assert.strictEqual(result.length, 2); 422 assert.strictEqual(result[0].keyword, 'related plumber'); 423 assert.strictEqual(result[0].searchVolume, 300); 424 assert.strictEqual(result[0].competition, 40); // 0.4 * 100 425 }); 426 427 test('throws on empty tasks array', async () => { 428 mockFetch({ tasks: [] }); 429 430 await assert.rejects( 431 () => expandKeyword('plumber', 'AU', 10), 432 /Invalid response from DataForSEO API/ 433 ); 434 }); 435 436 test('throws when status_code is not 20000', async () => { 437 mockFetch({ 438 tasks: [ 439 { 440 status_code: 40000, 441 status_message: 'API error occurred', 442 }, 443 ], 444 }); 445 446 await assert.rejects( 447 () => expandKeyword('plumber', 'AU', 10), 448 /DataForSEO API error: API error occurred/ 449 ); 450 }); 451 452 test('throws when status_code is not 20000 and status_message missing', async () => { 453 mockFetch({ 454 tasks: [{ status_code: 40000 }], 455 }); 456 457 await assert.rejects( 458 () => expandKeyword('plumber', 'AU', 10), 459 /DataForSEO API error: Unknown error/ 460 ); 461 }); 462 463 test('respects limit parameter by slicing result', async () => { 464 mockFetch({ 465 tasks: [ 466 { 467 status_code: 20000, 468 result: Array.from({ length: 20 }, (_, i) => ({ 469 keyword: `keyword-${i}`, 470 search_volume: i * 10, 471 competition: 0.1, 472 cpc: 1.0, 473 })), 474 }, 475 ], 476 }); 477 478 const result = await expandKeyword('plumber', 'AU', 5); 479 assert.strictEqual(result.length, 5); 480 }); 481 }); 482 483 // ─── getSearchVolumes (deprecated API) ──────────────────────────────────────── 484 485 describe('getSearchVolumes (deprecated batch API)', () => { 486 test('returns mapped keywords with volumes on success', async () => { 487 mockFetch({ 488 tasks: [ 489 { 490 status_code: 20000, 491 result: [{ keyword: 'plumber', search_volume: 500, competition: 0.5, cpc: 3.0 }], 492 }, 493 ], 494 }); 495 496 const result = await getSearchVolumes(['plumber'], 'AU'); 497 assert.strictEqual(result.length, 1); 498 assert.strictEqual(result[0].keyword, 'plumber'); 499 assert.strictEqual(result[0].searchVolume, 500); 500 }); 501 502 test('throws on empty tasks array', async () => { 503 mockFetch({ tasks: [] }); 504 505 await assert.rejects( 506 () => getSearchVolumes(['plumber'], 'AU'), 507 /Invalid response from DataForSEO API/ 508 ); 509 }); 510 511 test('throws when status_code is not 20000', async () => { 512 mockFetch({ 513 tasks: [ 514 { 515 status_code: 40000, 516 status_message: 'Batch error', 517 }, 518 ], 519 }); 520 521 await assert.rejects( 522 () => getSearchVolumes(['plumber'], 'AU'), 523 /DataForSEO API error: Batch error/ 524 ); 525 }); 526 527 test('processes multiple keywords in batches (>1000)', async () => { 528 let fetchCallCount = 0; 529 globalThis.fetch = async () => { 530 fetchCallCount++; 531 return { 532 ok: true, 533 json: async () => ({ 534 tasks: [ 535 { 536 status_code: 20000, 537 result: [ 538 { 539 keyword: `kw-batch-${fetchCallCount}`, 540 search_volume: 100, 541 competition: 0, 542 cpc: 0, 543 }, 544 ], 545 }, 546 ], 547 }), 548 }; 549 }; 550 551 const keywords = Array.from({ length: 1001 }, (_, i) => `keyword-${i}`); 552 const result = await getSearchVolumes(keywords, 'AU'); 553 554 // Should have made 2 fetch calls 555 assert.strictEqual(fetchCallCount, 2); 556 assert.strictEqual(result.length, 2); 557 }); 558 }); 559 560 // ─── generateSearchVolumeCSV ─────────────────────────────────────────────────── 561 562 describe('generateSearchVolumeCSV', () => { 563 const tmpSeedFile = '/tmp/kv-test-seeds.txt'; 564 const tmpOutputFile = '/tmp/kv-test-output.csv'; 565 566 after(() => { 567 if (existsSync(tmpSeedFile)) unlinkSync(tmpSeedFile); 568 if (existsSync(tmpOutputFile)) unlinkSync(tmpOutputFile); 569 }); 570 571 test('throws when seed file does not exist', async () => { 572 await assert.rejects( 573 () => generateSearchVolumeCSV('/tmp/nonexistent-seeds-xyz.txt', 'AU', tmpOutputFile), 574 /File not found/ 575 ); 576 }); 577 578 test('runs full workflow and writes CSV when seed file exists', async () => { 579 // Write a temp seed file 580 writeFileSync(tmpSeedFile, 'plumber\n# comment line\nelectrician\n', 'utf-8'); 581 582 // Mock fetch to return a valid expandKeywordsLabs response for each seed, 583 // then a valid getSearchVolumesLabs response 584 let fetchCallCount = 0; 585 globalThis.fetch = async () => { 586 fetchCallCount++; 587 return { 588 ok: true, 589 json: async () => ({ 590 tasks: [ 591 { 592 status_code: 20000, 593 result: [ 594 { 595 items: [ 596 { 597 keyword: `expanded-keyword-${fetchCallCount}`, 598 keyword_data: { 599 keyword_info: { 600 search_volume: fetchCallCount * 100, 601 competition: 0.3, 602 cpc: 2.0, 603 }, 604 }, 605 keyword_info: { 606 search_volume: fetchCallCount * 100, 607 competition: 0.3, 608 cpc: 2.0, 609 }, 610 }, 611 ], 612 }, 613 ], 614 }, 615 ], 616 }), 617 }; 618 }; 619 620 const result = await generateSearchVolumeCSV(tmpSeedFile, 'AU', tmpOutputFile); 621 622 assert.strictEqual(typeof result.seedCount, 'number'); 623 assert.strictEqual(result.seedCount, 2); // 'plumber' and 'electrician' (comment filtered) 624 assert.ok(existsSync(tmpOutputFile), 'CSV output file should be created'); 625 }); 626 });