diff --git a/__tests__/mcp-entry.test.js b/__tests__/mcp-entry.test.js new file mode 100644 index 0000000..7d2e239 --- /dev/null +++ b/__tests__/mcp-entry.test.js @@ -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(); + }); +}); diff --git a/__tests__/mcp-http.test.js b/__tests__/mcp-http.test.js new file mode 100644 index 0000000..873f573 --- /dev/null +++ b/__tests__/mcp-http.test.js @@ -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 }); + }); +}); diff --git a/__tests__/mcp-tools.test.js b/__tests__/mcp-tools.test.js new file mode 100644 index 0000000..93473fe --- /dev/null +++ b/__tests__/mcp-tools.test.js @@ -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); + }); +}); diff --git a/__tests__/qbt.test.js b/__tests__/qbt.test.js index 7e56612..4d3c6a4 100644 --- a/__tests__/qbt.test.js +++ b/__tests__/qbt.test.js @@ -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(); + }); +}); diff --git a/__tests__/torrent.test.js b/__tests__/torrent.test.js index 6207ff2..de7617e 100644 --- a/__tests__/torrent.test.js +++ b/__tests__/torrent.test.js @@ -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 : 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(); }); });