Initial commit: webshare-api
Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker packaging, MIT license, and tests.
This commit is contained in:
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