Raise Jest coverage above global thresholds.

Add real MCP HTTP/tool/entrypoint tests and extend torrent/qbt coverage
so statements, branches, functions, and lines all meet the configured floors.
This commit is contained in:
2026-08-07 19:37:22 +02:00
parent ac97862edb
commit b04e70e261
5 changed files with 549 additions and 7 deletions

View File

@@ -0,0 +1,88 @@
/**
* mcp.js is a side-effect entrypoint (listen). Load it with mocks so we cover
* wiring without binding a real port.
*/
describe('mcp.js entry', () => {
const originalEnv = { ...process.env };
let listenCb;
let mockApp;
let registerMcpHttp;
beforeEach(() => {
jest.resetModules();
listenCb = null;
mockApp = {
use: jest.fn(),
get: jest.fn((path, handler) => {
mockApp._health = handler;
}),
listen: jest.fn((port, cb) => {
listenCb = cb;
return { close: jest.fn() };
}),
};
const expressFn = jest.fn(() => mockApp);
expressFn.json = jest.fn(() => (req, res, next) => next && next());
jest.doMock('express', () => expressFn);
registerMcpHttp = jest.fn();
jest.doMock('../src/mcp-http', () => ({ registerMcpHttp }));
});
afterEach(() => {
process.env = { ...originalEnv };
jest.resetModules();
jest.dontMock('express');
jest.dontMock('../src/mcp-http');
});
it('registers health + MCP and listens (auth mode)', () => {
process.env.WEBSHARE_USERNAME = 'user';
process.env.WEBSHARE_PASSWORD = 'pass';
process.env.PORT = '3456';
process.env.MCP_PATH = '/custom-mcp';
require('../src/mcp');
expect(mockApp.use).toHaveBeenCalled();
expect(registerMcpHttp).toHaveBeenCalledWith(mockApp, {
username: 'user',
password: 'pass',
path: '/custom-mcp',
});
expect(mockApp.listen).toHaveBeenCalledWith('3456', expect.any(Function));
const res = { json: jest.fn() };
mockApp._health({}, res);
expect(res.json).toHaveBeenCalledWith({
ok: true,
mcp: '/custom-mcp',
guest: false,
});
const log = jest.spyOn(console, 'log').mockImplementation(() => {});
listenCb();
expect(log).toHaveBeenCalledWith(expect.stringMatching(/auth/));
log.mockRestore();
});
it('reports guest mode when credentials missing', () => {
delete process.env.WEBSHARE_USERNAME;
delete process.env.WEBSHARE_PASSWORD;
delete process.env.PORT;
delete process.env.MCP_PATH;
require('../src/mcp');
const res = { json: jest.fn() };
mockApp._health({}, res);
expect(res.json).toHaveBeenCalledWith({
ok: true,
mcp: '/mcp',
guest: true,
});
const log = jest.spyOn(console, 'log').mockImplementation(() => {});
listenCb();
expect(log).toHaveBeenCalledWith(expect.stringMatching(/guest/));
log.mockRestore();
});
});

View File

@@ -0,0 +1,98 @@
const express = require('express');
const request = require('supertest');
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({
handleRequest: jest.fn(async (req, res) => {
res.status(200).json({ ok: true });
}),
close: jest.fn(),
})),
}));
jest.mock('../src/mcp-server', () => ({
createWebshareMcpServer: jest.fn(() => ({
connect: jest.fn(async () => {}),
close: jest.fn(),
})),
}));
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
const { createWebshareMcpServer } = require('../src/mcp-server');
const { registerMcpHttp } = require('../src/mcp-http');
describe('registerMcpHttp', () => {
let app;
beforeEach(() => {
jest.clearAllMocks();
createWebshareMcpServer.mockImplementation(() => ({
connect: jest.fn(async () => {}),
close: jest.fn(),
}));
StreamableHTTPServerTransport.mockImplementation(() => ({
handleRequest: jest.fn(async (req, res) => {
res.status(200).json({ ok: true });
}),
close: jest.fn(),
}));
app = express();
app.use(express.json());
registerMcpHttp(app, { username: 'u', password: 'p', path: '/mcp' });
});
it('POST /mcp connects transport and handles request', async () => {
const res = await request(app)
.post('/mcp')
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
expect(res.status).toBe(200);
expect(createWebshareMcpServer).toHaveBeenCalledWith({ username: 'u', password: 'p' });
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
});
it('GET /mcp returns 405 with method-not-allowed body', async () => {
const res = await request(app).get('/mcp');
expect(res.status).toBe(405);
expect(res.body.error.message).toMatch(/Method not allowed/i);
});
it('DELETE /mcp returns 405', async () => {
const res = await request(app).delete('/mcp');
expect(res.status).toBe(405);
});
it('POST /mcp returns 500 JSON-RPC when connect throws and headers not sent', async () => {
createWebshareMcpServer.mockImplementation(() => ({
connect: jest.fn(async () => {
throw new Error('boom');
}),
close: jest.fn(),
}));
const res = await request(app).post('/mcp').send({});
expect(res.status).toBe(500);
expect(res.body.error.code).toBe(-32603);
});
it('uses default path /mcp when path option omitted', () => {
const app2 = express();
registerMcpHttp(app2, { username: 'a', password: 'b' });
const layer = app2._router.stack.find(
(l) => l.route && l.route.path === '/mcp' && l.route.methods.post
);
expect(layer).toBeTruthy();
});
it('skips 500 body when headers already sent after handler error', async () => {
StreamableHTTPServerTransport.mockImplementation(() => ({
handleRequest: jest.fn(async (req, res) => {
res.status(202).json({ partial: true });
throw new Error('late failure');
}),
close: jest.fn(),
}));
const res = await request(app).post('/mcp').send({});
// first write wins — not overwritten with 500
expect(res.status).toBe(202);
expect(res.body).toEqual({ partial: true });
});
});

