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: `${inner}` });
}
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: 'id1Movie.mkv100000010',
});
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 elements
axios.post.mockResolvedValueOnce({
data: `OK
aA.mkv1001
bB.mkv2002
`,
});
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) =>
`id${i}f${i}.mkv1000`
).join('');
axios.post.mockResolvedValueOnce({ data: `OK${files}` });
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: 'xf.mkv' });
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
});
});
});