212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
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, '<?xml version="1.0"?><rss></rss>', {
|
|
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('<?xml version="1.0"?><rss></rss>');
|
|
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, '<?xml version="1.0"?><feed></feed>', {
|
|
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('<?xml version="1.0"?><feed></feed>');
|
|
});
|
|
|
|
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, '<?xml version="1.0"?><rss></rss>', {
|
|
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, '<?xml version="1.0"?><rss></rss>', {
|
|
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, '<?xml version="1.0"?><rss></rss>', {
|
|
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);
|
|
});
|
|
});
|