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