Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
/**
Expand All @@ -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.
Expand All @@ -23,10 +29,16 @@ export class GetFollowersUseCase {
async execute(
input: GetFollowersUseCaseInput,
): Promise<GetFollowersUseCaseOutput[]> {
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,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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.
Expand All @@ -19,10 +25,16 @@ export class GetFollowingUseCase {
async execute(
input: GetFollowingUseCaseInput,
): Promise<GetFollowingUseCaseOutput[]> {
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,
);
Expand Down
8 changes: 2 additions & 6 deletions src/http/controllers/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions tests/e2e/follow-user/get-follows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,9 +19,10 @@ describe("GetFollowersUseCase", () => {
IFollowRepository,
"getFollowers" | "checkIsFollowingBulk"
>;
let profileRepo: Pick<IProfileRepository, "findByUsername">;

const baseInput = {
targetId: "user-1",
username: "testuser",
limit: 10,
offset: 0,
currentUserId: undefined,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,15 +19,29 @@ describe("GetFollowingUseCase", () => {
IFollowRepository,
"getFollowing" | "checkIsFollowingBulk"
>;
let profileRepo: Pick<IProfileRepository, "findByUsername">;

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 () => {
Expand Down Expand Up @@ -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();
});
});