100
__tests__/mcp-tools.test.js Normal file
View File

@@ -0,0 +1,100 @@
jest.mock('../src/webshare', () => {
const instances = [];
class WebshareClient {
constructor(username, password) {
this.username = username;
this.password = password;
this.guest = !username || !password;
this.ensureAuth = jest.fn(async () => {});
this.search = jest.fn(async () => []);
this.getFileLink = jest.fn(async () => 'https://cdn.example/file');
instances.push(this);
}
}
WebshareClient._instances = instances;
return { WebshareClient };
});
const { WebshareClient } = require('../src/webshare');
const { formatSize, createWebshareMcpServer } = require('../src/mcp-server');
function lastClient() {
return WebshareClient._instances[WebshareClient._instances.length - 1];
}
describe('formatSize', () => {
it('handles non-finite and large units', () => {
expect(formatSize(NaN)).toBe('0 B');
expect(formatSize(-1)).toBe('0 B');
expect(formatSize(1024 ** 3)).toBe('1.0 GB');
expect(formatSize(1024 ** 4)).toBe('1.0 TB');
expect(formatSize(512)).toBe('512 B');
});
});
describe('MCP tool handlers', () => {
beforeEach(() => {
WebshareClient._instances.length = 0;
});
it('webshare_search formats hits and empty results', async () => {
const server = createWebshareMcpServer({ username: 'u', password: 'p' });
const handler = server._registeredTools.webshare_search.handler;
const client = lastClient();
client.search.mockResolvedValueOnce([]);
let out = await handler({ query: 'nope' });
expect(out.content[0].text).toMatch(/No results/);
expect(out.structuredContent.count).toBe(0);
client.search.mockResolvedValueOnce([
{ name: 'Movie.mkv', ident: 'abc', size: 2048, votes: 3 },
]);
out = await handler({ query: 'movie', limit: 10, offset: 0, sort: 'recent', category: 'video' });
expect(out.content[0].text).toMatch(/Found 1 result/);
expect(out.content[0].text).toMatch(/ident: abc/);
expect(out.structuredContent.results[0].ident).toBe('abc');
});
it('webshare_search returns isError on failure', async () => {
const server = createWebshareMcpServer({ username: 'u', password: 'p' });
const handler = server._registeredTools.webshare_search.handler;
lastClient().ensureAuth.mockRejectedValueOnce(new Error('auth down'));
const out = await handler({ query: 'x' });
expect(out.isError).toBe(true);
expect(out.content[0].text).toMatch(/webshare_search failed/);
});
it('webshare_get_link resolves and surfaces errors', async () => {
const server = createWebshareMcpServer({ username: 'u', password: 'p' });
const handler = server._registeredTools.webshare_get_link.handler;
const client = lastClient();
let out = await handler({ ident: 'id1' });
expect(out.structuredContent.link).toBe('https://cdn.example/file');
expect(out.content[0].text).toMatch(/Resolved link/);
client.getFileLink.mockRejectedValueOnce(new Error('gone'));
out = await handler({ ident: 'id2' });
expect(out.isError).toBe(true);
expect(out.content[0].text).toMatch(/webshare_get_link failed/);
});
it('webshare_login_check guest / auth / error paths', async () => {
let server = createWebshareMcpServer({});
let handler = server._registeredTools.webshare_login_check.handler;
let out = await handler({});
expect(out.structuredContent.guest).toBe(true);
expect(out.content[0].text).toMatch(/guest mode/i);
server = createWebshareMcpServer({ username: 'u', password: 'p' });
handler = server._registeredTools.webshare_login_check.handler;
out = await handler({});
expect(out.structuredContent).toEqual({ ok: true, guest: false });
lastClient().ensureAuth.mockRejectedValueOnce(new Error('bad creds'));
out = await handler({});
expect(out.isError).toBe(true);
expect(out.structuredContent.ok).toBe(false);
});
});

