From 4c6e5820abc244b132b670c6ddc0b4cedf9beffe Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 07:58:06 +0300 Subject: [PATCH] perf(profile): resolve follower list targets without loading the full profile GET /profiles/:username/followers and /following called GetProfileUseCase purely to turn a username into a user id. That use case also counts the user's posts, and after the articleCount change it counts their published articles too - so every page of a follower list ran two aggregate queries whose results were thrown away. Username resolution moves into GetFollowersUseCase and GetFollowingUseCase, which now take a username and resolve it through the profile repository. The HTTP contract is unchanged, including the 404 for an unknown username: the use cases throw the same NotFoundError the controller's profile lookup used to raise. Without that, the endpoint would have started answering an empty 200 instead. The existing e2e suite covered shape, pagination and the empty case but not the unknown-username 404, which is precisely the contract this change could have broken silently. It does now. The controller keeps getProfileUseCase - getProfile still uses it. Co-Authored-By: Claude Opus 5 --- .../get-followers-usecase.input.ts | 8 +++- .../get-followers/get-followers.usecase.ts | 18 +++++++-- .../get-following-usecase.input.ts | 8 +++- .../get-following/get-following.usecase.ts | 18 +++++++-- src/http/controllers/profile.controller.ts | 8 +--- tests/e2e/follow-user/get-follows.test.ts | 28 +++++++++++++ .../follow-user/get-followers.usecase.test.ts | 35 +++++++++++++++- .../follow-user/get-following.usecase.test.ts | 40 ++++++++++++++++++- 8 files changed, 143 insertions(+), 20 deletions(-) diff --git a/src/core/use-cases/follow-user/get-followers/get-followers-usecase.input.ts b/src/core/use-cases/follow-user/get-followers/get-followers-usecase.input.ts index 7b04c607..51f8300e 100644 --- a/src/core/use-cases/follow-user/get-followers/get-followers-usecase.input.ts +++ b/src/core/use-cases/follow-user/get-followers/get-followers-usecase.input.ts @@ -3,9 +3,13 @@ */ export interface GetFollowersUseCaseInput { /** - * The ID of the user whose followers are being retrieved. This is a required field. + * The username of the user whose followers are being retrieved. + * + * Resolved to a user id inside the use case. The controller used to do + * this by loading the whole profile, which also computed post and article + * counts that this endpoint never reads. */ - targetId: string; + username: string; /** * The ID of the current user making the request. This is optional and is used to determine follow status for each follower. If provided, the response will indicate whether the current user follows each listed follower and whether any listed follower is the same as the current user. */ diff --git a/src/core/use-cases/follow-user/get-followers/get-followers.usecase.ts b/src/core/use-cases/follow-user/get-followers/get-followers.usecase.ts index d8dcd535..7ec76100 100644 --- a/src/core/use-cases/follow-user/get-followers/get-followers.usecase.ts +++ b/src/core/use-cases/follow-user/get-followers/get-followers.usecase.ts @@ -1,4 +1,6 @@ import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import { NotFoundError } from "@core/errors"; import type { GetFollowersUseCaseOutput } from "./get-followers-usecase.output"; import type { GetFollowersUseCaseInput } from "./get-followers-usecase.input"; /** @@ -12,8 +14,12 @@ export class GetFollowersUseCase { * Creates a new instance of GetFollowersUseCase. * * @param followUserRepository - Repository for managing follow relationships + * @param profileRepository - Repository used to resolve the username */ - constructor(private readonly followUserRepository: IFollowRepository) {} + constructor( + private readonly followUserRepository: IFollowRepository, + private readonly profileRepository: IProfileRepository, + ) {} /** * Executes the get followers use case. @@ -23,10 +29,16 @@ export class GetFollowersUseCase { async execute( input: GetFollowersUseCaseInput, ): Promise { - const { targetId, currentUserId, limit, offset } = input; + const { username, currentUserId, limit, offset } = input; + + const profile = await this.profileRepository.findByUsername(username); + + // Same error the controller's profile lookup used to raise, so an + // unknown username keeps answering 404 rather than an empty 200. + if (!profile) throw new NotFoundError("Profile not found."); const followers = await this.followUserRepository.getFollowers( - targetId, + profile.userId, limit, offset, ); diff --git a/src/core/use-cases/follow-user/get-following/get-following-usecase.input.ts b/src/core/use-cases/follow-user/get-following/get-following-usecase.input.ts index e85e348c..778cebcc 100644 --- a/src/core/use-cases/follow-user/get-following/get-following-usecase.input.ts +++ b/src/core/use-cases/follow-user/get-following/get-following-usecase.input.ts @@ -3,9 +3,13 @@ */ export interface GetFollowingUseCaseInput { /** - * The ID of the user whose following users are being retrieved. This is a required field. + * The username of the user whose following list is being retrieved. + * + * Resolved to a user id inside the use case. The controller used to do + * this by loading the whole profile, which also computed post and article + * counts that this endpoint never reads. */ - targetId: string; + username: string; /** * The ID of the current user making the request. This is optional and is used to determine follow status for each following user. If provided, the response will indicate whether the current user follows each listed following user and whether any listed following user is the same as the current user. */ diff --git a/src/core/use-cases/follow-user/get-following/get-following.usecase.ts b/src/core/use-cases/follow-user/get-following/get-following.usecase.ts index 1964eb10..c52dd63f 100644 --- a/src/core/use-cases/follow-user/get-following/get-following.usecase.ts +++ b/src/core/use-cases/follow-user/get-following/get-following.usecase.ts @@ -1,4 +1,6 @@ import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import { NotFoundError } from "@core/errors"; import type { GetFollowingUseCaseOutput } from "./get-following-usecase.output"; import type { GetFollowingUseCaseInput } from "./get-following-usecase.input"; @@ -7,8 +9,12 @@ export class GetFollowingUseCase { * Creates a new instance of GetFollowingUseCase. * * @param followUserRepository - Repository for managing follow relationships + * @param profileRepository - Repository used to resolve the username */ - constructor(private readonly followUserRepository: IFollowRepository) {} + constructor( + private readonly followUserRepository: IFollowRepository, + private readonly profileRepository: IProfileRepository, + ) {} /** * Executes the get following use case. @@ -19,10 +25,16 @@ export class GetFollowingUseCase { async execute( input: GetFollowingUseCaseInput, ): Promise { - const { targetId, currentUserId, limit, offset } = input; + const { username, currentUserId, limit, offset } = input; + + const profile = await this.profileRepository.findByUsername(username); + + // Same error the controller's profile lookup used to raise, so an + // unknown username keeps answering 404 rather than an empty 200. + if (!profile) throw new NotFoundError("Profile not found."); const following = await this.followUserRepository.getFollowing( - targetId, + profile.userId, limit, offset, ); diff --git a/src/http/controllers/profile.controller.ts b/src/http/controllers/profile.controller.ts index 547f54ca..aa8a6083 100644 --- a/src/http/controllers/profile.controller.ts +++ b/src/http/controllers/profile.controller.ts @@ -188,10 +188,8 @@ export class ProfileController { const { limit, offset } = request.query; const currentUserId = request.user?.id; - const { profile } = await this.getProfileUseCase.execute(username); - const followers = await this.getFollowersUseCase.execute({ - targetId: profile.userId, + username, currentUserId, limit, offset, @@ -219,10 +217,8 @@ export class ProfileController { const { limit, offset } = request.query; const currentUserId = request.user?.id; - const { profile } = await this.getProfileUseCase.execute(username); - const following = await this.getFollowingUseCase.execute({ - targetId: profile.userId, + username, currentUserId, limit, offset, diff --git a/tests/e2e/follow-user/get-follows.test.ts b/tests/e2e/follow-user/get-follows.test.ts index 2f34b618..07d68d1f 100644 --- a/tests/e2e/follow-user/get-follows.test.ts +++ b/tests/e2e/follow-user/get-follows.test.ts @@ -80,6 +80,20 @@ describe("GET Follow Lists", () => { }); describe("GET /profiles/:username/followers - Get User Followers", () => { + it("should return 404 for an unknown username", async () => { + // Username resolution moved into the use case; an unknown user + // must still be a 404 rather than a silently empty 200. + const response = await request({ + method: "GET", + url: "/profiles/no_such_user_xyz/followers", + }); + + expect(response.statusCode).toBe(404); + expect(parseBody<{ title: string }>(response).title).toBe( + "NotFoundError", + ); + }); + it("should return 200 with followers array and correct item shape", async () => { const response = await request({ method: "GET", @@ -171,6 +185,20 @@ describe("GET Follow Lists", () => { }); describe("GET /profiles/:username/following - Get User Following", () => { + it("should return 404 for an unknown username", async () => { + // Username resolution moved into the use case; an unknown user + // must still be a 404 rather than a silently empty 200. + const response = await request({ + method: "GET", + url: "/profiles/no_such_user_xyz/following", + }); + + expect(response.statusCode).toBe(404); + expect(parseBody<{ title: string }>(response).title).toBe( + "NotFoundError", + ); + }); + it("should return 200 with following array and correct item shape", async () => { const response = await request({ method: "GET", diff --git a/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts b/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts index beb18469..9941c23a 100644 --- a/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts +++ b/tests/unit/core/use-cases/follow-user/get-followers.usecase.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GetFollowersUseCase } from "@core/use-cases/follow-user/get-followers/get-followers.usecase"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import { NotFoundError } from "@core/errors"; +import { buildProfile } from "../../../helpers/mock-factories"; const buildFollowerEntry = (userId: string) => ({ userId, @@ -16,9 +19,10 @@ describe("GetFollowersUseCase", () => { IFollowRepository, "getFollowers" | "checkIsFollowingBulk" >; + let profileRepo: Pick; const baseInput = { - targetId: "user-1", + username: "testuser", limit: 10, offset: 0, currentUserId: undefined, @@ -29,7 +33,15 @@ describe("GetFollowersUseCase", () => { getFollowers: vi.fn(), checkIsFollowingBulk: vi.fn(), }; - useCase = new GetFollowersUseCase(followRepo as IFollowRepository); + profileRepo = { + findByUsername: vi + .fn() + .mockResolvedValue(buildProfile({ userId: "user-1" })), + }; + useCase = new GetFollowersUseCase( + followRepo as IFollowRepository, + profileRepo as IProfileRepository, + ); }); it("should return empty array when user has no followers", async () => { @@ -96,4 +108,23 @@ describe("GetFollowersUseCase", () => { expect(me?.isMe).toBe(true); expect(other?.isMe).toBe(false); }); + + it("should resolve the username to a user id before listing", async () => { + vi.mocked(followRepo.getFollowers).mockResolvedValue([]); + + await useCase.execute(baseInput); + + expect(profileRepo.findByUsername).toHaveBeenCalledWith("testuser"); + expect(followRepo.getFollowers).toHaveBeenCalledWith("user-1", 10, 0); + }); + + it("should throw NotFoundError for an unknown username", async () => { + vi.mocked(profileRepo.findByUsername).mockResolvedValue(null); + + // The controller used to raise this via a full profile load; the + // endpoint must keep answering 404 rather than an empty 200. + await expect(useCase.execute(baseInput)).rejects.toThrow(NotFoundError); + + expect(followRepo.getFollowers).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts b/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts index 75a2e4ae..8f42c0c7 100644 --- a/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts +++ b/tests/unit/core/use-cases/follow-user/get-following.usecase.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GetFollowingUseCase } from "@core/use-cases/follow-user/get-following/get-following.usecase"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; +import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; +import { NotFoundError } from "@core/errors"; +import { buildProfile } from "../../../helpers/mock-factories"; const buildFollowingEntry = (userId: string) => ({ userId, @@ -16,15 +19,29 @@ describe("GetFollowingUseCase", () => { IFollowRepository, "getFollowing" | "checkIsFollowingBulk" >; + let profileRepo: Pick; - const baseInput = { targetId: "user-1", limit: 10, offset: 0 }; + const baseInput = { + username: "testuser", + limit: 10, + offset: 0, + currentUserId: undefined, + }; beforeEach(() => { followRepo = { getFollowing: vi.fn(), checkIsFollowingBulk: vi.fn(), }; - useCase = new GetFollowingUseCase(followRepo as IFollowRepository); + profileRepo = { + findByUsername: vi + .fn() + .mockResolvedValue(buildProfile({ userId: "user-1" })), + }; + useCase = new GetFollowingUseCase( + followRepo as IFollowRepository, + profileRepo as IProfileRepository, + ); }); it("should return empty array when user follows nobody", async () => { @@ -90,4 +107,23 @@ describe("GetFollowingUseCase", () => { expect(me?.isMe).toBe(true); expect(other?.isMe).toBe(false); }); + + it("should resolve the username to a user id before listing", async () => { + vi.mocked(followRepo.getFollowing).mockResolvedValue([]); + + await useCase.execute(baseInput); + + expect(profileRepo.findByUsername).toHaveBeenCalledWith("testuser"); + expect(followRepo.getFollowing).toHaveBeenCalledWith("user-1", 10, 0); + }); + + it("should throw NotFoundError for an unknown username", async () => { + vi.mocked(profileRepo.findByUsername).mockResolvedValue(null); + + // The controller used to raise this via a full profile load; the + // endpoint must keep answering 404 rather than an empty 200. + await expect(useCase.execute(baseInput)).rejects.toThrow(NotFoundError); + + expect(followRepo.getFollowing).not.toHaveBeenCalled(); + }); });