/ tests / utils / contacts-storage.test.js
contacts-storage.test.js
  1  /**
  2   * Unit tests for src/utils/contacts-storage.js
  3   *
  4   * Tests filesystem-backed contacts_json storage:
  5   * - get/set/delete/has operations
  6   * - DB fallback when fs file is absent
  7   * - sentinel value detection
  8   * - CONTACTS_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    getContactsJson,
 19    getContactsData,
 20    setContactsJson,
 21    deleteContactsJson,
 22    hasContactsJson,
 23    getContactsJsonWithFallback,
 24    getContactsDataWithFallback,
 25    DATA_DIR,
 26  } from '../../src/utils/contacts-storage.js';
 27  
 28  const TEST_BASE = join(tmpdir(), `contacts-storage-test-${process.pid}`);
 29  const TEST_CONTACTS_DIR = join(TEST_BASE, 'contacts');
 30  const SITE_ID = 99901; // Use a high ID unlikely to collide with production files
 31  
 32  function setup() {
 33    mkdirSync(TEST_CONTACTS_DIR, { recursive: true });
 34    process.env.CONTACTS_STORAGE_BASE = TEST_BASE;
 35  }
 36  
 37  function teardown() {
 38    delete process.env.CONTACTS_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_CONTACTS_DIR, `${id}.json`);
 46  }
 47  
 48  const SAMPLE_JSON = JSON.stringify({
 49    email_addresses: ['owner@example.com.au'],
 50    phone_numbers: [],
 51    primary_contact_form: null,
 52    social_profiles: [],
 53    contact_pages: [],
 54  });
 55  
 56  const SAMPLE_OBJ = JSON.parse(SAMPLE_JSON);
 57  
 58  describe('contacts-storage', () => {
 59    before(setup);
 60    after(teardown);
 61    beforeEach(() => {
 62      // Remove any leftover file from the previous test
 63      try {
 64        rmSync(sitePath(), { force: true });
 65      } catch { /* ignore */ }
 66    });
 67  
 68    // ─── DATA_DIR ─────────────────────────────────────────────────────────────
 69  
 70    describe('DATA_DIR', () => {
 71      test('is a non-empty string', () => {
 72        assert.ok(typeof DATA_DIR === 'string' && DATA_DIR.length > 0, 'DATA_DIR should be a string');
 73      });
 74  
 75      test('ends with "contacts"', () => {
 76        assert.ok(DATA_DIR.endsWith('contacts'), `DATA_DIR should end with "contacts", got: ${DATA_DIR}`);
 77      });
 78    });
 79  
 80    // ─── sitePath validation ──────────────────────────────────────────────────
 81  
 82    describe('siteId validation', () => {
 83      // get/delete wrap sitePath in try/catch so they return null/false instead of throwing.
 84      // set/has call sitePath outside a try/catch and DO throw.
 85  
 86      test('setContactsJson throws for zero siteId', () => {
 87        assert.throws(() => setContactsJson(0, '{}'), /Invalid siteId/);
 88      });
 89  
 90      test('setContactsJson throws for negative siteId', () => {
 91        assert.throws(() => setContactsJson(-1, '{}'), /Invalid siteId/);
 92      });
 93  
 94      test('setContactsJson throws for non-numeric string', () => {
 95        assert.throws(() => setContactsJson('abc', '{}'), /Invalid siteId/);
 96      });
 97  
 98      test('setContactsJson throws for float siteId', () => {
 99        assert.throws(() => setContactsJson(1.5, '{}'), /Invalid siteId/);
100      });
101  
102      test('hasContactsJson throws for invalid siteId', () => {
103        assert.throws(() => hasContactsJson(0), /Invalid siteId/);
104      });
105  
106      test('getContactsJson returns null (not throw) for invalid siteId', () => {
107        // try/catch in getContactsJson swallows the sitePath error
108        assert.equal(getContactsJson(0), null);
109      });
110  
111      test('deleteContactsJson returns false (not throw) for invalid siteId', () => {
112        // try/catch in deleteContactsJson swallows the sitePath error
113        assert.equal(deleteContactsJson(0), false);
114      });
115  
116      test('accepts numeric string siteId for set', () => {
117        assert.doesNotThrow(() => setContactsJson('99901', '{}'));
118        deleteContactsJson(99901); // cleanup
119      });
120    });
121  
122    // ─── setContactsJson ─────────────────────────────────────────────────────
123  
124    describe('setContactsJson', () => {
125      test('writes JSON string to filesystem', () => {
126        setContactsJson(SITE_ID, SAMPLE_JSON);
127        assert.ok(existsSync(sitePath()), 'file should exist after set');
128      });
129  
130      test('writes object (auto-serialises) to filesystem', () => {
131        setContactsJson(SITE_ID, SAMPLE_OBJ);
132        const raw = getContactsJson(SITE_ID);
133        const parsed = JSON.parse(raw);
134        assert.deepEqual(parsed.email_addresses, SAMPLE_OBJ.email_addresses);
135      });
136  
137      test('is a no-op when contactsData is null', () => {
138        setContactsJson(SITE_ID, null);
139        assert.ok(!existsSync(sitePath()), 'file should not exist after null set');
140      });
141  
142      test('is a no-op when contactsData is undefined', () => {
143        setContactsJson(SITE_ID, undefined);
144        assert.ok(!existsSync(sitePath()), 'file should not exist after undefined set');
145      });
146  
147      test('throws when directory cannot be created (invalid base path)', () => {
148        // Use a siteId with a read-only parent (simulate by using a file as the dir)
149        // We can't reliably create a read-only dir in this env, so just verify set works normally
150        setContactsJson(SITE_ID, SAMPLE_JSON);
151        assert.ok(existsSync(sitePath()), 'normal write should succeed');
152      });
153    });
154  
155    // ─── getContactsJson ─────────────────────────────────────────────────────
156  
157    describe('getContactsJson', () => {
158      test('returns raw JSON string when file exists', () => {
159        setContactsJson(SITE_ID, SAMPLE_JSON);
160        const result = getContactsJson(SITE_ID);
161        assert.equal(result, SAMPLE_JSON);
162      });
163  
164      test('returns null when file does not exist', () => {
165        const result = getContactsJson(SITE_ID);
166        assert.equal(result, null);
167      });
168  
169      test('respects CONTACTS_STORAGE_BASE env var', () => {
170        setContactsJson(SITE_ID, SAMPLE_JSON);
171        // File should be in TEST_CONTACTS_DIR (not default DATA_DIR)
172        assert.ok(existsSync(sitePath()), 'file in TEST dir should exist');
173        assert.ok(!existsSync(join(DATA_DIR, `${SITE_ID}.json`)), 'file in DATA_DIR should NOT exist');
174      });
175    });
176  
177    // ─── getContactsData ─────────────────────────────────────────────────────
178  
179    describe('getContactsData', () => {
180      test('returns parsed object when file exists', () => {
181        setContactsJson(SITE_ID, SAMPLE_JSON);
182        const result = getContactsData(SITE_ID);
183        assert.deepEqual(result.email_addresses, SAMPLE_OBJ.email_addresses);
184      });
185  
186      test('returns null when file does not exist', () => {
187        const result = getContactsData(SITE_ID);
188        assert.equal(result, null);
189      });
190  
191      test('returns null when file contains invalid JSON', () => {
192        writeFileSync(sitePath(), 'not-valid-json', 'utf8');
193        const result = getContactsData(SITE_ID);
194        assert.equal(result, null);
195      });
196    });
197  
198    // ─── deleteContactsJson ───────────────────────────────────────────────────
199  
200    describe('deleteContactsJson', () => {
201      test('removes existing file and returns true', () => {
202        setContactsJson(SITE_ID, SAMPLE_JSON);
203        const result = deleteContactsJson(SITE_ID);
204        assert.equal(result, true);
205        assert.ok(!existsSync(sitePath()), 'file should not exist after delete');
206      });
207  
208      test('returns false when file does not exist', () => {
209        const result = deleteContactsJson(SITE_ID);
210        assert.equal(result, false);
211      });
212    });
213  
214    // ─── hasContactsJson ─────────────────────────────────────────────────────
215  
216    describe('hasContactsJson', () => {
217      test('returns true when file exists', () => {
218        setContactsJson(SITE_ID, SAMPLE_JSON);
219        assert.equal(hasContactsJson(SITE_ID), true);
220      });
221  
222      test('returns false when file does not exist', () => {
223        assert.equal(hasContactsJson(SITE_ID), false);
224      });
225  
226      test('returns false after file is deleted', () => {
227        setContactsJson(SITE_ID, SAMPLE_JSON);
228        deleteContactsJson(SITE_ID);
229        assert.equal(hasContactsJson(SITE_ID), false);
230      });
231    });
232  
233    // ─── getContactsJsonWithFallback ──────────────────────────────────────────
234  
235    describe('getContactsJsonWithFallback', () => {
236      test('returns filesystem data when file exists (ignores dbRow)', () => {
237        setContactsJson(SITE_ID, SAMPLE_JSON);
238        const dbRow = { contacts_json: '{"email_addresses":["db@example.com"]}' };
239        const result = getContactsJsonWithFallback(SITE_ID, dbRow);
240        assert.equal(result, SAMPLE_JSON, 'should return fs data, not dbRow');
241      });
242  
243      test('falls back to dbRow.contacts_json when no fs file', () => {
244        const dbJson = '{"email_addresses":["db@example.com"]}';
245        const dbRow = { contacts_json: dbJson };
246        const result = getContactsJsonWithFallback(SITE_ID, dbRow);
247        assert.equal(result, dbJson);
248      });
249  
250      test('returns null when no fs file and no dbRow', () => {
251        const result = getContactsJsonWithFallback(SITE_ID, undefined);
252        assert.equal(result, null);
253      });
254  
255      test('returns null when no fs file and dbRow has no contacts_json', () => {
256        const result = getContactsJsonWithFallback(SITE_ID, {});
257        assert.equal(result, null);
258      });
259  
260      test('skips dbRow sentinel value (contains "_fs")', () => {
261        const dbRow = { contacts_json: '{"_fs":true}' };
262        const result = getContactsJsonWithFallback(SITE_ID, dbRow);
263        assert.equal(result, null, 'sentinel value should be skipped');
264      });
265  
266      test('skips dbRow sentinel even with additional fields', () => {
267        const dbRow = { contacts_json: '{"_fs":true,"email_addresses":[]}' };
268        const result = getContactsJsonWithFallback(SITE_ID, dbRow);
269        assert.equal(result, null);
270      });
271  
272      test('uses dbRow when dbRow.contacts_json is present and not sentinel', () => {
273        const validDbJson = '{"email_addresses":["valid@example.com"]}';
274        const dbRow = { contacts_json: validDbJson };
275        const result = getContactsJsonWithFallback(SITE_ID, dbRow);
276        assert.equal(result, validDbJson);
277      });
278    });
279  
280    // ─── getContactsDataWithFallback ──────────────────────────────────────────
281  
282    describe('getContactsDataWithFallback', () => {
283      test('returns parsed object from filesystem when file exists', () => {
284        setContactsJson(SITE_ID, SAMPLE_JSON);
285        const result = getContactsDataWithFallback(SITE_ID, {});
286        assert.deepEqual(result.email_addresses, SAMPLE_OBJ.email_addresses);
287      });
288  
289      test('returns parsed object from dbRow when no fs file', () => {
290        const dbRow = { contacts_json: SAMPLE_JSON };
291        const result = getContactsDataWithFallback(SITE_ID, dbRow);
292        assert.deepEqual(result.email_addresses, SAMPLE_OBJ.email_addresses);
293      });
294  
295      test('returns null when no fs file and no dbRow', () => {
296        const result = getContactsDataWithFallback(SITE_ID, undefined);
297        assert.equal(result, null);
298      });
299  
300      test('returns null when JSON is invalid in dbRow', () => {
301        const dbRow = { contacts_json: 'invalid-json' };
302        const result = getContactsDataWithFallback(SITE_ID, dbRow);
303        assert.equal(result, null);
304      });
305  
306      test('returns null when sentinel in dbRow and no fs file', () => {
307        const dbRow = { contacts_json: '{"_fs":true}' };
308        const result = getContactsDataWithFallback(SITE_ID, dbRow);
309        assert.equal(result, null);
310      });
311    });
312  
313    // ─── CONTACTS_STORAGE_BASE override ──────────────────────────────────────
314  
315    describe('CONTACTS_STORAGE_BASE env var', () => {
316      test('reads from custom base when env var is set', () => {
317        const customBase = join(TEST_BASE, 'custom');
318        const customContactsDir = join(customBase, 'contacts');
319        mkdirSync(customContactsDir, { recursive: true });
320        writeFileSync(join(customContactsDir, `${SITE_ID}.json`), SAMPLE_JSON, 'utf8');
321  
322        const origBase = process.env.CONTACTS_STORAGE_BASE;
323        process.env.CONTACTS_STORAGE_BASE = customBase;
324        try {
325          const result = getContactsJson(SITE_ID);
326          assert.equal(result, SAMPLE_JSON);
327        } finally {
328          process.env.CONTACTS_STORAGE_BASE = origBase;
329          rmSync(customBase, { recursive: true, force: true });
330        }
331      });
332  
333      test('unset env var falls back to default DATA_DIR path', () => {
334        const origBase = process.env.CONTACTS_STORAGE_BASE;
335        delete process.env.CONTACTS_STORAGE_BASE;
336        try {
337          // Just verify it returns null (no real production file for this high ID)
338          const result = getContactsJson(SITE_ID);
339          assert.ok(result === null || typeof result === 'string', 'should return null or string');
340        } finally {
341          process.env.CONTACTS_STORAGE_BASE = origBase;
342        }
343      });
344    });
345  });