From 36919c782d5bbc932aae691540e88b044bea15a6 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 17 Apr 2026 13:54:46 +0300 Subject: [PATCH] test(e2e): add comment E2E test suite (27 tests) - create.test.ts: top-level comment, nested reply, 404 (post/parent), 400 (cross-post parentId), 401, validation error (7 tests) - delete.test.ts: owner delete 204, 404, non-owner 403, 401 (4 tests) - interact.test.ts: like/unlike with idempotency, 404, 401 (8 tests) - get-comments.test.ts: list with pagination, single comment shape, replies with pagination, 404 cases (8 tests) --- tests/e2e/comment/create.test.ts | 199 +++++++++++++++++ tests/e2e/comment/delete.test.ts | 123 +++++++++++ tests/e2e/comment/get-comments.test.ts | 281 +++++++++++++++++++++++++ tests/e2e/comment/interact.test.ts | 144 +++++++++++++ 4 files changed, 747 insertions(+) create mode 100644 tests/e2e/comment/create.test.ts create mode 100644 tests/e2e/comment/delete.test.ts create mode 100644 tests/e2e/comment/get-comments.test.ts create mode 100644 tests/e2e/comment/interact.test.ts diff --git a/tests/e2e/comment/create.test.ts b/tests/e2e/comment/create.test.ts new file mode 100644 index 0000000..ea116f2 --- /dev/null +++ b/tests/e2e/comment/create.test.ts @@ -0,0 +1,199 @@ +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 Create Comment endpoint. + * Validates top-level comment creation, nested reply creation, + * and proper error handling for invalid inputs, unknown resources, + * and unauthenticated requests. + */ +describe("POST /posts/:postId/comments - Create Comment", () => { + const ts = Date.now(); + const user = { + email: `cc-${ts}@test.com`, + password: "password123", + username: `cc${ts}`, + }; + + let accessToken = ""; + let postId = ""; + + /** + * Registers a test user, logs in to obtain an access token, + * and creates a post to attach comments to throughout all tests. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: user, + }); + + const loginRes = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + + accessToken = parseBody<{ data: { accessToken: string } }>(loginRes) + .data.accessToken; + + const postRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "E2E test post for comment creation" }, + }); + + postId = parseBody<{ data: { id: string } }>(postRes).data.id; + }); + + it("should return 201 when creating a top-level comment", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Top-level comment" }, + }); + const body = parseBody<{ + data: { + id: string; + content: string; + postId: string; + parentId: string | null; + mediaUrls: string[]; + createdAt: string; + likeCount: number; + replyCount: number; + isLiked: boolean; + isBookmarked: boolean; + author: { + id: string; + isMe: boolean; + }; + }; + meta: { timestamp: string }; + }>(response); + + expect(response.statusCode).toBe(201); + expect(body.data.id).toEqual(expect.any(String)); + expect(body.data.content).toBe("Top-level comment"); + expect(body.data.postId).toBe(postId); + expect(body.data.parentId).toBeNull(); + expect(body.data.mediaUrls).toEqual(expect.any(Array)); + expect(body.data.createdAt).toEqual(expect.any(String)); + expect(body.data.likeCount).toBe(0); + expect(body.data.replyCount).toBe(0); + expect(body.data.isLiked).toBe(false); + expect(body.data.isBookmarked).toBe(false); + expect(body.data.author.isMe).toBe(true); + expect(body.meta).toHaveProperty("timestamp", expect.any(String)); + }); + + it("should return 201 when creating a nested reply via parentId", async () => { + // Create a parent comment first + const parentRes = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Parent comment for reply test" }, + }); + const parentId = parseBody<{ data: { id: string } }>(parentRes).data.id; + + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Nested reply", parentId }, + }); + const body = parseBody<{ + data: { id: string; parentId: string | null }; + meta: { timestamp: string }; + }>(response); + + expect(response.statusCode).toBe(201); + expect(body.data.parentId).toBe(parentId); + expect(body.meta).toHaveProperty("timestamp", expect.any(String)); + }); + + it("should return 404 when the post does not exist", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${FAKE_UUID}/comments`, + payload: { content: "Comment on fake post" }, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + expect(body.detail).toBe("Post not found."); + }); + + it("should return 404 when the parentId does not exist", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Reply to ghost", parentId: FAKE_UUID }, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + expect(body.detail).toBe("Parent comment not found."); + }); + + it("should return 400 when parentId belongs to a different post", async () => { + // Create a second post and a comment on it + const secondPostRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "Second post for cross-post parentId test" }, + }); + const secondPostId = parseBody<{ data: { id: string } }>(secondPostRes) + .data.id; + + const commentOnSecondPostRes = await authRequest(accessToken, { + method: "POST", + url: `/posts/${secondPostId}/comments`, + payload: { content: "Comment on second post" }, + }); + const crossPostCommentId = parseBody<{ data: { id: string } }>( + commentOnSecondPostRes, + ).data.id; + + // Try to use that comment as parentId on the first post + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { + content: "Cross-post reply attempt", + parentId: crossPostCommentId, + }, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(400); + expect(body.title).toBe("BadRequestError"); + expect(body.detail).toBe("Parent comment belongs to a different post."); + }); + + it("should return 401 when not authenticated", async () => { + const response = await request({ + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Unauthenticated comment" }, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(401); + expect(body.title).toBe("UnauthorizedError"); + }); + + it("should return 400 when content is missing", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/tests/e2e/comment/delete.test.ts b/tests/e2e/comment/delete.test.ts new file mode 100644 index 0000000..f880f8d --- /dev/null +++ b/tests/e2e/comment/delete.test.ts @@ -0,0 +1,123 @@ +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 Delete Comment endpoint. + * Validates that the comment owner can delete their comment, + * that non-owners receive a 403, and that proper errors are returned + * for unknown comments or unauthenticated requests. + */ +describe("DELETE /comments/:commentId - Delete Comment", () => { + const ts = Date.now(); + const userA = { + email: `cd-a-${ts}@test.com`, + password: "password123", + username: `cda${ts}`, + }; + const userB = { + email: `cd-b-${ts}@test.com`, + password: "password123", + username: `cdb${ts}`, + }; + + let tokenA = ""; + let tokenB = ""; + let postId = ""; + let commentId = ""; + + /** + * Registers two test users, logs both in, and has user A create a post + * and a comment to use as the deletion target throughout all tests. + */ + beforeAll(async () => { + // Register & login user A + await request({ + method: "POST", + url: "/auth/register", + payload: userA, + }); + 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 & login user B + await request({ + method: "POST", + url: "/auth/register", + payload: userB, + }); + const loginB = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: userB.email, password: userB.password }, + }); + tokenB = parseBody<{ data: { accessToken: string } }>(loginB).data + .accessToken; + + // User A creates a post + const postRes = await authRequest(tokenA, { + method: "POST", + url: "/posts", + payload: { content: "E2E test post for comment deletion" }, + }); + postId = parseBody<{ data: { id: string } }>(postRes).data.id; + + // User A creates a comment + const commentRes = await authRequest(tokenA, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Comment to be deleted" }, + }); + commentId = parseBody<{ data: { id: string } }>(commentRes).data.id; + }); + + it("should return 403 when a non-owner tries to delete the comment", async () => { + const response = await authRequest(tokenB, { + method: "DELETE", + url: `/comments/${commentId}`, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(403); + expect(body.title).toBe("ForbiddenError"); + expect(body.detail).toBe("This comment is not yours."); + }); + + it("should return 404 when the comment does not exist", async () => { + const response = await authRequest(tokenA, { + method: "DELETE", + url: `/comments/${FAKE_UUID}`, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + + it("should return 401 when not authenticated", async () => { + const response = await request({ + method: "DELETE", + url: `/comments/${commentId}`, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(401); + expect(body.title).toBe("UnauthorizedError"); + }); + + it("should return 204 when the comment owner deletes their comment", async () => { + const response = await authRequest(tokenA, { + method: "DELETE", + url: `/comments/${commentId}`, + }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(""); + }); +}); diff --git a/tests/e2e/comment/get-comments.test.ts b/tests/e2e/comment/get-comments.test.ts new file mode 100644 index 0000000..935d77d --- /dev/null +++ b/tests/e2e/comment/get-comments.test.ts @@ -0,0 +1,281 @@ +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 Get Comment(s) endpoints. + * Covers: list top-level comments on a post, get a single comment, + * and get replies for a comment. Validates pagination meta and 404 errors. + */ +describe("GET Comments", () => { + /** + * Describe A: GET /posts/:postId/comments + * Validates listing top-level comments with pagination. + */ + describe("GET /posts/:postId/comments - Get Post Comments", () => { + const ts = Date.now(); + const user = { + email: `gc-a-${ts}@test.com`, + password: "password123", + username: `gca${ts}`, + }; + + let accessToken = ""; + let postId = ""; + let firstCommentId = ""; + + /** + * Registers a user, creates a post, and seeds 3 top-level comments. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: user, + }); + + const loginRes = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + accessToken = parseBody<{ data: { accessToken: string } }>(loginRes) + .data.accessToken; + + const postRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "E2E test post for get comments" }, + }); + postId = parseBody<{ data: { id: string } }>(postRes).data.id; + + // Seed 3 top-level comments + const c1 = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Comment A" }, + }); + firstCommentId = parseBody<{ data: { id: string } }>(c1).data.id; + + await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Comment B" }, + }); + + await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Comment C" }, + }); + }); + + it("should return 200 with an array of comments and correct meta", async () => { + const response = await request({ + method: "GET", + url: `/posts/${postId}/comments`, + }); + const body = parseBody<{ + data: { id: string; content: string }[]; + meta: { currentPage: number; limit: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThanOrEqual(3); + expect(body.meta).toHaveProperty("currentPage", expect.any(Number)); + expect(body.meta).toHaveProperty("limit", expect.any(Number)); + }); + + it("should support pagination with limit=1", async () => { + const response = await request({ + method: "GET", + url: `/posts/${postId}/comments?page=1&limit=1`, + }); + const body = parseBody<{ + data: unknown[]; + meta: { currentPage: number; limit: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.length).toBe(1); + expect(body.meta.currentPage).toBe(1); + expect(body.meta.limit).toBe(1); + }); + + it("should return 404 when the post does not exist", async () => { + const response = await request({ + method: "GET", + url: `/posts/${FAKE_UUID}/comments`, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + + /** + * Describe B: GET /comments/:commentId + * Re-uses the same post/comments seeded above. + */ + describe("GET /comments/:commentId - Get Single Comment", () => { + it("should return 200 with the comment's full data shape", async () => { + const response = await request({ + method: "GET", + url: `/comments/${firstCommentId}`, + }); + const body = parseBody<{ + data: { + id: string; + content: string; + postId: string; + parentId: string | null; + likeCount: number; + replyCount: number; + isLiked: boolean; + isBookmarked: boolean; + author: { id: string }; + }; + meta: { timestamp: string }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.id).toBe(firstCommentId); + expect(body.data.content).toBe("Comment A"); + expect(body.data.postId).toBe(postId); + expect(body.data.parentId).toBeNull(); + expect(body.data.likeCount).toEqual(expect.any(Number)); + expect(body.data.replyCount).toEqual(expect.any(Number)); + expect(body.data.isLiked).toEqual(expect.any(Boolean)); + expect(body.data.isBookmarked).toEqual(expect.any(Boolean)); + expect(body.data.author).toHaveProperty("id"); + expect(body.meta).toHaveProperty( + "timestamp", + expect.any(String), + ); + }); + + it("should return 404 when the comment does not exist", async () => { + const response = await request({ + method: "GET", + url: `/comments/${FAKE_UUID}`, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + }); + }); + + /** + * Describe C: GET /comments/:commentId/replies + * Validates listing nested replies for a given comment. + */ + describe("GET /comments/:commentId/replies - Get Comment Replies", () => { + const ts = Date.now(); + const user = { + email: `gc-c-${ts}@test.com`, + password: "password123", + username: `gcc${ts}`, + }; + + let accessToken = ""; + let parentCommentId = ""; + + /** + * Registers a user, creates a post, a parent comment, and 2 replies. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: user, + }); + + const loginRes = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + accessToken = parseBody<{ data: { accessToken: string } }>(loginRes) + .data.accessToken; + + const postRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "E2E test post for replies" }, + }); + const postId = parseBody<{ data: { id: string } }>(postRes).data.id; + + const parentRes = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Parent comment" }, + }); + parentCommentId = parseBody<{ data: { id: string } }>(parentRes) + .data.id; + + // Seed 2 replies + await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Reply 1", parentId: parentCommentId }, + }); + + await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Reply 2", parentId: parentCommentId }, + }); + }); + + it("should return 200 with an array of replies", async () => { + const response = await request({ + method: "GET", + url: `/comments/${parentCommentId}/replies`, + }); + const body = parseBody<{ + data: { id: string; parentId: string }[]; + meta: { currentPage: number; limit: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(2); + expect(body.data.every((r) => r.parentId === parentCommentId)).toBe( + true, + ); + expect(body.meta).toHaveProperty("currentPage", expect.any(Number)); + expect(body.meta).toHaveProperty("limit", expect.any(Number)); + }); + + it("should support pagination with limit=1", async () => { + const response = await request({ + method: "GET", + url: `/comments/${parentCommentId}/replies?page=1&limit=1`, + }); + const body = parseBody<{ + data: unknown[]; + meta: { currentPage: number; limit: number }; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data.length).toBe(1); + expect(body.meta.currentPage).toBe(1); + expect(body.meta.limit).toBe(1); + }); + + it("should return 404 when the parent comment does not exist", async () => { + const response = await request({ + method: "GET", + url: `/comments/${FAKE_UUID}/replies`, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + }); +}); diff --git a/tests/e2e/comment/interact.test.ts b/tests/e2e/comment/interact.test.ts new file mode 100644 index 0000000..7cfa443 --- /dev/null +++ b/tests/e2e/comment/interact.test.ts @@ -0,0 +1,144 @@ +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 Like/Unlike Comment endpoints. + * Validates that authenticated users can like and unlike comments, + * that both operations are idempotent, and that proper errors are returned + * for unknown comments or unauthenticated requests. + */ +describe("Comment Like/Unlike Interactions", () => { + const ts = Date.now(); + const user = { + email: `ci-${ts}@test.com`, + password: "password123", + username: `ci${ts}`, + }; + + let accessToken = ""; + let commentId = ""; + + /** + * Registers a test user, logs in, creates a post, and creates a comment + * to use as the interaction target throughout all tests. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: user, + }); + + const loginRes = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + accessToken = parseBody<{ data: { accessToken: string } }>(loginRes) + .data.accessToken; + + const postRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "E2E test post for comment interactions" }, + }); + const postId = parseBody<{ data: { id: string } }>(postRes).data.id; + + const commentRes = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Comment for like/unlike tests" }, + }); + commentId = parseBody<{ data: { id: string } }>(commentRes).data.id; + }); + + describe("POST /comments/:commentId/like - Like Comment", () => { + it("should return 200 when liking a comment", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/comments/${commentId}/like`, + }); + const body = parseBody<{ meta: { timestamp: string } }>(response); + + expect(response.statusCode).toBe(200); + expect(body.meta).toHaveProperty("timestamp", expect.any(String)); + }); + + it("should return 200 when liking an already-liked comment (idempotent)", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/comments/${commentId}/like`, + }); + + expect(response.statusCode).toBe(200); + }); + + it("should return 404 when liking a non-existent comment", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: `/comments/${FAKE_UUID}/like`, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + + it("should return 401 when liking without authentication", async () => { + const response = await request({ + method: "POST", + url: `/comments/${commentId}/like`, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(401); + expect(body.title).toBe("UnauthorizedError"); + }); + }); + + describe("DELETE /comments/:commentId/unlike - Unlike Comment", () => { + it("should return 200 when unliking a comment", async () => { + const response = await authRequest(accessToken, { + method: "DELETE", + url: `/comments/${commentId}/unlike`, + }); + const body = parseBody<{ meta: { timestamp: string } }>(response); + + expect(response.statusCode).toBe(200); + expect(body.meta).toHaveProperty("timestamp", expect.any(String)); + }); + + it("should return 200 when unliking a not-liked comment (idempotent)", async () => { + const response = await authRequest(accessToken, { + method: "DELETE", + url: `/comments/${commentId}/unlike`, + }); + + expect(response.statusCode).toBe(200); + }); + + it("should return 404 when unliking a non-existent comment", async () => { + const response = await authRequest(accessToken, { + method: "DELETE", + url: `/comments/${FAKE_UUID}/unlike`, + }); + const body = parseBody<{ title: string; detail: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + + it("should return 401 when unliking without authentication", async () => { + const response = await request({ + method: "DELETE", + url: `/comments/${commentId}/unlike`, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(401); + expect(body.title).toBe("UnauthorizedError"); + }); + }); +});