import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { MockAgent } from 'undici'; import { HttpFetcher } from './fetcher.js'; describe('HttpFetcher', () => { let mockAgent: MockAgent; let fetcher: HttpFetcher; beforeEach(() => { mockAgent = new MockAgent(); mockAgent.disableNetConnect(); fetcher = new HttpFetcher({ timeout: 5000, dispatcher: mockAgent }); }); afterEach(async () => { await mockAgent.close(); }); it('returns response for valid RSS feed', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/feed.xml', method: 'GET' }) .reply(200, '', { headers: { 'content-type': 'application/rss+xml' }, }); const result = await fetcher.fetch({ url: 'https://example.com/feed.xml', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(1); expect(result.errors).toHaveLength(0); expect(result.responses[0].body).toBe(''); expect(result.responses[0].contentType).toBe('application/rss+xml'); expect(result.responses[0].statusCode).toBe(200); }); it('returns response for valid Atom feed', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/atom.xml', method: 'GET' }) .reply(200, '', { headers: { 'content-type': 'application/atom+xml' }, }); const result = await fetcher.fetch({ url: 'https://example.com/atom.xml', expectedFormat: 'atom', }); expect(result.responses).toHaveLength(1); expect(result.errors).toHaveLength(0); expect(result.responses[0].body).toBe(''); }); it('returns PARSE error for wrong content type', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/feed.json', method: 'GET' }) .reply(200, '{"foo":"bar"}', { headers: { 'content-type': 'application/json' }, }); const result = await fetcher.fetch({ url: 'https://example.com/feed.json', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0].code).toBe('PARSE'); expect(result.errors[0].reason).toContain('Unexpected content type'); }); it('returns PARSE error when body does not look like XML', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/plain.txt', method: 'GET' }) .reply(200, 'not xml at all', { headers: { 'content-type': 'text/plain' }, }); const result = await fetcher.fetch({ url: 'https://example.com/plain.txt', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0].code).toBe('PARSE'); }); it('returns UNKNOWN error for non-2xx status', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/notfound.xml', method: 'GET' }) .reply(404, 'Not Found', { headers: { 'content-type': 'text/plain' }, }); const result = await fetcher.fetch({ url: 'https://example.com/notfound.xml', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0].code).toBe('UNKNOWN'); expect(result.errors[0].reason).toContain('404'); }); it('returns NETWORK error for connection refused', async () => { const mockPool = mockAgent.get('https://offline.com'); const networkError = new Error('connect ECONNREFUSED 127.0.0.1:443'); mockPool.intercept({ path: '/feed.xml', method: 'GET' }).replyWithError(networkError); const result = await fetcher.fetch({ url: 'https://offline.com/feed.xml', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0].code).toBe('NETWORK'); }); it('returns TIMEOUT error for abort error', async () => { const mockPool = mockAgent.get('https://slow.com'); const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; mockPool.intercept({ path: '/feed.xml', method: 'GET' }).replyWithError(abortError); const result = await fetcher.fetch({ url: 'https://slow.com/feed.xml', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0].code).toBe('TIMEOUT'); }); it('uses body sniff fallback when content-type is missing', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/feed.xml', method: 'GET' }) .reply(200, '', { headers: {}, }); const result = await fetcher.fetch({ url: 'https://example.com/feed.xml', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(1); expect(result.errors).toHaveLength(0); expect(result.responses[0].contentType).toBe('application/xml'); }); it('accepts generic application/xml for RSS', async () => { const mockPool = mockAgent.get('https://example.com'); mockPool .intercept({ path: '/feed.xml', method: 'GET' }) .reply(200, '', { headers: { 'content-type': 'application/xml' }, }); const result = await fetcher.fetch({ url: 'https://example.com/feed.xml', expectedFormat: 'rss', }); expect(result.responses).toHaveLength(1); expect(result.errors).toHaveLength(0); }); it('processes multiple feeds with mixed results via fetchMany', async () => { const okPool = mockAgent.get('https://ok.com'); okPool .intercept({ path: '/feed.xml', method: 'GET' }) .reply(200, '', { headers: { 'content-type': 'application/rss+xml' }, }); const failPool = mockAgent.get('https://fail.com'); failPool .intercept({ path: '/feed.xml', method: 'GET' }) .reply(500, 'Internal Server Error', { headers: { 'content-type': 'text/plain' }, }); const result = await fetcher.fetchMany([ { url: 'https://ok.com/feed.xml', expectedFormat: 'rss' }, { url: 'https://fail.com/feed.xml', expectedFormat: 'rss' }, ]); expect(result.responses).toHaveLength(1); expect(result.errors).toHaveLength(1); expect(result.responses[0].source).toBe('https://ok.com/feed.xml'); expect(result.errors[0].source).toBe('https://fail.com/feed.xml'); }); it('returns empty result for empty input array', async () => { const result = await fetcher.fetchMany([]); expect(result.responses).toHaveLength(0); expect(result.errors).toHaveLength(0); }); });