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 7b04c60..51f8300 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 d8dcd53..7ec7610 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 e85e348..778cebc 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 1964eb1..c52dd63 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 547f54c..aa8a608 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 2f34b61..07d68d1 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 beb1846..9941c23 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 75a2e4a..8f42c0c 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(); + }); });