Credentials optional: empty WEBSHARE_* uses file_link without wst (free CDN). Verified against live API; docs and tests updated. Includes cancel→dequeue fix for queued downloads.
212 lines
8.7 KiB
JavaScript
212 lines
8.7 KiB
JavaScript
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');
|
|
});
|
|
|
|
describe('guest mode', () => {
|
|
it('marks guest when username/password missing', () => {
|
|
expect(new WebshareClient('', '').guest).toBe(true);
|
|
expect(new WebshareClient(undefined, undefined).guest).toBe(true);
|
|
expect(new WebshareClient('u', '').guest).toBe(true);
|
|
expect(new WebshareClient('', 'p').guest).toBe(true);
|
|
expect(new WebshareClient('u', 'p').guest).toBe(false);
|
|
});
|
|
|
|
it('ensureAuth is a no-op in guest mode', async () => {
|
|
const guest = new WebshareClient('', '');
|
|
await guest.ensureAuth();
|
|
expect(axios.post).not.toHaveBeenCalled();
|
|
expect(guest.wst).toBeNull();
|
|
});
|
|
|
|
it('getFileLink works without login and omits wst', async () => {
|
|
const guest = new WebshareClient();
|
|
mockResponse({ status: 'OK', link: 'https://free.1.dl.wsfiles.cz/file.mkv' });
|
|
const link = await guest.getFileLink('identGuest');
|
|
expect(link).toBe('https://free.1.dl.wsfiles.cz/file.mkv');
|
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
|
const [url, body] = axios.post.mock.calls[0];
|
|
expect(url).toContain('file_link');
|
|
const params = new URLSearchParams(body);
|
|
expect(params.get('ident')).toBe('identGuest');
|
|
expect(params.get('wst')).toBeNull();
|
|
expect(params.get('download_type')).toBe('file_download');
|
|
});
|
|
|
|
it('getFileLink does not re-login when guest request fails', async () => {
|
|
const guest = new WebshareClient('', '');
|
|
mockResponse({ status: 'FATAL', message: 'File not found' });
|
|
await expect(guest.getFileLink('missing')).rejects.toThrow('file_link() FATAL: File not found');
|
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ── 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() FATAL: 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
|
|
});
|
|
});
|
|
});
|