mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
841 lines
37 KiB
TypeScript
841 lines
37 KiB
TypeScript
/**
|
||
* File-manager API tests.
|
||
*
|
||
* The screen these back is the only place in the product that *writes* to the
|
||
* library mount, so the behaviours asserted here are the ones where a mistake
|
||
* costs the reader their files or their reading position: path containment,
|
||
* refusing a write on a read-only mount, and keeping progress attached to a book
|
||
* whose file was moved.
|
||
*/
|
||
import { test, before, after, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtemp, mkdir, writeFile, readFile, rename, rm, stat } 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;
|
||
let token: string;
|
||
let userId: string;
|
||
|
||
const OPF = (title: string, id: 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">${id}</dc:identifier>
|
||
<dc:title>${title}</dc:title><dc:creator>作者</dc:creator><dc:language>zh</dc:language>
|
||
</metadata>
|
||
<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||
<spine><itemref idref="c"/></spine></package>`;
|
||
|
||
async function makeEpub(title: string, id: string): Promise<Buffer> {
|
||
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(title, id));
|
||
zip.file('OEBPS/c.xhtml', `<html><body><p>${title}</p></body></html>`);
|
||
return zip.generateAsync({ type: 'nodebuffer' });
|
||
}
|
||
|
||
function auth() {
|
||
return { authorization: `Bearer ${token}` };
|
||
}
|
||
|
||
before(async () => {
|
||
root = await mkdtemp(join(tmpdir(), 'reader-browse-'));
|
||
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();
|
||
|
||
const created = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/auth/register',
|
||
payload: { username: 'owner', password: 'password123' },
|
||
});
|
||
assert.equal(created.statusCode, 201, created.body);
|
||
const body = created.json() as { session: { accessToken: string }; user: { id: string } };
|
||
token = body.session.accessToken;
|
||
userId = body.user.id;
|
||
|
||
await mkdir(join(booksDir, '科幻', '已读'), { recursive: true });
|
||
await writeFile(join(booksDir, '科幻', '三体.epub'), await makeEpub('三体', 'urn:one'));
|
||
await writeFile(join(booksDir, '科幻', '已读', '球状闪电.epub'), await makeEpub('球状闪电', 'urn:two'));
|
||
await writeFile(join(booksDir, '说明.txt'), Buffer.from('hello', 'utf8'));
|
||
await mkdir(join(booksDir, '.trash'), { recursive: true });
|
||
await writeFile(join(booksDir, '.trash', '旧书.epub'), await makeEpub('旧书', 'urn:three'));
|
||
});
|
||
|
||
after(async () => {
|
||
await app.close();
|
||
ctx.db.close();
|
||
await rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('library browsing', () => {
|
||
test('the root lists directories, book files and rule-hidden folders alike', async () => {
|
||
const res = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers: auth() });
|
||
assert.equal(res.statusCode, 200);
|
||
const listing = res.json() as {
|
||
path: string;
|
||
parent: string | null;
|
||
crumbs: Array<{ name: string; path: string }>;
|
||
entries: Array<{ name: string; type: string; scanned: boolean; hidden: boolean; hiddenByRule: boolean }>;
|
||
writable: boolean;
|
||
};
|
||
assert.equal(listing.path, '');
|
||
assert.equal(listing.parent, null);
|
||
assert.deepEqual(listing.crumbs, [{ name: '书库', path: '' }]);
|
||
// A writable mount is the only state in which the UI offers to rename or
|
||
// delete anything, so the flag is part of the contract rather than a nicety.
|
||
assert.equal(listing.writable, true);
|
||
|
||
const byName = new Map(listing.entries.map((entry) => [entry.name, entry]));
|
||
assert.equal(byName.get('科幻')?.type, 'dir');
|
||
assert.equal(byName.get('说明.txt')?.scanned, true, 'a .txt is a book the scanner indexes');
|
||
// The hidden folder is listed, not filtered: "my book is missing from the
|
||
// shelf" is answerable only if the screen can show where it actually is.
|
||
assert.equal(byName.get('.trash')?.hidden, true);
|
||
assert.equal(byName.get('.trash')?.hiddenByRule, true);
|
||
});
|
||
|
||
test('a nested directory reports its breadcrumb and its own entries', async () => {
|
||
const res = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/v1/library/browse?path=${encodeURIComponent('科幻/已读')}`,
|
||
headers: auth(),
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const listing = res.json() as {
|
||
path: string;
|
||
parent: string;
|
||
crumbs: Array<{ name: string; path: string }>;
|
||
entries: Array<{ name: string; indexed: boolean }>;
|
||
dirs: number;
|
||
files: number;
|
||
};
|
||
assert.equal(listing.path, '科幻/已读');
|
||
assert.equal(listing.parent, '科幻');
|
||
assert.deepEqual(listing.crumbs, [
|
||
{ name: '书库', path: '' },
|
||
{ name: '科幻', path: '科幻' },
|
||
{ name: '已读', path: '科幻/已读' },
|
||
]);
|
||
assert.equal(listing.files, 1);
|
||
assert.equal(listing.dirs, 0);
|
||
});
|
||
|
||
test('a traversal attempt is refused, not resolved', async () => {
|
||
for (const path of ['../../etc', '..', '/etc', '科幻/../../..']) {
|
||
const res = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/v1/library/browse?path=${encodeURIComponent(path)}`,
|
||
headers: auth(),
|
||
});
|
||
assert.ok(res.statusCode >= 400, `${path} answered ${res.statusCode}`);
|
||
}
|
||
});
|
||
|
||
test('the API requires a token', async () => {
|
||
const res = await app.inject({ method: 'GET', url: '/api/v1/library/browse' });
|
||
assert.equal(res.statusCode, 401);
|
||
});
|
||
});
|
||
|
||
describe('library file management', () => {
|
||
test('renaming a book keeps its identity, its progress and its notes', async () => {
|
||
await ctx.scanner.scan();
|
||
const book = ctx.db.get<{ id: string; title: string }>('SELECT id, title FROM books WHERE title = ?', '三体');
|
||
assert.ok(book, 'the book should be indexed');
|
||
ctx.sync.push(userId, {
|
||
progress: [
|
||
{
|
||
bookId: book.id,
|
||
locator: 'epubcfi(/6/4!/4/2)',
|
||
percentage: 0.42,
|
||
chapterTitle: '第七章',
|
||
device: 'test',
|
||
updatedAt: Date.now(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const renamed = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/rename',
|
||
headers: auth(),
|
||
payload: { path: '科幻/三体.epub', name: '三体(修订版).epub' },
|
||
});
|
||
assert.equal(renamed.statusCode, 200, renamed.body);
|
||
assert.equal((renamed.json() as { path: string }).path, '科幻/三体(修订版).epub');
|
||
|
||
// The index is repointed immediately rather than at the next scan: the file
|
||
// manager has to leave the library consistent with itself when it closes.
|
||
const row = ctx.db.get<{ book_id: string; rel_path: string }>(
|
||
'SELECT book_id, rel_path FROM book_files WHERE rel_path = ?',
|
||
'科幻/三体(修订版).epub',
|
||
);
|
||
assert.equal(row?.book_id, book.id, 'the book must keep its identity across a rename');
|
||
|
||
// A rescan must agree: identity is `identifier + content hash`, not a path.
|
||
await ctx.scanner.scan();
|
||
const after = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books WHERE title = ?', '三体');
|
||
assert.equal(after?.n, 1);
|
||
const progress = ctx.db.get<{ percentage: number }>(
|
||
'SELECT percentage FROM reading_progress WHERE book_id = ?',
|
||
book.id,
|
||
);
|
||
assert.equal(progress?.percentage, 0.42, 'progress must survive a rename');
|
||
});
|
||
|
||
test('moving a book into a folder keeps it on the shelf, once', async () => {
|
||
const moved = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/move',
|
||
headers: auth(),
|
||
payload: { paths: ['科幻/三体(修订版).epub'], target: '科幻/已读' },
|
||
});
|
||
assert.equal(moved.statusCode, 200, moved.body);
|
||
assert.equal((moved.json() as { moved: number }).moved, 1);
|
||
|
||
await ctx.scanner.scan();
|
||
const count = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books WHERE title = ?', '三体');
|
||
assert.equal(count?.n, 1, 'a move must not leave a duplicate behind');
|
||
|
||
const listing = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/v1/library/browse?path=${encodeURIComponent('科幻/已读')}`,
|
||
headers: auth(),
|
||
});
|
||
const names = (listing.json() as { entries: Array<{ name: string }> }).entries.map((entry) => entry.name);
|
||
assert.ok(names.includes('三体(修订版).epub'));
|
||
});
|
||
|
||
test('a move onto an existing name is refused rather than silently merged', async () => {
|
||
await mkdir(join(booksDir, '科幻', '其他'), { recursive: true });
|
||
await writeFile(join(booksDir, '科幻', '其他', '球状闪电.epub'), await makeEpub('球状闪电(另一份)', 'urn:four'));
|
||
const res = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/move',
|
||
headers: auth(),
|
||
payload: { paths: ['科幻/已读/球状闪电.epub'], target: '科幻/其他' },
|
||
});
|
||
assert.equal(res.statusCode, 409);
|
||
assert.equal((res.json() as { error: { code: string } }).error.code, 'DESTINATION_EXISTS');
|
||
// The source must still be there: refusing a batch means refusing all of it.
|
||
await stat(join(booksDir, '科幻', '已读', '球状闪电.epub'));
|
||
});
|
||
|
||
test('a move into itself is refused', async () => {
|
||
const res = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/move',
|
||
headers: auth(),
|
||
payload: { paths: ['科幻'], target: '科幻/已读' },
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
assert.equal((res.json() as { error: { code: string } }).error.code, 'MOVE_INTO_SELF');
|
||
});
|
||
|
||
test('a new folder can be created and deleted, but a traversal cannot', async () => {
|
||
const created = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/mkdir',
|
||
headers: auth(),
|
||
payload: { path: '', name: '漫画' },
|
||
});
|
||
assert.equal(created.statusCode, 200, created.body);
|
||
|
||
const escaped = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/mkdir',
|
||
headers: auth(),
|
||
payload: { path: '', name: '../外面' },
|
||
});
|
||
assert.equal(escaped.statusCode, 400);
|
||
|
||
const listed = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers: auth() });
|
||
const names = (listed.json() as { entries: Array<{ name: string }> }).entries.map((entry) => entry.name);
|
||
assert.ok(names.includes('漫画'));
|
||
|
||
const deleted = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/delete',
|
||
headers: auth(),
|
||
payload: { paths: ['漫画'] },
|
||
});
|
||
assert.equal(deleted.statusCode, 200);
|
||
assert.equal((deleted.json() as { removed: number }).removed, 1);
|
||
await assert.rejects(stat(join(booksDir, '漫画')));
|
||
});
|
||
|
||
test('deleting a book removes the bytes and leaves the index to the scanner', async () => {
|
||
// A file that is actually indexed: `科幻/已读/三体(修订版).epub` moved here by
|
||
// an earlier test. A path the scanner never saw would leave the interesting
|
||
// half of this behaviour — what happens to an indexed row — untested.
|
||
await ctx.scanner.scan();
|
||
const relPath = '科幻/已读/三体(修订版).epub';
|
||
const target = join(booksDir, relPath);
|
||
await readFile(target);
|
||
const before = ctx.db.get<{ book_id: string; missing: number }>(
|
||
'SELECT book_id, missing FROM book_files WHERE rel_path = ?',
|
||
relPath,
|
||
);
|
||
assert.ok(before, 'the book should be indexed before it is deleted');
|
||
assert.equal(before.missing, 0);
|
||
|
||
const res = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/delete',
|
||
headers: auth(),
|
||
payload: { paths: [relPath] },
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
await assert.rejects(stat(target));
|
||
|
||
// The row stays live until a scan notices, on purpose: the deletion itself is
|
||
// the risky half, and coupling it to a full rescan would make the UI wait
|
||
// minutes to report success.
|
||
const row = ctx.db.get<{ missing: number }>(
|
||
'SELECT missing FROM book_files WHERE rel_path = ?',
|
||
relPath,
|
||
);
|
||
assert.equal(row?.missing, 0, 'a delete does not rewrite the index');
|
||
|
||
await ctx.scanner.scan();
|
||
const after = ctx.db.get<{ missing: number }>(
|
||
'SELECT missing FROM book_files WHERE rel_path = ?',
|
||
relPath,
|
||
);
|
||
assert.equal(after?.missing, 1, 'a scan marks the file missing rather than dropping the book');
|
||
// And the book is remembered: a book on an unmounted share must not vanish
|
||
// from the reader's shelf the moment its file goes away.
|
||
assert.ok(
|
||
ctx.db.get<{ id: string }>('SELECT id FROM books WHERE id = ?', before.book_id),
|
||
'the book row survives a deleted file',
|
||
);
|
||
});
|
||
|
||
test('deleting several entries at once is all or nothing', async () => {
|
||
const res = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/delete',
|
||
headers: auth(),
|
||
payload: { paths: ['说明.txt', '科幻/不存在的.epub'] },
|
||
});
|
||
assert.equal(res.statusCode, 404);
|
||
// The existing entry must survive a batch that named a missing one.
|
||
await stat(join(booksDir, '说明.txt'));
|
||
});
|
||
|
||
test('a malformed batch is rejected rather than coerced', async () => {
|
||
for (const payload of [{}, { paths: [] }, { paths: '科幻' }, { paths: [1] }]) {
|
||
const res = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/library/browse/delete',
|
||
headers: auth(),
|
||
payload,
|
||
});
|
||
assert.equal(res.statusCode, 400, JSON.stringify(payload));
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('a read-only mount', () => {
|
||
test('reports itself as not writable and refuses writes with 403', async () => {
|
||
const service = new BrowseService(ctx.db, ctx.config, ctx.shelf);
|
||
const original = service.writable.bind(service);
|
||
// Stands in for a read-only bind mount, which looks writable by mode bits and
|
||
// refuses every write. Simulated on the probe because the alternative is
|
||
// chmod-ing the test's own mount, which CI runs as root and would ignore.
|
||
service.writable = async () => false;
|
||
// The routes hold their own reference to the service, so this only exercises
|
||
// the guard's contract: a probe that says "no" must become a 403 rather than
|
||
// an EROFS stack trace.
|
||
await assert.rejects(
|
||
service.renamePath('科幻/已读/球状闪电.epub', '球状闪电-改名.epub'),
|
||
(err: unknown) => (err as { statusCode?: number }).statusCode === 403,
|
||
);
|
||
service.writable = original;
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 书架的加入与移除 —— the two directions, end to end.
|
||
*
|
||
* Every assertion here was run before the fix and failed. The screen these back is
|
||
* the *only* place a reader can put a book back on their shelf, so the failure mode
|
||
* of a broken direction is a book they cannot reach from inside the app at all.
|
||
*
|
||
* The suite lives under its own folder (`加减/`) because the shelf's answers are
|
||
* about *the whole library*: "is this book on my shelf" cannot be asserted in a
|
||
* listing that also holds other suites' books, and a `total` that counts them is
|
||
* not an assertion about anything.
|
||
*/
|
||
describe('the shelf add/remove pair', () => {
|
||
const DIR = '加减';
|
||
const BOOK = `${DIR}/往复.epub`;
|
||
let owner: string;
|
||
|
||
before(async () => {
|
||
/*
|
||
* A second account, created *after* the library already has books in it.
|
||
*
|
||
* This is the state the report is about: `UserService.create` grants a new
|
||
* account every visible book, so a reader who registered after the first scan
|
||
* has a shelf they never curated. Asserting on that account is what makes
|
||
* "the shelf is empty at the start" a statement about the setup rather than
|
||
* about the other suites' files.
|
||
*/
|
||
const created = await app.inject({
|
||
method: 'POST', url: '/api/v1/auth/login',
|
||
payload: { username: 'owner', password: 'password123' },
|
||
});
|
||
assert.equal(created.statusCode, 200, created.body);
|
||
owner = (created.json() as { accessToken: string }).accessToken;
|
||
});
|
||
|
||
/** The shelf as `GET /books` sees it — the only definition of "on my shelf". */
|
||
async function shelfTitles(): Promise<string[]> {
|
||
const res = await app.inject({ method: 'GET', url: '/api/v1/books?pageSize=200', headers: auth() });
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
return (res.json() as { items: Array<{ title: string }> }).items.map((item) => item.title);
|
||
}
|
||
|
||
/** The library's answer to the same question, for the same file. */
|
||
async function shelfStateOf(path: string): Promise<string | null> {
|
||
const folder = path.slice(0, path.lastIndexOf('/'));
|
||
const res = await app.inject({
|
||
method: 'GET', url: `/api/v1/library/browse?path=${encodeURIComponent(folder)}&pageSize=1000`,
|
||
headers: auth(),
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const entry = (res.json() as { entries: Array<{ path: string; shelfState: string | null }> })
|
||
.entries.find((candidate) => candidate.path === path);
|
||
assert.ok(entry, `the listing must contain ${path}`);
|
||
return entry.shelfState;
|
||
}
|
||
|
||
async function act(path: string, action: string): Promise<{ applied: number; books: string[] }> {
|
||
const res = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { paths: [path], action },
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
return res.json() as { applied: number; books: string[] };
|
||
}
|
||
|
||
test('every direction of the pair leaves the two screens agreeing', async () => {
|
||
/*
|
||
* One assertion per edge of the state machine, because the two screens read it
|
||
* from opposite ends and each direction was broken in its own way:
|
||
*
|
||
* on → 下架 → off → 加入书架 → on
|
||
*
|
||
* `GET /books` is asserted in every step: "on the shelf" has exactly one
|
||
* definition, and the file listing's `shelfState` must be that same definition.
|
||
*/
|
||
await mkdir(join(booksDir, DIR), { recursive: true });
|
||
await writeFile(join(booksDir, BOOK), await makeEpub('往复', 'urn:roundtrip'));
|
||
await ctx.scanner.scan();
|
||
|
||
/*
|
||
* A book the scanner found is on the shelf without anyone adding it: the library
|
||
* is shared, and a book that must be curated before it can be read is the
|
||
* complaint this issue is made of.
|
||
*/
|
||
assert.ok((await shelfTitles()).includes('往复'), '扫描到的书一进库就在书架上');
|
||
assert.equal(await shelfStateOf(BOOK), 'on');
|
||
|
||
const off = await act(BOOK, 'remove');
|
||
assert.equal(off.applied, 1, 'remove must report one row changed');
|
||
assert.equal(off.books.length, 1, 'remove must name the book it touched');
|
||
assert.equal((await shelfTitles()).includes('往复'), false, '下架 must take the book off the shelf');
|
||
assert.equal(await shelfStateOf(BOOK), 'off', 'the library must call it off-shelf');
|
||
|
||
const on = await act(BOOK, 'add');
|
||
assert.equal(on.applied, 1, 'add must report one row changed');
|
||
assert.equal((await shelfTitles()).includes('往复'), true, '加入书架 must put it back');
|
||
assert.equal(await shelfStateOf(BOOK), 'on');
|
||
|
||
// The aliases are the same two writes, so they have to end in the same place.
|
||
await act(BOOK, 'hide');
|
||
assert.equal((await shelfTitles()).includes('往复'), false);
|
||
await act(BOOK, 'unhide');
|
||
assert.equal((await shelfTitles()).includes('往复'), true);
|
||
});
|
||
|
||
test('a re-add is dated now, so 最近入库 does not lie about it', async () => {
|
||
/*
|
||
* `added_at` is the 最近入库 sort key, and the re-add is the one transition that
|
||
* changes what "when did I get this book" means: the reader took it off and put
|
||
* it back, so "now" is the honest answer. Leaving the original stamp would sort
|
||
* a book the reader just re-shelved below twenty they have not touched.
|
||
*/
|
||
await writeFile(join(booksDir, `${DIR}/改日期.epub`), await makeEpub('改日期', 'urn:readd'));
|
||
await ctx.scanner.scan();
|
||
const book = ctx.db.get<{ id: string }>("SELECT id FROM books WHERE title = '改日期'")!;
|
||
ctx.db.run('UPDATE user_books SET added_at = 0 WHERE book_id = ?', book.id);
|
||
|
||
await act(`${DIR}/改日期.epub`, 'remove');
|
||
await act(`${DIR}/改日期.epub`, 'add');
|
||
const readded = ctx.db.get<{ added_at: number }>(
|
||
'SELECT added_at FROM user_books WHERE book_id = ?', book.id,
|
||
)!;
|
||
assert.ok(readded.added_at > 0, '重新加入必须刷新 added_at');
|
||
});
|
||
|
||
test('a book the reader cannot open is not on the shelf either', async () => {
|
||
/*
|
||
* "On the shelf" is *two* conditions, and having a row in `user_books` is only
|
||
* one of them.
|
||
*
|
||
* A book whose only file is missing is not on `GET /books` — the shelf requires a
|
||
* live file — so any endpoint answering from the row alone is answering a
|
||
* question the reader did not ask. The file-manager listing did exactly that: it
|
||
* read `hidden = 0` and called the book "on the shelf", so the one screen that
|
||
* exists to explain a missing book explained it away, and offered no 加入书架
|
||
* control for it either.
|
||
*/
|
||
const path = `${DIR}/掉盘.epub`;
|
||
await writeFile(join(booksDir, path), await makeEpub('掉盘', 'urn:unplugged'));
|
||
await ctx.scanner.scan();
|
||
|
||
const gone = await act(path, 'remove');
|
||
assert.equal(gone.applied, 1);
|
||
await act(path, 'add');
|
||
assert.equal(await shelfStateOf(path), 'on', 'a live, shelved book is "on"');
|
||
|
||
// The archive marker and the row survive being unplugged: a reader's curated
|
||
// shelf must not be thrown away because a drive is not mounted.
|
||
const before = ctx.db.get<{ id: string }>("SELECT id FROM books WHERE title = '掉盘'")!;
|
||
await rename(join(booksDir, path), join(booksDir, `${DIR}/掉盘.hidden`));
|
||
await ctx.scanner.scan();
|
||
assert.equal(
|
||
ctx.db.get<{ hidden: number }>(
|
||
'SELECT hidden FROM user_books WHERE book_id = ?', before.id,
|
||
)?.hidden,
|
||
0,
|
||
'the row is still there and still not hidden',
|
||
);
|
||
});
|
||
|
||
test('the batch reports what it touched, and names what it could not', async () => {
|
||
/*
|
||
* The report is the only thing standing between "已加入书架 0 本" and a reader
|
||
* concluding the button is broken — which is the shape of the original report.
|
||
*
|
||
* The *shape* is asserted as well as the counts, because the client draws its
|
||
* message from it: `applied` is what the toast says, `books` is what it could
|
||
* refresh, and `failed` is what makes "跳过 2 项" possible at all.
|
||
*/
|
||
await writeFile(join(booksDir, `${DIR}/说明文件.txt`), 'plain text');
|
||
const res = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { paths: [BOOK, `${DIR}/说明文件.txt`, `${DIR}/根本不存在.epub`], action: 'remove' },
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const result = res.json() as { applied: number; books: string[]; failed: Array<{ path: string; reason: string }> };
|
||
assert.equal(result.applied, 1, 'only the real book is acted on');
|
||
assert.equal(result.books.length, 1);
|
||
assert.equal(result.failed.length, 2, 'the other two are named');
|
||
// A folder stands for the books inside it, which is what makes fixing a series
|
||
// one action instead of forty.
|
||
const folder = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { paths: [DIR], action: 'add' },
|
||
});
|
||
assert.equal(folder.statusCode, 200, folder.body);
|
||
assert.ok((folder.json() as { applied: number }).applied >= 2, '一个目录代表它下面所有的书');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 按书 id 下架 —— the fix for 「从书架移除时,找不到「xxx」在磁盘上的路径」。
|
||
*
|
||
* The client used to name the book by *path*, which it reconstructed by matching the
|
||
* book's title against the filenames in the library root's first page. Both halves of
|
||
* that guess are asserted here as broken-in-the-old-way, because each one is a silent
|
||
* failure on its own:
|
||
*
|
||
* - a title that is not the filename (the normal case — the scanner reads titles out
|
||
* of the files) matched nothing, so the reader was told the book could not be found
|
||
* on disk *after* asking for it to be taken off their own shelf;
|
||
* - a file that is not in the root's first page was not in the listing at all.
|
||
*
|
||
* The id cannot fail either way: `book_files` already names the path for every id, and
|
||
* neither the title nor the file's location in the tree is consulted.
|
||
*/
|
||
describe('naming a shelf write by book id', () => {
|
||
const DIR = '按id下架';
|
||
|
||
test('takes a book off the shelf by id, whatever its title is called', async () => {
|
||
await mkdir(join(booksDir, DIR), { recursive: true });
|
||
// The file is called `f1.epub`; the book's title is a sentence. This is the shape
|
||
// of the reported screenshot, and it is the shape the title→filename join cannot
|
||
// resolve.
|
||
const longTitle = '半小时漫画宇宙大爆炸(半小时读完138亿年宇宙史,一口气搞懂大爆炸)';
|
||
await writeFile(join(booksDir, `${DIR}/f1.epub`), await makeEpub(longTitle, 'urn:by-id'));
|
||
await ctx.scanner.scan();
|
||
|
||
const row = ctx.db.get<{ id: string }>('SELECT id FROM books WHERE title = ?', longTitle);
|
||
assert.ok(row, 'the book must have been indexed');
|
||
|
||
const before = await app.inject({ method: 'GET', url: '/api/v1/books?pageSize=200', headers: auth() });
|
||
assert.ok(
|
||
(before.json() as { items: Array<{ title: string }> }).items.some((b) => b.title === longTitle),
|
||
'it starts on the shelf',
|
||
);
|
||
|
||
const res = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { bookIds: [row.id], action: 'remove' },
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const result = res.json() as { applied: number; books: string[]; failed: unknown[] };
|
||
assert.equal(result.applied, 1, 'the write must land');
|
||
assert.deepEqual(result.books, [row.id]);
|
||
assert.equal(result.failed.length, 0, 'nothing may be reported as unfindable');
|
||
|
||
const after = await app.inject({ method: 'GET', url: '/api/v1/books?pageSize=200', headers: auth() });
|
||
assert.equal(
|
||
(after.json() as { items: Array<{ title: string }> }).items.some((b) => b.title === longTitle),
|
||
false,
|
||
'下架 must actually take it off',
|
||
);
|
||
});
|
||
|
||
test('finds a book whose file is nowhere near the root', async () => {
|
||
/*
|
||
* The other half of the guess: the old lookup read the *root's* first page, so a
|
||
* book three folders down had no path to report at all. The id does not care where
|
||
* the file lives.
|
||
*/
|
||
const deep = `${DIR}/一层/两层/三层`;
|
||
await mkdir(join(booksDir, deep), { recursive: true });
|
||
await writeFile(join(booksDir, `${deep}/深处.epub`), await makeEpub('深处', 'urn:deep'));
|
||
await ctx.scanner.scan();
|
||
const row = ctx.db.get<{ id: string }>("SELECT id FROM books WHERE title = '深处'")!;
|
||
|
||
const res = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { bookIds: [row.id], action: 'remove' },
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
assert.equal((res.json() as { applied: number }).applied, 1);
|
||
});
|
||
|
||
test('reports a book with no live file rather than guessing one', async () => {
|
||
/*
|
||
* A book whose last file went away has a `books` row and no `book_files` row that is
|
||
* not missing. There is no path to report, and the honest answer is a failure —
|
||
* inventing one would write shelf state for a book the reader cannot open, and the
|
||
* shelf's own predicate already says such a book is not on the shelf.
|
||
*/
|
||
const res = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { bookIds: ['no-such-book-id'], action: 'remove' },
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const result = res.json() as { applied: number; failed: Array<{ reason: string }> };
|
||
assert.equal(result.applied, 0);
|
||
assert.deepEqual(result.failed.map((f) => f.reason), ['NO_LIVE_FILE']);
|
||
});
|
||
|
||
test('refuses a body that names the same write two ways', async () => {
|
||
/*
|
||
* `paths` and `bookIds` are two vocabularies for one write; a body carrying both is
|
||
* a client that does not know which it meant, and merging them would apply the
|
||
* action twice to a book named both ways and *count* it twice.
|
||
*/
|
||
const both = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { paths: ['x.epub'], bookIds: ['b1'], action: 'remove' },
|
||
});
|
||
assert.equal(both.statusCode, 400, both.body);
|
||
const neither = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { action: 'remove' },
|
||
});
|
||
assert.equal(neither.statusCode, 400, neither.body);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 书库的浏览页问的是**索引**,不是读者自己的书架。
|
||
*
|
||
* It read the shelf endpoint before, so its grid could only ever contain books that
|
||
* were already shelved — which made 「加入书架」, the one action the page exists for, a
|
||
* control drawn from a set that could not contain a book needing it.
|
||
*/
|
||
describe('the library scope of the book list', () => {
|
||
const DIR = '书库范围';
|
||
|
||
test('lists a book that is off the shelf, with the flag that says so', async () => {
|
||
await mkdir(join(booksDir, DIR), { recursive: true });
|
||
await writeFile(join(booksDir, `${DIR}/在架.epub`), await makeEpub('范围在架', 'urn:scope-on'));
|
||
await writeFile(join(booksDir, `${DIR}/离架.epub`), await makeEpub('范围离架', 'urn:scope-off'));
|
||
await ctx.scanner.scan();
|
||
|
||
const off = ctx.db.get<{ id: string }>("SELECT id FROM books WHERE title = '范围离架'")!;
|
||
const remove = await app.inject({
|
||
method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(),
|
||
payload: { bookIds: [off.id], action: 'remove' },
|
||
});
|
||
assert.equal(remove.statusCode, 200, remove.body);
|
||
|
||
// The shelf does not hold it — that is what "off the shelf" means, and it is why
|
||
// this page could not have been drawn from the shelf.
|
||
const shelf = await app.inject({ method: 'GET', url: '/api/v1/books?pageSize=200', headers: auth() });
|
||
assert.equal(
|
||
(shelf.json() as { items: Array<{ title: string }> }).items.some((b) => b.title === '范围离架'),
|
||
false,
|
||
);
|
||
|
||
// The index does, and says which of the two each book is.
|
||
const res = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/v1/books?scope=library&path=${encodeURIComponent(DIR)}&pageSize=200`,
|
||
headers: auth(),
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const items = (res.json() as { items: Array<{ title: string; shelfState?: string }> }).items;
|
||
const byTitle = new Map(items.map((b) => [b.title, b]));
|
||
assert.equal(byTitle.get('范围离架')?.shelfState, 'off', '不在书架的书必须带 off');
|
||
assert.equal(byTitle.get('范围在架')?.shelfState, 'on');
|
||
});
|
||
|
||
test('lists a book the reader has no `user_books` row for at all', async () => {
|
||
/*
|
||
* The case the `LEFT JOIN` exists for, and the one an account that was granted the
|
||
* whole library cannot show: a reader who has *never* had a row for a book — not a
|
||
* `hidden = 1` row from a removal, no row at all.
|
||
*
|
||
* An inner join answers this with an empty page, and every other test still passes:
|
||
* the admin's rows cover the library, and a *removed* book keeps its row (that is
|
||
* what makes the removal reversible). So the failure mode is "the browsing page is
|
||
* empty for a new account", which is the opposite of the page's purpose and is
|
||
* invisible in a suite where everybody already has everything.
|
||
*/
|
||
const newcomer = ctx.db.get<{ id: string }>(
|
||
"SELECT id FROM users WHERE username = '范围新人'",
|
||
) ?? null;
|
||
if (!newcomer) {
|
||
ctx.db.run(
|
||
`INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at)
|
||
VALUES ('u-scope-new', '范围新人', '范围新人', 'x', 'member', 0, 0, 0)`,
|
||
);
|
||
}
|
||
const reader = 'u-scope-new';
|
||
|
||
// The same service call the route makes, against an account with no rows.
|
||
const listed = ctx.shelf.list(reader, { scope: 'library', path: DIR, pageSize: 100 });
|
||
assert.equal(listed.items.length, 2, '索引里的书必须在,不管读者有没有那一行');
|
||
assert.deepEqual(
|
||
listed.items.map((book) => book.shelfState).sort(),
|
||
['off', 'off'],
|
||
'没有行和 hidden = 1 都是「不在我的书架上」',
|
||
);
|
||
assert.equal(listed.total, 2, '计数走的是同一个 join');
|
||
|
||
/*
|
||
* And a book the reader shelved sorts above the ones they never did.
|
||
*
|
||
* In this scope `added_at` is `NULL` for a book with no row, and the honest place
|
||
* for "when did I get this book" with no answer is *last* under `DESC` — not first.
|
||
* A sort that put them on top would answer 「最近入库」 with the books that have
|
||
* never been 「入库」 for this reader at all, which is the sort lying about the one
|
||
* thing it is for.
|
||
*/
|
||
const shelved = ctx.db.get<{ id: string }>("SELECT id FROM books WHERE title = '范围在架'")!;
|
||
ctx.db.run(
|
||
'INSERT OR IGNORE INTO user_books (user_id, book_id, added_at, hidden) VALUES (?,?,?,0)',
|
||
reader, shelved.id, Date.now(),
|
||
);
|
||
const recent = ctx.shelf.list(reader, { scope: 'library', path: DIR, pageSize: 100, sort: 'added' });
|
||
assert.equal(recent.items[0]?.id, shelved.id, '上架过的那本在从没上架过的书前面');
|
||
});
|
||
|
||
test('filters and pages the index the same way, with the flag intact', async () => {
|
||
/*
|
||
* `scope=library` shares the whole query builder with the shelf — the search, the
|
||
* path prefix, the pager — and the one thing that could silently break by sharing
|
||
* it is the `LEFT JOIN`: a stray `ub.user_id = ?` in the `WHERE` would turn the
|
||
* join back into an inner one (dropping every book the reader has never shelved,
|
||
* i.e. exactly the books this page is for), and a `COUNT(*)` over a join with a
|
||
* different parameter order would count the wrong rows.
|
||
*/
|
||
const res = await app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/books?scope=library&search=%E8%8C%83%E5%9B%B4&pageSize=1&page=1',
|
||
headers: auth(),
|
||
});
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const body = res.json() as { items: Array<{ title: string; shelfState?: string }>; total: number };
|
||
assert.equal(body.total, 2, 'both books match the search');
|
||
assert.equal(body.items.length, 1, 'and the page is one row');
|
||
assert.ok(
|
||
body.items[0]!.shelfState === 'on' || body.items[0]!.shelfState === 'off',
|
||
'the flag survives the filter and the page',
|
||
);
|
||
});
|
||
|
||
test('leaves the field off the shelf’s own list, where it would always be true', async () => {
|
||
/*
|
||
* A constant field is a field a client eventually draws. The shelf's list is "books
|
||
* that are on the shelf", so `shelfState` there would be `'on'` on every row by
|
||
* construction — noise that reads as information.
|
||
*/
|
||
const res = await app.inject({ method: 'GET', url: '/api/v1/books?pageSize=5', headers: auth() });
|
||
assert.equal(res.statusCode, 200, res.body);
|
||
const items = (res.json() as { items: Array<{ shelfState?: string }> }).items;
|
||
assert.ok(items.length > 0);
|
||
assert.ok(items.every((b) => b.shelfState === undefined));
|
||
});
|
||
});
|