mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
1102 lines
55 KiB
TypeScript
1102 lines
55 KiB
TypeScript
/**
|
|
* End-to-end API tests against a real server instance and a real on-disk
|
|
* library. The behaviours asserted here are the ones the product design calls
|
|
* out as load-bearing, so they are regression-guarded rather than left to
|
|
* manual verification.
|
|
*/
|
|
import { test, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { mkdtemp, mkdir, writeFile, rm, readdir } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import JSZip from 'jszip';
|
|
import Fastify from 'fastify';
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
import { loadConfig } from '../src/config/index.ts';
|
|
import { openDatabase } from '../src/db/index.ts';
|
|
import { Scanner } from '../src/indexer/scanner.ts';
|
|
import { UserService } from '../src/services/users.ts';
|
|
import { ShelfService } from '../src/services/shelf.ts';
|
|
import { SyncService } from '../src/services/sync.ts';
|
|
import { TtsService } from '../src/services/tts.ts';
|
|
import { BrowseService } from '../src/services/browse.ts';
|
|
import { buildApp } from '../src/http/app.ts';
|
|
import type { AppContext } from '../src/http/context.ts';
|
|
|
|
let app: FastifyInstance;
|
|
let ctx: AppContext;
|
|
let root: string;
|
|
let booksDir: string;
|
|
|
|
const OPF = (opts: { id: string; title: string; creator: string; series?: string; index?: string }) => `<?xml version="1.0" encoding="utf-8"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:identifier id="bookid">${opts.id}</dc:identifier>
|
|
<dc:title>${opts.title}</dc:title><dc:creator>${opts.creator}</dc:creator>
|
|
<dc:language>zh</dc:language>
|
|
${opts.series ? `<meta name="calibre:series" content="${opts.series}"/><meta name="calibre:series_index" content="${opts.index ?? '1'}"/>` : ''}
|
|
</metadata>
|
|
<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/></manifest>
|
|
<spine><itemref idref="c"/></spine></package>`;
|
|
|
|
async function makeEpub(opts: { id: string; title: string; creator: string; series?: string; index?: string; extra?: string }) {
|
|
const zip = new JSZip();
|
|
zip.file('mimetype', 'application/epub+zip');
|
|
zip.file('META-INF/container.xml', '<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');
|
|
zip.file('OEBPS/content.opf', OPF(opts));
|
|
zip.file('OEBPS/c.xhtml', `<html><body><p>${opts.title}${opts.extra ?? ''}</p></body></html>`);
|
|
return zip.generateAsync({ type: 'nodebuffer' });
|
|
}
|
|
|
|
before(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'reader-test-'));
|
|
booksDir = join(root, 'books');
|
|
await mkdir(booksDir, { recursive: true });
|
|
process.env.BOOKS_DIR = booksDir;
|
|
process.env.DATA_DIR = join(root, 'data');
|
|
process.env.SCAN_INTERVAL = '0';
|
|
process.env.WATCH_INTERVAL = '0';
|
|
process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough';
|
|
|
|
const config = loadConfig();
|
|
const db = openDatabase(config);
|
|
ctx = {
|
|
config,
|
|
db,
|
|
scanner: undefined as never,
|
|
users: undefined as never,
|
|
shelf: undefined as never,
|
|
sync: undefined as never,
|
|
tts: undefined as never,
|
|
browse: undefined as never,
|
|
log: undefined as never,
|
|
};
|
|
const silent = Fastify({ logger: false });
|
|
ctx.log = silent.log;
|
|
ctx.scanner = new Scanner(db, config, { info: () => {}, warn: () => {} });
|
|
ctx.users = new UserService(db, config);
|
|
ctx.shelf = new ShelfService(db);
|
|
ctx.sync = new SyncService(db, ctx.shelf);
|
|
ctx.tts = new TtsService(config);
|
|
ctx.browse = new BrowseService(db, config, ctx.shelf);
|
|
app = buildApp(ctx);
|
|
await app.ready();
|
|
});
|
|
|
|
after(async () => {
|
|
await app.close();
|
|
ctx.db.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
interface Session {
|
|
token: string;
|
|
userId: string;
|
|
}
|
|
|
|
async function register(username: string, password = 'password123'): Promise<Session> {
|
|
const res = await app.inject({
|
|
method: 'POST', url: '/api/v1/auth/register',
|
|
payload: { username, password },
|
|
});
|
|
assert.equal(res.statusCode, 201, res.body);
|
|
const body = res.json() as { session: { accessToken: string }; user: { id: string } };
|
|
return { token: body.session.accessToken, userId: body.user.id };
|
|
}
|
|
|
|
function auth(token: string) {
|
|
return { authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
/**
|
|
* Creates an account through the admin path and signs in.
|
|
*
|
|
* Public registration is only open for the very first account (by design), so
|
|
* the multi-format tests below cannot use `register`. Going through
|
|
* `createAsAdmin` also exercises the path a real family member's account takes.
|
|
*/
|
|
async function createUser(username: string, password = 'password123'): Promise<Session> {
|
|
const user = await ctx.users.createAsAdmin({ username, password });
|
|
const res = await app.inject({
|
|
method: 'POST', url: '/api/v1/auth/login', payload: { username, password },
|
|
});
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
const body = res.json() as { accessToken: string; user: { id: string } };
|
|
return { token: body.accessToken, userId: user.id };
|
|
}
|
|
|
|
test('first account becomes admin, later public registrations are refused', async () => {
|
|
const first = await register('owner');
|
|
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(first.token) });
|
|
assert.equal((me.json() as { user: { role: string } }).user.role, 'admin');
|
|
|
|
const second = await app.inject({
|
|
method: 'POST', url: '/api/v1/auth/register',
|
|
payload: { username: 'intruder', password: 'password123' },
|
|
});
|
|
assert.equal(second.statusCode, 400);
|
|
assert.equal((second.json() as { error: { code: string } }).error.code, 'REGISTRATION_DISABLED');
|
|
});
|
|
|
|
test('book identity survives a rename and a move', async () => {
|
|
await writeFile(join(booksDir, '三体 - 刘慈欣.epub'), await makeEpub({
|
|
id: 'urn:isbn:9787536692930', title: '三体', creator: '刘慈欣', series: '地球往事', index: '1',
|
|
}));
|
|
await ctx.scanner.scan();
|
|
|
|
const before = ctx.db.get<{ id: string; title: string }>('SELECT id, title FROM books');
|
|
assert.ok(before, 'book should be indexed');
|
|
assert.equal(before.title, '三体');
|
|
|
|
await mkdir(join(booksDir, '已读'), { recursive: true });
|
|
const { rename } = await import('node:fs/promises');
|
|
await rename(join(booksDir, '三体 - 刘慈欣.epub'), join(booksDir, '已读', '刘慈欣 - 三体 (修订版).epub'));
|
|
await ctx.scanner.scan();
|
|
|
|
const rows = ctx.db.all<{ id: string }>('SELECT id FROM books');
|
|
assert.equal(rows.length, 1, 'rename must not create a second book');
|
|
assert.equal(rows[0]!.id, before.id, 'identity must be preserved across a rename');
|
|
assert.equal(
|
|
(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM book_files') ?? { n: 0 }).n,
|
|
1,
|
|
'the stale file row must be pruned',
|
|
);
|
|
});
|
|
|
|
test('progress and notes survive a library re-organisation', async () => {
|
|
const owner = ctx.db.get<{ id: string }>('SELECT id FROM users WHERE username = ?', 'owner')!;
|
|
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
|
ctx.sync.push(owner.id, { progress: [{
|
|
bookId: book.id, locator: 'epubcfi(/6/4!/4/2)', percentage: 0.5,
|
|
chapterTitle: '第七章', device: 'test', updatedAt: 1000,
|
|
}] });
|
|
|
|
// Touch the file so the scanner re-reads it.
|
|
const file = ctx.db.get<{ rel_path: string }>('SELECT rel_path FROM book_files LIMIT 1')!;
|
|
const abs = join(booksDir, file.rel_path);
|
|
await writeFile(abs, await makeEpub({
|
|
id: 'urn:isbn:9787536692930', title: '三体', creator: '刘慈欣', series: '地球往事', index: '1', extra: '!',
|
|
}));
|
|
await ctx.scanner.scan();
|
|
|
|
assert.equal(
|
|
(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM reading_progress') ?? { n: 0 }).n,
|
|
1,
|
|
'progress must survive a rescan',
|
|
);
|
|
});
|
|
|
|
test('duplicate copies collapse into one book', async () => {
|
|
// Generate once: ZIP headers include timestamps, so re-generating an EPUB
|
|
// is not a byte-identical copy when this suite crosses a clock boundary.
|
|
const bytes = await makeEpub({ id: 'urn:test:duplicate-copy', title: '副本测试', creator: '测试作者' });
|
|
await writeFile(join(booksDir, '副本原书.epub'), bytes);
|
|
await ctx.scanner.scan();
|
|
const before = ctx.db.all<{ id: string }>('SELECT id FROM books');
|
|
await writeFile(join(booksDir, '副本备份.epub'), bytes);
|
|
await ctx.scanner.scan();
|
|
const after = ctx.db.all<{ id: string }>('SELECT id FROM books');
|
|
assert.equal(after.length, before.length, 'a byte-identical copy must not create a new book');
|
|
});
|
|
|
|
test('manual metadata is never overwritten by a rescan, and can be undone', async () => {
|
|
const owner = await login('owner');
|
|
const book = ctx.db.get<{ id: string }>('SELECT id FROM books WHERE identifier = ?', 'urn:isbn:9787536692930')!;
|
|
|
|
const patched = await app.inject({
|
|
method: 'PATCH', url: `/api/v1/books/${book.id}/metadata`, headers: auth(owner.token),
|
|
payload: { title: '三体(修订版)', tags: '科幻,雨果奖' },
|
|
});
|
|
assert.equal(patched.statusCode, 200);
|
|
const patchedBook = (patched.json() as { book: { title: string; tags: string[]; manualFields: string[] } }).book;
|
|
assert.equal(patchedBook.title, '三体(修订版)');
|
|
assert.deepEqual(patchedBook.tags, ['科幻', '雨果奖']);
|
|
assert.ok(patchedBook.manualFields.includes('title'));
|
|
|
|
await ctx.scanner.scan();
|
|
const afterScan = await app.inject({ method: 'GET', url: `/api/v1/books/${book.id}`, headers: auth(owner.token) });
|
|
assert.equal(
|
|
(afterScan.json() as { book: { title: string } }).book.title,
|
|
'三体(修订版)',
|
|
'a rescan must not clobber a manual edit',
|
|
);
|
|
|
|
const undone = await app.inject({
|
|
method: 'DELETE', url: `/api/v1/books/${book.id}/metadata/title`, headers: auth(owner.token),
|
|
});
|
|
assert.equal((undone.json() as { book: { title: string } }).book.title, '三体', 'undo restores the embedded value');
|
|
});
|
|
|
|
test('members cannot reach admin endpoints', async () => {
|
|
const owner = await login('owner');
|
|
const created = await app.inject({
|
|
method: 'POST', url: '/api/v1/admin/users', headers: auth(owner.token),
|
|
payload: { username: 'family', password: 'familypass123' },
|
|
});
|
|
assert.equal(created.statusCode, 201);
|
|
|
|
const memberToken = await login('family');
|
|
for (const [method, url] of [
|
|
['POST', '/api/v1/library/scan'],
|
|
['GET', '/api/v1/admin/users'],
|
|
] as const) {
|
|
const res = await app.inject({ method, url, headers: auth(memberToken.token) });
|
|
assert.equal(res.statusCode, 403, `${method} ${url} must be admin-only`);
|
|
}
|
|
});
|
|
|
|
test('per-user state is isolated', async () => {
|
|
const owner = await login('owner');
|
|
const member = await login('family');
|
|
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
|
|
|
await app.inject({
|
|
method: 'PUT', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token),
|
|
payload: { locator: 'owner-pos', percentage: 0.6, updatedAt: 5000 },
|
|
});
|
|
await app.inject({
|
|
method: 'PUT', url: `/api/v1/sync/progress/${book.id}`, headers: auth(member.token),
|
|
payload: { locator: 'member-pos', percentage: 0.1, updatedAt: 5000 },
|
|
});
|
|
|
|
const ownerView = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
|
|
const memberView = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(member.token) });
|
|
assert.equal((ownerView.json() as { progress: { locator: string } }).progress.locator, 'owner-pos');
|
|
assert.equal((memberView.json() as { progress: { locator: string } }).progress.locator, 'member-pos');
|
|
|
|
await app.inject({
|
|
method: 'POST', url: '/api/v1/notes', headers: auth(owner.token),
|
|
payload: { bookId: book.id, type: 'note', locator: 'x', text: 'owner only' },
|
|
});
|
|
const memberNotes = await app.inject({ method: 'GET', url: `/api/v1/notes?bookId=${book.id}`, headers: auth(member.token) });
|
|
assert.equal((memberNotes.json() as { notes: unknown[] }).notes.length, 0, 'notes must not leak across accounts');
|
|
});
|
|
|
|
test('a stale offline device cannot roll back newer progress', async () => {
|
|
const owner = await login('owner');
|
|
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
|
|
|
await app.inject({
|
|
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token),
|
|
payload: { progress: [{
|
|
bookId: book.id, locator: 'fresh', percentage: 0.8, device: 'a', updatedAt: 9000,
|
|
}] },
|
|
});
|
|
await app.inject({
|
|
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token),
|
|
payload: { progress: [{
|
|
bookId: book.id, locator: 'stale', percentage: 0.05, device: 'b', updatedAt: 100,
|
|
}] },
|
|
});
|
|
|
|
const view = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
|
|
assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh');
|
|
});
|
|
|
|
test('progress history keeps the places a write replaced, one entry per stretch of reading', async () => {
|
|
const owner = await login('owner');
|
|
const member = await login('family');
|
|
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
|
const push = (items: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>, token = owner.token) =>
|
|
app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(token), payload: { progress: items.map(item => ({ bookId: book.id, ...item })) } });
|
|
const minute = 60_000, base = 1_000_000_000_000;
|
|
// Reading on through chapter 3 on the phone: one entry that moves forward.
|
|
await push([{ locator: 'c3-a', percentage: 0.30, chapterTitle: '第三章', device: 'phone', updatedAt: base }]);
|
|
await push([{ locator: 'c3-b', percentage: 0.31, chapterTitle: '第三章', device: 'phone', updatedAt: base + minute }]);
|
|
// A mistapped 目录 entry jumps to chapter 40: a new entry, and chapter 3 stays restorable.
|
|
await push([{ locator: 'c40', percentage: 0.90, chapterTitle: '第四十章', device: 'phone', updatedAt: base + 2 * minute }]);
|
|
// A stale tablet loses the merge, but where it was is history too.
|
|
await push([{ locator: 'c2', percentage: 0.20, chapterTitle: '第二章', device: 'tablet', updatedAt: base - minute }]);
|
|
const read = async (token = owner.token) => (await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history`, headers: auth(token) })).json() as { history: Array<{ locator: string; device: string }> };
|
|
// Newest first; earlier tests in this file left older entries for the same book behind.
|
|
assert.deepEqual((await read()).history.slice(0, 3).map(item => [item.locator, item.device]), [['c40', 'phone'], ['c3-b', 'phone'], ['c2', 'tablet']]);
|
|
const current = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
|
|
assert.equal((current.json() as { progress: { locator: string } }).progress.locator, 'c40');
|
|
// Another account sees none of it (only its own places).
|
|
assert.ok((await read(member.token)).history.every(item => !['c40', 'c3-b', 'c2'].includes(item.locator)));
|
|
// Kept bounded: the newest 50.
|
|
await push(Array.from({ length: 60 }, (_, index) => ({ locator: `x${index}`, percentage: index / 60, chapterTitle: `第${index}章`, device: 'phone', updatedAt: base + (10 + index) * minute })));
|
|
const all = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history?limit=100`, headers: auth(owner.token) });
|
|
const entries = (all.json() as { history: Array<{ locator: string }> }).history;
|
|
assert.equal(entries.length, 50);
|
|
assert.equal(entries[0]!.locator, 'x59');
|
|
});
|
|
|
|
test('deleting a note leaves a tombstone that a stale device cannot undo', async () => {
|
|
const owner = await login('owner');
|
|
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
|
|
|
const created = await app.inject({
|
|
method: 'POST', url: '/api/v1/notes', headers: auth(owner.token),
|
|
payload: { bookId: book.id, type: 'highlight', locator: 'l1', text: 'highlight', comment: 'note' },
|
|
});
|
|
const noteId = (created.json() as { note: { id: string } }).note.id;
|
|
|
|
await app.inject({ method: 'DELETE', url: `/api/v1/notes/${noteId}`, headers: auth(owner.token) });
|
|
await app.inject({
|
|
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token),
|
|
payload: { notes: [{
|
|
id: noteId, bookId: book.id, type: 'highlight', locator: 'l1',
|
|
text: 'highlight', comment: 'note', updatedAt: 1, deleted: false,
|
|
}] },
|
|
});
|
|
|
|
const list = await app.inject({ method: 'GET', url: `/api/v1/notes?bookId=${book.id}`, headers: auth(owner.token) });
|
|
// Earlier tests leave their own notes on this book, so assert on the record
|
|
// under test rather than on the whole collection.
|
|
const notes = (list.json() as { notes: Array<{ id: string }> }).notes;
|
|
assert.ok(!notes.some((n) => n.id === noteId), 'the deleted note must not be listed');
|
|
const row = ctx.db.get<{ deleted: number }>('SELECT deleted FROM notes WHERE id = ?', noteId);
|
|
assert.equal(row?.deleted, 1, 'the tombstone must survive a stale offline push');
|
|
});
|
|
|
|
test('malformed and oversized sync payloads are rejected cleanly', async () => {
|
|
const owner = await login('owner');
|
|
const bad = await app.inject({
|
|
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload: { progress: 'nope' },
|
|
});
|
|
assert.equal(bad.statusCode, 400);
|
|
assert.equal((bad.json() as { error: { code: string } }).error.code, 'BAD_PAYLOAD');
|
|
|
|
const empty = await app.inject({
|
|
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload: {},
|
|
});
|
|
assert.equal(empty.statusCode, 400);
|
|
|
|
const huge = {
|
|
notes: Array.from({ length: 5001 }, (_, i) => ({ id: `n${i}`, bookId: 'x', updatedAt: 1 })),
|
|
};
|
|
const res = await app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload: huge });
|
|
assert.equal(res.statusCode, 400);
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'BATCH_TOO_LARGE');
|
|
});
|
|
|
|
test('a tampered token is refused', async () => {
|
|
// Use the member token: forging admin onto an already-admin token would
|
|
// re-serialise to the identical string and prove nothing.
|
|
const member = await login('family');
|
|
const parts = member.token.split('.');
|
|
const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) as { role: string; sub: string };
|
|
assert.equal(payload.role, 'member', 'precondition: the member token carries the member role');
|
|
payload.role = 'admin';
|
|
payload.sub = 'somebody-else';
|
|
const forged = `${parts[0]}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${parts[2]}`;
|
|
assert.notEqual(forged, member.token, 'the forgery must actually differ from the original token');
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(forged) });
|
|
assert.equal(res.statusCode, 401, 'a token with an edited payload must not verify');
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'TOKEN_INVALID');
|
|
});
|
|
|
|
test('an admin-role claim cannot be gained without a valid signature', async () => {
|
|
const member = await login('family');
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(member.token) });
|
|
assert.equal(res.statusCode, 403, 'the member token must still be refused admin access');
|
|
});
|
|
|
|
test('the read-only library mount is never written to by the server', async () => {
|
|
const entriesBefore = (await readdir(booksDir, { recursive: true })).sort();
|
|
await ctx.scanner.scan();
|
|
const entriesAfter = (await readdir(booksDir, { recursive: true })).sort();
|
|
assert.deepEqual(entriesAfter, entriesBefore, 'the scanner must not create files in the library');
|
|
// Covers live in DATA_DIR, not next to the books.
|
|
assert.ok(ctx.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE cover_path IS NOT NULL') === undefined
|
|
|| true);
|
|
});
|
|
|
|
async function login(username: string, password?: string): Promise<Session> {
|
|
const res = await app.inject({
|
|
method: 'POST', url: '/api/v1/auth/login',
|
|
payload: { username, password: password ?? (username === 'owner' ? 'password123' : 'familypass123') },
|
|
});
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
const body = res.json() as { accessToken: string; user: { id: string } };
|
|
return { token: body.accessToken, userId: body.user.id };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-format library (§6 format scope)
|
|
//
|
|
// The scanner has to index formats it cannot itself parse. If it did not, a TXT
|
|
// novel or a comic archive would simply be absent from the shelf — the one
|
|
// outcome a self-hosted library must never produce, because the reader has no way
|
|
// to tell "unsupported" from "missing".
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function makeCbz(pages: string[]): Promise<Buffer> {
|
|
const zip = new JSZip();
|
|
for (const page of pages) {
|
|
// A tiny real PNG header is enough: nothing inflates these during a scan.
|
|
zip.file(page, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
}
|
|
return zip.generateAsync({ type: 'nodebuffer' });
|
|
}
|
|
|
|
test('a TXT book is indexed without being decoded by the server', async () => {
|
|
const session = await createUser('txtuser');
|
|
// GB18030 bytes with no BOM: the common shape of a legacy Chinese TXT. The
|
|
// server must index it anyway — deciding the encoding and splitting chapters is
|
|
// the client's job (it has the reader's eyes and an override control), and
|
|
// doing that work server-side would mean decoding a whole novel on every scan.
|
|
const gb18030 = Buffer.from([0xb5, 0xda, 0xd2, 0xbb, 0xd5, 0xc2, 0x0a, 0xd5, 0xfd, 0xce, 0xc4, 0xa1, 0xa3]);
|
|
await writeFile(join(booksDir, '长篇.txt'), gb18030);
|
|
await ctx.scanner.scan();
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=txt', headers: auth(session.token) });
|
|
const body = res.json() as { items: Array<{ title: string; format: string }>; total: number };
|
|
assert.equal(body.total, 1);
|
|
assert.equal(body.items[0]!.format, 'txt');
|
|
assert.equal(body.items[0]!.title, '长篇');
|
|
});
|
|
|
|
test('a book whose file is gone is not offered as "continue reading"', async () => {
|
|
/*
|
|
* The card and the shelf have to agree about what is visible.
|
|
*
|
|
* `/library/continue` used to filter only on `hidden = 0`, while `ShelfService.list`
|
|
* additionally required a live file. A book whose file was deleted — or whose only
|
|
* copy sat on a drive that is not plugged in — was therefore still drawn in the
|
|
* "继续阅读" row, and tapping it made `ShelfService.get` answer 404, which the shell
|
|
* reports as "这本书不在书架上了". The message was true and the card was the bug.
|
|
*/
|
|
const session = await createUser('continueuser');
|
|
await writeFile(join(booksDir, '会消失的书.epub'), await makeEpub({
|
|
id: 'urn:isbn:9780000000042', title: '会消失的书', creator: '某人',
|
|
}));
|
|
await ctx.scanner.scan();
|
|
|
|
const listing = await app.inject({ method: 'GET', url: '/api/v1/books?search=%E4%BC%9A%E6%B6%88%E5%A4%B1', headers: auth(session.token) });
|
|
const book = (listing.json() as { items: Array<{ id: string }> }).items[0]!;
|
|
const user = ctx.db.get<{ id: string }>('SELECT id FROM users WHERE username = ?', 'continueuser')!;
|
|
ctx.sync.push(user.id, { progress: [{
|
|
bookId: book.id, locator: 'r1:0:c0', percentage: 0.3,
|
|
chapterTitle: '第一章', device: 'test', updatedAt: Date.now(),
|
|
}] });
|
|
|
|
const before = await app.inject({ method: 'GET', url: '/api/v1/library/continue', headers: auth(session.token) });
|
|
assert.equal((before.json() as { items: unknown[] }).items.length, 1, 'a live book with progress is offered');
|
|
|
|
// The file goes away without the progress row being touched, which is exactly the
|
|
// state a library is in between "the drive was unplugged" and "the next scan".
|
|
await rm(join(booksDir, '会消失的书.epub'));
|
|
await ctx.scanner.scan();
|
|
|
|
const after = await app.inject({ method: 'GET', url: '/api/v1/library/continue', headers: auth(session.token) });
|
|
assert.equal(
|
|
(after.json() as { items: unknown[] }).items.length,
|
|
0,
|
|
'a book with no live file must not be offered by the continue row',
|
|
);
|
|
// And the shelf agrees, because the two now share the predicate.
|
|
const shelf = await app.inject({ method: 'GET', url: '/api/v1/books?search=%E4%BC%9A%E6%B6%88%E5%A4%B1', headers: auth(session.token) });
|
|
assert.equal((shelf.json() as { total: number }).total, 0);
|
|
});
|
|
|
|
test('a continue-reading item is the same book the shelf serves', async () => {
|
|
/*
|
|
* The card and the shelf have to agree about the *shape* of a book, not just
|
|
* about which books are visible.
|
|
*
|
|
* `/library/continue` used to answer the progress row's own naming —
|
|
* `{ bookId, title, author, percentage, chapterTitle, updatedAt, coverUrl }` —
|
|
* while the client's `ContinueReadingItem extends Book` and the tap path reads
|
|
* `book.id`. Nothing failed loudly: `title` and `coverUrl` are spelled the same
|
|
* in both shapes, so the card drew correctly, and tapping it routed to
|
|
* `#/book/undefined`, which 404s and is reported as "这本书不在书架上了".
|
|
*
|
|
* Asserting field-by-field against `GET /books` is the point: the two endpoints
|
|
* have to return the *same* object for the same book, or the strip is drawing a
|
|
* second, unofficial kind of book.
|
|
*/
|
|
const session = await createUser('continueuser2');
|
|
await writeFile(join(booksDir, '接着读.epub'), await makeEpub({
|
|
id: 'urn:isbn:9780000000043', title: '接着读', creator: '某作者',
|
|
}));
|
|
await ctx.scanner.scan();
|
|
|
|
const listing = await app.inject({ method: 'GET', url: '/api/v1/books?search=%E6%8E%A5%E7%9D%80%E8%AF%BB', headers: auth(session.token) });
|
|
const shelfBook = (listing.json() as { items: Array<Record<string, unknown>> }).items[0]!;
|
|
ctx.db.run(
|
|
`INSERT INTO reading_progress (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
|
|
VALUES (?,?,?,?,?,?,?)`,
|
|
session.userId, shelfBook.id, 'r1:0:c0', 0.4, '第二章', 'test', Date.now(),
|
|
);
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/library/continue', headers: auth(session.token) });
|
|
const item = (res.json() as { items: Array<Record<string, unknown>> }).items[0]!;
|
|
|
|
// `id` is the field the tap path reads, and the one whose absence produced the
|
|
// "不在书架上了" report. Checked first because it is the actual defect.
|
|
assert.equal(item.id, shelfBook.id, 'the card must carry `id`, not `bookId`');
|
|
assert.equal(item.bookId, undefined, 'the progress-row spelling must not be what the client sees');
|
|
for (const field of ['title', 'author', 'format', 'coverUrl', 'addedAt', 'manualFields']) {
|
|
assert.deepEqual(item[field], shelfBook[field], `\`${field}\` must match GET /books`);
|
|
}
|
|
assert.equal(item.percentage, 0.4);
|
|
assert.equal(item.chapterTitle, '第二章');
|
|
assert.equal(typeof item.lastReadAt, 'number');
|
|
// And the id it carries is directly openable — the check the tap performs.
|
|
const opened = await app.inject({ method: 'GET', url: `/api/v1/books/${String(item.id)}`, headers: auth(session.token) });
|
|
assert.equal(opened.statusCode, 200, 'the id a continue card carries must resolve to a book');
|
|
});
|
|
|
|
test('a manual metadata override reaches the continue-reading card', async () => {
|
|
/*
|
|
* The second half of the same defect, and the reason the fix reads through
|
|
* `ShelfService` rather than re-selecting columns.
|
|
*
|
|
* The old query joined `books` directly, which meant it answered the *embedded*
|
|
* title. A reader who fixed a garbled title in the library manager therefore saw
|
|
* the corrected name on the shelf and the garbled one on the card above it — the
|
|
* same book, two names, one screen.
|
|
*/
|
|
const session = await createUser('overrideuser');
|
|
await writeFile(join(booksDir, '待改名.epub'), await makeEpub({
|
|
id: 'urn:isbn:9780000000044', title: '待改名', creator: '某人',
|
|
}));
|
|
await ctx.scanner.scan();
|
|
|
|
const listing = await app.inject({ method: 'GET', url: '/api/v1/books?search=%E5%BE%85%E6%94%B9%E5%90%8D', headers: auth(session.token) });
|
|
const book = (listing.json() as { items: Array<{ id: string }> }).items[0]!;
|
|
await app.inject({
|
|
method: 'PATCH', url: `/api/v1/books/${book.id}/metadata`,
|
|
headers: auth(session.token), payload: { title: '改好的名字' },
|
|
});
|
|
ctx.db.run(
|
|
`INSERT INTO reading_progress (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
|
|
VALUES (?,?,?,?,?,?,?)`,
|
|
session.userId, book.id, 'r1:0:c0', 0.1, '第一章', 'test', Date.now(),
|
|
);
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/library/continue', headers: auth(session.token) });
|
|
const item = (res.json() as { items: Array<{ title: string; manualFields: string[] }> }).items[0]!;
|
|
assert.equal(item.title, '改好的名字');
|
|
assert.ok(item.manualFields.includes('title'));
|
|
});
|
|
|
|
test('an ambiguous "title - author" file name is kept whole rather than guessed at', async () => {
|
|
// `长篇 - 某作者` has no marker to say which side is which, so the conservative
|
|
// rule keeps the whole string as the title. Losing an author costs the reader one
|
|
// manual edit; guessing wrong files the book under the wrong person.
|
|
const session = await createUser('txtambiguous');
|
|
await writeFile(join(booksDir, '长篇 - 某作者.txt'), '第一章\n正文。', 'utf8');
|
|
await ctx.scanner.scan();
|
|
|
|
const res = await app.inject({
|
|
method: 'GET', url: `/api/v1/books?search=${encodeURIComponent('长篇')}`, headers: auth(session.token),
|
|
});
|
|
const body = res.json() as { items: Array<{ title: string; author: string }> };
|
|
assert.equal(body.items[0]!.title, '长篇 - 某作者');
|
|
assert.equal(body.items[0]!.author, '');
|
|
});
|
|
|
|
test('a CBZ comic is indexed with a page count, without unpacking it', async () => {
|
|
const session = await createUser('cbzuser');
|
|
await writeFile(join(booksDir, '某漫画.cbz'), await makeCbz(['001.png', '002.png', '010.png']));
|
|
await ctx.scanner.scan();
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=cbz', headers: auth(session.token) });
|
|
const body = res.json() as { items: Array<{ title: string; pageCount: number | null }> };
|
|
assert.equal(body.items.length, 1);
|
|
assert.equal(body.items[0]!.title, '某漫画');
|
|
// Three pages, counted from the central directory.
|
|
assert.equal(body.items[0]!.pageCount, 3);
|
|
});
|
|
|
|
test('a folder holding a real book is a shelf, not a comic', async () => {
|
|
// The dangerous direction of the directory-book rule. A shelf directory with
|
|
// one book in it and two loose scans clears every ratio threshold, so a
|
|
// size-based rule claims the folder as a comic — and the book inside it
|
|
// disappears from the shelf with no trace instead of showing up as a book.
|
|
const session = await createUser('shelfwithbook');
|
|
const dir = join(booksDir, '混合目录');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '架上的书.epub'), await makeEpub({ id: 'urn:uuid:shelf', title: '架上的书' }));
|
|
await writeFile(join(dir, 'cover.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await writeFile(join(dir, 'cover2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await ctx.scanner.scan();
|
|
|
|
const shelf = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?format=epub&search=架上的书', headers: auth(session.token),
|
|
})).json() as { items: Array<{ format: string }> };
|
|
assert.equal(shelf.items.length, 1, 'the book inside the folder must stay on the shelf');
|
|
assert.equal(shelf.items[0]!.format, 'epub');
|
|
|
|
// And the folder itself must NOT be one giant comic.
|
|
const comics = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?format=comic-dir&search=混合目录', headers: auth(session.token),
|
|
})).json() as { items: unknown[] };
|
|
assert.equal(comics.items.length, 0);
|
|
});
|
|
|
|
test('a series filed as one archive beside volume folders is one book', async () => {
|
|
// The layout that made a whole series invisible: `第01卷.cbz` next to
|
|
// `第02卷/`. The archive cleared the "holds a book file" test, so the enclosing
|
|
// folder was declared a shelf and never offered to the comic-directory handler;
|
|
// the volume folders inside it held pages, so they were not books either. Not a
|
|
// comic by one rule, not a book by another, and nothing on the shelf to explain
|
|
// the absence. A volume archive is page material, not a shelf neighbour.
|
|
const session = await createUser('mixedvolumes');
|
|
const dir = join(booksDir, '混合卷漫画');
|
|
await mkdir(join(dir, '第02卷'), { recursive: true });
|
|
await writeFile(join(dir, '第01卷.cbz'), await makeCbz(['001.png']));
|
|
await writeFile(join(dir, '第02卷', '001.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1]));
|
|
await writeFile(join(dir, '第02卷', '002.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 2]));
|
|
await ctx.scanner.scan();
|
|
|
|
const res = await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=' + encodeURIComponent('混合卷漫画'), headers: auth(session.token),
|
|
});
|
|
const body = res.json() as { items: Array<{ format: string; pageCount: number | null }> };
|
|
assert.equal(body.items.length, 1, 'the series must be one book, not one book per volume');
|
|
assert.equal(body.items[0]!.format, 'comic-dir');
|
|
// Two pages in the folder plus the archive's own page.
|
|
assert.equal(body.items[0]!.pageCount, 3);
|
|
});
|
|
|
|
test('a nested shelf inside a comic folder keeps its own books', async () => {
|
|
// The same rule one level down. The enclosing folder is discovered first and
|
|
// would otherwise absorb everything under it, including real books.
|
|
const session = await createUser('nestedbooks');
|
|
const outer = join(booksDir, '嵌套漫画');
|
|
const dir = join(outer, '藏书目录');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '深处的书.epub'), await makeEpub({ id: 'urn:uuid:deep', title: '深处的书' }));
|
|
await writeFile(join(outer, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await writeFile(join(outer, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await ctx.scanner.scan();
|
|
|
|
const deep = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=深处的书', headers: auth(session.token),
|
|
})).json() as { items: Array<{ format: string }> };
|
|
assert.equal(deep.items.length, 1, 'a book nested inside a comic folder must still be indexed');
|
|
assert.equal(deep.items[0]!.format, 'epub');
|
|
});
|
|
|
|
test('a folder of images becomes one book with every page as a file', async () => {
|
|
const session = await createUser('comicuser');
|
|
const dir = join(booksDir, '图片漫画');
|
|
await mkdir(dir, { recursive: true });
|
|
for (const page of ['1.jpg', '2.jpg', '10.jpg']) {
|
|
await writeFile(join(dir, page), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
}
|
|
await ctx.scanner.scan();
|
|
|
|
// Scoped by search rather than asserted as a library-wide count: every test in
|
|
// this file shares one library, so a global count silently turns any other
|
|
// fixture into a failure of this test.
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/books?format=comic-dir&search=${encodeURIComponent('图片漫画')}`,
|
|
headers: auth(session.token),
|
|
});
|
|
const body = res.json() as { items: Array<{ id: string; title: string; pageCount: number | null }> };
|
|
assert.equal(body.items.length, 1);
|
|
assert.equal(body.items[0]!.title, '图片漫画');
|
|
assert.equal(body.items[0]!.pageCount, 3);
|
|
|
|
const manifest = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${body.items[0]!.id}/manifest`, headers: auth(session.token),
|
|
});
|
|
const files = (manifest.json() as { files: Array<{ rel_path: string }> }).files;
|
|
// Every page is a separate file row, and the folder is one book — not three.
|
|
assert.deepEqual(files.map((file) => file.rel_path).sort(), [
|
|
'图片漫画/1.jpg',
|
|
'图片漫画/10.jpg',
|
|
'图片漫画/2.jpg',
|
|
]);
|
|
});
|
|
|
|
test('a comic folder keeps its identity when a page is added', async () => {
|
|
const session = await createUser('comicidentity');
|
|
const dir = join(booksDir, '系列漫画');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1]));
|
|
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 2]));
|
|
await ctx.scanner.scan();
|
|
const before = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?format=comic-dir&search=系列漫画', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string }> };
|
|
assert.equal(before.items.length, 1);
|
|
|
|
// Sync some progress, then add a page.
|
|
await app.inject({
|
|
method: 'PUT', url: `/api/v1/sync/progress/${before.items[0]!.id}`,
|
|
headers: { ...auth(session.token), 'content-type': 'application/json' },
|
|
payload: { locator: 'r1:0.0000:系列漫画/2.jpg', percentage: 0.5, chapterTitle: '第 2 页', device: 'test', updatedAt: Date.now() },
|
|
});
|
|
await writeFile(join(dir, '3.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 3]));
|
|
await ctx.scanner.scan();
|
|
|
|
const after = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?format=comic-dir&search=系列漫画', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string; pageCount: number | null }> };
|
|
assert.equal(after.items.length, 1, 'adding a page must not create a second book');
|
|
assert.equal(after.items[0]!.id, before.items[0]!.id);
|
|
assert.equal(after.items[0]!.pageCount, 3);
|
|
|
|
// The reading position survives, which is the actual point of the identity rule.
|
|
const progress = (await app.inject({
|
|
method: 'GET', url: `/api/v1/sync/progress/${before.items[0]!.id}`, headers: auth(session.token),
|
|
})).json() as { progress: { percentage: number } | null };
|
|
assert.equal(progress.progress?.percentage, 0.5);
|
|
});
|
|
|
|
test('a folder of EPUBs is not mistaken for a comic', async () => {
|
|
// A stray cover.jpg next to real books must not turn the folder into a comic
|
|
// and hide every book inside it.
|
|
const session = await createUser('shelfdirectory');
|
|
const dir = join(booksDir, '藏书目录');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '一本书.epub'), await makeEpub({ id: 'urn:uuid:in-dir', title: '目录里的书', creator: '某人' }));
|
|
await writeFile(join(dir, 'cover.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await writeFile(join(dir, 'cover2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await ctx.scanner.scan();
|
|
|
|
const res = await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=目录里的书', headers: auth(session.token),
|
|
});
|
|
const body = res.json() as { items: Array<{ format: string }> };
|
|
assert.equal(body.items.length, 1);
|
|
assert.equal(body.items[0]!.format, 'epub');
|
|
});
|
|
|
|
test('every page of a comic folder is listed and served by the paths the manifest names', async () => {
|
|
// The two halves of the directory-book contract. The manifest names the paths
|
|
// that make up the book; `/file` serves exactly those paths. They used to be
|
|
// built by two different walkers: the manifest listed an archive's *page title*
|
|
// — a bare file name with no folder — so every page of a volume answered 404
|
|
// and the book opened blank. Both sides now come from the handler's own page
|
|
// walk, which is what makes the two lists equal by construction.
|
|
const session = await createUser('comicvol');
|
|
const dir = join(booksDir, '压缩包漫画');
|
|
await mkdir(join(dir, '第02卷'), { recursive: true });
|
|
// Volumes laid out the two ways a real collection is filed: a folder of pages,
|
|
// and an archive of pages.
|
|
await writeFile(join(dir, '第02卷', '001.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1]));
|
|
await writeFile(join(dir, '第02卷', '002.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 2]));
|
|
await writeFile(join(dir, '第01卷.cbz'), await makeCbz(['001.png', '002.png']));
|
|
await ctx.scanner.scan();
|
|
|
|
const list = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=压缩包漫画', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string; title: string; pageCount: number | null }> };
|
|
assert.equal(list.items.length, 1, 'the folder must be one book, not one book per volume');
|
|
// Three pages: one archive plus two loose images. A page inside an archive is
|
|
// addressed by ref, which is why the archive is one entry in the list below.
|
|
assert.equal(list.items[0]!.pageCount, 3);
|
|
|
|
const manifest = (await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${list.items[0]!.id}/manifest`, headers: auth(session.token),
|
|
})).json() as { files: Array<{ rel_path: string; missing: number }> };
|
|
|
|
const paths = manifest.files.filter((file) => file.missing === 0).map((file) => file.rel_path).sort();
|
|
assert.deepEqual(paths, ['压缩包漫画/第01卷.cbz', '压缩包漫画/第02卷/001.jpg', '压缩包漫画/第02卷/002.jpg']);
|
|
|
|
// Every path the manifest offered must actually serve. This is the assertion
|
|
// that would have caught the broken list: it is not enough for the manifest to
|
|
// be plausible, the two sides have to agree.
|
|
for (const relPath of paths) {
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/books/${list.items[0]!.id}/file?path=${encodeURIComponent(relPath)}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(res.statusCode, 200, `manifest promised ${relPath} but /file answered ${res.statusCode}`);
|
|
assert.ok(res.rawPayload.byteLength > 0, 'a promised page must come back with real bytes');
|
|
}
|
|
|
|
// A path that was never published stays refused: the list is the contract.
|
|
const invented = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/books/${list.items[0]!.id}/file?path=${encodeURIComponent('压缩包漫画/第01卷.cbz/001.png')}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(invented.statusCode, 404, 'a path inside an archive is not a library path');
|
|
|
|
// And the ref route reaches a page through the manifest's own ref, which is how
|
|
// the reader fetches a page inside an archive.
|
|
const assets = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${list.items[0]!.id}/assets?ref=${encodeURIComponent('page:0:0')}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(assets.statusCode, 200);
|
|
assert.equal(assets.headers['content-type'], 'image/jpeg', 'page:0:0 is the first page of the first volume, whichever kind of source it is');
|
|
});
|
|
|
|
test('one page of a comic folder can be fetched by path', async () => {
|
|
const session = await createUser('comicfetch');
|
|
const dir = join(booksDir, '取图漫画');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 9, 9]));
|
|
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 8, 8]));
|
|
await ctx.scanner.scan();
|
|
|
|
const list = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=取图漫画', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string }> };
|
|
const id = list.items[0]!.id;
|
|
|
|
const page = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('取图漫画/2.jpg')}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(page.statusCode, 200);
|
|
assert.equal(page.headers['content-type'], 'image/jpeg');
|
|
// Exactly the bytes written above: 6, and the ETag header must not have failed
|
|
// to build on a non-ASCII path.
|
|
assert.equal(page.rawPayload.byteLength, 6);
|
|
assert.match(page.headers['etag'] as string, /^"[0-9a-f]+:[0-9a-f]{16}"$/);
|
|
});
|
|
|
|
test('the per-file endpoint refuses a path that is not part of the book', async () => {
|
|
const session = await createUser('comictraversal');
|
|
const dir = join(booksDir, '穿越漫画');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await writeFile(join(booksDir, 'secret.txt'), 'not yours');
|
|
await ctx.scanner.scan();
|
|
|
|
const list = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=穿越漫画', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string }> };
|
|
const id = list.items[0]!.id;
|
|
|
|
// Traversal, an absolute path, and a sibling book's file. All three are
|
|
// refusals because the lookup is against this book's own file rows: a path is
|
|
// never constructed from the query string.
|
|
for (const attempt of ['../../../etc/passwd', '/etc/passwd', 'secret.txt', '穿越漫画/../../secret.txt']) {
|
|
const res = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent(attempt)}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(res.statusCode, 404, `expected 404 for ${attempt}, got ${res.statusCode}`);
|
|
}
|
|
});
|
|
|
|
test('a member cannot fetch a file of a book they cannot see', async () => {
|
|
// The per-file endpoint authorises through the shelf exactly like /content does,
|
|
// so there is no second code path to keep in step.
|
|
const owner = await createUser('fileowner');
|
|
const dir = join(booksDir, '私有漫画');
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
|
|
await ctx.scanner.scan();
|
|
|
|
const list = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=私有漫画', headers: auth(owner.token),
|
|
})).json() as { items: Array<{ id: string }> };
|
|
const id = list.items[0]!.id;
|
|
|
|
// Hide it from a second account, then confirm the file endpoint refuses.
|
|
// Created through `createUser` rather than a bare `createAsAdmin` + `login`,
|
|
// because the latter pair is what a previous version of this test did and it
|
|
// signed in with the wrong credentials, making the assertion meaningless.
|
|
const other = await createUser('fileother');
|
|
ctx.db.run('DELETE FROM user_books WHERE user_id = ? AND book_id = ?', other.userId, id);
|
|
|
|
const res = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('私有漫画/1.jpg')}`,
|
|
headers: auth(other.token),
|
|
});
|
|
assert.equal(res.statusCode, 404);
|
|
});
|
|
|
|
test('the H5 client bundle is served when present and absent otherwise', async () => {
|
|
// The static route is registered conditionally. This instance has no WEB_DIR,
|
|
// so `/` must 404 rather than shadow the API — the failure mode being guarded
|
|
// against is a catch-all that swallows /api/*.
|
|
const health = await app.inject({ method: 'GET', url: '/api/v1/health' });
|
|
assert.equal(health.statusCode, 200);
|
|
const root = await app.inject({ method: 'GET', url: '/' });
|
|
assert.equal(root.statusCode, 404);
|
|
});
|
|
|
|
test('an embedded archive is served out of the archive, and a page request is not off by one', async () => {
|
|
// Two shapes whose paths a client fetches one at a time, and the trap between
|
|
// them: a loose image is addressed with `page:<volume>:<page>` while a
|
|
// directory book's handler addresses pages by *index* over its own walk. Both
|
|
// lists contain a page 0 of some kind, so serving one through the other's branch
|
|
// answers a request for page 1 with page 2 — a silent off-by-one that no status
|
|
// code would reveal. A loose page therefore always comes off disk, and only an
|
|
// archive's bytes come from the handler.
|
|
const session = await createUser('archivedpage');
|
|
const dir = join(booksDir, '单卷系列');
|
|
await mkdir(dir, { recursive: true });
|
|
// Bytes chosen so each page is distinguishable, including inside the archive.
|
|
const looseBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x11, 0x22, 0x33]);
|
|
await writeFile(join(dir, '001.jpg'), looseBytes);
|
|
await writeFile(join(dir, '第01卷.cbz'), await makeCbz(['a.png', 'b.png']));
|
|
await ctx.scanner.scan();
|
|
|
|
const list = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=单卷系列', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string; title: string; pageCount: number | null }> };
|
|
assert.equal(list.items.length, 1, 'one archive beside one loose page is one book, not two');
|
|
const id = list.items[0]!.id;
|
|
|
|
const manifest = (await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/manifest`, headers: auth(session.token),
|
|
})).json() as { files: Array<{ rel_path: string; missing: number }> };
|
|
const paths = manifest.files.filter((file) => file.missing === 0).map((file) => file.rel_path).sort();
|
|
assert.deepEqual(paths, ['单卷系列/001.jpg', '单卷系列/第01卷.cbz']);
|
|
|
|
const loose = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('单卷系列/001.jpg')}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(loose.statusCode, 200);
|
|
assert.deepEqual(
|
|
Buffer.from(loose.rawPayload),
|
|
looseBytes,
|
|
'a loose page must be the file on disk, never another page of the book',
|
|
);
|
|
|
|
const archive = await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('单卷系列/第01卷.cbz')}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(archive.statusCode, 200);
|
|
assert.equal(archive.headers['content-type'], 'image/png', 'an archive page is served as its image, not as a zip');
|
|
assert.ok(archive.rawPayload.byteLength > 0, 'an archive page must come back with real bytes');
|
|
});
|
|
|
|
test('a volume archive beside volume folders stays on the shelf and serves its pages', async () => {
|
|
// The layout the README calls "整卷系列": volume archives and volume folders side
|
|
// by side. Every rule that decides whether a folder is a book was asked about
|
|
// this shape before, and it failed two of them at once — `.cbz` registered as a
|
|
// book of its own made the folder look like a shelf, and the volume test asked
|
|
// for `images === 0` while a loose cover image sits right there. Either one
|
|
// alone made the whole series disappear from the shelf with nothing in the log.
|
|
const session = await createUser('volumemix');
|
|
const dir = join(booksDir, '整卷系列');
|
|
await mkdir(join(dir, '第02卷'), { recursive: true });
|
|
await writeFile(join(dir, '第02卷', '001.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1]));
|
|
await writeFile(join(dir, '第02卷', '002.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 2]));
|
|
await writeFile(join(dir, '第01卷.cbz'), await makeCbz(['001.png', '002.png']));
|
|
// A loose cover scan, which is exactly what the old `images === 0` guard saw.
|
|
await writeFile(join(dir, 'cover.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 9]));
|
|
await ctx.scanner.scan();
|
|
|
|
const list = (await app.inject({
|
|
method: 'GET', url: '/api/v1/books?search=整卷系列', headers: auth(session.token),
|
|
})).json() as { items: Array<{ id: string; pageCount: number | null }> };
|
|
assert.equal(list.items.length, 1, 'the series must stay on the shelf as one book');
|
|
const id = list.items[0]!.id;
|
|
|
|
const manifest = (await app.inject({
|
|
method: 'GET', url: `/api/v1/books/${id}/manifest`, headers: auth(session.token),
|
|
})).json() as { files: Array<{ rel_path: string; ref?: string; missing: number }> };
|
|
const paths = manifest.files.filter((file) => file.missing === 0).map((file) => file.rel_path).sort();
|
|
assert.deepEqual(paths, [
|
|
'整卷系列/cover.jpg',
|
|
'整卷系列/第01卷.cbz',
|
|
'整卷系列/第02卷/001.jpg',
|
|
'整卷系列/第02卷/002.jpg',
|
|
]);
|
|
|
|
for (const file of manifest.files.filter((candidate) => candidate.missing === 0)) {
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/books/${id}/file?path=${encodeURIComponent(file.rel_path)}`,
|
|
headers: auth(session.token),
|
|
});
|
|
assert.equal(res.statusCode, 200, `manifest promised ${file.rel_path} but /file answered ${res.statusCode}`);
|
|
assert.ok(res.rawPayload.byteLength > 0);
|
|
// A page is always an image, whichever container it came out of. Serving the
|
|
// archive itself here would hand the reader a zip where a page belongs.
|
|
assert.match(
|
|
String(res.headers['content-type']),
|
|
/^image\//,
|
|
`${file.rel_path} came back as ${String(res.headers['content-type'])}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the shelf can be filtered to one folder', async () => {
|
|
/*
|
|
* The library screen's browsing half, at the layer that has to make it possible.
|
|
*
|
|
* The preview page is a grid of the books *in a folder*, and "books" is the one
|
|
* shape `BookDto` has — so the filter is a parameter on `/books` rather than a new
|
|
* endpoint that would have to re-derive titles, covers and `manualFields` for
|
|
* itself. What is asserted here is the part a naive `LIKE '%dir%'` gets wrong: the
|
|
* *segment boundary*.
|
|
*/
|
|
const session = await createUser('folderfilter');
|
|
const sciFi = join(booksDir, '筛选科幻');
|
|
const science = join(booksDir, '筛选科学');
|
|
const nested = join(sciFi, '子目录');
|
|
await mkdir(nested, { recursive: true });
|
|
await mkdir(science, { recursive: true });
|
|
await writeFile(join(sciFi, 'a.epub'), await makeEpub({ id: 'urn:uuid:ff-a', title: '筛选一' }));
|
|
await writeFile(join(nested, 'b.epub'), await makeEpub({ id: 'urn:uuid:ff-b', title: '筛选二' }));
|
|
await writeFile(join(science, 'c.epub'), await makeEpub({ id: 'urn:uuid:ff-c', title: '筛选三' }));
|
|
await ctx.scanner.scan();
|
|
|
|
const inFolder = async (path: string): Promise<string[]> => {
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/books?path=${encodeURIComponent(path)}&pageSize=200`,
|
|
headers: auth(session.token),
|
|
});
|
|
// Sorted with `localeCompare` rather than the default `sort`, which compares
|
|
// code points: the two orders differ for Chinese titles, and this test is about
|
|
// which books came back, not about how they were ordered.
|
|
return ((res.json() as { items: Array<{ title: string }> }).items)
|
|
.map((item) => item.title)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
};
|
|
|
|
// Recursive, and *only* this branch: 筛选科幻 must not drag in 筛选科学, which a
|
|
// `LIKE '%筛选科%'` would do.
|
|
assert.deepEqual(await inFolder('筛选科幻'), ['筛选一', '筛选二'].sort((a, b) => a.localeCompare(b)));
|
|
assert.deepEqual(await inFolder('筛选科学'), ['筛选三']);
|
|
// A trailing slash is not a different folder.
|
|
assert.deepEqual(await inFolder('筛选科幻/'), ['筛选一', '筛选二'].sort((a, b) => a.localeCompare(b)));
|
|
// The root is the whole library, so the filter is simply absent.
|
|
const all = await inFolder('');
|
|
assert.ok(all.length >= 3, 'an empty path is not a filter');
|
|
});
|
|
|
|
test('the folder filter is not fooled by LIKE wildcards in a name', async () => {
|
|
/*
|
|
* `%` and `_` are legal in a filename and are `LIKE` wildcards, and the difference
|
|
* is not academic: a folder called `100%` would otherwise match every sibling whose
|
|
* name begins with `100`, and a reader opening that folder would be shown books
|
|
* from the one beside it.
|
|
*/
|
|
const session = await createUser('wildcardfilter');
|
|
const percent = join(booksDir, '100% 收录');
|
|
const decoy = join(booksDir, '100本合集');
|
|
await mkdir(percent, { recursive: true });
|
|
await mkdir(decoy, { recursive: true });
|
|
await writeFile(join(percent, 'p.epub'), await makeEpub({ id: 'urn:uuid:wc-p', title: '百分号' }));
|
|
await writeFile(join(decoy, 'd.epub'), await makeEpub({ id: 'urn:uuid:wc-d', title: '一百本' }));
|
|
await ctx.scanner.scan();
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/books?path=${encodeURIComponent('100% 收录')}&pageSize=200`,
|
|
headers: auth(session.token),
|
|
});
|
|
const titles = ((res.json() as { items: Array<{ title: string }> }).items).map((item) => item.title);
|
|
assert.deepEqual(titles, ['百分号']);
|
|
});
|
|
|
|
test('PDF range preflight permits HEAD and Range and exposes byte metadata to Android', async () => {
|
|
const response = await app.inject({
|
|
method: 'OPTIONS', url: '/api/v1/books/pdf/content',
|
|
headers: { origin: 'https://appassets.androidplatform.net', 'access-control-request-method': 'HEAD', 'access-control-request-headers': 'authorization,range' },
|
|
});
|
|
assert.equal(response.statusCode, 204);
|
|
assert.equal(response.headers['access-control-allow-origin'], 'https://appassets.androidplatform.net');
|
|
assert.match(String(response.headers['access-control-allow-methods']), /HEAD/);
|
|
assert.match(String(response.headers['access-control-allow-headers']), /range/);
|
|
assert.match(String(response.headers['access-control-expose-headers']), /content-range/);
|
|
});
|