From cbe4199206bb18c5bb59280badd4d7ae1c7e996d Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 28 Jul 2025 18:33:16 +0200 Subject: [PATCH] - add feed repo - Add feed service - Add tests for feed service --- src/__tests__/FeedService.test.ts | 237 +++++++++++++++++++++++++++++ src/repositories/FeedRepository.ts | 149 +++++++++++++++++- src/services/FeedService.ts | 63 ++++++++ 3 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/FeedService.test.ts create mode 100644 src/services/FeedService.ts diff --git a/src/__tests__/FeedService.test.ts b/src/__tests__/FeedService.test.ts new file mode 100644 index 0000000..c7b83d9 --- /dev/null +++ b/src/__tests__/FeedService.test.ts @@ -0,0 +1,237 @@ +import { FeedService } from '../services/FeedService.js'; +import { IFeedRepository } from '../repositories/FeedRepository.js'; +import { NewsSource, IFeed, ICreateFeedDto, IUpdateFeedDto, IFeedQuery, IPaginatedResponse } from '../types/Feed.js'; + +// Mock FeedRepository +const mockFeedRepository: jest.Mocked = { + create: jest.fn(), + findById: jest.fn(), + findByUrl: jest.fn(), + findAll: jest.fn(), + findBySource: jest.fn(), + findTodaysFrontPage: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + count: jest.fn(), + exists: jest.fn() +}; + +describe('FeedService', () => { + let feedService: FeedService; + + const mockFeed: IFeed = { + _id: '507f1f77bcf86cd799439011', + title: 'Test News Title', + description: 'Test news description', + url: 'https://example.com/news/1', + source: NewsSource.EL_PAIS, + publishedAt: new Date('2024-01-15T10:00:00Z'), + imageUrl: 'https://example.com/image.jpg', + category: 'Politics', + isManual: false, + createdAt: new Date('2024-01-15T10:00:00Z'), + updatedAt: new Date('2024-01-15T10:00:00Z') + }; + + const mockCreateFeedDto: ICreateFeedDto = { + title: 'New Test News', + description: 'New test news description', + url: 'https://example.com/news/new', + source: NewsSource.EL_MUNDO, + publishedAt: new Date('2024-01-15T12:00:00Z'), + imageUrl: 'https://example.com/new-image.jpg', + category: 'Sports' + }; + + beforeEach(() => { + jest.clearAllMocks(); + feedService = new FeedService(mockFeedRepository); + }); + + describe('createFeed', () => { + test('should create a feed successfully', async () => { + mockFeedRepository.findByUrl.mockResolvedValueOnce(null); + mockFeedRepository.create.mockResolvedValueOnce(mockFeed); + + const result = await feedService.createFeed(mockCreateFeedDto); + + expect(mockFeedRepository.findByUrl).toHaveBeenCalledWith(mockCreateFeedDto.url); + expect(mockFeedRepository.create).toHaveBeenCalledWith(mockCreateFeedDto); + expect(result).toEqual(mockFeed); + }); + + test('should throw error if URL already exists', async () => { + mockFeedRepository.findByUrl.mockResolvedValueOnce(mockFeed); + + await expect(feedService.createFeed(mockCreateFeedDto)) + .rejects.toThrow('A feed with this URL already exists'); + + expect(mockFeedRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe('getFeedById', () => { + test('should return feed by ID', async () => { + mockFeedRepository.findById.mockResolvedValueOnce(mockFeed); + + const result = await feedService.getFeedById('507f1f77bcf86cd799439011'); + + expect(mockFeedRepository.findById).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); + expect(result).toEqual(mockFeed); + }); + + test('should return null for non-existent feed', async () => { + mockFeedRepository.findById.mockResolvedValueOnce(null); + + const result = await feedService.getFeedById('507f1f77bcf86cd799439011'); + + expect(result).toBeNull(); + }); + }); + + describe('getAllFeeds', () => { + test('should return paginated feeds with default values', async () => { + const mockResponse: IPaginatedResponse = { + data: [mockFeed], + pagination: { + page: 1, + limit: 20, + total: 1, + totalPages: 1, + hasNext: false, + hasPrev: false + } + }; + + mockFeedRepository.findAll.mockResolvedValueOnce(mockResponse); + + const result = await feedService.getAllFeeds(); + + expect(mockFeedRepository.findAll).toHaveBeenCalledWith({ + limit: 20, + page: 1 + }); + expect(result).toEqual(mockResponse); + }); + + test('should pass custom query parameters', async () => { + const query: IFeedQuery = { + source: NewsSource.EL_PAIS, + limit: 10, + page: 2 + }; + + const mockResponse: IPaginatedResponse = { + data: [mockFeed], + pagination: { + page: 2, + limit: 10, + total: 1, + totalPages: 1, + hasNext: false, + hasPrev: true + } + }; + + mockFeedRepository.findAll.mockResolvedValueOnce(mockResponse); + + const result = await feedService.getAllFeeds(query); + + expect(mockFeedRepository.findAll).toHaveBeenCalledWith(query); + expect(result).toEqual(mockResponse); + }); + }); + + describe('updateFeed', () => { + const updateData: IUpdateFeedDto = { + title: 'Updated Title', + description: 'Updated description' + }; + + test('should update feed successfully', async () => { + const updatedFeed = { ...mockFeed, ...updateData }; + mockFeedRepository.update.mockResolvedValueOnce(updatedFeed); + + const result = await feedService.updateFeed('507f1f77bcf86cd799439011', updateData); + + expect(mockFeedRepository.update).toHaveBeenCalledWith('507f1f77bcf86cd799439011', updateData); + expect(result).toEqual(updatedFeed); + }); + + test('should check URL conflicts when updating URL', async () => { + const updateWithUrl: IUpdateFeedDto = { + ...updateData, + url: 'https://example.com/new-url' + }; + + const existingFeed = { ...mockFeed, _id: 'different-id' }; + mockFeedRepository.findByUrl.mockResolvedValueOnce(existingFeed); + + await expect(feedService.updateFeed('507f1f77bcf86cd799439011', updateWithUrl)) + .rejects.toThrow('A feed with this URL already exists'); + + expect(mockFeedRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe('deleteFeed', () => { + test('should delete feed successfully', async () => { + mockFeedRepository.delete.mockResolvedValueOnce(true); + + const result = await feedService.deleteFeed('507f1f77bcf86cd799439011'); + + expect(mockFeedRepository.delete).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); + expect(result).toBe(true); + }); + }); + + describe('getTodaysFrontPageNews', () => { + test('should return combined feeds from all sources', async () => { + const elPaisFeeds = [{ ...mockFeed, source: NewsSource.EL_PAIS }]; + const elMundoFeeds = [{ ...mockFeed, source: NewsSource.EL_MUNDO }]; + + mockFeedRepository.findTodaysFrontPage + .mockResolvedValueOnce(elPaisFeeds) + .mockResolvedValueOnce(elMundoFeeds); + + const result = await feedService.getTodaysFrontPageNews(); + + expect(mockFeedRepository.findTodaysFrontPage).toHaveBeenCalledWith(NewsSource.EL_PAIS); + expect(mockFeedRepository.findTodaysFrontPage).toHaveBeenCalledWith(NewsSource.EL_MUNDO); + expect(result).toEqual([...elPaisFeeds, ...elMundoFeeds]); + }); + + test('should return empty array if no feeds found', async () => { + mockFeedRepository.findTodaysFrontPage + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const result = await feedService.getTodaysFrontPageNews(); + + expect(result).toEqual([]); + }); + }); + + describe('getFeedsBySource', () => { + test('should return feeds by source', async () => { + const sourceFeeds = [mockFeed]; + mockFeedRepository.findBySource.mockResolvedValueOnce(sourceFeeds); + + const result = await feedService.getFeedsBySource(NewsSource.EL_PAIS); + + expect(mockFeedRepository.findBySource).toHaveBeenCalledWith(NewsSource.EL_PAIS); + expect(result).toEqual(sourceFeeds); + }); + + test('should use default limit', async () => { + const sourceFeeds = [mockFeed]; + mockFeedRepository.findBySource.mockResolvedValueOnce(sourceFeeds); + + const result = await feedService.getFeedsBySource(NewsSource.EL_PAIS, 5); + + expect(mockFeedRepository.findBySource).toHaveBeenCalledWith(NewsSource.EL_PAIS); + expect(result).toEqual(sourceFeeds); + }); + }); +}); \ No newline at end of file diff --git a/src/repositories/FeedRepository.ts b/src/repositories/FeedRepository.ts index db7aae1..89f5420 100644 --- a/src/repositories/FeedRepository.ts +++ b/src/repositories/FeedRepository.ts @@ -1,2 +1,147 @@ -// AquĆ­ el "repositorio" para lidiar con el modelo -// Lo tipico : ( Find , FindById, CreateOne ) etc. +import { Feed, IFeedDocument } from '../models/Feed.js'; +import { IFeed, ICreateFeedDto, IUpdateFeedDto, IFeedQuery, IPaginatedResponse, NewsSource } from '../types/Feed.js'; +import { FilterQuery, UpdateQuery } from 'mongoose'; + +export interface IFeedRepository { + create(feedData: ICreateFeedDto): Promise; + findById(id: string): Promise; + findByUrl(url: string): Promise; + findAll(query: IFeedQuery): Promise>; + findBySource(source: NewsSource): Promise; + findTodaysFrontPage(source: NewsSource): Promise; + update(id: string, updateData: IUpdateFeedDto): Promise; + delete(id: string): Promise; + deleteMany(filter: FilterQuery): Promise; + count(filter?: FilterQuery): Promise; + exists(url: string): Promise; +} + +export class FeedRepository implements IFeedRepository { + async create(feedData: ICreateFeedDto): Promise { + const feed = new Feed({ + ...feedData, + publishedAt: feedData.publishedAt || new Date(), + isManual: feedData.isManual ?? false + }); + + const savedFeed = await feed.save(); + return savedFeed.toObject(); + } + + async findById(id: string): Promise { + const feed = await Feed.findById(id).lean(); + return feed ? this.transformDocument(feed) : null; + } + + async findByUrl(url: string): Promise { + const feed = await Feed.findOne({ url }).lean(); + return feed ? this.transformDocument(feed) : null; + } + + async findAll(query: IFeedQuery): Promise> { + const { + source, + page = 1, + limit = 20 + } = query; + + const filter: FilterQuery = {}; + + if (source) filter.source = source; + + const skip = (page - 1) * limit; + + const [feeds, total] = await Promise.all([ + Feed.find(filter) + .sort({ publishedAt: -1 }) + .skip(skip) + .limit(limit) + .lean(), + Feed.countDocuments(filter) + ]); + + const totalPages = Math.ceil(total / limit); + + return { + data: feeds.map(feed => this.transformDocument(feed)), + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1 + } + }; + } + + async findBySource(source: NewsSource): Promise { + const feeds = await Feed.find({ source }).sort({ publishedAt: -1 }).lean(); + return feeds.map((feed: any) => this.transformDocument(feed)); + } + + async findTodaysFrontPage(source: NewsSource): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const feeds = await Feed.find({ + source, + isManual: false, + publishedAt: { + $gte: today, + $lt: tomorrow + } + }).sort({ publishedAt: -1 }).limit(10).lean(); + + return feeds.map((feed: any) => this.transformDocument(feed)); + } + + async update(id: string, updateData: IUpdateFeedDto): Promise { + const updatedFeed = await Feed.findByIdAndUpdate( + id, + updateData, + { new: true, runValidators: true } + ).lean(); + + return updatedFeed ? this.transformDocument(updatedFeed) : null; + } + + async delete(id: string): Promise { + const result = await Feed.findByIdAndDelete(id); + return !!result; + } + + async deleteMany(filter: FilterQuery): Promise { + const result = await Feed.deleteMany(filter); + return result.deletedCount || 0; + } + + async count(filter: FilterQuery = {}): Promise { + return await Feed.countDocuments(filter); + } + + async exists(url: string): Promise { + const feed = await Feed.findOne({ url }).select('_id').lean(); + return !!feed; + } + + private transformDocument(doc: any): IFeed { + return { + _id: doc._id.toString(), + title: doc.title, + description: doc.description, + url: doc.url, + source: doc.source, + publishedAt: doc.publishedAt, + imageUrl: doc.imageUrl, + category: doc.category, + isManual: doc.isManual, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt + }; + } +} + +export default FeedRepository; \ No newline at end of file diff --git a/src/services/FeedService.ts b/src/services/FeedService.ts new file mode 100644 index 0000000..608c655 --- /dev/null +++ b/src/services/FeedService.ts @@ -0,0 +1,63 @@ +import { IFeedRepository } from '../repositories/FeedRepository.js'; +import { IFeed, ICreateFeedDto, IUpdateFeedDto, IFeedQuery, IPaginatedResponse, NewsSource } from '../types/Feed.js'; + +export interface IFeedService { + createFeed(feedData: ICreateFeedDto): Promise; + getFeedById(id: string): Promise; + getAllFeeds(query?: IFeedQuery): Promise>; + updateFeed(id: string, updateData: IUpdateFeedDto): Promise; + deleteFeed(id: string): Promise; + getTodaysFrontPageNews(): Promise; + getFeedsBySource(source: NewsSource, limit?: number): Promise; +} + +export class FeedService implements IFeedService { + constructor(private feedRepository: IFeedRepository) {} + + async createFeed(feedData: ICreateFeedDto): Promise { + const existingFeed = await this.feedRepository.findByUrl(feedData.url); + if (existingFeed) { + throw new Error('A feed with this URL already exists'); + } + return await this.feedRepository.create(feedData); + } + + async getFeedById(id: string): Promise { + return await this.feedRepository.findById(id); + } + + async getAllFeeds(query: IFeedQuery = {}): Promise> { + const sanitizedQuery = { + ...query, + limit: query.limit || 20, + page: query.page || 1 + }; + return await this.feedRepository.findAll(sanitizedQuery); + } + + async updateFeed(id: string, updateData: IUpdateFeedDto): Promise { + if (updateData.url) { + const existingFeed = await this.feedRepository.findByUrl(updateData.url); + if (existingFeed && existingFeed._id !== id) { + throw new Error('A feed with this URL already exists'); + } + } + return await this.feedRepository.update(id, updateData); + } + + async deleteFeed(id: string): Promise { + return await this.feedRepository.delete(id); + } + + async getTodaysFrontPageNews(): Promise { + const [elPaisFeeds, elMundoFeeds] = await Promise.all([ + this.feedRepository.findTodaysFrontPage(NewsSource.EL_PAIS), + this.feedRepository.findTodaysFrontPage(NewsSource.EL_MUNDO) + ]); + return [...elPaisFeeds, ...elMundoFeeds]; + } + + async getFeedsBySource(source: NewsSource, limit: number = 10): Promise { + return await this.feedRepository.findBySource(source); + } +} \ No newline at end of file