Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/api/service/user-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export const userServiceRemote = () => ({

// 4. 알림 설정 변경
updateMyNotification: async (queryParams: UpdateMyNotificationQueryParams) => {
return apiV1.patch<User>(
`/users/notification?isNotificationEnabled=${queryParams.isNotificationEnabled}`,
);
return apiV1.patch<User>(`/users/notification`, null, {
params: { ...queryParams },
});
},

// 5. 유저 프로필 조회
Expand All @@ -50,7 +50,7 @@ export const userServiceRemote = () => ({
// 7. 닉네임 중복 검사
getNicknameAvailability: async (queryParams: GetNicknameAvailabilityQueryParams) => {
return apiV1.get<Availability>(`/users/nickname/availability`, {
params: { nickname: queryParams.nickName },
params: { ...queryParams },
});
},

Expand Down
152 changes: 152 additions & 0 deletions src/components/pages/user/profile/profile-edit-modal/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';

import { API } from '@/api';
import { ModalProvider } from '@/components/ui';
import { server } from '@/mock/server';
import { createMockSuccessResponse } from '@/mock/service/common/common-mock';
import { mockUserItems } from '@/mock/service/user/user-mock';
import { User } from '@/types/service/user';

import { ProfileEditModal } from '.';

Expand Down Expand Up @@ -254,4 +258,152 @@ describe('ProfileEditModal 테스트', () => {
});
});
});

