From 7628c9e6c23c2869cce000a380c936b2d3e1cc5e Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 30 Aug 2026 07:31:43 +0300 Subject: [PATCH] fix(article): match the tag filter case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags are stored lowercased on both write paths, but the article list passed the query string tag through untouched, so /articles?tag=NodeJS returned nothing while /posts?tag=NodeJS returned the full page. A client carrying a tag's display casing into the URL lost every article on the tag page. Normalize the filter in the use case rather than the repository so the cache key is built from the same value: otherwise "NodeJS" and "nodejs" would select the same articles under two separate cache entries. A blank filter now means no filter instead of a lookup for the empty tag. Unlike the write path the filter never throws — a malformed tag matches no article, which is not a bad request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XJ4fR79WAs2pxEYxY9uNY9 --- src/core/use-cases/article/article-input.ts | 19 +++++++++++++ .../get-articles/get-articles.usecase.ts | 18 ++++++++++--- .../use-cases/article/article-input.test.ts | 23 +++++++++++++--- .../article/get-articles.usecase.test.ts | 27 +++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) 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],