/ tests / cron / claude-store-semantic-score.test.js
claude-store-semantic-score.test.js
  1  /**
  2   * Integration tests for storeSemanticScore() in scripts/claude-store.js
  3   *
  4   * New behavior (status rename refactor):
  5   * - After semantic pass, status advances: prog_scored → semantic_scored
  6   * - If recomputed score > LOW_SCORE_CUTOFF (82), status becomes high_score instead
  7   * - If site is NOT at prog_scored (e.g. already enriched), status is left unchanged
  8   *
  9   * Uses pg-mock (in-memory SQLite via db.js mock) so tests run without PG.
 10   *
 11   * Run with:
 12   *   NODE_ENV=test LOGS_DIR=/tmp/test-logs node --test --experimental-test-module-mocks \
 13   *   tests/cron/claude-store-semantic-score.test.js
 14   */
 15  
 16  import { test, describe, beforeEach, after, mock } from 'node:test';
 17  import assert from 'node:assert/strict';
 18  import { join } from 'path';
 19  import { existsSync, unlinkSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
 20  import Database from 'better-sqlite3';
 21  import { createPgMock } from '../helpers/pg-mock.js';
 22  
 23  const SCORES_DIR = join(import.meta.dirname, '..', '..', 'data', 'scores');
 24  const insertedSiteIds = [];
 25  
 26  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 27  
 28  const db = new Database(':memory:');
 29  
 30  db.exec(`
 31    CREATE TABLE sites (
 32      id INTEGER PRIMARY KEY,
 33      domain TEXT NOT NULL DEFAULT 'example.com',
 34      landing_page_url TEXT NOT NULL DEFAULT 'https://example.com',
 35      keyword TEXT DEFAULT 'test kw',
 36      status TEXT DEFAULT 'prog_scored',
 37      score REAL,
 38      grade TEXT DEFAULT 'C',
 39      rescored_at DATETIME,
 40      updated_at DATETIME DEFAULT (datetime('now'))
 41    );
 42  `);
 43  
 44  // ─── Mock db.js BEFORE importing claude-store.js ──────────────────────────────
 45  
 46  mock.module('../../src/utils/db.js', {
 47    namedExports: createPgMock(db),
 48  });
 49  
 50  mock.module('../../src/utils/logger.js', {
 51    defaultExport: class {
 52      info() {}
 53      warn() {}
 54      error() {}
 55      success() {}
 56      debug() {}
 57    },
 58  });
 59  
 60  mock.module('../../src/utils/load-env.js', {
 61    defaultExport: {},
 62  });
 63  
 64  mock.module('../../src/utils/llm-provider.js', {
 65    namedExports: {
 66      callLLM: async () => ({ content: '' }),
 67      getProvider: () => 'openrouter',
 68      getProviderDisplayName: () => 'OpenRouter',
 69    },
 70  });
 71  
 72  // ─── Import AFTER mock.module ─────────────────────────────────────────────────
 73  
 74  const { storeSemanticScore } = await import('../../scripts/claude-store.js');
 75  
 76  // ─── Helpers ──────────────────────────────────────────────────────────────────
 77  
 78  let siteSeq = 200;
 79  
 80  function insertSite({ status = 'prog_scored', score = 60, factorScores } = {}) {
 81    const id = siteSeq++;
 82    const scoreJson = { factor_scores: factorScores || LOW_FACTOR_SCORES, conversion_score: score };
 83    db.prepare(
 84      `INSERT OR REPLACE INTO sites (id, domain, landing_page_url, status, keyword, score, grade)
 85       VALUES (?, ?, ?, ?, 'test kw', ?, 'C')`
 86    ).run(id, `site${id}.com`, `https://site${id}.com`, status, score);
 87    // Write score to filesystem (score_json column was dropped in migration 121)
 88    mkdirSync(SCORES_DIR, { recursive: true });
 89    writeFileSync(join(SCORES_DIR, `${id}.json`), JSON.stringify(scoreJson));
 90    insertedSiteIds.push(id);
 91    return id;
 92  }
 93  
 94  function getStatus(id) {
 95    return db.prepare('SELECT status, score FROM sites WHERE id = ?').get(id);
 96  }
 97  
 98  // Create a fake pg-style client using the mock db
 99  function makeFakeClient() {
100    const pgMock = createPgMock(db);
101    return {
102      query: async (sql, params = []) => {
103        // Use run or getAll depending on the query type
104        const trimmed = sql.trim().toUpperCase();
105        if (trimmed.startsWith('SELECT') || trimmed.startsWith('WITH')) {
106          const rows = await pgMock.getAll(sql, params);
107          return { rows, rowCount: rows.length };
108        } else {
109          const result = await pgMock.run(sql, params);
110          return { rows: [], rowCount: result.changes };
111        }
112      },
113    };
114  }
115  
116  function clearSites() {
117    db.prepare('DELETE FROM sites').run();
118    siteSeq = 200;
119  }
120  
121  // ─── Factor scores ────────────────────────────────────────────────────────────
122  
123  const LOW_FACTOR_SCORES = {
124    headline_quality: { score: 5, reasoning: 'ok' },
125    value_proposition: { score: 5, reasoning: 'ok' },
126    unique_selling_proposition: { score: 4, reasoning: 'ok' },
127    call_to_action: { score: 5, reasoning: 'ok' },
128    urgency_messaging: { score: 4, reasoning: 'ok' },
129    hook_engagement: { score: 4, reasoning: 'ok' },
130    trust_signals: { score: 5, reasoning: 'ok' },
131    imagery_design: { score: 5, reasoning: 'ok' },
132    offer_clarity: { score: 4, reasoning: 'ok' },
133    contextual: { score: 4, reasoning: 'ok' },
134  };
135  
136  const HIGH_FACTOR_SCORES = {
137    headline_quality: { score: 10, reasoning: 'excellent' },
138    value_proposition: { score: 10, reasoning: 'excellent' },
139    unique_selling_proposition: { score: 9, reasoning: 'excellent' },
140    call_to_action: { score: 10, reasoning: 'excellent' },
141    urgency_messaging: { score: 9, reasoning: 'excellent' },
142    hook_engagement: { score: 9, reasoning: 'excellent' },
143    trust_signals: { score: 10, reasoning: 'excellent' },
144    imagery_design: { score: 9, reasoning: 'excellent' },
145    offer_clarity: { score: 9, reasoning: 'excellent' },
146    contextual: { score: 9, reasoning: 'excellent' },
147  };
148  
149  // ─── Cleanup ──────────────────────────────────────────────────────────────────
150  
151  after(() => {
152    for (const id of insertedSiteIds) {
153      const scorePath = join(SCORES_DIR, `${id}.json`);
154      if (existsSync(scorePath)) {
155        try { unlinkSync(scorePath); } catch { /* ignore */ }
156      }
157    }
158  });
159  
160  // ─── Tests ────────────────────────────────────────────────────────────────────
161  
162  describe('storeSemanticScore — status advancement', () => {
163    beforeEach(() => clearSites());
164  
165    test('advances prog_scored → semantic_scored when recomputed score ≤ 82', async () => {
166      process.env.LOW_SCORE_CUTOFF = '82';
167      process.env.ENABLE_VISION = 'false';
168      const id = insertSite({ status: 'prog_scored', score: 60, factorScores: LOW_FACTOR_SCORES });
169      const client = makeFakeClient();
170      await storeSemanticScore(client, {
171        site_id: id,
172        headline_quality: { score: 5, reasoning: 'ok' },
173        value_proposition: { score: 5, reasoning: 'ok' },
174        unique_selling_proposition: { score: 4, reasoning: 'ok' },
175      });
176      assert.equal(
177        getStatus(id).status,
178        'semantic_scored',
179        'Low-scoring site should advance to semantic_scored'
180      );
181    });
182  
183    test('advances prog_scored → high_score when recomputed score > 82', async () => {
184      process.env.LOW_SCORE_CUTOFF = '82';
185      process.env.ENABLE_VISION = 'false';
186      const id = insertSite({ status: 'prog_scored', score: 70, factorScores: HIGH_FACTOR_SCORES });
187      const client = makeFakeClient();
188      await storeSemanticScore(client, {
189        site_id: id,
190        headline_quality: { score: 10, reasoning: 'excellent' },
191        value_proposition: { score: 10, reasoning: 'excellent' },
192        unique_selling_proposition: { score: 9, reasoning: 'excellent' },
193      });
194      assert.equal(getStatus(id).status, 'high_score', 'High-scoring site should become high_score');
195    });
196  
197    test('does NOT change status when site is not at prog_scored (e.g. enriched)', async () => {
198      process.env.LOW_SCORE_CUTOFF = '82';
199      process.env.ENABLE_VISION = 'false';
200      const id = insertSite({ status: 'enriched', score: 60 });
201      const client = makeFakeClient();
202      await storeSemanticScore(client, {
203        site_id: id,
204        headline_quality: { score: 5, reasoning: 'ok' },
205        value_proposition: { score: 5, reasoning: 'ok' },
206        unique_selling_proposition: { score: 4, reasoning: 'ok' },
207      });
208      assert.equal(
209        getStatus(id).status,
210        'enriched',
211        'Status should not change if site is past prog_scored'
212      );
213    });
214  
215    test('advances semantic_scored → enriched (ENABLE_VISION=false vision-off path)', async () => {
216      process.env.LOW_SCORE_CUTOFF = '82';
217      process.env.ENABLE_VISION = 'false';
218      const id = insertSite({ status: 'semantic_scored', score: 65 });
219      const client = makeFakeClient();
220      await storeSemanticScore(client, {
221        site_id: id,
222        headline_quality: { score: 5, reasoning: 'ok' },
223        value_proposition: { score: 5, reasoning: 'ok' },
224        unique_selling_proposition: { score: 4, reasoning: 'ok' },
225      });
226      assert.equal(
227        getStatus(id).status,
228        'enriched',
229        'semantic_scored should advance to enriched in vision-off path'
230      );
231    });
232  
233    test('score_json is updated with semantic_scored=true and scoring_method=hybrid', async () => {
234      process.env.LOW_SCORE_CUTOFF = '82';
235      process.env.ENABLE_VISION = 'false';
236      const id = insertSite({ status: 'prog_scored', score: 55 });
237      const client = makeFakeClient();
238      await storeSemanticScore(client, {
239        site_id: id,
240        headline_quality: { score: 5, reasoning: 'ok' },
241        value_proposition: { score: 5, reasoning: 'ok' },
242        unique_selling_proposition: { score: 4, reasoning: 'ok' },
243      });
244      const scorePath = join(SCORES_DIR, `${id}.json`);
245      assert.ok(existsSync(scorePath), 'Score file should exist on filesystem');
246      const scoreJson = JSON.parse(readFileSync(scorePath, 'utf-8'));
247      assert.equal(scoreJson.semantic_scored, true, 'score_json.semantic_scored should be true');
248      assert.equal(scoreJson.scoring_method, 'hybrid', 'scoring_method should be hybrid');
249    });
250  
251    test('handles batch of multiple sites correctly', async () => {
252      process.env.LOW_SCORE_CUTOFF = '82';
253      process.env.ENABLE_VISION = 'false';
254      const lowId = insertSite({ status: 'prog_scored', score: 50, factorScores: LOW_FACTOR_SCORES });
255      const highId = insertSite({
256        status: 'prog_scored',
257        score: 70,
258        factorScores: HIGH_FACTOR_SCORES,
259      });
260      const client = makeFakeClient();
261      await storeSemanticScore(client, {
262        site_id: lowId,
263        headline_quality: { score: 4, reasoning: 'ok' },
264        value_proposition: { score: 4, reasoning: 'ok' },
265        unique_selling_proposition: { score: 3, reasoning: 'ok' },
266      });
267      await storeSemanticScore(client, {
268        site_id: highId,
269        headline_quality: { score: 10, reasoning: 'excellent' },
270        value_proposition: { score: 10, reasoning: 'excellent' },
271        unique_selling_proposition: { score: 9, reasoning: 'excellent' },
272      });
273      assert.equal(getStatus(lowId).status, 'semantic_scored', 'Low scorer → semantic_scored');
274      assert.equal(getStatus(highId).status, 'high_score', 'High scorer → high_score');
275    });
276  
277    test('skips result when site_id is missing (no throw)', async () => {
278      const client = makeFakeClient();
279      await assert.doesNotReject(() =>
280        storeSemanticScore(client, { headline_quality: { score: 5, reasoning: 'ok' } })
281      );
282    });
283  });
284  
285  describe('storeSemanticScore — LOW_SCORE_CUTOFF boundary', () => {
286    beforeEach(() => clearSites());
287  
288    test('with impossibly high cutoff, high score still becomes semantic_scored (not high_score)', async () => {
289      process.env.LOW_SCORE_CUTOFF = '200';
290      process.env.ENABLE_VISION = 'false';
291      const id = insertSite({ status: 'prog_scored', score: 90, factorScores: HIGH_FACTOR_SCORES });
292      const client = makeFakeClient();
293      await storeSemanticScore(client, {
294        site_id: id,
295        headline_quality: { score: 10, reasoning: 'excellent' },
296        value_proposition: { score: 10, reasoning: 'excellent' },
297        unique_selling_proposition: { score: 9, reasoning: 'excellent' },
298      });
299      assert.equal(
300        getStatus(id).status,
301        'semantic_scored',
302        'When cutoff=200, even a high score should become semantic_scored not high_score'
303      );
304    });
305  });