mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
735 lines
51 KiB
TypeScript
735 lines
51 KiB
TypeScript
import { AppError } from '../src/lib/errors.ts';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer } from 'node:http';
|
|
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import type { AppConfig } from '../src/config/index.ts';
|
|
import { openDatabase } from '../src/db/index.ts';
|
|
import { buildApp } from '../src/http/app.ts';
|
|
import type { AppContext } from '../src/http/context.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 { UploadService } from '../src/services/uploads.ts';
|
|
import type { CatalogPage, SourceProvider } from '../src/sources/types.ts';
|
|
import { pluginArchive } from './helpers/plugin-package.ts';
|
|
|
|
interface Session { token: string; id: string }
|
|
|
|
test('personal credential state is private, invalidated on change and ignores stale in-flight access', async t => {
|
|
const h = await harness(); t.after(() => h.close()); const member = await h.member();
|
|
const host = h.ctx.sources!;
|
|
let errorCode = '', release: (() => void) | undefined;
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'access', label: 'Access', version: '1', capabilities: ['browse'], credentialKeys: [{ key: 'password', label: '密码' }] },
|
|
async detail() { return { ref: 'book', title: 'Book' }; },
|
|
async acquire() { return { kind: 'chapters', publicationRef: 'book' }; },
|
|
async browse() {
|
|
if (errorCode === 'pending') await new Promise<void>(resolve => { release = resolve; });
|
|
else if (errorCode) throw new AppError(401, errorCode, '需要重新验证');
|
|
return { items: [] };
|
|
},
|
|
};
|
|
host.registry.register({ pluginId: 'test.access', provider });
|
|
await host.create({ id: 'access', pluginId: 'test.access', sourceType: 'access', name: 'Access', config: {} });
|
|
const url = '/api/v1/sources/access/credentials';
|
|
const status = async (session = h.admin) => (await h.app.inject({ method: 'GET', url, headers: auth(session) })).json();
|
|
assert.equal((await h.app.inject({ method: 'GET', url })).statusCode, 401);
|
|
host.setCredential('access',h.admin.id,'password','secret-value');
|
|
assert.deepEqual((await status()).fields, [{ key: 'password', label: '密码', configured: true }]);
|
|
assert.equal((await status(member)).fields[0].configured, false);
|
|
assert.ok(!JSON.stringify(await status()).includes('secret-value'));
|
|
await host.browse(h.admin.id,'access',{});
|
|
assert.equal((await status()).state,'reachable');
|
|
for (const code of ['AUTH_EXPIRED','VERIFICATION_REQUIRED']) {
|
|
errorCode = code;
|
|
await assert.rejects(host.browse(h.admin.id,'access',{}), { code });
|
|
assert.equal((await status()).state,code === 'AUTH_EXPIRED' ? 'auth-required' : 'verification-required');
|
|
}
|
|
assert.equal((await status(member)).state,'unknown');
|
|
errorCode = 'pending'; const pending = host.browse(h.admin.id,'access',{});
|
|
host.setCredential('access',h.admin.id,'password','new-secret');
|
|
release!(); await pending;
|
|
assert.equal((await status()).state,'unknown'); assert.equal((await status()).checkedAt,null);
|
|
await h.app.inject({ method: 'DELETE', url: url + '/password', headers: auth(member) });
|
|
assert.equal((await status()).fields[0].configured,true);
|
|
await h.app.inject({ method: 'DELETE', url: url + '/password', headers: auth(h.admin) });
|
|
assert.equal((await status()).fields[0].configured,false);
|
|
host.setCredential('access',h.admin.id,'password','');
|
|
assert.equal((await status()).fields[0].configured,false);
|
|
});
|
|
function streamEvents(body: string): Array<{ event: string; data: any }> {
|
|
return body.split('\n\n').filter(frame => frame.startsWith('event:')).map(frame => ({
|
|
event: /^event: (.+)/m.exec(frame)![1]!, data: JSON.parse(/^data: (.+)/m.exec(frame)![1]!),
|
|
}));
|
|
}
|
|
const searchPayload = { query: 'book', sessionId: 'test-search-session' };
|
|
|
|
test('web plugin upload enforces admin, trust and file validation, installs enabled and restores on restart', async t => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const member = await h.member();
|
|
const body = (filename = 'example.tgz', trust = true, bytes = pluginArchive()) => Buffer.concat([
|
|
Buffer.from('--plugin-upload\r\nContent-Disposition: form-data; name="file"; filename="' + filename + '"\r\nContent-Type: application/gzip\r\n\r\n'), bytes,
|
|
Buffer.from('\r\n--plugin-upload\r\nContent-Disposition: form-data; name="trusted"\r\n\r\n' + trust + '\r\n--plugin-upload--\r\n'),
|
|
]);
|
|
const upload = (session: Session, payload: Buffer) => h.app.inject({ method: 'POST', url: '/api/v1/plugins/upload',
|
|
headers: { ...auth(session), 'content-type': 'multipart/form-data; boundary=plugin-upload' }, payload });
|
|
assert.equal((await upload(member, body())).statusCode, 403);
|
|
assert.equal((await upload(h.admin, body('example.zip'))).statusCode, 400);
|
|
assert.equal((await upload(h.admin, body('example.tgz', false))).json().error.code, 'PLUGIN_TRUST_REQUIRED');
|
|
const invalid = await upload(h.admin, body('example.tgz', true, Buffer.from('not a tarball')));
|
|
assert.equal(invalid.statusCode, 502, invalid.body);
|
|
assert.deepEqual(await readdir(join(h.dataDir, 'plugins')), []);
|
|
const response = await upload(h.admin, body());
|
|
assert.equal(response.statusCode, 201, response.body);
|
|
assert.equal(response.json().plugin.enabled, true);
|
|
assert.equal(response.json().plugin.pluginId, 'test.upload');
|
|
assert.equal((await upload(h.admin, body())).statusCode, 409);
|
|
const updated = await upload(h.admin, body('example-new.tgz', true, pluginArchive(undefined, undefined, true, '1.1.0')));
|
|
assert.equal(updated.statusCode, 201, updated.body);
|
|
assert.equal(updated.json().plugin.updated, true);
|
|
assert.equal(updated.json().plugin.previousVersion, '1.0.0');
|
|
assert.equal(updated.json().plugin.version, '1.1.0');
|
|
await h.restart();
|
|
const list = await h.app.inject({ method: 'GET', url: '/api/v1/plugins', headers: auth(h.admin) });
|
|
assert.equal(list.json().plugins.find((plugin: { pluginId: string }) => plugin.pluginId === 'test.upload').enabled, true);
|
|
assert.equal(list.json().plugins.find((plugin: { pluginId: string }) => plugin.pluginId === 'test.upload').version, '1.1.0');
|
|
const untrusted = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { package: 'example' } });
|
|
assert.equal(untrusted.json().error.code, 'PLUGIN_TRUST_REQUIRED');
|
|
const path = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { package: '../package', trusted: true } });
|
|
assert.equal(path.json().error.code, 'PLUGIN_INVALID_PACKAGE');
|
|
const denied = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(member), payload: { package: 'example', trusted: true } });
|
|
assert.equal(denied.statusCode, 403);
|
|
});
|
|
|
|
function auth(session: Session) { return { authorization: `Bearer ${session.token}` }; }
|
|
|
|
async function harness() {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-sources-api-'));
|
|
const booksDir = join(root, 'books');
|
|
const dataDir = join(root, 'data');
|
|
await mkdir(booksDir);
|
|
await mkdir(dataDir);
|
|
const config: AppConfig = {
|
|
booksDir, dataDir, host: '127.0.0.1', port: 0, jwtSecret: 'sources-api-test-secret-stable-across-restarts',
|
|
accessTokenTtl: 86400, refreshTokenTtl: 86400, scanInterval: 0, watchInterval: 0,
|
|
logLevel: 'silent', publicUrl: '', corsOrigins: [], webDir: join(root, 'no-web'),
|
|
};
|
|
let app: FastifyInstance;
|
|
let ctx: AppContext;
|
|
let active = false;
|
|
async function start() {
|
|
const db = openDatabase(config);
|
|
ctx = { config, db } as AppContext;
|
|
app = buildApp(ctx);
|
|
ctx.log = app.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);
|
|
ctx.uploads = new UploadService(db, config, ctx.browse, ctx.scanner);
|
|
active = true;
|
|
await app.ready();
|
|
}
|
|
async function stop() {
|
|
if (!active) return;
|
|
active = false;
|
|
try { await app.close(); } finally { ctx.db.close(); }
|
|
}
|
|
await start();
|
|
const response = await app!.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { username: 'owner', password: 'password123' } });
|
|
assert.equal(response.statusCode, 201, response.body);
|
|
const created = response.json();
|
|
const admin: Session = { token: created.session.accessToken, id: created.user.id };
|
|
return {
|
|
root, booksDir, dataDir, admin,
|
|
get app() { return app; }, get ctx() { return ctx; },
|
|
async member(): Promise<Session> {
|
|
const create = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(admin), payload: { username: 'reader', password: 'password123' } });
|
|
assert.equal(create.statusCode, 201, create.body);
|
|
const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: 'reader', password: 'password123' } });
|
|
assert.equal(login.statusCode, 200, login.body);
|
|
return { token: login.json().accessToken, id: login.json().user.id };
|
|
},
|
|
async restart() { await stop(); await start(); },
|
|
async close() { await stop(); await rm(root, { recursive: true, force: true }); },
|
|
};
|
|
}
|
|
|
|
test('source editing validates configuration, clears destination credentials and keeps acquired sources', async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const member = await h.member();
|
|
const create = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
|
id: 'editable', pluginId: 'reader.opds', sourceType: 'opds', name: 'Before', config: { url: 'https://one.test/opds' },
|
|
} });
|
|
assert.equal(create.statusCode, 201, create.body);
|
|
await h.app.inject({ method: 'PUT', url: '/api/v1/sources/editable/credentials/password', headers: auth(member), payload: { value: 'private' } });
|
|
const forbidden = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(member), payload: { name: 'bad' } });
|
|
assert.equal(forbidden.statusCode, 403);
|
|
const edited = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(h.admin), payload: { name: 'After', config: { url: 'https://two.test/opds' } } });
|
|
assert.equal(edited.statusCode, 200, edited.body); assert.equal(edited.json().source.name, 'After');
|
|
assert.equal(h.ctx.db.get<{ count: number }>('SELECT count(*) AS count FROM source_credentials WHERE source_id = ?', 'editable')!.count, 0);
|
|
const invalid = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(h.admin), payload: { config: { url: 'file:///secret' } } });
|
|
assert.equal(invalid.statusCode, 400);
|
|
const removed = await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/editable', headers: auth(h.admin) });
|
|
assert.equal(removed.statusCode, 200, removed.body);
|
|
assert.equal((await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/local', headers: auth(h.admin) })).statusCode, 400);
|
|
});
|
|
|
|
async function opdsFixture() {
|
|
const body = '第一章 远程下载\n\n这是经 OPDS 获取的本地可读正文。\n\n第二章 继续阅读\n\n下载后继续复用现有书架和阅读进度。';
|
|
let downloads = 0;
|
|
const server = createServer((request, response) => {
|
|
if (request.url === '/catalog') {
|
|
response.setHeader('content-type', 'application/opds+json');
|
|
response.end(JSON.stringify({
|
|
metadata: { title: 'Test OPDS' },
|
|
publications: [{
|
|
metadata: { identifier: 'urn:reader:test:opds-txt', title: 'OPDS 集成测试书', author: [{ name: '测试作者' }] },
|
|
links: [{ rel: 'http://opds-spec.org/acquisition/open-access', href: '/download/book.txt', type: 'text/plain' }],
|
|
}],
|
|
}));
|
|
} else if (request.url === '/download/book.txt') {
|
|
downloads += 1;
|
|
response.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
response.end(body);
|
|
} else {
|
|
response.statusCode = 404; response.end();
|
|
}
|
|
});
|
|
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
|
|
const address = server.address();
|
|
assert.ok(address && typeof address !== 'string');
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/catalog`, body,
|
|
get downloads() { return downloads; },
|
|
async close() {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((done, reject) => server.close((error) => error ? reject(error) : done()));
|
|
},
|
|
};
|
|
}
|
|
|
|
test('built-in source discovery and administrative boundaries preserve configuration and credentials', async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const member = await h.member();
|
|
const types = await h.app.inject({ method: 'GET', url: '/api/v1/sources/types', headers: auth(member) });
|
|
assert.equal(types.statusCode, 200, types.body);
|
|
assert.deepEqual(types.json().types.map((type: { id: string }) => type.id).sort(), ['local', 'opds']);
|
|
assert.deepEqual(types.json().types.map((type: { id: string; pluginId: string; builtin: boolean }) =>
|
|
({ id: type.id, pluginId: type.pluginId, builtin: type.builtin })), [
|
|
{ id: 'local', pluginId: 'reader.local', builtin: true },
|
|
{ id: 'opds', pluginId: 'reader.opds', builtin: true },
|
|
]);
|
|
assert.ok(h.ctx.sources, 'buildApp initializes the source host');
|
|
const payload = { id: 'secured-opds', pluginId: 'reader.opds', sourceType: 'opds', name: 'OPDS', config: { url: 'http://127.0.0.1:9999/catalog', username: 'private-config-user' } };
|
|
const deniedCreate = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(member), payload });
|
|
assert.equal(deniedCreate.statusCode, 403, deniedCreate.body);
|
|
const deniedInstall = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(member), payload: { folder: 'npm:reader-source-example', trusted: true } });
|
|
assert.equal(deniedInstall.statusCode, 403, deniedInstall.body);
|
|
const deniedList = await h.app.inject({ method: 'GET', url: '/api/v1/plugins', headers: auth(member) });
|
|
assert.equal(deniedList.statusCode, 403, deniedList.body);
|
|
const noAuth = await h.app.inject({ method: 'GET', url: '/api/v1/sources' });
|
|
assert.equal(noAuth.statusCode, 401, noAuth.body);
|
|
|
|
const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload });
|
|
assert.equal(created.statusCode, 201, created.body);
|
|
const password = 'source-password-not-in-a-dto';
|
|
const credential = await h.app.inject({ method: 'PUT', url: '/api/v1/sources/secured-opds/credentials/password', headers: auth(member), payload: { value: password } });
|
|
assert.equal(credential.statusCode, 200, credential.body);
|
|
const stored = h.ctx.db.get<{ encrypted_value: string }>('SELECT encrypted_value FROM source_credentials WHERE source_id = ? AND user_id = ?', 'secured-opds', member.id);
|
|
assert.ok(stored?.encrypted_value);
|
|
assert.ok(!stored.encrypted_value.includes(password));
|
|
const publicSources = await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(member) });
|
|
assert.equal(publicSources.statusCode, 200, publicSources.body);
|
|
assert.equal(publicSources.json().sources.find((source: { id: string }) => source.id === 'secured-opds').config, undefined);
|
|
assert.ok(!publicSources.body.includes('private-config-user'));
|
|
assert.ok(!publicSources.body.includes(password));
|
|
const adminSources = await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(h.admin) });
|
|
assert.equal(adminSources.statusCode, 200, adminSources.body);
|
|
assert.equal(adminSources.json().sources.find((source: { id: string }) => source.id === 'secured-opds').config.username, 'private-config-user');
|
|
assert.ok(!adminSources.body.includes(password));
|
|
const inlinePassword = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { ...payload, id: 'bad-password', config: { ...payload.config, password } } });
|
|
assert.equal(inlinePassword.statusCode, 400, inlinePassword.body);
|
|
});
|
|
|
|
test('source query validation rejects repeated text parameters and invalid page limits', async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
for (const query of ['limit=0', 'limit=-1', 'limit=201', 'limit=1.5', 'limit=NaN', 'limit=10&limit=20']) {
|
|
const response = await h.app.inject({ method: 'GET', url: `/api/v1/sources/local/browse?${query}`, headers: auth(h.admin) });
|
|
assert.equal(response.statusCode, 400, `${query}: ${response.body}`);
|
|
}
|
|
for (const payload of [{ query: ['one', 'two'] }, { query: '' }, { query: 'valid', cursor: ['one', 'two'] }, { query: 'valid', resultLimit: true }, { query: 'valid', limit: 201 }]) {
|
|
const response = await h.app.inject({ method: 'POST', url: '/api/v1/sources/local/search', headers: auth(h.admin), payload: { ...searchPayload, ...payload } });
|
|
assert.equal(response.statusCode, 400, response.body);
|
|
}
|
|
assert.equal((await h.app.inject({ method: 'GET', url: '/api/v1/sources/local/search?q=book', headers: auth(h.admin) })).statusCode, 404);
|
|
});
|
|
|
|
test('local source acquisition restores the existing publication to the requesting users shelf', async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const member = await h.member();
|
|
await writeFile(join(h.booksDir, '本地书.txt'), '第一章\n\n本地来源沿用既有内容和身份。');
|
|
await h.ctx.scanner.scan();
|
|
const browse = await h.app.inject({ method: 'GET', url: '/api/v1/sources/local/browse', headers: auth(member) });
|
|
assert.equal(browse.statusCode, 200, browse.body);
|
|
const entry = browse.json().items.find((item: { title: string }) => item.title === '本地书');
|
|
assert.ok(entry, browse.body);
|
|
h.ctx.db.run('UPDATE user_books SET hidden = 1 WHERE user_id = ? AND book_id = ?', member.id, entry.ref);
|
|
const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/local/acquire', headers: auth(member), payload: { entryRef: entry.ref, optionId: 'read' } });
|
|
assert.equal(acquired.statusCode, 200, acquired.body);
|
|
assert.deepEqual(acquired.json(), { kind: 'ready', publicationId: entry.ref });
|
|
const shelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) });
|
|
assert.ok(shelf.json().items.some((book: { id: string }) => book.id === entry.ref), shelf.body);
|
|
const content = await h.app.inject({ method: 'GET', url: `/api/v1/books/${entry.ref}/content`, headers: auth(member) });
|
|
assert.equal(content.statusCode, 200, content.body);
|
|
assert.match(content.body, /既有内容和身份/);
|
|
});
|
|
|
|
test('OPDS acquisition feeds existing reading, shelf and progress APIs and survives source disable and restart', async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const catalog = await opdsFixture(); t.after(() => catalog.close());
|
|
const member = await h.member();
|
|
const source = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
|
id: 'test-opds', pluginId: 'reader.opds', sourceType: 'opds', name: 'Test OPDS', config: { url: catalog.url },
|
|
} });
|
|
assert.equal(source.statusCode, 201, source.body);
|
|
const browse = await h.app.inject({ method: 'GET', url: '/api/v1/sources/test-opds/browse', headers: auth(member) });
|
|
assert.equal(browse.statusCode, 200, browse.body);
|
|
const entry = browse.json().items[0];
|
|
assert.equal(entry.title, 'OPDS 集成测试书');
|
|
const request = { entryRef: entry.ref, optionId: entry.options[0].id };
|
|
const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/test-opds/acquire', headers: auth(member), payload: request });
|
|
assert.equal(acquired.statusCode, 200, acquired.body);
|
|
const { publicationId: bookId, kind } = acquired.json();
|
|
assert.equal(kind, 'ready');
|
|
assert.ok(bookId);
|
|
assert.equal(catalog.downloads, 1);
|
|
const originalFiles = (await readdir(join(h.dataDir, 'acquired'))).sort();
|
|
assert.equal(originalFiles.length, 1);
|
|
const repeated = await h.app.inject({ method: 'POST', url: '/api/v1/sources/test-opds/acquire', headers: auth(member), payload: request });
|
|
assert.equal(repeated.statusCode, 200, repeated.body);
|
|
assert.equal(repeated.json().publicationId, bookId);
|
|
assert.equal(catalog.downloads, 1, 'repeated acquisition reuses the imported file');
|
|
assert.deepEqual((await readdir(join(h.dataDir, 'acquired'))).sort(), originalFiles, 'repeated acquisition creates no additional files');
|
|
|
|
const otherShelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(h.admin) });
|
|
assert.ok(!otherShelf.json().items.some((book: { id: string }) => book.id === bookId), otherShelf.body);
|
|
const otherContent = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(h.admin) });
|
|
assert.equal(otherContent.statusCode, 404, otherContent.body);
|
|
const unauthorizedAdd = await h.app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(h.admin), payload: { bookIds: [bookId], action: 'add' } });
|
|
assert.equal(unauthorizedAdd.statusCode, 200, unauthorizedAdd.body);
|
|
assert.equal(unauthorizedAdd.json().applied, 0);
|
|
assert.deepEqual(unauthorizedAdd.json().failed, [{ path: bookId, reason: 'NO_LIVE_FILE' }]);
|
|
const stillForbidden = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(h.admin) });
|
|
assert.equal(stillForbidden.statusCode, 404, stillForbidden.body);
|
|
|
|
const removedFromShelf = await h.app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(member), payload: { bookIds: [bookId], action: 'remove' } });
|
|
assert.equal(removedFromShelf.statusCode, 200, removedFromShelf.body);
|
|
assert.equal(removedFromShelf.json().applied, 1, removedFromShelf.body);
|
|
assert.deepEqual(removedFromShelf.json().failed, []);
|
|
const hiddenShelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) });
|
|
assert.ok(!hiddenShelf.json().items.some((book: { id: string }) => book.id === bookId), hiddenShelf.body);
|
|
const restored = await h.app.inject({ method: 'POST', url: '/api/v1/sources/test-opds/acquire', headers: auth(member), payload: request });
|
|
assert.equal(restored.statusCode, 200, restored.body);
|
|
assert.equal(restored.json().publicationId, bookId);
|
|
assert.equal(catalog.downloads, 1, 'restoring a book to the shelf reuses the existing download');
|
|
assert.deepEqual((await readdir(join(h.dataDir, 'acquired'))).sort(), originalFiles);
|
|
|
|
const manifest = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/manifest`, headers: auth(member) });
|
|
assert.equal(manifest.statusCode, 200, manifest.body);
|
|
assert.equal(manifest.json().book.title, 'OPDS 集成测试书');
|
|
const first = manifest.json().items[0]; assert.ok(first);
|
|
const asset = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/assets?ref=${encodeURIComponent(first.href)}`, headers: auth(member) });
|
|
assert.equal(asset.statusCode, 200, asset.body);
|
|
assert.match(asset.body, /远程下载|OPDS/);
|
|
const content = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(member) });
|
|
assert.equal(content.statusCode, 200, content.body);
|
|
assert.equal(content.body, catalog.body);
|
|
const search = await h.app.inject({method:'GET',url:`/api/v1/books/${bookId}/search?q=OPDS`,headers:auth(member)});
|
|
assert.equal(search.statusCode,200,search.body);assert.ok(search.json().hits.length>0);assert.equal(search.json().failures.length,0);
|
|
const deniedSearch=await h.app.inject({method:'GET',url:`/api/v1/books/${bookId}/search?q=OPDS`,headers:auth(h.admin)});
|
|
assert.equal(deniedSearch.statusCode,404);
|
|
const invalidSearch=await h.app.inject({method:'GET',url:`/api/v1/books/${bookId}/search?q=`,headers:auth(member)});
|
|
assert.equal(invalidSearch.statusCode,400);
|
|
const shelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) });
|
|
assert.ok(shelf.json().items.some((book: { id: string }) => book.id === bookId), shelf.body);
|
|
const progress = await h.app.inject({ method: 'PUT', url: `/api/v1/sync/progress/${bookId}`, headers: auth(member), payload: {
|
|
locator: first.href, percentage: 0.42, chapterTitle: first.title, device: 'test', updatedAt: Date.now(),
|
|
} });
|
|
assert.equal(progress.statusCode, 200, progress.body);
|
|
assert.equal(progress.json().progress.percentage, 0.42);
|
|
|
|
const disabled = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/test-opds', headers: auth(h.admin), payload: { enabled: false } });
|
|
assert.equal(disabled.statusCode, 200, disabled.body);
|
|
const denied = await h.app.inject({ method: 'GET', url: '/api/v1/sources/test-opds/browse', headers: auth(member) });
|
|
assert.equal(denied.json().error.code, 'SOURCE_DISABLED');
|
|
await h.ctx.scanner.scan();
|
|
await h.restart();
|
|
const retained = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(member) });
|
|
assert.equal(retained.statusCode, 200, retained.body);
|
|
assert.equal(retained.body, catalog.body);
|
|
const retainedProgress = await h.app.inject({ method: 'GET', url: `/api/v1/sync/progress/${bookId}`, headers: auth(member) });
|
|
assert.equal(retainedProgress.json().progress.percentage, 0.42);
|
|
const retainedShelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) });
|
|
assert.ok(retainedShelf.json().items.some((book: { id: string }) => book.id === bookId), retainedShelf.body);
|
|
});
|
|
|
|
test('source host enforces per-source and global concurrency limits and releases slots after completion', { timeout: 10_000 }, async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const queued: Array<{ sourceId: string; release(): void }> = [];
|
|
const entered = new Map<string, number>();
|
|
const watchers: Array<{ sourceId: string; count: number; resolve(): void }> = [];
|
|
let hold = true;
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'blocking', label: 'Concurrency test', version: '1', capabilities: ['browse', 'detail'] },
|
|
async detail(_ctx, ref) { return { ref, title: ref }; },
|
|
async acquire() { return { kind: 'action-required', action: { type: 'external', label: 'Test' } }; },
|
|
async browse(ctx) {
|
|
const count = (entered.get(ctx.instance.id) ?? 0) + 1;
|
|
entered.set(ctx.instance.id, count);
|
|
for (const watcher of watchers) {
|
|
if (watcher.sourceId === ctx.instance.id && count >= watcher.count) watcher.resolve();
|
|
}
|
|
if (!hold) return { items: [] };
|
|
return new Promise<CatalogPage>((resolvePage) => {
|
|
queued.push({ sourceId: ctx.instance.id, release: () => resolvePage({ items: [] }) });
|
|
});
|
|
},
|
|
};
|
|
h.ctx.sources!.registry.register({ pluginId: 'reader.test', provider });
|
|
for (const id of ['limit-one', 'limit-two', 'limit-three', 'limit-four', 'limit-five']) {
|
|
const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
|
id, pluginId: 'reader.test', sourceType: 'blocking', name: id, config: {},
|
|
} });
|
|
assert.equal(created.statusCode, 201, created.body);
|
|
}
|
|
const waitUntilEntered = (sourceId: string, count: number): Promise<void> => {
|
|
if ((entered.get(sourceId) ?? 0) >= count) return Promise.resolve();
|
|
return new Promise((resolveEntered) => watchers.push({ sourceId, count, resolve: resolveEntered }));
|
|
};
|
|
const browse = (sourceId: string) => h.app.inject({
|
|
method: 'GET' as const, url: `/api/v1/sources/${sourceId}/browse`, headers: auth(h.admin),
|
|
});
|
|
const pending: ReturnType<typeof browse>[] = [];
|
|
try {
|
|
const first = browse('limit-one'); const second = browse('limit-one');
|
|
pending.push(first, second);
|
|
await waitUntilEntered('limit-one', 2);
|
|
const blocked = await browse('limit-one');
|
|
assert.equal(blocked.statusCode, 429, blocked.body);
|
|
assert.equal(blocked.json().error.code, 'RATE_LIMITED');
|
|
assert.equal(entered.get('limit-one'), 2, 'rejected calls never invoke the provider');
|
|
hold = false;
|
|
for (const request of queued.splice(0)) request.release();
|
|
assert.equal((await first).statusCode, 200);
|
|
assert.equal((await second).statusCode, 200);
|
|
const recovered = await browse('limit-one');
|
|
assert.equal(recovered.statusCode, 200, recovered.body);
|
|
|
|
hold = true;
|
|
const sourceIds = ['limit-one', 'limit-two', 'limit-three', 'limit-four'];
|
|
const globalRequests: ReturnType<typeof browse>[] = [];
|
|
for (const sourceId of sourceIds) {
|
|
const count = (entered.get(sourceId) ?? 0) + 2;
|
|
const pair = [browse(sourceId), browse(sourceId)];
|
|
globalRequests.push(...pair); pending.push(...pair);
|
|
await waitUntilEntered(sourceId, count);
|
|
}
|
|
const globalBlocked = await browse('limit-five');
|
|
assert.equal(globalBlocked.statusCode, 429, globalBlocked.body);
|
|
assert.equal(globalBlocked.json().error.code, 'RATE_LIMITED');
|
|
assert.equal(entered.get('limit-five'), undefined);
|
|
hold = false;
|
|
queued.shift()!.release();
|
|
assert.equal((await globalRequests[0]!).statusCode, 200);
|
|
const globalRecovered = await browse('limit-five');
|
|
assert.equal(globalRecovered.statusCode, 200, globalRecovered.body);
|
|
} finally {
|
|
hold = false;
|
|
for (const request of queued.splice(0)) request.release();
|
|
await Promise.allSettled(pending);
|
|
}
|
|
});
|
|
|
|
test('trusted example plugins can be installed, queried, disabled, reloaded after restart and uninstalled', async (t) => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
await mkdir(join(h.dataDir, 'plugins'));
|
|
await cp(resolve(import.meta.dirname, '../../examples/plugins/demo-chapters'), join(h.dataDir, 'plugins', 'node_modules', 'reader-source-example'), { recursive: true });
|
|
const untrusted = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'npm:reader-source-example', trusted: false } });
|
|
assert.ok(untrusted.statusCode >= 400 && untrusted.statusCode < 500, untrusted.body);
|
|
const installed = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'npm:reader-source-example', trusted: true } });
|
|
assert.equal(installed.statusCode, 201, installed.body);
|
|
const pluginId = 'reader.source.demo';
|
|
const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
|
id: 'demo-instance', pluginId, sourceType: 'demo-chapters', name: 'Demo', config: {},
|
|
} });
|
|
assert.equal(created.statusCode, 201, created.body);
|
|
const browse = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) });
|
|
assert.equal(browse.statusCode, 200, browse.body);
|
|
assert.equal(browse.json().items[0].ref, 'demo-book');
|
|
const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/demo-instance/acquire', headers: auth(h.admin), payload: { entryRef: 'demo-book' } });
|
|
assert.equal(acquired.statusCode, 200, acquired.body);
|
|
assert.equal(acquired.json().kind, 'ready');
|
|
const publicationId = acquired.json().publicationId as string;
|
|
const book = await h.app.inject({ method: 'GET', url: `/api/v1/books/${publicationId}`, headers: auth(h.admin) });
|
|
assert.equal(book.statusCode, 200, book.body);
|
|
assert.equal(book.json().book.format, 'chapters');
|
|
const manifest = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/publications/demo-book/manifest', headers: auth(h.admin) });
|
|
assert.equal(manifest.statusCode, 200, manifest.body);
|
|
const resource = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/publications/demo-book/resource?ref=chapter-one', headers: auth(h.admin) });
|
|
assert.equal(resource.statusCode, 200, resource.body);
|
|
assert.match(resource.body, /独立 Node 进程/);
|
|
const disabled = await h.app.inject({ method: 'PATCH', url: `/api/v1/plugins/${pluginId}`, headers: auth(h.admin), payload: { enabled: false } });
|
|
assert.equal(disabled.statusCode, 200, disabled.body);
|
|
const unavailable = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) });
|
|
assert.equal(unavailable.json().error.code, 'PLUGIN_UNAVAILABLE');
|
|
await h.restart();
|
|
const stillUnavailable = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) });
|
|
assert.equal(stillUnavailable.json().error.code, 'PLUGIN_UNAVAILABLE');
|
|
const enabled = await h.app.inject({ method: 'PATCH', url: `/api/v1/plugins/${pluginId}`, headers: auth(h.admin), payload: { enabled: true } });
|
|
assert.equal(enabled.statusCode, 200, enabled.body);
|
|
await h.restart();
|
|
const reloaded = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) });
|
|
assert.equal(reloaded.statusCode, 200, reloaded.body);
|
|
assert.equal(reloaded.json().items[0].ref, 'demo-book');
|
|
const removed = await h.app.inject({ method: 'DELETE', url: `/api/v1/plugins/${pluginId}`, headers: auth(h.admin) });
|
|
assert.equal(removed.statusCode, 200, removed.body);
|
|
const instances = await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(h.admin) });
|
|
assert.ok(instances.json().sources.some((source: { id: string }) => source.id === 'demo-instance'), 'uninstall keeps instance configuration');
|
|
});
|
|
|
|
test('an unacknowledged search stop does not prevent adding a book to the shelf', { timeout: 15000 }, async t => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const directory = join(h.dataDir, 'plugins', 'node_modules', 'reader-source-example');
|
|
await cp(resolve(import.meta.dirname, '../../examples/plugins/demo-chapters'), directory, { recursive: true });
|
|
const manifest = JSON.parse(await readFile(join(directory, 'plugin.json'), 'utf8'));
|
|
manifest.sourceTypes[0].capabilities.push('search.cancel', 'search.session');
|
|
await writeFile(join(directory, 'plugin.json'), JSON.stringify(manifest));
|
|
const entry = await readFile(join(directory, 'main.mjs'), 'utf8');
|
|
await writeFile(join(directory, 'main.mjs'), entry.replace("request.method === '$/cancelRequest'", "['$/cancelRequest', 'searchCancel'].includes(request.method)"));
|
|
const installed = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'npm:reader-source-example', trusted: true } });
|
|
assert.equal(installed.statusCode, 201, installed.body);
|
|
const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
|
id: 'cancel-test', pluginId: manifest.id, sourceType: 'demo-chapters', name: 'Cancel test', config: {},
|
|
} });
|
|
assert.equal(created.statusCode, 201, created.body);
|
|
const stopped = await h.app.inject({ method: 'POST', url: '/api/v1/sources/cancel-test/search/cancel', headers: auth(h.admin), payload: { sessionId: 'test-search-session' } });
|
|
assert.equal(stopped.json().error.code, 'PLUGIN_TIMEOUT', stopped.body);
|
|
const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/cancel-test/acquire', headers: auth(h.admin), payload: { entryRef: 'demo-book' } });
|
|
assert.equal(acquired.statusCode, 200, acquired.body);
|
|
const bookId = acquired.json().publicationId;
|
|
assert.ok(h.ctx.db.get('SELECT book_id FROM user_books WHERE user_id = ? AND book_id = ?', h.admin.id, bookId));
|
|
const book = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}`, headers: auth(h.admin) });
|
|
assert.equal(book.json().book.format, 'chapters');
|
|
});
|
|
|
|
test('search sessions pass generic options and cancellation remains available with saturated search slots', { timeout: 10000 }, async t => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const member = await h.member(), stopped: Array<{ user: string; source: string; session: string }> = [];
|
|
let entered = 0, ready!: () => void;
|
|
const full = new Promise<void>(resolve => { ready = resolve; });
|
|
const held: Array<() => void> = [];
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'sessions', label: 'Sessions', version: '1', capabilities: ['search', 'search.cancel', 'search.session', 'detail'] },
|
|
async detail(_ctx, ref) { return { ref, title: ref }; },
|
|
async acquire() { return { kind: 'action-required', action: { type: 'external', label: 'Test' } }; },
|
|
async search(_ctx, request) {
|
|
assert.equal(request.sessionId, 'search-session-id'); assert.equal(request.resultLimit, 3);
|
|
assert.deepEqual(request.filters, { group: 'chosen' });
|
|
entered++; if (entered === 2) ready();
|
|
await new Promise<void>(resolve => { held.push(resolve); });
|
|
return { items: [], batch: { completed: 2, total: 2 } };
|
|
},
|
|
async cancelSearch(ctx, sessionId) { stopped.push({ user: ctx.userId, source: ctx.instance.id, session: sessionId }); }
|
|
};
|
|
h.ctx.sources!.registry.register({ pluginId: 'reader.sessions', provider });
|
|
await h.ctx.sources!.create({ id: 'session-test', pluginId: 'reader.sessions', sourceType: 'sessions', name: 'session test', config: {} });
|
|
const url = '/api/v1/sources/session-test/search', payload = { query: 'test', sessionId: 'search-session-id', resultLimit: 3, filters: { group: 'chosen' } };
|
|
const requests = [h.app.inject({ method: 'POST', url, payload, headers: auth(member) }), h.app.inject({ method: 'POST', url, payload, headers: auth(h.admin) })];
|
|
try {
|
|
await full;
|
|
const response = await h.app.inject({ method: 'POST', url: '/api/v1/sources/session-test/search/cancel', headers: auth(member), payload: { sessionId: 'search-session-id' } });
|
|
assert.equal(response.statusCode, 200, response.body);
|
|
assert.deepEqual(stopped, [{ user: member.id, source: 'session-test', session: 'search-session-id' }]);
|
|
assert.equal((await h.app.inject({ method: 'POST', url: '/api/v1/sources/session-test/search/cancel', payload: { sessionId: 'search-session-id' } })).statusCode, 401);
|
|
} finally { held.forEach(release => release()); await Promise.all(requests); }
|
|
for (const invalid of [{ sessionId: 'bad' }, { sessionId: ['search-session-id', 'other-session-id'] }, { resultLimit: 0 }, { resultLimit: 10001 }, { resultLimit: 1.5 }]) {
|
|
assert.equal((await h.app.inject({ method: 'POST', url, payload: { ...payload, ...invalid }, headers: auth(member) })).statusCode, 400);
|
|
}
|
|
});
|
|
|
|
test('Streamable HTTP sends results before completion and disconnect cancels pending work', { timeout: 10000 }, async t => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
let entered!: () => void, cancelled!: () => void, closed!: () => void;
|
|
const waiting = new Promise<void>(resolve => { entered = resolve; });
|
|
const aborted = new Promise<void>(resolve => { cancelled = resolve; });
|
|
const cleanup = new Promise<void>(resolve => { closed = resolve; });
|
|
const cursors: Array<string | undefined> = [];
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'streaming', label: 'Stream', version: '1', capabilities: ['search', 'search.cancel', 'search.session', 'detail'] },
|
|
async detail(_ctx, ref) { return { ref, title: ref }; },
|
|
async acquire() { return { kind: 'action-required', action: { type: 'external', label: 'Test' } }; },
|
|
async search(ctx, request) {
|
|
cursors.push(request.cursor);
|
|
if (!request.cursor) return { items: [{ ref: 'first', title: '首条中文结果' }], batch: { completed: 1, total: 2 }, nextCursor: 'resume-here' };
|
|
entered();
|
|
return new Promise((_, reject) => { ctx.signal.addEventListener('abort', () => { cancelled(); reject(ctx.signal.reason); }, { once: true }); });
|
|
},
|
|
async cancelSearch() { closed(); }
|
|
};
|
|
h.ctx.sources!.registry.register({ pluginId: 'reader.streaming', provider });
|
|
await h.ctx.sources!.create({ id: 'stream-test', pluginId: 'reader.streaming', sourceType: 'streaming', name: 'stream test', config: {} });
|
|
const base = await h.app.listen({ port: 0, host: '127.0.0.1' });
|
|
const controller = new AbortController(); t.after(() => controller.abort());
|
|
const response = await fetch(base + '/api/v1/sources/stream-test/search', { method: 'POST', headers: { ...auth(h.admin), 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify(searchPayload), signal: controller.signal });
|
|
assert.match(response.headers.get('content-type')!, /text\/event-stream/); assert.equal(response.headers.get('x-accel-buffering'), 'no');
|
|
const reader = response.body!.getReader(), decoder = new TextDecoder(); let text = '';
|
|
while (!text.includes('首条中文结果')) { const part = await reader.read(); assert.equal(part.done, false); text += decoder.decode(part.value, { stream: true }); }
|
|
await waiting; assert.deepEqual(cursors, [undefined, 'resume-here']); assert.doesNotMatch(text, /event: done/);
|
|
controller.abort(); await Promise.all([aborted, cleanup]); await reader.cancel().catch(() => {});
|
|
});
|
|
|
|
test('stream errors retain earlier events and caps prevent extra provider calls', async t => {
|
|
const h = await harness(); t.after(() => h.close()); let calls = 0;
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'stream-error', label: 'Stream', version: '1', capabilities: ['search', 'detail'] },
|
|
async detail(_ctx, ref) { return { ref, title: ref }; },
|
|
async acquire() { return { kind: 'action-required', action: { type: 'external', label: 'Test' } }; },
|
|
async search(_ctx, request) {
|
|
calls++;
|
|
if (request.cursor) return { items: [], batch: { completed: 1, total: 2 }, nextCursor: 'same' };
|
|
return { items: [{ ref: 'one', title: 'one' }, { ref: 'one', title: 'duplicate' }, { ref: 'two', title: 'two' }], batch: { completed: 1, total: 2 }, nextCursor: 'same' };
|
|
}
|
|
};
|
|
h.ctx.sources!.registry.register({ pluginId: 'reader.stream-error', provider });
|
|
await h.ctx.sources!.create({ id: 'stream-error', pluginId: 'reader.stream-error', sourceType: 'stream-error', name: 'stream error', config: {} });
|
|
const response = await h.app.inject({ method: 'POST', url: '/api/v1/sources/stream-error/search', headers: auth(h.admin), payload: searchPayload });
|
|
const events = streamEvents(response.body); assert.equal(events[0]!.data.items.length, 2);
|
|
assert.equal(events.at(-1)!.event, 'error'); assert.equal(events.at(-1)!.data.code, 'INVALID_CURSOR');
|
|
calls = 0;
|
|
const limited = await h.app.inject({ method: 'POST', url: '/api/v1/sources/stream-error/search', headers: auth(h.admin), payload: { ...searchPayload, resultLimit: 1 } });
|
|
const result = streamEvents(limited.body); assert.equal(calls, 1); assert.equal(result[0]!.data.items.length, 1);
|
|
assert.equal(result[0]!.data.limitReached, true); assert.equal(result.at(-1)!.data.reason, 'limit');
|
|
});
|
|
|
|
test('resuming a disconnected stream waits for its previous cleanup before polling again', { timeout: 10000 }, async t => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
let entered!: () => void, stopping!: () => void, release!: () => void;
|
|
const waiting = new Promise<void>(resolve => { entered = resolve; });
|
|
const stopped = new Promise<void>(resolve => { stopping = resolve; });
|
|
const cleanup = new Promise<void>(resolve => { release = resolve; });
|
|
t.after(() => release());
|
|
let calls = 0, stops = 0;
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'resume', label: 'Resume', version: '1', capabilities: ['search', 'search.cancel', 'search.session', 'detail'] },
|
|
async detail(_ctx, ref) { return { ref, title: ref }; },
|
|
async acquire() { return { kind: 'action-required', action: { type: 'external', label: 'Test' } }; },
|
|
async search(ctx) {
|
|
calls++;
|
|
if (calls === 1) return { items: [{ ref: 'first', title: 'First' }], nextCursor: 'next' };
|
|
if (calls === 2) { entered(); return new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(ctx.signal.reason), { once: true })); }
|
|
return { items: [{ ref: 'second', title: 'Resumed' }] };
|
|
},
|
|
async cancelSearch() { stops++; stopping(); await cleanup; },
|
|
};
|
|
h.ctx.sources!.registry.register({ pluginId: 'reader.resume', provider });
|
|
await h.ctx.sources!.create({ id: 'resume-test', pluginId: 'reader.resume', sourceType: 'resume', name: 'resume', config: {} });
|
|
const base = await h.app.listen({ port: 0, host: '127.0.0.1' });
|
|
const controller = new AbortController(); t.after(() => controller.abort());
|
|
const headers = { ...auth(h.admin), 'content-type': 'application/json' };
|
|
const response = await fetch(base + '/api/v1/sources/resume-test/search', { method: 'POST', headers, body: JSON.stringify(searchPayload), signal: controller.signal });
|
|
const reader = response.body!.getReader();
|
|
await reader.read(); await waiting;
|
|
controller.abort(); await stopped;
|
|
const resumed = await fetch(base + '/api/v1/sources/resume-test/search', { method: 'POST', headers, body: JSON.stringify({ ...searchPayload, cursor: 'next' }) });
|
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
assert.equal(calls, 2, 'old cleanup must finish before the next poll');
|
|
release();
|
|
assert.match(await resumed.text(), /Resumed/);
|
|
assert.equal(calls, 3); assert.equal(stops, 1);
|
|
await reader.cancel().catch(() => {});
|
|
});
|
|
|
|
|
|
test('alternative streams use owned book identity and forced chapter refresh updates only its cache', async t => {
|
|
const h = await harness(); t.after(() => h.close()); const member = await h.member();
|
|
let text = 'old'; const calls: string[] = [];
|
|
const provider: SourceProvider = {
|
|
descriptor: { id: 'switch-test', label: 'Test', version: '1', capabilities: ['detail', 'acquire.chapters', 'content.manifest', 'content.resource', 'content.alternatives'] },
|
|
async detail() { return { ref: 'book', title: 'Expected title', authors: ['Expected author'] }; },
|
|
async acquire() { return { kind: 'chapters', publicationRef: 'book' }; },
|
|
async getManifest() { return { publicationRef: 'book', sourceName: 'Example source', items: [{ id: 'ch', seq: 0, title: 'Chapter', kind: 'chapter', mediaType: 'text/plain', ref: 'body', sourceUrl: 'https://example.test/ch' }] }; },
|
|
async readResource() { return { mediaType: 'text/plain', text }; },
|
|
async alternatives(ctx, request) {
|
|
assert.equal(ctx.userId, h.admin.id); assert.equal(request.query, 'Expected title'); assert.deepEqual(request.authors, ['Expected author']);
|
|
assert.equal(request.sessionId, 'alternative-session'); assert.equal(request.resultLimit, 3);
|
|
calls.push(request.cursor ?? 'first');
|
|
return request.cursor ? { items: [{ ref: 'second', title: 'Expected title' }] }
|
|
: { items: [{ ref: 'first', title: 'Expected title' }], nextCursor: 'next' };
|
|
},
|
|
async cancelSearch() {},
|
|
};
|
|
h.ctx.sources!.registry.register({ pluginId: 'test.switch', provider });
|
|
await h.ctx.sources!.create({ id: 'switch-instance', pluginId: 'test.switch', sourceType: 'switch-test', name: 'Switch', config: {} });
|
|
const acquired = await h.ctx.sources!.acquire(h.admin.id, 'switch-instance', 'book');
|
|
assert.equal(acquired.kind, 'ready'); if (acquired.kind !== 'ready') return;
|
|
const id = acquired.publicationId, content = h.ctx.sources!.chapters.manifest(h.admin.id, id), ref = content.items[0]!.resourceRef!;
|
|
assert.equal(content.sourceName, 'Example source'); assert.equal(content.items[0]!.sourceUrl, 'https://example.test/ch');
|
|
const response = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/alternatives', headers: auth(h.admin), payload: { sessionId: 'alternative-session', resultLimit: 3, query: 'untrusted' } });
|
|
assert.equal(response.statusCode, 200); assert.match(String(response.headers['content-type']), /text\/event-stream/);
|
|
assert.deepEqual(streamEvents(response.body).map(event => event.event), ['results', 'results', 'done']); assert.deepEqual(calls, ['first', 'next']);
|
|
const quality = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/switch-quality', headers: auth(h.admin), payload: { entryRef: 'book', chapterId: 'ch' } });
|
|
assert.equal(quality.statusCode, 200, quality.body); assert.equal(quality.json().characters, 3);
|
|
assert.equal(h.ctx.db.get<{body:string|null}>('SELECT body FROM chapter_resources WHERE book_id=?',id)!.body,null);
|
|
await h.ctx.sources!.chapters.asset(h.admin.id, id, ref); text = 'new';
|
|
const refreshed = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/refresh-chapter', headers: auth(h.admin), payload: { ref } });
|
|
const chapterSearch=await h.app.inject({method:'GET',url:'/api/v1/books/'+id+'/search?q=new',headers:auth(h.admin)});
|
|
assert.equal(chapterSearch.statusCode,200,chapterSearch.body);assert.equal(chapterSearch.json().hits.length,1);
|
|
assert.equal(chapterSearch.json().hits[0].anchor.sectionId,content.items[0]!.href);
|
|
assert.equal((await h.app.inject({method:'GET',url:'/api/v1/books/'+id+'/search?q=new',headers:auth(member)})).statusCode,404);
|
|
assert.equal(refreshed.statusCode, 200, refreshed.body); assert.equal(refreshed.body, 'new'); assert.equal(refreshed.headers['cache-control'], 'no-store');
|
|
assert.equal((await h.ctx.sources!.chapters.asset(h.admin.id, id, ref)).data!.toString(), 'new');
|
|
for (const endpoint of ['alternatives', 'refresh-chapter', 'switch-quality']) {
|
|
const denied = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/' + endpoint, headers: auth(member), payload: { ref, sessionId: 'alternative-session' } });
|
|
assert.equal(denied.statusCode, 404, denied.body);
|
|
}
|
|
});
|
|
|
|
test('default search source is exclusive, persistent, admin-managed and cleared on disable or delete', async t => {
|
|
const h = await harness(); t.after(() => h.close());
|
|
const member = await h.member();
|
|
const patch = (id: string, payload: object, session = h.admin) => h.app.inject({ method: 'PATCH', url: '/api/v1/sources/' + id, headers: auth(session), payload });
|
|
const defaults = async () => (await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(member) })).json().sources.filter((s: any) => s.isDefault).map((s: any) => s.id);
|
|
for (const id of ['one', 'two']) {
|
|
const response = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { id, pluginId: 'reader.opds', sourceType: 'opds', name: id, config: { url: 'https://example.test/opds' } } });
|
|
assert.equal(response.statusCode, 201, response.body);
|
|
}
|
|
assert.equal((await patch('one', { isDefault: true }, member)).statusCode, 403);
|
|
assert.equal((await patch('one', { isDefault: 'true' })).statusCode, 400);
|
|
assert.equal((await patch('missing', { isDefault: true })).statusCode, 404);
|
|
assert.equal((await patch('one', { isDefault: true })).statusCode, 200);
|
|
assert.deepEqual(await defaults(), ['one']);
|
|
await h.restart(); assert.deepEqual(await defaults(), ['one']);
|
|
assert.equal((await patch('two', { isDefault: true })).statusCode, 200);
|
|
assert.deepEqual(await defaults(), ['two']);
|
|
assert.equal((await patch('one', { name: '' , isDefault: true })).statusCode, 400);
|
|
assert.deepEqual(await defaults(), ['two']);
|
|
await patch('two', { enabled: false }); assert.deepEqual(await defaults(), []);
|
|
assert.equal((await patch('two', { isDefault: true })).statusCode, 400);
|
|
assert.equal((await patch('two', { enabled: false, isDefault: true })).statusCode, 400);
|
|
await patch('two', { enabled: true, isDefault: true }); assert.deepEqual(await defaults(), ['two']);
|
|
await patch('two', { isDefault: false }); assert.deepEqual(await defaults(), []);
|
|
await patch('two', { isDefault: true });
|
|
await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/two', headers: auth(h.admin) });
|
|
assert.deepEqual(await defaults(), []);
|
|
});
|