import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } 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'; interface Session { token: string; id: string } interface Item { href: string; resourceRef: string; title: string; seq: number } interface Chapter { id: string; seq: number; title: string; kind: 'chapter'; mediaType: string; ref: string } interface FixtureState { version: string; items: Chapter[]; resources: Record; failManifest?: boolean; resourceMediaType?: string; } const PLUGIN_ID = 'reader.source.chapters-test'; const SOURCE_ID = 'test-chapters'; const BOOK_REF = 'same-book-for-every-user'; const auth = (session: Session) => ({ authorization: `Bearer ${session.token}` }); const chapter = (id: string, seq: number, title: string): Chapter => ({ id, seq, title, kind: 'chapter', mediaType: 'text/plain', ref: id }); const initialState = (): FixtureState => ({ version: '1', items: [chapter('first/稳定?#', 0, '第一章'), chapter('second', 1, '第二章'), chapter('third', 2, '第三章')], resources: { 'first/稳定?#': '第一章旧正文\n\n', second: '第二章旧正文', third: '第三章旧正文' }, }); async function harness() { const root = await mkdtemp(join(tmpdir(), 'reader-chapter-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: 'chapter-api-stable-restart-secret', 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 registration = await app!.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { username: 'owner', password: 'password123' } }); assert.equal(registration.statusCode, 201, registration.body); const admin: Session = { token: registration.json().session.accessToken, id: registration.json().user.id }; return { root, booksDir, dataDir, admin, get app() { return app; }, get ctx() { return ctx; }, async member(): Promise { const creation = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(admin), payload: { username: 'reader', password: 'password123' } }); assert.equal(creation.statusCode, 201, creation.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 }); }, }; } type Harness = Awaited>; /** A real stdio process whose upstream catalogue can change between requests. */ async function installFixture(h: Harness) { const directory = join(h.dataDir, 'plugins', 'chapters-test'); await mkdir(directory, { recursive: true }); await writeFile(join(directory, 'plugin.json'), JSON.stringify({ id: PLUGIN_ID, name: '章节集成测试', version: '1.0.0', apiVersion: 1, runtime: 'node', entry: 'main.mjs', sourceTypes: [{ id: 'chapters-test', label: '章节测试', capabilities: ['browse', 'detail', 'acquire.chapters', 'content.manifest', 'content.resource', 'content.update'] }], })); await writeFile(join(directory, 'state.json'), JSON.stringify(initialState())); await writeFile(join(directory, 'calls.jsonl'), ''); await writeFile(join(directory, 'main.mjs'), ` import { createInterface } from 'node:readline'; import { appendFile, readFile } from 'node:fs/promises'; const entry = { ref: ${JSON.stringify(BOOK_REF)}, title: '来自插件的章节书', authors: ['测试作者'] }; const methods = { validateConfig: () => null, browse: () => ({ items: [entry] }), detail: () => entry, acquire: () => ({ kind: 'chapters', publicationRef: entry.ref }), getManifest: async ({ publicationRef }) => { const state = JSON.parse(await readFile(new URL('./state.json', import.meta.url), 'utf8')); if (state.failManifest) throw Object.assign(new Error('fixture upstream is unavailable'), { code: 'SOURCE_CHANGED' }); return { publicationRef, version: state.version, items: state.items }; }, readResource: async ({ context, request }) => { const state = JSON.parse(await readFile(new URL('./state.json', import.meta.url), 'utf8')); const text = state.resources[request.ref]; if (text === undefined) throw Object.assign(new Error('missing fixture chapter'), { code: 'RESOURCE_GONE' }); return { mediaType: state.resourceMediaType ?? 'text/plain; charset=utf-8', text: text + '\\nreader=' + context.userId }; }, }; for await (const line of createInterface({ input: process.stdin, crlfDelay: Infinity })) { let request; try { request = JSON.parse(line); if (request.method === '$/cancelRequest') continue; await appendFile(new URL('./calls.jsonl', import.meta.url), JSON.stringify({ method: request.method, params: request.params }) + '\\n'); const result = await methods[request.method](request.params); process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'); } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request?.id, error: { code: error.code ?? 'PLUGIN_ERROR', message: error.message } }) + '\\n'); } } `); const installed = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'chapters-test', trusted: true } }); assert.equal(installed.statusCode, 201, installed.body); const source = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { id: SOURCE_ID, pluginId: PLUGIN_ID, sourceType: 'chapters-test', name: '测试章节来源', config: {}, } }); assert.equal(source.statusCode, 201, source.body); return { async state(state: FixtureState) { await writeFile(join(directory, 'state.json'), JSON.stringify(state)); }, async calls(method: string) { return (await readFile(join(directory, 'calls.jsonl'), 'utf8')).trim().split('\n').filter(Boolean) .map((line) => JSON.parse(line)).filter((call: { method: string }) => call.method === method); }, }; } async function acquire(h: Harness, session: Session) { const response = await h.app.inject({ method: 'POST', url: `/api/v1/sources/${SOURCE_ID}/acquire`, headers: auth(session), payload: { entryRef: BOOK_REF } }); assert.equal(response.statusCode, 200, response.body); assert.equal(response.json().kind, 'ready', response.body); const id = response.json().publicationId as string; assert.ok(id); return id; } async function manifest(h: Harness, session: Session, id: string) { const response = await h.app.inject({ method: 'GET', url: `/api/v1/books/${id}/manifest?group=all`, headers: auth(session) }); assert.equal(response.statusCode, 200, response.body); return response.json() as { book: { id: string; title: string; format: string }; files: unknown[]; content: { revision: string }; items: Item[] }; } function asset(h: Harness, session: Session, id: string, item: Item) { return h.app.inject({ method: 'GET', url: `/api/v1/books/${id}/assets?ref=${encodeURIComponent(item.resourceRef)}`, headers: auth(session) }); } test('chapter acquisition creates a stable private publication and integrates with shelf, reading and progress', async (t) => { const h = await harness(); t.after(() => h.close()); const fixture = await installFixture(h); const member = await h.member(); const id = await acquire(h, member); assert.equal(await acquire(h, member), id, 'repeated acquisition has the same book identity'); const book = await manifest(h, member, id); assert.equal(book.book.format, 'chapters'); assert.equal(book.book.title, '来自插件的章节书'); assert.deepEqual(book.files, [], 'chapter publications must not advertise invented files'); assert.equal(book.items.length, 3); assert.ok(book.items.every((item) => item.resourceRef && item.href !== item.resourceRef)); const first = await asset(h, member, id, book.items[0]!); assert.equal(first.statusCode, 200, first.body); assert.match(first.headers['content-type'] as string, /^text\/plain/); assert.equal(first.headers['x-content-type-options'], 'nosniff'); assert.match(first.headers['content-security-policy'] as string, /sandbox/); assert.match(first.body, /