mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/**
|
|
* HTTP TTS tests.
|
|
*
|
|
* The proxy has two failure modes that are worse than an error, and both are
|
|
* asserted here:
|
|
*
|
|
* - **Silence with a 200.** A misconfigured `TTS_URL` (a wrong host landing on
|
|
* a web page) answers `text/html` with a status of 200. Streaming that into an
|
|
* `<audio>` element plays nothing and reports nothing, so the proxy refuses any
|
|
* content type that is not audio.
|
|
* - **An open relay.** `TTS_URL` is a URL the *server* holds, so a client must
|
|
* never be able to point the request somewhere else, and the route must be
|
|
* authenticated so a stray instance cannot spend someone else's quota.
|
|
*
|
|
* A stub upstream is used rather than a real service: the contract being tested
|
|
* is *this* proxy's behaviour, and a network dependency in a unit test is a test
|
|
* that fails on a train.
|
|
*/
|
|
import { test, before, after, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer, type Server } from 'node:http';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import Fastify from 'fastify';
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
import { loadConfig } from '../src/config/index.ts';
|
|
import { openDatabase } from '../src/db/index.ts';
|
|
import { Scanner } from '../src/indexer/scanner.ts';
|
|
import { UserService } from '../src/services/users.ts';
|
|
import { ShelfService } from '../src/services/shelf.ts';
|
|
import { SyncService } from '../src/services/sync.ts';
|
|
import { TtsService } from '../src/services/tts.ts';
|
|
import {BusinessSettings} from '../src/services/business-settings.ts';
|
|
import { BrowseService } from '../src/services/browse.ts';
|
|
import { buildApp } from '../src/http/app.ts';
|
|
import type { AppContext } from '../src/http/context.ts';
|
|
|
|
let root: string;
|
|
let app: FastifyInstance;
|
|
let ctx: AppContext;
|
|
let token = '';
|
|
let upstream: Server | null = null;
|
|
let upstreamUrl = '';
|
|
/** What the stub upstream should answer with on the next call. */
|
|
let upstreamBehaviour: 'audio' | 'html' | 'error' | 'empty' | 'auth' | 'slow' = 'audio';
|
|
let lastUpstreamQuery = '';
|
|
|
|
/** Minimal stand-in for an edge-tts-compatible service. */
|
|
function startUpstream(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
upstream = createServer((req, res) => {
|
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
lastUpstreamQuery = url.search;
|
|
if (url.pathname === '/voices') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify([{ id: 'zh-CN-XiaoxiaoNeural', name: '晓晓', lang: 'zh-CN' }]));
|
|
return;
|
|
}
|
|
if (upstreamBehaviour === 'empty') { res.writeHead(200, { 'content-type': 'audio/mpeg' }); res.end(); return; }
|
|
if (upstreamBehaviour === 'auth') { res.writeHead(401); res.end(); return; }
|
|
if (upstreamBehaviour === 'slow') { res.writeHead(200, { 'content-type': 'audio/mpeg' }); res.flushHeaders(); setTimeout(() => res.end('audio'), 200); return; }
|
|
if (upstreamBehaviour === 'html') {
|
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
res.end('<html><body>not audio</body></html>');
|
|
return;
|
|
}
|
|
if (upstreamBehaviour === 'error') {
|
|
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
res.end('bad gateway');
|
|
return;
|
|
}
|
|
// A recognisable, deterministic body: the tests assert the bytes came from
|
|
// upstream rather than being invented here.
|
|
res.writeHead(200, { 'content-type': 'audio/mpeg' });
|
|
res.end(Buffer.from(`AUDIO:${url.searchParams.get('text') ?? ''}`));
|
|
});
|
|
upstream.listen(0, '127.0.0.1', () => {
|
|
const address = upstream!.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
upstreamUrl = `http://127.0.0.1:${port}/tts`;
|
|
// Same origin, different path: the convention the docs describe, made
|
|
// explicit because a proxy that *guesses* the voice path is a proxy that
|
|
// asks a stranger's server for a URL nobody configured.
|
|
process.env.TTS_VOICES_URL = `http://127.0.0.1:${port}/voices`;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function buildInstance(): Promise<void> {
|
|
const config = loadConfig();
|
|
const db = openDatabase(config);
|
|
// Each case supplies its desired fixture explicitly; production imports env only once.
|
|
const settings=new BusinessSettings(db,config);
|
|
settings.save('tts',{enabled:!!process.env.TTS_URL,url:process.env.TTS_URL||'',voicesUrl:process.env.TTS_VOICES_URL||'',cacheMaxBytes:0},settings.view('tts').revision);
|
|
ctx = {
|
|
config,
|
|
db,
|
|
scanner: undefined as never,
|
|
users: undefined as never,
|
|
shelf: undefined as never,
|
|
sync: undefined as never,
|
|
tts: undefined as never,
|
|
browse: undefined as never,
|
|
log: undefined as never,
|
|
};
|
|
const silent = Fastify({ logger: false });
|
|
ctx.log = silent.log;
|
|
ctx.scanner = new Scanner(db, config, { info: () => {}, warn: () => {} });
|
|
ctx.users = new UserService(db, config);
|
|
ctx.shelf = new ShelfService(db);
|
|
ctx.sync = new SyncService(db, ctx.shelf);
|
|
ctx.tts = new TtsService(config);
|
|
ctx.browse = new BrowseService(db, config, ctx.shelf);
|
|
app = buildApp(ctx);
|
|
await app.ready();
|
|
}
|
|
|
|
before(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'reader-tts-test-'));
|
|
process.env.BOOKS_DIR = join(root, 'books');
|
|
process.env.DATA_DIR = join(root, 'data');
|
|
process.env.SCAN_INTERVAL = '0';
|
|
process.env.WATCH_INTERVAL = '0';
|
|
process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough';
|
|
process.env.TTS_CACHE_BYTES = '0';
|
|
await startUpstream();
|
|
});
|
|
|
|
after(async () => {
|
|
await app?.close();
|
|
ctx?.db.close();
|
|
await new Promise<void>((resolve) => upstream?.close(() => resolve()));
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Rebuilds the instance so the environment (`TTS_URL`) is read fresh. */
|
|
beforeEach(async () => {
|
|
await app?.close();
|
|
ctx?.db.close();
|
|
await buildInstance();
|
|
if (!token) {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/register',
|
|
payload: { username: 'tts-user', password: 'password123' },
|
|
});
|
|
assert.equal(res.statusCode, 201, res.body);
|
|
token = (res.json() as { session: { accessToken: string } }).session.accessToken;
|
|
}
|
|
});
|
|
|
|
test('an instance with no TTS_URL reports http:false rather than 404', async () => {
|
|
delete process.env.TTS_URL;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/tts/voices', headers: auth() });
|
|
assert.equal(res.statusCode, 200);
|
|
const body = res.json() as { http: boolean; maxLength: number };
|
|
assert.equal(body.http, false);
|
|
// The ceiling is advertised whether or not the proxy is on: the client splits
|
|
// sentences against it before deciding which engine to use.
|
|
assert.equal(body.maxLength, 800);
|
|
});
|
|
|
|
test('synthesis is refused with a clear error when TTS_URL is unset', async () => {
|
|
delete process.env.TTS_URL;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/tts?text=${encodeURIComponent('你好')}&access_token=${token}`,
|
|
});
|
|
assert.equal(res.statusCode, 400);
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_DISABLED');
|
|
});
|
|
|
|
test('a sentence is proxied to the upstream service and streamed back as audio', async () => {
|
|
process.env.TTS_URL = upstreamUrl;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
upstreamBehaviour = 'audio';
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/tts?text=${encodeURIComponent('床前明月光。')}&speed=1.2&access_token=${token}`,
|
|
});
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
assert.equal(res.headers['content-type'], 'audio/mpeg');
|
|
assert.equal(res.body, 'AUDIO:床前明月光。');
|
|
// The query is built with `URLSearchParams`, so a Chinese sentence survives the
|
|
// trip; a hand-built URL would have sent mojibake.
|
|
assert.match(lastUpstreamQuery, /text=%E5%BA%8A%E5%89%8D%E6%98%8E%E6%9C%88%E5%85%89/);
|
|
assert.match(lastUpstreamQuery, /speed=1\.2/);
|
|
});
|
|
|
|
test('an upstream that answers HTML is refused instead of served as silence', async () => {
|
|
process.env.TTS_URL = upstreamUrl;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
upstreamBehaviour = 'html';
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/tts?text=hello&access_token=${token}`,
|
|
});
|
|
assert.equal(res.statusCode, 400);
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_CONTENT');
|
|
});
|
|
|
|
test('an upstream error is reported as such, not as a 200', async () => {
|
|
process.env.TTS_URL = upstreamUrl;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
upstreamBehaviour = 'error';
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/tts?text=hello&access_token=${token}`,
|
|
});
|
|
assert.equal(res.statusCode, 400);
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'TTS_UPSTREAM');
|
|
});
|
|
|
|
test('an over-long utterance is refused before it reaches the upstream', async () => {
|
|
process.env.TTS_URL = upstreamUrl;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
upstreamBehaviour = 'audio';
|
|
lastUpstreamQuery = '';
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/tts?text=${encodeURIComponent('长'.repeat(900))}&access_token=${token}`,
|
|
});
|
|
assert.equal(res.statusCode, 400);
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'TEXT_TOO_LONG');
|
|
assert.equal(lastUpstreamQuery, '', 'the upstream must not be called at all');
|
|
});
|
|
|
|
test('the voice list is passed through from the upstream service', async () => {
|
|
process.env.TTS_URL = upstreamUrl;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/tts/voices', headers: auth() });
|
|
assert.equal(res.statusCode, 200);
|
|
const body = res.json() as { http: boolean; voices: Array<{ id: string; lang: string }> };
|
|
assert.equal(body.http, true);
|
|
assert.deepEqual(body.voices, [{ id: 'zh-CN-XiaoxiaoNeural', name: '晓晓', lang: 'zh-CN' }]);
|
|
});
|
|
|
|
test('the audio route requires a token, in the header or the query', async () => {
|
|
process.env.TTS_URL = upstreamUrl;
|
|
await app.close();
|
|
ctx.db.close();
|
|
await buildInstance();
|
|
|
|
const anonymous = await app.inject({ method: 'GET', url: '/api/v1/tts?text=hello' });
|
|
assert.equal(anonymous.statusCode, 401);
|
|
|
|
// A query token is allowed on this route only because an `<audio src>` cannot
|
|
// carry a header. Anything else must use the header.
|
|
const viaQuery = await app.inject({ method: 'GET', url: `/api/v1/tts?text=hello&access_token=${token}` });
|
|
assert.equal(viaQuery.statusCode, 200);
|
|
|
|
const viaHeader = await app.inject({ method: 'GET', url: '/api/v1/tts?text=hello', headers: auth() });
|
|
assert.equal(viaHeader.statusCode, 200);
|
|
|
|
// The capability route is an API call, so a query token is not accepted there.
|
|
const capabilityWithQuery = await app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/tts/voices?access_token=${token}`,
|
|
});
|
|
assert.equal(capabilityWithQuery.statusCode, 401);
|
|
});
|
|
|
|
function auth(): Record<string, string> {
|
|
return { authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
test('diagnostic endpoint synthesizes audio and requires header authentication', async () => {
|
|
const unauth = await app.inject({method:'POST',url:'/api/v1/tts/test',payload:{}}); assert.equal(unauth.statusCode,401);
|
|
const result = await app.inject({method:'POST',url:'/api/v1/tts/test',headers:{authorization:'Bearer '+token},payload:{}});
|
|
assert.equal(result.statusCode,200,result.body); assert.ok(result.json().bytes > 0);
|
|
});
|
|
for (const [behaviour,code] of [['empty','TTS_EMPTY'],['auth','TTS_AUTH'],['slow','TTS_TIMEOUT']] as const) {
|
|
test('diagnosis classifies ' + behaviour, async () => {
|
|
upstreamBehaviour = behaviour;
|
|
if (behaviour === 'slow') { ctx.tts = new TtsService(ctx.config,{...ctx.settings!.read('tts'),timeoutMs:30}); }
|
|
try { const result = await app.inject({method:'POST',url:'/api/v1/tts/test',headers:{authorization:'Bearer '+token},payload:{}}); assert.equal(result.statusCode,400,result.body); assert.equal(result.json().error.code,code); }
|
|
finally { delete process.env.TTS_TIMEOUT_MS; }
|
|
});
|
|
}
|