import 'fake-indexeddb/auto'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { downloadChapter, isChapterDownloaded, getChapterPageBlobs, getDownloadedChapter, deleteChapter, listDownloads, OfflineQuotaError, } from './offlineLibrary'; const META = { mediaId: 'm1', mediaTitle: 'Test Manga', chapterNumber: 3, chapterTitle: 'Ch 3' }; const PAGES = [ { number: 0, image_url: 'https://host/p0.jpg' }, { number: 1, image_url: 'https://host/p1.jpg' }, ]; function mockFetchOk() { vi.stubGlobal( 'fetch', vi.fn(async (url: string) => ({ ok: true, blob: async () => new Blob([url], { type: 'image/jpeg' }), })), ); } describe('offlineLibrary', () => { beforeEach(async () => { // Clear DB between tests by deleting any known chapter await deleteChapter('m1', 3).catch(() => {}); }); afterEach(() => vi.unstubAllGlobals()); it('downloads, stores blobs + meta, and reports progress', async () => { mockFetchOk(); const progress: number[] = []; const result = await downloadChapter(META, PAGES, (r) => progress.push(r)); expect(result.pageCount).toBe(2); expect(result.pageKeys).toHaveLength(2); expect(await isChapterDownloaded('m1', 3)).toBe(true); expect(progress[progress.length - 1]).toBe(1); const blobs = await getChapterPageBlobs('m1', 3); expect(blobs).toHaveLength(2); expect(await blobs[0].text()).toBe('https://host/p0.jpg'); // ordered by page number }); it('is idempotent: re-downloading overwrites cleanly', async () => { mockFetchOk(); await downloadChapter(META, PAGES); await downloadChapter(META, PAGES); expect((await getChapterPageBlobs('m1', 3))).toHaveLength(2); expect(await listDownloads()).toHaveLength(1); }); it('deleteChapter removes blobs, meta, and index entry', async () => { mockFetchOk(); await downloadChapter(META, PAGES); await deleteChapter('m1', 3); expect(await isChapterDownloaded('m1', 3)).toBe(false); expect(await getDownloadedChapter('m1', 3)).toBeNull(); expect(await getChapterPageBlobs('m1', 3)).toHaveLength(0); expect(await listDownloads()).toHaveLength(0); }); it('rolls back and rejects when a page fetch fails (nothing left downloaded)', async () => { vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { if (url.endsWith('p1.jpg')) return { ok: false, status: 500, blob: async () => new Blob() }; return { ok: true, blob: async () => new Blob([url]) }; }), ); await expect(downloadChapter(META, PAGES)).rejects.toThrow(); expect(await isChapterDownloaded('m1', 3)).toBe(false); expect(await getChapterPageBlobs('m1', 3)).toHaveLength(0); }); it('wraps QuotaExceededError as OfflineQuotaError', async () => { mockFetchOk(); // Force the second blob store to throw a quota error by spying via a sabotaged fetch blob vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, blob: async () => { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; }, })), ); await expect(downloadChapter(META, PAGES)).rejects.toBeInstanceOf(OfflineQuotaError); expect(await isChapterDownloaded('m1', 3)).toBe(false); }); it('listDownloads parses mediaIds that contain a colon', async () => { mockFetchOk(); const colonMeta = { mediaId: 'source:42', mediaTitle: 'Colon Manga', chapterNumber: 7, chapterTitle: 'Ch 7' }; await downloadChapter(colonMeta, PAGES); try { const downloads = await listDownloads(); const found = downloads.find((d) => d.mediaId === 'source:42' && d.chapterNumber === 7); expect(found).toBeDefined(); expect(found?.mediaTitle).toBe('Colon Manga'); } finally { await deleteChapter('source:42', 7); } }); });