/ tests / utils / score-storage.test.js
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  });