From 862c94a4e634d5d400fd156c2c6bb344ab3fd715 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 28 Jul 2025 18:31:55 +0200 Subject: [PATCH 1/3] - update model and repository - add tests for feed Model --- src/__tests__/Feed.model.test.ts | 114 +++++++++++++++++++++++++++++++ src/models/Feed.ts | 99 +++++++++++++++++++++------ src/types/Feed.ts | 57 ++++++++++++++-- 3 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/Feed.model.test.ts diff --git a/src/__tests__/Feed.model.test.ts b/src/__tests__/Feed.model.test.ts new file mode 100644 index 0000000..d4eefdd --- /dev/null +++ b/src/__tests__/Feed.model.test.ts @@ -0,0 +1,114 @@ +import mongoose from 'mongoose'; +import { Feed, IFeedDocument } from '../models/Feed.js'; +import { NewsSource } from '../types/Feed.js'; + +describe('Feed Model', () => { + const mockFeedData = { + 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 + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Schema Validation', () => { + test('should create a valid feed document', () => { + const feed = new Feed(mockFeedData); + + expect(feed.title).toBe(mockFeedData.title); + expect(feed.description).toBe(mockFeedData.description); + expect(feed.url).toBe(mockFeedData.url); + expect(feed.source).toBe(mockFeedData.source); + expect(feed.publishedAt).toEqual(mockFeedData.publishedAt); + expect(feed.imageUrl).toBe(mockFeedData.imageUrl); + expect(feed.category).toBe(mockFeedData.category); + expect(feed.isManual).toBe(mockFeedData.isManual); + }); + + test('should set default values correctly', () => { + const minimalData = { + title: 'Test Title', + description: 'Test description', + url: 'https://example.com/test', + source: NewsSource.EL_MUNDO, + publishedAt: new Date() + }; + + const feed = new Feed(minimalData); + + expect(feed.isManual).toBe(false); + expect(feed.createdAt).toBeDefined(); + expect(feed.updatedAt).toBeDefined(); + }); + + test('should require mandatory fields', () => { + const feed = new Feed({}); + const validationError = feed.validateSync(); + + expect(validationError?.errors.title).toBeDefined(); + expect(validationError?.errors.url).toBeDefined(); + expect(validationError?.errors.source).toBeDefined(); + expect(validationError?.errors.publishedAt).toBeDefined(); + }); + + test('should validate NewsSource enum', () => { + const invalidData = { + ...mockFeedData, + source: 'Invalid Source' as any + }; + + const feed = new Feed(invalidData); + const validationError = feed.validateSync(); + + expect(validationError?.errors.source).toBeDefined(); + }); + }); + + describe('Document Transformation', () => { + test('should transform _id to id in JSON', () => { + const feed = new Feed(mockFeedData); + feed._id = '507f1f77bcf86cd799439011'; + + const json = feed.toJSON(); + + expect(json.id.toString()).toBe('507f1f77bcf86cd799439011'); + expect(json._id).toBeUndefined(); + expect(json.__v).toBeUndefined(); + }); + + test('should transform _id to id in Object', () => { + const feed = new Feed(mockFeedData); + feed._id = '507f1f77bcf86cd799439011'; + + const obj = feed.toObject(); + + expect(obj.id.toString()).toBe('507f1f77bcf86cd799439011'); + expect(obj._id).toBeUndefined(); + expect(obj.__v).toBeUndefined(); + }); + }); + + describe('Model Methods', () => { + test('should create Feed model instance', () => { + expect(Feed).toBeDefined(); + expect(Feed.modelName).toBe('Feed'); + }); + + test('should have unique URL index', () => { + const indexes = Feed.schema.indexes(); + const urlIndex = indexes.find(index => + index[0].url && index[1].unique + ); + + expect(urlIndex).toBeDefined(); + expect(urlIndex?.[1].unique).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/models/Feed.ts b/src/models/Feed.ts index 5da0e23..19c7d4c 100644 --- a/src/models/Feed.ts +++ b/src/models/Feed.ts @@ -1,31 +1,86 @@ import mongoose, { Schema, Document } from 'mongoose'; -import { IFeed } from '../types/Feed.js'; +import { IFeed, NewsSource } from '../types/Feed.js'; export interface IFeedDocument extends IFeed, Document { - _id: string; + _id: string; } -const feedSchema = new Schema({ - }, { - timestamps: true, - toJSON: { - transform: function(doc, ret) { - ret.id = ret._id; - delete (ret as any)._id; - delete (ret as any).__v; - return ret; - } - }, - toObject: { - transform: function(doc, ret) { - ret.id = ret._id; - delete (ret as any)._id; - delete (ret as any).__v; - return ret; - } - } - }); +const feedSchemaObject = { + title: { + type: String, + required: true + }, + + description: { + type: String + }, + + url: { + type: String, + required: true, + unique: true + }, + + imageUrl: { + type: String + }, + + source: { + type: String, + required: true, + enum: Object.values(NewsSource) + }, + + category: { + type: String + }, + + publishedAt: { + type: Date, + required: true + }, + + createdAt: { + type: Date, + default: Date.now + }, + + updatedAt: { + type: Date, + default: Date.now + }, + + isManual: { + type: Boolean, + default: false + } +} as const; +const feedSchemaSettings = { + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + + toJSON: { + transform: (doc: any, ret: any) => { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + return ret; + } + }, + + toObject: { + transform: (doc: any, ret: any) => { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + return ret; + } + } +} as const; + +const feedSchema = new Schema(feedSchemaObject as any, feedSchemaSettings); + +feedSchema.index({ url: 1 }, { unique: true }); export const Feed = mongoose.model('Feed', feedSchema); export default Feed; \ No newline at end of file diff --git a/src/types/Feed.ts b/src/types/Feed.ts index 79db4c5..eee3741 100644 --- a/src/types/Feed.ts +++ b/src/types/Feed.ts @@ -2,9 +2,9 @@ export enum NewsSource { EL_PAIS = 'El País', EL_MUNDO = 'El Mundo', MANUAL = 'Manual' -} - -export interface IFeed { + } + + export interface IFeed { _id?: string; title: string; description: string; @@ -13,6 +13,55 @@ export interface IFeed { publishedAt: Date; imageUrl?: string; category?: string; + isManual: boolean; createdAt?: Date; updatedAt?: Date; -} + } + + export interface ICreateFeedDto { + title: string; + description: string; + url: string; + source: NewsSource; + publishedAt?: Date; + imageUrl?: string; + category?: string; + isManual?: boolean; + } + + export interface IUpdateFeedDto { + title?: string; + description?: string; + url?: string; + source?: NewsSource; + publishedAt?: Date; + imageUrl?: string; + category?: string; + isManual?: boolean; + } + + export interface IFeedQuery { + source?: NewsSource; + page?: number; + limit?: number; + } + + export interface IPaginatedResponse { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; + } + + export interface IScrapedNews { + title: string; + description: string; + url: string; + imageUrl?: string; + category?: string; + } \ No newline at end of file From cbe4199206bb18c5bb59280badd4d7ae1c7e996d Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 28 Jul 2025 18:33:16 +0200 Subject: [PATCH 2/3] - 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 From 0dbdc41124e8c68b411a848cafc6b7f1582f8f11 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 28 Jul 2025 18:37:23 +0200 Subject: [PATCH 3/3] feed model -> unique:true -> for safety (and to avoid mongoose warning) --- src/models/Feed.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/models/Feed.ts b/src/models/Feed.ts index 19c7d4c..0a5f90d 100644 --- a/src/models/Feed.ts +++ b/src/models/Feed.ts @@ -17,8 +17,7 @@ const feedSchemaObject = { url: { type: String, - required: true, - unique: true + required: true }, imageUrl: {