From 4a82b844031239716554b285a4a2301548f5332f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Tue, 11 Aug 2026 16:17:19 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?@WebMvcTest=2010=EA=B0=9C=20+=20Swagger=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=90=EB=8F=99=ED=99=94=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(prod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev에서 검증 완료된 컨트롤러 슬라이스 테스트 전체(Auth/Banner/ ServerProfile/Course/Health/PublicCourse/Record/Scrap/User/ UserStamp, 총 236개 테스트 통과)와 springdoc-openapi 기반 Swagger 문서 자동화를 main에 반영한다. JpaAuditingConfig로 @EnableJpaAuditing을 ServerApplication에서 분리한 것도 함께 포팅했다 — @WebMvcTest가 엔티티 스캔 없이도 JPA Auditing 인프라를 초기화하려다 나던 "JPA metamodel must not be empty" 컨텍스트 로딩 실패를 막기 위해 필요한 선행 조건이다. --- build.gradle | 3 + .../org/runnect/server/ServerApplication.java | 2 - .../server/config/jpa/JpaAuditingConfig.java | 9 + .../server/config/swagger/SwaggerConfig.java | 43 +++ .../auth/controller/AuthControllerTest.java | 143 +++++++++ .../controller/BannerControllerTest.java | 56 ++++ .../ServerProfileControllerTest.java | 36 +++ .../controller/CourseControllerTest.java | 230 ++++++++++++++ .../controller/HealthControllerTest.java | 187 +++++++++++ .../PublicCourseControllerTest.java | 297 ++++++++++++++++++ .../controller/RecordControllerTest.java | 161 ++++++++++ .../scrap/controller/ScrapControllerTest.java | 122 +++++++ .../user/controller/UserControllerTest.java | 168 ++++++++++ .../controller/UserStampControllerTest.java | 64 ++++ 14 files changed, 1519 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/runnect/server/config/jpa/JpaAuditingConfig.java create mode 100644 src/main/java/org/runnect/server/config/swagger/SwaggerConfig.java create mode 100644 src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java create mode 100644 src/test/java/org/runnect/server/banner/controller/BannerControllerTest.java create mode 100644 src/test/java/org/runnect/server/common/controller/ServerProfileControllerTest.java create mode 100644 src/test/java/org/runnect/server/course/controller/CourseControllerTest.java create mode 100644 src/test/java/org/runnect/server/health/controller/HealthControllerTest.java create mode 100644 src/test/java/org/runnect/server/publicCourse/controller/PublicCourseControllerTest.java create mode 100644 src/test/java/org/runnect/server/record/controller/RecordControllerTest.java create mode 100644 src/test/java/org/runnect/server/scrap/controller/ScrapControllerTest.java create mode 100644 src/test/java/org/runnect/server/user/controller/UserControllerTest.java create mode 100644 src/test/java/org/runnect/server/user/controller/UserStampControllerTest.java diff --git a/build.gradle b/build.gradle index c3ce667..e2b0549 100644 --- a/build.gradle +++ b/build.gradle @@ -72,6 +72,9 @@ dependencies { // Sentry implementation 'io.sentry:sentry-spring-boot-starter:4.3.0' + // Swagger (OpenAPI) + implementation 'org.springdoc:springdoc-openapi-ui:1.7.0' + } tasks.named('test') { diff --git a/src/main/java/org/runnect/server/ServerApplication.java b/src/main/java/org/runnect/server/ServerApplication.java index acad388..beb387a 100644 --- a/src/main/java/org/runnect/server/ServerApplication.java +++ b/src/main/java/org/runnect/server/ServerApplication.java @@ -2,9 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@EnableJpaAuditing @SpringBootApplication public class ServerApplication { static { diff --git a/src/main/java/org/runnect/server/config/jpa/JpaAuditingConfig.java b/src/main/java/org/runnect/server/config/jpa/JpaAuditingConfig.java new file mode 100644 index 0000000..976a9ae --- /dev/null +++ b/src/main/java/org/runnect/server/config/jpa/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package org.runnect.server.config.jpa; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/org/runnect/server/config/swagger/SwaggerConfig.java b/src/main/java/org/runnect/server/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..0aa5572 --- /dev/null +++ b/src/main/java/org/runnect/server/config/swagger/SwaggerConfig.java @@ -0,0 +1,43 @@ +package org.runnect.server.config.swagger; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + private static final String ACCESS_TOKEN_HEADER = "accessToken"; + private static final String REFRESH_TOKEN_HEADER = "refreshToken"; + + @Bean + public OpenAPI openAPI() { + SecurityScheme accessTokenScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name(ACCESS_TOKEN_HEADER); + + SecurityScheme refreshTokenScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name(REFRESH_TOKEN_HEADER); + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList(ACCESS_TOKEN_HEADER) + .addList(REFRESH_TOKEN_HEADER); + + return new OpenAPI() + .info(new Info() + .title("Runnect API") + .description("Runnect 서버 API 문서") + .version("v1")) + .components(new Components() + .addSecuritySchemes(ACCESS_TOKEN_HEADER, accessTokenScheme) + .addSecuritySchemes(REFRESH_TOKEN_HEADER, refreshTokenScheme)) + .addSecurityItem(securityRequirement); + } +} diff --git a/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java b/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java new file mode 100644 index 0000000..788245c --- /dev/null +++ b/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java @@ -0,0 +1,143 @@ +package org.runnect.server.auth.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.auth.dto.response.GetNewTokenResponseDto; +import org.runnect.server.auth.dto.response.SignInResponseDto; +import org.runnect.server.auth.dto.response.SignUpResponseDto; +import org.runnect.server.auth.service.AuthService; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.user.exception.authException.InvalidRefreshTokenException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AuthController.class) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AuthService authService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @Nested + @DisplayName("POST /api/auth") + class SignIn { + + @Test + @DisplayName("기존 유저면 200과 LOGIN 응답을 반환한다") + void 기존_유저_로그인() throws Exception { + when(authService.signIn(BDDMockito.any())).thenReturn( + SignInResponseDto.of("KAKAO", "user@runnect.io", "access-token", "refresh-token")); + + mockMvc.perform(post("/api/auth") + .contentType("application/json") + .content("{\"token\":\"kakao-token\",\"provider\":\"kakao\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.email").value("user@runnect.io")); + } + + @Test + @DisplayName("신규 유저면 200과 SIGNUP 응답을 반환한다") + void 신규_유저_회원가입() throws Exception { + when(authService.signIn(BDDMockito.any())).thenReturn( + SignUpResponseDto.of("KAKAO", "new@runnect.io", "러너1", "access-token", "refresh-token")); + + mockMvc.perform(post("/api/auth") + .contentType("application/json") + .content("{\"token\":\"kakao-token\",\"provider\":\"kakao\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nickname").value("러너1")); + } + + @Test + @DisplayName("token이 없으면 400을 반환한다") + void 토큰_없음() throws Exception { + mockMvc.perform(post("/api/auth") + .contentType("application/json") + .content("{\"provider\":\"kakao\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(authService); + } + + @Test + @DisplayName("provider가 없으면 400을 반환한다") + void 프로바이더_없음() throws Exception { + mockMvc.perform(post("/api/auth") + .contentType("application/json") + .content("{\"token\":\"kakao-token\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(authService); + } + } + + @Nested + @DisplayName("GET /api/auth/getNewToken") + class GetNewToken { + + @Test + @DisplayName("정상 요청이면 200과 재발급된 토큰을 반환한다") + void 정상_재발급() throws Exception { + when(authService.getNewToken("old-access", "valid-refresh")) + .thenReturn(GetNewTokenResponseDto.of("new-access", "new-refresh")); + + mockMvc.perform(get("/api/auth/getNewToken") + .header("accessToken", "old-access") + .header("refreshToken", "valid-refresh")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access")); + } + + @Test + @DisplayName("refreshToken 헤더가 없으면 400을 반환한다") + void 리프레시토큰_헤더_없음() throws Exception { + mockMvc.perform(get("/api/auth/getNewToken").header("accessToken", "old-access")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(authService); + } + + @Test + @DisplayName("refreshToken이 빈 문자열이면 400을 반환한다") + void 리프레시토큰_빈문자열() throws Exception { + mockMvc.perform(get("/api/auth/getNewToken") + .header("accessToken", "old-access") + .header("refreshToken", "")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(authService); + } + + @Test + @DisplayName("저장된 리프레시 토큰과 일치하지 않으면 401을 반환한다") + void 리프레시토큰_불일치() throws Exception { + when(authService.getNewToken("old-access", "wrong-refresh")) + .thenThrow(new InvalidRefreshTokenException( + ErrorStatus.INVALID_REFRESH_TOKEN_EXCEPTION, + ErrorStatus.INVALID_REFRESH_TOKEN_EXCEPTION.getMessage())); + + mockMvc.perform(get("/api/auth/getNewToken") + .header("accessToken", "old-access") + .header("refreshToken", "wrong-refresh")) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/org/runnect/server/banner/controller/BannerControllerTest.java b/src/test/java/org/runnect/server/banner/controller/BannerControllerTest.java new file mode 100644 index 0000000..cd08adb --- /dev/null +++ b/src/test/java/org/runnect/server/banner/controller/BannerControllerTest.java @@ -0,0 +1,56 @@ +package org.runnect.server.banner.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.runnect.server.banner.dto.response.BannerResponse; +import org.runnect.server.banner.dto.response.GetBannerResponseDto; +import org.runnect.server.banner.service.BannerService; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(BannerController.class) +class BannerControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private BannerService bannerService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @Test + @DisplayName("GET /api/banner - 정상 요청이면 200과 배너 목록을 반환한다") + void 정상_조회() throws Exception { + List banners = Collections.singletonList(BannerResponse.of(0, "image.png", "https://a.com")); + when(bannerService.getBanners()).thenReturn(GetBannerResponseDto.of(banners)); + + mockMvc.perform(get("/api/banner")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.banners[0].index").value(0)) + .andExpect(jsonPath("$.data.banners[0].imageUrl").value("image.png")); + } + + @Test + @DisplayName("GET /api/banner - 배너가 없으면 200과 빈 목록을 반환한다") + void 배너_없음() throws Exception { + when(bannerService.getBanners()).thenReturn(GetBannerResponseDto.of(Collections.emptyList())); + + mockMvc.perform(get("/api/banner")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.banners").isEmpty()); + } +} diff --git a/src/test/java/org/runnect/server/common/controller/ServerProfileControllerTest.java b/src/test/java/org/runnect/server/common/controller/ServerProfileControllerTest.java new file mode 100644 index 0000000..1c30afa --- /dev/null +++ b/src/test/java/org/runnect/server/common/controller/ServerProfileControllerTest.java @@ -0,0 +1,36 @@ +package org.runnect.server.common.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ServerProfileController.class) +@ActiveProfiles("test") +class ServerProfileControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @Test + @DisplayName("GET /profile - 활성 프로파일명을 그대로 반환한다") + void 활성_프로파일_반환() throws Exception { + mockMvc.perform(get("/profile")) + .andExpect(status().isOk()) + .andExpect(content().string("test")); + } +} diff --git a/src/test/java/org/runnect/server/course/controller/CourseControllerTest.java b/src/test/java/org/runnect/server/course/controller/CourseControllerTest.java new file mode 100644 index 0000000..2b5e645 --- /dev/null +++ b/src/test/java/org/runnect/server/course/controller/CourseControllerTest.java @@ -0,0 +1,230 @@ +package org.runnect.server.course.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.convert.CoordinateDto; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.course.dto.request.CourseCreateRequestDto; +import org.runnect.server.course.dto.request.DeleteCoursesRequestDto; +import org.runnect.server.course.dto.response.CourseCreateResponseDto; +import org.runnect.server.course.dto.response.CourseGetByUserResponseDto; +import org.runnect.server.course.dto.response.DeleteCoursesResponseDto; +import org.runnect.server.course.dto.response.UpdateCourseResponseDto; +import org.runnect.server.course.dto.response.UserResponse; +import org.runnect.server.course.service.CourseService; +import org.runnect.server.external.aws.S3Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 컨트롤러 레이어(라우팅, @Valid 검증, @UserId 인증 리졸버, 예외→HTTP 상태코드 매핑)를 + * 서비스 레이어 단위 테스트와는 별개로 검증한다. CourseService/S3Service는 모킹하고 + * 나머지(WebConfig, UserIdResolver, ControllerExceptionAdvice)는 실제 빈을 그대로 쓴다. + */ +@WebMvcTest(CourseController.class) +class CourseControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private CourseService courseService; + @MockBean + private S3Service s3Service; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth( + B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + private CourseCreateRequestDto validCreateRequest() { + return new CourseCreateRequestDto( + Arrays.asList(new CoordinateDto(37.5665, 126.9780), new CoordinateDto(37.5651, 126.9895)), + "정왕역 코스", 5.2f, "정왕역", "경기 시흥시 정왕동"); + } + + @Nested + @DisplayName("POST /api/course") + class CreateCourse { + + @Test + @DisplayName("정상 요청이면 201과 생성된 코스 정보를 반환한다") + void 정상_생성() throws Exception { + when(s3Service.uploadImage(any(), eq("course"))).thenReturn("https://image.example/course.png"); + when(courseService.createCourse(eq(1L), any(), any())) + .thenReturn(CourseCreateResponseDto.of(100L, LocalDateTime.of(2026, 1, 1, 0, 0))); + + MockMultipartFile data = new MockMultipartFile("data", "", "application/json", + objectMapper.writeValueAsBytes(validCreateRequest())); + MockMultipartFile image = new MockMultipartFile("image", "photo.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(withAuth(multipart("/api/course")).file(data).file(image)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(100)); + } + + @Test + @DisplayName("인증 헤더가 없으면 400과 함께 요청이 서비스까지 도달하지 않는다") + void 인증헤더_없음() throws Exception { + MockMultipartFile data = new MockMultipartFile("data", "", "application/json", + objectMapper.writeValueAsBytes(validCreateRequest())); + MockMultipartFile image = new MockMultipartFile("image", "photo.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart("/api/course").file(data).file(image)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + + BDDMockito.verifyNoInteractions(courseService); + } + + @Test + @DisplayName("title이 비어있으면 유효성 검증에서 400을 반환한다") + void 유효성검증_실패() throws Exception { + CourseCreateRequestDto invalid = new CourseCreateRequestDto( + Arrays.asList(new CoordinateDto(37.5665, 126.9780), new CoordinateDto(37.5651, 126.9895)), + "", 5.2f, "정왕역", "경기 시흥시 정왕동"); + MockMultipartFile data = new MockMultipartFile("data", "", "application/json", + objectMapper.writeValueAsBytes(invalid)); + MockMultipartFile image = new MockMultipartFile("image", "photo.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(withAuth(multipart("/api/course")).file(data).file(image)) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(courseService); + } + } + + @Nested + @DisplayName("GET /api/course/user") + class GetCourseByUser { + + @Test + @DisplayName("정상 요청이면 200과 코스 목록을 반환한다") + void 정상_조회() throws Exception { + when(courseService.getCourseByUser(1L)).thenReturn( + CourseGetByUserResponseDto.of(UserResponse.of(1L), Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/course/user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(1)); + } + } + + @Nested + @DisplayName("GET /api/course/detail/{courseId}") + class GetCourseDetail { + + @Test + @DisplayName("서비스에서 NotFoundException이 나면 400으로 매핑된다") + void 존재하지_않는_코스() throws Exception { + when(courseService.getCourseDetail(anyLong(), eq(1L))) + .thenThrow(new NotFoundException( + org.runnect.server.common.constant.ErrorStatus.NOT_FOUND_COURSE_EXCEPTION, + org.runnect.server.common.constant.ErrorStatus.NOT_FOUND_COURSE_EXCEPTION.getMessage())); + + mockMvc.perform(withAuth(get("/api/course/detail/999"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + } + + @Nested + @DisplayName("PATCH /api/course/{courseId}") + class UpdateCourse { + + @Test + @DisplayName("정상 요청이면 200과 수정된 제목을 반환한다") + void 정상_수정() throws Exception { + when(courseService.updateCourse(eq(1L), eq(10L), eq("새 제목"))) + .thenReturn(UpdateCourseResponseDto.of(courseWithTitle(10L, "새 제목"))); + + mockMvc.perform(withAuth(patch("/api/course/10")) + .contentType("application/json") + .content("{\"title\":\"새 제목\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.course.title").value("새 제목")); + } + + @Test + @DisplayName("title이 비어있으면 400을 반환한다") + void 빈_제목() throws Exception { + mockMvc.perform(withAuth(patch("/api/course/10")) + .contentType("application/json") + .content("{\"title\":\"\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(courseService); + } + + private org.runnect.server.course.entity.Course courseWithTitle(Long id, String title) { + org.runnect.server.course.entity.Course course = org.runnect.server.course.entity.Course.builder() + .title(title) + .departureRegion("경기").departureCity("시흥시").departureTown("정왕동") + .distance(5.2f).image("image.png") + .path(org.runnect.server.common.module.convert.CoordinatePathConverter.coorConvertPath( + Arrays.asList(new CoordinateDto(37.5665, 126.9780), new CoordinateDto(37.5651, 126.9895)))) + .build(); + org.springframework.test.util.ReflectionTestUtils.setField(course, "id", id); + return course; + } + } + + @Nested + @DisplayName("PUT /api/course") + class DeleteCourses { + + @Test + @DisplayName("정상 요청이면 200과 삭제 개수를 반환한다") + void 정상_삭제() throws Exception { + when(courseService.deleteCourses(Collections.singletonList(10L), 1L)) + .thenReturn(DeleteCoursesResponseDto.from(1)); + + mockMvc.perform(withAuth(put("/api/course")) + .contentType("application/json") + .content(objectMapper.writeValueAsString( + new DeleteCoursesRequestDto(Collections.singletonList(10L))))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedCourseCount").value(1)); + } + } +} diff --git a/src/test/java/org/runnect/server/health/controller/HealthControllerTest.java b/src/test/java/org/runnect/server/health/controller/HealthControllerTest.java new file mode 100644 index 0000000..3475fc3 --- /dev/null +++ b/src/test/java/org/runnect/server/health/controller/HealthControllerTest.java @@ -0,0 +1,187 @@ +package org.runnect.server.health.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.health.dto.request.HealthDataRequestDto; +import org.runnect.server.health.dto.response.CreateHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthSummaryResponseDto; +import org.runnect.server.health.service.HealthService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@WebMvcTest(HealthController.class) +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private HealthService healthService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth(B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + private HealthDataRequestDto validRequest() { + return new HealthDataRequestDto(150.0, 180.0, 100.0, 320.0, 60, 120, 90, 30, 10, 190.0, Collections.emptyList()); + } + + @Nested + @DisplayName("POST /api/record/{recordId}/health") + class CreateHealthData { + + @Test + @DisplayName("정상 요청이면 201과 생성된 건강 데이터 id를 반환한다") + void 정상_생성() throws Exception { + when(healthService.createHealthData(eq(1L), eq(10L), any())) + .thenReturn(CreateHealthDataResponseDto.of(100L)); + + mockMvc.perform(withAuth(post("/api/record/10/health")) + .contentType("application/json") + .content(objectMapper.writeValueAsString(validRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.healthDataId").value(100)); + } + + @Test + @DisplayName("필수값(avgHeartRate)이 없으면 400을 반환한다") + void 필수값_없음() throws Exception { + mockMvc.perform(withAuth(post("/api/record/10/health")) + .contentType("application/json") + .content("{\"calories\":320.0,\"zone1Seconds\":1,\"zone2Seconds\":1,\"zone3Seconds\":1,\"zone4Seconds\":1,\"zone5Seconds\":1}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(healthService); + } + + @Test + @DisplayName("다른 유저의 레코드면 403을 반환한다") + void 소유권_없음() throws Exception { + when(healthService.createHealthData(eq(1L), eq(10L), any())) + .thenThrow(new PermissionDeniedException( + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION.getMessage())); + + mockMvc.perform(withAuth(post("/api/record/10/health")) + .contentType("application/json") + .content(objectMapper.writeValueAsString(validRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("이미 건강 데이터가 있으면 409를 반환한다") + void 이미_존재() throws Exception { + when(healthService.createHealthData(eq(1L), eq(10L), any())) + .thenThrow(new ConflictException( + ErrorStatus.ALREADY_EXIST_HEALTH_DATA_EXCEPTION, + ErrorStatus.ALREADY_EXIST_HEALTH_DATA_EXCEPTION.getMessage())); + + mockMvc.perform(withAuth(post("/api/record/10/health")) + .contentType("application/json") + .content(objectMapper.writeValueAsString(validRequest()))) + .andExpect(status().isConflict()); + } + } + + @Nested + @DisplayName("GET /api/record/{recordId}/health") + class GetHealthData { + + @Test + @DisplayName("정상 요청이면 200과 건강 데이터를 반환한다") + void 정상_조회() throws Exception { + GetHealthDataResponseDto.ZoneResponse zones = + GetHealthDataResponseDto.ZoneResponse.of(60, 120, 90, 30, 10); + GetHealthDataResponseDto response = GetHealthDataResponseDto.of( + GetHealthDataResponseDto.HealthDataDetailResponse.of( + 100L, 10L, 150.0, 180.0, 100.0, 320.0, zones, 190.0, Collections.emptyList())); + when(healthService.getHealthData(anyLong(), eq(10L))).thenReturn(response); + + mockMvc.perform(withAuth(get("/api/record/10/health"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.healthData.id").value(100)); + } + } + + @Nested + @DisplayName("GET /api/health/summary") + class GetHealthSummary { + + @Test + @DisplayName("정상 요청이면 200과 요약 데이터를 반환한다") + void 정상_조회() throws Exception { + GetHealthSummaryResponseDto response = GetHealthSummaryResponseDto.of( + GetHealthSummaryResponseDto.HealthSummaryResponse.of( + 10L, 5L, 150.0, 300.0, 1500.0, + GetHealthDataResponseDto.ZoneResponse.of(60, 120, 90, 30, 10))); + when(healthService.getHealthSummary(1L, "2026-01-01", "2026-01-31")).thenReturn(response); + + mockMvc.perform(withAuth(get("/api/health/summary") + .param("startDate", "2026-01-01") + .param("endDate", "2026-01-31"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.totalRecords").value(10)); + } + + @Test + @DisplayName("필수 파라미터가 없으면 400을 반환한다") + void 파라미터_없음() throws Exception { + mockMvc.perform(withAuth(get("/api/health/summary").param("startDate", "2026-01-01"))) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(healthService); + } + } + + @Nested + @DisplayName("DELETE /api/record/{recordId}/health") + class DeleteHealthData { + + @Test + @DisplayName("정상 요청이면 200을 반환한다") + void 정상_삭제() throws Exception { + mockMvc.perform(withAuth(delete("/api/record/10/health"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + } +} diff --git a/src/test/java/org/runnect/server/publicCourse/controller/PublicCourseControllerTest.java b/src/test/java/org/runnect/server/publicCourse/controller/PublicCourseControllerTest.java new file mode 100644 index 0000000..37e990a --- /dev/null +++ b/src/test/java/org/runnect/server/publicCourse/controller/PublicCourseControllerTest.java @@ -0,0 +1,297 @@ +package org.runnect.server.publicCourse.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.publicCourse.dto.response.CreatePublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.DeletePublicCoursesResponseDto; +import org.runnect.server.publicCourse.dto.response.GetPublicCourseTotalPageCountResponseDto; +import org.runnect.server.publicCourse.dto.response.UpdatePublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.getMarathonPublicCourse.GetMarathonPublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.getPublicCourseByUser.GetPublicCourseByUserResponseDto; +import org.runnect.server.publicCourse.dto.response.recommendPublicCourse.RecommendPublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.searchPublicCourse.SearchPublicCourseResponseDto; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.service.PublicCourseService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@WebMvcTest(PublicCourseController.class) +class PublicCourseControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private PublicCourseService publicCourseService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth(B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + @Nested + @DisplayName("GET /api/public-course") + class RecommendPublicCourse { + + @Test + @DisplayName("정상 요청이면 200과 추천 코스 목록을 반환한다") + void 정상_조회() throws Exception { + when(publicCourseService.recommendPublicCourse(eq(1L), eq(1), eq("date"))) + .thenReturn(RecommendPublicCourseResponseDto.of("date", 3, false, Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/public-course"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.ordering").value("date")); + } + + @Test + @DisplayName("sort 파라미터가 유효하지 않으면 400을 반환한다") + void 정렬값_유효하지_않음() throws Exception { + mockMvc.perform(withAuth(get("/api/public-course?sort=invalid"))) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(publicCourseService); + } + + @Test + @DisplayName("[설계상 주의] pageNo에 @Positive가 있어도 클래스에 @Validated가 없어 실제로는 검증되지 않는다") + void 페이지번호_0이어도_검증되지_않음() throws Exception { + when(publicCourseService.recommendPublicCourse(eq(1L), eq(0), eq("date"))) + .thenReturn(RecommendPublicCourseResponseDto.of("date", 3, false, Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/public-course").param("pageNo", "0"))) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("GET /api/public-course/search") + class SearchPublicCourse { + + @Test + @DisplayName("정상 요청이면 200과 검색 결과를 반환한다") + void 정상_검색() throws Exception { + when(publicCourseService.searchPublicCourse(eq(1L), eq("정왕역"))) + .thenReturn(SearchPublicCourseResponseDto.of(Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/public-course/search").param("keyword", "정왕역"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicCourses").isEmpty()); + } + + @Test + @DisplayName("keyword가 없으면 400을 반환한다") + void 검색어_없음() throws Exception { + mockMvc.perform(withAuth(get("/api/public-course/search"))) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(publicCourseService); + } + } + + @Nested + @DisplayName("GET /api/public-course/marathon") + class GetMarathonPublicCourse { + + @Test + @DisplayName("정상 요청이면 200과 마라톤 코스 목록을 반환한다") + void 정상_조회() throws Exception { + when(publicCourseService.getMarathonPublicCourse(1L)) + .thenReturn(GetMarathonPublicCourseResponseDto.of(Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/public-course/marathon"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.marathonPublicCourses").isEmpty()); + } + } + + @Nested + @DisplayName("POST /api/public-course") + class CreatePublicCourse { + + @Test + @DisplayName("정상 요청이면 201과 생성된 퍼블릭 코스 정보를 반환한다") + void 정상_생성() throws Exception { + when(publicCourseService.createPublicCourse(eq(1L), any())) + .thenReturn(CreatePublicCourseResponseDto.of(100L, "2026-01-01T00:00:00")); + + mockMvc.perform(withAuth(post("/api/public-course")) + .contentType("application/json") + .content("{\"courseId\":10,\"title\":\"정왕역 코스\",\"description\":\"설명\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.publicCourse.id").value(100)); + } + + @Test + @DisplayName("title이 없으면 400을 반환한다") + void 제목_없음() throws Exception { + mockMvc.perform(withAuth(post("/api/public-course")) + .contentType("application/json") + .content("{\"courseId\":10,\"description\":\"설명\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(publicCourseService); + } + } + + @Nested + @DisplayName("GET /api/public-course/detail/{publicCourseId}") + class GetPublicCourseDetail { + + @Test + @DisplayName("존재하지 않는 퍼블릭 코스면 400을 반환한다") + void 존재하지_않음() throws Exception { + when(publicCourseService.getPublicCourseDetail(anyLong(), eq(999L))) + .thenThrow(new NotFoundException( + ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION, + ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION.getMessage())); + + mockMvc.perform(withAuth(get("/api/public-course/detail/999"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + } + + @Nested + @DisplayName("GET /api/public-course/user") + class GetPublicCourseByUser { + + @Test + @DisplayName("정상 요청이면 200과 유저의 퍼블릭 코스 목록을 반환한다") + void 정상_조회() throws Exception { + when(publicCourseService.getPublicCourseByUser(1L)) + .thenReturn(GetPublicCourseByUserResponseDto.of(1L, Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/public-course/user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(1)); + } + } + + @Nested + @DisplayName("GET /api/public-course/total-page-count") + class GetPublicCourseTotalPageCount { + + @Test + @DisplayName("정상 요청이면 200과 전체 페이지 수를 반환한다") + void 정상_조회() throws Exception { + when(publicCourseService.getPublicCourseTotalPageCount()) + .thenReturn(GetPublicCourseTotalPageCountResponseDto.of(5L)); + + mockMvc.perform(get("/api/public-course/total-page-count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalPageCount").value(5)); + } + } + + @Nested + @DisplayName("PUT /api/public-course") + class DeletePublicCourses { + + @Test + @DisplayName("정상 요청이면 200과 삭제 개수를 반환한다") + void 정상_삭제() throws Exception { + when(publicCourseService.deletePublicCourses(eq(1L), any())) + .thenReturn(DeletePublicCoursesResponseDto.from(1)); + + mockMvc.perform(withAuth(put("/api/public-course")) + .contentType("application/json") + .content("{\"publicCourseIdList\":[10]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedPublicCourseCount").value(1)); + } + + @Test + @DisplayName("publicCourseIdList가 비어있으면 400을 반환한다") + void 목록_비어있음() throws Exception { + mockMvc.perform(withAuth(put("/api/public-course")) + .contentType("application/json") + .content("{\"publicCourseIdList\":[]}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(publicCourseService); + } + } + + @Nested + @DisplayName("PATCH /api/public-course/{publicCourseId}") + class UpdatePublicCourse { + + @Test + @DisplayName("정상 요청이면 200과 수정된 정보를 반환한다") + void 정상_수정() throws Exception { + PublicCourse publicCourse = PublicCourse.builder().title("새 제목").description("새 설명").build(); + ReflectionTestUtils.setField(publicCourse, "id", 10L); + when(publicCourseService.updatePublicCourse(eq(1L), eq(10L), eq("새 제목"), eq("새 설명"))) + .thenReturn(UpdatePublicCourseResponseDto.of(publicCourse)); + + mockMvc.perform(withAuth(patch("/api/public-course/10")) + .contentType("application/json") + .content("{\"title\":\"새 제목\",\"description\":\"새 설명\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicCourse.title").value("새 제목")); + } + + @Test + @DisplayName("title이 없으면 400을 반환한다") + void 제목_없음() throws Exception { + mockMvc.perform(withAuth(patch("/api/public-course/10")) + .contentType("application/json") + .content("{\"title\":\"\",\"description\":\"설명\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(publicCourseService); + } + + @Test + @DisplayName("소유권이 없으면 403을 반환한다") + void 소유권_없음() throws Exception { + when(publicCourseService.updatePublicCourse(eq(1L), eq(10L), any(), any())) + .thenThrow(new PermissionDeniedException( + ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION.getMessage())); + + mockMvc.perform(withAuth(patch("/api/public-course/10")) + .contentType("application/json") + .content("{\"title\":\"새 제목\",\"description\":\"새 설명\"}")) + .andExpect(status().isForbidden()); + } + } +} diff --git a/src/test/java/org/runnect/server/record/controller/RecordControllerTest.java b/src/test/java/org/runnect/server/record/controller/RecordControllerTest.java new file mode 100644 index 0000000..055aeb8 --- /dev/null +++ b/src/test/java/org/runnect/server/record/controller/RecordControllerTest.java @@ -0,0 +1,161 @@ +package org.runnect.server.record.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.record.dto.response.CreateRecordDto; +import org.runnect.server.record.dto.response.CreateRecordResponseDto; +import org.runnect.server.record.dto.response.DeleteRecordsResponseDto; +import org.runnect.server.record.dto.response.GetRecordResponseDto; +import org.runnect.server.record.dto.response.UpdateRecordResponse; +import org.runnect.server.record.dto.response.UpdateRecordResponseDto; +import org.runnect.server.record.dto.response.UserResponse; +import org.runnect.server.record.service.RecordService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@WebMvcTest(RecordController.class) +class RecordControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private RecordService recordService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth(B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + @Nested + @DisplayName("POST /api/record") + class CreateRecord { + + @Test + @DisplayName("정상 요청이면 201과 생성된 레코드 정보를 반환한다") + void 정상_생성() throws Exception { + when(recordService.createRecord(eq(1L), any())) + .thenReturn(new CreateRecordResponseDto(new CreateRecordDto(100L, "2026-01-01T00:00:00"))); + + mockMvc.perform(withAuth(post("/api/record")) + .contentType("application/json") + .content("{\"courseId\":10,\"title\":\"정왕역 코스\",\"time\":\"00:30:00\",\"pace\":\"6'00\\\"\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.record.id").value(100)); + } + + @Test + @DisplayName("title이 없으면 400을 반환한다") + void 제목_없음() throws Exception { + mockMvc.perform(withAuth(post("/api/record")) + .contentType("application/json") + .content("{\"courseId\":10,\"time\":\"00:30:00\",\"pace\":\"6'00\\\"\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(recordService); + } + } + + @Nested + @DisplayName("GET /api/record/user") + class GetRecordByUser { + + @Test + @DisplayName("정상 요청이면 200과 레코드 목록을 반환한다") + void 정상_조회() throws Exception { + when(recordService.getRecordByUser(1L)) + .thenReturn(GetRecordResponseDto.of(UserResponse.of(1L), Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/record/user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.userId").value(1)); + } + } + + @Nested + @DisplayName("PATCH /api/record/{recordId}") + class UpdateRecord { + + @Test + @DisplayName("정상 요청이면 200과 수정된 제목을 반환한다") + void 정상_수정() throws Exception { + when(recordService.updateRecord(eq(1L), eq(10L), any())) + .thenReturn(UpdateRecordResponseDto.of(UpdateRecordResponse.of(10L, "새 제목"))); + + mockMvc.perform(withAuth(patch("/api/record/10")) + .contentType("application/json") + .content("{\"title\":\"새 제목\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.record.title").value("새 제목")); + } + + @Test + @DisplayName("title이 없으면 400을 반환한다") + void 제목_없음() throws Exception { + mockMvc.perform(withAuth(patch("/api/record/10")) + .contentType("application/json") + .content("{\"title\":\"\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(recordService); + } + } + + @Nested + @DisplayName("PUT /api/record") + class DeleteRecords { + + @Test + @DisplayName("정상 요청이면 200과 삭제 개수를 반환한다") + void 정상_삭제() throws Exception { + when(recordService.deleteRecords(eq(1L), any())).thenReturn(DeleteRecordsResponseDto.from(1L)); + + mockMvc.perform(withAuth(put("/api/record")) + .contentType("application/json") + .content("{\"recordIdList\":[10]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedRecordIdCount").value(1)); + } + + @Test + @DisplayName("recordIdList가 비어있으면 400을 반환한다") + void 목록_비어있음() throws Exception { + mockMvc.perform(withAuth(put("/api/record")) + .contentType("application/json") + .content("{\"recordIdList\":[]}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(recordService); + } + } +} diff --git a/src/test/java/org/runnect/server/scrap/controller/ScrapControllerTest.java b/src/test/java/org/runnect/server/scrap/controller/ScrapControllerTest.java new file mode 100644 index 0000000..cda5a92 --- /dev/null +++ b/src/test/java/org/runnect/server/scrap/controller/ScrapControllerTest.java @@ -0,0 +1,122 @@ +package org.runnect.server.scrap.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.scrap.dto.response.CreateAndDeleteScrapResponseDto; +import org.runnect.server.scrap.dto.response.GetScrapCourseResponseDto; +import org.runnect.server.scrap.dto.response.UserResponse; +import org.runnect.server.scrap.service.ScrapService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@WebMvcTest(ScrapController.class) +class ScrapControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ScrapService scrapService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth(B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + @Nested + @DisplayName("POST /api/scrap") + class CreateAndDeleteScrap { + + @Test + @DisplayName("scrapTF가 true면 200과 스크랩 생성 결과를 반환한다") + void 스크랩_생성() throws Exception { + when(scrapService.createAndDeleteScrap(eq(1L), any())) + .thenReturn(CreateAndDeleteScrapResponseDto.of(10L, 1L, true)); + + mockMvc.perform(withAuth(post("/api/scrap")) + .contentType("application/json") + .content("{\"publicCourseId\":10,\"scrapTF\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scrapTF").value(true)); + } + + @Test + @DisplayName("scrapTF가 false면 200과 스크랩 삭제 결과를 반환한다") + void 스크랩_삭제() throws Exception { + when(scrapService.createAndDeleteScrap(eq(1L), any())) + .thenReturn(CreateAndDeleteScrapResponseDto.of(10L, 0L, false)); + + mockMvc.perform(withAuth(post("/api/scrap")) + .contentType("application/json") + .content("{\"publicCourseId\":10,\"scrapTF\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scrapTF").value(false)); + } + + @Test + @DisplayName("publicCourseId가 없으면 400을 반환한다") + void 코스아이디_없음() throws Exception { + mockMvc.perform(withAuth(post("/api/scrap")) + .contentType("application/json") + .content("{\"scrapTF\":true}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(scrapService); + } + + @Test + @DisplayName("scrapTF가 없으면 400을 반환한다") + void 스크랩여부_없음() throws Exception { + mockMvc.perform(withAuth(post("/api/scrap")) + .contentType("application/json") + .content("{\"publicCourseId\":10}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(scrapService); + } + } + + @Nested + @DisplayName("GET /api/scrap/user") + class GetScrapCourseByUser { + + @Test + @DisplayName("정상 요청이면 200과 스크랩 목록을 반환한다") + void 정상_조회() throws Exception { + when(scrapService.getScrapCourseByUser(1L)) + .thenReturn(GetScrapCourseResponseDto.of(UserResponse.of(1L), Collections.emptyList())); + + mockMvc.perform(withAuth(get("/api/scrap/user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.userId").value(1)); + } + } +} diff --git a/src/test/java/org/runnect/server/user/controller/UserControllerTest.java b/src/test/java/org/runnect/server/user/controller/UserControllerTest.java new file mode 100644 index 0000000..4b24817 --- /dev/null +++ b/src/test/java/org/runnect/server/user/controller/UserControllerTest.java @@ -0,0 +1,168 @@ +package org.runnect.server.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.user.dto.response.DeleteUserResponseDto; +import org.runnect.server.user.dto.response.MyPageResponseDto; +import org.runnect.server.user.dto.response.UpdateUserNicknameResponseDto; +import org.runnect.server.user.dto.response.UserProfileResponseDto; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private UserService userService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth(B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + private RunnectUser buildUser(Long id, String nickname) { + RunnectUser user = RunnectUser.builder() + .nickname(nickname).socialId("social-" + id).email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO).build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Nested + @DisplayName("GET /api/user") + class GetMyPage { + + @Test + @DisplayName("정상 요청이면 200과 마이페이지 정보를 반환한다") + void 정상_조회() throws Exception { + RunnectUser user = buildUser(1L, "러너1"); + when(userService.getMyPage(1L)).thenReturn(MyPageResponseDto.of(user, 50)); + + mockMvc.perform(withAuth(get("/api/user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andExpect(jsonPath("$.data.user.nickname").value("러너1")); + } + + @Test + @DisplayName("존재하지 않는 유저면 404를 반환한다") + void 존재하지_않는_유저() throws Exception { + when(userService.getMyPage(1L)).thenThrow(new NotFoundUserException( + ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage())); + + mockMvc.perform(withAuth(get("/api/user"))) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("PATCH /api/user") + class UpdateUserNickname { + + @Test + @DisplayName("정상 요청이면 200과 변경된 닉네임을 반환한다") + void 정상_변경() throws Exception { + RunnectUser user = buildUser(1L, "새닉네임"); + when(userService.updateUserNickname(eq(1L), any())).thenReturn( + UpdateUserNicknameResponseDto.of(user, 50)); + + mockMvc.perform(withAuth(patch("/api/user")) + .contentType("application/json") + .content("{\"nickname\":\"새닉네임\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.nickname").value("새닉네임")); + } + + @Test + @DisplayName("닉네임이 빈 값이면 400을 반환한다") + void 닉네임_빈값() throws Exception { + mockMvc.perform(withAuth(patch("/api/user")) + .contentType("application/json") + .content("{\"nickname\":\"\"}")) + .andExpect(status().isBadRequest()); + + BDDMockito.verifyNoInteractions(userService); + } + } + + @Nested + @DisplayName("GET /api/user/{profileUserId}") + class GetUserProfile { + + @Test + @DisplayName("정상 요청이면 200과 프로필 정보를 반환한다") + void 정상_조회() throws Exception { + RunnectUser profileUser = buildUser(2L, "상대유저"); + UserProfileResponseDto response = UserProfileResponseDto.of( + UserProfileResponseDto.UserProfile.of(profileUser, 30), Collections.emptyList()); + when(userService.getUserProfile(2L, 1L)).thenReturn(response); + + mockMvc.perform(withAuth(get("/api/user/2"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.userId").value(2)) + .andExpect(jsonPath("$.data.user.nickname").value("상대유저")); + } + } + + @Nested + @DisplayName("DELETE /api/user") + class DeleteUser { + + @Test + @DisplayName("정상 요청이면 200과 삭제된 유저 id를 반환한다") + void 정상_삭제() throws Exception { + when(userService.deleteUser(1L, "apple-token")).thenReturn(DeleteUserResponseDto.of(1L)); + + mockMvc.perform(withAuth(delete("/api/user")).header("appleAccessToken", "apple-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedUserId").value(1)); + } + + @Test + @DisplayName("appleAccessToken 헤더가 없어도 400 없이 서비스에 null로 위임된다") + void 애플토큰_헤더_없음() throws Exception { + when(userService.deleteUser(1L, null)).thenReturn(DeleteUserResponseDto.of(1L)); + + mockMvc.perform(withAuth(delete("/api/user"))) + .andExpect(status().isOk()); + } + } +} diff --git a/src/test/java/org/runnect/server/user/controller/UserStampControllerTest.java b/src/test/java/org/runnect/server/user/controller/UserStampControllerTest.java new file mode 100644 index 0000000..16a1f4c --- /dev/null +++ b/src/test/java/org/runnect/server/user/controller/UserStampControllerTest.java @@ -0,0 +1,64 @@ +package org.runnect.server.user.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.slack.SlackApi; +import org.runnect.server.user.dto.response.GetUserStampsResponseDto; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.service.UserStampService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@WebMvcTest(UserStampController.class) +class UserStampControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private UserStampService userStampService; + @MockBean + private JwtService jwtService; + @MockBean + private SlackApi slackApi; + + @BeforeEach + void setUpAuth() { + BDDMockito.lenient().when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + BDDMockito.lenient().when(jwtService.getJwtContents("valid")).thenReturn("1"); + } + + private B withAuth(B builder) { + builder.header("accessToken", "valid").header("refreshToken", "valid"); + return builder; + } + + @Test + @DisplayName("GET /api/stamp/user - 정상 요청이면 200과 유저 스탬프 목록을 반환한다") + void 정상_조회() throws Exception { + RunnectUser user = RunnectUser.builder() + .nickname("러너1").socialId("social-1").email("user1@runnect.io") + .provider(SocialType.KAKAO).build(); + ReflectionTestUtils.setField(user, "id", 1L); + when(userStampService.findUserStamps(1L)).thenReturn(GetUserStampsResponseDto.from(user)); + + mockMvc.perform(withAuth(get("/api/stamp/user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andExpect(jsonPath("$.data.stamps").isEmpty()); + } +}