describe('폼 제출 테스트', () => {
describe('닉네임 중복 검사 테스트', () => {
test('닉네임 수정 시 닉네임 중복 검사가 실행된다.', async () => {
const user = userEvent.setup();

const getNicknameAvailabilitySpy = jest.spyOn(API.userService, 'getNicknameAvailability');

await renderWithProviders(<ProfileEditModal user={mockUser} />);

const nickNameInput = screen.getByDisplayValue(mockUser.nickName);
await user.clear(nickNameInput);
await user.type(nickNameInput, '새로운 닉네임');

const submitButton = screen.getByText('수정하기');
await user.click(submitButton);

await waitFor(() => {
expect(getNicknameAvailabilitySpy).toHaveBeenCalledWith({ nickName: '새로운 닉네임' });
});

getNicknameAvailabilitySpy.mockRestore();
});

test('중복된 닉네임일 경우 에러 메시지가 표시된다.', async () => {
const user = userEvent.setup();
await renderWithProviders(<ProfileEditModal user={mockUser} />);

const nickNameInput = screen.getByDisplayValue(mockUser.nickName);
await user.clear(nickNameInput);
await user.type(nickNameInput, '페르난도 토레스');

const submitButton = screen.getByText('수정하기');
await user.click(submitButton);

await waitFor(() => {
expect(screen.getByText('이미 사용중인 닉네임 입니다.')).toBeInTheDocument();
});
});

test('닉네임이 변경되지 않으면 중복 검사를 하지 않는다.', async () => {
const user = userEvent.setup();
const getNicknameAvailabilitySpy = jest.spyOn(API.userService, 'getNicknameAvailability');

await renderWithProviders(<ProfileEditModal user={mockUser} />);

const prevNickName = mockUser.nickName;
const nickNameInput = screen.getByDisplayValue(prevNickName);
await user.clear(nickNameInput);
await user.type(nickNameInput, prevNickName);

const submitButton = screen.getByText('수정하기');
await user.click(submitButton);

await waitFor(() => {
expect(getNicknameAvailabilitySpy).not.toHaveBeenCalledWith({
nickName: prevNickName,
});
});

getNicknameAvailabilitySpy.mockRestore();
});
});

describe('MBTI 제출 테스트', () => {
test('MBTI가 대문자로 변환되어 전송된다.', async () => {
const user = userEvent.setup();

const updateMyInfoSpy = jest.spyOn(API.userService, 'updateMyInfo');

await renderWithProviders(<ProfileEditModal user={mockUser} />);

const mbtiInput = screen.getByDisplayValue(mockUser.mbti);
await user.clear(mbtiInput);
await user.type(mbtiInput, 'enfp');

const submitButton = screen.getByText('수정하기');
await user.click(submitButton);

await waitFor(() => {
expect(updateMyInfoSpy).toHaveBeenCalledWith({ mbti: 'ENFP' });
});

updateMyInfoSpy.mockRestore();
});
});

describe('API 호출 테스트', () => {
test('이미지가 변경되지 않으면 updateMyImage API가 호출되지 않는다.', async () => {
const user = userEvent.setup();

const updateMyImageSpy = jest.spyOn(API.userService, 'updateMyImage');

await renderWithProviders(<ProfileEditModal user={mockUser} />);

const nickNameInput = screen.getByDisplayValue(mockUser.nickName);
await user.clear(nickNameInput);
await user.type(nickNameInput, '새로운 닉네임');

const messageInput = screen.getByDisplayValue(mockUser.profileMessage);
await user.clear(messageInput);
await user.type(messageInput, '새로운 소개글');

const mbtiInput = screen.getByDisplayValue(mockUser.mbti);
await user.clear(mbtiInput);
await user.type(mbtiInput, 'ISTJ');

const submitButton = screen.getByText('수정하기');
await user.click(submitButton);

await waitFor(() => {
expect(updateMyImageSpy).not.toHaveBeenCalled();
});

updateMyImageSpy.mockRestore();
});
});

describe('제출 버튼 상태 테스트', () => {
test('제출 중일 때 버튼이 "수정 중..."으로 변경되며 비활성화 된다', async () => {
server.use(
http.patch(`*/users/profile`, async ({ request }) => {
await delay(200);
const body = (await request.json()) as User;
return HttpResponse.json(
createMockSuccessResponse({
...mockUserItems[0],
...body,
}),
);
}),
);

const user = userEvent.setup();

await renderWithProviders(<ProfileEditModal user={mockUser} />);

const mbtiInput = screen.getByDisplayValue(mockUser.mbti);
await user.clear(mbtiInput);
await user.type(mbtiInput, 'enfp');

const submitButton = screen.getByText('수정하기');
await user.click(submitButton);

expect(await screen.findByRole('button', { name: '수정 중...' })).toBeDisabled();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const ProfileEditModal = ({ user }: Props) => {
return {
form: '입력값을 확인해주세요',
fields: {
nickName: { message: '이미 사용 중인 닉네임입니다' },
nickName: { message: '이미 사용중인 닉네임 입니다.' },
},
};
}
Expand Down
121 changes: 106 additions & 15 deletions src/mock/service/user/user-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ import { User } from '@/types/service/user';
import { createMockErrorResponse, createMockSuccessResponse } from '../common/common-mock';
import { mockUserItems } from './user-mock';

// 1. 팔로우 등록 모킹
const followUserMock = http.post(`*/users/follow`, async ({ request }) => {
const url = new URL(request.url);
const followNickname = url.searchParams.get('followNickname');
const user = mockUserItems.find((v) => v.nickName === followNickname);

if (user?.isFollow === false) {
return HttpResponse.json(createMockSuccessResponse('팔로우 성공'));
}

if (user?.isFollow === null) {
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '회원 : 자기 자신을 팔로우할 수 없습니다.',
errorCode: 'NOT_SAME_FOLLOW',
}),
);
}

if (user?.isFollow === true) {
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '회원 : 이미 팔로우 중입니다.',
errorCode: 'ALREADY_EXIST_FOLLOW',
}),
);
}
});

// 2. 유저 프로필 변경 모킹
const updateUserItemMock = http.patch(`*/users/profile`, async ({ request }) => {
const body = (await request.json()) as User;
return HttpResponse.json(
createMockSuccessResponse({
...mockUserItems[0],
...body,
}),
);
});

