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(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 { 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((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((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(); 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((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 => { 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[] = []; 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[] = []; 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(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(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(resolve => { entered = resolve; }); const aborted = new Promise(resolve => { cancelled = resolve; }); const cleanup = new Promise(resolve => { closed = resolve; }); const cursors: Array = []; 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(resolve => { entered = resolve; }); const stopped = new Promise(resolve => { stopping = resolve; }); const cleanup = new Promise(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(), []); });