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:
88
__tests__/mcp-entry.test.js
Normal file
88
__tests__/mcp-entry.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
98
__tests__/mcp-http.test.js
Normal file
98
__tests__/mcp-http.test.js
Normal 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
100
__tests__/mcp-tools.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -663,3 +663,219 @@ describe('download pipeline', () => {
|
|||||||
expect(body[0].name).toBe('renamed.mkv');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { makeTorrent, extractWebseed } = require('../src/torrent');
|
const { makeTorrent, extractWebseed, extractInfoHash } = require('../src/torrent');
|
||||||
|
|
||||||
describe('makeTorrent + extractWebseed round-trip', () => {
|
describe('makeTorrent + extractWebseed round-trip', () => {
|
||||||
it('extracts the webseed URL back from a generated torrent', () => {
|
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' });
|
const buf = makeTorrent({ name: 'movie.mkv', size: 500, webseedUrl: 'http://x.com/s/z' });
|
||||||
expect(buf.toString()).toContain('movie.mkv');
|
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', () => {
|
describe('extractWebseed', () => {
|
||||||
@@ -55,12 +65,42 @@ describe('extractWebseed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles url-list as array', () => {
|
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 url = 'http://cdn.example.com/stream/abcdef';
|
||||||
const buf = mt({ name: 't.mkv', size: 50, webseedUrl: url });
|
// d8:url-list l <len>:<url> e e
|
||||||
expect(typeof extractWebseed(buf)).toBe('string');
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user