pulse/modules/fetcher/fetcher.test.ts

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);
});
});