/ tests / utils / keyword-validator-supplement2.test.js
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  });