Merge pull request #4 from aabril/feat/database_and_feed_model_finish
02 - 2nd part - feat/database and feed model
This commit is contained in:
114
src/__tests__/Feed.model.test.ts
Normal file
114
src/__tests__/Feed.model.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
237
src/__tests__/FeedService.test.ts
Normal file
237
src/__tests__/FeedService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,31 +1,85 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
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 {
|
export interface IFeedDocument extends IFeed, Document {
|
||||||
_id: string;
|
_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedSchema = new Schema<IFeedDocument>({
|
const feedSchemaObject = {
|
||||||
}, {
|
title: {
|
||||||
timestamps: true,
|
type: String,
|
||||||
toJSON: {
|
required: true
|
||||||
transform: function(doc, ret) {
|
},
|
||||||
ret.id = ret._id;
|
|
||||||
delete (ret as any)._id;
|
description: {
|
||||||
delete (ret as any).__v;
|
type: String
|
||||||
return ret;
|
},
|
||||||
}
|
|
||||||
},
|
url: {
|
||||||
toObject: {
|
type: String,
|
||||||
transform: function(doc, ret) {
|
required: true
|
||||||
ret.id = ret._id;
|
},
|
||||||
delete (ret as any)._id;
|
|
||||||
delete (ret as any).__v;
|
imageUrl: {
|
||||||
return ret;
|
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<IFeedDocument>(feedSchemaObject as any, feedSchemaSettings);
|
||||||
|
|
||||||
|
feedSchema.index({ url: 1 }, { unique: true });
|
||||||
|
|
||||||
export const Feed = mongoose.model<IFeedDocument>('Feed', feedSchema);
|
export const Feed = mongoose.model<IFeedDocument>('Feed', feedSchema);
|
||||||
export default Feed;
|
export default Feed;
|
@ -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;
|
63
src/services/FeedService.ts
Normal file
63
src/services/FeedService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -2,9 +2,9 @@ export enum NewsSource {
|
|||||||
EL_PAIS = 'El País',
|
EL_PAIS = 'El País',
|
||||||
EL_MUNDO = 'El Mundo',
|
EL_MUNDO = 'El Mundo',
|
||||||
MANUAL = 'Manual'
|
MANUAL = 'Manual'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeed {
|
export interface IFeed {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -13,6 +13,55 @@ export interface IFeed {
|
|||||||
publishedAt: Date;
|
publishedAt: Date;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
isManual: boolean;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: 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<T> {
|
||||||
|
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;
|
||||||
|
}
|
Reference in New Issue
Block a user