FeedReaderService
This commit is contained in:
		
							
								
								
									
										108
									
								
								src/__tests__/FeedReaderService.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/__tests__/FeedReaderService.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
import { FeedReaderService } from '../services/FeedReaderService';
 | 
			
		||||
import { IFeedRepository } from '../repositories/FeedRepository';
 | 
			
		||||
import { NewsSource } from '../types/Feed';
 | 
			
		||||
 | 
			
		||||
// Mock dependencies
 | 
			
		||||
jest.mock('../utils/logger');
 | 
			
		||||
jest.mock('../services/ScrapingService');
 | 
			
		||||
jest.mock('../utils/WebScraper');
 | 
			
		||||
jest.mock('../extractors/ElPaisExtractor');
 | 
			
		||||
jest.mock('../extractors/ElMundoExtractor');
 | 
			
		||||
 | 
			
		||||
// Mock fetch globally
 | 
			
		||||
global.fetch = jest.fn();
 | 
			
		||||
 | 
			
		||||
const mockFeedRepository: jest.Mocked<IFeedRepository> = {
 | 
			
		||||
  create: jest.fn(),
 | 
			
		||||
  findAll: jest.fn(),
 | 
			
		||||
  findById: jest.fn(),
 | 
			
		||||
  findByUrl: jest.fn(),
 | 
			
		||||
  update: jest.fn(),
 | 
			
		||||
  delete: jest.fn(),
 | 
			
		||||
  findBySource: jest.fn(),
 | 
			
		||||
  findTodaysFrontPage: jest.fn(),
 | 
			
		||||
  deleteMany: jest.fn(),
 | 
			
		||||
  count: jest.fn(),
 | 
			
		||||
  exists: jest.fn()
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Mock ScrapingService
 | 
			
		||||
const mockScrapingService = {
 | 
			
		||||
  processFeedBatch: jest.fn()
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
jest.mock('../services/ScrapingService', () => {
 | 
			
		||||
  return {
 | 
			
		||||
    ScrapingService: jest.fn().mockImplementation(() => mockScrapingService)
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Mock WebScraper
 | 
			
		||||
const mockWebScraper = {
 | 
			
		||||
  scrapeUrl: jest.fn(),
 | 
			
		||||
  convertToFeedData: jest.fn()
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
jest.mock('../utils/WebScraper', () => {
 | 
			
		||||
  return {
 | 
			
		||||
    WebScraper: jest.fn().mockImplementation(() => mockWebScraper)
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Mock extractors
 | 
			
		||||
const mockExtractor = {
 | 
			
		||||
  extractNews: jest.fn(),
 | 
			
		||||
  isEnabled: jest.fn().mockReturnValue(true),
 | 
			
		||||
  getName: jest.fn(),
 | 
			
		||||
  getSource: jest.fn()
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const mockElPaisExtractor = {
 | 
			
		||||
  ...mockExtractor,
 | 
			
		||||
  getName: jest.fn().mockReturnValue('El País'),
 | 
			
		||||
  getSource: jest.fn().mockReturnValue(NewsSource.EL_PAIS)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const mockElMundoExtractor = {
 | 
			
		||||
  ...mockExtractor,
 | 
			
		||||
  getName: jest.fn().mockReturnValue('El Mundo'),
 | 
			
		||||
  getSource: jest.fn().mockReturnValue(NewsSource.EL_MUNDO)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
jest.mock('../extractors/NewspaperExtractorFactory', () => ({
 | 
			
		||||
  NewspaperExtractorFactory: {
 | 
			
		||||
    getAllAvailableExtractors: jest.fn(() => [mockElPaisExtractor, mockElMundoExtractor]),
 | 
			
		||||
    createExtractor: jest.fn((source) => {
 | 
			
		||||
      if (source === NewsSource.EL_PAIS) return mockElPaisExtractor;
 | 
			
		||||
      if (source === NewsSource.EL_MUNDO) return mockElMundoExtractor;
 | 
			
		||||
      return null;
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
describe('FeedReaderService', () => {
 | 
			
		||||
  let feedReaderService: FeedReaderService;
 | 
			
		||||
  const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    jest.clearAllMocks();
 | 
			
		||||
    feedReaderService = new FeedReaderService(mockFeedRepository);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('Constructor and Initialization', () => {
 | 
			
		||||
    it('should initialize with available extractors', () => {
 | 
			
		||||
      const newspapers = feedReaderService.getAvailableNewspapers();
 | 
			
		||||
      expect(newspapers).toHaveLength(2);
 | 
			
		||||
      expect(newspapers.map(n => n.source)).toContain(NewsSource.EL_PAIS);
 | 
			
		||||
      expect(newspapers.map(n => n.source)).toContain(NewsSource.EL_MUNDO);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should have all extractors enabled by default', () => {
 | 
			
		||||
      const newspapers = feedReaderService.getAvailableNewspapers();
 | 
			
		||||
      newspapers.forEach(newspaper => {
 | 
			
		||||
        expect(newspaper.enabled).toBe(true);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										193
									
								
								src/services/FeedReaderService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								src/services/FeedReaderService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,193 @@
 | 
			
		||||
import { ScrapingService } from './ScrapingService';
 | 
			
		||||
import { IFeed, NewsSource } from '../types/Feed';
 | 
			
		||||
import { IFeedRepository } from '../repositories/FeedRepository';
 | 
			
		||||
import { Logger } from '../utils/logger';
 | 
			
		||||
import { BaseNewspaperExtractor } from '../extractors/BaseNewspaperExtractor';
 | 
			
		||||
import { NewspaperExtractorFactory } from '../extractors/NewspaperExtractorFactory';
 | 
			
		||||
import { ScrapingResult } from '../types/NewspaperTypes';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Servicio principal de lectura de feeds mediante web scraping
 | 
			
		||||
 */
 | 
			
		||||
export class FeedReaderService {
 | 
			
		||||
  private scrapingService: ScrapingService;
 | 
			
		||||
  private extractors: Map<NewsSource, BaseNewspaperExtractor>;
 | 
			
		||||
 | 
			
		||||
  constructor(feedRepository: IFeedRepository) {
 | 
			
		||||
    this.scrapingService = new ScrapingService(feedRepository);
 | 
			
		||||
    this.extractors = new Map();
 | 
			
		||||
    this.initializeExtractors();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Inicializa todos los extractores disponibles
 | 
			
		||||
   */
 | 
			
		||||
  private initializeExtractors(): void {
 | 
			
		||||
    const availableExtractors = NewspaperExtractorFactory.getAllAvailableExtractors();
 | 
			
		||||
    
 | 
			
		||||
    for (const extractor of availableExtractors) {
 | 
			
		||||
      this.extractors.set(extractor.getSource(), extractor);
 | 
			
		||||
      Logger.info(`Initialized extractor for ${extractor.getName()}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Extrae noticias de un periódico específico
 | 
			
		||||
   */
 | 
			
		||||
  async extractFromNewspaper(source: NewsSource): Promise<ScrapingResult> {
 | 
			
		||||
    const extractor = this.extractors.get(source);
 | 
			
		||||
    
 | 
			
		||||
    if (!extractor) {
 | 
			
		||||
      const error = `No extractor found for source: ${source}`;
 | 
			
		||||
      Logger.error(error);
 | 
			
		||||
      return {
 | 
			
		||||
        success: 0,
 | 
			
		||||
        failed: 1,
 | 
			
		||||
        duplicates: 0,
 | 
			
		||||
        items: [],
 | 
			
		||||
        errors: [error]
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!extractor.isEnabled()) {
 | 
			
		||||
      Logger.info(`Skipping disabled extractor: ${extractor.getName()}`);
 | 
			
		||||
      return {
 | 
			
		||||
        success: 0,
 | 
			
		||||
        failed: 0,
 | 
			
		||||
        duplicates: 0,
 | 
			
		||||
        items: [],
 | 
			
		||||
        errors: []
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      Logger.info(`Starting extraction for ${extractor.getName()}`);
 | 
			
		||||
      const newsItems = await extractor.extractNews();
 | 
			
		||||
      
 | 
			
		||||
      if (newsItems.length === 0) {
 | 
			
		||||
        Logger.warn(`No news items extracted for ${extractor.getName()}`);
 | 
			
		||||
        return {
 | 
			
		||||
          success: 0,
 | 
			
		||||
          failed: 0,
 | 
			
		||||
          duplicates: 0,
 | 
			
		||||
          items: [],
 | 
			
		||||
          errors: []
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const results = await this.scrapingService.processFeedBatch(newsItems);
 | 
			
		||||
      const analyzed = this.analyzeResults(results);
 | 
			
		||||
      
 | 
			
		||||
      Logger.info(`Completed extraction for ${extractor.getName()}: ${analyzed.success} success, ${analyzed.failed} failed, ${analyzed.duplicates} duplicates`);
 | 
			
		||||
      return analyzed;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      const errorMsg = `Error extracting from ${extractor.getName()}: ${error}`;
 | 
			
		||||
      Logger.error(errorMsg);
 | 
			
		||||
      return {
 | 
			
		||||
        success: 0,
 | 
			
		||||
        failed: 1,
 | 
			
		||||
        duplicates: 0,
 | 
			
		||||
        items: [],
 | 
			
		||||
        errors: [errorMsg]
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Extrae noticias de todos los periódicos disponibles
 | 
			
		||||
   */
 | 
			
		||||
  async extractFromAllNewspapers(): Promise<Map<NewsSource, ScrapingResult>> {
 | 
			
		||||
    Logger.info(`Starting batch extraction from ${this.extractors.size} newspapers`);
 | 
			
		||||
    const results = new Map<NewsSource, ScrapingResult>();
 | 
			
		||||
 | 
			
		||||
    for (const [source, extractor] of this.extractors) {
 | 
			
		||||
      if (extractor.isEnabled()) {
 | 
			
		||||
        const result = await this.extractFromNewspaper(source);
 | 
			
		||||
        results.set(source, result);
 | 
			
		||||
      } else {
 | 
			
		||||
        Logger.info(`Skipping disabled newspaper: ${extractor.getName()}`);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const totalStats = this.calculateTotalStats(results);
 | 
			
		||||
    Logger.info(`Batch extraction completed: ${totalStats.success} total success, ${totalStats.failed} total failed, ${totalStats.duplicates} total duplicates`);
 | 
			
		||||
    
 | 
			
		||||
    return results;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Obtiene la lista de periódicos disponibles
 | 
			
		||||
   */
 | 
			
		||||
  getAvailableNewspapers(): { source: NewsSource; name: string; enabled: boolean }[] {
 | 
			
		||||
    const newspapers: { source: NewsSource; name: string; enabled: boolean }[] = [];
 | 
			
		||||
    
 | 
			
		||||
    for (const [source, extractor] of this.extractors) {
 | 
			
		||||
      newspapers.push({
 | 
			
		||||
        source,
 | 
			
		||||
        name: extractor.getName(),
 | 
			
		||||
        enabled: extractor.isEnabled()
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return newspapers;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Habilita o deshabilita un extractor específico
 | 
			
		||||
   */
 | 
			
		||||
  setExtractorEnabled(source: NewsSource, enabled: boolean): boolean {
 | 
			
		||||
    const extractor = this.extractors.get(source);
 | 
			
		||||
    if (!extractor) {
 | 
			
		||||
      Logger.error(`Cannot set enabled state: No extractor found for source ${source}`);
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Nota: En una implementación real, esto podría modificar la configuración
 | 
			
		||||
    // Por ahora, solo registramos el cambio
 | 
			
		||||
    Logger.info(`${enabled ? 'Enabled' : 'Disabled'} extractor for ${extractor.getName()}`);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Analiza los resultados del procesamiento
 | 
			
		||||
   */
 | 
			
		||||
  private analyzeResults(results: (IFeed | null)[]): ScrapingResult {
 | 
			
		||||
    const success = results.filter(item => item !== null).length;
 | 
			
		||||
    const failed = results.filter(item => item === null).length;
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
      success,
 | 
			
		||||
      failed,
 | 
			
		||||
      duplicates: 0, // El ScrapingService maneja duplicados internamente
 | 
			
		||||
      items: results,
 | 
			
		||||
      errors: []
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Calcula estadísticas totales de múltiples resultados
 | 
			
		||||
   */
 | 
			
		||||
  private calculateTotalStats(results: Map<NewsSource, ScrapingResult>): ScrapingResult {
 | 
			
		||||
    let totalSuccess = 0;
 | 
			
		||||
    let totalFailed = 0;
 | 
			
		||||
    let totalDuplicates = 0;
 | 
			
		||||
    const allItems: (IFeed | null)[] = [];
 | 
			
		||||
    const allErrors: string[] = [];
 | 
			
		||||
 | 
			
		||||
    for (const result of results.values()) {
 | 
			
		||||
      totalSuccess += result.success;
 | 
			
		||||
      totalFailed += result.failed;
 | 
			
		||||
      totalDuplicates += result.duplicates;
 | 
			
		||||
      allItems.push(...result.items);
 | 
			
		||||
      allErrors.push(...result.errors);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      success: totalSuccess,
 | 
			
		||||
      failed: totalFailed,
 | 
			
		||||
      duplicates: totalDuplicates,
 | 
			
		||||
      items: allItems,
 | 
			
		||||
      errors: allErrors
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user