From 878fc1094bdcc2f9a5a18363ae598bb907f9735f Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 9 Apr 2026 21:49:29 -0300 Subject: [PATCH] feat: implement article management features with CRUD operations --- .../1775782403581-adds-articles-table.ts | 33 +++ package.json | 1 + src/lib/db/entities.ts | 1 + src/lib/feature/article/article.entity.ts | 58 +++++ src/lib/feature/article/article.external.ts | 61 +++++ src/lib/feature/article/article.model.ts | 45 ++++ src/lib/feature/article/article.service.ts | 177 +++++++++++++ .../feature/article/article.service.test.ts | 232 ++++++++++++++++++ tests/setup/__mocks__/fileMock.js | 1 + 9 files changed, 609 insertions(+) create mode 100644 migrations/1775782403581-adds-articles-table.ts create mode 100644 src/lib/feature/article/article.entity.ts create mode 100644 src/lib/feature/article/article.external.ts create mode 100644 src/lib/feature/article/article.model.ts create mode 100644 src/lib/feature/article/article.service.ts create mode 100644 tests/lib/feature/article/article.service.test.ts create mode 100644 tests/setup/__mocks__/fileMock.js diff --git a/migrations/1775782403581-adds-articles-table.ts b/migrations/1775782403581-adds-articles-table.ts new file mode 100644 index 0000000..afd09f1 --- /dev/null +++ b/migrations/1775782403581-adds-articles-table.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddsArticlesTable1775782403581 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`--sql +CREATE TABLE articles ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT NOT NULL, + cover_image_url TEXT NOT NULL, + content TEXT NOT NULL, + author_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + external_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + + FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TRIGGER set_articles_updated_at +BEFORE UPDATE on articles +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); +--end-sql`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`--sql +DROP TABLE articles CASCADE; +--end-sql`); + } +} diff --git a/package.json b/package.json index baafe89..5bb96ff 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "^.+\\.(ts|tsx)$": "ts-jest" }, "moduleNameMapper": { + "\\.(png|jpg|jpeg|gif|webp|svg|ico)$": "/tests/setup/__mocks__/fileMock.js", "^@/(.*)$": "/src/$1", "^~/(.*)$": "/$1" }, diff --git a/src/lib/db/entities.ts b/src/lib/db/entities.ts index 7d5ffab..7783868 100644 --- a/src/lib/db/entities.ts +++ b/src/lib/db/entities.ts @@ -1 +1,2 @@ +export { ArticleEntity } from '@/lib/feature/article/article.entity'; export { UserEntity } from '@/lib/feature/user/user.entity'; diff --git a/src/lib/feature/article/article.entity.ts b/src/lib/feature/article/article.entity.ts new file mode 100644 index 0000000..c9042b1 --- /dev/null +++ b/src/lib/feature/article/article.entity.ts @@ -0,0 +1,58 @@ +import { UserEntity } from '@/lib/feature/user/user.entity'; +import type { Relation } from '@/utils/types/relation'; +import type { UUIDv4 } from '@/utils/types/uuid'; +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; + +@Entity('articles') +export class ArticleEntity { + @PrimaryColumn({ + type: 'bigint', + primary: true, + generated: true, + }) + id: string; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'varchar', length: 255, unique: true }) + slug: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'cover_image_url', type: 'text' }) + coverImageUrl: string; + + @Column({ type: 'text' }) + content: string; + + @Column({ name: 'author_id', type: 'bigint' }) + authorId: string; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'author_id' }) + author: Relation; + + @Column({ + name: 'created_at', + type: 'timestamp with time zone', + default: () => 'now()', + }) + createdAt: Date; + + @Column({ + name: 'updated_at', + type: 'timestamp with time zone', + default: () => 'now()', + }) + updatedAt: Date; + + @Column({ + name: 'external_id', + type: 'uuid', + unique: true, + default: () => 'gen_random_uuid()', + }) + externalId: UUIDv4; +} diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts new file mode 100644 index 0000000..26b97bb --- /dev/null +++ b/src/lib/feature/article/article.external.ts @@ -0,0 +1,61 @@ +'use server'; + +import { + ArticleModel, + CreateArticleModel, + PaginatedArticlesResult, + UpdateArticleModel, +} from '@/lib/feature/article/article.model'; +import * as service from '@/lib/feature/article/article.service'; +import { getSessionData } from '@/lib/session/session-storage'; +import { UUIDv4 } from '@/utils/types/uuid'; + +export const getArticleByExternalId = async ( + externalId: UUIDv4 +): Promise => { + return await service.getArticleByExternalId(externalId); +}; + +export const getArticleBySlug = async ( + slug: string +): Promise => { + return await service.getArticleBySlug(slug); +}; + +export const getArticlesPaginated = async ( + page: number = 1, + pageSize: number = 10 +): Promise => { + return await service.getArticlesPaginated(page, pageSize); +}; + +export const saveArticle = async ( + article: CreateArticleModel +): Promise => { + const session = await getSessionData(); + if (!session || !session?.user || session?.user.role !== 'admin') { + throw new Error('Unauthorized: Only admin users can save articles.'); + } + article.authorId = session.user.id; + + return await service.saveArticle(article); +}; + +export const updateArticle = async ( + articleId: string, + article: UpdateArticleModel +): Promise => { + const session = await getSessionData(); + if (!session || !session?.user || session?.user.role !== 'admin') { + throw new Error('Unauthorized: Only admin users can save articles.'); + } + return await service.updateArticle(articleId, article); +}; + +export const deleteArticle = async (articleId: string): Promise => { + const session = await getSessionData(); + if (!session || !session?.user || session?.user.role !== 'admin') { + throw new Error('Unauthorized: Only admin users can delete articles.'); + } + await service.deleteArticle(articleId); +}; diff --git a/src/lib/feature/article/article.model.ts b/src/lib/feature/article/article.model.ts new file mode 100644 index 0000000..6207293 --- /dev/null +++ b/src/lib/feature/article/article.model.ts @@ -0,0 +1,45 @@ +import { Pagination } from '@/utils/types/pagination'; +import { z } from 'zod'; + +export const CreateArticleModel = z.object({ + title: z.string(), + slug: z.string(), + description: z.string(), + coverImageUrl: z.string(), + content: z.string(), + authorId: z.string().optional(), +}); +export type CreateArticleModel = z.infer; + +export const UpdateArticleModel = z.object({ + title: z.string().optional(), + slug: z.string().optional(), + description: z.string().optional(), + coverImageUrl: z.string().optional(), + content: z.string().optional(), +}); +export type UpdateArticleModel = z.infer; + +export const ArticleModel = z.object({ + id: z.string(), + title: z.string(), + slug: z.string(), + description: z.string(), + coverImageUrl: z.string(), + content: z.string(), + authorId: z.string(), + externalId: z.uuid(), +}); +export type ArticleModel = z.infer; + +export const ArticleInfoModel = z.object({ + externalId: z.uuid(), + title: z.string(), + slug: z.string(), + description: z.string(), + coverImageUrl: z.string(), +}); +export type ArticleInfoModel = z.infer; + +export const PaginatedArticlesResult = Pagination(ArticleModel); +export type PaginatedArticlesResult = z.infer; diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts new file mode 100644 index 0000000..78d61f8 --- /dev/null +++ b/src/lib/feature/article/article.service.ts @@ -0,0 +1,177 @@ +import { getRepository } from '@/lib/db/client'; +import { ArticleEntity } from '@/lib/feature/article/article.entity'; +import { + ArticleModel, + CreateArticleModel, + PaginatedArticlesResult, + UpdateArticleModel, +} from '@/lib/feature/article/article.model'; +import { UUIDv4 } from '@/utils/types/uuid'; + +export const articleEntityToModel = ( + articleEntity: ArticleEntity +): ArticleModel => { + return { + id: articleEntity.id, + title: articleEntity.title, + slug: articleEntity.slug, + description: articleEntity.description, + coverImageUrl: articleEntity.coverImageUrl, + content: articleEntity.content, + authorId: articleEntity.authorId, + externalId: articleEntity.externalId, + }; +}; + +/** Retrieves an artible by its external ID. + * @param externalId - The external ID of the article to retrieve. + * @returns {Promise} The article model if found, otherwise null. + * */ +export const getArticleByExternalId = async ( + externalId: UUIDv4 +): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + const articleEntity = await articleRepository.findOneBy({ + externalId: externalId, + }); + + if (!articleEntity) { + return null; + } + + return articleEntityToModel(articleEntity); +}; + +/** + * Retrieves an article by its slug. + * @param slug - The slug of the article to retrieve. + * @returns {Promise} The article model if found, otherwise null. + */ +export const getArticleBySlug = async ( + slug: string +): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + const articleEntity = await articleRepository.findOneBy({ slug }); + + if (!articleEntity) { + return null; + } + + return articleEntityToModel(articleEntity); +}; + +/** + * Retrieves all articles by a given author ID. + * @param authorId - The ID of the author. + * @returns {Promise} A list of article models. + */ +export const getArticlesByAuthorId = async ( + authorId: string +): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + const articleEntities = await articleRepository.findBy({ authorId }); + + return articleEntities.map(articleEntityToModel); +}; + +/** + * Retrieves a paginated list of articles ordered by creation date descending. + * @param page - The page number (1-based). + * @param pageSize - The number of articles per page. + * @returns {Promise} The paginated result. + */ +export const getArticlesPaginated = async ( + page: number = 1, + pageSize: number = 10 +): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + const [articleEntities, total] = await articleRepository.findAndCount({ + order: { createdAt: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data: articleEntities.map(articleEntityToModel), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; +}; + +/** + * Saves a new article to the database. + * @param article - The article data to save. + * @returns {Promise} The saved article model. + * @throws {Error} If an article with the same slug already exists. + */ +export const saveArticle = async ( + article: CreateArticleModel +): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + if (!article.authorId) { + throw new Error('Author ID is required to save an article'); + } + + if (!!(await articleRepository.findOneBy({ slug: article.slug }))) { + throw new Error(`Article with slug ${article.slug} already exists`); + } + + const newArticle = articleRepository.create(article); + return articleEntityToModel(await articleRepository.save(newArticle)); +}; + +/** + * Updates an existing article in the database. + * @param articleId - The ID of the article to update. + * @param article - The new article data. + * @returns {Promise} The updated article model. + * @throws {Error} If the article with the given ID does not exist. + */ +export const updateArticle = async ( + articleId: string, + article: UpdateArticleModel +): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + const existingArticle = await articleRepository.findOneBy({ + id: articleId, + }); + if (!existingArticle) { + throw new Error(`Article with ID ${articleId} not found`); + } + + if (!!article.title) existingArticle.title = article.title; + if (!!article.slug) existingArticle.slug = article.slug; + if (!!article.description) + existingArticle.description = article.description; + if (!!article.coverImageUrl) + existingArticle.coverImageUrl = article.coverImageUrl; + if (!!article.content) existingArticle.content = article.content; + + return articleEntityToModel(await articleRepository.save(existingArticle)); +}; + +/** + * Deletes an article from the database. + * @param articleId - The ID of the article to delete. + * @throws {Error} If the article with the given ID does not exist. + */ +export const deleteArticle = async (articleId: string): Promise => { + const articleRepository = await getRepository(ArticleEntity); + + const existingArticle = await articleRepository.findOneBy({ + id: articleId, + }); + if (!existingArticle) { + throw new Error(`Article with ID ${articleId} not found`); + } + + await articleRepository.remove(existingArticle); +}; diff --git a/tests/lib/feature/article/article.service.test.ts b/tests/lib/feature/article/article.service.test.ts new file mode 100644 index 0000000..fb282ba --- /dev/null +++ b/tests/lib/feature/article/article.service.test.ts @@ -0,0 +1,232 @@ +import { getArticlesPaginated } from '@/lib/feature/article/article.external'; +import { + CreateArticleModel, + UpdateArticleModel, +} from '@/lib/feature/article/article.model'; +import { + deleteArticle, + getArticleByExternalId, + getArticleBySlug, + getArticlesByAuthorId, + saveArticle, + updateArticle, +} from '@/lib/feature/article/article.service'; +import { CreateUserModel } from '@/lib/feature/user/user.model'; +import { saveUser } from '@/lib/feature/user/user.service'; +import { UUIDv4 } from '@/utils/types/uuid'; +import { AbstractStartedContainer } from 'testcontainers'; +import { startTestDB } from '~/tests/setup/setup-db'; + +jest.mock('@clerk/nextjs/server', () => ({ + clerkClient: jest.fn(), +})); + +describe('ArticleService', () => { + let container: AbstractStartedContainer; + let authorId: string; + + beforeAll(async () => { + container = await startTestDB(); + + const author: CreateUserModel = { + name: 'Article Author', + email: 'author@email.com', + role: 'admin', + }; + const savedAuthor = await saveUser(author); + authorId = savedAuthor.id; + }, 1_000_000); + + afterAll(async () => { + await container.stop(); + }, 1_000_000); + + test('can save article', async () => { + const articleToSave: CreateArticleModel = { + title: 'Test Article', + slug: 'test-article', + description: 'A test article description', + coverImageUrl: 'https://example.com/cover.png', + content: 'This is the article content.', + authorId: authorId, + }; + + const savedArticle = await saveArticle(articleToSave); + + expect(savedArticle.id).toBeDefined(); + expect(savedArticle.title).toBe(articleToSave.title); + expect(savedArticle.slug).toBe(articleToSave.slug); + expect(savedArticle.description).toBe(articleToSave.description); + expect(savedArticle.coverImageUrl).toBe(articleToSave.coverImageUrl); + expect(savedArticle.content).toBe(articleToSave.content); + expect(savedArticle.authorId).toBe(authorId); + expect(savedArticle.externalId).toBeDefined(); + }); + + test('cannot save article with existing slug', async () => { + const articleToSave: CreateArticleModel = { + title: 'Duplicate Slug Article', + slug: 'test-article', + description: 'Another article with the same slug', + coverImageUrl: 'https://example.com/cover2.png', + content: 'Duplicate content.', + authorId: authorId, + }; + await expect(saveArticle(articleToSave)).rejects.toThrow( + `Article with slug ${articleToSave.slug} already exists` + ); + }); + + test('can getArticleBySlug', async () => { + const article = await getArticleBySlug('test-article'); + + expect(article).toBeDefined(); + expect(article?.slug).toBe('test-article'); + expect(article?.title).toBe('Test Article'); + expect(article?.authorId).toBe(authorId); + expect(article?.externalId).toBeDefined(); + }); + + test('cannot getArticleBySlug with non-existing slug', async () => { + await expect(getArticleBySlug('non-existing-slug')).resolves.toBeNull(); + }); + + test('can getArticlesByAuthorId', async () => { + const articles = await getArticlesByAuthorId(authorId); + + expect(articles).toBeDefined(); + expect(articles.length).toBeGreaterThanOrEqual(1); + expect(articles[0].authorId).toBe(authorId); + }); + + test('getArticlesByAuthorId returns empty for non-existing author', async () => { + const articles = await getArticlesByAuthorId('9999'); + + expect(articles).toBeDefined(); + expect(articles.length).toBe(0); + }); + + test('can getArticleByExternalId', async () => { + const article = await getArticleBySlug('test-article'); + expect(article).toBeDefined(); + + const foundArticle = await getArticleByExternalId( + article!.externalId as UUIDv4 + ); + + expect(foundArticle).toBeDefined(); + expect(foundArticle?.id).toBe(article!.id); + expect(foundArticle?.title).toBe(article!.title); + expect(foundArticle?.slug).toBe(article!.slug); + expect(foundArticle?.externalId).toBe(article!.externalId); + }); + + test('getArticleByExternalId returns null for non-existing id', async () => { + const result = await getArticleByExternalId( + '00000000-0000-4000-a000-000000000000' as UUIDv4 + ); + expect(result).toBeNull(); + }); + + test('can getArticlesPaginated with defaults', async () => { + const result = await getArticlesPaginated(); + + expect(result).toBeDefined(); + expect(result.data.length).toBeGreaterThanOrEqual(1); + expect(result.page).toBe(1); + expect(result.pageSize).toBe(10); + expect(result.total).toBeGreaterThanOrEqual(1); + expect(result.totalPages).toBeGreaterThanOrEqual(1); + }); + + test('can getArticlesPaginated with custom page size', async () => { + // Save extra articles to test pagination + for (let i = 0; i < 3; i++) { + await saveArticle({ + title: `Paginated Article ${i}`, + slug: `paginated-article-${i}`, + description: `Description ${i}`, + coverImageUrl: `https://example.com/cover-${i}.png`, + content: `Content ${i}`, + authorId: authorId, + }); + } + + const firstPage = await getArticlesPaginated(1, 2); + + expect(firstPage.data.length).toBe(2); + expect(firstPage.page).toBe(1); + expect(firstPage.pageSize).toBe(2); + expect(firstPage.total).toBeGreaterThanOrEqual(4); + expect(firstPage.totalPages).toBeGreaterThanOrEqual(2); + + const secondPage = await getArticlesPaginated(2, 2); + + expect(secondPage.data.length).toBe(2); + expect(secondPage.page).toBe(2); + expect(secondPage.pageSize).toBe(2); + }); + + test('can getArticlesPaginated returns empty on out-of-range page', async () => { + const result = await getArticlesPaginated(999, 10); + + expect(result.data.length).toBe(0); + expect(result.page).toBe(999); + expect(result.total).toBeGreaterThanOrEqual(1); + }); + + test('can update article', async () => { + const dataToUpdate: UpdateArticleModel = { + title: 'Updated Article Title', + description: 'Updated description', + }; + + const article = await getArticleBySlug('test-article'); + expect(article).toBeDefined(); + + const updatedArticle = await updateArticle(article!.id, dataToUpdate); + + expect(updatedArticle).toBeDefined(); + expect(updatedArticle.id).toBe(article!.id); + expect(updatedArticle.title).toBe(dataToUpdate.title); + expect(updatedArticle.description).toBe(dataToUpdate.description); + expect(updatedArticle.slug).toBe(article!.slug); + expect(updatedArticle.content).toBe(article!.content); + expect(updatedArticle.externalId).toBeDefined(); + }); + + test('cannot update non-existing article', async () => { + const dataToUpdate: UpdateArticleModel = { + title: 'Updated Article Title', + }; + + await expect(updateArticle('9999', dataToUpdate)).rejects.toThrow( + `Article with ID 9999 not found` + ); + }); + + test('can delete article', async () => { + const articleToSave: CreateArticleModel = { + title: 'Article to Delete', + slug: 'article-to-delete', + description: 'This article will be deleted', + coverImageUrl: 'https://example.com/delete-cover.png', + content: 'Content to delete.', + authorId: authorId, + }; + + const savedArticle = await saveArticle(articleToSave); + expect(savedArticle.id).toBeDefined(); + + await deleteArticle(savedArticle.id); + + const deletedArticle = await getArticleBySlug('article-to-delete'); + expect(deletedArticle).toBeNull(); + }); + + test('cannot delete non-existing article', async () => { + await expect(deleteArticle('9999')).rejects.toThrow( + `Article with ID 9999 not found` + ); + }); +}); diff --git a/tests/setup/__mocks__/fileMock.js b/tests/setup/__mocks__/fileMock.js new file mode 100644 index 0000000..86059f3 --- /dev/null +++ b/tests/setup/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub';