diff --git a/apis/images.test.ts b/apis/images.test.ts new file mode 100644 index 00000000..f6a61ec0 --- /dev/null +++ b/apis/images.test.ts @@ -0,0 +1,99 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { uploadImage, ROUTE_IMAGES, ROUTE_IMAGES_UPLOAD } from "./images"; + +const mockPresignedUrl = "https://s3.example.com/presigned"; +const mockPublicUrl = "https://cdn.example.com/image.png"; +const mockFileName = "test.png"; +const mockFileType = "image/png"; + +const ENDPOINT_PRESIGNED = `/api${ROUTE_IMAGES}`; +const ENDPOINT_UPLOAD = `/api${ROUTE_IMAGES_UPLOAD}`; + +function createMockFile(name: string, type: string) { + return new File(["dummy"], name, { type }); +} + +describe("uploadImage api 테스트", () => { + test("presigned URL 발급 후 S3 업로드에 성공하면 publicUrl을 반환함", async () => { + server.use( + http.post(ENDPOINT_PRESIGNED, () => + HttpResponse.json( + { presignedUrl: mockPresignedUrl, publicUrl: mockPublicUrl }, + { status: 200 }, + ), + ), + http.put(ENDPOINT_UPLOAD, () => HttpResponse.json({}, { status: 200 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + const result = await uploadImage(file); + + expect(result).toBe(mockPublicUrl); + }); + + test("지원하지 않는 파일 형식이면 에러를 throw함", async () => { + const errorFileName = "test.svg"; + const errorFileType = "image/svg"; + const file = createMockFile(errorFileName, errorFileType); + + await expect(uploadImage(file)).rejects.toThrow( + `'${errorFileType}'는 지원하지 않는 파일 형식입니다.`, + ); + }); + + test("presigned URL 발급에 실패하면 에러를 throw함", async () => { + const errorResponse = { code: "PRESIGN_FAILED", message: "presigned URL 발급 실패" }; + server.use( + http.post(ENDPOINT_PRESIGNED, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow(errorResponse.message); + }); + + test("presigned URL 발급 실패 시 응답 바디 파싱이 안 되면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_PRESIGNED, () => new HttpResponse(null, { status: 500 }))); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow( + "업로드 주소 생성 중 알 수 없는 에러가 발생했습니다.", + ); + }); + + test("S3 업로드에 실패하면 에러를 throw함", async () => { + const errorResponse = { code: "UPLOAD_FAILED", message: "S3 업로드 실패" }; + + server.use( + http.post(ENDPOINT_PRESIGNED, () => + HttpResponse.json( + { presignedUrl: mockPresignedUrl, publicUrl: mockPublicUrl }, + { status: 200 }, + ), + ), + http.put(ENDPOINT_UPLOAD, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow(errorResponse.message); + }); + + test("S3 업로드 실패 시 응답 바디 파싱이 안 되면 기본 에러 메시지를 throw함", async () => { + server.use( + http.post(ENDPOINT_PRESIGNED, () => + HttpResponse.json( + { presignedUrl: mockPresignedUrl, publicUrl: mockPublicUrl }, + { status: 200 }, + ), + ), + http.put(ENDPOINT_UPLOAD, () => new HttpResponse(null, { status: 500 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow("업로드 중 알 수 없는 에러가 발생했습니다."); + }); +}); diff --git a/apis/images.ts b/apis/images.ts index 7666ddd8..951f4a0e 100644 --- a/apis/images.ts +++ b/apis/images.ts @@ -35,7 +35,7 @@ export async function uploadImage(file: File): Promise { } /** 이미지 업로드 Step1: presigned URL 발급 */ -const ROUTE_IMAGES = "/images/presigned"; +export const ROUTE_IMAGES = "/images/presigned"; async function getPresignedUrl(fileName: string, contentType: string, folder: string = "meetings") { const res = await clientFetch(ROUTE_IMAGES, { method: "POST", @@ -54,7 +54,7 @@ async function getPresignedUrl(fileName: string, contentType: string, folder: st } /** 이미지 업로드 Step2: S3에 이미지 업로드 */ -const ROUTE_IMAGES_UPLOAD = "/images/upload"; +export const ROUTE_IMAGES_UPLOAD = "/images/upload"; async function uploadToS3(presignedUrl: string, file: File) { const res = await clientFetch(ROUTE_IMAGES_UPLOAD, { method: "PUT", diff --git a/apis/meetingTypes.test.ts b/apis/meetingTypes.test.ts new file mode 100644 index 00000000..2b5f3cf4 --- /dev/null +++ b/apis/meetingTypes.test.ts @@ -0,0 +1,80 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { BASE_URL } from "@/mocks/constants"; +import { + getMeetingTypes, + initMeetingTypes, + ROUTE_MEETING_TYPES, + type MeetingTypeResponse, +} from "./meetingTypes"; + +const ENDPOINT_MEETING_TYPES = `${BASE_URL}${ROUTE_MEETING_TYPES}`; + +const mockMeetingTypes: MeetingTypeResponse = [ + { + id: 266, + createdAt: "2025-01-01T00:00:00Z", + name: "자기계발", + description: "게임, 코딩 등의 모임입니다.", + }, + { + id: 267, + createdAt: "2025-01-01T00:00:00Z", + name: "운동/스포츠", + description: "런닝, 테니스 등의 모임입니다.", + }, +]; + +describe("meetingTypes api 테스트", () => { + describe("getMeetingTypes", () => { + test("성공 시 카테고리 목록을 반환함", async () => { + server.use( + http.get(ENDPOINT_MEETING_TYPES, () => + HttpResponse.json(mockMeetingTypes, { status: 200 }), + ), + ); + + const result = await getMeetingTypes(); + + expect(result).toEqual(mockMeetingTypes); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { code: "FETCH_FAILED", message: "카테고리 조회 실패" }; + + server.use( + http.get(ENDPOINT_MEETING_TYPES, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + await expect(getMeetingTypes()).rejects.toThrow(errorResponse.message); + }); + + test("실패 시 응답 바디 파싱이 안 되면 기본 에러 메시지를 throw함", async () => { + server.use(http.get(ENDPOINT_MEETING_TYPES, () => new HttpResponse(null, { status: 500 }))); + + await expect(getMeetingTypes()).rejects.toThrow("모임 카테고리 조회에 실패했습니다."); + }); + }); + + describe("initMeetingTypes", () => { + test("성공 시 카테고리 목록을 반환함", async () => { + server.use( + http.get(ENDPOINT_MEETING_TYPES, () => + HttpResponse.json(mockMeetingTypes, { status: 200 }), + ), + ); + + const result = await initMeetingTypes(); + + expect(result).toEqual(mockMeetingTypes); + }); + + test("getMeetingTypes가 실패하면 null을 반환함", async () => { + server.use(http.get(ENDPOINT_MEETING_TYPES, () => new HttpResponse(null, { status: 500 }))); + + const result = await initMeetingTypes(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/apis/meetingTypes.ts b/apis/meetingTypes.ts index 4fe07b45..592d4cc6 100644 --- a/apis/meetingTypes.ts +++ b/apis/meetingTypes.ts @@ -7,7 +7,7 @@ if (!BASE_URL) { } /** 모임 카테고리 목록 조회(빌드에서 실행) */ -const ROUTE_MEETING_TYPES = "/meeting-types"; +export const ROUTE_MEETING_TYPES = "/meeting-types"; export async function getMeetingTypes() { const res = await fetch(`${BASE_URL}${ROUTE_MEETING_TYPES}`, { method: "GET", diff --git a/apis/meetings.test.ts b/apis/meetings.test.ts new file mode 100644 index 00000000..a6847188 --- /dev/null +++ b/apis/meetings.test.ts @@ -0,0 +1,97 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { + postMeetingsFavorite, + deleteMeetingsFavorite, + postMeetingsJoin, + deleteMeetingsJoin, + ROUTE_MEETINGS_FAVORITES, + ROUTE_MEETINGS_JOIN, +} from "./meetings"; + +const ENDPOINT_FAVORITES = (id: number) => `/api${ROUTE_MEETINGS_FAVORITES(id)}`; +const ENDPOINT_JOIN = (id: number) => `/api${ROUTE_MEETINGS_JOIN(id)}`; + +describe("postMeetingsFavorite api 테스트", () => { + test("성공 시 응답 데이터를 반환함", async () => { + const result = await postMeetingsFavorite({ meetingId: 3 }); + + expect(result).toMatchObject({ id: 3, isFavorited: true }); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(postMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임을 찾을 수 없습니다.", + ); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_FAVORITES(9999), () => new HttpResponse(null, { status: 500 }))); + + await expect(postMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임 찜 추가에 실패했습니다.", + ); + }); +}); + +describe("deleteMeetingsFavorite api 테스트", () => { + test("성공 시 응답 데이터를 반환함", async () => { + const result = await deleteMeetingsFavorite({ meetingId: 4 }); + + expect(result).toMatchObject({ message: "찜 해제 성공" }); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(deleteMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임을 찾을 수 없습니다.", + ); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use( + http.delete(ENDPOINT_FAVORITES(9999), () => new HttpResponse(null, { status: 500 })), + ); + + await expect(deleteMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임 찜 해제에 실패했습니다.", + ); + }); +}); + +describe("postMeetingsJoin api 테스트", () => { + test("이미 참여한 모임이면 에러를 throw함", async () => { + await expect(postMeetingsJoin({ meetingId: 1 })).rejects.toThrow("ALREADY_JOINED"); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(postMeetingsJoin({ meetingId: 9999 })).rejects.toThrow("NOT_FOUND"); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_JOIN(9999), () => new HttpResponse(null, { status: 500 }))); + + await expect(postMeetingsJoin({ meetingId: 9999 })).rejects.toThrow( + "모임 참여에 실패했습니다.", + ); + }); +}); + +describe("deleteMeetingsJoin api 테스트", () => { + test("성공 시 응답 데이터를 반환함", async () => { + const result = await deleteMeetingsJoin({ meetingId: 5 }); + + expect(result).toMatchObject({ message: "참여 취소 성공" }); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(deleteMeetingsJoin({ meetingId: 9999 })).rejects.toThrow("NOT_FOUND"); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.delete(ENDPOINT_JOIN(9999), () => new HttpResponse(null, { status: 500 }))); + + await expect(deleteMeetingsJoin({ meetingId: 9999 })).rejects.toThrow( + "모임 참여 취소에 실패했습니다.", + ); + }); +}); diff --git a/apis/meetings.ts b/apis/meetings.ts index 4b1409eb..7ecbcd71 100644 --- a/apis/meetings.ts +++ b/apis/meetings.ts @@ -1,7 +1,7 @@ import { clientFetch } from "@/libs/clientFetch"; -const ROUTE_MEETINGS_FAVORITES = (meetingId: number) => `/meetings/${meetingId}/favorites`; -const ROUTE_MEETINGS_JOIN = (meetingId: number) => `/meetings/${meetingId}/join`; +export const ROUTE_MEETINGS_FAVORITES = (meetingId: number) => `/meetings/${meetingId}/favorites`; +export const ROUTE_MEETINGS_JOIN = (meetingId: number) => `/meetings/${meetingId}/join`; /** 모임 찜 추가 */ export async function postMeetingsFavorite({ meetingId }: { meetingId: number }): Promise { diff --git a/components/ui/GroupCard/index.test.tsx b/components/ui/GroupCard/index.test.tsx new file mode 100644 index 00000000..f47a9518 --- /dev/null +++ b/components/ui/GroupCard/index.test.tsx @@ -0,0 +1,240 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import GroupCard from "."; + +const joinButtonText = "참여하기"; +const cancelButtonText = "참여 취소"; +const likeButtonLabel = "찜 하기"; +const unlikeButtonLabel = "찜 취소"; +const ribbonText = "참여 모임"; +const regClosedText = "모집 마감"; +const completedText = "모임 완료"; +const confirmedText = "개설 확정"; + +const mockId = 1; +const mockHref = "/meetup/1"; +const mockImageSrc = "https://example.com/img.jpg"; +const mockImageAlt = "모임 이미지"; +const mockName = "오피스 스트레칭"; +const mockRegion = "을지로 3가"; +const mockType = "운동/건강"; +const mockDate = "1월 7일"; +const mockTime = "17:30"; +const mockDeadlineText = "오늘 21시 마감"; +const mockCapacity = 20; +const mockParticipantCount = 12; +const defaultStatus = { + isConfirmed: false, + isRegClosed: false, + isLiked: false, + isJoined: false, + isCompleted: false, +}; + +jest.mock("next/image", () => { + type MockImageProps = React.ImgHTMLAttributes; + return function MockImage(props: MockImageProps) { + return ; + }; +}); + +jest.mock("next/link", () => { + type MockLinkProps = React.AnchorHTMLAttributes & { href: string }; + return function MockLink({ href, children, ...props }: MockLinkProps) { + return ( + + {children} + + ); + }; +}); + +function renderFullCard( + statusOverrides: Partial = {}, + onClick = jest.fn(), + onLike = jest.fn(), +) { + const status = { ...defaultStatus, ...statusOverrides }; + return render( + + + + + + + + + + + , + ); +} + +describe("GroupCard 컴포넌트 테스트", () => { + test("isJoined가 true이면 참여 리본이 표시되어야 함", () => { + renderFullCard({ isJoined: true }); + + expect(screen.getByText(ribbonText)).toBeInTheDocument(); + }); + + test("isJoined가 false이면 참여 리본이 표시되지 않아야 함", () => { + renderFullCard({ isJoined: false }); + + expect(screen.queryByText(ribbonText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.Image 테스트", () => { + test("이미지가 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByAltText(mockImageAlt)).toBeInTheDocument(); + }); + + test("isRegClosed가 true이면 '모집 마감' 오버레이가 표시되어야 함", () => { + renderFullCard({ isRegClosed: true }); + + expect(screen.getByText(regClosedText)).toBeInTheDocument(); + }); + + test("isCompleted가 true이면 '모임 완료' 오버레이가 표시되어야 함", () => { + renderFullCard({ isRegClosed: true, isCompleted: true }); + + expect(screen.getByText(completedText)).toBeInTheDocument(); + }); + + test("isRegClosed가 false이면 오버레이가 표시되지 않아야 함", () => { + renderFullCard({ isRegClosed: false }); + + expect(screen.queryByText(regClosedText)).not.toBeInTheDocument(); + expect(screen.queryByText(completedText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.Title 테스트", () => { + test("모임 이름이 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(mockName)).toBeInTheDocument(); + }); + + test("isConfirmed가 true이면 '개설 확정' 레이블이 표시되어야 함", () => { + renderFullCard({ isConfirmed: true }); + + expect(screen.getByText(confirmedText)).toBeInTheDocument(); + }); + + test("isConfirmed가 false이면 '개설 확정' 레이블이 표시되지 않아야 함", () => { + renderFullCard({ isConfirmed: false }); + + expect(screen.queryByText(confirmedText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.SubTitle 테스트", () => { + test("지역과 모임 종류가 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(mockRegion)).toBeInTheDocument(); + expect(screen.getByText(mockType)).toBeInTheDocument(); + }); +}); + +describe("GroupCard.BadgeGroup 테스트", () => { + test("날짜, 시간, 마감 텍스트가 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(mockDate)).toBeInTheDocument(); + expect(screen.getByText(mockTime)).toBeInTheDocument(); + expect(screen.getByText(mockDeadlineText)).toBeInTheDocument(); + }); + + test("deadlineText가 없으면 마감 태그가 렌더링되지 않아야 함", () => { + render( + + + + + , + ); + + expect(screen.getByText(mockDate)).toBeInTheDocument(); + expect(screen.queryByText(mockDeadlineText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.ParticipantBar 테스트", () => { + test("참여 인원과 정원이 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(String(mockParticipantCount))).toBeInTheDocument(); + expect(screen.getByText(String(mockCapacity))).toBeInTheDocument(); + }); +}); + +describe("GroupCard.JoinButton 테스트", () => { + test("미참여 상태에서 '참여하기' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isJoined: false }); + + expect(screen.getByText(joinButtonText)).toBeInTheDocument(); + }); + + test("참여 상태에서 '참여 취소' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isJoined: true }); + + expect(screen.getByText(cancelButtonText)).toBeInTheDocument(); + }); + + test("클릭 시 onClick이 호출되어야 함", async () => { + const onClick = jest.fn(); + const user = userEvent.setup(); + renderFullCard({ isJoined: false }, onClick); + + await user.click(screen.getByText(joinButtonText)); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("isRegClosed가 true이고 미참여이면 버튼이 비활성화되어야 함", () => { + renderFullCard({ isRegClosed: true, isJoined: false }); + + expect(screen.getByText(joinButtonText)).toBeDisabled(); + }); + + test("isCompleted가 true이면 버튼이 렌더링되지 않아야 함", () => { + renderFullCard({ isCompleted: true }); + + expect(screen.queryByText(joinButtonText)).not.toBeInTheDocument(); + expect(screen.queryByText(cancelButtonText)).not.toBeInTheDocument(); + }); + + test("isRegClosed가 true이지만 참여 상태이면 '참여 취소' 버튼이 활성화되어야 함", () => { + renderFullCard({ isRegClosed: true, isJoined: true }); + + expect(screen.getByText(cancelButtonText)).toBeEnabled(); + }); +}); + +describe("GroupCard.LikeButton 테스트", () => { + test("찜 안 한 상태에서 '찜 하기' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isLiked: false }); + + expect(screen.getByRole("button", { name: likeButtonLabel })).toBeInTheDocument(); + }); + + test("찜한 상태에서 '찜 취소' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isLiked: true }); + + expect(screen.getByRole("button", { name: unlikeButtonLabel })).toBeInTheDocument(); + }); + + test("클릭 시 onClick이 호출되어야 함", async () => { + const onLike = jest.fn(); + const user = userEvent.setup(); + renderFullCard({}, jest.fn(), onLike); + + await user.click(screen.getByRole("button", { name: likeButtonLabel })); + + expect(onLike).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/ui/Inputs/InputFile/index.test.tsx b/components/ui/Inputs/InputFile/index.test.tsx new file mode 100644 index 00000000..d14f80e6 --- /dev/null +++ b/components/ui/Inputs/InputFile/index.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import InputFile from "."; + +const placeholderText = "파일 첨부"; +const hiddenFileInputSelector = 'input[type="file"]'; +const thumbnailAltText = "thumbnail"; +const pendingStatusAriaLabel = "파일 업로드 중"; + +const mockPreviewUrl = "https://example.com/thumb.jpg"; +const mockLabel = "테스트 라벨명"; +const mockName = "customName"; +const mockUseInputImage = { + previewUrl: null as string | null, + setPreviewUrl: jest.fn(), + resetFile: jest.fn(), + changeFile: jest.fn(), +}; + +jest.mock("next/image", () => { + type MockImageProps = React.ImgHTMLAttributes; + return function MockImage(props: MockImageProps) { + return ; + }; +}); + +jest.mock("@/hooks/useInputImage", () => ({ + __esModule: true, + default: () => mockUseInputImage, +})); + +describe("InputFile 컴포넌트 테스트", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseInputImage.previewUrl = null; + }); + + test("커스텀 name이 input에 적용되어야 함", () => { + render(); + + const input = screen.getByLabelText(placeholderText, { selector: hiddenFileInputSelector }); + expect(input).toHaveAttribute("name", mockName); + }); + + test("커스텀 label이 렌더링되어야 함", () => { + render(); + + expect(screen.getByText(mockLabel)).toBeInTheDocument(); + + expect(screen.getByLabelText(mockLabel, { selector: hiddenFileInputSelector })).toBeInTheDocument(); + }); + + test("isPending이 true이면 로딩 오버레이가 표시되어야 함", () => { + render(); + + expect(screen.getByRole("status", { name: pendingStatusAriaLabel })).toBeInTheDocument(); + }); + + test("미리보기 이미지가 있으면 삭제 버튼이 보이고 플레이스홀더는 숨겨져야 함", () => { + mockUseInputImage.previewUrl = mockPreviewUrl; + render(); + + expect(screen.queryByText(placeholderText)).not.toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByAltText(thumbnailAltText)).toBeInTheDocument(); + }); +}); diff --git a/components/ui/Inputs/InputFile/index.tsx b/components/ui/Inputs/InputFile/index.tsx index 8bc82494..cbb5a583 100644 --- a/components/ui/Inputs/InputFile/index.tsx +++ b/components/ui/Inputs/InputFile/index.tsx @@ -112,7 +112,10 @@ export default function InputFile({ isPending ? "pointer-events-none" : !previewUrl ? "cursor-pointer" : "cursor-initial", )}> {isPending && ( -
+
)} diff --git a/features/meetup/apis.test.ts b/features/meetup/apis.test.ts new file mode 100644 index 00000000..e006946b --- /dev/null +++ b/features/meetup/apis.test.ts @@ -0,0 +1,122 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { getKakaoPlace, getMeetups, postMeetup } from "./apis"; +import { buildMeetupListResponse } from "@/mocks/data/meetings/helpers"; +import { MEETINGS } from "@/mocks/data/meetings/fixtures"; +import type { KakaoPlaceItem, MeetupCreateRequest } from "./types"; + +const ENDPOINT_KAKAO_PLACE = "/api/kakao/place"; +const ENDPOINT_MEETINGS = "/api/meetings"; + +describe("getKakaoPlace api 테스트", () => { + const mockPlaceItemData: KakaoPlaceItem[] = [ + { + id: "1", + address_name: "서울 강남구 역삼동 123", + road_address_name: "서울 강남구 테헤란로 123", + place_name: "테스트 장소", + x: "127.0276", + y: "37.4979", + category_group_code: "", + category_group_name: "", + category_name: "", + distance: "", + phone: "", + place_url: "", + }, + ]; + + test("성공 시 KakaoPlaceItem 목록 데이터를 반환함", async () => { + server.use( + http.get(ENDPOINT_KAKAO_PLACE, () => + HttpResponse.json({ documents: mockPlaceItemData }, { status: 200 }), + ), + ); + + const result = await getKakaoPlace("역삼"); + + expect(result).toEqual(mockPlaceItemData); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { message: "카카오 API 오류" }; + server.use( + http.get(ENDPOINT_KAKAO_PLACE, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + await expect(getKakaoPlace("역삼")).rejects.toThrow(errorResponse.message); + }); + + test("응답 body가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.get(ENDPOINT_KAKAO_PLACE, () => new HttpResponse(null, { status: 500 }))); + + await expect(getKakaoPlace("역삼")).rejects.toThrow( + "카카오 장소 검색 API 호출에 실패했습니다.", + ); + }); +}); + +describe("getMeetups api 테스트", () => { + test("성공 시 MeetupListResponse 데이터를 반환함", async () => { + const result = await getMeetups({}); + const expected = buildMeetupListResponse(MEETINGS.data, {}); + + expect(result).toEqual(expected); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { message: "알 수 없는 서버 에러가 발생했습니다." }; + server.use( + http.get(ENDPOINT_MEETINGS, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + await expect(getMeetups({})).rejects.toThrow(errorResponse.message); + }); + + test("응답 body가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.get(ENDPOINT_MEETINGS, () => new HttpResponse(null, { status: 500 }))); + + await expect(getMeetups({})).rejects.toThrow("모임 목록을 불러오는데 실패했습니다."); + }); +}); + +describe("postMeetup api 테스트", () => { + const mockCreateData: MeetupCreateRequest = { + name: "테스트 모임", + type: "자기계발", + region: "서울특별시 강남구", + address: "서울특별시 강남구 역삼동 123", + latitude: 37.4979, + longitude: 127.0276, + dateTime: "2026-06-01T10:00:00.000Z", + registrationEnd: "2026-05-31T23:59:59.000Z", + capacity: 10, + image: "https://example.com/image.png", + description: "테스트 모임입니다.", + }; + + test("성공 시 생성된 모임을 반환함", async () => { + const result = await postMeetup(mockCreateData); + + expect(result).toMatchObject({ + name: mockCreateData.name, + type: mockCreateData.type, + }); + expect(result.id).toBeDefined(); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { message: "알 수 없는 클라이언트 문제가 발생했습니다." }; + server.use( + http.post(ENDPOINT_MEETINGS, () => HttpResponse.json(errorResponse, { status: 400 })), + ); + + await expect(postMeetup(mockCreateData)).rejects.toThrow(errorResponse.message); + }); + + test("응답 body가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_MEETINGS, () => new HttpResponse(null, { status: 500 }))); + + await expect(postMeetup(mockCreateData)).rejects.toThrow("모임 생성에 실패했습니다."); + }); +}); diff --git a/features/meetup/components/AddressField/index.test.tsx b/features/meetup/components/AddressField/index.test.tsx new file mode 100644 index 00000000..ec43cbee --- /dev/null +++ b/features/meetup/components/AddressField/index.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AddressField, { type AddressValues } from "."; +import type { KakaoPlaceItem } from "../../types"; + +const searchInputPlaceholder = "건물, 지번 또는 도로명 검색"; +const detailInputPlaceholder = "상세 주소"; +const listboxRole = "listbox"; + +const mockAddressNamePartial = "역삼"; +const mockAddressName = "서울 강남구 역삼동 123"; +const mockPlace: KakaoPlaceItem = { + id: "1", + address_name: mockAddressName, + road_address_name: "서울 강남구 테헤란로 123", + place_name: "테스트 장소", + x: "127.0276", + y: "37.4979", + category_group_code: "", + category_group_name: "", + category_name: "", + distance: "", + phone: "", + place_url: "", +}; +function createDefaultValue(): AddressValues { + return { + addressName: "", + addressDetail: "", + latitude: 0, + longitude: 0, + region: "", + }; +} + +const mockHandleShowToast = jest.fn(); +jest.mock("@/providers/toast-provider", () => ({ + useToast: () => ({ handleShowToast: mockHandleShowToast }), +})); + +describe("AddressField 컴포넌트 테스트", () => { + let getKakaoPlaceFn: jest.Mock; + let setIsComboOpened: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + getKakaoPlaceFn = jest.fn().mockResolvedValue([mockPlace]); + setIsComboOpened = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("주소 입력 포커스 시 setIsComboOpened(true)가 호출되어야 함", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + await user.click(addressInput); + + expect(setIsComboOpened).toHaveBeenCalledWith(true); + }); + + test("isComboOpened가 true여도 검색어가 없으면 콤보박스가 표시되지 않아야 함", () => { + render( + , + ); + + const listbox = screen.queryByRole(listboxRole); + expect(listbox).not.toBeInTheDocument(); + }); + + test("isComboOpened가 true이고 검색 결과가 있으면 콤보박스가 표시되어야 함", async () => { + const setValue = jest.fn(); + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + fireEvent.change(addressInput, { target: { value: mockAddressNamePartial } }); + jest.advanceTimersByTime(200); + + await waitFor(() => { + expect(getKakaoPlaceFn).toHaveBeenCalledWith(mockAddressNamePartial); + expect(screen.getByRole(listboxRole)).toBeInTheDocument(); + expect(screen.getByText(mockAddressName)).toBeInTheDocument(); + }); + }); + + test("콤보박스 항목 클릭 시 주소가 선택되고 콤보박스가 닫혀야 함", async () => { + const setValue = jest.fn(); + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + fireEvent.change(addressInput, { target: { value: mockAddressNamePartial } }); + jest.advanceTimersByTime(200); + + await waitFor(() => { + expect(screen.getByRole(listboxRole)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(mockAddressName)); + + expect(setValue).toHaveBeenCalledWith( + expect.objectContaining({ + addressName: mockAddressName, + latitude: 37.4979, + longitude: 127.0276, + }), + ); + expect(setIsComboOpened).toHaveBeenCalledWith(false); + }); + + test("상세 주소 변경 시 setValue가 호출되어야 함", async () => { + const setValue = jest.fn(); + render( + , + ); + + const detailInput = screen.getByPlaceholderText(detailInputPlaceholder); + fireEvent.change(detailInput, { target: { value: "5층" } }); + + expect(setValue).toHaveBeenCalled(); + }); + + test("카카오 API 에러 시 에러 토스트가 표시되어야 함", async () => { + getKakaoPlaceFn.mockRejectedValue(new Error("API 오류")); + const setValue = jest.fn(); + + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + fireEvent.change(addressInput, { target: { value: mockAddressNamePartial } }); + jest.advanceTimersByTime(200); + + await waitFor(() => { + expect(mockHandleShowToast).toHaveBeenCalledWith({ + message: "API 오류", + status: "error", + }); + }); + }); + + test("onValuesChange 콜백이 호출되어야 함", async () => { + const onValuesChange = jest.fn(); + + render( + , + ); + + const mockAddressDetail = "테스트 주소 상세"; + const detailInput = screen.getByPlaceholderText(detailInputPlaceholder); + fireEvent.change(detailInput, { target: { value: mockAddressDetail } }); + + expect(onValuesChange).toHaveBeenCalledWith( + expect.objectContaining({ addressDetail: mockAddressDetail }), + ); + }); +}); diff --git a/features/meetup/components/CapacityField/index.test.tsx b/features/meetup/components/CapacityField/index.test.tsx new file mode 100644 index 00000000..60d366a0 --- /dev/null +++ b/features/meetup/components/CapacityField/index.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CapacityField from "."; + +const placeholderText = "숫자만 입력해주세요"; + +describe("CapacityField 컴포넌트 테스트", () => { + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("커스텀 name이 input에 적용되어야 함", () => { + const name = "customName"; + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).toHaveAttribute("name", name); + }); + + test("입력된 값이 양수일 때 input에 표시되어야 함", () => { + const value = 5; + render(); + + expect(screen.getByDisplayValue(value.toString())).toBeInTheDocument(); + }); + + test("value가 0 이하이면 빈 값으로 표시되어야 함", () => { + const value = 0; + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).toHaveValue(null); + }); + + test("입력 시 onChange가 (number, event) 형태로 호출되어야 함", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText(placeholderText); + await user.type(input, "10"); + + expect(onChange).toHaveBeenCalledWith(expect.any(Number), expect.any(Object)); + }); + + test("isRequired가 false이면 required 속성이 없어야 함", () => { + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).not.toBeRequired(); + }); +}); diff --git a/features/meetup/components/CapacityField/index.tsx b/features/meetup/components/CapacityField/index.tsx index bdc299ca..82d04e6a 100644 --- a/features/meetup/components/CapacityField/index.tsx +++ b/features/meetup/components/CapacityField/index.tsx @@ -5,7 +5,7 @@ import { MIN_CONFIRMED_COUNT } from "@/features/meetupDetail/components/Personne interface CapacityFieldProps { /** 필드 이름 @default "capacity" */ - name: string; + name?: string; /** 필드 값 */ value?: number; /** 필드 값 변경 함수 */ diff --git a/features/meetup/components/DateTimeField/index.test.tsx b/features/meetup/components/DateTimeField/index.test.tsx new file mode 100644 index 00000000..d86a7e01 --- /dev/null +++ b/features/meetup/components/DateTimeField/index.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type React from "react"; +import DateTimeField from "."; + +const dateLabelText = "날짜 선택 열기"; +const timeLabelText = "시간 선택 열기"; +const applyButtonText = "적용"; + +describe("DateTimeField 컴포넌트 테스트", () => { + const onDateChange = jest.fn(); + const onTimeChange = jest.fn(); + + function renderDateTimeField(props?: Partial>) { + return render( + , + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("날짜와 시간 값이 표시되어야 함", () => { + const date = "2026-05-01"; + const time = "14:00"; + renderDateTimeField({ date, time }); + + expect(screen.getByDisplayValue(date)).toBeInTheDocument(); + expect(screen.getByDisplayValue(time)).toBeInTheDocument(); + }); + + test("날짜 선택 시 onDateChange가 호출되어야 함", async () => { + const user = userEvent.setup(); + const date = "2026-05-01"; + renderDateTimeField({ date }); + + await user.click(screen.getByLabelText(dateLabelText)); + const applyButton = screen.getByRole("button", { name: applyButtonText }); + await user.click(applyButton); + + expect(onDateChange).toHaveBeenCalledWith(date); + }); + + test("시, 분 선택 시 onTimeChange가 호출되어야 함", async () => { + const user = userEvent.setup(); + renderDateTimeField(); + + await user.click(screen.getByLabelText(timeLabelText)); + + const hourOption = screen.getByRole("option", { name: "14시" }); + await user.click(hourOption); + + const minuteOption = screen.getByRole("option", { name: "30분" }); + await user.click(minuteOption); + + expect(onTimeChange).toHaveBeenCalledWith("14:30"); + }); +}); diff --git a/features/meetup/components/DescField/index.test.tsx b/features/meetup/components/DescField/index.test.tsx new file mode 100644 index 00000000..d9e70c5f --- /dev/null +++ b/features/meetup/components/DescField/index.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DescField from "."; + +const placeholderText = "모임을 설명해주세요"; + +describe("DescField 컴포넌트 테스트", () => { + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("입력 시 onChange가 (value, event) 형태로 호출되어야 함", async () => { + const user = userEvent.setup(); + render(); + + const value = "테스트 설명"; + const textarea = screen.getByPlaceholderText(placeholderText); + await user.type(textarea, value); + + expect(onChange).toHaveBeenCalledWith(value, expect.any(Object)); + }); + + test("isRequired가 false이면 required 속성이 없어야 함", () => { + render(); + + const textarea = screen.getByPlaceholderText(placeholderText); + expect(textarea).not.toBeRequired(); + }); + + test("커스텀 name이 textarea에 적용되어야 함", () => { + const name = "customDesc"; + render(); + + const textarea = screen.getByPlaceholderText(placeholderText); + expect(textarea).toHaveAttribute("name", name); + }); +}); diff --git a/features/meetup/components/FileField/index.test.tsx b/features/meetup/components/FileField/index.test.tsx new file mode 100644 index 00000000..85dcc3a1 --- /dev/null +++ b/features/meetup/components/FileField/index.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import FileField from "."; + +const fieldLabelText = "이미지"; +const hiddenFileInputSelector = 'input[type="file"]'; + +const mockImgUrl = "https://example.com/image.jpg"; +const createFile = () => new File(["dummy"], "image.jpg", { type: "image/jpg" }); + +const mockHandleShowToast = jest.fn(); +jest.mock("@/providers/toast-provider", () => ({ + useToast: () => ({ handleShowToast: mockHandleShowToast }), +})); + +jest.mock("@/hooks/useInputImage", () => ({ + __esModule: true, + default: ({ onChange }: { onChange?: (e: React.ChangeEvent) => void }) => ({ + previewUrl: null, + setPreviewUrl: jest.fn(), + resetFile: jest.fn(), + changeFile: (e: React.ChangeEvent) => onChange?.(e), + }), +})); + +jest.mock("next/image", () => { + return function MockImage(props: { alt: string; src: string }) { + return {props.alt}; + }; +}); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +describe("FileField 컴포넌트 테스트", () => { + let onChange: jest.Mock; + let uploadImageFn: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + onChange = jest.fn(); + uploadImageFn = jest.fn(); + }); + + test("이미지 업로드 성공 시 onChange와 성공 토스트가 호출되어야 함", async () => { + uploadImageFn.mockResolvedValue(mockImgUrl); + + render(, { + wrapper: createWrapper(), + }); + + const input = screen.getByLabelText(fieldLabelText, { + selector: hiddenFileInputSelector, + exact: false, + }); + + const file = createFile(); + const user = userEvent.setup(); + await user.upload(input, file); + + await waitFor(() => { + expect(uploadImageFn).toHaveBeenCalled(); + }); + expect(uploadImageFn.mock.calls[0][0]).toEqual(file); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(mockImgUrl, expect.any(Object)); + }); + expect(mockHandleShowToast).toHaveBeenCalledWith({ + message: "이미지가 업로드되었습니다.", + status: "success", + }); + }); + + test("이미지 업로드 실패 시 에러 토스트가 호출되어야 함", async () => { + uploadImageFn.mockRejectedValue(new Error("업로드 실패")); + + render(, { + wrapper: createWrapper(), + }); + + const input = screen.getByLabelText(fieldLabelText, { + selector: hiddenFileInputSelector, + exact: false, + }); + + const file = createFile(); + const user = userEvent.setup(); + await user.upload(input, file); + + await waitFor(() => { + expect(mockHandleShowToast).toHaveBeenCalledWith({ + message: "업로드 실패", + status: "error", + }); + }); + }); + + test("커스텀 name이 적용되어야 함", () => { + const name = "customThumbnail"; + render(, { + wrapper: createWrapper(), + }); + + const input = screen.getByLabelText(fieldLabelText, { + selector: hiddenFileInputSelector, + exact: false, + }); + expect(input).toHaveAttribute("name", name); + }); +}); diff --git a/features/meetup/components/NameField/index.test.tsx b/features/meetup/components/NameField/index.test.tsx new file mode 100644 index 00000000..c235d3bf --- /dev/null +++ b/features/meetup/components/NameField/index.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import NameField from "."; + +const placeholderText = "모임 이름을 입력해주세요"; + +describe("NameField 컴포넌트 테스트", () => { + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("입력 시 onChange가 (value, event) 형태로 호출되어야 함", async () => { + const user = userEvent.setup(); + render(); + + const value = "테스트 모임명"; + const input = screen.getByPlaceholderText(placeholderText); + await user.type(input, value); + + expect(onChange).toHaveBeenCalledWith(value, expect.any(Object)); + }); + + test("isRequired가 false이면 required 속성이 없어야 함", () => { + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).not.toBeRequired(); + }); + + test("커스텀 name이 input에 적용되어야 함", () => { + const name = "customName"; + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).toHaveAttribute("name", name); + }); +}); diff --git a/features/meetup/components/NameField/index.tsx b/features/meetup/components/NameField/index.tsx index be36f5f1..a910ac93 100644 --- a/features/meetup/components/NameField/index.tsx +++ b/features/meetup/components/NameField/index.tsx @@ -29,7 +29,7 @@ export default function NameField({ - {String(getStepValid(1))} - {String(getStepValid(2))} - {String(checkAllStepValid())} - - + {String(getStepValid(1))} + {String(getStepValid(2))} + {String(getStepValid(3))} + {String(checkAllStepValid())} +
); } @@ -37,16 +45,26 @@ describe("모임 생성 폼 단계별 유효성 상태 저장 테스트", () => }); test("특정 단계를 true로 바꾸면 해당 단계는 true이고 전체 통과는 false", async () => { - expect(screen.getByTestId("step1")).toHaveTextContent("false"); - await user.click(screen.getByRole("button", { name: "valid" })); - expect(screen.getByTestId("step1")).toHaveTextContent("true"); - expect(screen.getByTestId("all")).toHaveTextContent("false"); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("false"); + await user.click(screen.getByTestId(testIdValid1)); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdAll)).toHaveTextContent("false"); }); test("특정 단계 true 후 다시 false로 바꾸면 해당 단계는 false이고 전체 통과는 false", async () => { - await user.click(screen.getByRole("button", { name: "valid" })); - await user.click(screen.getByRole("button", { name: "invalid" })); - expect(screen.getByTestId("step1")).toHaveTextContent("false"); - expect(screen.getByTestId("all")).toHaveTextContent("false"); + await user.click(screen.getByTestId(testIdValid1)); + await user.click(screen.getByTestId(testIdInvalid1)); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("false"); + expect(screen.getByTestId(testIdAll)).toHaveTextContent("false"); + }); + + test("모든 단계가 통과하면 전체 통과는 true", async () => { + await user.click(screen.getByTestId(testIdValid1)); + await user.click(screen.getByTestId(testIdValid2)); + await user.click(screen.getByTestId(testIdValid3)); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdStep2)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdStep3)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdAll)).toHaveTextContent("true"); }); }); diff --git a/features/meetup/create/providers/FormStepProvider.test.tsx b/features/meetup/create/providers/FormStepProvider.test.tsx index 056897ba..1da6741a 100644 --- a/features/meetup/create/providers/FormStepProvider.test.tsx +++ b/features/meetup/create/providers/FormStepProvider.test.tsx @@ -22,16 +22,24 @@ function StepProbe() { ); } +const totalSteps = 5; function setupFormStepProbe(options?: { step?: number; totalSteps?: number }) { const user = userEvent.setup(); - render( - - + let key = 0; + const buildUi = () => ( + + - , + ); - return user; + const view = render(buildUi()); + // key를 사용한 리렌더링 트리거 + const rerender = () => { + key++; + view.rerender(buildUi()); + }; + return { ...view, user, rerender }; } describe("모임 생성 폼 단계별 이동 및 주소 변경 테스트", () => { @@ -39,32 +47,55 @@ describe("모임 생성 폼 단계별 이동 및 주소 변경 테스트", () => window.history.replaceState(null, "", "/"); }); - test("next 함수 실행 시 브라우저 쿼리 step은 1 증가", async () => { + test("next 함수 실행 시 step은 1 증가", async () => { window.history.replaceState(null, "", "?step=2"); - const user = setupFormStepProbe(); + const { user, rerender } = setupFormStepProbe(); expect(screen.getByTestId("current")).toHaveTextContent("2"); + await user.click(screen.getByRole("button", { name: "next" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("3"); expect(new URLSearchParams(window.location.search).get("step")).toBe("3"); }); - test("prev 함수 실행 시 브라우저 쿼리 step은 1 감소", async () => { + test("prev 함수 실행 시 step은 1 감소", async () => { window.history.replaceState(null, "", "?step=2"); - const user = setupFormStepProbe(); + const { user, rerender } = setupFormStepProbe(); + await user.click(screen.getByRole("button", { name: "prev" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("1"); expect(new URLSearchParams(window.location.search).get("step")).toBe("1"); }); - test("step 쿼리가 없을 때 next 함수는 step prop + 1을 브라우저 쿼리에 적용", async () => { - const user = setupFormStepProbe({ step: 4 }); + test("step 쿼리가 없을 때 next 함수는 step prop + 1을 step에 적용", async () => { + const { user, rerender } = setupFormStepProbe({ step: 4 }); expect(screen.getByTestId("current")).toHaveTextContent("4"); + await user.click(screen.getByRole("button", { name: "next" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("5"); expect(new URLSearchParams(window.location.search).get("step")).toBe("5"); }); - test("step 쿼리가 없을 때 prev 함수는 step prop - 1을 브라우저 쿼리에 적용", async () => { - const user = setupFormStepProbe({ step: 4 }); + test("step 쿼리가 없을 때 prev 함수는 step prop - 1을 step에 적용", async () => { + const { user, rerender } = setupFormStepProbe({ step: 4 }); expect(screen.getByTestId("current")).toHaveTextContent("4"); + await user.click(screen.getByRole("button", { name: "prev" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("3"); expect(new URLSearchParams(window.location.search).get("step")).toBe("3"); }); + + test("마지막 단계에서 next 함수 실행 시 step은 totalSteps 값과 동일해야 함", async () => { + window.history.replaceState(null, "", "?step=5"); + const { user, rerender } = setupFormStepProbe(); + + // 아래 동작이 실행되어도 step 쿼리 값은 그대로여야 함 + await user.click(screen.getByRole("button", { name: "next" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("5"); + expect(new URLSearchParams(window.location.search).get("step")).toBe("5"); + }); }); diff --git a/hooks/useDragScroll.test.ts b/hooks/useDragScroll.test.ts new file mode 100644 index 00000000..b48d89f0 --- /dev/null +++ b/hooks/useDragScroll.test.ts @@ -0,0 +1,51 @@ +import { renderHook, act } from "@testing-library/react"; +import type { PointerEvent, MouseEvent } from "react"; +import useDragScroll from "./useDragScroll"; + +type MockPointerEvent = Pick, "button" | "clientX">; +type MockMouseEvent = Pick; + +function setup() { + const { result } = renderHook(() => useDragScroll()); + + // ref.current 직접 주입 + const el = document.createElement("div"); + result.current.ref.current = el; + + // 클릭 이벤트 모킹 + const click: MockMouseEvent = { stopPropagation: jest.fn(), preventDefault: jest.fn() }; + return { result, click }; +} + +describe("useDragScroll 커스텀 훅 테스트", () => { + test("초기 overflow 상태에서 left와 right는 모두 false여야 함", () => { + const { result } = setup(); + + expect(result.current.overflow).toEqual({ left: false, right: false }); + }); + + test("드래그에서 발생되는 기본 클릭 이벤트는 무효여야 함", () => { + const { result, click } = setup(); + + act(() => { + const down: MockPointerEvent = { button: 0, clientX: 100 }; + const move: MockPointerEvent = { button: 0, clientX: 110 }; + result.current.onPointerDown(down as PointerEvent); + result.current.onPointerMove(move as PointerEvent); + }); + + result.current.onClickCapture(click as MouseEvent); + + expect(click.stopPropagation).toHaveBeenCalled(); + expect(click.preventDefault).toHaveBeenCalled(); + }); + + test("드래그 없이 클릭 시 클릭 이벤트가 실행되어야 함", () => { + const { result, click } = setup(); + + result.current.onClickCapture(click as MouseEvent); + + expect(click.stopPropagation).not.toHaveBeenCalled(); + expect(click.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/useInputImage.test.ts b/hooks/useInputImage.test.ts new file mode 100644 index 00000000..58c04c66 --- /dev/null +++ b/hooks/useInputImage.test.ts @@ -0,0 +1,47 @@ +import { renderHook, act } from "@testing-library/react"; +import useInputImage from "./useInputImage"; + +beforeEach(() => { + jest.spyOn(URL, "revokeObjectURL").mockImplementation(jest.fn()); + jest.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function setup(props: Partial[0]> = {}) { + const inputRef = { current: document.createElement("input") }; + const { result } = renderHook(() => useInputImage({ inputRef, ...props })); + return { result, inputRef }; +} + +describe("useInputImage 커스텀 훅 테스트", () => { + const defaultUrl = "https://example.com/img.jpg"; + const mockValue = "C:\\fakepath\\img.jpg"; + + test("초기 previewUrl은 defaultUrl과 동일해야 함", () => { + const { result } = setup({ defaultUrl }); + + expect(result.current.previewUrl).toBe(defaultUrl); + }); + + test("resetFile 호출 시 previewUrl이 null이 되고 input value가 초기화됨", () => { + const { result, inputRef } = setup({ defaultUrl }); + inputRef.current.value = mockValue; + + act(() => result.current.resetFile()); + + expect(result.current.previewUrl).toBeNull(); + expect(inputRef.current.value).toBe(""); + }); + + test("resetFile 호출 시 onChange 콜백이 실행됨", () => { + const onChange = jest.fn(); + const { result, inputRef } = setup({ onChange }); + + act(() => result.current.resetFile()); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: inputRef.current })); + }); +}); diff --git a/hooks/useQueryParams.test.ts b/hooks/useQueryParams.test.ts new file mode 100644 index 00000000..36f21efd --- /dev/null +++ b/hooks/useQueryParams.test.ts @@ -0,0 +1,47 @@ +import { renderHook, act } from "@testing-library/react"; +import { useQueryParams } from "./useQueryParams"; + +jest.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +beforeEach(() => { + window.history.replaceState(null, "", "/"); +}); + +describe("useQueryParams 커스텀 훅 테스트", () => { + test("get 함수 실행 시 해당 키의 쿼리 파라미터 값을 반환", () => { + window.history.replaceState(null, "", "?tab=info&page=2"); + const { result } = renderHook(() => useQueryParams()); + + expect(result.current.get("tab")).toBe("info"); + expect(result.current.get("page")).toBe("2"); + expect(result.current.get("missing")).toBeNull(); + }); + + test("set 함수 실행 시 쿼리 파라미터가 URL에 반영", () => { + window.history.replaceState(null, "", "?tab=info"); + const { result } = renderHook(() => useQueryParams()); + + act(() => { + result.current.set({ tab: "review", page: "3" }); + }); + + const updated = new URLSearchParams(window.location.search); + expect(updated.get("tab")).toBe("review"); + expect(updated.get("page")).toBe("3"); + }); + + test("set 함수에 null 전달 시 해당 쿼리 파라미터가 URL에서 삭제", () => { + window.history.replaceState(null, "", "?tab=info&page=2"); + const { result } = renderHook(() => useQueryParams()); + + act(() => { + result.current.set({ tab: null, page: "5" }); + }); + + const updated = new URLSearchParams(window.location.search); + expect(updated.has("tab")).toBe(false); + expect(updated.get("page")).toBe("5"); + }); +}); diff --git a/hooks/useScrollVisibilityDynamic.test.ts b/hooks/useScrollVisibilityDynamic.test.ts new file mode 100644 index 00000000..3852a400 --- /dev/null +++ b/hooks/useScrollVisibilityDynamic.test.ts @@ -0,0 +1,68 @@ +import { renderHook, act } from "@testing-library/react"; +import useScrollVisibilityDynamic from "./useScrollVisibilityDynamic"; + +// rAF 내부 스크롤 판정 함수(flush)를 테스트에서 수동 실행하기 위한 변수 +let pendingRaf: FrameRequestCallback | null = null; + +// 대기 중인 rAF 콜백을 즉시 실행 +function flushRaf() { + if (pendingRaf) { + const cb = pendingRaf; + pendingRaf = null; + cb(performance.now()); + } +} + +// 브라우저 스크롤 시뮬레이션 (위치 변경 => 이벤트 발생 => rAF 실행) +function scrollTo(y: number) { + Object.defineProperty(window, "scrollY", { value: y, writable: true, configurable: true }); + window.dispatchEvent(new Event("scroll")); + flushRaf(); +} + +beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + pendingRaf = cb; + return 1; + }); + jest.spyOn(window, "cancelAnimationFrame").mockImplementation(jest.fn()); + jest.spyOn(performance, "now").mockReturnValue(0); + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("useScrollVisibilityDynamic 커스텀 훅 테스트", () => { + const params = { cooldownMs: 0, collapsePx: 80, revealPx: 40 }; + + test("초기 상태는 isVisible가 true, hasPassedTopOffset가 false여야 함", () => { + const { result } = renderHook(() => useScrollVisibilityDynamic(params)); + + act(() => flushRaf()); + expect(result.current.isVisible).toBe(true); + expect(result.current.hasPassedTopOffset).toBe(false); + }); + + test("topOffset 초과 후 아래로 collapsePx 이상 스크롤 시 isVisible이 false로 전환", () => { + const { result } = renderHook(() => useScrollVisibilityDynamic(params)); + + act(() => flushRaf()); + act(() => scrollTo(170)); + expect(result.current.isVisible).toBe(false); + expect(result.current.hasPassedTopOffset).toBe(true); + }); + + test("접힌 상태에서 위로 revealPx 이상 스크롤 시 isVisible이 true로 복귀", () => { + const { result } = renderHook(() => useScrollVisibilityDynamic(params)); + + act(() => flushRaf()); + act(() => scrollTo(170)); + expect(result.current.isVisible).toBe(false); + + act(() => scrollTo(130)); + expect(result.current.isVisible).toBe(true); + expect(result.current.hasPassedTopOffset).toBe(true); + }); +});