From 65cb6f330c8514fd51a7ea2a4edbd5cda151bcef Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 07:47:41 +0300 Subject: [PATCH] feat(profile): expose published articleCount on the profile response The profile carried followersCount, followingCount and postCount but said nothing about articles, even though the article feature has been merged for several releases. The count is published-only, and identical for every viewer including the owner. A total that included drafts would leak both the existence and the volume of unpublished work, and a viewer-dependent number would be unstable and would fork a code path that is otherwise the same for everyone. An author's own draft count is the meta of GET /articles/me?status=DRAFT. countPublishedByAuthorId is a single count query covered exactly by the existing (author_id, status) index, rather than reusing findAll with limit 1 and reading total - that would run a findMany with the full relation include to produce a number. It joins the Promise.all that already fetches the follow status and the post count, so the profile still makes one round of parallel queries. Note for review: adding a required property to ProfileItemSchema is not type-checked anywhere. Controller methods take a bare FastifyReply, so send() is never compared against the route's response generic, and fast-json-stringify fails serialization on a missing required property. The e2e test is the only thing standing between that and a 500, which is why this change adds four. Co-Authored-By: Claude Opus 5 --- .../ports/repositories/article.repository.ts | 13 +++ .../get-profile/get-profile-usecase.output.ts | 3 + .../get-profile/get-profile.usecase.ts | 12 ++- src/http/controllers/profile.controller.ts | 3 +- .../schemas/profile/get-profile.schema.ts | 1 + .../repositories/prisma-article.repository.ts | 15 +++ tests/e2e/profile/get-profile.test.ts | 96 ++++++++++++++++++- .../prisma-article.repository.test.ts | 62 ++++++++++++ .../profile/get-profile.usecase.test.ts | 77 +++++++++++++++ 9 files changed, 279 insertions(+), 3 deletions(-) diff --git a/src/core/ports/repositories/article.repository.ts b/src/core/ports/repositories/article.repository.ts index 0bd1277e..a8fbe5f7 100644 --- a/src/core/ports/repositories/article.repository.ts +++ b/src/core/ports/repositories/article.repository.ts @@ -100,6 +100,19 @@ export interface IArticleRepository { */ findById(id: string, currentUserId?: string): Promise
; + /** + * Counts the published articles written by one author. + * + * Published only, by design: this number is rendered on a public profile, + * and a total that included drafts would leak both the existence and the + * volume of unpublished work. An author's own draft count comes from the + * meta of GET /articles/me instead. + * + * @param authorId - The author whose articles are counted + * @returns The number of published articles + */ + countPublishedByAuthorId(authorId: string): Promise; + /** * Deletes an article. Likes, bookmarks and tag links cascade in the schema. * diff --git a/src/core/use-cases/profile/get-profile/get-profile-usecase.output.ts b/src/core/use-cases/profile/get-profile/get-profile-usecase.output.ts index f42db807..2aacad04 100644 --- a/src/core/use-cases/profile/get-profile/get-profile-usecase.output.ts +++ b/src/core/use-cases/profile/get-profile/get-profile-usecase.output.ts @@ -5,4 +5,7 @@ export interface GetProfileOutput { isMe: boolean; isFollowing: boolean; postCount: number; + + /** Published articles written by this user; drafts are never counted */ + articleCount: number; } diff --git a/src/core/use-cases/profile/get-profile/get-profile.usecase.ts b/src/core/use-cases/profile/get-profile/get-profile.usecase.ts index 1568a130..83ef3f5b 100644 --- a/src/core/use-cases/profile/get-profile/get-profile.usecase.ts +++ b/src/core/use-cases/profile/get-profile/get-profile.usecase.ts @@ -2,6 +2,7 @@ import { NotFoundError } from "@core/errors/common/not-found.error"; import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { GetProfileOutput } from "./get-profile-usecase.output"; /** @@ -16,11 +17,14 @@ export class GetProfileUseCase { * * @param profileRepository - Repository for managing profile data * @param followUserRepository - Repository for managing follow relationships + * @param postRepository - Repository used to count the user's posts + * @param articleRepository - Repository used to count published articles */ constructor( private readonly profileRepository: IProfileRepository, private readonly followUserRepository: IFollowRepository, private readonly postRepository: IPostRepository, + private readonly articleRepository: IArticleRepository, ) {} /** @@ -47,7 +51,11 @@ export class GetProfileUseCase { const isMe = currentUserId ? profile.userId === currentUserId : false; - const [isFollowing, postCount] = await Promise.all([ + // The article count is published-only and therefore identical for every + // viewer, including the owner. A viewer-dependent number would be + // unstable and would fork a code path that is otherwise the same for + // everyone. + const [isFollowing, postCount, articleCount] = await Promise.all([ currentUserId && !isMe ? this.followUserRepository.checkIsFollowing( currentUserId, @@ -55,6 +63,7 @@ export class GetProfileUseCase { ) : Promise.resolve(false), this.postRepository.countByUserId(profile.userId), + this.articleRepository.countPublishedByAuthorId(profile.userId), ]); return { @@ -62,6 +71,7 @@ export class GetProfileUseCase { isMe, isFollowing, postCount, + articleCount, }; } } diff --git a/src/http/controllers/profile.controller.ts b/src/http/controllers/profile.controller.ts index 547f54ca..9654f943 100644 --- a/src/http/controllers/profile.controller.ts +++ b/src/http/controllers/profile.controller.ts @@ -123,7 +123,7 @@ export class ProfileController { const currentUserId = request.user?.id; - const { profile, isMe, isFollowing, postCount } = + const { profile, isMe, isFollowing, postCount, articleCount } = await this.getProfileUseCase.execute(username, currentUserId); const profileData = ProfilePrismaMapper.toResponse(profile); @@ -134,6 +134,7 @@ export class ProfileController { isMe, isFollowing, postCount, + articleCount, avatarUrl: this.getFullImageUrl(profileData.avatarUrl), bannerUrl: this.getFullImageUrl(profileData.bannerUrl), }, diff --git a/src/http/types/schemas/profile/get-profile.schema.ts b/src/http/types/schemas/profile/get-profile.schema.ts index f39dd300..d7517e0c 100644 --- a/src/http/types/schemas/profile/get-profile.schema.ts +++ b/src/http/types/schemas/profile/get-profile.schema.ts @@ -15,6 +15,7 @@ export const ProfileItemSchema = FBType.Object({ followersCount: FBType.Number(), followingCount: FBType.Number(), postCount: FBType.Number(), + articleCount: FBType.Number(), isMe: FBType.Boolean(), isFollowing: FBType.Boolean(), }); diff --git a/src/infrastructure/persistence/repositories/prisma-article.repository.ts b/src/infrastructure/persistence/repositories/prisma-article.repository.ts index 1e261a70..dee0a1cb 100644 --- a/src/infrastructure/persistence/repositories/prisma-article.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-article.repository.ts @@ -280,6 +280,21 @@ export class PrismaArticleRepository implements IArticleRepository { : null; } + /** + * Counts the published articles written by one author. + * + * A single count query rather than a list read: the composite index on + * (author_id, status) covers it exactly. + * + * @param authorId - The author whose articles are counted + * @returns The number of published articles + */ + async countPublishedByAuthorId(authorId: string): Promise { + return await this.prisma.article.count({ + where: { authorId, status: ArticleStatus.PUBLISHED }, + }); + } + /** * Deletes an article. Likes, bookmarks and tag links cascade in the schema. * diff --git a/tests/e2e/profile/get-profile.test.ts b/tests/e2e/profile/get-profile.test.ts index 1cea4df8..3b007fab 100644 --- a/tests/e2e/profile/get-profile.test.ts +++ b/tests/e2e/profile/get-profile.test.ts @@ -68,6 +68,7 @@ describe("GET /profiles/:username - Get Profile", () => { followersCount: number; followingCount: number; postCount: number; + articleCount: number; isMe: boolean; isFollowing: boolean; }; @@ -81,6 +82,7 @@ describe("GET /profiles/:username - Get Profile", () => { expect(body.data.followersCount).toEqual(expect.any(Number)); expect(body.data.followingCount).toEqual(expect.any(Number)); expect(body.data.postCount).toEqual(expect.any(Number)); + expect(body.data.articleCount).toEqual(expect.any(Number)); expect(body.data.avatarUrl).toEqual(expect.any(String)); expect(body.data.bannerUrl).toEqual(expect.any(String)); expect(body.meta).toHaveProperty("timestamp", expect.any(String)); @@ -140,4 +142,96 @@ describe("GET /profiles/:username - Get Profile", () => { expect(body.title).toBe("NotFoundError"); expect(body.detail).toBe("Profile not found."); }); -}); + + + describe("GET /profiles/:username - articleCount", () => { + /** + * Reads the article count off a profile as the given viewer. + */ + const readCount = async (token?: string): Promise => { + const response = token + ? await authRequest(token, { + method: "GET", + url: `/profiles/${userA.username}`, + }) + : await request({ + method: "GET", + url: `/profiles/${userA.username}`, + }); + return parseBody<{ data: { articleCount: number } }>(response).data + .articleCount; + }; + + it("should not count a draft", async () => { + const before = await readCount(tokenA); + + await authRequest(tokenA, { + method: "POST", + url: "/articles", + payload: { + title: `Uncounted draft ${Date.now()}`, + body: "Body prose for the draft.", + }, + }); + + expect(await readCount(tokenA)).toBe(before); + }); + + it("should count a published article", async () => { + const before = await readCount(tokenA); + + const created = await authRequest(tokenA, { + method: "POST", + url: "/articles", + payload: { + title: `Counted article ${Date.now()}`, + body: "Body prose for the published article.", + }, + }); + const { id } = parseBody<{ data: { id: string } }>(created).data; + + await authRequest(tokenA, { + method: "POST", + url: `/articles/${id}/publish`, + }); + + expect(await readCount(tokenA)).toBe(before + 1); + }); + + it("should report the same count to the owner, a stranger and a guest", async () => { + // Published-only, so a viewer-dependent number would mean the owner + // was being shown how much unpublished work exists. + const asOwner = await readCount(tokenA); + const asStranger = await readCount(tokenB); + const asGuest = await readCount(); + + expect(asStranger).toBe(asOwner); + expect(asGuest).toBe(asOwner); + }); + + it("should stop counting an article once it is archived", async () => { + const created = await authRequest(tokenA, { + method: "POST", + url: "/articles", + payload: { + title: `Archivable article ${Date.now()}`, + body: "Body prose.", + }, + }); + const { id } = parseBody<{ data: { id: string } }>(created).data; + + await authRequest(tokenA, { + method: "POST", + url: `/articles/${id}/publish`, + }); + const published = await readCount(tokenA); + + await authRequest(tokenA, { + method: "POST", + url: `/articles/${id}/archive`, + }); + + expect(await readCount(tokenA)).toBe(published - 1); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/persistence/repositories/prisma-article.repository.test.ts b/tests/integration/persistence/repositories/prisma-article.repository.test.ts index 554fcd33..fc02f15a 100644 --- a/tests/integration/persistence/repositories/prisma-article.repository.test.ts +++ b/tests/integration/persistence/repositories/prisma-article.repository.test.ts @@ -331,6 +331,68 @@ describe("PrismaArticleRepository (integration)", () => { }); }); + describe("countPublishedByAuthorId()", () => { + it("should count only published articles", async () => { + const { articles } = await articleRepo.findAll({ + page: 1, + limit: 100, + authorId, + }); + + const count = await articleRepo.countPublishedByAuthorId(authorId); + + expect(count).toBe(articles.length); + expect(count).toBeGreaterThan(0); + }); + + it("should not count a draft", async () => { + const before = await articleRepo.countPublishedByAuthorId(authorId); + + await articleRepo.create( + makeArticle({ title: "Uncounted draft" }), + ); + + expect(await articleRepo.countPublishedByAuthorId(authorId)).toBe( + before, + ); + }); + + it("should not count an archived article", async () => { + const before = await articleRepo.countPublishedByAuthorId(authorId); + + const article = await articleRepo.create( + makeArticle({ title: "Soon archived" }), + ); + article.publish(); + await articleRepo.update(article); + expect(await articleRepo.countPublishedByAuthorId(authorId)).toBe( + before + 1, + ); + + article.archive(); + await articleRepo.update(article); + + expect(await articleRepo.countPublishedByAuthorId(authorId)).toBe( + before, + ); + }); + + it("should not count another author's articles", async () => { + const forOther = + await articleRepo.countPublishedByAuthorId(otherUserId); + + expect(forOther).toBe(0); + }); + + it("should return zero for an unknown author", async () => { + const count = await articleRepo.countPublishedByAuthorId( + "00000000-0000-4000-8000-000000000000", + ); + + expect(count).toBe(0); + }); + }); + describe("delete()", () => { it("should remove the article and cascade its likes", async () => { const article = await articleRepo.create( diff --git a/tests/unit/core/use-cases/profile/get-profile.usecase.test.ts b/tests/unit/core/use-cases/profile/get-profile.usecase.test.ts index 5eeccf58..25046ec8 100644 --- a/tests/unit/core/use-cases/profile/get-profile.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/get-profile.usecase.test.ts @@ -3,6 +3,7 @@ import { GetProfileUseCase } from "@core/use-cases/profile/get-profile"; import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import { NotFoundError } from "@core/errors"; import { buildProfile } from "../../../helpers/mock-factories"; @@ -11,6 +12,10 @@ describe("GetProfileUseCase", () => { let profileRepository: Pick; let followRepository: Pick; let postRepository: Pick; + let articleRepository: Pick< + IArticleRepository, + "countPublishedByAuthorId" + >; beforeEach(() => { profileRepository = { @@ -22,10 +27,14 @@ describe("GetProfileUseCase", () => { postRepository = { countByUserId: vi.fn().mockResolvedValue(0), }; + articleRepository = { + countPublishedByAuthorId: vi.fn().mockResolvedValue(0), + }; useCase = new GetProfileUseCase( profileRepository as IProfileRepository, followRepository as IFollowRepository, postRepository as IPostRepository, + articleRepository as IArticleRepository, ); }); @@ -158,4 +167,72 @@ describe("GetProfileUseCase", () => { expect(result.profile).toBe(profile); }); + + describe("articleCount", () => { + it("should return the published article count for the profile owner", async () => { + const profile = buildProfile({ userId: "user-1" }); + vi.mocked(profileRepository.findByUsername).mockResolvedValue( + profile, + ); + vi.mocked( + articleRepository.countPublishedByAuthorId, + ).mockResolvedValue(7); + + const result = await useCase.execute("testuser"); + + expect(result.articleCount).toBe(7); + expect( + articleRepository.countPublishedByAuthorId, + ).toHaveBeenCalledWith("user-1"); + }); + + it("should report the same count to the owner and to a guest", async () => { + const profile = buildProfile({ userId: "user-1" }); + vi.mocked(profileRepository.findByUsername).mockResolvedValue( + profile, + ); + vi.mocked( + articleRepository.countPublishedByAuthorId, + ).mockResolvedValue(3); + + const asOwner = await useCase.execute("testuser", "user-1"); + const asGuest = await useCase.execute("testuser"); + const asStranger = await useCase.execute("testuser", "user-2"); + + // Published-only, so it cannot vary by viewer. A count that grew + // for the owner would leak how much unpublished work exists. + expect(asOwner.isMe).toBe(true); + expect(asOwner.articleCount).toBe(3); + expect(asGuest.articleCount).toBe(3); + expect(asStranger.articleCount).toBe(3); + }); + + it("should count articles alongside posts, not instead of them", async () => { + const profile = buildProfile({ userId: "user-1" }); + vi.mocked(profileRepository.findByUsername).mockResolvedValue( + profile, + ); + vi.mocked(postRepository.countByUserId).mockResolvedValue(12); + vi.mocked( + articleRepository.countPublishedByAuthorId, + ).mockResolvedValue(4); + + const result = await useCase.execute("testuser"); + + expect(result.postCount).toBe(12); + expect(result.articleCount).toBe(4); + }); + + it("should not query articles when the profile does not exist", async () => { + vi.mocked(profileRepository.findByUsername).mockResolvedValue(null); + + await expect(useCase.execute("nobody")).rejects.toThrow( + NotFoundError, + ); + + expect( + articleRepository.countPublishedByAuthorId, + ).not.toHaveBeenCalled(); + }); + }); });