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}`).join(''); axios.post.mockResolvedValueOnce({ data: `${inner}` }); } 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: '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() 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 }); }); });