diff --git a/features/favorites/apis/server.test.ts b/features/favorites/apis/server.test.ts new file mode 100644 index 00000000..258e4525 --- /dev/null +++ b/features/favorites/apis/server.test.ts @@ -0,0 +1,164 @@ +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"; +import { server } from "@/mocks/server"; +import { ApiError } from "@/utils/api"; + +const TEST_API_BASE = "http://localhost/api"; + +beforeEach(() => { + mockedCookies.mockResolvedValue(createCookieStore()); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("favorites server apis 테스트", () => { + describe("getFavorites", () => { + it("성공 시 찜 목록 데이터를 반환한다", async () => { + 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: 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: 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).toBe(expectedResponse.nextCursor); + expect(result.hasMore).toBe(expectedResponse.hasMore); + }); + + 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(favorites.list()); + }), + ); + + 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(favorites.list()); + }), + ); + + 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(favorites.list()); + }), + ); + + 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..aab9862a --- /dev/null +++ b/features/reviews/apis/server.test.ts @@ -0,0 +1,218 @@ +import { createCookieStore, mockedCookies } from "@/mocks/utils/mockHeader"; +import { http, HttpResponse } from "msw"; +import { + getReviews, + 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"; + +const TEST_API_BASE = "http://localhost/api"; + +beforeEach(() => { + mockedCookies.mockResolvedValue(createCookieStore()); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("reviews server apis 테스트", () => { + describe("getReviews", () => { + it("성공 시 리뷰 목록 데이터를 반환한다", async () => { + 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: 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).toBe(expectedResponse.nextCursor); + expect(result.hasMore).toBe(expectedResponse.hasMore); + }); + + 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(reviews.list()); + }), + ); + + 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(reviews.list()); + }), + ); + + 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(reviews.list()); + }), + ); + + 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(STATISTICS)), + ); + + await expect(getReviewsStatistics()).resolves.toEqual(STATISTICS); + }); + + 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(CATEGORY_STATISTICS), + ), + ); + + const result = await getReviewsCategoriesStatistics(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toMatchObject({ + type: CATEGORY_STATISTICS[0].type, + averageScore: CATEGORY_STATISTICS[0].averageScore, + totalReviews: CATEGORY_STATISTICS[0].totalReviews, + }); + expect(result).toHaveLength(CATEGORY_STATISTICS.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, + }); + }); + }); +}); 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() }); }), ]; 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; +}