Initial commit: webshare-api
Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker packaging, MIT license, and tests.
This commit is contained in:
35
__tests__/md5crypt.test.js
Normal file
35
__tests__/md5crypt.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { passwordDigest } = require('../src/md5crypt');
|
||||
|
||||
describe('passwordDigest', () => {
|
||||
it('returns a 40-char hex string', () => {
|
||||
expect(passwordDigest('password', 'salt')).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it('is deterministic', () => {
|
||||
expect(passwordDigest('mypass', 'mysalt')).toBe(passwordDigest('mypass', 'mysalt'));
|
||||
});
|
||||
|
||||
it('differs for different passwords', () => {
|
||||
expect(passwordDigest('pass1', 'salt')).not.toBe(passwordDigest('pass2', 'salt'));
|
||||
});
|
||||
|
||||
it('differs for different salts', () => {
|
||||
expect(passwordDigest('pass', 'salt1')).not.toBe(passwordDigest('pass', 'salt2'));
|
||||
});
|
||||
|
||||
it('strips $1$ prefix from salt', () => {
|
||||
expect(passwordDigest('pass', '$1$salt')).toBe(passwordDigest('pass', 'salt'));
|
||||
});
|
||||
|
||||
it('truncates salt to 8 characters', () => {
|
||||
expect(passwordDigest('pass', 'salt1234extra')).toBe(passwordDigest('pass', 'salt1234'));
|
||||
});
|
||||
|
||||
it('handles empty password', () => {
|
||||
expect(passwordDigest('', 'salt')).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it('handles empty salt', () => {
|
||||
expect(passwordDigest('pass', '')).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
});
|
||||
658
__tests__/qbt.test.js
Normal file
658
__tests__/qbt.test.js
Normal file
@@ -0,0 +1,658 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { makeTorrent } = require('../src/torrent');
|
||||
|
||||
// ── shared temp dir ─────────────────────────────────────────────────────────
|
||||
let tmpDir;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qbt-test-'));
|
||||
process.env.DOWNLOAD_PATH = tmpDir;
|
||||
process.env.MEDIA_ROOT = tmpDir;
|
||||
process.env.MAX_CONCURRENT_DOWNLOADS = '0'; // no actual downloads
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Fresh qbt module + express app for each test (module-level state isolation)
|
||||
function makeApp() {
|
||||
let qbtModule;
|
||||
jest.isolateModules(() => {
|
||||
qbtModule = require('../src/qbt');
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.json());
|
||||
qbtModule.register(app);
|
||||
return { app, qbt: qbtModule };
|
||||
}
|
||||
|
||||
// Helper: clean state file between tests that check persistence
|
||||
function cleanState() {
|
||||
try { fs.unlinkSync(path.join(tmpDir, '.queue-state.json')); } catch {}
|
||||
}
|
||||
|
||||
// Helper: build a real .torrent buffer
|
||||
function makeTorrentBuf(webseedUrl = 'http://webshare-api:3001/stream/testident') {
|
||||
return makeTorrent({ name: 'test.mkv', size: 1000, webseedUrl });
|
||||
}
|
||||
|
||||
// ── auth endpoints ───────────────────────────────────────────────────────────
|
||||
describe('auth', () => {
|
||||
let app;
|
||||
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
it('POST /api/v2/auth/login → Ok.', async () => {
|
||||
const res = await request(app).post('/api/v2/auth/login').send('username=a&password=b');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('Ok.');
|
||||
});
|
||||
|
||||
it('GET /api/v2/auth/logout → Ok.', async () => {
|
||||
const res = await request(app).get('/api/v2/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('Ok.');
|
||||
});
|
||||
});
|
||||
|
||||
// ── app info endpoints ───────────────────────────────────────────────────────
|
||||
describe('app info', () => {
|
||||
let app;
|
||||
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
it('GET /api/v2/app/version → 5.0.0', async () => {
|
||||
expect((await request(app).get('/api/v2/app/version')).text).toBe('5.0.0');
|
||||
});
|
||||
|
||||
it('GET /api/v2/app/webapiVersion → 2.8.3', async () => {
|
||||
expect((await request(app).get('/api/v2/app/webapiVersion')).text).toBe('2.8.3');
|
||||
});
|
||||
|
||||
it('GET /api/v2/app/buildInfo → JSON with version fields', async () => {
|
||||
const res = await request(app).get('/api/v2/app/buildInfo');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ bitness: 64, libtorrent: '2.0.10' });
|
||||
});
|
||||
|
||||
it('GET /api/v2/app/preferences → JSON with save_path', async () => {
|
||||
const res = await request(app).get('/api/v2/app/preferences');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.save_path).toBe(tmpDir + '/');
|
||||
expect(res.body.temp_path_enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── torrent list / properties / categories ───────────────────────────────────
|
||||
describe('torrent list', () => {
|
||||
let app;
|
||||
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
it('GET /api/v2/torrents/info → empty array initially', async () => {
|
||||
const res = await request(app).get('/api/v2/torrents/info');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('GET /api/v2/torrents/properties → 404 for unknown hash', async () => {
|
||||
const res = await request(app).get('/api/v2/torrents/properties?hash=nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/v2/torrents/categories → returns webshare category', async () => {
|
||||
const res = await request(app).get('/api/v2/torrents/categories');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('webshare');
|
||||
expect(res.body.webshare.savePath).toBe(tmpDir + '/');
|
||||
});
|
||||
});
|
||||
|
||||
// ── add via .torrent upload ──────────────────────────────────────────────────
|
||||
describe('POST /api/v2/torrents/add (.torrent upload)', () => {
|
||||
let app;
|
||||
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
it('responds Ok. and item appears in info list', async () => {
|
||||
const torrent = makeTorrentBuf();
|
||||
const addRes = await request(app)
|
||||
.post('/api/v2/torrents/add')
|
||||
.attach('torrents', torrent, 'test.torrent');
|
||||
expect(addRes.status).toBe(200);
|
||||
expect(addRes.text).toBe('Ok.');
|
||||
|
||||
const infoRes = await request(app).get('/api/v2/torrents/info');
|
||||
expect(infoRes.body).toHaveLength(1);
|
||||
expect(infoRes.body[0].name).toBe('test');
|
||||
expect(infoRes.body[0].state).toBe('queuedDL');
|
||||
expect(infoRes.body[0].eta).toBe(9999);
|
||||
});
|
||||
|
||||
it('sets category from request body', async () => {
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/cattest');
|
||||
await request(app)
|
||||
.post('/api/v2/torrents/add')
|
||||
.field('category', 'webshare')
|
||||
.attach('torrents', torrent, 'cat.torrent');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].category).toBe('webshare');
|
||||
});
|
||||
|
||||
it('skips duplicate (same webseed URL)', async () => {
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/dup');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dup.torrent');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dup.torrent');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips torrent with no url-list', async () => {
|
||||
// A buffer that decodes to a dict without url-list
|
||||
const invalid = Buffer.from('d4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese', 'utf8');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', invalid, 'bad.torrent');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('item has correct save_path and content_path', async () => {
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/pathtest');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'path.torrent');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].save_path).toBe(tmpDir + '/');
|
||||
expect(body[0].content_path).toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('GET /api/v2/torrents/properties returns data for added item', async () => {
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/proptest');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'prop.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/properties?hash=${hash}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hash).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ── add via URL list (legacy fallback) ──────────────────────────────────────
|
||||
describe('POST /api/v2/torrents/add (url list)', () => {
|
||||
let app;
|
||||
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
it('adds item from url list', async () => {
|
||||
await request(app)
|
||||
.post('/api/v2/torrents/add')
|
||||
.send('urls=http://webshare-api:3001/stream/urltest1');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].name).toBe('urltest1');
|
||||
});
|
||||
|
||||
it('extracts ident from /download/ path', async () => {
|
||||
await request(app)
|
||||
.post('/api/v2/torrents/add')
|
||||
.send('urls=http://webshare-api:3001/download/myident99');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].name).toBe('myident99');
|
||||
});
|
||||
|
||||
it('skips duplicate URLs', async () => {
|
||||
const url = 'http://webshare-api:3001/stream/dup2';
|
||||
await request(app).post('/api/v2/torrents/add').send(`urls=${url}`);
|
||||
await request(app).post('/api/v2/torrents/add').send(`urls=${url}`);
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete ───────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/v2/torrents/delete', () => {
|
||||
let app;
|
||||
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
async function addAndGetHash(webseedUrl = 'http://webshare-api:3001/stream/deltest') {
|
||||
const torrent = makeTorrentBuf(webseedUrl);
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 't.torrent');
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
return body[0].hash;
|
||||
}
|
||||
|
||||
it('removes item from list', async () => {
|
||||
const hash = await addAndGetHash();
|
||||
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('responds Ok.', async () => {
|
||||
const hash = await addAndGetHash('http://webshare-api:3001/stream/delok');
|
||||
const res = await request(app).post('/api/v2/torrents/delete')
|
||||
.send(`hashes=${hash}&deleteFiles=false`);
|
||||
expect(res.text).toBe('Ok.');
|
||||
});
|
||||
|
||||
it('deleteFiles=true attempts fs.unlink on the file', async () => {
|
||||
const hash = await addAndGetHash('http://webshare-api:3001/stream/delfile');
|
||||
// Write a fake file so unlinkSync has something to delete
|
||||
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
|
||||
const fpath = path.join(tmpDir, name);
|
||||
fs.writeFileSync(fpath, 'fake data');
|
||||
|
||||
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=true`);
|
||||
|
||||
expect(fs.existsSync(fpath)).toBe(false);
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleteFiles=false does not delete file', async () => {
|
||||
const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf');
|
||||
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
|
||||
const fpath = path.join(tmpDir, name);
|
||||
fs.writeFileSync(fpath, 'keep me');
|
||||
|
||||
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
|
||||
|
||||
expect(fs.existsSync(fpath)).toBe(true);
|
||||
fs.unlinkSync(fpath);
|
||||
});
|
||||
|
||||
it('ignores unknown hashes gracefully', async () => {
|
||||
const res = await request(app).post('/api/v2/torrents/delete')
|
||||
.send('hashes=nonexistenthash&deleteFiles=false');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('Ok.');
|
||||
});
|
||||
});
|
||||
|
||||
// ── no-op control endpoints ──────────────────────────────────────────────────
|
||||
describe('no-op control endpoints', () => {
|
||||
let app;
|
||||
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||
|
||||
const endpoints = [
|
||||
'pause', 'resume', 'setCategory', 'setLocation',
|
||||
'rename', 'createCategory', 'editCategory', 'removeCategories',
|
||||
];
|
||||
for (const ep of endpoints) {
|
||||
it(`POST /api/v2/torrents/${ep} → Ok.`, async () => {
|
||||
const res = await request(app).post(`/api/v2/torrents/${ep}`).send('hashes=abc');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('Ok.');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── state persistence ────────────────────────────────────────────────────────
|
||||
describe('state persistence', () => {
|
||||
beforeEach(cleanState);
|
||||
|
||||
it('saveState writes JSON file and loadState restores queuedDL items', async () => {
|
||||
const { app: app1 } = makeApp();
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/persist1');
|
||||
await request(app1).post('/api/v2/torrents/add').attach('torrents', torrent, 't.torrent');
|
||||
|
||||
// State file should exist
|
||||
const stateFile = path.join(tmpDir, '.queue-state.json');
|
||||
expect(fs.existsSync(stateFile)).toBe(true);
|
||||
const state = JSON.parse(fs.readFileSync(stateFile));
|
||||
expect(state).toHaveLength(1);
|
||||
expect(state[0].state).toBe('queuedDL');
|
||||
expect(state[0].webseedUrl).toBe('http://webshare-api:3001/stream/persist1');
|
||||
});
|
||||
|
||||
it('loadState re-queues downloading items and deletes partial files', async () => {
|
||||
const webseedUrl = 'http://webshare-api:3001/stream/requeue1';
|
||||
const partialFile = path.join(tmpDir, 'partial.mkv');
|
||||
fs.writeFileSync(partialFile, 'partial data');
|
||||
|
||||
// Write a state file manually as if a download was in progress
|
||||
const state = [{ hash: 'abc123', name: 'partial.mkv', size: 0, progress: 0.5,
|
||||
state: 'downloading', category: '', webseedUrl }];
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||
|
||||
const { app: app2 } = makeApp();
|
||||
const { body } = await request(app2).get('/api/v2/torrents/info');
|
||||
// Should be re-queued
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].state).toBe('queuedDL');
|
||||
expect(body[0].progress).toBe(0);
|
||||
// Partial file should be deleted
|
||||
expect(fs.existsSync(partialFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('loadState restores uploading items when file exists', async () => {
|
||||
const fname = 'done.mkv';
|
||||
fs.writeFileSync(path.join(tmpDir, fname), 'complete file');
|
||||
const state = [{ hash: 'def456', name: fname, size: 100, progress: 1.0,
|
||||
state: 'uploading', category: '' }];
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||
|
||||
const { app: app3 } = makeApp();
|
||||
const { body } = await request(app3).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].state).toBe('uploading');
|
||||
expect(body[0].progress).toBe(1.0);
|
||||
|
||||
fs.unlinkSync(path.join(tmpDir, fname));
|
||||
});
|
||||
|
||||
it('loadState re-queues uploading items when file is missing', async () => {
|
||||
const webseedUrl = 'http://webshare-api:3001/stream/missingup';
|
||||
const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0,
|
||||
state: 'uploading', category: '', webseedUrl }];
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||
|
||||
const { app: app4 } = makeApp();
|
||||
const { body } = await request(app4).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].state).toBe('queuedDL');
|
||||
});
|
||||
|
||||
it('loadState re-queues error items', async () => {
|
||||
const webseedUrl = 'http://webshare-api:3001/stream/errretry';
|
||||
const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0,
|
||||
state: 'error', category: '', webseedUrl }];
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||
|
||||
const { app: app5 } = makeApp();
|
||||
const { body } = await request(app5).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].state).toBe('queuedDL');
|
||||
});
|
||||
|
||||
it('loadState preserves other states as-is', async () => {
|
||||
const state = [{ hash: 'mno111', name: 'other.mkv', size: 0, progress: 0,
|
||||
state: 'stalledUP', category: '' }];
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||
|
||||
const { app: app6 } = makeApp();
|
||||
const { body } = await request(app6).get('/api/v2/torrents/info');
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].state).toBe('stalledUP');
|
||||
});
|
||||
|
||||
it('loadState handles missing state file gracefully', () => {
|
||||
expect(() => {
|
||||
const { app: app7 } = makeApp();
|
||||
return app7; // just needs to not throw
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('loadState handles corrupt state file gracefully', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), 'not valid json{{{');
|
||||
expect(() => { makeApp(); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── cleanupStaging ───────────────────────────────────────────────────────────
|
||||
describe('cleanupStaging (runs on startup)', () => {
|
||||
beforeEach(cleanState);
|
||||
afterEach(cleanState);
|
||||
|
||||
it('deletes files with nlink > 1 immediately', () => {
|
||||
const fpath = path.join(tmpDir, 'hardlinked.mkv');
|
||||
fs.writeFileSync(fpath, 'data');
|
||||
// Create a hardlink so nlink > 1
|
||||
const link = path.join(tmpDir, 'hardlinked-link.mkv');
|
||||
fs.linkSync(fpath, link);
|
||||
|
||||
makeApp(); // triggers cleanupStaging
|
||||
|
||||
expect(fs.existsSync(fpath)).toBe(false);
|
||||
fs.unlinkSync(link); // cleanup the link
|
||||
});
|
||||
|
||||
it('deletes files older than 2 hours that are not active downloads', () => {
|
||||
const fpath = path.join(tmpDir, 'stale.mkv');
|
||||
fs.writeFileSync(fpath, 'old data');
|
||||
// Backdate mtime to 3 hours ago
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||
fs.utimesSync(fpath, threeHoursAgo, threeHoursAgo);
|
||||
|
||||
makeApp(); // triggers cleanupStaging
|
||||
|
||||
expect(fs.existsSync(fpath)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves files that are currently downloading', () => {
|
||||
const fname = 'active-download.mkv';
|
||||
const fpath = path.join(tmpDir, fname);
|
||||
fs.writeFileSync(fpath, 'partial');
|
||||
// Backdate to 3 hours ago — would normally be deleted
|
||||
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||
fs.utimesSync(fpath, old, old);
|
||||
|
||||
// Write state showing this file is actively downloading
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{
|
||||
hash: 'activehash', name: fname, size: 0, progress: 0.5,
|
||||
state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active',
|
||||
}]));
|
||||
|
||||
makeApp(); // triggers cleanupStaging
|
||||
|
||||
// File should still exist because it's in active download state
|
||||
expect(fs.existsSync(fpath)).toBe(true);
|
||||
fs.unlinkSync(fpath);
|
||||
});
|
||||
|
||||
it('preserves recent files (< 2 hours old)', () => {
|
||||
const fpath = path.join(tmpDir, 'recent.mkv');
|
||||
fs.writeFileSync(fpath, 'fresh data');
|
||||
// mtime is just now, well under 2 hours
|
||||
|
||||
makeApp();
|
||||
|
||||
expect(fs.existsSync(fpath)).toBe(true);
|
||||
fs.unlinkSync(fpath);
|
||||
});
|
||||
|
||||
it('skips dotfiles (like .queue-state.json)', () => {
|
||||
// State file must not be deleted even if it's old
|
||||
const stateContent = JSON.stringify([]);
|
||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), stateContent);
|
||||
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||
fs.utimesSync(path.join(tmpDir, '.queue-state.json'), old, old);
|
||||
|
||||
makeApp();
|
||||
|
||||
expect(fs.existsSync(path.join(tmpDir, '.queue-state.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── download pipeline (mocked http) ─────────────────────────────────────────
|
||||
describe('download pipeline', () => {
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
beforeEach(() => {
|
||||
cleanState();
|
||||
// Allow 1 concurrent download for these tests
|
||||
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('marks entry as uploading after successful download', async () => {
|
||||
// Mock http.get to write a small file
|
||||
const httpMod = require('http');
|
||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 200;
|
||||
res.headers = { 'content-length': '10' };
|
||||
res.resume = jest.fn();
|
||||
res.destroy = jest.fn();
|
||||
res.pipe = jest.fn((dest) => {
|
||||
setImmediate(() => {
|
||||
// Write actual content to the file so createWriteStream's finish fires
|
||||
dest.emit('finish');
|
||||
});
|
||||
return dest;
|
||||
});
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn() };
|
||||
});
|
||||
|
||||
// Also mock fs.createWriteStream to avoid writing real files here
|
||||
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/dltest');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dl.torrent');
|
||||
|
||||
// Wait for async download to complete
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].state).toBe('uploading');
|
||||
expect(body[0].progress).toBe(1.0);
|
||||
});
|
||||
|
||||
it('marks entry as error on HTTP failure', async () => {
|
||||
const httpMod = require('http');
|
||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 404;
|
||||
res.resume = jest.fn();
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn() };
|
||||
});
|
||||
|
||||
const { app } = makeApp();
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/err404');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'err.torrent');
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].state).toBe('error');
|
||||
});
|
||||
|
||||
it('retries on stalled download (ECONNRESET)', async () => {
|
||||
const httpMod = require('http');
|
||||
let callCount = 0;
|
||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||
callCount++;
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 200;
|
||||
res.headers = { 'content-length': '10' };
|
||||
res.resume = jest.fn();
|
||||
res.destroy = jest.fn();
|
||||
res.pipe = jest.fn((dest) => {
|
||||
setImmediate(() => {
|
||||
if (callCount === 1) {
|
||||
res.emit('error', new Error('ECONNRESET'));
|
||||
} else {
|
||||
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; })()
|
||||
);
|
||||
jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
||||
|
||||
const { app } = makeApp();
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/retry');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'retry.torrent');
|
||||
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
expect(callCount).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('follows HTTP redirects', async () => {
|
||||
const httpMod = require('http');
|
||||
let call = 0;
|
||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||
call++;
|
||||
const res = new EventEmitter();
|
||||
if (call === 1) {
|
||||
res.statusCode = 302;
|
||||
res.headers = { location: 'http://cdn.example.com/file.mkv' };
|
||||
res.resume = jest.fn();
|
||||
setImmediate(() => cb(res));
|
||||
} else {
|
||||
res.statusCode = 200;
|
||||
res.headers = { 'content-length': '5' };
|
||||
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();
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/redir');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'redir.torrent');
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
|
||||
expect(call).toBe(2); // original + redirected
|
||||
});
|
||||
|
||||
it('rejects on too many redirects', async () => {
|
||||
const httpMod = require('http');
|
||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 301;
|
||||
res.headers = { location: url }; // redirect to itself
|
||||
res.resume = jest.fn();
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn() };
|
||||
});
|
||||
|
||||
const { app } = makeApp();
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/loop');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'loop.torrent');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].state).toBe('error');
|
||||
});
|
||||
|
||||
it('updates filename from Content-Disposition header', async () => {
|
||||
const httpMod = require('http');
|
||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 200;
|
||||
res.headers = {
|
||||
'content-length': '10',
|
||||
'content-disposition': 'attachment; filename="renamed.mkv"',
|
||||
};
|
||||
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();
|
||||
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/rename');
|
||||
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'orig.torrent');
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||
expect(body[0].name).toBe('renamed.mkv');
|
||||
});
|
||||
});
|
||||
55
__tests__/torrent.test.js
Normal file
55
__tests__/torrent.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const { makeTorrent, extractWebseed } = require('../src/torrent');
|
||||
|
||||
describe('makeTorrent + extractWebseed round-trip', () => {
|
||||
it('extracts the webseed URL back from a generated torrent', () => {
|
||||
const url = 'http://example.com/stream/abc123';
|
||||
const buf = makeTorrent({ name: 'test.mkv', size: 1024 * 1024, webseedUrl: url });
|
||||
expect(extractWebseed(buf)).toBe(url);
|
||||
});
|
||||
|
||||
it('works with size 0', () => {
|
||||
const url = 'http://example.com/stream/xyz';
|
||||
const buf = makeTorrent({ name: 'empty.mkv', size: 0, webseedUrl: url });
|
||||
expect(extractWebseed(buf)).toBe(url);
|
||||
});
|
||||
|
||||
it('produces a Buffer', () => {
|
||||
const buf = makeTorrent({ name: 'f.mkv', size: 100, webseedUrl: 'http://x.com/s/y' });
|
||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('encodes name into the torrent', () => {
|
||||
const buf = makeTorrent({ name: 'movie.mkv', size: 500, webseedUrl: 'http://x.com/s/z' });
|
||||
expect(buf.toString()).toContain('movie.mkv');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractWebseed', () => {
|
||||
it('returns null for empty buffer', () => {
|
||||
expect(extractWebseed(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-torrent data', () => {
|
||||
expect(extractWebseed(Buffer.from('not a torrent'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when url-list is missing', () => {
|
||||
// Bencode a dict without url-list
|
||||
const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' });
|
||||
// Corrupt the url-list key to make it missing
|
||||
const str = buf.toString('binary').replace('url-list', 'url-xxxx');
|
||||
const corrupted = Buffer.from(str, 'binary');
|
||||
expect(extractWebseed(corrupted)).toBeNull();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
73
__tests__/torznab.test.js
Normal file
73
__tests__/torznab.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { caps, feed } = require('../src/torznab');
|
||||
|
||||
describe('caps()', () => {
|
||||
let result;
|
||||
beforeAll(() => { result = caps(); });
|
||||
|
||||
it('returns a string', () => expect(typeof result).toBe('string'));
|
||||
it('starts with XML declaration', () => expect(result).toMatch(/^<\?xml/));
|
||||
it('contains caps element', () => expect(result).toContain('<caps>'));
|
||||
it('advertises tv-search', () => expect(result).toContain('tv-search'));
|
||||
it('advertises movie-search', () => expect(result).toContain('movie-search'));
|
||||
it('has Movies category id 2000', () => expect(result).toContain('id="2000"'));
|
||||
it('has TV category id 5000', () => expect(result).toContain('id="5000"'));
|
||||
it('marks registration as not available', () => expect(result).toContain('available="no"'));
|
||||
});
|
||||
|
||||
describe('feed()', () => {
|
||||
const baseUrl = 'http://localhost:3001';
|
||||
|
||||
it('returns valid XML with no items', () => {
|
||||
const result = feed([], baseUrl);
|
||||
expect(result).toMatch(/^<\?xml/);
|
||||
expect(result).toContain('<channel>');
|
||||
expect(result).not.toContain('<item>');
|
||||
});
|
||||
|
||||
it('includes item title', () => {
|
||||
const result = feed([{ name: 'Show.S01E01.mkv', ident: 'abc', size: 1000, votes: 5 }], baseUrl);
|
||||
expect(result).toContain('<title>Show.S01E01.mkv</title>');
|
||||
});
|
||||
|
||||
it('includes correct download URL in enclosure', () => {
|
||||
const result = feed([{ name: 'f.mkv', ident: 'xyz789', size: 500, votes: 0 }], baseUrl);
|
||||
expect(result).toContain(`${baseUrl}/download/xyz789`);
|
||||
});
|
||||
|
||||
it('includes file size in enclosure', () => {
|
||||
const result = feed([{ name: 'f.mkv', ident: 'id1', size: 123456, votes: 0 }], baseUrl);
|
||||
expect(result).toContain('length="123456"');
|
||||
});
|
||||
|
||||
it('escapes & in title', () => {
|
||||
const result = feed([{ name: 'Tom & Jerry.mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||
expect(result).toContain('Tom & Jerry.mkv');
|
||||
expect(result).not.toContain('Tom & Jerry');
|
||||
});
|
||||
|
||||
it('escapes < and > in name', () => {
|
||||
const result = feed([{ name: '<bad>.mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||
expect(result).toContain('<bad>.mkv');
|
||||
});
|
||||
|
||||
it('escapes " in name', () => {
|
||||
const result = feed([{ name: '"quoted".mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||
expect(result).toContain('"quoted".mkv');
|
||||
});
|
||||
|
||||
it('includes torznab:attr for category 5040', () => {
|
||||
const result = feed([{ name: 'f.mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||
expect(result).toContain('name="category" value="5040"');
|
||||
});
|
||||
|
||||
it('handles multiple items', () => {
|
||||
const items = [
|
||||
{ name: 'ep1.mkv', ident: 'a', size: 100, votes: 1 },
|
||||
{ name: 'ep2.mkv', ident: 'b', size: 200, votes: 2 },
|
||||
];
|
||||
const result = feed(items, baseUrl);
|
||||
expect(result).toContain('ep1.mkv');
|
||||
expect(result).toContain('ep2.mkv');
|
||||
expect((result.match(/<item>/g) || []).length).toBe(2);
|
||||
});
|
||||
});
|
||||
173
__tests__/webshare.test.js
Normal file
173
__tests__/webshare.test.js
Normal file
@@ -0,0 +1,173 @@
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
const { WebshareClient } = require('../src/webshare');
|
||||
|
||||
// Helper: make axios.post return a parsed XML body
|
||||
function mockResponse(fields) {
|
||||
const inner = Object.entries(fields).map(([k, v]) => `<${k}>${v}</${k}>`).join('');
|
||||
axios.post.mockResolvedValueOnce({ data: `<response>${inner}</response>` });
|
||||
}
|
||||
|
||||
describe('WebshareClient', () => {
|
||||
let client;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client = new WebshareClient('user', 'pass');
|
||||
});
|
||||
|
||||
// ── ensureAuth / _login ─────────────────────────────────────────────────
|
||||
|
||||
describe('ensureAuth()', () => {
|
||||
it('calls login and stores token', async () => {
|
||||
mockResponse({ status: 'OK', salt: 'abcdefgh' });
|
||||
mockResponse({ status: 'OK', token: 'tok123' });
|
||||
await client.ensureAuth();
|
||||
expect(client.wst).toBe('tok123');
|
||||
});
|
||||
|
||||
it('is a no-op if token already set', async () => {
|
||||
client.wst = 'existing-token';
|
||||
await client.ensureAuth();
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates concurrent calls (only one login)', async () => {
|
||||
mockResponse({ status: 'OK', salt: 'salthash' });
|
||||
mockResponse({ status: 'OK', token: 'tok456' });
|
||||
await Promise.all([client.ensureAuth(), client.ensureAuth(), client.ensureAuth()]);
|
||||
// salt + login = 2 calls, not 6
|
||||
expect(axios.post).toHaveBeenCalledTimes(2);
|
||||
expect(client.wst).toBe('tok456');
|
||||
});
|
||||
|
||||
it('throws when salt request fails', async () => {
|
||||
mockResponse({ status: 'FATAL', message: 'User not found' });
|
||||
await expect(client.ensureAuth()).rejects.toThrow('salt() failed: User not found');
|
||||
});
|
||||
|
||||
it('throws when login request fails', async () => {
|
||||
mockResponse({ status: 'OK', salt: 'abcdefgh' });
|
||||
mockResponse({ status: 'FATAL', message: 'Wrong password' });
|
||||
await expect(client.ensureAuth()).rejects.toThrow('login() failed: Wrong password');
|
||||
});
|
||||
|
||||
it('clears _loginPromise after completion', async () => {
|
||||
mockResponse({ status: 'OK', salt: 'abc12345' });
|
||||
mockResponse({ status: 'OK', token: 'tok789' });
|
||||
await client.ensureAuth();
|
||||
expect(client._loginPromise).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── search ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('search()', () => {
|
||||
it('returns mapped file list', async () => {
|
||||
mockResponse({
|
||||
status: 'OK',
|
||||
file: '<ident>id1</ident><name>Movie.mkv</name><size>1000000</size><positive_votes>10</positive_votes>',
|
||||
});
|
||||
const results = await client.search('Movie');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toEqual({ ident: 'id1', name: 'Movie.mkv', size: 1000000, votes: 10 });
|
||||
});
|
||||
|
||||
it('returns empty array when no files', async () => {
|
||||
mockResponse({ status: 'OK' });
|
||||
const results = await client.search('nothing');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles array of files', async () => {
|
||||
// xml2js returns an array when there are multiple <file> elements
|
||||
axios.post.mockResolvedValueOnce({
|
||||
data: `<response><status>OK</status>
|
||||
<file><ident>a</ident><name>A.mkv</name><size>100</size><positive_votes>1</positive_votes></file>
|
||||
<file><ident>b</ident><name>B.mkv</name><size>200</size><positive_votes>2</positive_votes></file>
|
||||
</response>`,
|
||||
});
|
||||
const results = await client.search('test');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].ident).toBe('a');
|
||||
expect(results[1].ident).toBe('b');
|
||||
});
|
||||
|
||||
it('respects limit parameter', async () => {
|
||||
// Build 5 files
|
||||
const files = Array.from({ length: 5 }, (_, i) =>
|
||||
`<file><ident>id${i}</ident><name>f${i}.mkv</name><size>100</size><positive_votes>0</positive_votes></file>`
|
||||
).join('');
|
||||
axios.post.mockResolvedValueOnce({ data: `<response><status>OK</status>${files}</response>` });
|
||||
const results = await client.search('test', { limit: 3 });
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('includes wst in request when authenticated', async () => {
|
||||
client.wst = 'mytoken';
|
||||
mockResponse({ status: 'OK' });
|
||||
await client.search('q');
|
||||
const body = new URLSearchParams(axios.post.mock.calls[0][1]);
|
||||
expect(body.get('wst')).toBe('mytoken');
|
||||
});
|
||||
|
||||
it('omits wst when not authenticated', async () => {
|
||||
mockResponse({ status: 'OK' });
|
||||
await client.search('q');
|
||||
const body = new URLSearchParams(axios.post.mock.calls[0][1]);
|
||||
expect(body.get('wst')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to missing size/votes as 0', async () => {
|
||||
mockResponse({ status: 'OK', file: '<ident>x</ident><name>f.mkv</name>' });
|
||||
const [result] = await client.search('q');
|
||||
expect(result.size).toBe(0);
|
||||
expect(result.votes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getFileLink ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getFileLink()', () => {
|
||||
it('returns link on success', async () => {
|
||||
client.wst = 'tok';
|
||||
mockResponse({ status: 'OK', link: 'https://cdn.example.com/file.mkv' });
|
||||
const link = await client.getFileLink('ident123');
|
||||
expect(link).toBe('https://cdn.example.com/file.mkv');
|
||||
});
|
||||
|
||||
it('re-authenticates and retries when token is expired', async () => {
|
||||
client.wst = 'expired-token';
|
||||
// First file_link call fails (token expired)
|
||||
mockResponse({ status: 'FATAL', message: 'Token invalid' });
|
||||
// Re-login
|
||||
mockResponse({ status: 'OK', salt: 'newsalt1' });
|
||||
mockResponse({ status: 'OK', token: 'fresh-token' });
|
||||
// Retry file_link succeeds
|
||||
mockResponse({ status: 'OK', link: 'https://cdn.example.com/fresh.mkv' });
|
||||
|
||||
const link = await client.getFileLink('ident456');
|
||||
expect(link).toBe('https://cdn.example.com/fresh.mkv');
|
||||
expect(client.wst).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('throws when both attempts fail', async () => {
|
||||
client.wst = 'tok';
|
||||
mockResponse({ status: 'FATAL', message: 'File unavailable' });
|
||||
mockResponse({ status: 'OK', salt: 'salt123a' });
|
||||
mockResponse({ status: 'OK', token: 'tok2' });
|
||||
mockResponse({ status: 'FATAL', message: 'File unavailable' });
|
||||
await expect(client.getFileLink('badident')).rejects.toThrow('file_link() failed: File unavailable');
|
||||
});
|
||||
|
||||
it('calls ensureAuth before requesting link', async () => {
|
||||
// client.wst is null — must login first
|
||||
mockResponse({ status: 'OK', salt: 'saltsalt' });
|
||||
mockResponse({ status: 'OK', token: 'tok-new' });
|
||||
mockResponse({ status: 'OK', link: 'https://cdn.example.com/x.mkv' });
|
||||
const link = await client.getFileLink('identX');
|
||||
expect(link).toBe('https://cdn.example.com/x.mkv');
|
||||
expect(axios.post).toHaveBeenCalledTimes(3); // salt + login + file_link
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user