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); + }); +});