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