View File

@@ -663,3 +663,219 @@ describe('download pipeline', () => {
expect(body[0].name).toBe('renamed.mkv');
});
});
// ── exported helpers + extra routes ──────────────────────────────────────────
describe('getQueue / enqueueDirect / files / re-hash', () => {
beforeEach(() => {
cleanState();
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
});
it('getQueue returns queue snapshot', async () => {
const { app, qbt } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/gq1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'gq.torrent');
const q = qbt.getQueue();
expect(q).toHaveLength(1);
expect(q[0]).toMatchObject({ name: 'gq', state: 'queuedDL' });
expect(q[0].path).toContain(tmpDir);
});
it('enqueueDirect stages a webshare download and rejects duplicates', () => {
process.env.BASE_URL = 'http://localhost:3001';
const { qbt } = makeApp();
expect(qbt.enqueueDirect('ident99', 'Show.S01E01.mkv', 1000)).toBe(true);
expect(qbt.enqueueDirect('ident99', 'Show.S01E01.mkv', 1000)).toBe(false);
const q = qbt.getQueue();
expect(q).toHaveLength(1);
expect(q[0].name).toBe('Show.S01E01.mkv');
expect(q[0].path).toContain('Show.S01E01.mkv');
});
it('GET /api/v2/torrents/files returns file list for known hash', async () => {
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/files1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'files.torrent');
const { body: items } = await request(app).get('/api/v2/torrents/info');
const hash = items[0].hash;
const res = await request(app).get(`/api/v2/torrents/files?hash=${hash}`);
expect(res.status).toBe(200);
expect(res.body[0].name).toBe('files');
expect(res.body[0].priority).toBe(1);
const missing = await request(app).get('/api/v2/torrents/files?hash=nope');
expect(missing.status).toBe(404);
});
it('re-maps existing entry when same webseed is re-added with different hash', async () => {
const { app } = makeApp();
const webseed = 'http://webshare-api:3001/stream/rehash1';
const t1 = makeTorrentBuf(webseed);
await request(app).post('/api/v2/torrents/add').attach('torrents', t1, 'old.torrent');
// Manually corrupt hash in state by adding via url first then torrent — add via url uses sha1(url)
await request(app).post('/api/v2/torrents/add').send(`urls=${webseed}`);
// second torrent add with same webseed should re-hash path, not create second download
await request(app).post('/api/v2/torrents/add').attach('torrents', t1, 'new.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
// one logical download for the webseed
const sameUrl = body.filter((b) => b.name === 'old' || b.name === 'new' || b.name === 'rehash1');
expect(sameUrl.length).toBeGreaterThanOrEqual(1);
// overall should not keep exploding
expect(body.length).toBeLessThanOrEqual(2);
});
it('cancelDownload returns false for unknown hash and true for queued', async () => {
const { app, qbt } = makeApp();
expect(qbt.cancelDownload('missing')).toBe(false);
expect(qbt.cancelDownload('')).toBe(false);
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/canc1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'c.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(qbt.cancelDownload(body[0].hash)).toBe(true);
expect((await request(app).get('/api/v2/torrents/info')).body).toHaveLength(0);
});
it('safeFileName strips path and unsafe characters via enqueueDirect', () => {
const { qbt } = makeApp();
qbt.enqueueDirect('x', '../../evil:name?.mkv', 1);
expect(qbt.getQueue()[0].name).toBe('evil_name_.mkv');
});
it('re-hashes entry when torrent re-add has same webseed but different info-hash name', async () => {
const { app } = makeApp();
const webseed = 'http://webshare-api:3001/stream/samews';
// First: add via URL (hash = sha1(url), name = ident)
await request(app).post('/api/v2/torrents/add').send(`urls=${webseed}`);
let { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
const oldHash = body[0].hash;
// Second: same webseed via .torrent → extractInfoHash differs from sha1(url)
const torrent = makeTorrentBuf(webseed);
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'samews.torrent');
({ body } = await request(app).get('/api/v2/torrents/info'));
expect(body).toHaveLength(1);
expect(body[0].hash).not.toBe(oldHash);
});
it('cancelDownload aborts active HTTP request', async () => {
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
const { EventEmitter } = require('events');
const httpMod = require('http');
const reqObj = { destroy: jest.fn(), on: jest.fn() };
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 200;
res.headers = { 'content-length': '1000000' };
res.destroy = jest.fn();
res.resume = jest.fn();
res.pipe = jest.fn(() => res);
// never finish — stays "downloading"
setImmediate(() => cb(res));
return reqObj;
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app, qbt } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/actcancel');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'ac.torrent');
await new Promise((r) => setTimeout(r, 50));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('downloading');
expect(qbt.cancelDownload(body[0].hash)).toBe(true);
expect(reqObj.destroy).toHaveBeenCalled();
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
it('follows 303 redirect then completes', async () => {
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
const { EventEmitter } = require('events');
const httpMod = require('http');
let call = 0;
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
call++;
const res = new EventEmitter();
if (call === 1) {
res.statusCode = 303;
res.headers = { location: 'http://cdn.example.com/f.mkv' };
res.resume = jest.fn();
setImmediate(() => cb(res));
} else {
res.statusCode = 200;
res.headers = { 'content-length': '4' };
res.destroy = jest.fn();
res.pipe = jest.fn((dest) => {
setImmediate(() => dest.emit('finish'));
return dest;
});
setImmediate(() => cb(res));
}
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app } = makeApp();
await request(app)
.post('/api/v2/torrents/add')
.attach('torrents', makeTorrentBuf('http://webshare-api:3001/stream/r303'), 'r.torrent');
await new Promise((r) => setTimeout(r, 120));
expect(call).toBe(2);
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
it('updates speed on data chunks during download', async () => {
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
const { EventEmitter } = require('events');
const httpMod = require('http');
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 200;
res.headers = { 'content-length': '10000' };
res.destroy = jest.fn();
res.resume = jest.fn();
res.pipe = jest.fn((dest) => {
setImmediate(() => {
res.emit('data', Buffer.alloc(3000));
res.emit('data', Buffer.alloc(3000));
dest.emit('finish');
});
return dest;
});
setImmediate(() => cb(res));
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/speed1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'sp.torrent');
await new Promise((r) => setTimeout(r, 150));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('uploading');
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
});

View File

@@ -1,4 +1,4 @@
const { makeTorrent, extractWebseed } = require('../src/torrent');
const { makeTorrent, extractWebseed, extractInfoHash } = require('../src/torrent');
describe('makeTorrent + extractWebseed round-trip', () => {
it('extracts the webseed URL back from a generated torrent', () => {
@@ -23,6 +23,16 @@ describe('makeTorrent + extractWebseed round-trip', () => {
const buf = makeTorrent({ name: 'movie.mkv', size: 500, webseedUrl: 'http://x.com/s/z' });
expect(buf.toString()).toContain('movie.mkv');
});
it('encodes multi-piece torrents for large sizes', () => {
const buf = makeTorrent({
name: 'big.mkv',
size: 512 * 1024 * 3,
webseedUrl: 'http://x.com/s/big',
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.length).toBeGreaterThan(60);
});
});
describe('extractWebseed', () => {
@@ -55,12 +65,42 @@ describe('extractWebseed', () => {
});
it('handles url-list as array', () => {
// Manually build a torrent-like bencode with url-list as list
const { makeTorrent: mt } = require('../src/torrent');
// We test via the actual makeTorrent which uses a string — round-trip test above covers it.
// Here just verify the array branch: if url-list is a Buffer (string) it still returns a string.
const url = 'http://cdn.example.com/stream/abcdef';
const buf = mt({ name: 't.mkv', size: 50, webseedUrl: url });
expect(typeof extractWebseed(buf)).toBe('string');
// d8:url-list l <len>:<url> e e
const buf = Buffer.from(`d8:url-listl${url.length}:${url}ee`);
expect(extractWebseed(buf)).toBe(url);
});
it('returns null for empty url-list array', () => {
const buf = Buffer.from('d8:url-listlee');
expect(extractWebseed(buf)).toBeNull();
});
});
describe('extractInfoHash', () => {
it('returns stable sha1 hex for a generated torrent', () => {
const buf = makeTorrent({ name: 't.mkv', size: 50, webseedUrl: 'http://x/s/a' });
const h = extractInfoHash(buf);
expect(h).toMatch(/^[0-9a-f]{40}$/);
expect(extractInfoHash(buf)).toBe(h);
});
it('returns null without info dict or on garbage', () => {
expect(extractInfoHash(Buffer.from('d3:fooi1ee'))).toBeNull();
expect(extractInfoHash(Buffer.from('not-bencode'))).toBeNull();
});
});
describe('bdecode edge cases via extractWebseed', () => {
it('decodes lists and rejects unclosed list', () => {
const url = 'http://a/b';
expect(extractWebseed(Buffer.from(`d8:url-listl${url.length}:${url}ee`))).toBe(url);
// unclosed list → catch → null
expect(extractWebseed(Buffer.from('d8:url-listl5:hello'))).toBeNull();
});
it('rejects bad string lengths and unclosed dicts', () => {
expect(extractWebseed(Buffer.from('d3:fo'))).toBeNull();
expect(extractWebseed(Buffer.from('d4:name5:hello'))).toBeNull();
});
});