144 lines
4.4 KiB
TypeScript
144 lines
4.4 KiB
TypeScript
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<string, Uint8Array> = {};
|
|
|
|
files['META-INF/container.xml'] = enc.encode(
|
|
`<?xml version="1.0"?>
|
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
<rootfiles>
|
|
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
|
</rootfiles>
|
|
</container>`,
|
|
);
|
|
|
|
const manifestItems = chapters
|
|
.map((_, i) => `<item id="ch${i}" href="ch${i}.xhtml" media-type="application/xhtml+xml"/>`)
|
|
.join('\n ');
|
|
const spineItems = chapters.map((_, i) => `<itemref idref="ch${i}"/>`).join('\n ');
|
|
|
|
files['content.opf'] = enc.encode(
|
|
`<?xml version="1.0"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
|
<manifest>
|
|
${manifestItems}
|
|
</manifest>
|
|
<spine>
|
|
${spineItems}
|
|
</spine>
|
|
</package>`,
|
|
);
|
|
|
|
for (let i = 0; i < chapters.length; i++) {
|
|
const ch = chapters[i]!;
|
|
files[`ch${i}.xhtml`] = enc.encode(
|
|
`<html><body><div>${ch.content}</div></body></html>`,
|
|
);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|