score-storage.test.js
1 /** 2 * Unit tests for src/utils/score-storage.js 3 * 4 * Tests filesystem-backed score_json storage: 5 * - get/set/delete/has operations 6 * - DB fallback when fs file is absent 7 * - sentinel value detection 8 * - SCORE_STORAGE_BASE env-var override (path isolation) 9 * - invalid siteId validation 10 */ 11 12 import { describe, test, before, after, beforeEach } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import { mkdirSync, rmSync, existsSync, writeFileSync } from 'fs'; 15 import { join } from 'path'; 16 import { tmpdir } from 'os'; 17 import { 18 getScoreJson, 19 getScoreData, 20 setScoreJson, 21 deleteScoreJson, 22 hasScoreJson, 23 getScoreJsonWithFallback, 24 getScoreDataWithFallback, 25 DATA_DIR, 26 } from '../../src/utils/score-storage.js'; 27 28 const TEST_BASE = join(tmpdir(), `score-storage-test-${process.pid}`); 29 const TEST_SCORES_DIR = join(TEST_BASE, 'scores'); 30 const SITE_ID = 99902; // Use a high ID unlikely to collide with production files 31 32 function setup() { 33 mkdirSync(TEST_SCORES_DIR, { recursive: true }); 34 process.env.SCORE_STORAGE_BASE = TEST_BASE; 35 } 36 37 function teardown() { 38 delete process.env.SCORE_STORAGE_BASE; 39 if (existsSync(TEST_BASE)) { 40 rmSync(TEST_BASE, { recursive: true, force: true }); 41 } 42 } 43 44 function sitePath(id = SITE_ID) { 45 return join(TEST_SCORES_DIR, `${id}.json`); 46 } 47 48 const SAMPLE_JSON = JSON.stringify({ 49 overall_calculation: { conversion_score: 55, letter_grade: 'F' }, 50 sections: { 51 performance: { score: 60 }, 52 seo: { score: 50 }, 53 }, 54 }); 55 56 const SAMPLE_OBJ = JSON.parse(SAMPLE_JSON); 57 58 describe('score-storage', () => { 59 before(setup); 60 after(teardown); 61 beforeEach(() => { 62 try { 63 rmSync(sitePath(), { force: true }); 64 } catch { /* ignore */ } 65 }); 66 67 // ─── DATA_DIR ───────────────────────────────────────────────────────────── 68 69 describe('DATA_DIR', () => { 70 test('is a non-empty string', () => { 71 assert.ok(typeof DATA_DIR === 'string' && DATA_DIR.length > 0); 72 }); 73 74 test('ends with "scores"', () => { 75 assert.ok(DATA_DIR.endsWith('scores'), `DATA_DIR should end with "scores", got: ${DATA_DIR}`); 76 }); 77 }); 78 79 // ─── siteId validation ──────────────────────────────────────────────────── 80 81 describe('siteId validation', () => { 82 test('setScoreJson throws for zero siteId', () => { 83 assert.throws(() => setScoreJson(0, '{}'), /Invalid siteId/); 84 }); 85 86 test('setScoreJson throws for negative siteId', () => { 87 assert.throws(() => setScoreJson(-5, '{}'), /Invalid siteId/); 88 }); 89 90 test('setScoreJson throws for non-numeric string', () => { 91 assert.throws(() => setScoreJson('foo', '{}'), /Invalid siteId/); 92 }); 93 94 test('setScoreJson throws for float siteId', () => { 95 assert.throws(() => setScoreJson(2.7, '{}'), /Invalid siteId/); 96 }); 97 98 test('hasScoreJson throws for invalid siteId', () => { 99 assert.throws(() => hasScoreJson(0), /Invalid siteId/); 100 }); 101 102 test('getScoreJson returns null (not throw) for invalid siteId', () => { 103 assert.equal(getScoreJson(0), null); 104 }); 105 106 test('deleteScoreJson returns false (not throw) for invalid siteId', () => { 107 assert.equal(deleteScoreJson(0), false); 108 }); 109 110 test('accepts numeric string siteId for set', () => { 111 assert.doesNotThrow(() => setScoreJson('99902', '{}')); 112 deleteScoreJson(99902); // cleanup 113 }); 114 }); 115 116 // ─── setScoreJson ───────────────────────────────────────────────────────── 117 118 describe('setScoreJson', () => { 119 test('writes JSON string to filesystem', () => { 120 setScoreJson(SITE_ID, SAMPLE_JSON); 121 assert.ok(existsSync(sitePath()), 'file should exist after set'); 122 }); 123 124 test('writes object (auto-serialises) to filesystem', () => { 125 setScoreJson(SITE_ID, SAMPLE_OBJ); 126 const raw = getScoreJson(SITE_ID); 127 const parsed = JSON.parse(raw); 128 assert.equal(parsed.overall_calculation.conversion_score, 55); 129 }); 130 131 test('is a no-op when scoreData is null', () => { 132 setScoreJson(SITE_ID, null); 133 assert.ok(!existsSync(sitePath()), 'file should not exist after null set'); 134 }); 135 136 test('is a no-op when scoreData is undefined', () => { 137 setScoreJson(SITE_ID, undefined); 138 assert.ok(!existsSync(sitePath()), 'file should not exist after undefined set'); 139 }); 140 }); 141 142 // ─── getScoreJson ───────────────────────────────────────────────────────── 143 144 describe('getScoreJson', () => { 145 test('returns raw JSON string when file exists', () => { 146 setScoreJson(SITE_ID, SAMPLE_JSON); 147 const result = getScoreJson(SITE_ID); 148 assert.equal(result, SAMPLE_JSON); 149 }); 150 151 test('returns null when file does not exist', () => { 152 const result = getScoreJson(SITE_ID); 153 assert.equal(result, null); 154 }); 155 156 test('respects SCORE_STORAGE_BASE env var', () => { 157 setScoreJson(SITE_ID, SAMPLE_JSON); 158 assert.ok(existsSync(sitePath()), 'file in TEST dir should exist'); 159 assert.ok(!existsSync(join(DATA_DIR, `${SITE_ID}.json`)), 'file in DATA_DIR should NOT exist'); 160 }); 161 }); 162 163 // ─── getScoreData ───────────────────────────────────────────────────────── 164 165 describe('getScoreData', () => { 166 test('returns parsed object when file exists', () => { 167 setScoreJson(SITE_ID, SAMPLE_JSON); 168 const result = getScoreData(SITE_ID); 169 assert.equal(result.overall_calculation.conversion_score, 55); 170 }); 171 172 test('returns null when file does not exist', () => { 173 const result = getScoreData(SITE_ID); 174 assert.equal(result, null); 175 }); 176 177 test('returns null when file contains invalid JSON', () => { 178 writeFileSync(sitePath(), 'broken-json', 'utf8'); 179 const result = getScoreData(SITE_ID); 180 assert.equal(result, null); 181 }); 182 }); 183 184 // ─── deleteScoreJson ────────────────────────────────────────────────────── 185 186 describe('deleteScoreJson', () => { 187 test('removes existing file and returns true', () => { 188 setScoreJson(SITE_ID, SAMPLE_JSON); 189 const result = deleteScoreJson(SITE_ID); 190 assert.equal(result, true); 191 assert.ok(!existsSync(sitePath()), 'file should not exist after delete'); 192 }); 193 194 test('returns false when file does not exist', () => { 195 const result = deleteScoreJson(SITE_ID); 196 assert.equal(result, false); 197 }); 198 }); 199 200 // ─── hasScoreJson ───────────────────────────────────────────────────────── 201 202 describe('hasScoreJson', () => { 203 test('returns true when file exists', () => { 204 setScoreJson(SITE_ID, SAMPLE_JSON); 205 assert.equal(hasScoreJson(SITE_ID), true); 206 }); 207 208 test('returns false when file does not exist', () => { 209 assert.equal(hasScoreJson(SITE_ID), false); 210 }); 211 212 test('returns false after file is deleted', () => { 213 setScoreJson(SITE_ID, SAMPLE_JSON); 214 deleteScoreJson(SITE_ID); 215 assert.equal(hasScoreJson(SITE_ID), false); 216 }); 217 }); 218 219 // ─── getScoreJsonWithFallback ───────────────────────────────────────────── 220 221 describe('getScoreJsonWithFallback', () => { 222 test('returns filesystem data when file exists (ignores dbRow)', () => { 223 setScoreJson(SITE_ID, SAMPLE_JSON); 224 const dbRow = { score_json: '{"overall_calculation":{"conversion_score":99}}' }; 225 const result = getScoreJsonWithFallback(SITE_ID, dbRow); 226 assert.equal(result, SAMPLE_JSON, 'should return fs data, not dbRow'); 227 }); 228 229 test('falls back to dbRow.score_json when no fs file', () => { 230 const dbJson = '{"overall_calculation":{"conversion_score":77}}'; 231 const dbRow = { score_json: dbJson }; 232 const result = getScoreJsonWithFallback(SITE_ID, dbRow); 233 assert.equal(result, dbJson); 234 }); 235 236 test('returns null when no fs file and no dbRow', () => { 237 const result = getScoreJsonWithFallback(SITE_ID, undefined); 238 assert.equal(result, null); 239 }); 240 241 test('returns null when no fs file and dbRow has no score_json', () => { 242 const result = getScoreJsonWithFallback(SITE_ID, {}); 243 assert.equal(result, null); 244 }); 245 246 test('skips dbRow sentinel value (contains "_fs")', () => { 247 const dbRow = { score_json: '{"_fs":true}' }; 248 const result = getScoreJsonWithFallback(SITE_ID, dbRow); 249 assert.equal(result, null, 'sentinel value should be skipped'); 250 }); 251 252 test('skips dbRow sentinel even with additional fields', () => { 253 const dbRow = { score_json: '{"_fs":true,"overall_calculation":{}}' }; 254 const result = getScoreJsonWithFallback(SITE_ID, dbRow); 255 assert.equal(result, null); 256 }); 257 258 test('uses dbRow when score_json is present and not sentinel', () => { 259 const validDbJson = '{"overall_calculation":{"conversion_score":42}}'; 260 const dbRow = { score_json: validDbJson }; 261 const result = getScoreJsonWithFallback(SITE_ID, dbRow); 262 assert.equal(result, validDbJson); 263 }); 264 }); 265 266 // ─── getScoreDataWithFallback ───────────────────────────────────────────── 267 268 describe('getScoreDataWithFallback', () => { 269 test('returns parsed object from filesystem when file exists', () => { 270 setScoreJson(SITE_ID, SAMPLE_JSON); 271 const result = getScoreDataWithFallback(SITE_ID, {}); 272 assert.equal(result.overall_calculation.conversion_score, 55); 273 }); 274 275 test('returns parsed object from dbRow when no fs file', () => { 276 const dbRow = { score_json: SAMPLE_JSON }; 277 const result = getScoreDataWithFallback(SITE_ID, dbRow); 278 assert.equal(result.overall_calculation.conversion_score, 55); 279 }); 280 281 test('returns null when no fs file and no dbRow', () => { 282 const result = getScoreDataWithFallback(SITE_ID, undefined); 283 assert.equal(result, null); 284 }); 285 286 test('returns null when JSON is invalid in dbRow', () => { 287 const dbRow = { score_json: 'not-json' }; 288 const result = getScoreDataWithFallback(SITE_ID, dbRow); 289 assert.equal(result, null); 290 }); 291 292 test('returns null when sentinel in dbRow and no fs file', () => { 293 const dbRow = { score_json: '{"_fs":true}' }; 294 const result = getScoreDataWithFallback(SITE_ID, dbRow); 295 assert.equal(result, null); 296 }); 297 }); 298 299 // ─── SCORE_STORAGE_BASE override ───────────────────────────────────────── 300 301 describe('SCORE_STORAGE_BASE env var', () => { 302 test('reads from custom base when env var is set', () => { 303 const customBase = join(TEST_BASE, 'custom'); 304 const customScoresDir = join(customBase, 'scores'); 305 mkdirSync(customScoresDir, { recursive: true }); 306 writeFileSync(join(customScoresDir, `${SITE_ID}.json`), SAMPLE_JSON, 'utf8'); 307 308 const origBase = process.env.SCORE_STORAGE_BASE; 309 process.env.SCORE_STORAGE_BASE = customBase; 310 try { 311 const result = getScoreJson(SITE_ID); 312 assert.equal(result, SAMPLE_JSON); 313 } finally { 314 process.env.SCORE_STORAGE_BASE = origBase; 315 rmSync(customBase, { recursive: true, force: true }); 316 } 317 }); 318 319 test('unset env var falls back to default DATA_DIR path', () => { 320 const origBase = process.env.SCORE_STORAGE_BASE; 321 delete process.env.SCORE_STORAGE_BASE; 322 try { 323 const result = getScoreJson(SITE_ID); 324 assert.ok(result === null || typeof result === 'string', 'should return null or string'); 325 } finally { 326 process.env.SCORE_STORAGE_BASE = origBase; 327 } 328 }); 329 }); 330 });