// Tests for blobUrl.js — the isBlobUrl predicate and revokeBlobUrl // wrapper used by persistence stripping and the photo-edit cleanup // path. // // What this guards // ──────────────── // isBlobUrl is consulted in two places that both have correctness // consequences: // // 1. `persistence.savePersistedState` strips elements whose `src` // is a blob: URL before saving to localStorage. Blob URLs go // stale on reload — keeping them would produce broken-image // elements on next session restore. If isBlobUrl returns false // for a real blob URL, those broken images appear after refresh. // // 2. The photo-edit flow revokes the previous blob URL when the // user applies an edit so the browser frees the underlying // memory. If isBlobUrl returns true for a non-blob URL, we'd // try to revoke a regular https:// URL, which is at best a no-op // and at worst a deopt the browser logs. // // Both call sites benefit from a stable predicate, so the tests pin // the exact behavior: string-typed AND starts with literal "blob:". import { isBlobUrl, revokeBlobUrl } from './blobUrl'; describe('isBlobUrl', () => { it('returns true for a string starting with "blob:"', () => { // Real blob URLs look like blob:https://example.com/. The // predicate only checks the prefix, not the suffix shape — any // string starting with "blob:" matches. expect(isBlobUrl('blob:https://example.com/abc-123')).toBe(true); expect(isBlobUrl('blob:')).toBe(true); }); it('returns false for non-blob URLs', () => { // These are the URL shapes we DON'T want to revoke: // - data: URLs (survive a reload, persist fine) // - https:// URLs (CDN-hosted images) // - file:// URLs (local file pickers) // - relative paths expect(isBlobUrl('data:image/png;base64,iVBORw0KG')).toBe(false); expect(isBlobUrl('https://example.com/photo.jpg')).toBe(false); expect(isBlobUrl('file:///Users/khalid/photo.jpg')).toBe(false); expect(isBlobUrl('/uploads/photo.jpg')).toBe(false); expect(isBlobUrl('photo.jpg')).toBe(false); }); it('returns false for non-strings (no throw)', () => { // The docblock specifies: "Returns false for non-strings rather // than throwing — callers usually test optional fields like // `element.src` and shouldn't have to null-check twice." This // is the regression test for that contract. expect(isBlobUrl(null)).toBe(false); expect(isBlobUrl(undefined)).toBe(false); expect(isBlobUrl(42)).toBe(false); expect(isBlobUrl({})).toBe(false); expect(isBlobUrl([])).toBe(false); }); it('returns false for the empty string', () => { expect(isBlobUrl('')).toBe(false); }); it('is case-sensitive ("BLOB:" is not "blob:")', () => { // Real browser-issued blob URLs are always lowercase, so this // is the desirable behavior. Pinning it here so a future // "normalize case" refactor doesn't silently expand the // predicate's surface to include uppercase variants we never // intended to support. expect(isBlobUrl('BLOB:https://example.com/abc')).toBe(false); expect(isBlobUrl('Blob:https://example.com/abc')).toBe(false); }); }); describe('revokeBlobUrl', () => { // jsdom does not provide URL.revokeObjectURL by default. The // function under test wraps the call in try/catch precisely to // handle environments where it's missing (or where the URL has // already been revoked). These tests verify the no-throw contract // across the various inputs callers actually send. it('does not throw for a blob: URL even if revokeObjectURL is missing', () => { // The cleanup path in the photo editor will call this on every // edit. It MUST NOT throw — that would interrupt the edit flow // and leave the user staring at a half-applied state. expect(() => revokeBlobUrl('blob:https://example.com/abc')).not.toThrow(); }); it('does not throw for non-blob URLs (no-op)', () => { // Defensive: if a caller accidentally passes a data: URL, we // shouldn't try to revoke it. The isBlobUrl guard inside // revokeBlobUrl short-circuits this case. expect(() => revokeBlobUrl('data:image/png;base64,abc')).not.toThrow(); expect(() => revokeBlobUrl('https://example.com/photo.jpg')).not.toThrow(); }); it('does not throw for non-string inputs', () => { expect(() => revokeBlobUrl(null)).not.toThrow(); expect(() => revokeBlobUrl(undefined)).not.toThrow(); expect(() => revokeBlobUrl(42)).not.toThrow(); }); });