// Tests for persistence.js — the editor-state save/load layer. // // What this guards // ──────────────── // persistence.js is one layer above storage.js. It adds the // editor-specific knowledge of WHAT shape gets saved and which // fields shouldn't survive a reload. The crucial logic is: // // 1. Round-trip integrity: elements + preferences come back exactly // as they went in, modulo the next point. // 2. Blob URL stripping: elements whose `src` is a `blob:` URL // are dropped at save time. Blob URLs go stale immediately on // reload, so persisting them produces broken-image elements // after refresh. // // The stripping is the rule with the most blast radius — if it ever // regresses, every user who'd uploaded a photo would see broken-image // placeholders on reload. The tests below pin that behavior precisely // (including the negative case: data: and https:// URLs must survive). // // Test isolation // ────────────── // Same pattern as storage.test.js: clear localStorage in beforeEach // so the missing-state assertions actually see a clean slate. import { loadPersistedState, savePersistedState, clearPersistedState, } from './persistence'; // The storage key is private to persistence.js. We don't import it; // we test through the public API instead. The only time we touch // localStorage directly is in beforeEach to clear state. describe('persistence', () => { beforeEach(() => { window.localStorage.clear(); }); describe('loadPersistedState on empty storage', () => { it('returns null when nothing has been saved', () => { // Fresh user, first visit. Caller treats null as "start with // defaults". If this regressed to returning an empty object, // the caller would think there's a saved session and skip // the default-template-load path. expect(loadPersistedState()).toBeNull(); }); }); describe('savePersistedState / loadPersistedState round-trip', () => { it('round-trips a typical editor state', () => { const state = { elements: [ { id: 'a', type: 'image', src: 'data:image/png;base64,abc', x: 10, y: 20, width: 100, height: 100, }, { id: 'b', type: 'text', text: 'Hello', fontSize: 48, fontFamily: 'Pacifico', x: 50, y: 200, }, ], activeTemplateId: 'tee-001', preferences: { lastColor: '#ec4899', lastSize: 'M' }, }; savePersistedState(state); expect(loadPersistedState()).toEqual(state); }); it('preserves remote URL src values', () => { // Sticker library elements have remote URLs like `/stickers/foo.svg` // — these survive reload and must be kept. const state = { elements: [ { id: 'a', type: 'sticker', src: '/stickers/star.svg' }, ], }; savePersistedState(state); const loaded = loadPersistedState(); expect(loaded.elements).toHaveLength(1); expect(loaded.elements[0].src).toBe('/stickers/star.svg'); }); it('preserves data: URL src values', () => { // Cropped photos get saved as data: URLs after the crop apply // (they're rasterized from canvas → base64). These survive // reload and must be kept. const state = { elements: [ { id: 'a', type: 'image', src: 'data:image/png;base64,iVBOR' }, ], }; savePersistedState(state); const loaded = loadPersistedState(); expect(loaded.elements).toHaveLength(1); expect(loaded.elements[0].src).toMatch(/^data:/); }); }); describe('blob URL stripping at save time', () => { it('strips elements whose src is a blob: URL', () => { // The headline rule: blob: URLs don't survive reload. The // user has uploaded a photo, the editor created an // `Object URL` for the file, the URL became `blob:https://...`, // and persisting it would result in a 404'd image on reload. // The fix is to drop the element entirely so the saved state // doesn't contain a broken reference. const state = { elements: [ { id: 'a', type: 'image', src: 'blob:https://example.com/abc-123' }, { id: 'b', type: 'text', text: 'Hello', x: 0, y: 0 }, ], }; savePersistedState(state); const loaded = loadPersistedState(); expect(loaded.elements).toHaveLength(1); expect(loaded.elements[0].id).toBe('b'); }); it('keeps multiple non-blob elements alongside the stripped blob element', () => { // The filter is per-element. Adjacent good elements must be // unaffected even when one is stripped. const state = { elements: [ { id: 'a', type: 'image', src: 'data:image/png;base64,abc' }, { id: 'b', type: 'image', src: 'blob:https://example.com/x' }, { id: 'c', type: 'sticker', src: '/stickers/star.svg' }, { id: 'd', type: 'text', text: 'Hi' }, ], }; savePersistedState(state); const loaded = loadPersistedState(); expect(loaded.elements.map((el) => el.id)).toEqual(['a', 'c', 'd']); }); it('preserves non-element fields when filtering elements', () => { // Stripping happens inside the elements array only; the rest // of the state object (preferences, activeTemplateId, etc.) // is unchanged. const state = { elements: [ { id: 'a', type: 'image', src: 'blob:https://example.com/x' }, ], preferences: { lastColor: '#ec4899' }, activeTemplateId: 'tee-001', }; savePersistedState(state); const loaded = loadPersistedState(); expect(loaded.elements).toEqual([]); expect(loaded.preferences).toEqual({ lastColor: '#ec4899' }); expect(loaded.activeTemplateId).toBe('tee-001'); }); it('handles a state with no elements field gracefully', () => { // Pre-elements state, or state from a future schema where // elements moved elsewhere. The stripping function should // not crash — it should just pass the state through. const state = { preferences: { lastColor: '#000000' } }; savePersistedState(state); expect(loadPersistedState()).toEqual(state); }); }); describe('clearPersistedState', () => { it('removes saved state so subsequent loads return null', () => { savePersistedState({ elements: [{ id: 'a', type: 'text', text: 'Hi' }] }); expect(loadPersistedState()).not.toBeNull(); clearPersistedState(); expect(loadPersistedState()).toBeNull(); }); it('is a no-op when nothing was saved', () => { // Defensive — calling clear on an empty store shouldn't throw. expect(() => clearPersistedState()).not.toThrow(); expect(loadPersistedState()).toBeNull(); }); }); describe('malformed saved data', () => { it('treats a non-object saved value as no saved state', () => { // If something other than our save format ends up at the // storage key (e.g. an older app version wrote a string), the // load path must return null rather than hand back a value // the caller can't safely destructure. window.localStorage.setItem('pawfectly-yours-state-v1', JSON.stringify('not an object')); expect(loadPersistedState()).toBeNull(); }); it('treats malformed JSON at the storage key as no saved state', () => { // Same protection one layer up — storage.readJson already // returns null on JSON.parse failure, persistence inherits // that. Pinning the behavior here ensures the contract holds // end-to-end through both layers. window.localStorage.setItem('pawfectly-yours-state-v1', '{not valid'); expect(loadPersistedState()).toBeNull(); }); }); });