import { test } from 'node:test'; import assert from 'node:assert/strict'; import { getSettings, setSettings, Settings } from './settings'; // getSettings() reads localStorage; node has none, so install a controllable // mock per case. (The module already loaded safely with no localStorage thanks // to the guarded catch — that path is exercised by the "throws" test below.) const setStored = (value: string | null): void => { (globalThis as { localStorage?: unknown }).localStorage = { getItem: () => value, setItem: () => undefined, removeItem: () => undefined, }; }; // Map-backed mock (unlike setStored above, get/set actually round-trip) // for the setSettings merge tests below, which need writes to persist. const installStorage = (): Map => { const map = new Map(); (globalThis as { localStorage?: unknown }).localStorage = { getItem: (k: string) => (map.has(k) ? map.get(k)! : null), setItem: (k: string, v: string) => { map.set(k, v); }, removeItem: (k: string) => { map.delete(k); }, }; return map; }; const setThrowingStorage = (): void => { (globalThis as { localStorage?: unknown }).localStorage = { getItem: () => { throw new Error('storage blocked'); }, setItem: () => { throw new Error('storage blocked'); }, removeItem: () => { throw new Error('storage blocked'); }, }; }; test('returns defaults when nothing is stored', () => { setStored(null); assert.equal(getSettings().callNoiseSuppression, 'browser'); }); test('migrates the legacy boolean callNoiseSuppression to the 3-way mode', () => { setStored(JSON.stringify({ callNoiseSuppression: true })); assert.equal(getSettings().callNoiseSuppression, 'browser'); setStored(JSON.stringify({ callNoiseSuppression: false })); assert.equal(getSettings().callNoiseSuppression, 'off'); // a new string value passes through untouched setStored(JSON.stringify({ callNoiseSuppression: 'ml' })); assert.equal(getSettings().callNoiseSuppression, 'ml'); }); test('coerces unknown persisted denoise model / ringtone id back to defaults', () => { setStored(null); const defaults = getSettings(); setStored(JSON.stringify({ callDenoiseModel: 'retired-model', ringtoneId: 'bogus' })); const coerced = getSettings(); assert.equal(coerced.callDenoiseModel, defaults.callDenoiseModel); assert.equal(coerced.ringtoneId, defaults.ringtoneId); setStored(JSON.stringify({ callDenoiseModel: 'rnnoise', ringtoneId: 'chime' })); const valid = getSettings(); assert.equal(valid.callDenoiseModel, 'rnnoise'); assert.equal(valid.ringtoneId, 'chime'); }); test('coerces unknown persisted seasonalThemeOverride back to auto', () => { setStored(null); const defaults = getSettings(); assert.equal(defaults.seasonalThemeOverride, 'auto'); setStored(JSON.stringify({ seasonalThemeOverride: 'retired-theme' })); assert.equal(getSettings().seasonalThemeOverride, 'auto'); setStored(JSON.stringify({ seasonalThemeOverride: 'earthday' })); assert.equal(getSettings().seasonalThemeOverride, 'earthday'); }); test('merges stored values over defaults', () => { setStored(JSON.stringify({ callNoiseSuppression: 'off', someUnknownKey: 1 })); const s = getSettings(); assert.equal(s.callNoiseSuppression, 'off'); // a default field not present in storage is still populated assert.notEqual(s.callDenoiseModel, undefined); }); test('returns defaults without throwing when localStorage access throws', () => { setThrowingStorage(); // regression: the catch used to call localStorage.removeItem(), which re-threw // and crashed the app at module load when storage was blocked. assert.doesNotThrow(() => getSettings()); assert.equal(getSettings().callNoiseSuppression, 'browser'); }); test('returns defaults on malformed JSON', () => { setStored('{ not valid json'); assert.doesNotThrow(() => getSettings()); assert.equal(getSettings().callNoiseSuppression, 'browser'); }); // Gitea #42: two tabs, each holding its own stale snapshot, must not clobber // a key the *other* tab changed. setSettings() is given `previous` (this // tab's own pre-update snapshot) and `next` (its whole-object update) and must // apply only the keys that actually differ, on top of the freshest stored blob // — not overwrite the blob wholesale with its own stale view. test('setSettings merges only the changed key onto the freshest stored blob', () => { installStorage(); const original = getSettings(); // Tab A reads settings, then tab B (concurrently) changes an unrelated key // and writes it to the shared storage. const tabB = { ...original, gifPickerEnabled: true }; setSettings(original, tabB); // Tab A, still holding its original snapshot, now changes a different key. const tabA = { ...original, isMarkdown: !original.isMarkdown }; setSettings(original, tabA); // Both survive: tab B's change was not reverted by tab A's stale write. const result = getSettings(); assert.equal(result.gifPickerEnabled, true); assert.equal(result.isMarkdown, !original.isMarkdown); }); test('setSettings is a no-op for keys unchanged relative to previous', () => { const store = installStorage(); store.set('settings', JSON.stringify({ isMarkdown: false })); const current = getSettings(); assert.equal(current.isMarkdown, false); // `next` differs from `current` in object identity/composerToolbarButtons // etc. but not in any actual value vs `previous` — nothing should be // reapplied, so an out-of-band external change to storage survives. const externallyChanged: Settings = { ...current, isMarkdown: true }; localStorage.setItem('settings', JSON.stringify(externallyChanged)); const previous = current; const next = { ...current }; // identical values to `previous` setSettings(previous, next); assert.equal(getSettings().isMarkdown, true); });