/ tests / free-score-api.test.js
free-score-api.test.js
  1  /**
  2   * Tests for the free website scanner inbound funnel.
  3   *
  4   * Unit tests for:
  5   *   - URL normalisation (logic mirrored in Worker scorer.js)
  6   *   - Factor summary building (logic mirrored in Worker index.js)
  7   *   - Free peek selection (logic mirrored in Worker index.js)
  8   *   - Issue counting (logic mirrored in Worker index.js)
  9   *   - Rate limit logic (logic mirrored in Worker index.js)
 10   *   - Poll daemon archiveScans() — SQLite write path
 11   *
 12   * Does NOT test the Cloudflare Worker directly (requires wrangler dev).
 13   * Uses :memory: SQLite and a temporary test DB path — never writes to prod.
 14   */
 15  
 16  import { test, describe, mock, before, after } from 'node:test';
 17  import assert from 'node:assert/strict';
 18  import { randomUUID } from 'crypto';
 19  import Database from 'better-sqlite3';
 20  import { createLazyPgMock } from './helpers/pg-mock.js';
 21  
 22  // archiveScans uses db.js (withTransaction) internally — mock it before dynamic import
 23  let archiveScanDb = null;
 24  mock.module('../src/utils/db.js', {
 25    namedExports: createLazyPgMock(() => archiveScanDb),
 26  });
 27  mock.module('../src/utils/logger.js', {
 28    defaultExport: class { info() {} warn() {} error() {} debug() {} },
 29  });
 30  
 31  const { archiveScans } = await import('../src/api/free-score-api.js');
 32  
 33  // ─── normaliseUrl ─────────────────────────────────────────────────────────────
 34  // Inline the logic since it mirrors the Worker (module-private in the Worker)
 35  
 36  describe('normaliseUrl', () => {
 37    function normaliseUrl(raw) {
 38      let url = (raw || '').trim();
 39      if (!url.startsWith('http://') && !url.startsWith('https://')) {
 40        url = `https://${url}`;
 41      }
 42      try {
 43        const parsed = new URL(url);
 44        if (parsed.hostname === 'auditandfix.com' || parsed.hostname === 'www.auditandfix.com') {
 45          return null;
 46        }
 47        return parsed.href;
 48      } catch {
 49        return null;
 50      }
 51    }
 52  
 53    test('prepends https:// when missing', () => {
 54      assert.equal(normaliseUrl('example.com'), 'https://example.com/');
 55    });
 56  
 57    test('preserves https:// scheme', () => {
 58      assert.equal(normaliseUrl('https://example.com'), 'https://example.com/');
 59    });
 60  
 61    test('preserves http:// scheme', () => {
 62      assert.equal(normaliseUrl('http://example.com'), 'http://example.com/');
 63    });
 64  
 65    test('returns null for auditandfix.com (blocks self-scan)', () => {
 66      assert.equal(normaliseUrl('https://auditandfix.com'), null);
 67      assert.equal(normaliseUrl('www.auditandfix.com'), null);
 68    });
 69  
 70    test('returns null for invalid URL', () => {
 71      assert.equal(normaliseUrl('not a url at all!!'), null);
 72    });
 73  
 74    test('trims whitespace', () => {
 75      assert.match(normaliseUrl('  example.com  '), /^https:\/\/example\.com/);
 76    });
 77  });
 78  
 79  // ─── buildFactorSummary ───────────────────────────────────────────────────────
 80  
 81  describe('buildFactorSummary', () => {
 82    function scoreToStatus(score) {
 83      if (score >= 7) return 'good';
 84      if (score >= 4) return 'fair';
 85      return 'needs_work';
 86    }
 87  
 88    function buildFactorSummary(factorScores) {
 89      if (!factorScores) return {};
 90      return Object.fromEntries(
 91        Object.entries(factorScores).map(([factor, data]) => [
 92          factor,
 93          scoreToStatus(data?.score ?? 0),
 94        ])
 95      );
 96    }
 97  
 98    test('scores >= 7 are good', () => {
 99      const summary = buildFactorSummary({ headline_quality: { score: 8 } });
100      assert.equal(summary.headline_quality, 'good');
101    });
102  
103    test('scores 4-6 are fair', () => {
104      const summary = buildFactorSummary({ call_to_action: { score: 5 } });
105      assert.equal(summary.call_to_action, 'fair');
106    });
107  
108    test('scores < 4 are needs_work', () => {
109      const summary = buildFactorSummary({ trust_signals: { score: 2 } });
110      assert.equal(summary.trust_signals, 'needs_work');
111    });
112  
113    test('handles null factor scores', () => {
114      const summary = buildFactorSummary(null);
115      assert.deepEqual(summary, {});
116    });
117  
118    test('handles missing score field', () => {
119      const summary = buildFactorSummary({ headline_quality: {} });
120      assert.equal(summary.headline_quality, 'needs_work');
121    });
122  });
123  
124  // ─── buildFreePeek ────────────────────────────────────────────────────────────
125  
126  describe('buildFreePeek', () => {
127    function buildFreePeek(factorScores) {
128      if (!factorScores) return null;
129      let weakest = null;
130      let weakestScore = Infinity;
131      for (const [factor, data] of Object.entries(factorScores)) {
132        if ((data?.score ?? 10) < weakestScore) {
133          weakestScore = data.score;
134          weakest = { factor, ...data };
135        }
136      }
137      if (!weakest) return null;
138      return {
139        factor: weakest.factor,
140        score: weakest.score,
141        reasoning: weakest.reasoning || null,
142      };
143    }
144  
145    test('returns weakest factor', () => {
146      const factors = {
147        headline_quality: { score: 8, reasoning: 'Good headline' },
148        call_to_action: { score: 2, reasoning: 'No visible CTA' },
149        trust_signals: { score: 5, reasoning: 'Some trust signals' },
150      };
151      const peek = buildFreePeek(factors);
152      assert.equal(peek.factor, 'call_to_action');
153      assert.equal(peek.score, 2);
154      assert.equal(peek.reasoning, 'No visible CTA');
155    });
156  
157    test('includes reasoning', () => {
158      const factors = { headline_quality: { score: 3, reasoning: 'Weak headline' } };
159      const peek = buildFreePeek(factors);
160      assert.equal(peek.reasoning, 'Weak headline');
161    });
162  
163    test('returns null for empty factor scores', () => {
164      assert.equal(buildFreePeek(null), null);
165      assert.equal(buildFreePeek({}), null);
166    });
167  });
168  
169  // ─── countIssues ─────────────────────────────────────────────────────────────
170  
171  describe('countIssues', () => {
172    function countIssues(factorScores) {
173      if (!factorScores) return 0;
174      return Object.values(factorScores).filter(d => (d?.score ?? 10) < 5).length;
175    }
176  
177    test('counts factors with score < 5', () => {
178      const factors = {
179        a: { score: 2 },
180        b: { score: 4 },
181        c: { score: 7 },
182      };
183      assert.equal(countIssues(factors), 2);
184    });
185  
186    test('returns 0 for null', () => {
187      assert.equal(countIssues(null), 0);
188    });
189  
190    test('returns 0 when all factors are healthy', () => {
191      assert.equal(countIssues({ a: { score: 8 }, b: { score: 9 } }), 0);
192    });
193  });
194  
195  // ─── Rate limit logic ─────────────────────────────────────────────────────────
196  
197  describe('rate limit', () => {
198    const rateLimitMap = new Map();
199  
200    function checkRateLimit(ip) {
201      const now = Date.now();
202      const hourMs = 60 * 60 * 1000;
203      const dayMs = 24 * hourMs;
204  
205      if (!rateLimitMap.has(ip)) {
206        rateLimitMap.set(ip, { hour: 0, day: 0, resetHour: now + hourMs, resetDay: now + dayMs });
207      }
208  
209      const entry = rateLimitMap.get(ip);
210  
211      if (now > entry.resetHour) {
212        entry.hour = 0;
213        entry.resetHour = now + hourMs;
214      }
215      if (now > entry.resetDay) {
216        entry.day = 0;
217        entry.resetDay = now + dayMs;
218      }
219  
220      if (entry.hour >= 10)
221        return { blocked: true, reason: 'Too many scans — try again in an hour.' };
222      if (entry.day >= 50) return { blocked: true, reason: 'Daily scan limit reached.' };
223  
224      entry.hour++;
225      entry.day++;
226      return { blocked: false };
227    }
228  
229    test('allows first 10 scans per hour', () => {
230      const ip = `192.0.2.${randomUUID().slice(0, 3)}`;
231      for (let i = 0; i < 10; i++) {
232        assert.equal(checkRateLimit(ip).blocked, false);
233      }
234    });
235  
236    test('blocks 11th scan in same hour', () => {
237      const ip = `192.0.2.${randomUUID().slice(0, 3)}`;
238      for (let i = 0; i < 10; i++) checkRateLimit(ip);
239      assert.equal(checkRateLimit(ip).blocked, true);
240    });
241  
242    test('different IPs have independent limits', () => {
243      const ip1 = '10.0.0.1';
244      const ip2 = '10.0.0.2';
245      for (let i = 0; i < 10; i++) checkRateLimit(ip1);
246      assert.equal(checkRateLimit(ip1).blocked, true);
247      assert.equal(checkRateLimit(ip2).blocked, false);
248    });
249  });
250  
251  // ─── archiveScans (poll daemon SQLite write) ──────────────────────────────────
252  
253  describe('archiveScans', () => {
254    before(() => {
255      archiveScanDb = new Database(':memory:');
256      archiveScanDb.pragma('journal_mode = WAL');
257      // Minimal free_scans table matching the migration
258      archiveScanDb.exec(`
259        CREATE TABLE free_scans (
260          id                 INTEGER PRIMARY KEY AUTOINCREMENT,
261          scan_id            TEXT    UNIQUE NOT NULL,
262          url                TEXT    NOT NULL,
263          domain             TEXT    NOT NULL,
264          email              TEXT,
265          ip_address         TEXT,
266          score              REAL,
267          grade              TEXT,
268          score_json         TEXT,
269          industry           TEXT,
270          country_code       TEXT,
271          is_js_heavy        INTEGER DEFAULT 0,
272          utm_source         TEXT,
273          utm_medium         TEXT,
274          utm_campaign       TEXT,
275          ref                TEXT,
276          converted_to       TEXT,
277          converted_at       TEXT,
278          created_at         TEXT    NOT NULL DEFAULT (datetime('now')),
279          email_captured_at  TEXT,
280          marketing_optin    INTEGER NOT NULL DEFAULT 0,
281          optin_timestamp    TEXT,
282          expires_at         TEXT    NOT NULL DEFAULT (datetime('now', '+7 days'))
283        )
284      `);
285      // sites and messages tables needed by feedScanEmailToNurture
286      archiveScanDb.exec(`
287        CREATE TABLE IF NOT EXISTS sites (
288          id INTEGER PRIMARY KEY AUTOINCREMENT,
289          domain TEXT UNIQUE NOT NULL,
290          landing_page_url TEXT,
291          status TEXT DEFAULT 'scan_optin',
292          keyword TEXT,
293          score REAL,
294          grade TEXT,
295          country_code TEXT,
296          created_at TEXT DEFAULT (datetime('now')),
297          updated_at TEXT DEFAULT (datetime('now'))
298        );
299        CREATE TABLE IF NOT EXISTS messages (
300          id INTEGER PRIMARY KEY AUTOINCREMENT,
301          site_id INTEGER,
302          contact_uri TEXT,
303          message_type TEXT,
304          body TEXT,
305          status TEXT DEFAULT 'pending',
306          created_at TEXT DEFAULT (datetime('now')),
307          UNIQUE(site_id, contact_uri, message_type)
308        );
309      `);
310    });
311  
312    after(() => {
313      archiveScanDb.close();
314      archiveScanDb = null;
315    });
316  
317    function makeScan(overrides = {}) {
318      return {
319        scan_id: randomUUID(),
320        url: 'https://example.com/',
321        domain: 'example.com',
322        ip_address: '1.2.3.4',
323        score: 65,
324        grade: 'D',
325        factor_scores: { headline_quality: { score: 5, reasoning: 'OK' } },
326        industry: 'general_business',
327        country_code: 'AU',
328        is_js_heavy: 0,
329        utm_source: null,
330        utm_medium: null,
331        utm_campaign: null,
332        ref: null,
333        email: null,
334        email_captured_at: null,
335        created_at: new Date().toISOString(),
336        kv_key: `scan_${randomUUID()}`,
337        ...overrides,
338      };
339    }
340  
341    test('inserts a new scan record', async () => {
342      const scan = makeScan();
343      const inserted = await archiveScans([scan]);
344      assert.equal(inserted, 1);
345      const row = archiveScanDb.prepare('SELECT * FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
346      assert.ok(row);
347      assert.equal(row.domain, 'example.com');
348      assert.equal(row.score, 65);
349      assert.equal(row.grade, 'D');
350      assert.ok(row.score_json.includes('headline_quality'));
351    });
352  
353    test('ignores duplicate scan_id (INSERT OR IGNORE)', async () => {
354      const scan = makeScan();
355      const first = await archiveScans([scan]);
356      const second = await archiveScans([scan]);
357      assert.equal(first, 1);
358      assert.equal(second, 0);
359      const count = archiveScanDb
360        .prepare('SELECT COUNT(*) as cnt FROM free_scans WHERE scan_id = ?')
361        .get(scan.scan_id).cnt;
362      assert.equal(count, 1);
363    });
364  
365    test('inserts multiple scans in one call', async () => {
366      const scans = [makeScan(), makeScan(), makeScan()];
367      const inserted = await archiveScans(scans);
368      assert.equal(inserted, 3);
369    });
370  
371    test('handles null factor_scores gracefully', async () => {
372      const scan = makeScan({ factor_scores: null });
373      const inserted = await archiveScans([scan]);
374      assert.equal(inserted, 1);
375      const row = archiveScanDb.prepare('SELECT score_json FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
376      assert.equal(row.score_json, null);
377    });
378  
379    test('stores email when provided', async () => {
380      const scan = makeScan({
381        email: 'test@example.com',
382        email_captured_at: new Date().toISOString(),
383      });
384      await archiveScans([scan]);
385      const row = archiveScanDb.prepare('SELECT email FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
386      assert.equal(row.email, 'test@example.com');
387    });
388  
389    test('returns 0 for empty array', async () => {
390      const inserted = await archiveScans([]);
391      assert.equal(inserted, 0);
392    });
393  });