mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
227 lines
12 KiB
TypeScript
227 lines
12 KiB
TypeScript
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { Readable } from 'node:stream';
|
|
import JSZip from 'jszip';
|
|
import { Db } from '../src/db/index.ts';
|
|
import { FileAcquisitions } from '../src/sources/acquisitions.ts';
|
|
import type { SourceContext, SourceInstance, SourceProvider } from '../src/sources/types.ts';
|
|
|
|
function fixture(root: string): { db: Db; config: { dataDir: string }; context: SourceContext } {
|
|
const db = new Db(join(root, 'reader.db'));
|
|
db.run(
|
|
`INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at)
|
|
VALUES ('u1', 'u1', '', 'x', 'member', 0, 0, 0)`,
|
|
);
|
|
db.run(
|
|
`INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at)
|
|
VALUES ('s1', 'demo', 'demo', 'Demo', '{}', 1, 0)`,
|
|
);
|
|
const instance: SourceInstance = {
|
|
id: 's1', pluginId: 'demo', sourceType: 'demo', name: 'Demo', config: {}, enabled: true,
|
|
};
|
|
return { db, config: { dataDir: root }, context: { instance, userId: 'u1', signal: new AbortController().signal } };
|
|
}
|
|
|
|
function provider(body: Buffer | Readable, mediaType = 'text/plain'): SourceProvider {
|
|
return {
|
|
descriptor: { id: 'demo', label: 'Demo', version: '1', capabilities: ['detail', 'acquire.file'] },
|
|
async detail() { return { ref: 'entry-1', title: 'Remote title', authors: ['Author'] }; },
|
|
async acquire() { return { kind: 'file', acquisitionRef: 'file-1', mediaType, filename: 'remote.txt' }; },
|
|
async openFile() { return { mediaType, data: Buffer.isBuffer(body) ? body : undefined, stream: Buffer.isBuffer(body) ? undefined : body }; },
|
|
};
|
|
}
|
|
|
|
describe('FileAcquisitions', () => {
|
|
test('downloads, parses, persists, and shelves a file', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const importer = new FileAcquisitions(db, config);
|
|
const bytes = Buffer.from('第一章\nhello\n', 'utf8');
|
|
const bookId = await importer.acquire(
|
|
provider(bytes), context, { entryRef: 'entry-1', optionId: 'txt' },
|
|
{ kind: 'file', acquisitionRef: 'file-1', mediaType: 'text/plain', filename: 'remote.txt' },
|
|
{ ref: 'entry-1', title: 'Remote title', authors: ['Author'] },
|
|
);
|
|
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
const book = db.get<{ title: string; content_hash: string; source: string }>('SELECT title, content_hash, source FROM books WHERE id = ?', bookId);
|
|
assert.deepEqual({ ...book }, { title: 'Remote title', content_hash: hash, source: 'provider:demo' });
|
|
assert.equal(db.get('SELECT book_id FROM user_books WHERE user_id = ? AND book_id = ? AND hidden = 0', 'u1', bookId)?.book_id, bookId);
|
|
const acquired = db.get<{ rel_path: string; size: number }>('SELECT rel_path, size FROM acquired_files WHERE book_id = ?', bookId);
|
|
assert.equal(acquired?.size, bytes.byteLength);
|
|
assert.deepEqual(await readFile(join(config.dataDir, acquired!.rel_path)), bytes);
|
|
assert.equal(db.get('SELECT book_id FROM source_acquisitions WHERE source_id = ? AND user_id = ? AND entry_ref = ? AND option_id = ?', 's1', 'u1', 'entry-1', 'txt')?.book_id, bookId);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('serial repeat reuses the committed file and does not create another copy', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const importer = new FileAcquisitions(db, config);
|
|
const body = Buffer.from('same bytes');
|
|
const args = [provider(body), context, { entryRef: 'entry-1', optionId: '' },
|
|
{ kind: 'file', acquisitionRef: 'file-1', mediaType: 'text/plain', filename: 'same.txt' },
|
|
{ ref: 'entry-1', title: 'Same' }] as const;
|
|
const first = await importer.acquire(...args);
|
|
const second = await importer.acquire(...args);
|
|
assert.equal(second, first);
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 1);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('rejects an oversized response and cleans temporary files', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const importer = new FileAcquisitions(db, config, { maxBytes: 4 });
|
|
await assert.rejects(
|
|
importer.acquire(
|
|
provider(Buffer.from('12345')), context, { entryRef: 'large' },
|
|
{ kind: 'file', acquisitionRef: 'large', mediaType: 'text/plain' }, { ref: 'large', title: 'Large' },
|
|
),
|
|
(error: unknown) => (error as { code?: string }).code === 'ACQUISITION_TOO_LARGE',
|
|
);
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 0);
|
|
assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('cancellation after provider response leaves no acquired file', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context: original } = fixture(root);
|
|
try {
|
|
const controller = new AbortController();
|
|
const context = { ...original, signal: controller.signal };
|
|
const source: SourceProvider = {
|
|
...provider(Buffer.from('cancel me')),
|
|
async openFile() {
|
|
controller.abort();
|
|
return { mediaType: 'text/plain', stream: Readable.from([Buffer.from('cancel me')]) };
|
|
},
|
|
};
|
|
const importer = new FileAcquisitions(db, config);
|
|
await assert.rejects(importer.acquire(
|
|
source, context, { entryRef: 'cancel' },
|
|
{ kind: 'file', acquisitionRef: 'cancel', mediaType: 'text/plain' }, { ref: 'cancel', title: 'Cancel' },
|
|
), { name: 'AbortError' });
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 0);
|
|
assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('concurrent requests for one acquisition download once and restore a hidden shelf entry', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const importer = new FileAcquisitions(db, config);
|
|
let calls = 0;
|
|
const source = provider(Buffer.from('same bytes'));
|
|
source.openFile = async () => {
|
|
calls += 1;
|
|
return { mediaType: 'text/plain', stream: Readable.from([Buffer.from('same bytes')]) };
|
|
};
|
|
const acquire = () => importer.acquire(source, context, { entryRef: 'same' },
|
|
{ kind: 'file', acquisitionRef: 'same', mediaType: 'text/plain' }, { ref: 'same', title: 'Same' });
|
|
const [first, second] = await Promise.all([acquire(), acquire()]);
|
|
assert.equal(first, second);
|
|
assert.equal(calls, 1);
|
|
db.run('UPDATE user_books SET hidden = 1 WHERE user_id = ? AND book_id = ?', context.userId, first);
|
|
assert.equal(await acquire(), first);
|
|
assert.equal(db.get('SELECT hidden FROM user_books WHERE user_id = ? AND book_id = ?', context.userId, first)?.hidden, 0);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('concurrent sources with identical bytes share one file and preserve existing metadata', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const importer = new FileAcquisitions(db, config);
|
|
const body = Buffer.from('the same publication');
|
|
const acquire = (entryRef: string, title: string) => importer.acquire(
|
|
provider(body), context, { entryRef }, { kind: 'file', acquisitionRef: entryRef, mediaType: 'text/plain' },
|
|
{ ref: entryRef, title },
|
|
);
|
|
const [first, second] = await Promise.all([acquire('first', 'First'), acquire('second', 'Second')]);
|
|
assert.equal(first, second);
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 1);
|
|
assert.equal(db.get('SELECT COUNT(*) AS n FROM source_acquisitions')?.n, 2);
|
|
db.run('UPDATE books SET title = ?, author = ?, source = ? WHERE id = ?', 'Edited title', 'Edited author', 'embedded', first);
|
|
await acquire('third', 'Different catalog title');
|
|
assert.deepEqual({ ...db.get('SELECT title, author, source FROM books WHERE id = ?', first) },
|
|
{ title: 'Edited title', author: 'Edited author', source: 'embedded' });
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 1);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('rejects HTML pretending to be a PDF without adding a book', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const importer = new FileAcquisitions(db, config);
|
|
await assert.rejects(importer.acquire(
|
|
provider(Buffer.from('<html>login required</html>'), 'application/pdf'), context, { entryRef: 'pdf' },
|
|
{ kind: 'file', acquisitionRef: 'pdf', mediaType: 'application/pdf' }, { ref: 'pdf', title: 'PDF' },
|
|
), (error: unknown) => (error as { code?: string }).code === 'INVALID_RESOURCE');
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 0);
|
|
assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a database failure rolls back rows and removes downloaded file and extracted cover', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-'));
|
|
const { db, config, context } = fixture(root);
|
|
try {
|
|
const zip = new JSZip();
|
|
zip.file('mimetype', 'application/epub+zip');
|
|
zip.file('META-INF/container.xml', '<container><rootfiles><rootfile full-path="book.opf"/></rootfiles></container>');
|
|
zip.file('book.opf', `<package><metadata><dc:identifier>test-book</dc:identifier><dc:title>Embedded</dc:title>
|
|
<meta name="cover" content="cover"/></metadata><manifest>
|
|
<item id="c1" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
|
<item id="cover" href="cover.png" media-type="image/png" properties="cover-image"/>
|
|
</manifest><spine><itemref idref="c1"/></spine></package>`);
|
|
zip.file('chapter.xhtml', '<html><body>Text</body></html>');
|
|
zip.file('cover.png', Buffer.from([137, 80, 78, 71]));
|
|
const bytes = await zip.generateAsync({ type: 'nodebuffer' });
|
|
const importer = new FileAcquisitions(db, config);
|
|
const missingUser = { ...context, userId: 'missing' };
|
|
await assert.rejects(importer.acquire(
|
|
provider(bytes, 'application/epub+zip'), missingUser, { entryRef: 'epub' },
|
|
{ kind: 'file', acquisitionRef: 'epub', mediaType: 'application/epub+zip' }, { ref: 'epub', title: 'Catalog' },
|
|
), /FOREIGN KEY constraint failed/);
|
|
assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0);
|
|
assert.equal(db.get('SELECT COUNT(*) AS n FROM acquired_files')?.n, 0);
|
|
assert.equal((await readdir(join(root, 'acquired'))).length, 0);
|
|
assert.equal((await readdir(join(root, 'covers'))).length, 0);
|
|
} finally {
|
|
db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|