diff --git a/src/core/use-cases/article/article-input.ts b/src/core/use-cases/article/article-input.ts index 02a7904..11137f7 100644 --- a/src/core/use-cases/article/article-input.ts +++ b/src/core/use-cases/article/article-input.ts @@ -134,3 +134,22 @@ export function validateCoverImageKey( return key; } + +/** + * Normalizes a tag used as a *read* filter. + * + * Tags are stored lowercased by {@link normalizeTags}, so a filter that is not + * lowercased matches nothing. Unlike the write path this never throws: a + * malformed filter is a filter that matches no article, not a bad request, and + * a blank one is no filter at all. + * + * @param tag - The raw tag from the query string + * @returns The lowercased tag, or undefined when nothing was supplied + */ +export function normalizeTagFilter(tag?: string): string | undefined { + if (!tag) return undefined; + + const normalized = tag.trim().toLowerCase(); + + return normalized.length > 0 ? normalized : undefined; +} 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 index 042270c..e0b991f 100644 --- a/src/core/use-cases/article/get-articles/get-articles.usecase.ts +++ b/src/core/use-cases/article/get-articles/get-articles.usecase.ts @@ -6,6 +6,7 @@ import type { IFollowRepository } from "@core/ports/repositories/follow.reposito import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { CachePort } from "@core/ports/services/cache.port"; import { UnauthorizedError } from "@core/errors"; +import { normalizeTagFilter } from "../article-input"; import type { GetArticlesUseCaseInput } from "./get-articles-usecase.input"; import type { GetArticlesUseCaseOutput } from "./get-articles-usecase.output"; @@ -95,6 +96,7 @@ export class GetArticlesUseCase { const page = input.page ?? 1; const limit = input.limit ?? DEFAULT_LIMIT; const followedOnly = input.followedOnly ?? false; + const tag = normalizeTagFilter(input.tag); if (followedOnly && !input.currentUserId) { throw new UnauthorizedError( @@ -102,7 +104,13 @@ export class GetArticlesUseCase { ); } - const cacheKey = this.buildCacheKey(input, page, limit, followedOnly); + const cacheKey = this.buildCacheKey( + input, + page, + limit, + followedOnly, + tag, + ); const cached = await this.cacheService.get(cacheKey); if (cached) { @@ -136,7 +144,7 @@ export class GetArticlesUseCase { const result = await this.articleRepository.findAll({ page, limit, - tag: input.tag, + tag, authorId, categories: input.categories, followingIds, @@ -167,6 +175,7 @@ export class GetArticlesUseCase { * @param page - Resolved page number * @param limit - Resolved page size * @param followedOnly - Resolved followed-authors flag + * @param normalizedTag - The tag filter as the repository will see it * @returns The cache key */ private buildCacheKey( @@ -174,8 +183,11 @@ export class GetArticlesUseCase { page: number, limit: number, followedOnly: boolean, + normalizedTag?: string, ): string { - const tag = input.tag ?? "ALL"; + // The normalized tag, not the raw one: "NodeJS" and "nodejs" select the + // same articles, so they must not occupy two cache entries. + const tag = normalizedTag ?? "ALL"; const author = input.authorUsername ?? "ALL"; const categories = input.categories && input.categories.length > 0 diff --git a/tests/unit/core/use-cases/article/article-input.test.ts b/tests/unit/core/use-cases/article/article-input.test.ts index eebc23c..3af6b10 100644 --- a/tests/unit/core/use-cases/article/article-input.test.ts +++ b/tests/unit/core/use-cases/article/article-input.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { normalizeBody, normalizeTags, + normalizeTagFilter, normalizeTitle, validateCoverImageKey, } from "@core/use-cases/article/article-input"; @@ -87,9 +88,9 @@ describe("article input rules", () => { }); it("should reject more than five distinct tags", () => { - expect(() => - normalizeTags(["a", "b", "c", "d", "e", "f"]), - ).toThrow(BadRequestError); + expect(() => normalizeTags(["a", "b", "c", "d", "e", "f"])).toThrow( + BadRequestError, + ); }); it("should skip blank entries rather than reject them", () => { @@ -164,4 +165,20 @@ describe("article input rules", () => { ); }); }); + + describe("normalizeTagFilter()", () => { + it("should lowercase and trim the filter", () => { + expect(normalizeTagFilter(" NodeJS ")).toBe("nodejs"); + }); + + it("should treat a missing or blank filter as no filter", () => { + expect(normalizeTagFilter()).toBeUndefined(); + expect(normalizeTagFilter("")).toBeUndefined(); + expect(normalizeTagFilter(" ")).toBeUndefined(); + }); + + it("should not throw on a malformed filter", () => { + expect(normalizeTagFilter("Not A Tag!")).toBe("not a tag!"); + }); + }); }); 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 index a209329..8ac7ff5 100644 --- a/tests/unit/core/use-cases/article/get-articles.usecase.test.ts +++ b/tests/unit/core/use-cases/article/get-articles.usecase.test.ts @@ -120,6 +120,33 @@ describe("GetArticlesUseCase", () => { expect(key.startsWith("articles:list:")).toBe(true); }); + it("should lowercase the tag filter before it reaches the repository", async () => { + await useCase.execute({ tag: " NodeJS " }); + + expect(articleRepository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ tag: "nodejs" }), + ); + }); + + it("should drop a blank tag filter instead of matching the empty tag", async () => { + await useCase.execute({ tag: " " }); + + expect(articleRepository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ tag: undefined }), + ); + }); + + it("should reuse one key for tags differing only in case", async () => { + await useCase.execute({ tag: "NodeJS" }); + const first = vi.mocked(cacheService.get).mock.calls[0][0]; + + vi.mocked(cacheService.get).mockClear(); + await useCase.execute({ tag: "nodejs" }); + const second = vi.mocked(cacheService.get).mock.calls[0][0]; + + expect(first).toBe(second); + }); + it("should order categories so the same filter reuses one key", async () => { await useCase.execute({ categories: [PostCategory.FRONTEND, PostCategory.BACKEND],