From 56a69812eebfcb797139d5d5fdc179fb67e6cfa2 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 17 Apr 2026 15:50:11 +0300 Subject: [PATCH 1/2] test(e2e): add follow-user E2E test suite (16 tests) - follow.test.ts: follow 200, idempotent, self-follow 400, 401 (4 tests) - unfollow.test.ts: unfollow 200, idempotent, self-unfollow 400, 404, 401 (5 tests) - get-follows.test.ts: followers shape, public isMe/isFollowing, pagination, empty + following shape, pagination, empty (7 tests) fix(routes): add optionalAuthenticate hook to followers/following routes so isMe and isFollowing computed fields are populated when a JWT is present --- src/http/routes/profile/profile.routes.ts | 2 + tests/e2e/follow-user/follow.test.ts | 109 ++++++++++ tests/e2e/follow-user/get-follows.test.ts | 251 ++++++++++++++++++++++ tests/e2e/follow-user/unfollow.test.ts | 129 +++++++++++ 4 files changed, 491 insertions(+) create mode 100644 tests/e2e/follow-user/follow.test.ts create mode 100644 tests/e2e/follow-user/get-follows.test.ts create mode 100644 tests/e2e/follow-user/unfollow.test.ts diff --git a/src/http/routes/profile/profile.routes.ts b/src/http/routes/profile/profile.routes.ts index f674f52..05d5d43 100644 --- a/src/http/routes/profile/profile.routes.ts +++ b/src/http/routes/profile/profile.routes.ts @@ -139,6 +139,7 @@ function profileRoutes(fastify: FastifyInstance): void { fastify.get<{ Reply: { 200: FollowsListResponse } }>( "/:username/followers", { + onRequest: [fastify.optionalAuthenticate], schema: { params: FollowersParamsSchema, querystring: PaginationQuerySchema, @@ -152,6 +153,7 @@ function profileRoutes(fastify: FastifyInstance): void { fastify.get<{ Reply: { 200: FollowsListResponse } }>( "/:username/following", { + onRequest: [fastify.optionalAuthenticate], schema: { params: FollowersParamsSchema, querystring: PaginationQuerySchema, diff --git a/tests/e2e/follow-user/follow.test.ts b/tests/e2e/follow-user/follow.test.ts new file mode 100644 index 0000000..b61f99d --- /dev/null +++ b/tests/e2e/follow-user/follow.test.ts @@ -0,0 +1,109 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for the Follow User endpoint. + * Validates that an authenticated user can follow another user, + * that the operation is idempotent, and that proper errors are returned + * for self-follow attempts or unauthenticated requests. + */ +describe("POST /follows - Follow User", () => { + const ts = Date.now(); + const userA = { + email: `fu-a-${ts}@test.com`, + password: "password123", + username: `fua${ts}`, + }; + const userB = { + email: `fu-b-${ts}@test.com`, + password: "password123", + username: `fub${ts}`, + }; + + let tokenA = ""; + let userAId = ""; + let userBId = ""; + + /** + * Registers two users and logs in as user A. + * Both user IDs are extracted from register responses. + */ + beforeAll(async () => { + // Register user A and extract their ID + const registerA = await request({ + method: "POST", + url: "/auth/register", + payload: userA, + }); + userAId = parseBody<{ data: { id: string } }>(registerA).data.id; + + const loginA = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: userA.email, password: userA.password }, + }); + tokenA = parseBody<{ data: { accessToken: string } }>(loginA).data + .accessToken; + + // Register user B and extract their ID + const registerB = await request({ + method: "POST", + url: "/auth/register", + payload: userB, + }); + userBId = parseBody<{ data: { id: string } }>(registerB).data.id; + }); + + it("should return 200 when following a user", async () => { + const response = await authRequest(tokenA, { + method: "POST", + url: "/follows", + payload: { targetId: userBId }, + }); + const body = parseBody<{ + data: { followersCount: number }; + meta: { timestamp: string }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.followersCount).toBeGreaterThanOrEqual(1); + expect(body.meta).toHaveProperty("timestamp", expect.any(String)); + }); + + it("should return 200 when following an already-followed user (idempotent)", async () => { + const response = await authRequest(tokenA, { + method: "POST", + url: "/follows", + payload: { targetId: userBId }, + }); + const body = parseBody<{ data: { followersCount: number } }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.followersCount).toBeGreaterThanOrEqual(1); + }); + + it("should return 400 when following yourself", async () => { + const response = await authRequest(tokenA, { + method: "POST", + url: "/follows", + payload: { targetId: userAId }, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(400); + expect(body.title).toBe("BadRequestError"); + expect(body.detail).toBe("You cannot follow yourself."); + }); + + it("should return 401 when not authenticated", async () => { + const response = await request({ + method: "POST", + url: "/follows", + payload: { targetId: userBId }, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(401); + expect(body.title).toBe("UnauthorizedError"); + }); +}); diff --git a/tests/e2e/follow-user/get-follows.test.ts b/tests/e2e/follow-user/get-follows.test.ts new file mode 100644 index 0000000..17f08d1 --- /dev/null +++ b/tests/e2e/follow-user/get-follows.test.ts @@ -0,0 +1,251 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +const FAKE_USERNAME = "nonexistent_user_xyz"; + +/** + * E2E tests for the Get Followers and Get Following endpoints. + * Validates listing a user's followers/following with correct shape, + * pagination, computed fields (isFollowing, isMe), and empty states. + */ +describe("GET Follow Lists", () => { + /** + * Shared setup: + * - User A: the target whose followers/following we inspect + * - User B: follows user A (becomes a follower of A) + * - User A: follows user B (becomes a followee of A) + */ + const ts = Date.now(); + const userA = { + email: `gfl-a-${ts}@test.com`, + password: "password123", + username: `gfla${ts}`, + }; + const userB = { + email: `gfl-b-${ts}@test.com`, + password: "password123", + username: `gflb${ts}`, + }; + + let tokenA = ""; + let tokenB = ""; + + /** + * Registers both users, logs them both in, then: + * - B follows A (so A has 1 follower) + * - A follows B (so A is following 1 person) + */ + beforeAll(async () => { + const registerA = await request({ + method: "POST", + url: "/auth/register", + payload: userA, + }); + const userAId = parseBody<{ data: { id: string } }>(registerA).data.id; + + const registerB = await request({ + method: "POST", + url: "/auth/register", + payload: userB, + }); + const userBId = parseBody<{ data: { id: string } }>(registerB).data.id; + + const loginA = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: userA.email, password: userA.password }, + }); + tokenA = parseBody<{ data: { accessToken: string } }>(loginA).data + .accessToken; + + const loginB = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: userB.email, password: userB.password }, + }); + tokenB = parseBody<{ data: { accessToken: string } }>(loginB).data + .accessToken; + + // B follows A → A gets 1 follower + await authRequest(tokenB, { + method: "POST", + url: "/follows", + payload: { targetId: userAId }, + }); + + // A follows B → A is following 1 person + await authRequest(tokenA, { + method: "POST", + url: "/follows", + payload: { targetId: userBId }, + }); + }); + + describe("GET /profiles/:username/followers - Get User Followers", () => { + it("should return 200 with followers array and correct item shape", async () => { + const response = await request({ + method: "GET", + url: `/profiles/${userA.username}/followers`, + }); + const body = parseBody<{ + data: { + userId: string; + username: string; + fullName: string; + avatarUrl: string; + isFollowing: boolean; + isMe: boolean; + }[]; + meta: { limit: number; offset: number; count: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThanOrEqual(1); + + const follower = body.data[0]; + expect(follower).toHaveProperty("userId", expect.any(String)); + expect(follower).toHaveProperty("username", expect.any(String)); + expect(follower).toHaveProperty("isFollowing", expect.any(Boolean)); + expect(follower).toHaveProperty("isMe", expect.any(Boolean)); + + expect(body.meta).toHaveProperty("limit", expect.any(Number)); + expect(body.meta).toHaveProperty("offset", expect.any(Number)); + expect(body.meta).toHaveProperty("count", expect.any(Number)); + }); + + it("should return isMe: false and isFollowing: false for all items (public endpoint)", async () => { + const response = await request({ + method: "GET", + url: `/profiles/${userA.username}/followers`, + }); + const body = parseBody<{ + data: { isMe: boolean; isFollowing: boolean }[]; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.every((u) => u.isMe === false)).toBe(true); + expect(body.data.every((u) => u.isFollowing === false)).toBe(true); + }); + + it("should support pagination with limit=1", async () => { + const response = await request({ + method: "GET", + url: `/profiles/${userA.username}/followers?limit=1&offset=0`, + }); + const body = parseBody<{ + data: unknown[]; + meta: { limit: number; offset: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.length).toBe(1); + expect(body.meta.limit).toBe(1); + expect(body.meta.offset).toBe(0); + }); + + it("should return 200 with empty array for a user with no followers", async () => { + const ts2 = Date.now(); + const newUser = { + email: `gfl-empty-${ts2}@test.com`, + password: "password123", + username: `gflempty${ts2}`, + }; + await request({ + method: "POST", + url: "/auth/register", + payload: newUser, + }); + + const response = await request({ + method: "GET", + url: `/profiles/${newUser.username}/followers`, + }); + const body = parseBody<{ + data: unknown[]; + meta: { count: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data).toEqual([]); + expect(body.meta.count).toBe(0); + }); + }); + + describe("GET /profiles/:username/following - Get User Following", () => { + it("should return 200 with following array and correct item shape", async () => { + const response = await request({ + method: "GET", + url: `/profiles/${userA.username}/following`, + }); + const body = parseBody<{ + data: { + userId: string; + username: string; + isFollowing: boolean; + isMe: boolean; + }[]; + meta: { limit: number; offset: number; count: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThanOrEqual(1); + + const following = body.data[0]; + expect(following).toHaveProperty("userId", expect.any(String)); + expect(following).toHaveProperty("username", expect.any(String)); + expect(following).toHaveProperty( + "isFollowing", + expect.any(Boolean), + ); + expect(following).toHaveProperty("isMe", expect.any(Boolean)); + + expect(body.meta).toHaveProperty("limit", expect.any(Number)); + expect(body.meta).toHaveProperty("offset", expect.any(Number)); + expect(body.meta).toHaveProperty("count", expect.any(Number)); + }); + + it("should support pagination with limit=1", async () => { + const response = await request({ + method: "GET", + url: `/profiles/${userA.username}/following?limit=1&offset=0`, + }); + const body = parseBody<{ + data: unknown[]; + meta: { limit: number; offset: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.length).toBe(1); + expect(body.meta.limit).toBe(1); + }); + + it("should return 200 with empty array for a user following nobody", async () => { + const ts3 = Date.now(); + const newUser = { + email: `gfl-nf-${ts3}@test.com`, + password: "password123", + username: `gflnf${ts3}`, + }; + await request({ + method: "POST", + url: "/auth/register", + payload: newUser, + }); + + const response = await request({ + method: "GET", + url: `/profiles/${newUser.username}/following`, + }); + const body = parseBody<{ + data: unknown[]; + meta: { count: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data).toEqual([]); + expect(body.meta.count).toBe(0); + }); + }); +}); diff --git a/tests/e2e/follow-user/unfollow.test.ts b/tests/e2e/follow-user/unfollow.test.ts new file mode 100644 index 0000000..ab12689 --- /dev/null +++ b/tests/e2e/follow-user/unfollow.test.ts @@ -0,0 +1,129 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +const FAKE_UUID = "00000000-0000-0000-0000-000000000000"; + +/** + * E2E tests for the Unfollow User endpoint. + * Validates that an authenticated user can unfollow another user, + * that the operation is idempotent, and that proper errors are returned + * for self-unfollow, non-existent targets, or unauthenticated requests. + */ +describe("DELETE /follows - Unfollow User", () => { + const ts = Date.now(); + const userA = { + email: `ufu-a-${ts}@test.com`, + password: "password123", + username: `ufua${ts}`, + }; + const userB = { + email: `ufu-b-${ts}@test.com`, + password: "password123", + username: `ufub${ts}`, + }; + + let tokenA = ""; + let userAId = ""; + let userBId = ""; + + /** + * Registers two users, logs in as user A, and has A follow B + * so that the first unfollow test has a real follow to remove. + */ + beforeAll(async () => { + // Register user B first + const registerB = await request({ + method: "POST", + url: "/auth/register", + payload: userB, + }); + userBId = parseBody<{ data: { id: string } }>(registerB).data.id; + + // Register & login user A + const registerA = await request({ + method: "POST", + url: "/auth/register", + payload: userA, + }); + userAId = parseBody<{ data: { id: string } }>(registerA).data.id; + + const loginA = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: userA.email, password: userA.password }, + }); + tokenA = parseBody<{ data: { accessToken: string } }>(loginA).data + .accessToken; + + // Seed: A follows B + await authRequest(tokenA, { + method: "POST", + url: "/follows", + payload: { targetId: userBId }, + }); + }); + + it("should return 200 when unfollowing a followed user", async () => { + const response = await authRequest(tokenA, { + method: "DELETE", + url: "/follows", + payload: { targetId: userBId }, + }); + const body = parseBody<{ + data: { followersCount: number }; + meta: { timestamp: string }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.followersCount).toEqual(expect.any(Number)); + expect(body.meta).toHaveProperty("timestamp", expect.any(String)); + }); + + it("should return 200 when unfollowing a user not currently followed (idempotent)", async () => { + const response = await authRequest(tokenA, { + method: "DELETE", + url: "/follows", + payload: { targetId: userBId }, + }); + + expect(response.statusCode).toBe(200); + }); + + it("should return 400 when unfollowing yourself", async () => { + const response = await authRequest(tokenA, { + method: "DELETE", + url: "/follows", + payload: { targetId: userAId }, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(400); + expect(body.title).toBe("BadRequestError"); + expect(body.detail).toBe("You cannot unfollow yourself."); + }); + + it("should return 404 when the target user does not exist", async () => { + const response = await authRequest(tokenA, { + method: "DELETE", + url: "/follows", + payload: { targetId: FAKE_UUID }, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + expect(body.detail).toBe("User not found."); + }); + + it("should return 401 when not authenticated", async () => { + const response = await request({ + method: "DELETE", + url: "/follows", + payload: { targetId: userBId }, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(401); + expect(body.title).toBe("UnauthorizedError"); + }); +}); From f139b6676a6f30dde8cdfaaf2a1f988d329e2d74 Mon Sep 17 00:00:00 2001 From: aqu Date: Fri, 17 Apr 2026 15:54:03 +0300 Subject: [PATCH 2/2] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/e2e/follow-user/get-follows.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/follow-user/get-follows.test.ts b/tests/e2e/follow-user/get-follows.test.ts index 17f08d1..2f34b61 100644 --- a/tests/e2e/follow-user/get-follows.test.ts +++ b/tests/e2e/follow-user/get-follows.test.ts @@ -1,8 +1,6 @@ import { authRequest, parseBody, request } from "../setup"; import { beforeAll, describe, expect, it } from "vitest"; -const FAKE_USERNAME = "nonexistent_user_xyz"; - /** * E2E tests for the Get Followers and Get Following endpoints. * Validates listing a user's followers/following with correct shape,