diff --git a/components/common/ErrorPage/index.test.tsx b/components/common/ErrorPage/index.test.tsx new file mode 100644 index 00000000..9078af24 --- /dev/null +++ b/components/common/ErrorPage/index.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import ErrorPage from "."; +import userEvent from "@testing-library/user-event"; + +describe("ErrorPage", () => { + test("prefix, title, description을 전달하면 해당 문구와 다시시도 버튼이 렌더링된다 ", () => { + render( + {}} + prefix="목록을 " + title="불러오지 못했습니다." + description="커스텀 설명입니다." + />, + ); + const title = screen.getByText("목록을 불러오지 못했습니다."); + const description = screen.getByText("커스텀 설명입니다."); + const button = screen.getByRole("button", { name: "다시 시도" }); + + expect(title).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + expect(button).toBeInTheDocument(); + }); + + test("다시 시도 버튼 클릭 시 onRetryAction이 호출된다", async () => { + const user = userEvent.setup(); + const onRetryAction = jest.fn(); + render(); + + const button = screen.getByRole("button", { name: "다시 시도" }); + await user.click(button); + + expect(onRetryAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/common/QueryErrorBoundary/index.test.tsx b/components/common/QueryErrorBoundary/index.test.tsx new file mode 100644 index 00000000..40bbeddd --- /dev/null +++ b/components/common/QueryErrorBoundary/index.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import QueryErrorBoundary from "."; +import userEvent from "@testing-library/user-event"; + +function ThrowError(): never { + throw new Error("테스트 에러"); +} +describe("QueryErrorBoundary", () => { + // 테스트 로그 쌓임 방지 + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + // 테스트 끝날때마다 호출기록 초기화ㅏ + afterEach(() => { + consoleError.mockClear(); + }); + + // 테스트 종료시 원래 콘솔로 돌리기 + afterAll(() => { + consoleError.mockRestore(); + }); + + test("자식 컴포넌트에서 에러가 발생하면 fallback UI가 렌더되는지 확인", () => { + render( + + + , + ); + + const title = screen.getByText("목록을 불러오지 못했습니다."); + const description = screen.getByText("잠시 후 다시 시도해주세요."); + const button = screen.getByRole("button", { name: "다시 시도" }); + + expect(title).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + expect(button).toBeInTheDocument(); + }); + test("다시 시도 버튼 클릭시 reset 후 자식컴포넌트를 리렌더링하는지 확인 ", async () => { + const user = userEvent.setup(); + + let isError = true; + + function TestComponent() { + if (isError) { + throw new Error("초기 에러"); + } + return
복구된 콘텐츠
; + } + + render( + + + , + ); + + const title = screen.getByText("목록을 불러오지 못했습니다."); + expect(title).toBeInTheDocument(); + + isError = false; + + const button = screen.getByRole("button", { name: "다시 시도" }); + await user.click(button); + + const content = await screen.findByText("복구된 콘텐츠"); + expect(content).toBeInTheDocument(); + }); +}); diff --git a/components/ui/Modals/AlertModal/index.test.tsx b/components/ui/Modals/AlertModal/index.test.tsx index c943259b..4206cf07 100644 --- a/components/ui/Modals/AlertModal/index.test.tsx +++ b/components/ui/Modals/AlertModal/index.test.tsx @@ -22,7 +22,7 @@ jest.mock("..", () => ({ })); describe("AlertModal", () => { - describe("isOpen 상태에 따라 모달이 열리고 닫히는지 확인", () => { + describe("모달이 열리고 닫히는지 확인한다", () => { test("isOpen이 true면 모달이 열리고 메세지가 보인다", () => { render( {}} handleConfirmButton={() => {}}> @@ -32,7 +32,7 @@ describe("AlertModal", () => { expect(screen.getByText("Alert 메세지")).toBeInTheDocument(); }); - test("isOpen이 false면 모달이 렌더링되지 않는다.", () => { + test("isOpen이 false면 메세지가 보이지 않는다.", () => { render( {}} handleConfirmButton={() => {}}> Alert 메세지 @@ -43,8 +43,8 @@ describe("AlertModal", () => { }); }); - describe("onClose 확인", () => { - test("취소 버튼 클릭 시 onClose가 호출되는지 확인", async () => { + describe("취소 버튼으로 모달을 닫을 수 있는지 확인한다", () => { + test("취소 버튼 클릭 시 onClose가 호출되며 모달이 닫긴다.", async () => { const handleClose = jest.fn(); render( @@ -62,7 +62,7 @@ describe("AlertModal", () => { }); }); - describe("handleConfirmButton 확인", () => { + describe("확인 버튼으로 동작을 실행할 수 있는지 확인한다", () => { test("확인 버튼 클릭 시 handleConfirm이 호출되는지 확인", async () => { const handleConfirm = jest.fn(); render( @@ -79,7 +79,7 @@ describe("AlertModal", () => { }); }); - describe("confirmLabel 확인", () => { + describe("버튼 문구가 올바르게 보이는지 확인한다", () => { test("confirmLabel='삭제'면 버튼에 삭제가 보이는지 확인", async () => { render( {}} handleConfirmButton={() => {}}> diff --git a/features/header/components/Notification/index.test.tsx b/features/header/components/Notification/index.test.tsx new file mode 100644 index 00000000..721a6156 --- /dev/null +++ b/features/header/components/Notification/index.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; + +import Notification from "."; + +jest.mock("@/components/ui/icons", () => ({ + IcBellOutline: () => 읽은 알림 아이콘, + IcBellUnreadOutline: () => 읽지 않은 알림 아이콘, +})); + +jest.mock("@/features/header/queries", () => ({ + useGetNotificationsCount: jest.fn(), +})); + +jest.mock("@/features/header/components/NotificationPanel", () => { + return function MockNotificationPanel() { + return
알림 패널
; + }; +}); + +const { useGetNotificationsCount } = jest.requireMock("@/features/header/queries"); + +describe("상단 알림 아이콘 컴포넌트 ", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("알림 상태를 확인한다", () => { + test("읽지 않은 알림이 없으면 기본 알림 아이콘이 보인다", () => { + useGetNotificationsCount.mockReturnValue({ + data: { count: 0 }, + }); + render(); + + const icon = screen.getByText("읽은 알림 아이콘"); + expect(icon).toBeInTheDocument(); + }); + + test("읽지 않은 알림이 있으면 읽지 않은 알림 아이콘이 보인다", () => { + useGetNotificationsCount.mockReturnValue({ + data: { count: 3 }, + }); + render(); + + const icon = screen.getByText("읽지 않은 알림 아이콘"); + expect(icon).toBeInTheDocument(); + }); + }); +}); diff --git a/features/header/components/NotificationCard/index.test.tsx b/features/header/components/NotificationCard/index.test.tsx new file mode 100644 index 00000000..d88e6889 --- /dev/null +++ b/features/header/components/NotificationCard/index.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import NotificationCard from "."; +import userEvent from "@testing-library/user-event"; + +const mockItem = { + id: 1, + type: "COMMENT", + message: "새 댓글이 달렸습니다", + image: "", + isRead: false, + createdAt: "2026-05-01T00:00:00.000Z", + postId: 10, +}; + +jest.mock("@/components/ui/Thumbnail", () => { + return function MockThumbnail() { + return
; + }; +}); + +jest.mock("@/components/ui/RelativeTime", () => { + return function MockRelativeTime({ date }: { date: string }) { + return {date}; + }; +}); + +function renderNotificationCard(props = {}) { + const handleDeleteAction = jest.fn(); + const handleReadAction = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + return { + user, + handleDeleteAction, + handleReadAction, + }; +} + +describe("상단 알림 카드 컴포넌트", () => { + describe("알림 내용을 확인한다", () => { + test("알림 타입과 메세지가 렌더링된다", () => { + renderNotificationCard(); + + const notificationType = screen.getByText("새로운 댓글"); + const comment = screen.getByText("새 댓글이 달렸습니다"); + + expect(notificationType).toBeInTheDocument(); + expect(comment).toBeInTheDocument(); + }); + + test("읽은 알림이면 읽지 않음 표시가 렌더링되지 않는다", () => { + renderNotificationCard({ + item: { + ...mockItem, + isRead: true, + }, + }); + + const card = screen.queryByTestId("unread-indicator"); + expect(card).not.toBeInTheDocument(); + }); + }); + + describe("알림을 읽고 삭제 할 수 있는지 확인한다", () => { + test("카드를 클릭하면 읽음 액션이 호출된다", async () => { + const { handleReadAction, user } = renderNotificationCard(); + + const card = screen.getByRole("button", { name: "읽지 않은 알림" }); + await user.click(card); + + expect(handleReadAction).toHaveBeenCalledTimes(1); + }); + + test("삭제 버튼을 클릭하면 삭제 액션이 호출된다", async () => { + const { handleDeleteAction, user } = renderNotificationCard(); + + const deleteButton = screen.getByRole("button", { name: "알림 삭제" }); + await user.click(deleteButton); + + expect(handleDeleteAction).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/features/header/components/NotificationCard/index.tsx b/features/header/components/NotificationCard/index.tsx index 55c42f32..399fc802 100644 --- a/features/header/components/NotificationCard/index.tsx +++ b/features/header/components/NotificationCard/index.tsx @@ -5,11 +5,12 @@ import Thumbnail from "@/components/ui/Thumbnail"; import RelativeTime from "@/components/ui/RelativeTime"; const NOTIFICATION_STYLE = { - card: "px-5 py-3 w-full block text-left hover:bg-purple-50 cursor-pointer", + card: "block w-full cursor-pointer px-5 py-3 text-left hover:bg-purple-50", cardContent: "flex items-start gap-4", cardDot: "size-1 shrink-0 rounded-full bg-linear-to-r from-purple-400 to-purple-700", cardType: "flex items-center text-xs font-semibold text-gray-800", - cardDeleteBtn: "flex size-4 cursor-pointer items-center justify-center rounded-full bg-gray-700", + cardDeleteBtn: + "absolute top-3 right-5 z-1 flex size-4 cursor-pointer items-center justify-center rounded-full bg-gray-700", cardMessage: "pt-1 text-sm text-gray-600 break-keep", cardDate: "flex items-center justify-end gap-1 text-xs text-gray-400", }; @@ -50,58 +51,48 @@ export default function NotificationCard({ const typeUi = NOTIFICATION_TYPE_UI[item.type as keyof typeof NOTIFICATION_TYPE_UI] ?? DEFAULT_NOTIFICATION_TYPE_UI; - // 알림 클릭 시 - function handleCardClick() { - handleReadAction(); - } - // 알림 키보드 활성화 - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleReadAction(); - } - } - function handleDeleteClick(event: React.MouseEvent) { - event.stopPropagation(); - handleDeleteAction(); - } return ( -
-
- -
-
+
+ -
-

{item.message}

-
- {!item.isRead &&
-
+ + +
); } diff --git a/features/header/components/NotificationPanel/index.test.tsx b/features/header/components/NotificationPanel/index.test.tsx new file mode 100644 index 00000000..982f288c --- /dev/null +++ b/features/header/components/NotificationPanel/index.test.tsx @@ -0,0 +1,244 @@ +import userEvent from "@testing-library/user-event"; +import NotificationPanel from "."; +import { render, screen } from "@testing-library/react"; + +const mockCommentItem = { + id: 1, + type: "COMMENT", + message: "새 댓글이 달렸습니다.", + image: "", + isRead: false, + createdAt: "2026-05-01T00:00:00.000Z", + postId: 10, +}; + +const mockMeetingItem = { + id: 2, + type: "MEETING_CONFIRMED", + message: "모임이 확정됐습니다.", + image: "", + isRead: true, + createdAt: "2026-05-01T00:00:00.000Z", + meetingId: 20, +}; + +const mockFns = { + push: jest.fn(), + fetchNextPage: jest.fn(), + putRead: jest.fn(), + putReadAll: jest.fn(), + deleteOne: jest.fn(), + deleteAll: jest.fn(), + close: jest.fn(), +}; +jest.mock("@/components/ui/Loading", () => { + return function MockLoading() { + return
로딩 중
; + }; +}); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockFns.push }), +})); + +jest.mock("@/hooks/useIntersectionObserver", () => ({ + useIntersectionObserver: jest.fn(), +})); + +jest.mock("@/features/header/queries", () => ({ + useGetNotifications: jest.fn(), +})); + +jest.mock("@/features/header/mutations", () => ({ + usePutNotificationsRead: () => ({ mutate: mockFns.putRead }), + usePutNotificationsReadAll: () => ({ mutate: mockFns.putReadAll, isPending: false }), + useDeleteNotifications: () => ({ mutate: mockFns.deleteOne }), + useDeleteNotificationsAll: () => ({ mutate: mockFns.deleteAll, isPending: false }), +})); + +const { useGetNotifications } = jest.requireMock("@/features/header/queries"); +const { useIntersectionObserver } = jest.requireMock("@/hooks/useIntersectionObserver"); + +function mockNotifications(overrides = {}) { + useGetNotifications.mockReturnValue({ + data: { + pages: [{ data: [mockCommentItem, mockMeetingItem] }], + }, + fetchNextPage: mockFns.fetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isPending: false, + ...overrides, + }); +} + +function renderNotificationPanel(props = {}) { + const user = userEvent.setup(); + + render(); + return { + user, + }; +} + +describe("상단 알림 패널 컴포넌트", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNotifications(); + }); + + describe("알림 내역을 확인한다", () => { + test("로딩 중이면 로딩 UI가 보인다", () => { + mockNotifications({ + isPending: true, + data: undefined, + }); + renderNotificationPanel({ unreadCount: 0 }); + + const loading = screen.getByText("로딩 중"); + + expect(loading).toBeInTheDocument(); + }); + + test("알림이 없으면 빈 상태 메시지가 보인다", () => { + mockNotifications({ + data: { pages: [{ data: [] }] }, + }); + renderNotificationPanel({ unreadCount: 0 }); + + const empty = screen.getByText("아직 알림이 없어요"); + + expect(empty).toBeInTheDocument(); + }); + + test("알림이 있으면 알림 메시지가 렌더링된다", () => { + renderNotificationPanel(); + + const comment = screen.getByText("새 댓글이 달렸습니다."); + const meeting = screen.getByText("모임이 확정됐습니다."); + expect(comment).toBeInTheDocument(); + expect(meeting).toBeInTheDocument(); + }); + }); + + describe("알림을 읽을 수 있는지 확인한다", () => { + test("읽지 않은 댓글 알림을 클릭하면 읽음 처리 후 해당 게시글로 이동한다", async () => { + const { user } = renderNotificationPanel(); + + const notifications = screen.getByText("새 댓글이 달렸습니다."); + await user.click(notifications); + + expect(mockFns.putRead).toHaveBeenCalledWith({ notificationId: 1 }); + expect(mockFns.close).toHaveBeenCalledTimes(1); + expect(mockFns.push).toHaveBeenCalledWith("/connect/10"); + }); + + test("이미 읽은 모임 알림을 클릭하면 읽음 처리는 하지 않고 모임 상세로 이동한다", async () => { + const { user } = renderNotificationPanel(); + + const notifications = screen.getByText("모임이 확정됐습니다."); + await user.click(notifications); + + expect(mockFns.putRead).not.toHaveBeenCalledWith({ notificationId: 2 }); + expect(mockFns.close).toHaveBeenCalledTimes(1); + expect(mockFns.push).toHaveBeenCalledWith("/meetup/20"); + }); + + test("모두 읽기 버튼을 클릭하면 전체 읽음 액션이 호출된다", async () => { + const { user } = renderNotificationPanel(); + + const readButton = screen.getByRole("button", { name: "모두 읽기" }); + await user.click(readButton); + + expect(mockFns.putReadAll).toHaveBeenCalledTimes(1); + }); + + test("읽지 않은 알림이 없으면 모두 읽기 버튼이 비활성화된다", () => { + mockNotifications({ + data: { + pages: [ + { + data: [ + { ...mockCommentItem, isRead: true }, + { ...mockMeetingItem, isRead: true }, + ], + }, + ], + }, + }); + renderNotificationPanel({ unreadCount: 0 }); + + const readButton = screen.getByRole("button", { name: "모두 읽기" }); + + expect(readButton).toBeDisabled(); + }); + }); + + describe("알림을 삭제할 수 있는지 확인한다", () => { + test("알림 삭제 버튼을 클릭하면 개별 삭제 액션이 호출된다", async () => { + const { user } = renderNotificationPanel(); + + const deleteButtons = screen.getAllByRole("button", { name: "알림 삭제" }); + await user.click(deleteButtons[0]); + + expect(mockFns.deleteOne).toHaveBeenCalledWith({ notificationId: 1 }); + }); + test("알림 전체 삭제 버튼을 클릭하면 전체 삭제 액션이 호출된다", async () => { + const { user } = renderNotificationPanel(); + + const deleteButton = screen.getByRole("button", { name: "알림 전체 삭제" }); + await user.click(deleteButton); + + expect(mockFns.deleteAll).toHaveBeenCalledTimes(1); + }); + }); + + describe("무한스크롤이 올바르게 작동하는지 확인한다", () => { + test("다음 페이지가 있고 가져오는 중이 아니면 무한 스크롤 감시가 활성화된다", () => { + mockNotifications({ + hasNextPage: true, + }); + + renderNotificationPanel(); + + expect(useIntersectionObserver).toHaveBeenCalledWith( + expect.objectContaining({ + onIntersect: mockFns.fetchNextPage, + isEnabled: true, + }), + ); + }); + + test("다음 페이지가 없으면 무한 스크롤 감시가 비활성화된다", () => { + mockNotifications({ + hasNextPage: false, + isFetchingNextPage: false, + }); + + renderNotificationPanel(); + + expect(useIntersectionObserver).toHaveBeenCalledWith( + expect.objectContaining({ + onIntersect: mockFns.fetchNextPage, + isEnabled: false, + }), + ); + }); + + test("다음 페이지를 가져오는 중이면 무한 스크롤 감시가 비활성화된다", () => { + mockNotifications({ + hasNextPage: true, + isFetchingNextPage: true, + }); + + renderNotificationPanel(); + + expect(useIntersectionObserver).toHaveBeenCalledWith( + expect.objectContaining({ + onIntersect: mockFns.fetchNextPage, + isEnabled: false, + }), + ); + }); + }); +}); diff --git a/features/mypage/components/DetailCard/index.test.tsx b/features/mypage/components/DetailCard/index.test.tsx new file mode 100644 index 00000000..8c61ccf4 --- /dev/null +++ b/features/mypage/components/DetailCard/index.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DetailCard from "."; +import { mockMeMeetingApiRes } from "../../mockData"; + +jest.mock("next/image", () => { + return function MockImage(props: { alt: string }) { + return {props.alt}; + }; +}); + +function renderDetailCard(props = {}) { + const user = userEvent.setup(); + + render(); + return { + user, + }; +} + +describe("모임 카드 컴포넌트", () => { + describe("모임 정보를 확인한다", () => { + test("모임명, 참여 인원, 위치, 날짜, 시간이 렌더링 된다", () => { + renderDetailCard(); + + const name = screen.getByText("코딩 스터디"); + const participantCount = screen.getByText("2/2"); + const region = screen.getByText("경기 수원시 영통구"); + const date = screen.getByText("4월 6일"); + const time = screen.getByText("01:05"); + + expect(name).toBeInTheDocument(); + expect(participantCount).toBeInTheDocument(); + expect(region).toBeInTheDocument(); + expect(date).toBeInTheDocument(); + expect(time).toBeInTheDocument(); + }); + + test("모임명과 이미지에 상세페이지 링크가 연결된다", () => { + renderDetailCard(); + + const titleLink = screen.getByRole("link", { name: "코딩 스터디" }); + const imageLink = screen.getByRole("link", { + name: "코딩 스터디모임 대표 이미지", + }); + + expect(titleLink).toHaveAttribute("href", "/meetup/1000"); + expect(imageLink).toHaveAttribute("href", "/meetup/1000"); + }); + + test("badge가 있으면 렌더링 된다", () => { + renderDetailCard({ + badges: [ + { + label: "개설확정", + variant: "completed", + }, + ], + }); + + const badge = screen.getByText("개설확정"); + expect(badge).toBeInTheDocument(); + }); + }); + describe("모임 액션 버튼을 클릭한다", () => { + test("액션 버튼을 클릭하면 액션 핸들러가 실행된다", async () => { + const handleClick = jest.fn(); + const { user } = renderDetailCard({ + actions: [ + { + label: "리뷰 작성하기", + variant: "primary", + handleCardButtonClick: handleClick, + }, + ], + }); + + const reviewCreateButton = screen.getByRole("button", { name: "리뷰 작성하기" }); + await user.click(reviewCreateButton); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test("찜 버튼을 클릭하면 찜하기가 된다", async () => { + const handleWishClick = jest.fn(); + + const { user } = renderDetailCard({ + wishAction: { + isWished: false, + isPending: false, + handleWishClick, + }, + }); + + const wishButton = screen.getByRole("button", { name: "찜 하기" }); + await user.click(wishButton); + + expect(handleWishClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/features/mypage/components/ProfileModal/ProfileImage.test.tsx b/features/mypage/components/ProfileModal/ProfileImage.test.tsx new file mode 100644 index 00000000..9ed9f897 --- /dev/null +++ b/features/mypage/components/ProfileModal/ProfileImage.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ProfileImage from "./ProfileImage"; + +const mockFns = { + uploadImage: jest.fn(), + resetFile: jest.fn(), + changeFile: jest.fn(), +}; + +jest.mock("@/components/ui/Avatar", () => { + return function MockAvatar({ src }: { src: string | null }) { + return
{src ?? "no-image"}
; + }; +}); + +jest.mock("@/hooks/useInputImage", () => ({ + __esModule: true, + default: () => ({ + previewUrl: null, + resetFile: mockFns.resetFile, + changeFile: mockFns.changeFile, + }), +})); + +jest.mock("@/features/mypage/mutations", () => ({ + useUploadProfileImage: () => ({ + mutateAsync: mockFns.uploadImage, + isPending: false, + }), +})); + +function renderProfileImage(props = {}) { + const user = userEvent.setup(); + const handleImageChange = jest.fn(); + const handleUploadPendingChange = jest.fn(); + + render( + , + ); + + return { + user, + handleImageChange, + handleUploadPendingChange, + }; +} + +// 테스트용 이미지 파일 +function createImageFile({ name = "profile.png", type = "image/png", size = 1024 } = {}) { + return new File([new Uint8Array(size)], name, { type }); +} + +function getFileInput() { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +describe("프로필 이미지 업로드 컴포넌트", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFns.uploadImage.mockResolvedValue("https://example.com/uploaded.jpg"); + }); + + test("현재 프로필 이미지가 렌더링된다", () => { + renderProfileImage(); + + const profileImage = screen.getByTestId("avatar-src"); + + expect(profileImage).toHaveTextContent("https://example.com/profile.jpg"); + }); + + test("이미지 크기가 1MB 초과 시 용량 제한 메세지가 보인다", async () => { + const { user, handleImageChange } = renderProfileImage(); + const oversizedImage = createImageFile({ size: 1024 * 1024 + 1 }); + const fileInput = getFileInput(); + + await user.upload(fileInput, oversizedImage); + + const errorMessage = screen.getByText("파일 크기는 1MB를 초과할 수 없습니다."); + + expect(errorMessage).toBeInTheDocument(); + expect(mockFns.uploadImage).not.toHaveBeenCalled(); + expect(handleImageChange).toHaveBeenCalledWith("https://example.com/profile.jpg"); + }); + + test("지원하지 않는 이미지 형식이면 형식 제한 메세지가 보인다", async () => { + renderProfileImage(); + const textFile = createImageFile({ name: "profile.txt", type: "text/plain" }); + const fileInput = getFileInput(); + + fireEvent.change(fileInput, { + target: { files: [textFile] }, + }); + + const errorMessage = await screen.findByText( + "JPEG, PNG, WebP, GIF 형식의 이미지만 업로드 가능합니다.", + ); + + expect(errorMessage).toBeInTheDocument(); + expect(mockFns.uploadImage).not.toHaveBeenCalled(); + }); + + test("이미지 업로드 성공 시 업로드된 이미지 URL을 전달한다", async () => { + const { user, handleImageChange } = renderProfileImage(); + const image = createImageFile(); + const fileInput = getFileInput(); + + await user.upload(fileInput, image); + + expect(mockFns.changeFile).toHaveBeenCalled(); + expect(mockFns.uploadImage).toHaveBeenCalledWith(image); + expect(handleImageChange).toHaveBeenCalledWith("https://example.com/uploaded.jpg"); + }); + + test("업로드된 이미지 삭제 시 이미지가 삭제된다", async () => { + const { user, handleImageChange } = renderProfileImage({ + initialImageUrl: null, + value: "https://example.com/uploaded.jpg", + }); + const deleteButton = screen.getByRole("button", { name: "업로드 이미지 삭제" }); + + await user.click(deleteButton); + + expect(mockFns.resetFile).toHaveBeenCalled(); + expect(handleImageChange).toHaveBeenCalledWith(null); + }); +}); diff --git a/features/mypage/components/ProfileModal/ProfileImage.tsx b/features/mypage/components/ProfileModal/ProfileImage.tsx index 4971c379..b510e402 100644 --- a/features/mypage/components/ProfileModal/ProfileImage.tsx +++ b/features/mypage/components/ProfileModal/ProfileImage.tsx @@ -154,6 +154,7 @@ export default function ProfileImage({ iconSize="md" onClick={() => handleImageReset()} className={STYLE.profileButton} + aria-label="업로드 이미지 삭제" /> )}
diff --git a/features/mypage/components/ProfileModal/index.test.tsx b/features/mypage/components/ProfileModal/index.test.tsx new file mode 100644 index 00000000..14c0083a --- /dev/null +++ b/features/mypage/components/ProfileModal/index.test.tsx @@ -0,0 +1,222 @@ +import { render, screen } from "@testing-library/react"; +import ProfileModal from "."; +import userEvent from "@testing-library/user-event"; +import { mockUserProfile } from "../../mockData"; + +const patchMutate = jest.fn(); + +jest.mock("@/components/ui/Modals", () => ({ + Modal: ({ + isOpen, + title, + onClose, + children, + footer, + }: { + isOpen: boolean; + title?: string; + onClose?: () => void; + children: React.ReactNode; + footer?: React.ReactNode; + }) => + isOpen ? ( +
+ {title &&

{title}

} + +
{children}
+ {footer &&
{footer}
} +
+ ) : null, +})); + +jest.mock("@/features/mypage/mutations", () => ({ + usePatchUsersMe: jest.fn(() => ({ + mutate: patchMutate, + isPending: false, + })), +})); + +jest.mock("./ProfileImage", () => { + return function MockProfileImage({ + value, + handleImageChange, + handleUploadPendingChange, + }: { + value: string | null; + handleImageChange: (imageUrl: string | null) => void; + handleUploadPendingChange?: (isPending: boolean) => void; + }) { + return ( +
+
{value ?? "no-image"}
+ + +
+ ); + }; +}); + +function renderProfileModal(props = {}) { + const user = userEvent.setup(); + const onClose = jest.fn(); + + render(); + return { + user, + onClose, + }; +} + +describe("프로필 수정 모달 컴포넌트", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("프로필 수정 모달이 열리고 닫히는지 확인한다", () => { + test("모달이 열리면 프로필이 기본값으로 렌더링 된다", () => { + renderProfileModal(); + + const title = screen.getByRole("heading", { name: "프로필 수정하기" }); + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const email = screen.getByRole("textbox", { name: "아이디" }); + const image = screen.getByTestId("profile-image-value"); + + expect(title).toBeInTheDocument(); + expect(name).toHaveValue("홍길동"); + expect(email).toHaveValue("test@example.com"); + expect(image).toHaveTextContent("https://example.com/profile.jpg"); + }); + + test("닫기버튼 클릭 시 onClose가 호출되며 모달이 닫힌다", async () => { + const { user, onClose } = renderProfileModal(); + const closeButton = screen.getByRole("button", { name: "모달 닫기" }); + + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("프로필을 수정할 수 있는지 확인한다", () => { + test("변경된 값이 없으면 mutation을 호출하지 않고 모달을 닫는다", async () => { + const { user, onClose } = renderProfileModal(); + + const editButton = screen.getByRole("button", { name: "수정하기" }); + await user.click(editButton); + + expect(patchMutate).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("닉네임만 변경하고 제출하면 변경된 필드만 payload로 전달된다.", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.type(name, "김코딩"); + await user.click(editButton); + + expect(patchMutate).toHaveBeenCalledTimes(1); + expect(patchMutate).toHaveBeenCalledWith({ name: "김코딩" }); + }); + + test("이미지와 닉네임 모두 변경 시 변경된 필드가 payload로 전달된다", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const imageButton = screen.getByRole("button", { name: "이미지 변경" }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.type(name, "김코딩"); + await user.click(imageButton); + await user.click(editButton); + + expect(patchMutate).toHaveBeenCalledWith({ + name: "김코딩", + image: "https://example.com/new.jpg", + }); + }); + }); + + describe("잘못된 값 제출 시 유효성 검사 메세지를 확인한다", () => { + test("닉네임을 비우고 제출하면 필수 입력 메세지가 보인다", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.click(editButton); + + const message = screen.getByText("닉네임은 필수 입력 항목입니다."); + expect(message).toBeInTheDocument(); + expect(patchMutate).not.toHaveBeenCalled(); + }); + test("닉네임이 8자 초과 시 길이 제한 메세지가 보인다", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.type(name, "아홉글자닉네임테스트"); + await user.click(editButton); + + const message = screen.getByText("닉네임은 8자 이하로 입력해주세요."); + expect(message).toBeInTheDocument(); + expect(patchMutate).not.toHaveBeenCalled(); + }); + }); + + describe("수정 취소 시 모달이 닫히는지 확인한다", () => { + test("변경된 내용이 없으면 취소 클릭 시 바로 모달이 닫긴다", async () => { + const { user, onClose } = renderProfileModal(); + const closeButton = screen.getByRole("button", { name: "취소" }); + + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("변경된 내용이 있으면 취소 클릭 시 Alert가 열리고 Alert 확인 시 모달이 닫긴다", async () => { + const { user, onClose } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const closeButton = screen.getByRole("button", { name: "취소" }); + + await user.clear(name); + await user.type(name, "김코딩"); + await user.click(closeButton); + + expect(onClose).not.toHaveBeenCalled(); + + const alertMessage = screen.getByText("변경된 내용이 있습니다.수정을 취소하시겠습니까?"); + expect(alertMessage).toBeInTheDocument(); + + const AlertButton = screen.getByRole("button", { name: "확인" }); + await user.click(AlertButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("이미지 업로드 중 상태를 확인한다", () => { + test("이미지 업로드 중이면 수정하기 버튼이 비활성화된다", async () => { + const { user } = renderProfileModal(); + + const uploadButton = screen.getByRole("button", { name: "이미지 업로드 중" }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.click(uploadButton); + expect(editButton).toBeDisabled(); + }); + }); +}); diff --git a/features/mypage/components/ReviewCard/index.test.tsx b/features/mypage/components/ReviewCard/index.test.tsx new file mode 100644 index 00000000..4ce95f41 --- /dev/null +++ b/features/mypage/components/ReviewCard/index.test.tsx @@ -0,0 +1,102 @@ +import userEvent from "@testing-library/user-event"; +import ReviewCard from "."; +import { ReviewCardItem } from "../../types"; +import { mockUserProfile } from "../../mockData"; +import { render, screen } from "@testing-library/react"; + +jest.mock("next/image", () => { + return function MockImage(props: { alt: string }) { + return {props.alt}; + }; +}); + +jest.mock("@smastrom/react-rating", () => ({ + Rating: ({ value }: { value: number }) =>
{value}
, +})); + +const mockItem: ReviewCardItem = { + id: 10, + score: 5, + comment: "함께 공부해서 좋았어요", + meetingId: 1000, + meetingType: "자기계발", + meetingName: "코딩 스터디", + meetingImage: "https://example.com/image.jpg", + meetingDateTime: "2026-04-02T16:05:00.000Z", + createdAt: "2026-04-08T06:22:04.892Z", +}; + +function renderReviewCard(props = {}) { + const user = userEvent.setup(); + const handleEdit = jest.fn(); + const handleDelete = jest.fn(); + + render( + , + ); + return { + user, + handleEdit, + handleDelete, + }; +} + +describe("작성한 리뷰 컴포넌트", () => { + describe("작성한 리뷰 내용을 확인한다", () => { + test("별점, 리뷰 내용, 모임명, 카테고리, 작성자 이름이 렌더링 된다", () => { + renderReviewCard(); + + const score = screen.getByTestId("rating-value"); + const review = screen.getByText("함께 공부해서 좋았어요"); + const meetingName = screen.getByText("코딩 스터디"); + const type = screen.getByText("자기계발"); + const userName = screen.getByText("홍길동"); + + expect(score).toHaveTextContent("5"); + expect(review).toBeInTheDocument(); + expect(meetingName).toBeInTheDocument(); + expect(type).toBeInTheDocument(); + expect(userName).toBeInTheDocument(); + }); + + test("이미지에 상세페이지 링크가 연결된다", () => { + renderReviewCard(); + + const imageLink = screen.getByRole("link", { + name: "코딩 스터디모임 대표 이미지", + }); + + expect(imageLink).toHaveAttribute("href", "/meetup/1000"); + }); + }); + describe("작성한 리뷰를 변경한다", () => { + test("수정하기 클릭 시 수정 액션이 실행된다", async () => { + const { user, handleEdit } = renderReviewCard(); + + const dropdown = screen.getByRole("button", { name: "리뷰 옵션 열기" }); + await user.click(dropdown); + + const editButton = await screen.findByRole("menuitem", { name: "수정하기" }); + await user.click(editButton); + + expect(handleEdit).toHaveBeenCalledTimes(1); + }); + test("삭제하기 클릭 시 삭제 액션이 실행된다", async () => { + const { user, handleDelete } = renderReviewCard(); + + const dropdown = screen.getByRole("button", { name: "리뷰 옵션 열기" }); + await user.click(dropdown); + + const deleteButton = await screen.findByRole("menuitem", { name: "삭제하기" }); + await user.click(deleteButton); + + expect(handleDelete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/features/mypage/mapper.test.ts b/features/mypage/mapper.test.ts index feb9a881..8a487e1e 100644 --- a/features/mypage/mapper.test.ts +++ b/features/mypage/mapper.test.ts @@ -27,11 +27,11 @@ describe("mypage mapper", () => { id: 1000, name: "코딩 스터디", region: "경기 수원시 영통구", - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", canceledAt: null, confirmedAt: null, hostId: 1234, @@ -62,13 +62,13 @@ describe("mypage mapper", () => { test("canceledAt과 confirmedAt에 값이 있으면 그대로 반환한다", () => { const input = { ...mockMeMeetingApiRes, - canceledAt: "2026-03-31T09:41:49.482Z", - confirmedAt: "2026-03-31T09:40:02.178Z", + canceledAt: "2026-04-05T09:41:49.482Z", + confirmedAt: "2026-04-05T09:40:02.178Z", }; const result = mapUsersMeMeetings(input); - expect(result.canceledAt).toBe("2026-03-31T09:41:49.482Z"); - expect(result.confirmedAt).toBe("2026-03-31T09:40:02.178Z"); + expect(result.canceledAt).toBe("2026-04-05T09:41:49.482Z"); + expect(result.confirmedAt).toBe("2026-04-05T09:40:02.178Z"); }); test("mapJoinedMeeting와 달리 role를 반환한다 ", () => { const result = mapUsersMeMeetings(mockMeMeetingApiRes); @@ -85,11 +85,11 @@ describe("mypage mapper", () => { id: 1000, name: "코딩 스터디", region: "경기 수원시 영통구", - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", canceledAt: null, confirmedAt: null, hostId: 1234, @@ -119,13 +119,13 @@ describe("mypage mapper", () => { test("canceledAt과 confirmedAt에 값이 있으면 그대로 반환한다", () => { const input = { ...mockMeetingJoinedApiRes, - canceledAt: "2026-03-31T09:41:49.482Z", - confirmedAt: "2026-03-31T09:40:02.178Z", + canceledAt: "2026-04-05T09:41:49.482Z", + confirmedAt: "2026-04-05T09:40:02.178Z", }; const result = mapJoinedMeeting(input); - expect(result.canceledAt).toBe("2026-03-31T09:41:49.482Z"); - expect(result.confirmedAt).toBe("2026-03-31T09:40:02.178Z"); + expect(result.canceledAt).toBe("2026-04-05T09:41:49.482Z"); + expect(result.confirmedAt).toBe("2026-04-05T09:40:02.178Z"); }); }); diff --git a/features/mypage/mockData.ts b/features/mypage/mockData.ts index a8f09f0c..7eb99594 100644 --- a/features/mypage/mockData.ts +++ b/features/mypage/mockData.ts @@ -11,25 +11,25 @@ export const mockMeMeetingApiRes: MeMeetingApiRes = { address: "경기 수원시 영통구 원천동 산 5-1, 상세주소", latitude: 37.28295793156606, longitude: 127.0435528563181, - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", description: "함께 공부해요", canceledAt: null, confirmedAt: null, hostId: 1234, - createdAt: "2026-03-31T09:41:49.482Z", - updatedAt: "2026-03-31T09:42:02.178Z", + createdAt: "2026-04-05T09:41:49.482Z", + updatedAt: "2026-04-05T09:42:02.178Z", host: { id: 1234, name: "홍길동", - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", }, createdBy: 1234, isFavorited: false, - joinedAt: "2026-03-31T09:41:50.024Z", + joinedAt: "2026-04-05T09:41:50.024Z", isReviewed: false, isCompleted: true, role: "host", @@ -44,25 +44,25 @@ export const mockMeetingJoinedApiRes: MeetingJoinedApiRes = { address: "경기 수원시 영통구 원천동 산 5-1, 상세주소", latitude: 37.28295793156606, longitude: 127.0435528563181, - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", description: "함께 공부해요", canceledAt: null, confirmedAt: null, hostId: 1234, - createdAt: "2026-03-31T09:41:49.482Z", - updatedAt: "2026-03-31T09:42:02.178Z", + createdAt: "2026-04-05T09:41:49.482Z", + updatedAt: "2026-04-05T09:42:02.178Z", host: { id: 1234, name: "홍길동", - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", }, createdBy: 1234, isFavorited: false, - joinedAt: "2026-03-31T09:41:50.024Z", + joinedAt: "2026-04-05T09:41:50.024Z", isReviewed: false, isCompleted: true, }; @@ -86,5 +86,5 @@ export const mockUserProfile: UserProfile = { id: 1234, name: "홍길동", email: "test@example.com", - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", }; diff --git a/features/mypage/utils.ts b/features/mypage/utils.ts index 06159c46..434342fa 100644 --- a/features/mypage/utils.ts +++ b/features/mypage/utils.ts @@ -8,7 +8,6 @@ export interface HostMeetupActionHandlers { /** 모임 취소 */ onCancelMeetup: () => void; /** 모임 리뷰 작성 */ - onWriteReview: () => void; } diff --git a/features/shared/components/ReviewModal/index.test.tsx b/features/shared/components/ReviewModal/index.test.tsx index 44e86733..4f9c0de7 100644 --- a/features/shared/components/ReviewModal/index.test.tsx +++ b/features/shared/components/ReviewModal/index.test.tsx @@ -21,7 +21,7 @@ jest.mock("@smastrom/react-rating", () => ({ ), })); // HeadlessUI 테스트 경고로 인해 단순 모달 UI로 대체 -jest.mock("../../../../components/ui/Modals", () => ({ +jest.mock("@/components/ui/Modals", () => ({ Modal: ({ isOpen, title, @@ -46,7 +46,7 @@ function renderReviewModal(props = {}) { const onClose = jest.fn(); const handleFormSubmit = jest.fn(); - const utils = render( + render( , ); return { - ...utils, onClose, handleFormSubmit, }; } describe("ReviewModal", () => { - describe("isOpen 상태에 따라 모달이 열리고 닫히는지 확인", () => { + describe("모달이 열리고 닫히는지 확인한다", () => { test("isOpen이 true면 모달이 열리고 메시지가 보인다", () => { renderReviewModal(); @@ -76,7 +75,7 @@ describe("ReviewModal", () => { }); }); - describe("onClose 확인", () => { + describe("취소 버튼으로 모달을 닫을 수 있는지 확인한다", () => { test("취소 버튼 클릭 시 onClose가 호출되는지 확인", async () => { const { onClose } = renderReviewModal(); @@ -89,7 +88,7 @@ describe("ReviewModal", () => { }); }); - describe("mode 분기 확인", () => { + describe("작성모드와 수정모드를 확인한다", () => { test("create 모드면 '리뷰 작성' 타이틀과 '작성 완료' 버튼이 보인다", () => { renderReviewModal({ mode: "create" }); @@ -111,7 +110,7 @@ describe("ReviewModal", () => { }); }); - describe("초기값 반영 확인", () => { + describe("리뷰 초기값이 반영 되는지 확인한다", () => { test("edit 모드에서 초기 score와 comment가 반영된다", () => { renderReviewModal({ mode: "edit", initialValue: { score: 4, comment: "리뷰 내용" } }); @@ -124,7 +123,7 @@ describe("ReviewModal", () => { }); }); - describe("form 제출 확인", () => { + describe("별점과 리뷰 제출 되는지 확인한다", () => { test("별점과 리뷰를 제출하면 handleFormSubmit이 호출된다", async () => { const { handleFormSubmit } = renderReviewModal(); const user = userEvent.setup(); @@ -145,7 +144,7 @@ describe("ReviewModal", () => { }); }); - describe("validation 확인", () => { + describe("별점과 리뷰를 입력하지 않고 제출했을때 유효성메시지가 뜨는지 확인한다", () => { test("별점과 리뷰를 입력하지 않고 제출하면 validation 메시지가 표시된다", async () => { const { handleFormSubmit } = renderReviewModal(); const user = userEvent.setup(); @@ -162,7 +161,7 @@ describe("ReviewModal", () => { }); }); - describe("dirty 상태 확인", () => { + describe("작성 중 취소할 때 경고창이 보이는지 확인한다", () => { test("변경된 내용이 없으면 취소 클릭 시 바로 onClose가 호출된다", async () => { const { onClose } = renderReviewModal({ mode: "edit", @@ -175,8 +174,8 @@ describe("ReviewModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - describe("Alert 동작 확인", () => { - test("작성 중 취소시 dirty 상태면 Alert이 열리는지 확인", async () => { + describe("작성 중 취소시 Alert이 열리는지 확인", () => { + test("create 모드일 때 취소시 Alert이 열리는지 확인", async () => { renderReviewModal(); const user = userEvent.setup(); @@ -187,7 +186,7 @@ describe("ReviewModal", () => { expect(alert).toBeInTheDocument(); }); - test("수정 중 취소시 dirty 상태면 Alert이 열리는지 확인", async () => { + test("edit 모드일 때 취소시 Alert이 열리는지 확인", async () => { renderReviewModal({ mode: "edit", initialValue: { score: 4, comment: "기존 리뷰" } }); const user = userEvent.setup();