From 9c31d71262e354d7ec0ec63456a98170f0275fd8 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 01:44:59 +0300 Subject: [PATCH] feat(article): add the article read path Stage 3 of the article feature: the public list, a single article by slug, and an author's own articles. GET /articles optional auth, PUBLIC published only, cached 60s GET /articles/me auth required, STANDARD drafts included, never cached GET /articles/:slug optional auth, PUBLIC 404 when not visible Draft containment now has both of its layers. The repository pins status to PUBLISHED on every list query, and this stage adds the second: the slug lookup returns an article of any status so an author can read their own draft back, and the use case decides who may see it. A viewer who may not see it gets a 404 rather than a 403, because a distinct status code for a draft that exists would confirm the slug - which is the leak the two layers exist to prevent. An unknown author username returns an empty page for the same reason. GetMyArticlesUseCase has no cache dependency at all. Sharing a cache with the public list is exactly how a draft leaks into it, so the ability is absent rather than merely unused. The cached page is written through an explicit DTO and rebuilt field by field, rather than spreading whatever the entity serialized to. A loose shape keeps stale fields alive across deploys and the reader silently accepts them. The key carries every filter plus the viewer, with absent values as literals, so one pattern delete clears the whole space. Categories are sorted into the key so the same filter in a different order reuses one entry instead of splitting the cache. Two things this run caught that type checking could not: - GetArticlesUseCase took a constructor parameter named followRepository while the container registers followUserRepository. Under awilix CLASSIC that resolves by name, so it compiled cleanly and failed only when the container built the controller. Booting the app is the only check that finds this. - /articles/me is matched ahead of /articles/:slug by find-my-way, confirmed by the route answering 401 rather than the slug route's 404. Co-Authored-By: Claude Opus 5 --- .../get-article/get-article-usecase.input.ts | 10 + .../get-article/get-article.usecase.ts | 43 +++ .../use-cases/article/get-article/index.ts | 6 + .../get-articles-usecase.input.ts | 30 ++ .../get-articles-usecase.output.ts | 12 + .../get-articles/get-articles.usecase.ts | 270 +++++++++++++ .../use-cases/article/get-articles/index.ts | 7 + .../get-my-articles-usecase.input.ts | 24 ++ .../get-my-articles-usecase.output.ts | 12 + .../get-my-articles.usecase.ts | 39 ++ .../article/get-my-articles/index.ts | 7 + src/http/controllers/article.controller.ts | 115 ++++++ src/http/plugins/di/use-cases.di.ts | 18 + src/http/routes/article/article.routes.ts | 67 ++++ .../schemas/article/get-article.schema.ts | 15 + .../schemas/article/get-articles.schema.ts | 29 ++ .../schemas/article/get-my-articles.schema.ts | 10 + tests/e2e/article/read.test.ts | 361 ++++++++++++++++++ .../article/get-article.usecase.test.ts | 116 ++++++ .../article/get-articles.usecase.test.ts | 182 +++++++++ .../article/get-my-articles.usecase.test.ts | 58 +++ 21 files changed, 1431 insertions(+) create mode 100644 src/core/use-cases/article/get-article/get-article-usecase.input.ts create mode 100644 src/core/use-cases/article/get-article/get-article.usecase.ts create mode 100644 src/core/use-cases/article/get-article/index.ts create mode 100644 src/core/use-cases/article/get-articles/get-articles-usecase.input.ts create mode 100644 src/core/use-cases/article/get-articles/get-articles-usecase.output.ts create mode 100644 src/core/use-cases/article/get-articles/get-articles.usecase.ts create mode 100644 src/core/use-cases/article/get-articles/index.ts create mode 100644 src/core/use-cases/article/get-my-articles/get-my-articles-usecase.input.ts create mode 100644 src/core/use-cases/article/get-my-articles/get-my-articles-usecase.output.ts create mode 100644 src/core/use-cases/article/get-my-articles/get-my-articles.usecase.ts create mode 100644 src/core/use-cases/article/get-my-articles/index.ts create mode 100644 src/http/types/schemas/article/get-article.schema.ts create mode 100644 src/http/types/schemas/article/get-articles.schema.ts create mode 100644 src/http/types/schemas/article/get-my-articles.schema.ts create mode 100644 tests/e2e/article/read.test.ts create mode 100644 tests/unit/core/use-cases/article/get-article.usecase.test.ts create mode 100644 tests/unit/core/use-cases/article/get-articles.usecase.test.ts create mode 100644 tests/unit/core/use-cases/article/get-my-articles.usecase.test.ts diff --git a/src/core/use-cases/article/get-article/get-article-usecase.input.ts b/src/core/use-cases/article/get-article/get-article-usecase.input.ts new file mode 100644 index 0000000..3d6fa06 --- /dev/null +++ b/src/core/use-cases/article/get-article/get-article-usecase.input.ts @@ -0,0 +1,10 @@ +/** + * Input for reading a single article by slug. + */ +export interface GetArticleUseCaseInput { + /** The article slug from the URL */ + slug: string; + + /** The viewer, when authenticated */ + viewerId?: string; +} diff --git a/src/core/use-cases/article/get-article/get-article.usecase.ts b/src/core/use-cases/article/get-article/get-article.usecase.ts new file mode 100644 index 0000000..3a7360a --- /dev/null +++ b/src/core/use-cases/article/get-article/get-article.usecase.ts @@ -0,0 +1,43 @@ +import type { Article } from "@core/domain/entities/article.entity"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import { NotFoundError } from "@core/errors"; +import type { GetArticleUseCaseInput } from "./get-article-usecase.input"; + +/** + * Use case for reading a single article by its slug. + * + * This is the second of the two layers keeping drafts private. The repository + * returns an article of any status so an author can read their own draft back; + * this use case decides who is allowed to see it. + */ +export class GetArticleUseCase { + /** + * Creates a new instance of GetArticleUseCase. + * + * @param articleRepository - Repository for reading articles + */ + constructor(private readonly articleRepository: IArticleRepository) {} + + /** + * Executes the lookup. + * + * @param input - The slug and the viewer + * @returns The article, when the viewer may see it + * + * @throws NotFoundError - When no article matches, or the viewer may not + * see it. Deliberately not a 403: a different status code for a draft that + * exists would confirm the slug, which is the leak this prevents. + */ + async execute(input: GetArticleUseCaseInput): Promise
{ + const article = await this.articleRepository.findBySlug( + input.slug, + input.viewerId, + ); + + if (!article || !article.isVisibleTo(input.viewerId)) { + throw new NotFoundError("Article not found."); + } + + return article; + } +} diff --git a/src/core/use-cases/article/get-article/index.ts b/src/core/use-cases/article/get-article/index.ts new file mode 100644 index 0000000..232a6a6 --- /dev/null +++ b/src/core/use-cases/article/get-article/index.ts @@ -0,0 +1,6 @@ +/** + * Single article read module exports. + */ + +export * from "./get-article.usecase"; +export * from "./get-article-usecase.input"; diff --git a/src/core/use-cases/article/get-articles/get-articles-usecase.input.ts b/src/core/use-cases/article/get-articles/get-articles-usecase.input.ts new file mode 100644 index 0000000..2cffd45 --- /dev/null +++ b/src/core/use-cases/article/get-articles/get-articles-usecase.input.ts @@ -0,0 +1,30 @@ +import type { PostCategory } from "@core/domain/enums/post-category-enum"; + +/** + * Input for the public article list. + * + * There is no status filter: this list is published articles only, decided by + * the repository rather than by the caller. + */ +export interface GetArticlesUseCaseInput { + /** 1-based page number */ + page?: number; + + /** Page size */ + limit?: number; + + /** Restrict to articles carrying this tag */ + tag?: string; + + /** Restrict to articles written by this username */ + authorUsername?: string; + + /** Restrict to articles in any of these categories */ + categories?: PostCategory[]; + + /** Restrict to authors the viewer follows; requires authentication */ + followedOnly?: boolean; + + /** The viewer, used for like and bookmark flags and for the cache key */ + currentUserId?: string; +} diff --git a/src/core/use-cases/article/get-articles/get-articles-usecase.output.ts b/src/core/use-cases/article/get-articles/get-articles-usecase.output.ts new file mode 100644 index 0000000..c3fdff6 --- /dev/null +++ b/src/core/use-cases/article/get-articles/get-articles-usecase.output.ts @@ -0,0 +1,12 @@ +import type { Article } from "@core/domain/entities/article.entity"; + +/** + * Output of the public article list. + */ +export interface GetArticlesUseCaseOutput { + /** The page of articles */ + articles: Article[]; + + /** Total number of articles matching the filters */ + total: number; +} diff --git a/src/core/use-cases/article/get-articles/get-articles.usecase.ts b/src/core/use-cases/article/get-articles/get-articles.usecase.ts new file mode 100644 index 0000000..042270c --- /dev/null +++ b/src/core/use-cases/article/get-articles/get-articles.usecase.ts @@ -0,0 +1,270 @@ +import { Article } from "@core/domain/entities/article.entity"; +import type { ArticleStatus } from "@core/domain/enums"; +import type { PostCategory } from "@core/domain/enums/post-category-enum"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { UnauthorizedError } from "@core/errors"; +import type { GetArticlesUseCaseInput } from "./get-articles-usecase.input"; +import type { GetArticlesUseCaseOutput } from "./get-articles-usecase.output"; + +/** How long a rendered page of the list stays cached, in seconds. */ +const CACHE_TTL_SECONDS = 60; + +/** Default page size when the caller does not ask for one. */ +const DEFAULT_LIMIT = 10; + +/** + * The exact shape written to the cache. + * + * Declared explicitly rather than spreading whatever the entity happened to + * serialize to: a loose shape keeps stale fields alive across deploys, and the + * reader silently accepts them. + */ +interface CachedArticle { + id: string; + slug: string; + title: string; + body: string; + excerpt: string | null; + coverImageKey: string | null; + coverImageAlt: string | null; + status: string; + publishedAt: string | null; + readingTimeMinutes: number; + author: { + id: string; + username?: string; + avatarUrl?: string; + fullName?: string; + }; + tags: string[]; + categories: string[]; + createdAt: string; + updatedAt: string; + likeCount: number; + commentCount: number; + isLiked: boolean; + isBookmarked: boolean; +} + +interface CachedPage { + articles: CachedArticle[]; + total: number; +} + +/** + * Use case for the public, paginated article list. + * + * Only published articles ever reach this path, and the cache is only ever + * touched here: an author reading their own drafts goes through + * GetMyArticlesUseCase, which shares no cache key space with this one. + */ +export class GetArticlesUseCase { + /** + * Creates a new instance of GetArticlesUseCase. + * + * Parameter names are load-bearing: awilix runs in CLASSIC mode and + * resolves each argument by its name, so they must match the container + * registration keys exactly. + * + * @param articleRepository - Repository for reading articles + * @param cacheService - Cache holding rendered pages of the list + * @param userRepository - Used to resolve an author username to an id + * @param followUserRepository - Used to resolve the followed-authors filter + */ + constructor( + private readonly articleRepository: IArticleRepository, + private readonly cacheService: CachePort, + private readonly userRepository: IUserRepository, + private readonly followUserRepository: IFollowRepository, + ) {} + + /** + * Executes the list query. + * + * @param input - Pagination and filters + * @returns The page of published articles and the total count + * + * @throws UnauthorizedError - When followedOnly is used without a viewer + */ + async execute( + input: GetArticlesUseCaseInput, + ): Promise { + const page = input.page ?? 1; + const limit = input.limit ?? DEFAULT_LIMIT; + const followedOnly = input.followedOnly ?? false; + + if (followedOnly && !input.currentUserId) { + throw new UnauthorizedError( + "Authentication is required to use the followedOnly filter.", + ); + } + + const cacheKey = this.buildCacheKey(input, page, limit, followedOnly); + const cached = await this.cacheService.get(cacheKey); + + if (cached) { + const parsed = JSON.parse(cached) as CachedPage; + return { + articles: parsed.articles.map((entry) => this.fromCache(entry)), + total: parsed.total, + }; + } + + let authorId: string | undefined; + if (input.authorUsername) { + const author = await this.userRepository.findByUsername( + input.authorUsername, + ); + + // An unknown username is a filter that matches nothing, not an + // error: it must not be distinguishable from an author with no + // published articles. + if (!author) return { articles: [], total: 0 }; + + authorId = author.id; + } + + const followingIds = followedOnly + ? await this.followUserRepository.getFollowingIds( + input.currentUserId as string, + ) + : undefined; + + const result = await this.articleRepository.findAll({ + page, + limit, + tag: input.tag, + authorId, + categories: input.categories, + followingIds, + currentUserId: input.currentUserId, + }); + + await this.cacheService.set( + cacheKey, + JSON.stringify({ + articles: result.articles.map((article) => + this.toCache(article), + ), + total: result.total, + } satisfies CachedPage), + CACHE_TTL_SECONDS, + ); + + return result; + } + + /** + * Builds the cache key for one page of the list. + * + * Every filter appears in the key, and absent values become a literal so + * the key space stays flat and a single pattern delete can clear it. + * + * @param input - The request filters + * @param page - Resolved page number + * @param limit - Resolved page size + * @param followedOnly - Resolved followed-authors flag + * @returns The cache key + */ + private buildCacheKey( + input: GetArticlesUseCaseInput, + page: number, + limit: number, + followedOnly: boolean, + ): string { + const tag = input.tag ?? "ALL"; + const author = input.authorUsername ?? "ALL"; + const categories = + input.categories && input.categories.length > 0 + ? [...input.categories].sort().join(",") + : "ALL"; + const viewer = input.currentUserId ?? "guest"; + + return ( + "articles:list:page:" + + page + + ":limit:" + + limit + + ":tag:" + + tag + + ":author:" + + author + + ":categories:" + + categories + + ":followedOnly:" + + followedOnly + + ":user:" + + viewer + ); + } + + /** + * Projects an article onto the cached shape. + * + * @param article - The article to cache + * @returns The serializable projection + */ + private toCache(article: Article): CachedArticle { + return { + id: article.id, + slug: article.slug, + title: article.title, + body: article.body, + excerpt: article.excerpt, + coverImageKey: article.coverImageKey, + coverImageAlt: article.coverImageAlt, + status: article.status, + publishedAt: article.publishedAt + ? article.publishedAt.toISOString() + : null, + readingTimeMinutes: article.readingTimeMinutes, + author: { + id: article.author.id, + username: article.author.username, + avatarUrl: article.author.avatarUrl, + fullName: article.author.fullName, + }, + tags: article.tags, + categories: article.categories, + createdAt: article.createdAt.toISOString(), + updatedAt: article.updatedAt.toISOString(), + likeCount: article.likeCount, + commentCount: article.commentCount, + isLiked: article.isLiked, + isBookmarked: article.isBookmarked, + }; + } + + /** + * Rebuilds an article from the cached shape, field by field. + * + * @param entry - The cached projection + * @returns The reconstructed article + */ + private fromCache(entry: CachedArticle): Article { + return Article.with({ + id: entry.id, + slug: entry.slug, + title: entry.title, + body: entry.body, + excerpt: entry.excerpt, + coverImageKey: entry.coverImageKey, + coverImageAlt: entry.coverImageAlt, + status: entry.status as ArticleStatus, + publishedAt: entry.publishedAt ? new Date(entry.publishedAt) : null, + readingTimeMinutes: entry.readingTimeMinutes, + author: entry.author, + tags: entry.tags, + categories: entry.categories as PostCategory[], + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + likeCount: entry.likeCount, + commentCount: entry.commentCount, + isLiked: entry.isLiked, + isBookmarked: entry.isBookmarked, + }); + } +} diff --git a/src/core/use-cases/article/get-articles/index.ts b/src/core/use-cases/article/get-articles/index.ts new file mode 100644 index 0000000..5c6f365 --- /dev/null +++ b/src/core/use-cases/article/get-articles/index.ts @@ -0,0 +1,7 @@ +/** + * Public article list module exports. + */ + +export * from "./get-articles.usecase"; +export * from "./get-articles-usecase.input"; +export * from "./get-articles-usecase.output"; diff --git a/src/core/use-cases/article/get-my-articles/get-my-articles-usecase.input.ts b/src/core/use-cases/article/get-my-articles/get-my-articles-usecase.input.ts new file mode 100644 index 0000000..7654b1c --- /dev/null +++ b/src/core/use-cases/article/get-my-articles/get-my-articles-usecase.input.ts @@ -0,0 +1,24 @@ +import type { ArticleStatus } from "@core/domain/enums"; + +/** + * Input for an author listing their own articles. + */ +export interface GetMyArticlesUseCaseInput { + /** + * The authenticated author. + * + * Always taken from the verified token by the controller, never from a + * path or query parameter: this is the only read path that returns + * unpublished articles. + */ + authorId: string; + + /** 1-based page number */ + page?: number; + + /** Page size */ + limit?: number; + + /** Optional status filter */ + status?: ArticleStatus; +} diff --git a/src/core/use-cases/article/get-my-articles/get-my-articles-usecase.output.ts b/src/core/use-cases/article/get-my-articles/get-my-articles-usecase.output.ts new file mode 100644 index 0000000..c5fb970 --- /dev/null +++ b/src/core/use-cases/article/get-my-articles/get-my-articles-usecase.output.ts @@ -0,0 +1,12 @@ +import type { Article } from "@core/domain/entities/article.entity"; + +/** + * Output of an author's own article list. + */ +export interface GetMyArticlesUseCaseOutput { + /** The page of articles, drafts included */ + articles: Article[]; + + /** Total number of articles owned by the author */ + total: number; +} diff --git a/src/core/use-cases/article/get-my-articles/get-my-articles.usecase.ts b/src/core/use-cases/article/get-my-articles/get-my-articles.usecase.ts new file mode 100644 index 0000000..5ea9c04 --- /dev/null +++ b/src/core/use-cases/article/get-my-articles/get-my-articles.usecase.ts @@ -0,0 +1,39 @@ +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { GetMyArticlesUseCaseInput } from "./get-my-articles-usecase.input"; +import type { GetMyArticlesUseCaseOutput } from "./get-my-articles-usecase.output"; + +/** Default page size when the caller does not ask for one. */ +const DEFAULT_LIMIT = 10; + +/** + * Use case for an author reading their own articles, drafts included. + * + * This use case deliberately has no cache dependency. Sharing a cache with the + * public list is exactly how a draft leaks into it, so the ability is not + * present rather than merely unused. + */ +export class GetMyArticlesUseCase { + /** + * Creates a new instance of GetMyArticlesUseCase. + * + * @param articleRepository - Repository for reading articles + */ + constructor(private readonly articleRepository: IArticleRepository) {} + + /** + * Executes the query. + * + * @param input - The author, pagination and an optional status filter + * @returns The page of articles and the total count + */ + async execute( + input: GetMyArticlesUseCaseInput, + ): Promise { + return await this.articleRepository.findByAuthorId({ + authorId: input.authorId, + page: input.page ?? 1, + limit: input.limit ?? DEFAULT_LIMIT, + status: input.status, + }); + } +} diff --git a/src/core/use-cases/article/get-my-articles/index.ts b/src/core/use-cases/article/get-my-articles/index.ts new file mode 100644 index 0000000..56f7bbf --- /dev/null +++ b/src/core/use-cases/article/get-my-articles/index.ts @@ -0,0 +1,7 @@ +/** + * Author article list module exports. + */ + +export * from "./get-my-articles.usecase"; +export * from "./get-my-articles-usecase.input"; +export * from "./get-my-articles-usecase.output"; diff --git a/src/http/controllers/article.controller.ts b/src/http/controllers/article.controller.ts index 45f0510..4075196 100644 --- a/src/http/controllers/article.controller.ts +++ b/src/http/controllers/article.controller.ts @@ -4,10 +4,16 @@ import type { UpdateArticleUseCase } from "@core/use-cases/article/update-articl import type { PublishArticleUseCase } from "@core/use-cases/article/publish-article"; import type { ArchiveArticleUseCase } from "@core/use-cases/article/archive-article"; import type { DeleteArticleUseCase } from "@core/use-cases/article/delete-article"; +import type { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; +import type { GetArticleUseCase } from "@core/use-cases/article/get-article"; +import type { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; import { ArticlePrismaMapper } from "@infrastructure/persistence/mappers/article-prisma.mapper"; import type { CreateArticleBody } from "@typings/schemas/article/create-article.schema"; import type { UpdateArticleBody } from "@typings/schemas/article/update-article.schema"; import type { ArticleIdParams } from "@typings/schemas/article/article-params.schema"; +import type { GetArticlesQuery } from "@typings/schemas/article/get-articles.schema"; +import type { GetArticleParams } from "@typings/schemas/article/get-article.schema"; +import type { GetMyArticlesQuery } from "@typings/schemas/article/get-my-articles.schema"; /** * Controller for article write operations. @@ -24,6 +30,9 @@ export class ArticleController { * @param publishArticleUseCase - Use case for publishing an article * @param archiveArticleUseCase - Use case for archiving an article * @param deleteArticleUseCase - Use case for deleting an article + * @param getArticlesUseCase - Use case for the public article list + * @param getArticleUseCase - Use case for reading one article by slug + * @param getMyArticlesUseCase - Use case for an author's own articles */ constructor( private readonly createArticleUseCase: CreateArticleUseCase, @@ -31,6 +40,9 @@ export class ArticleController { private readonly publishArticleUseCase: PublishArticleUseCase, private readonly archiveArticleUseCase: ArchiveArticleUseCase, private readonly deleteArticleUseCase: DeleteArticleUseCase, + private readonly getArticlesUseCase: GetArticlesUseCase, + private readonly getArticleUseCase: GetArticleUseCase, + private readonly getMyArticlesUseCase: GetMyArticlesUseCase, ) {} /** @@ -168,6 +180,109 @@ export class ArticleController { return reply.status(204).send(); } + /** + * Returns a page of published articles. + * + * @param request - Request carrying the pagination and filter query + * @param reply - The Fastify reply object + * @returns A 200 response with the page and its counts + */ + async list( + request: FastifyRequest<{ Querystring: GetArticlesQuery }>, + reply: FastifyReply, + ): Promise { + const currentUserId = request.user?.id; + const { page = 1, limit = 10 } = request.query; + + const result = await this.getArticlesUseCase.execute({ + ...request.query, + page, + limit, + currentUserId, + }); + + return reply.status(200).send({ + data: ArticlePrismaMapper.toListResponse( + result.articles, + this.cdnUrl(request), + currentUserId, + ), + meta: { + total: result.total, + currentPage: page, + limit, + totalPages: Math.ceil(result.total / limit), + }, + }); + } + + /** + * Returns the authenticated author's own articles, drafts included. + * + * The author is read from the token, so this cannot be pointed at another + * user by changing a parameter. + * + * @param request - Request carrying the pagination and status query + * @param reply - The Fastify reply object + * @returns A 200 response with the page and its counts + */ + async mine( + request: FastifyRequest<{ Querystring: GetMyArticlesQuery }>, + reply: FastifyReply, + ): Promise { + const authorId = request.user.id; + const { page = 1, limit = 10, status } = request.query; + + const result = await this.getMyArticlesUseCase.execute({ + authorId, + page, + limit, + status, + }); + + return reply.status(200).send({ + data: ArticlePrismaMapper.toListResponse( + result.articles, + this.cdnUrl(request), + authorId, + ), + meta: { + total: result.total, + currentPage: page, + limit, + totalPages: Math.ceil(result.total / limit), + }, + }); + } + + /** + * Returns a single article by slug. + * + * @param request - Request carrying the slug + * @param reply - The Fastify reply object + * @returns A 200 response with the article + */ + async detail( + request: FastifyRequest<{ Params: GetArticleParams }>, + reply: FastifyReply, + ): Promise { + const viewerId = request.user?.id; + + const article = await this.getArticleUseCase.execute({ + slug: request.params.slug, + viewerId, + }); + + return reply.status(200).send({ + data: ArticlePrismaMapper.toResponse( + article, + this.cdnUrl(request), + viewerId, + ), + meta: { timestamp: new Date().toISOString() }, + }); + } + /** * Resolves the CDN base URL, without a trailing slash. * diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 1165ff7..40fca65 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -61,6 +61,9 @@ import { UpdateArticleUseCase } from "@core/use-cases/article/update-article"; import { PublishArticleUseCase } from "@core/use-cases/article/publish-article"; import { ArchiveArticleUseCase } from "@core/use-cases/article/archive-article"; import { DeleteArticleUseCase } from "@core/use-cases/article/delete-article"; +import { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; +import { GetArticleUseCase } from "@core/use-cases/article/get-article"; +import { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; /** * Dependency injection module for use cases @@ -434,4 +437,19 @@ export const useCasesModule = { * Use case for deleting an article */ deleteArticleUseCase: asClass(DeleteArticleUseCase).singleton(), + + /** + * Use case for the public article list + */ + getArticlesUseCase: asClass(GetArticlesUseCase).singleton(), + + /** + * Use case for reading a single article by slug + */ + getArticleUseCase: asClass(GetArticleUseCase).singleton(), + + /** + * Use case for an author's own article list + */ + getMyArticlesUseCase: asClass(GetMyArticlesUseCase).singleton(), }; diff --git a/src/http/routes/article/article.routes.ts b/src/http/routes/article/article.routes.ts index 1cd6120..0d6f072 100644 --- a/src/http/routes/article/article.routes.ts +++ b/src/http/routes/article/article.routes.ts @@ -22,6 +22,20 @@ import { updateArticleBodySchema, type UpdateArticleBody, } from "@typings/schemas/article/update-article.schema"; +import { + getArticlesQuerySchema, + GetArticlesResponseSchema, + type GetArticlesQuery, + type GetArticlesResponse, +} from "@typings/schemas/article/get-articles.schema"; +import { + getArticleParamsSchema, + type GetArticleParams, +} from "@typings/schemas/article/get-article.schema"; +import { + getMyArticlesQuerySchema, + type GetMyArticlesQuery, +} from "@typings/schemas/article/get-my-articles.schema"; /** * Registers the article write endpoints. @@ -35,6 +49,59 @@ import { export function articleRoutes(fastify: FastifyInstance): void { const { articleController } = fastify.diContainer.cradle; + // Read routes are declared first for readability only: find-my-way scores + // the static "me" segment above the ":slug" parameter regardless of order. + fastify.get<{ + Querystring: GetArticlesQuery; + Reply: { 200: GetArticlesResponse }; + }>( + "/articles", + { + onRequest: [fastify.optionalAuthenticate], + schema: { + querystring: getArticlesQuerySchema, + response: { 200: GetArticlesResponseSchema }, + tags: ["Article"], + }, + config: { rateLimit: RateLimitPolicies.PUBLIC }, + }, + articleController.list.bind(articleController), + ); + + fastify.get<{ + Querystring: GetMyArticlesQuery; + Reply: { 200: GetArticlesResponse }; + }>( + "/articles/me", + { + onRequest: [fastify.authenticate], + schema: { + querystring: getMyArticlesQuerySchema, + response: { 200: GetArticlesResponseSchema }, + tags: ["Article"], + }, + config: { rateLimit: RateLimitPolicies.STANDARD }, + }, + articleController.mine.bind(articleController), + ); + + fastify.get<{ + Params: GetArticleParams; + Reply: { 200: ArticleResponse }; + }>( + "/articles/:slug", + { + onRequest: [fastify.optionalAuthenticate], + schema: { + params: getArticleParamsSchema, + response: { 200: ArticleResponseSchema }, + tags: ["Article"], + }, + config: { rateLimit: RateLimitPolicies.PUBLIC }, + }, + articleController.detail.bind(articleController), + ); + fastify.post<{ Body: CreateArticleBody; Reply: { 201: ArticleResponse } }>( "/articles", { diff --git a/src/http/types/schemas/article/get-article.schema.ts b/src/http/types/schemas/article/get-article.schema.ts new file mode 100644 index 0000000..7595e6d --- /dev/null +++ b/src/http/types/schemas/article/get-article.schema.ts @@ -0,0 +1,15 @@ +import { Type, type Static } from "@fastify/type-provider-typebox"; + +/** + * The slug pattern is deliberately narrow: it is the only shape the slug + * generator can produce, so anything else can be rejected by the router before + * it reaches a query. + */ +export const getArticleParamsSchema = Type.Object({ + slug: Type.String({ + pattern: "^[a-z0-9-]{1,120}$", + description: "The URL slug of the article", + }), +}); + +export type GetArticleParams = Static; diff --git a/src/http/types/schemas/article/get-articles.schema.ts b/src/http/types/schemas/article/get-articles.schema.ts new file mode 100644 index 0000000..0d4c52f --- /dev/null +++ b/src/http/types/schemas/article/get-articles.schema.ts @@ -0,0 +1,29 @@ +import { Type, type Static } from "@fastify/type-provider-typebox"; +import { PostCategory } from "@core/domain/enums/post-category-enum"; +import { ArticleItemSchema } from "./article-item.schema"; + +export const getArticlesQuerySchema = Type.Object({ + page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, default: 10 })), + tag: Type.Optional(Type.String({ maxLength: 30 })), + authorUsername: Type.Optional(Type.String({ maxLength: 30 })), + categories: Type.Optional( + Type.Array(Type.Enum(PostCategory), { maxItems: 5, uniqueItems: true }), + ), + followedOnly: Type.Optional(Type.Boolean({ default: false })), +}); + +export type GetArticlesQuery = Static; + +/** Paginated envelope, hand-rolled because it carries counts rather than a timestamp. */ +export const GetArticlesResponseSchema = Type.Object({ + data: Type.Array(ArticleItemSchema), + meta: Type.Object({ + total: Type.Number(), + currentPage: Type.Number(), + limit: Type.Number(), + totalPages: Type.Number(), + }), +}); + +export type GetArticlesResponse = Static; diff --git a/src/http/types/schemas/article/get-my-articles.schema.ts b/src/http/types/schemas/article/get-my-articles.schema.ts new file mode 100644 index 0000000..a917d40 --- /dev/null +++ b/src/http/types/schemas/article/get-my-articles.schema.ts @@ -0,0 +1,10 @@ +import { Type, type Static } from "@fastify/type-provider-typebox"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; + +export const getMyArticlesQuerySchema = Type.Object({ + page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, default: 10 })), + status: Type.Optional(Type.Enum(ArticleStatus)), +}); + +export type GetMyArticlesQuery = Static; diff --git a/tests/e2e/article/read.test.ts b/tests/e2e/article/read.test.ts new file mode 100644 index 0000000..919fc21 --- /dev/null +++ b/tests/e2e/article/read.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { request, authRequest, parseBody } from "../setup"; + +interface ArticleData { + id: string; + slug: string; + title: string; + status: string; + publishedAt: string | null; + tags: { name: string }[]; + author: { id: string; isMe: boolean }; +} + +type ArticleEnvelope = { data: ArticleData; meta: { timestamp: string } }; +type ListEnvelope = { + data: ArticleData[]; + meta: { + total: number; + currentPage: number; + limit: number; + totalPages: number; + }; +}; +type ErrorEnvelope = { title: string; status: number }; + +const ts = Date.now(); +const author = { + email: `reader-author-${ts}@article-read-test.com`, + password: "password123", + username: `ra${ts}`, +}; +const stranger = { + email: `reader-stranger-${ts}@article-read-test.com`, + password: "password123", + username: `rs${ts}`, +}; + +let authorToken: string; +let strangerToken: string; + +const readTag = `readtag${ts}`.slice(0, 30); + +let draft: ArticleData; +let published: ArticleData; +let archived: ArticleData; + +/** + * Registers a user and returns their access token. + */ +async function login(user: { + email: string; + password: string; + username: string; +}): Promise { + await request({ method: "POST", url: "/auth/register", payload: user }); + const response = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + return parseBody<{ data: { accessToken: string } }>(response).data + .accessToken; +} + +/** + * Creates a draft owned by the author. + */ +async function createDraft( + title: string, + extra: Record = {}, +): Promise { + const response = await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { title, body: "Body prose for the read tests.", ...extra }, + }); + return parseBody(response).data; +} + +beforeAll(async () => { + authorToken = await login(author); + strangerToken = await login(stranger); + + draft = await createDraft(`Draft piece ${ts}`); + + published = await createDraft(`Published piece ${ts}`, { + tags: [readTag], + }); + await authRequest(authorToken, { + method: "POST", + url: `/articles/${published.id}/publish`, + }); + + archived = await createDraft(`Archived piece ${ts}`); + await authRequest(authorToken, { + method: "POST", + url: `/articles/${archived.id}/publish`, + }); + await authRequest(authorToken, { + method: "POST", + url: `/articles/${archived.id}/archive`, + }); +}); + +describe("GET /articles", () => { + it("should return published articles with pagination metadata", async () => { + const response = await request({ + method: "GET", + url: "/articles?limit=50", + }); + const body = parseBody(response); + + expect(response.statusCode).toBe(200); + expect(body.meta).toEqual({ + total: expect.any(Number), + currentPage: 1, + limit: 50, + totalPages: expect.any(Number), + }); + expect(body.data.map((a) => a.id)).toContain(published.id); + }); + + it("should never include drafts or archived articles", async () => { + const response = await request({ + method: "GET", + url: "/articles?limit=50", + }); + const ids = parseBody(response).data.map((a) => a.id); + + expect(ids).not.toContain(draft.id); + expect(ids).not.toContain(archived.id); + }); + + it("should not leak the author's own drafts back to the author", async () => { + const response = await authRequest(authorToken, { + method: "GET", + url: "/articles?limit=50", + }); + const ids = parseBody(response).data.map((a) => a.id); + + expect(ids).toContain(published.id); + expect(ids).not.toContain(draft.id); + }); + + it("should filter by tag without exposing drafts", async () => { + const response = await request({ + method: "GET", + url: `/articles?tag=${readTag}`, + }); + const ids = parseBody(response).data.map((a) => a.id); + + expect(ids).not.toContain(draft.id); + }); + + it("should filter by author username", async () => { + const response = await request({ + method: "GET", + url: `/articles?authorUsername=${author.username}&limit=50`, + }); + const body = parseBody(response); + + expect(body.data.map((a) => a.id)).toContain(published.id); + expect(body.data.every((a) => a.status === "PUBLISHED")).toBe(true); + }); + + it("should return an empty page for an unknown author", async () => { + const response = await request({ + method: "GET", + url: "/articles?authorUsername=nobody-here-at-all", + }); + const body = parseBody(response); + + expect(response.statusCode).toBe(200); + expect(body.data).toEqual([]); + expect(body.meta.total).toBe(0); + }); + + it("should require authentication for the followedOnly filter", async () => { + const response = await request({ + method: "GET", + url: "/articles?followedOnly=true", + }); + + expect(response.statusCode).toBe(401); + }); +}); + +describe("GET /articles/:slug", () => { + it("should return a published article to a guest", async () => { + const response = await request({ + method: "GET", + url: `/articles/${published.slug}`, + }); + const body = parseBody(response); + + expect(response.statusCode).toBe(200); + expect(body.data.id).toBe(published.id); + expect(body.data.author.isMe).toBe(false); + }); + + it("should hide a draft from a guest", async () => { + const response = await request({ + method: "GET", + url: `/articles/${draft.slug}`, + }); + + expect(response.statusCode).toBe(404); + expect(parseBody(response).title).toBe("NotFoundError"); + }); + + it("should hide a draft from another authenticated user", async () => { + const response = await authRequest(strangerToken, { + method: "GET", + url: `/articles/${draft.slug}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return the draft to its own author", async () => { + const response = await authRequest(authorToken, { + method: "GET", + url: `/articles/${draft.slug}`, + }); + const body = parseBody(response); + + expect(response.statusCode).toBe(200); + expect(body.data.status).toBe("DRAFT"); + expect(body.data.author.isMe).toBe(true); + }); + + it("should hide an archived article from everyone but its author", async () => { + const asGuest = await request({ + method: "GET", + url: `/articles/${archived.slug}`, + }); + const asAuthor = await authRequest(authorToken, { + method: "GET", + url: `/articles/${archived.slug}`, + }); + + expect(asGuest.statusCode).toBe(404); + expect(asAuthor.statusCode).toBe(200); + }); + + it("should answer 404 for an unknown slug, the same as for a draft", async () => { + const unknown = await request({ + method: "GET", + url: "/articles/no-such-article-00000000", + }); + const draftResponse = await request({ + method: "GET", + url: `/articles/${draft.slug}`, + }); + + expect(unknown.statusCode).toBe(draftResponse.statusCode); + expect(parseBody(unknown).title).toBe( + parseBody(draftResponse).title, + ); + }); + + it("should reject a slug that cannot have been generated", async () => { + const response = await request({ + method: "GET", + url: "/articles/Not_A_Valid_Slug", + }); + + expect(response.statusCode).toBe(400); + }); +}); + +describe("GET /articles/me", () => { + it("should route to the author list rather than the slug lookup", async () => { + const response = await request({ method: "GET", url: "/articles/me" }); + + expect(response.statusCode).toBe(401); + }); + + it("should include the author's drafts and archived articles", async () => { + const response = await authRequest(authorToken, { + method: "GET", + url: "/articles/me?limit=50", + }); + const ids = parseBody(response).data.map((a) => a.id); + + expect(response.statusCode).toBe(200); + expect(ids).toContain(draft.id); + expect(ids).toContain(published.id); + expect(ids).toContain(archived.id); + }); + + it("should filter by status", async () => { + const response = await authRequest(authorToken, { + method: "GET", + url: "/articles/me?status=DRAFT&limit=50", + }); + const body = parseBody(response); + + expect(body.data.every((a) => a.status === "DRAFT")).toBe(true); + expect(body.data.map((a) => a.id)).toContain(draft.id); + }); + + it("should never return another user's articles", async () => { + const response = await authRequest(strangerToken, { + method: "GET", + url: "/articles/me?limit=50", + }); + const body = parseBody(response); + + expect(body.data).toEqual([]); + expect(body.meta.total).toBe(0); + }); +}); + +describe("cache isolation", () => { + it("should not serve a cached guest page to an author, or the reverse", async () => { + const guestFirst = await request({ + method: "GET", + url: "/articles?limit=50", + }); + const asAuthor = await authRequest(authorToken, { + method: "GET", + url: "/articles?limit=50", + }); + const guestSecond = await request({ + method: "GET", + url: "/articles?limit=50", + }); + + for (const response of [guestFirst, asAuthor, guestSecond]) { + const ids = parseBody(response).data.map((a) => a.id); + expect(ids).not.toContain(draft.id); + expect(ids).not.toContain(archived.id); + } + }); + + it("should reflect a publish immediately, not after the cache expires", async () => { + const fresh = await createDraft(`Cache check ${ts}`); + + const before = await request({ + method: "GET", + url: "/articles?limit=50", + }); + expect( + parseBody(before).data.map((a) => a.id), + ).not.toContain(fresh.id); + + await authRequest(authorToken, { + method: "POST", + url: `/articles/${fresh.id}/publish`, + }); + + const after = await request({ + method: "GET", + url: "/articles?limit=50", + }); + expect(parseBody(after).data.map((a) => a.id)).toContain( + fresh.id, + ); + }); +}); diff --git a/tests/unit/core/use-cases/article/get-article.usecase.test.ts b/tests/unit/core/use-cases/article/get-article.usecase.test.ts new file mode 100644 index 0000000..379443c --- /dev/null +++ b/tests/unit/core/use-cases/article/get-article.usecase.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetArticleUseCase } from "@core/use-cases/article/get-article"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; +import { NotFoundError } from "@core/errors"; +import { buildArticle } from "../../../helpers/mock-factories"; + +const AUTHOR = "11111111-1111-4111-8111-111111111111"; +const STRANGER = "22222222-2222-4222-8222-222222222222"; + +describe("GetArticleUseCase", () => { + let useCase: GetArticleUseCase; + let articleRepository: Pick; + + beforeEach(() => { + articleRepository = { findBySlug: vi.fn() }; + useCase = new GetArticleUseCase( + articleRepository as IArticleRepository, + ); + }); + + it("should return a published article to a guest", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue( + buildArticle({ + status: ArticleStatus.PUBLISHED, + author: { id: AUTHOR }, + }), + ); + + const article = await useCase.execute({ slug: "some-slug-1a2b3c4d" }); + + expect(article.status).toBe(ArticleStatus.PUBLISHED); + }); + + it("should return the author's own draft to the author", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: AUTHOR }, + }), + ); + + const article = await useCase.execute({ + slug: "draft-1a2b3c4d", + viewerId: AUTHOR, + }); + + expect(article.status).toBe(ArticleStatus.DRAFT); + }); + + it("should throw NotFoundError when nothing matches the slug", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue(null); + + await expect(useCase.execute({ slug: "missing" })).rejects.toThrow( + NotFoundError, + ); + }); + + it("should hide a draft from a guest behind the same NotFoundError", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: AUTHOR }, + }), + ); + + await expect( + useCase.execute({ slug: "draft-1a2b3c4d" }), + ).rejects.toThrow(NotFoundError); + }); + + it("should hide a draft from another authenticated user", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: AUTHOR }, + }), + ); + + await expect( + useCase.execute({ slug: "draft-1a2b3c4d", viewerId: STRANGER }), + ).rejects.toThrow(NotFoundError); + }); + + it("should hide an archived article from everyone but its author", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue( + buildArticle({ + status: ArticleStatus.ARCHIVED, + author: { id: AUTHOR }, + }), + ); + + await expect( + useCase.execute({ slug: "archived-1a2b3c4d", viewerId: STRANGER }), + ).rejects.toThrow(NotFoundError); + + const forAuthor = await useCase.execute({ + slug: "archived-1a2b3c4d", + viewerId: AUTHOR, + }); + expect(forAuthor.status).toBe(ArticleStatus.ARCHIVED); + }); + + it("should pass the viewer down so like flags resolve", async () => { + vi.mocked(articleRepository.findBySlug).mockResolvedValue( + buildArticle({ status: ArticleStatus.PUBLISHED }), + ); + + await useCase.execute({ slug: "s-1a2b3c4d", viewerId: STRANGER }); + + expect(articleRepository.findBySlug).toHaveBeenCalledWith( + "s-1a2b3c4d", + STRANGER, + ); + }); +}); diff --git a/tests/unit/core/use-cases/article/get-articles.usecase.test.ts b/tests/unit/core/use-cases/article/get-articles.usecase.test.ts new file mode 100644 index 0000000..a209329 --- /dev/null +++ b/tests/unit/core/use-cases/article/get-articles.usecase.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { CachePort } from "@core/ports/services/cache.port"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; +import { PostCategory } from "@core/domain/enums/post-category-enum"; +import { UnauthorizedError } from "@core/errors"; +import { buildArticle, buildUser } from "../../../helpers/mock-factories"; + +const VIEWER = "11111111-1111-4111-8111-111111111111"; + +describe("GetArticlesUseCase", () => { + let useCase: GetArticlesUseCase; + let articleRepository: Pick; + let cacheService: Pick; + let userRepository: Pick; + let followUserRepository: Pick; + + beforeEach(() => { + articleRepository = { + findAll: vi.fn().mockResolvedValue({ + articles: [ + buildArticle({ + id: "a1", + status: ArticleStatus.PUBLISHED, + publishedAt: new Date("2026-01-01T00:00:00Z"), + }), + ], + total: 1, + }), + }; + cacheService = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + userRepository = { findByUsername: vi.fn() }; + followUserRepository = { + getFollowingIds: vi.fn().mockResolvedValue(["f1", "f2"]), + }; + + useCase = new GetArticlesUseCase( + articleRepository as IArticleRepository, + cacheService as CachePort, + userRepository as IUserRepository, + followUserRepository as IFollowRepository, + ); + }); + + it("should query the repository and cache the page on a miss", async () => { + const result = await useCase.execute({ page: 1, limit: 10 }); + + expect(result.total).toBe(1); + expect(articleRepository.findAll).toHaveBeenCalledTimes(1); + expect(cacheService.set).toHaveBeenCalledTimes(1); + + const [, , ttl] = vi.mocked(cacheService.set).mock.calls[0]; + expect(ttl).toBe(60); + }); + + it("should serve a cache hit without touching the repository", async () => { + const cachedPage = JSON.stringify({ + articles: [ + { + id: "cached-1", + slug: "cached-article-1a2b3c4d", + title: "Cached", + body: "Cached body", + excerpt: null, + coverImageKey: null, + coverImageAlt: null, + status: "PUBLISHED", + publishedAt: "2026-01-01T00:00:00.000Z", + readingTimeMinutes: 3, + author: { id: "u1", username: "someone" }, + tags: ["fastify"], + categories: ["BACKEND"], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + likeCount: 7, + commentCount: 2, + isLiked: true, + isBookmarked: false, + }, + ], + total: 42, + }); + vi.mocked(cacheService.get).mockResolvedValue(cachedPage); + + const result = await useCase.execute({ page: 1, limit: 10 }); + + expect(articleRepository.findAll).not.toHaveBeenCalled(); + expect(result.total).toBe(42); + expect(result.articles[0].id).toBe("cached-1"); + expect(result.articles[0].title).toBe("Cached"); + expect(result.articles[0].likeCount).toBe(7); + expect(result.articles[0].isLiked).toBe(true); + expect(result.articles[0].publishedAt).toBeInstanceOf(Date); + expect(result.articles[0].createdAt).toBeInstanceOf(Date); + }); + + it("should scope the cache key to the viewer", async () => { + await useCase.execute({ page: 1, limit: 10, currentUserId: VIEWER }); + const authenticatedKey = vi.mocked(cacheService.get).mock.calls[0][0]; + + vi.mocked(cacheService.get).mockClear(); + await useCase.execute({ page: 1, limit: 10 }); + const guestKey = vi.mocked(cacheService.get).mock.calls[0][0]; + + expect(authenticatedKey).toContain("user:" + VIEWER); + expect(guestKey).toContain("user:guest"); + expect(authenticatedKey).not.toBe(guestKey); + }); + + it("should produce a key that the invalidation pattern matches", async () => { + await useCase.execute({ page: 2, limit: 5, tag: "fastify" }); + const key = vi.mocked(cacheService.get).mock.calls[0][0]; + + expect(key.startsWith("articles:list:")).toBe(true); + }); + + it("should order categories so the same filter reuses one key", async () => { + await useCase.execute({ + categories: [PostCategory.FRONTEND, PostCategory.BACKEND], + }); + const first = vi.mocked(cacheService.get).mock.calls[0][0]; + + vi.mocked(cacheService.get).mockClear(); + await useCase.execute({ + categories: [PostCategory.BACKEND, PostCategory.FRONTEND], + }); + const second = vi.mocked(cacheService.get).mock.calls[0][0]; + + expect(first).toBe(second); + }); + + it("should reject the followedOnly filter without a viewer", async () => { + await expect(useCase.execute({ followedOnly: true })).rejects.toThrow( + UnauthorizedError, + ); + }); + + it("should resolve followed authors when the filter is used", async () => { + await useCase.execute({ followedOnly: true, currentUserId: VIEWER }); + + expect(followUserRepository.getFollowingIds).toHaveBeenCalledWith( + VIEWER, + ); + expect(articleRepository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ followingIds: ["f1", "f2"] }), + ); + }); + + it("should resolve an author username to an id", async () => { + vi.mocked(userRepository.findByUsername).mockResolvedValue( + buildUser({ id: "author-9" }), + ); + + await useCase.execute({ authorUsername: "someone" }); + + expect(articleRepository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ authorId: "author-9" }), + ); + }); + + it("should return an empty page for an unknown author rather than an error", async () => { + vi.mocked(userRepository.findByUsername).mockResolvedValue(null); + + const result = await useCase.execute({ authorUsername: "ghost" }); + + expect(result).toEqual({ articles: [], total: 0 }); + expect(articleRepository.findAll).not.toHaveBeenCalled(); + }); + + it("should never ask the repository for a status", async () => { + await useCase.execute({ page: 1, limit: 10 }); + + const params = vi.mocked(articleRepository.findAll).mock.calls[0][0]; + expect(params).not.toHaveProperty("status"); + }); +}); diff --git a/tests/unit/core/use-cases/article/get-my-articles.usecase.test.ts b/tests/unit/core/use-cases/article/get-my-articles.usecase.test.ts new file mode 100644 index 0000000..b2227ef --- /dev/null +++ b/tests/unit/core/use-cases/article/get-my-articles.usecase.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; +import { buildArticle } from "../../../helpers/mock-factories"; + +const AUTHOR = "11111111-1111-4111-8111-111111111111"; + +describe("GetMyArticlesUseCase", () => { + let useCase: GetMyArticlesUseCase; + let articleRepository: Pick; + + beforeEach(() => { + articleRepository = { + findByAuthorId: vi.fn().mockResolvedValue({ + articles: [buildArticle({ status: ArticleStatus.DRAFT })], + total: 1, + }), + }; + useCase = new GetMyArticlesUseCase( + articleRepository as IArticleRepository, + ); + }); + + it("should query only the requesting author", async () => { + await useCase.execute({ authorId: AUTHOR }); + + expect(articleRepository.findByAuthorId).toHaveBeenCalledWith( + expect.objectContaining({ authorId: AUTHOR }), + ); + }); + + it("should default pagination", async () => { + await useCase.execute({ authorId: AUTHOR }); + + expect(articleRepository.findByAuthorId).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, limit: 10 }), + ); + }); + + it("should pass a status filter through", async () => { + await useCase.execute({ + authorId: AUTHOR, + status: ArticleStatus.PUBLISHED, + }); + + expect(articleRepository.findByAuthorId).toHaveBeenCalledWith( + expect.objectContaining({ status: ArticleStatus.PUBLISHED }), + ); + }); + + it("should return drafts", async () => { + const result = await useCase.execute({ authorId: AUTHOR }); + + expect(result.articles[0].status).toBe(ArticleStatus.DRAFT); + expect(result.total).toBe(1); + }); +});