Files
bookorbit/server/src/modules/reader/cbz/cbz.service.sevenzip.test.ts
T
neon 198d36ae56 fix(reader): serve PDF and comic files correctly to every client
Fix the server and web gaps found while porting the PDF and comic
readers and offline downloads to Android, checked against the iOS app:

- KOReader: answer a pull for a PDF or comic with the page number when a
  BookOrbit client wrote last, instead of a percentage with no page
- comics: key the zip offset, RAR and CB7 caches by file id and
  modification time, so pages are not served from stale offsets after a
  metadata write-back rewrites the archive
- comics: answer an unreadable archive with 422 rather than a page count
  of 0, and extract CB7 with its folders so same-named pages in
  different folders no longer overwrite each other
- book files: send an ETag and Last-Modified on serve and download and
  honour If-None-Match and If-Range, clamp a range that runs past the
  end, answer suffix ranges with 206, and honour Range on download,
  which advertised it but sent the whole file
- book files: leave raw file routes uncompressed, so FB2 and unknown
  formats keep their Content-Length
- progress: reject a fractional or negative page number with 400 rather
  than failing with 500 at the database
- types: declare the comic page-count response shared by the web and
  the apps
- web: open a percentage-only PDF position at its estimated page, and
  flush pending PDF and comic progress before the reading session ends
2026-10-13 16:33:11 +00:00

103 lines
4.3 KiB
TypeScript

import { mkdtemp, rm, utimes, writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { createSevenZipTempId, getSevenZip } from '../../../common/sevenzip';
import { CbzService, type ComicPageResult } from './cbz.service';
/**
* CB7 page listing walks a tree the real 7-Zip wrote, so these cases run against the real WASM
* module: the unit tests stand on a fake filesystem, and this is where the two would disagree.
*/
describe('CbzService CB7 pages against the real 7z module', () => {
const user = { id: 9, isSuperuser: false, permissions: [] } as any;
let workspace: string;
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'bookorbit-cb7-real-'));
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
});
async function buildSevenZip(files: Record<string, string>): Promise<Uint8Array> {
const sevenZip = await getSevenZip();
const root = `/${createSevenZipTempId('build')}`;
sevenZip.FS.mkdir(root);
for (const [name, contents] of Object.entries(files)) {
const segments = name.split('/');
for (let depth = 1; depth < segments.length; depth++) {
try {
sevenZip.FS.mkdir(`${root}/${segments.slice(0, depth).join('/')}`);
} catch {
// already there
}
}
const fd = sevenZip.FS.open(`${root}/${name}`, 'w+');
const bytes = new TextEncoder().encode(contents);
sevenZip.FS.write(fd, bytes, 0, bytes.length);
sevenZip.FS.close(fd);
}
sevenZip.callMain(['a', `${root}/comic.7z`, `${root}/*`, '-y']);
return sevenZip.FS.readFile(`${root}/comic.7z`);
}
/** The page list the flat `e` extraction produced before folders were kept. */
async function flatPageNames(archive: Uint8Array): Promise<string[]> {
const sevenZip = await getSevenZip();
const root = `/${createSevenZipTempId('flat')}`;
sevenZip.FS.mkdir(root);
const fd = sevenZip.FS.open(`${root}.7z`, 'w+');
sevenZip.FS.write(fd, archive, 0, archive.length);
sevenZip.FS.close(fd);
sevenZip.callMain(['e', `${root}.7z`, `-o${root}`, '-y']);
return sevenZip.FS.readdir(root)
.filter((name) => name !== '.' && name !== '..' && /\.(jpe?g|png)$/i.test(name) && !name.startsWith('.'))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}
async function pageTexts(service: CbzService, fileId: number): Promise<string[]> {
const count = await service.getPageCount(fileId, user);
const texts: string[] = [];
for (let index = 0; index < count; index++) {
const result: ComicPageResult = await service.streamPage(fileId, index, user);
if (result.notModified) throw new Error('expected page bytes');
const chunks: Buffer[] = [];
for await (const chunk of result.stream) chunks.push(Buffer.from(chunk));
texts.push(Buffer.concat(chunks).toString());
}
return texts;
}
it('keeps the flat page order for an archive whose page names are unique', async () => {
const files = {
'Chapter 10/c10.jpg': 'c10.jpg',
'Chapter 2/c2.jpg': 'c2.jpg',
'cover.jpg': 'cover.jpg',
'extras/.thumbs/z1.jpg': 'z1.jpg',
'extras/credits.png': 'credits.png',
'extras/._credits.png': 'hidden',
};
const archive = await buildSevenZip(files);
const path = join(workspace, 'unique.cb7');
await writeFile(path, archive);
const service = new CbzService({ verifyFileAccess: vi.fn().mockResolvedValue({ format: 'cb7', absolutePath: path }) } as any);
await expect(pageTexts(service, 31)).resolves.toEqual(await flatPageNames(archive));
});
it('serves pages that share a name in different folders, and rereads a rewritten file', async () => {
const path = join(workspace, 'nested.cb7');
await writeFile(path, await buildSevenZip({ 'ch2/001.jpg': 'ch2-1', 'ch1/001.jpg': 'ch1-1', 'ch1/002.jpg': 'ch1-2' }));
const service = new CbzService({ verifyFileAccess: vi.fn().mockResolvedValue({ format: 'cb7', absolutePath: path }) } as any);
await expect(pageTexts(service, 32)).resolves.toEqual(['ch1-1', 'ch2-1', 'ch1-2']);
await writeFile(path, await buildSevenZip({ 'ch1/001.jpg': 'rewritten' }));
await utimes(path, new Date(), new Date(Date.now() + 60_000));
await expect(pageTexts(service, 32)).resolves.toEqual(['rewritten']);
});
});