Major update for v1 and tests
This commit is contained in:
209
src/utils/persistence.test.js
Normal file
209
src/utils/persistence.test.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user