From d6b13023379bf66a18522c4847b2483627d83f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Mon, 20 Apr 2026 21:51:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test(reviews/favorites):=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=C2=B7=EC=B0=9C=20=EC=84=9C=EB=B2=84=20API=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MSW 기반 서버 API 테스트를 추가 - 조회 성공, 쿼리 변환, 에러 처리를 검증 Resolves: #348 --- features/favorites/apis/server.test.ts | 231 ++++++++++++++++++ features/reviews/apis/server.test.ts | 313 +++++++++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 features/favorites/apis/server.test.ts create mode 100644 features/reviews/apis/server.test.ts diff --git a/features/favorites/apis/server.test.ts b/features/favorites/apis/server.test.ts new file mode 100644 index 00000000..0481a640 --- /dev/null +++ b/features/favorites/apis/server.test.ts @@ -0,0 +1,231 @@ +jest.mock("next/headers", () => ({ + cookies: jest.fn(), +})); + +import { cookies } from "next/headers"; +import { http, HttpResponse } from "msw"; +import { getFavorites } from "@/features/favorites/apis/server"; +import { server } from "@/mocks/server"; +import { ApiError } from "@/utils/api"; +import type { FavoriteMeeting, FavoritesListResponse } from "../types"; + +const TEST_API_BASE = "http://localhost/api"; + +const mockedCookies = cookies as jest.MockedFunction; + +type CookieStore = Awaited>; + +function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } = {}) { + return { + get: jest.fn((name: string) => { + if (name === "accessToken" && tokens.accessToken) { + return { name, value: tokens.accessToken }; + } + + if (name === "refreshToken" && tokens.refreshToken) { + return { name, value: tokens.refreshToken }; + } + + return undefined; + }), + set: jest.fn(), + delete: jest.fn(), + } as unknown as CookieStore; +} + +const favoriteItem: FavoriteMeeting = { + id: 1, + meetingId: 101, + userId: 7, + createdAt: "2026-04-05T09:00:00.000Z", + meeting: { + id: 101, + teamId: "dev-lucky7", + name: "주말 독서 모임", + type: "자기계발", + region: "서울특별시 광진구", + address: "서울특별시 광진구 아차산로 123", + latitude: 37.544, + longitude: 127.082, + dateTime: "2026-04-20T10:00:00.000Z", + registrationEnd: "2026-04-18T18:00:00.000Z", + capacity: 12, + participantCount: 8, + image: "https://example.com/favorite.jpg", + description: "책 한 권씩 읽고 대화하는 모임", + canceledAt: null, + confirmedAt: "2026-04-18T19:00:00.000Z", + hostId: 3, + createdAt: "2026-03-20T10:00:00.000Z", + updatedAt: "2026-03-21T10:00:00.000Z", + host: { + id: 3, + name: "모임장", + image: null, + }, + isCompleted: false, + isJoined: true, + }, +}; + +const favoritesListResponse: FavoritesListResponse = { + data: [favoriteItem], + nextCursor: null, + hasMore: false, +}; + +beforeEach(() => { + mockedCookies.mockResolvedValue(createCookieStore()); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("favorites server apis 테스트", () => { + describe("getFavorites", () => { + it("성공 시 찜 목록 데이터를 반환한다", async () => { + server.use( + http.get(`${TEST_API_BASE}/favorites`, () => HttpResponse.json(favoritesListResponse)), + ); + + const result = await getFavorites(); + + expect(Array.isArray(result.data)).toBe(true); + expect(result.data[0]).toMatchObject({ + id: favoriteItem.id, + meetingId: favoriteItem.meetingId, + userId: favoriteItem.userId, + createdAt: favoriteItem.createdAt, + }); + expect(result.data[0].meeting).toMatchObject({ + id: favoriteItem.meeting.id, + name: favoriteItem.meeting.name, + type: favoriteItem.meeting.type, + region: favoriteItem.meeting.region, + }); + expect(result.nextCursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("필터와 날짜 조건을 쿼리스트링으로 변환해 요청한다", async () => { + let capturedUrl = ""; + let authorizationHeader = ""; + + mockedCookies.mockResolvedValue(createCookieStore({ accessToken: "access-token" })); + + server.use( + http.get(`${TEST_API_BASE}/favorites`, ({ request }) => { + capturedUrl = request.url; + authorizationHeader = request.headers.get("authorization") ?? ""; + return HttpResponse.json(favoritesListResponse); + }), + ); + + await getFavorites({ + type: "자기계발", + region: "서울특별시 광진구", + dateStart: "2026-04-01", + dateEnd: "2026-04-30", + sortBy: "participantCount", + sortOrder: "asc", + cursor: "10", + size: 5, + }); + + const url = new URL(capturedUrl); + + expect(url.searchParams.get("type")).toBe("자기계발"); + expect(url.searchParams.get("region")).toBe("서울특별시 광진구"); + expect(url.searchParams.get("dateStart")).toBe("2026-04-01T00:00:00+09:00"); + expect(url.searchParams.get("dateEnd")).toBe("2026-04-30T23:59:59+09:00"); + expect(url.searchParams.get("sortBy")).toBe("participantCount"); + expect(url.searchParams.get("sortOrder")).toBe("asc"); + expect(url.searchParams.get("cursor")).toBe("10"); + expect(url.searchParams.get("size")).toBe("5"); + expect(authorizationHeader).toBe("Bearer access-token"); + }); + + it("URLSearchParams 입력을 파싱해 숫자 size만 요청에 포함한다", async () => { + let capturedUrl = ""; + + server.use( + http.get(`${TEST_API_BASE}/favorites`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(favoritesListResponse); + }), + ); + + await getFavorites( + new URLSearchParams({ + type: "운동/스포츠", + sortBy: "registrationEnd", + sortOrder: "desc", + size: "3", + cursor: "6", + }), + ); + + const url = new URL(capturedUrl); + + expect(url.searchParams.get("type")).toBe("운동/스포츠"); + expect(url.searchParams.get("sortBy")).toBe("registrationEnd"); + expect(url.searchParams.get("sortOrder")).toBe("desc"); + expect(url.searchParams.get("size")).toBe("3"); + expect(url.searchParams.get("cursor")).toBe("6"); + }); + + it("size가 숫자가 아니면 쿼리에서 제외한다", async () => { + let capturedUrl = ""; + + server.use( + http.get(`${TEST_API_BASE}/favorites`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(favoritesListResponse); + }), + ); + + await getFavorites( + new URLSearchParams({ + type: "여행", + size: "not-a-number", + }), + ); + + const url = new URL(capturedUrl); + + expect(url.searchParams.get("type")).toBe("여행"); + expect(url.searchParams.has("size")).toBe(false); + }); + + it("실패 시 응답 메시지와 status를 담은 ApiError를 던진다", async () => { + server.use( + http.get(`${TEST_API_BASE}/favorites`, () => + HttpResponse.json( + { message: "찜 목록을 불러오지 못했습니다.", code: "FAVORITES_FETCH_FAILED" }, + { status: 400 }, + ), + ), + ); + + await expect(getFavorites()).rejects.toMatchObject({ + name: "ApiError", + message: "찜 목록을 불러오지 못했습니다.", + status: 400, + code: "FAVORITES_FETCH_FAILED", + }); + }); + + it("에러 응답 바디가 없으면 상태 코드별 기본 메시지로 ApiError를 던진다", async () => { + server.use( + http.get(`${TEST_API_BASE}/favorites`, () => new HttpResponse(null, { status: 401 })), + ); + + await expect(getFavorites()).rejects.toMatchObject({ + name: "ApiError", + message: "인증이 필요합니다", + status: 401, + }); + }); + }); +}); diff --git a/features/reviews/apis/server.test.ts b/features/reviews/apis/server.test.ts new file mode 100644 index 00000000..0cc2defc --- /dev/null +++ b/features/reviews/apis/server.test.ts @@ -0,0 +1,313 @@ +jest.mock("next/headers", () => ({ + cookies: jest.fn(), +})); + +import { cookies } from "next/headers"; +import { http, HttpResponse } from "msw"; +import { + getReviews, + getReviewsCategoriesStatistics, + getReviewsStatistics, +} from "@/features/reviews/apis/server"; +import { server } from "@/mocks/server"; +import { ApiError } from "@/utils/api"; +import type { + RatingSummaryResponse, + ReviewCategoryStatistics, + ReviewsListItem, + ReviewsListResponse, +} from "../types"; + +const TEST_API_BASE = "http://localhost/api"; + +const mockedCookies = cookies as jest.MockedFunction; + +type CookieStore = Awaited>; + +function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } = {}) { + return { + get: jest.fn((name: string) => { + if (name === "accessToken" && tokens.accessToken) { + return { name, value: tokens.accessToken }; + } + + if (name === "refreshToken" && tokens.refreshToken) { + return { name, value: tokens.refreshToken }; + } + + return undefined; + }), + set: jest.fn(), + delete: jest.fn(), + } as unknown as CookieStore; +} + +const reviewItem: ReviewsListItem = { + id: 1, + meetingId: 101, + userId: 7, + score: 5, + comment: "정말 만족스러운 모임이었어요.", + createdAt: "2026-04-10T09:00:00.000Z", + updatedAt: "2026-04-10T09:00:00.000Z", + user: { + id: 7, + email: "tester@example.com", + name: "테스터", + image: null, + }, + meeting: { + id: 101, + name: "아침 러닝 모임", + type: "운동/스포츠", + region: "서울특별시 광진구", + image: "https://example.com/review.jpg", + dateTime: "2026-04-20T07:00:00.000Z", + }, +}; + +const reviewsListResponse: ReviewsListResponse = { + data: [reviewItem], + nextCursor: null, + hasMore: false, +}; + +const ratingSummaryResponse: RatingSummaryResponse = { + averageScore: 4.6, + totalReviews: 12, + oneStar: 0, + twoStars: 1, + threeStars: 2, + fourStars: 4, + fiveStars: 5, +}; + +const categoryStatisticsResponse: ReviewCategoryStatistics = [ + { + type: "운동/스포츠", + averageScore: 4.7, + totalReviews: 7, + oneStar: 0, + twoStars: 0, + threeStars: 1, + fourStars: 2, + fiveStars: 4, + }, + { + type: "자기계발", + averageScore: 4.3, + totalReviews: 5, + oneStar: 0, + twoStars: 1, + threeStars: 1, + fourStars: 2, + fiveStars: 1, + }, +]; + +beforeEach(() => { + mockedCookies.mockResolvedValue(createCookieStore()); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("reviews server apis 테스트", () => { + describe("getReviews", () => { + it("성공 시 리뷰 목록 데이터를 반환한다", async () => { + server.use( + http.get(`${TEST_API_BASE}/reviews`, () => HttpResponse.json(reviewsListResponse)), + ); + + const result = await getReviews(); + + expect(Array.isArray(result.data)).toBe(true); + expect(result.data[0]).toMatchObject({ + id: reviewItem.id, + meetingId: reviewItem.meetingId, + userId: reviewItem.userId, + score: reviewItem.score, + comment: reviewItem.comment, + }); + expect(result.nextCursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("필터와 날짜 조건을 쿼리스트링으로 변환해 요청한다", async () => { + let capturedUrl = ""; + let authorizationHeader = ""; + + mockedCookies.mockResolvedValue(createCookieStore({ accessToken: "access-token" })); + + server.use( + http.get(`${TEST_API_BASE}/reviews`, ({ request }) => { + capturedUrl = request.url; + authorizationHeader = request.headers.get("authorization") ?? ""; + return HttpResponse.json(reviewsListResponse); + }), + ); + + await getReviews({ + type: "운동/스포츠", + region: "서울특별시 광진구", + dateStart: "2026-04-01", + dateEnd: "2026-04-30", + registrationEndStart: "2026-03-20", + registrationEndEnd: "2026-03-25", + sortBy: "dateTime", + sortOrder: "asc", + cursor: "10", + size: 5, + }); + + const url = new URL(capturedUrl); + + expect(url.searchParams.get("type")).toBe("운동/스포츠"); + expect(url.searchParams.get("region")).toBe("서울특별시 광진구"); + expect(url.searchParams.get("dateStart")).toBe("2026-04-01T00:00:00+09:00"); + expect(url.searchParams.get("dateEnd")).toBe("2026-04-30T23:59:59+09:00"); + expect(url.searchParams.get("registrationEndStart")).toBe("2026-03-20T00:00:00+09:00"); + expect(url.searchParams.get("registrationEndEnd")).toBe("2026-03-25T23:59:59+09:00"); + expect(url.searchParams.get("sortBy")).toBe("dateTime"); + expect(url.searchParams.get("sortOrder")).toBe("asc"); + expect(url.searchParams.get("cursor")).toBe("10"); + expect(url.searchParams.get("size")).toBe("5"); + expect(authorizationHeader).toBe("Bearer access-token"); + }); + + it("URLSearchParams 입력을 파싱해 숫자 size만 요청에 포함한다", async () => { + let capturedUrl = ""; + + server.use( + http.get(`${TEST_API_BASE}/reviews`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(reviewsListResponse); + }), + ); + + await getReviews( + new URLSearchParams({ + type: "자기계발", + sortBy: "participantCount", + sortOrder: "desc", + size: "3", + cursor: "6", + }), + ); + + const url = new URL(capturedUrl); + + expect(url.searchParams.get("type")).toBe("자기계발"); + expect(url.searchParams.get("sortBy")).toBe("participantCount"); + expect(url.searchParams.get("sortOrder")).toBe("desc"); + expect(url.searchParams.get("size")).toBe("3"); + expect(url.searchParams.get("cursor")).toBe("6"); + }); + + it("size가 숫자가 아니면 쿼리에서 제외한다", async () => { + let capturedUrl = ""; + + server.use( + http.get(`${TEST_API_BASE}/reviews`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(reviewsListResponse); + }), + ); + + await getReviews( + new URLSearchParams({ + type: "여행", + size: "not-a-number", + }), + ); + + const url = new URL(capturedUrl); + + expect(url.searchParams.get("type")).toBe("여행"); + expect(url.searchParams.has("size")).toBe(false); + }); + + it("실패 시 응답 메시지와 status를 담은 ApiError를 던진다", async () => { + server.use( + http.get(`${TEST_API_BASE}/reviews`, () => + HttpResponse.json( + { message: "리뷰 목록 조회에 실패했습니다.", code: "REVIEWS_FETCH_FAILED" }, + { status: 400 }, + ), + ), + ); + + await expect(getReviews()).rejects.toMatchObject({ + name: "ApiError", + message: "리뷰 목록 조회에 실패했습니다.", + status: 400, + code: "REVIEWS_FETCH_FAILED", + }); + }); + }); + + describe("getReviewsStatistics", () => { + it("성공 시 전체 평점 요약을 반환한다", async () => { + server.use( + http.get(`${TEST_API_BASE}/reviews/statistics`, () => + HttpResponse.json(ratingSummaryResponse), + ), + ); + + await expect(getReviewsStatistics()).resolves.toEqual(ratingSummaryResponse); + }); + + it("에러 응답 바디가 없으면 기본 메시지로 ApiError를 던진다", async () => { + server.use( + http.get( + `${TEST_API_BASE}/reviews/statistics`, + () => new HttpResponse(null, { status: 500 }), + ), + ); + + await expect(getReviewsStatistics()).rejects.toMatchObject({ + name: "ApiError", + message: "리뷰 전체 통계 조회에 실패했습니다.", + status: 500, + }); + }); + }); + + describe("getReviewsCategoriesStatistics", () => { + it("성공 시 카테고리별 평점 통계를 반환한다", async () => { + server.use( + http.get(`${TEST_API_BASE}/reviews/categories/statistics`, () => + HttpResponse.json(categoryStatisticsResponse), + ), + ); + + const result = await getReviewsCategoriesStatistics(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toMatchObject({ + type: categoryStatisticsResponse[0].type, + averageScore: categoryStatisticsResponse[0].averageScore, + totalReviews: categoryStatisticsResponse[0].totalReviews, + }); + expect(result).toHaveLength(categoryStatisticsResponse.length); + }); + + it("실패 시 응답 메시지와 status를 담은 ApiError를 던진다", async () => { + server.use( + http.get(`${TEST_API_BASE}/reviews/categories/statistics`, () => + HttpResponse.json( + { message: "카테고리별 리뷰 통계를 불러오지 못했습니다." }, + { status: 503 }, + ), + ), + ); + + await expect(getReviewsCategoriesStatistics()).rejects.toMatchObject({ + name: "ApiError", + message: "카테고리별 리뷰 통계를 불러오지 못했습니다.", + status: 503, + }); + }); + }); +}); From 200fa75888906594b0940a805f667b69946dc10b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Mon, 20 Apr 2026 22:30:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(reviews/favorites):=20mock=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20API=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - favorites 목록/count 응답을 mocks/data 구조로 분리 - favorites 핸들러가 mocks/data를 사용하도록 정리 - reviews/favorites server test가 mocks/data fixture를 재사용하도록 변경 Resolves: #348 --- features/favorites/apis/server.test.ts | 75 +++---------- features/reviews/apis/server.test.ts | 113 ++++---------------- mocks/data/favorites/fixtures.ts | 5 + mocks/data/favorites/helpers.ts | 139 +++++++++++++++++++++++++ mocks/data/favorites/index.ts | 93 +++++++++++++++++ mocks/handlers/favorites.ts | 10 +- 6 files changed, 285 insertions(+), 150 deletions(-) create mode 100644 mocks/data/favorites/fixtures.ts create mode 100644 mocks/data/favorites/helpers.ts create mode 100644 mocks/data/favorites/index.ts diff --git a/features/favorites/apis/server.test.ts b/features/favorites/apis/server.test.ts index 0481a640..be1c3ab8 100644 --- a/features/favorites/apis/server.test.ts +++ b/features/favorites/apis/server.test.ts @@ -5,9 +5,9 @@ jest.mock("next/headers", () => ({ import { cookies } from "next/headers"; import { http, HttpResponse } from "msw"; import { getFavorites } from "@/features/favorites/apis/server"; +import favorites from "@/mocks/data/favorites"; import { server } from "@/mocks/server"; import { ApiError } from "@/utils/api"; -import type { FavoriteMeeting, FavoritesListResponse } from "../types"; const TEST_API_BASE = "http://localhost/api"; @@ -33,47 +33,6 @@ function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } as unknown as CookieStore; } -const favoriteItem: FavoriteMeeting = { - id: 1, - meetingId: 101, - userId: 7, - createdAt: "2026-04-05T09:00:00.000Z", - meeting: { - id: 101, - teamId: "dev-lucky7", - name: "주말 독서 모임", - type: "자기계발", - region: "서울특별시 광진구", - address: "서울특별시 광진구 아차산로 123", - latitude: 37.544, - longitude: 127.082, - dateTime: "2026-04-20T10:00:00.000Z", - registrationEnd: "2026-04-18T18:00:00.000Z", - capacity: 12, - participantCount: 8, - image: "https://example.com/favorite.jpg", - description: "책 한 권씩 읽고 대화하는 모임", - canceledAt: null, - confirmedAt: "2026-04-18T19:00:00.000Z", - hostId: 3, - createdAt: "2026-03-20T10:00:00.000Z", - updatedAt: "2026-03-21T10:00:00.000Z", - host: { - id: 3, - name: "모임장", - image: null, - }, - isCompleted: false, - isJoined: true, - }, -}; - -const favoritesListResponse: FavoritesListResponse = { - data: [favoriteItem], - nextCursor: null, - hasMore: false, -}; - beforeEach(() => { mockedCookies.mockResolvedValue(createCookieStore()); }); @@ -85,27 +44,27 @@ afterEach(() => { describe("favorites server apis 테스트", () => { describe("getFavorites", () => { it("성공 시 찜 목록 데이터를 반환한다", async () => { - server.use( - http.get(`${TEST_API_BASE}/favorites`, () => HttpResponse.json(favoritesListResponse)), - ); + const expectedResponse = favorites.list(); + + server.use(http.get(`${TEST_API_BASE}/favorites`, () => HttpResponse.json(expectedResponse))); const result = await getFavorites(); expect(Array.isArray(result.data)).toBe(true); expect(result.data[0]).toMatchObject({ - id: favoriteItem.id, - meetingId: favoriteItem.meetingId, - userId: favoriteItem.userId, - createdAt: favoriteItem.createdAt, + id: expectedResponse.data[0].id, + meetingId: expectedResponse.data[0].meetingId, + userId: expectedResponse.data[0].userId, + createdAt: expectedResponse.data[0].createdAt, }); expect(result.data[0].meeting).toMatchObject({ - id: favoriteItem.meeting.id, - name: favoriteItem.meeting.name, - type: favoriteItem.meeting.type, - region: favoriteItem.meeting.region, + id: expectedResponse.data[0].meeting.id, + name: expectedResponse.data[0].meeting.name, + type: expectedResponse.data[0].meeting.type, + region: expectedResponse.data[0].meeting.region, }); - expect(result.nextCursor).toBeNull(); - expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBe(expectedResponse.nextCursor); + expect(result.hasMore).toBe(expectedResponse.hasMore); }); it("필터와 날짜 조건을 쿼리스트링으로 변환해 요청한다", async () => { @@ -118,7 +77,7 @@ describe("favorites server apis 테스트", () => { http.get(`${TEST_API_BASE}/favorites`, ({ request }) => { capturedUrl = request.url; authorizationHeader = request.headers.get("authorization") ?? ""; - return HttpResponse.json(favoritesListResponse); + return HttpResponse.json(favorites.list()); }), ); @@ -152,7 +111,7 @@ describe("favorites server apis 테스트", () => { server.use( http.get(`${TEST_API_BASE}/favorites`, ({ request }) => { capturedUrl = request.url; - return HttpResponse.json(favoritesListResponse); + return HttpResponse.json(favorites.list()); }), ); @@ -181,7 +140,7 @@ describe("favorites server apis 테스트", () => { server.use( http.get(`${TEST_API_BASE}/favorites`, ({ request }) => { capturedUrl = request.url; - return HttpResponse.json(favoritesListResponse); + return HttpResponse.json(favorites.list()); }), ); diff --git a/features/reviews/apis/server.test.ts b/features/reviews/apis/server.test.ts index 0cc2defc..e5a379bc 100644 --- a/features/reviews/apis/server.test.ts +++ b/features/reviews/apis/server.test.ts @@ -9,14 +9,10 @@ import { getReviewsCategoriesStatistics, getReviewsStatistics, } from "@/features/reviews/apis/server"; +import reviews from "@/mocks/data/reviews"; +import { CATEGORY_STATISTICS, STATISTICS } from "@/mocks/data/reviews/fixtures"; import { server } from "@/mocks/server"; import { ApiError } from "@/utils/api"; -import type { - RatingSummaryResponse, - ReviewCategoryStatistics, - ReviewsListItem, - ReviewsListResponse, -} from "../types"; const TEST_API_BASE = "http://localhost/api"; @@ -42,69 +38,6 @@ function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } as unknown as CookieStore; } -const reviewItem: ReviewsListItem = { - id: 1, - meetingId: 101, - userId: 7, - score: 5, - comment: "정말 만족스러운 모임이었어요.", - createdAt: "2026-04-10T09:00:00.000Z", - updatedAt: "2026-04-10T09:00:00.000Z", - user: { - id: 7, - email: "tester@example.com", - name: "테스터", - image: null, - }, - meeting: { - id: 101, - name: "아침 러닝 모임", - type: "운동/스포츠", - region: "서울특별시 광진구", - image: "https://example.com/review.jpg", - dateTime: "2026-04-20T07:00:00.000Z", - }, -}; - -const reviewsListResponse: ReviewsListResponse = { - data: [reviewItem], - nextCursor: null, - hasMore: false, -}; - -const ratingSummaryResponse: RatingSummaryResponse = { - averageScore: 4.6, - totalReviews: 12, - oneStar: 0, - twoStars: 1, - threeStars: 2, - fourStars: 4, - fiveStars: 5, -}; - -const categoryStatisticsResponse: ReviewCategoryStatistics = [ - { - type: "운동/스포츠", - averageScore: 4.7, - totalReviews: 7, - oneStar: 0, - twoStars: 0, - threeStars: 1, - fourStars: 2, - fiveStars: 4, - }, - { - type: "자기계발", - averageScore: 4.3, - totalReviews: 5, - oneStar: 0, - twoStars: 1, - threeStars: 1, - fourStars: 2, - fiveStars: 1, - }, -]; - beforeEach(() => { mockedCookies.mockResolvedValue(createCookieStore()); }); @@ -116,22 +49,22 @@ afterEach(() => { describe("reviews server apis 테스트", () => { describe("getReviews", () => { it("성공 시 리뷰 목록 데이터를 반환한다", async () => { - server.use( - http.get(`${TEST_API_BASE}/reviews`, () => HttpResponse.json(reviewsListResponse)), - ); + const expectedResponse = reviews.list(); + + server.use(http.get(`${TEST_API_BASE}/reviews`, () => HttpResponse.json(expectedResponse))); const result = await getReviews(); expect(Array.isArray(result.data)).toBe(true); expect(result.data[0]).toMatchObject({ - id: reviewItem.id, - meetingId: reviewItem.meetingId, - userId: reviewItem.userId, - score: reviewItem.score, - comment: reviewItem.comment, + id: expectedResponse.data[0].id, + meetingId: expectedResponse.data[0].meetingId, + userId: expectedResponse.data[0].userId, + score: expectedResponse.data[0].score, + comment: expectedResponse.data[0].comment, }); - expect(result.nextCursor).toBeNull(); - expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBe(expectedResponse.nextCursor); + expect(result.hasMore).toBe(expectedResponse.hasMore); }); it("필터와 날짜 조건을 쿼리스트링으로 변환해 요청한다", async () => { @@ -144,7 +77,7 @@ describe("reviews server apis 테스트", () => { http.get(`${TEST_API_BASE}/reviews`, ({ request }) => { capturedUrl = request.url; authorizationHeader = request.headers.get("authorization") ?? ""; - return HttpResponse.json(reviewsListResponse); + return HttpResponse.json(reviews.list()); }), ); @@ -182,7 +115,7 @@ describe("reviews server apis 테스트", () => { server.use( http.get(`${TEST_API_BASE}/reviews`, ({ request }) => { capturedUrl = request.url; - return HttpResponse.json(reviewsListResponse); + return HttpResponse.json(reviews.list()); }), ); @@ -211,7 +144,7 @@ describe("reviews server apis 테스트", () => { server.use( http.get(`${TEST_API_BASE}/reviews`, ({ request }) => { capturedUrl = request.url; - return HttpResponse.json(reviewsListResponse); + return HttpResponse.json(reviews.list()); }), ); @@ -250,12 +183,10 @@ describe("reviews server apis 테스트", () => { describe("getReviewsStatistics", () => { it("성공 시 전체 평점 요약을 반환한다", async () => { server.use( - http.get(`${TEST_API_BASE}/reviews/statistics`, () => - HttpResponse.json(ratingSummaryResponse), - ), + http.get(`${TEST_API_BASE}/reviews/statistics`, () => HttpResponse.json(STATISTICS)), ); - await expect(getReviewsStatistics()).resolves.toEqual(ratingSummaryResponse); + await expect(getReviewsStatistics()).resolves.toEqual(STATISTICS); }); it("에러 응답 바디가 없으면 기본 메시지로 ApiError를 던진다", async () => { @@ -278,7 +209,7 @@ describe("reviews server apis 테스트", () => { it("성공 시 카테고리별 평점 통계를 반환한다", async () => { server.use( http.get(`${TEST_API_BASE}/reviews/categories/statistics`, () => - HttpResponse.json(categoryStatisticsResponse), + HttpResponse.json(CATEGORY_STATISTICS), ), ); @@ -286,11 +217,11 @@ describe("reviews server apis 테스트", () => { expect(Array.isArray(result)).toBe(true); expect(result[0]).toMatchObject({ - type: categoryStatisticsResponse[0].type, - averageScore: categoryStatisticsResponse[0].averageScore, - totalReviews: categoryStatisticsResponse[0].totalReviews, + type: CATEGORY_STATISTICS[0].type, + averageScore: CATEGORY_STATISTICS[0].averageScore, + totalReviews: CATEGORY_STATISTICS[0].totalReviews, }); - expect(result).toHaveLength(categoryStatisticsResponse.length); + expect(result).toHaveLength(CATEGORY_STATISTICS.length); }); it("실패 시 응답 메시지와 status를 담은 ApiError를 던진다", async () => { diff --git a/mocks/data/favorites/fixtures.ts b/mocks/data/favorites/fixtures.ts new file mode 100644 index 00000000..add7f025 --- /dev/null +++ b/mocks/data/favorites/fixtures.ts @@ -0,0 +1,5 @@ +export const FAVORITED_AT_BY_MEETING_ID: Record = { + 2: "2026-02-18T09:00:00.000Z", + 4: "2026-03-02T08:30:00.000Z", + 7: "2026-03-08T07:45:00.000Z", +}; diff --git a/mocks/data/favorites/helpers.ts b/mocks/data/favorites/helpers.ts new file mode 100644 index 00000000..4a9a12bc --- /dev/null +++ b/mocks/data/favorites/helpers.ts @@ -0,0 +1,139 @@ +import type { + FavoriteMeeting, + FavoritesListRequest, + FavoritesListResponse, +} from "@/features/favorites/types"; + +const SORT_ORDER_VALUES = new Set(["asc", "desc"]); + +type FavoriteListSortKey = NonNullable; + +const FAVORITE_LIST_SORT_KEYS = new Set([ + "createdAt", + "meetingCreatedAt", + "dateTime", + "registrationEnd", + "participantCount", +]); + +function toTimestamp(value: string): number | null { + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +export function parseFavoritesListRequest(url: URL): FavoritesListRequest { + const num = (key: string): number | undefined => { + const value = url.searchParams.get(key); + if (value == null || value === "") return undefined; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + }; + + const str = (key: string): string | undefined => { + const value = url.searchParams.get(key); + return value != null && value !== "" ? value : undefined; + }; + + return { + type: str("type"), + region: str("region"), + dateStart: str("dateStart"), + dateEnd: str("dateEnd"), + sortBy: str("sortBy") as FavoritesListRequest["sortBy"], + sortOrder: str("sortOrder") as FavoritesListRequest["sortOrder"], + cursor: str("cursor"), + size: num("size"), + }; +} + +export function buildFavoritesListResponse( + rows: readonly FavoriteMeeting[], + params: FavoritesListRequest = {}, +): FavoritesListResponse { + let list = [...rows]; + + if (params.type) { + list = list.filter((favorite) => favorite.meeting.type === params.type); + } + + if (params.region) { + const needle = params.region.trim(); + list = list.filter( + (favorite) => + favorite.meeting.region.includes(needle) || needle.includes(favorite.meeting.region), + ); + } + + if (params.dateStart) { + const dateStart = new Date(params.dateStart).getTime(); + if (Number.isFinite(dateStart)) { + list = list.filter((favorite) => { + const meetingTime = toTimestamp(favorite.meeting.dateTime); + return meetingTime == null || meetingTime >= dateStart; + }); + } + } + + if (params.dateEnd) { + const dateEnd = new Date(params.dateEnd).getTime(); + if (Number.isFinite(dateEnd)) { + list = list.filter((favorite) => { + const meetingTime = toTimestamp(favorite.meeting.dateTime); + return meetingTime == null || meetingTime <= dateEnd; + }); + } + } + + const sortKeyRaw = params.sortBy; + const sortBy: FavoriteListSortKey = + sortKeyRaw && FAVORITE_LIST_SORT_KEYS.has(sortKeyRaw) ? sortKeyRaw : "createdAt"; + const sortOrder = + params.sortOrder && SORT_ORDER_VALUES.has(params.sortOrder) ? params.sortOrder : "desc"; + const direction = sortOrder === "desc" ? -1 : 1; + + list.sort((a, b) => { + let aValue: number; + let bValue: number; + + if (sortBy === "meetingCreatedAt") { + aValue = toTimestamp(a.meeting.createdAt) ?? 0; + bValue = toTimestamp(b.meeting.createdAt) ?? 0; + } else if (sortBy === "dateTime") { + aValue = toTimestamp(a.meeting.dateTime) ?? 0; + bValue = toTimestamp(b.meeting.dateTime) ?? 0; + } else if (sortBy === "registrationEnd") { + aValue = toTimestamp(a.meeting.registrationEnd) ?? 0; + bValue = toTimestamp(b.meeting.registrationEnd) ?? 0; + } else if (sortBy === "participantCount") { + aValue = a.meeting.participantCount; + bValue = b.meeting.participantCount; + } else { + aValue = toTimestamp(a.createdAt) ?? 0; + bValue = toTimestamp(b.createdAt) ?? 0; + } + + if (aValue === bValue) return a.id - b.id; + return (aValue - bValue) * direction; + }); + + const limit = params.size != null && params.size > 0 ? Math.min(params.size, 100) : 10; + let offset = 0; + + if (params.cursor != null && params.cursor !== "") { + const parsedCursor = Number(params.cursor); + if (Number.isFinite(parsedCursor) && parsedCursor >= 0) { + offset = Math.floor(parsedCursor); + } + } + + const pageRows = list.slice(offset, offset + limit); + const nextOffset = offset + pageRows.length; + const hasMore = nextOffset < list.length; + + return { + data: pageRows, + nextCursor: hasMore ? String(nextOffset) : null, + hasMore, + }; +} diff --git a/mocks/data/favorites/index.ts b/mocks/data/favorites/index.ts new file mode 100644 index 00000000..d26f05aa --- /dev/null +++ b/mocks/data/favorites/index.ts @@ -0,0 +1,93 @@ +import type { + FavoriteMeeting, + FavoritesListRequest, + FavoritesListResponse, +} from "@/features/favorites/types"; +import { buildFavoritesListResponse } from "./helpers"; +import { FAVORITED_AT_BY_MEETING_ID } from "./fixtures"; +import { MEETINGS } from "../meetings/fixtures"; + +const CURRENT_USER_ID = 1; + +type MeetingRow = (typeof MEETINGS.data)[number]; + +function getFavoriteCreatedAt(meeting: MeetingRow) { + return FAVORITED_AT_BY_MEETING_ID[meeting.id] ?? meeting.updatedAt ?? meeting.createdAt; +} + +function toFavoriteMeeting(meeting: MeetingRow): FavoriteMeeting { + const { + id, + teamId, + name, + type, + region, + address, + latitude, + longitude, + dateTime, + registrationEnd, + capacity, + participantCount, + image, + description, + canceledAt, + confirmedAt, + hostId, + createdAt, + updatedAt, + host, + isCompleted, + isJoined, + } = meeting; + + return { + id, + meetingId: id, + userId: CURRENT_USER_ID, + createdAt: getFavoriteCreatedAt(meeting), + meeting: { + id, + teamId, + name, + type, + region, + address, + latitude, + longitude, + dateTime, + registrationEnd, + capacity, + participantCount, + image, + description, + canceledAt, + confirmedAt, + hostId, + createdAt, + updatedAt, + host, + isCompleted, + isJoined, + }, + }; +} + +function getFavoriteRows(): FavoriteMeeting[] { + return MEETINGS.data.filter((meeting) => meeting.isFavorited).map(toFavoriteMeeting); +} + +function listFavorites(params: FavoritesListRequest = {}): FavoritesListResponse { + return buildFavoritesListResponse(getFavoriteRows(), params); +} + +function getFavoritesCount() { + return getFavoriteRows().length; +} + +const favorites = { + list: listFavorites, + getCount: getFavoritesCount, +}; + +export default favorites; diff --git a/mocks/handlers/favorites.ts b/mocks/handlers/favorites.ts index a0dea50e..0ca7b943 100644 --- a/mocks/handlers/favorites.ts +++ b/mocks/handlers/favorites.ts @@ -1,9 +1,17 @@ import { http, HttpResponse } from "msw"; import { BASE_URL } from "../constants"; +import favorites from "../data/favorites"; +import { parseFavoritesListRequest } from "../data/favorites/helpers"; export const favoritesHandlers = [ + // GET /api/favorites + http.get(`${BASE_URL}/favorites`, ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json(favorites.list(parseFavoritesListRequest(url))); + }), + // GET /api/favorites/count http.get(`${BASE_URL}/favorites/count`, () => { - return HttpResponse.json({ count: 1 }); + return HttpResponse.json({ count: favorites.getCount() }); }), ]; From 2cb5bb7869009032a83de0dd0af905dde4e4c466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Mon, 20 Apr 2026 23:01:29 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(reviews/favorites):=20next=20headers?= =?UTF-8?q?=20mock=20=EC=9C=A0=ED=8B=B8=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드 중복 방지와 재사용성을 위해 reviews와 favorites에서 createCookieStore와 mockedCookies를 mockHeader.ts로 분리 Resolves: #348 --- features/favorites/apis/server.test.ts | 28 +---------------------- features/reviews/apis/server.test.ts | 28 +---------------------- mocks/utils/mockHeader.ts | 31 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 54 deletions(-) create mode 100644 mocks/utils/mockHeader.ts diff --git a/features/favorites/apis/server.test.ts b/features/favorites/apis/server.test.ts index be1c3ab8..258e4525 100644 --- a/features/favorites/apis/server.test.ts +++ b/features/favorites/apis/server.test.ts @@ -1,8 +1,4 @@ -jest.mock("next/headers", () => ({ - cookies: jest.fn(), -})); - -import { cookies } from "next/headers"; +import { createCookieStore, mockedCookies } from "@/mocks/utils/mockHeader"; import { http, HttpResponse } from "msw"; import { getFavorites } from "@/features/favorites/apis/server"; import favorites from "@/mocks/data/favorites"; @@ -11,28 +7,6 @@ import { ApiError } from "@/utils/api"; const TEST_API_BASE = "http://localhost/api"; -const mockedCookies = cookies as jest.MockedFunction; - -type CookieStore = Awaited>; - -function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } = {}) { - return { - get: jest.fn((name: string) => { - if (name === "accessToken" && tokens.accessToken) { - return { name, value: tokens.accessToken }; - } - - if (name === "refreshToken" && tokens.refreshToken) { - return { name, value: tokens.refreshToken }; - } - - return undefined; - }), - set: jest.fn(), - delete: jest.fn(), - } as unknown as CookieStore; -} - beforeEach(() => { mockedCookies.mockResolvedValue(createCookieStore()); }); diff --git a/features/reviews/apis/server.test.ts b/features/reviews/apis/server.test.ts index e5a379bc..aab9862a 100644 --- a/features/reviews/apis/server.test.ts +++ b/features/reviews/apis/server.test.ts @@ -1,8 +1,4 @@ -jest.mock("next/headers", () => ({ - cookies: jest.fn(), -})); - -import { cookies } from "next/headers"; +import { createCookieStore, mockedCookies } from "@/mocks/utils/mockHeader"; import { http, HttpResponse } from "msw"; import { getReviews, @@ -16,28 +12,6 @@ import { ApiError } from "@/utils/api"; const TEST_API_BASE = "http://localhost/api"; -const mockedCookies = cookies as jest.MockedFunction; - -type CookieStore = Awaited>; - -function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } = {}) { - return { - get: jest.fn((name: string) => { - if (name === "accessToken" && tokens.accessToken) { - return { name, value: tokens.accessToken }; - } - - if (name === "refreshToken" && tokens.refreshToken) { - return { name, value: tokens.refreshToken }; - } - - return undefined; - }), - set: jest.fn(), - delete: jest.fn(), - } as unknown as CookieStore; -} - beforeEach(() => { mockedCookies.mockResolvedValue(createCookieStore()); }); diff --git a/mocks/utils/mockHeader.ts b/mocks/utils/mockHeader.ts new file mode 100644 index 00000000..755df45b --- /dev/null +++ b/mocks/utils/mockHeader.ts @@ -0,0 +1,31 @@ +jest.mock("next/headers", () => ({ + cookies: jest.fn(), +})); + +import { cookies } from "next/headers"; + +export type MockCookieStore = Awaited>; + +/** + * 서버 API 테스트에서 `next/headers`의 `cookies()`를 일관되게 mock 하기 위한 유틸. + * access/refresh token 유무를 제어해 `serverFetch`가 읽는 쿠키 상태를 재현한다. + */ +export const mockedCookies = cookies as jest.MockedFunction; + +export function createCookieStore(tokens: { accessToken?: string; refreshToken?: string } = {}) { + return { + get: jest.fn((name: string) => { + if (name === "accessToken" && tokens.accessToken) { + return { name, value: tokens.accessToken }; + } + + if (name === "refreshToken" && tokens.refreshToken) { + return { name, value: tokens.refreshToken }; + } + + return undefined; + }), + set: jest.fn(), + delete: jest.fn(), + } as unknown as MockCookieStore; +}