// 4. 알림 설정 변경 모킹
const updateMyNotificationMock = http.patch(`*/users/notification`, async ({ request }) => {
const url = new URL(request.url);
const isNotificationEnabled = url.searchParams.get('isNotificationEnabled');

return HttpResponse.json(
createMockSuccessResponse({
...mockUserItems[0],
isNotificationEnabled: !!isNotificationEnabled,
}),
);
});

// 5. 유저 프로필 조회 모킹
const getUserItemMock = http.get(`*/users/:userId`, ({ params }) => {
const id = Number(params.userId);
const user = mockUserItems.find((item) => item.userId === id);
Expand All @@ -23,39 +79,74 @@ const getUserItemMock = http.get(`*/users/:userId`, ({ params }) => {
return HttpResponse.json(createMockSuccessResponse(user));
});

// 7. 닉네임 중복 검사 모킹
const getNicknameAvailabilityMock = http.get(`*/users/nickname/availability`, ({ request }) => {
const url = new URL(request.url);
const nickname = url.searchParams.get('nickname');
const user = mockUserItems.find((item) => item.nickName === nickname);
return HttpResponse.json(
createMockSuccessResponse({
data: { available: !user },
}),
);
});
Comment on lines +82 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

응답 구조가 API 타입 정의와 일치하지 않습니다.

Line 94-96에서 { data: { available: !user } } 형태로 중첩된 객체를 반환하고 있지만, createMockSuccessResponse는 이미 { status, success, data } 구조를 생성합니다. 이로 인해 최종 응답이 { status: 200, success: true, data: { data: { available: boolean } } } 형태가 되어 불필요한 중첩이 발생합니다.

🔎 수정 제안
 const getNicknameAvailabilityMock = http.get(`*/users/nickname/availability`, ({ request }) => {
   const url = new URL(request.url);
-  const nickname = url.searchParams.get('nickname');
+  const nickName = url.searchParams.get('nickName');
-  const user = mockUserItems.find((item) => item.nickName === nickname);
+  const user = mockUserItems.find((item) => item.nickName === nickName);
   return HttpResponse.json(
-    createMockSuccessResponse({
-      data: { available: !user },
-    }),
+    createMockSuccessResponse({ available: !user }),
   );
 });

참고: 파라미터 이름도 nickname에서 nickName으로 변경하여 API 서비스 코드(Line 48 in src/api/service/user-service/index.ts)와 일치시켰습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 7. 닉네임 중복 검사 모킹
const getNicknameAvailabilityMock = http.get(`*/users/nickname/availability`, ({ request }) => {
const url = new URL(request.url);
const nickname = url.searchParams.get('nickname');
const user = mockUserItems.find((item) => item.nickName === nickname);
return HttpResponse.json(
createMockSuccessResponse({
data: { available: !user },
}),
);
});
// 7. 닉네임 중복 검사 모킹
const getNicknameAvailabilityMock = http.get(`*/users/nickname/availability`, ({ request }) => {
const url = new URL(request.url);
const nickName = url.searchParams.get('nickName');
const user = mockUserItems.find((item) => item.nickName === nickName);
return HttpResponse.json(
createMockSuccessResponse({ available: !user }),
);
});
🤖 Prompt for AI Agents
In src/mock/service/user/user-handler.ts around lines 88 to 98, the mock handler
returns an extra nested "data" object and uses the wrong query param name;
change the search param from 'nickname' to 'nickName' and pass the availability
boolean directly to createMockSuccessResponse (i.e., call
createMockSuccessResponse({ data: { available: !user } }) should be replaced so
createMockSuccessResponse receives data: { available: !user } without adding
another data wrapper), ensuring the final response shape matches { status,
success, data: { available: boolean } }.


// 8. 본인 프로필 조회 모킹
const getMeItemMock = http.get(`*/users/me`, () => {
const id = 1;
const user = mockUserItems.find((item) => item.userId === id);
return HttpResponse.json(createMockSuccessResponse(user));
});

const updateUserItemMock = http.patch(`*/users`, async ({ request }) => {
const body = (await request.json()) as User;
// 7. 닉네임 중복 검사 모킹
const getEmailAvailabilityMock = http.get(`*/users/email/availability`, ({ request }) => {
const url = new URL(request.url);
const email = url.searchParams.get('email');
const user = mockUserItems.find((item) => item.email === email);
return HttpResponse.json(
createMockSuccessResponse({
...mockUserItems[0],
...body,
data: { available: !user },
}),
);
});
Comment on lines +101 to 111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

응답 구조가 일관되지 않습니다.

Line 113-115에서 getNicknameAvailabilityMock과 동일한 불필요한 중첩 구조 문제가 있습니다.

🔎 수정 제안
-const getEmailAvailabilityMock = http.get(`*/users/email/availability`, ({ request }) => {
+const getEmailAvailabilityMock = http.get(`*/users/email/availability`, ({ request }) => {
   const url = new URL(request.url);
   const email = url.searchParams.get('email');
   const user = mockUserItems.find((item) => item.email === email);
   return HttpResponse.json(
-    createMockSuccessResponse({
-      data: { available: !user },
-    }),
+    createMockSuccessResponse({ available: !user }),
   );
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 7. 닉네임 중복 검사 모킹
const getEmailAvailabilityMock = http.get(`*/users/email/availability`, ({ request }) => {
const url = new URL(request.url);
const email = url.searchParams.get('email');
const user = mockUserItems.find((item) => item.email === email);
return HttpResponse.json(
createMockSuccessResponse({
...mockUserItems[0],
...body,
data: { available: !user },
}),
);
});
// 7. 닉네임 중복 검사 모킹
const getEmailAvailabilityMock = http.get(`*/users/email/availability`, ({ request }) => {
const url = new URL(request.url);
const email = url.searchParams.get('email');
const user = mockUserItems.find((item) => item.email === email);
return HttpResponse.json(
createMockSuccessResponse({ available: !user }),
);
});
🤖 Prompt for AI Agents
In src/mock/service/user/user-handler.ts around lines 107 to 117, the JSON
response for getEmailAvailabilityMock uses an extra/unnecessary nested structure
inconsistent with getNicknameAvailabilityMock; modify the response so the
success payload shape matches the nickname availability handler (i.e., return
createMockSuccessResponse({ data: { available: !user } }) without additional
nesting or wrappers), ensuring the final HttpResponse.json contains the same
top-level keys and structure as other availability endpoints.


const deleteUserItemMock = http.delete(`*/users`, async () => {
return HttpResponse.json(createMockSuccessResponse(null));
});
const unfollowUserMock = http.delete(`*/users/unfollow`, ({ request }) => {
const url = new URL(request.url);
const unFollowNickname = url.searchParams.get('unFollowNickname');
const user = mockUserItems.find((v) => v.nickName === unFollowNickname);

const followUserItemMock = http.post(`*/follows`, async () => {
return HttpResponse.json(createMockSuccessResponse(null));
});
if (user?.isFollow === false) {
return HttpResponse.json(createMockSuccessResponse('팔로우 취소 성공'));
}

if (user?.isFollow === null) {
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '회원 : 팔로우 취소 대상은 본인 될 수 없습니다.',
errorCode: 'NOT_SAME_FOLLOW',
}),
);
}

const unfollowUserItemMock = http.delete(`*/follows/:followId`, async () => {
return HttpResponse.json(createMockSuccessResponse(null));
if (user?.isFollow === true) {
return HttpResponse.json(
createMockErrorResponse({
status: 400,
detail: '회원 : 팔로우 관계를 찾을 수 없습니다.',
errorCode: 'NOT_FOUND_FOLLOW',
}),
);
}
});

export const userHandlers = [
followUserMock,
getUserItemMock,
updateMyNotificationMock,
getMeItemMock,
updateUserItemMock,
deleteUserItemMock,
followUserItemMock,
unfollowUserItemMock,
getNicknameAvailabilityMock,
getEmailAvailabilityMock,
unfollowUserMock,
];