diff --git a/src/core/ports/repositories/article.repository.ts b/src/core/ports/repositories/article.repository.ts index 0bd1277..a8fbe5f 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 f42db80..2aacad0 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 1568a13..83ef3f5 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 547f54c..9654f94 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 f39dd30..d7517e0 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 1e261a7..dee0a1c 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 1cea4df..3b007fa 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 554fcd3..fc02f15 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 5eeccf5..25046ec 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(); + }); + }); });