- add feed repo

- Add feed service
- Add tests for feed service
This commit is contained in:
albert
2025-07-28 18:33:16 +02:00
parent 862c94a4e6
commit cbe4199206
3 changed files with 447 additions and 2 deletions

View File

@ -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<IFeedRepository> = {
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<IFeed> = {
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<IFeed> = {
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);
});
});
});

View File

@ -1,2 +1,147 @@
// Aquí el "repositorio" para lidiar con el modelo import { Feed, IFeedDocument } from '../models/Feed.js';
// Lo tipico : ( Find , FindById, CreateOne ) etc. import { IFeed, ICreateFeedDto, IUpdateFeedDto, IFeedQuery, IPaginatedResponse, NewsSource } from '../types/Feed.js';
import { FilterQuery, UpdateQuery } from 'mongoose';
export interface IFeedRepository {
create(feedData: ICreateFeedDto): Promise<IFeed>;
findById(id: string): Promise<IFeed | null>;
findByUrl(url: string): Promise<IFeed | null>;
findAll(query: IFeedQuery): Promise<IPaginatedResponse<IFeed>>;
findBySource(source: NewsSource): Promise<IFeed[]>;
findTodaysFrontPage(source: NewsSource): Promise<IFeed[]>;
update(id: string, updateData: IUpdateFeedDto): Promise<IFeed | null>;
delete(id: string): Promise<boolean>;
deleteMany(filter: FilterQuery<IFeedDocument>): Promise<number>;
count(filter?: FilterQuery<IFeedDocument>): Promise<number>;
exists(url: string): Promise<boolean>;
}
export class FeedRepository implements IFeedRepository {
async create(feedData: ICreateFeedDto): Promise<IFeed> {
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<IFeed | null> {
const feed = await Feed.findById(id).lean();
return feed ? this.transformDocument(feed) : null;
}
async findByUrl(url: string): Promise<IFeed | null> {
const feed = await Feed.findOne({ url }).lean();
return feed ? this.transformDocument(feed) : null;
}
async findAll(query: IFeedQuery): Promise<IPaginatedResponse<IFeed>> {
const {
source,
page = 1,
limit = 20
} = query;
const filter: FilterQuery<IFeedDocument> = {};
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<IFeed[]> {
const feeds = await Feed.find({ source }).sort({ publishedAt: -1 }).lean();
return feeds.map((feed: any) => this.transformDocument(feed));
}
async findTodaysFrontPage(source: NewsSource): Promise<IFeed[]> {
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<IFeed | null> {
const updatedFeed = await Feed.findByIdAndUpdate(
id,
updateData,
{ new: true, runValidators: true }
).lean();
return updatedFeed ? this.transformDocument(updatedFeed) : null;
}
async delete(id: string): Promise<boolean> {
const result = await Feed.findByIdAndDelete(id);
return !!result;
}
async deleteMany(filter: FilterQuery<IFeedDocument>): Promise<number> {
const result = await Feed.deleteMany(filter);
return result.deletedCount || 0;
}
async count(filter: FilterQuery<IFeedDocument> = {}): Promise<number> {
return await Feed.countDocuments(filter);
}
async exists(url: string): Promise<boolean> {
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;

View File

@ -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<IFeed>;
getFeedById(id: string): Promise<IFeed | null>;
getAllFeeds(query?: IFeedQuery): Promise<IPaginatedResponse<IFeed>>;
updateFeed(id: string, updateData: IUpdateFeedDto): Promise<IFeed | null>;
deleteFeed(id: string): Promise<boolean>;
getTodaysFrontPageNews(): Promise<IFeed[]>;
getFeedsBySource(source: NewsSource, limit?: number): Promise<IFeed[]>;
}
export class FeedService implements IFeedService {
constructor(private feedRepository: IFeedRepository) {}
async createFeed(feedData: ICreateFeedDto): Promise<IFeed> {
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<IFeed | null> {
return await this.feedRepository.findById(id);
}
async getAllFeeds(query: IFeedQuery = {}): Promise<IPaginatedResponse<IFeed>> {
const sanitizedQuery = {
...query,
limit: query.limit || 20,
page: query.page || 1
};
return await this.feedRepository.findAll(sanitizedQuery);
}
async updateFeed(id: string, updateData: IUpdateFeedDto): Promise<IFeed | null> {
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<boolean> {
return await this.feedRepository.delete(id);
}
async getTodaysFrontPageNews(): Promise<IFeed[]> {
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<IFeed[]> {
return await this.feedRepository.findBySource(source);
}
}