import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { zipSync } from 'fflate'; import { Cache } from '../src/services/cache.ts'; import { fetchEpub } from '../src/services/epub.ts'; const enc = new TextEncoder(); function buildEpub(chapters: { title: string; content: string }[]): Uint8Array { const files: Record = {}; files['META-INF/container.xml'] = enc.encode( ` `, ); const manifestItems = chapters .map((_, i) => ``) .join('\n '); const spineItems = chapters.map((_, i) => ``).join('\n '); files['content.opf'] = enc.encode( ` ${manifestItems} ${spineItems} `, ); for (let i = 0; i < chapters.length; i++) { const ch = chapters[i]!; files[`ch${i}.xhtml`] = enc.encode( `
${ch.content}
`, ); } return zipSync(files); } let tmpDir: string; let cache: Cache; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'qa-epub-test-')); cache = new Cache(tmpDir); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); function mockFetch(data: Uint8Array) { return spyOn(globalThis, 'fetch').mockResolvedValue( new Response(data, { status: 200 }), ); } describe('epub — parsing', () => { test('extracts the correct number of chapters', async () => { const epub = buildEpub([ { title: 'Ch 1', content: 'First chapter content here.' }, { title: 'Ch 2', content: 'Second chapter content here.' }, { title: 'Ch 3', content: 'Third chapter content here.' }, ]); const spy = mockFetch(epub); let rangeStart = -1, rangeEnd = -1; const view = await fetchEpub('http://fake/book.epub', 1, 0, 2, cache, (s, e) => { rangeStart = s; rangeEnd = e; }); expect(view.chapters).toHaveLength(3); expect(rangeStart).toBe(0); expect(rangeEnd).toBe(2); spy.mockRestore(); }); test('serves from cache on second call without re-fetching', async () => { const epub = buildEpub([ { title: 'Ch 1', content: 'Chapter one long enough content.' }, ]); const spy = mockFetch(epub); await fetchEpub('http://fake/book.epub', 2, 0, 0, cache, () => {}); expect(spy).toHaveBeenCalledTimes(1); // Second call — should hit cache await fetchEpub('http://fake/book.epub', 2, 0, 0, cache, () => {}); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); }); test('skips blank chapters', async () => { const epub = buildEpub([ { title: 'Cover', content: ' ' }, // blank — should be skipped { title: 'Ch 1', content: 'Real content here for this chapter.' }, { title: 'Ch 2', content: 'More real content for the next chapter.' }, ]); const spy = mockFetch(epub); let rangeEnd = -1; await fetchEpub('http://fake/book.epub', 3, 0, 10, cache, (_, e) => { rangeEnd = e; }); expect(rangeEnd).toBe(1); // only 2 non-blank chapters (indices 0 and 1) spy.mockRestore(); }); test('throws on HTTP error', async () => { const spy = spyOn(globalThis, 'fetch').mockResolvedValue( new Response('Not Found', { status: 404 }), ); await expect(fetchEpub('http://fake/missing.epub', 4, 0, 0, cache, () => {})).rejects.toThrow( 'HTTP 404', ); spy.mockRestore(); }); test('slices to the requested range', async () => { const epub = buildEpub([ { title: 'Ch 0', content: 'Chapter zero has enough content here.' }, { title: 'Ch 1', content: 'Chapter one has enough content here.' }, { title: 'Ch 2', content: 'Chapter two has enough content here.' }, { title: 'Ch 3', content: 'Chapter three has enough content here.' }, ]); const spy = mockFetch(epub); const view = await fetchEpub('http://fake/book.epub', 5, 1, 2, cache, () => {}); expect(view.min).toBe(1); expect(view.max).toBe(2); expect(view.chapters).toHaveLength(2); spy.mockRestore(); }); });