openreader/tests/unit/blob-cache.vitest.spec.ts
Richard R b2bbd8fbef feat(data-storage): migrate all user state from IndexedDB to server-backed storage
Remove all Dexie/IndexedDB code and dependencies, including document and preview caches, local config, onboarding, and migration logic. Replace with server-backed React Query hooks for documents, folders, preferences, onboarding, and progress. Add browser Cache Storage for blob caching of documents, previews, and audio. Update API routes, database schema, and tests to support folder management, onboarding state, and server-side persistence of all user data. Refactor UI and hooks to use server state exclusively, ensuring all user state is synced and portable across devices.

BREAKING CHANGE: All user data, preferences, onboarding, and document state are now stored and synced on the server; browser IndexedDB is no longer used. Existing local-only data will not be available after this update.
2026-06-14 18:13:39 -06:00

74 lines
3 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest';
import {
audioBlobCacheKey,
documentBlobCacheKey,
getCachedBlob,
previewBlobCacheKey,
} from '../../src/lib/client/cache/blob-cache';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('blob cache stable keys', () => {
test('builds versioned synthetic keys', () => {
expect(documentBlobCacheKey('doc/id', 'v1')).toBe('/openreader-cache/documents/doc%2Fid/v1');
expect(previewBlobCacheKey('doc', 'etag/1')).toBe('/openreader-cache/previews/doc/etag%2F1');
expect(audioBlobCacheKey('audio/key', 2)).toBe('/openreader-cache/audio/audio%2Fkey/2');
});
test('returns a cache hit without fetching', async () => {
const cached = new Response('cached');
const match = vi.fn().mockResolvedValue(cached);
vi.stubGlobal('window', {});
vi.stubGlobal('caches', { open: vi.fn().mockResolvedValue({ match, put: vi.fn() }) });
const fetchSource = vi.fn();
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v1', fetchSource)).text()).toBe('cached');
expect(fetchSource).not.toHaveBeenCalled();
});
test('fetches and caches a successful full response on a miss', async () => {
const put = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('window', {});
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
});
const response = await getCachedBlob('/openreader-cache/documents/doc/v2', async () => new Response('network'));
expect(await response.text()).toBe('network');
expect(put).toHaveBeenCalledOnce();
});
test('treats missing Cache Storage and cache write failures as non-fatal', async () => {
vi.stubGlobal('window', {});
vi.stubGlobal('caches', {
open: vi.fn()
.mockRejectedValueOnce(new Error('unavailable'))
.mockResolvedValueOnce({
match: vi.fn().mockResolvedValue(undefined),
put: vi.fn().mockRejectedValue(new Error('quota')),
}),
});
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v3', async () => new Response('first'))).text()).toBe('first');
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v4', async () => new Response('second'))).text()).toBe('second');
});
test('does not cache partial responses and throws for failed fetches', async () => {
const put = vi.fn();
vi.stubGlobal('window', {});
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
});
const partial = await getCachedBlob(
'/openreader-cache/audio/key/v1',
async () => new Response('partial', { status: 206, headers: { 'Content-Range': 'bytes 0-6/20' } }),
);
expect(partial.status).toBe(206);
expect(put).not.toHaveBeenCalled();
await expect(getCachedBlob('/openreader-cache/audio/key/v2', async () => new Response(null, { status: 404 })))
.rejects.toThrow('Blob fetch failed: 404');
});
});