diff --git a/src/main/java/com/wayble/server/auth/resolver/CurrentUser.java b/src/main/java/com/wayble/server/auth/resolver/CurrentUser.java new file mode 100644 index 00000000..ff32e4b3 --- /dev/null +++ b/src/main/java/com/wayble/server/auth/resolver/CurrentUser.java @@ -0,0 +1,8 @@ +package com.wayble.server.auth.resolver; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface CurrentUser {} diff --git a/src/main/java/com/wayble/server/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/wayble/server/auth/resolver/CurrentUserArgumentResolver.java new file mode 100644 index 00000000..091bad77 --- /dev/null +++ b/src/main/java/com/wayble/server/auth/resolver/CurrentUserArgumentResolver.java @@ -0,0 +1,45 @@ +package com.wayble.server.auth.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && Long.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mav, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + throw new IllegalStateException("인증 정보가 없습니다."); + } + + Object principal = auth.getPrincipal(); + if (principal instanceof Long l) return l; + if (principal instanceof Integer i) return i.longValue(); + if (principal instanceof String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException ignored) {} + } + try { + return Long.parseLong(auth.getName()); + } catch (Exception e) { + throw new IllegalStateException("userId를 추출할 수 없습니다.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/wayble/server/common/config/WebMvcConfig.java b/src/main/java/com/wayble/server/common/config/WebMvcConfig.java new file mode 100644 index 00000000..cb514877 --- /dev/null +++ b/src/main/java/com/wayble/server/common/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.wayble.server.common.config; + +import com.wayble.server.auth.resolver.CurrentUserArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } +} \ No newline at end of file diff --git a/src/main/java/com/wayble/server/review/controller/ReviewController.java b/src/main/java/com/wayble/server/review/controller/ReviewController.java index 5c460247..1661a7c9 100644 --- a/src/main/java/com/wayble/server/review/controller/ReviewController.java +++ b/src/main/java/com/wayble/server/review/controller/ReviewController.java @@ -1,5 +1,6 @@ package com.wayble.server.review.controller; +import com.wayble.server.auth.resolver.CurrentUser; import com.wayble.server.common.response.CommonResponse; import com.wayble.server.review.dto.ReviewRegisterDto; import com.wayble.server.review.dto.ReviewResponseDto; @@ -11,8 +12,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; @@ -38,9 +37,9 @@ public class ReviewController { }) public CommonResponse registerReview( @PathVariable Long waybleZoneId, + @CurrentUser Long userId, @RequestBody @Valid ReviewRegisterDto dto ) { - Long userId = extractUserId(); // 토큰에서 유저 ID 추출 reviewService.registerReview(waybleZoneId, userId, dto); return CommonResponse.success("리뷰가 등록되었습니다."); } @@ -57,26 +56,4 @@ public CommonResponse> getReviews( ) { return CommonResponse.success(reviewService.getReviews(waybleZoneId, sort)); } - - private Long extractUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null) { throw new IllegalStateException("인증 정보가 없습니다."); } - - Object p = auth.getPrincipal(); - if (p instanceof Long l) { return l; } - if (p instanceof Integer i) { return i.longValue(); } - if (p instanceof String s) { - try { - return Long.parseLong(s); - } catch (NumberFormatException e) { - throw new IllegalStateException("principal에서 userId 파싱 실패"); - } - } - try { - return Long.parseLong(auth.getName()); - } - catch (Exception e) { - throw new IllegalStateException("인증 정보에서 userId를 추출할 수 없습니다."); - } - } } \ No newline at end of file diff --git a/src/main/java/com/wayble/server/user/controller/UserPlaceController.java b/src/main/java/com/wayble/server/user/controller/UserPlaceController.java index ea1e6a64..5dc568a7 100644 --- a/src/main/java/com/wayble/server/user/controller/UserPlaceController.java +++ b/src/main/java/com/wayble/server/user/controller/UserPlaceController.java @@ -1,6 +1,7 @@ package com.wayble.server.user.controller; +import com.wayble.server.auth.resolver.CurrentUser; import com.wayble.server.common.response.CommonResponse; import com.wayble.server.user.dto.UserPlaceRemoveRequestDto; import com.wayble.server.user.dto.UserPlaceRequestDto; @@ -13,8 +14,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -35,9 +34,9 @@ public class UserPlaceController { @ApiResponse(responseCode = "403", description = "권한이 없습니다.") }) public CommonResponse saveUserPlace( + @CurrentUser Long userId, @RequestBody @Valid UserPlaceRequestDto request ) { - Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); userPlaceService.saveUserPlace(userId, request); // userId 파라미터로 넘김 return CommonResponse.success("장소가 저장되었습니다."); } @@ -50,9 +49,9 @@ public CommonResponse saveUserPlace( @ApiResponse(responseCode = "403", description = "권한이 없습니다.") }) public CommonResponse> getMyPlaceSummaries( + @CurrentUser Long userId, @RequestParam(name = "sort", defaultValue = "latest") String sort ) { - Long userId = extractUserId(); List summaries = userPlaceService.getMyPlaceSummaries(userId, sort); return CommonResponse.success(summaries); } @@ -67,11 +66,11 @@ public CommonResponse> getMyPlaceSummaries( @ApiResponse(responseCode = "403", description = "권한이 없습니다.") }) public CommonResponse> getZonesInPlace( + @CurrentUser Long userId, @RequestParam Long placeId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer size ) { - Long userId = extractUserId(); Page zones = userPlaceService.getZonesInPlace(userId, placeId, page, size); return CommonResponse.success(zones); } @@ -86,37 +85,11 @@ public CommonResponse> getZonesInPlace( @ApiResponse(responseCode = "404", description = "장소 또는 매핑 정보를 찾을 수 없음"), @ApiResponse(responseCode = "403", description = "권한이 없습니다.") }) - public CommonResponse removeZoneFromPlace(@RequestBody @Valid UserPlaceRemoveRequestDto request) { - Long userId = extractUserId(); + public CommonResponse removeZoneFromPlace( + @CurrentUser Long userId, + @RequestBody @Valid UserPlaceRemoveRequestDto request + ) { userPlaceService.removeZoneFromPlace(userId, request.placeId(), request.waybleZoneId()); return CommonResponse.success("제거되었습니다."); } - - - // SecurityContext에서 userId 추출하는 로직 - private Long extractUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null) { - throw new IllegalStateException("인증 정보가 없습니다."); - } - - Object p = auth.getPrincipal(); - - if (p instanceof Long l) { return l; } - if (p instanceof Integer i) { return i.longValue(); } - if (p instanceof String s) { - try { - return Long.parseLong(s); - } catch (NumberFormatException e) { - // 숫자 변환 실패 시 출력 - System.err.println("Principal 문자열을 Long으로 변환할 수 없습니다: " + s); - } - } - - try { - return Long.parseLong(auth.getName()); - } catch (Exception e) { - throw new IllegalStateException("인증 정보에서 userId를 추출할 수 없습니다. Principal=" + p, e); - } - } } diff --git a/src/main/java/com/wayble/server/wayblezone/dto/WaybleZoneDetailResponseDto.java b/src/main/java/com/wayble/server/wayblezone/dto/WaybleZoneDetailResponseDto.java index 93d0bc20..3f252816 100644 --- a/src/main/java/com/wayble/server/wayblezone/dto/WaybleZoneDetailResponseDto.java +++ b/src/main/java/com/wayble/server/wayblezone/dto/WaybleZoneDetailResponseDto.java @@ -18,7 +18,9 @@ public record WaybleZoneDetailResponseDto( String imageUrl, FacilityDto facilities, Map businessHours, - List photos + List photos, + Double latitude, + Double longitude ) { @Builder public record BusinessHourDto( diff --git a/src/main/java/com/wayble/server/wayblezone/service/WaybleZoneService.java b/src/main/java/com/wayble/server/wayblezone/service/WaybleZoneService.java index 0707bdc5..490e93b8 100644 --- a/src/main/java/com/wayble/server/wayblezone/service/WaybleZoneService.java +++ b/src/main/java/com/wayble/server/wayblezone/service/WaybleZoneService.java @@ -83,6 +83,9 @@ public WaybleZoneDetailResponseDto getWaybleZoneDetail(Long waybleZoneId) { .build()); } + Double lat = zone.getAddress() != null ? zone.getAddress().getLatitude() : null; + Double lon = zone.getAddress() != null ? zone.getAddress().getLongitude() : null; + return WaybleZoneDetailResponseDto.builder() .waybleZoneId(zone.getId()) .name(zone.getZoneName()) @@ -102,6 +105,8 @@ public WaybleZoneDetailResponseDto getWaybleZoneDetail(Long waybleZoneId) { .floorInfo(f.getFloorInfo()) .build()) .businessHours(businessHours) + .latitude(lat) + .longitude(lon) .build(); } diff --git a/src/test/java/com/wayble/server/review/service/ReviewServiceTest.java b/src/test/java/com/wayble/server/review/service/ReviewServiceTest.java new file mode 100644 index 00000000..41fd9b8d --- /dev/null +++ b/src/test/java/com/wayble/server/review/service/ReviewServiceTest.java @@ -0,0 +1,106 @@ +package com.wayble.server.review.service; + +import com.wayble.server.common.exception.ApplicationException; +import com.wayble.server.review.dto.ReviewRegisterDto; +import com.wayble.server.review.entity.Review; +import com.wayble.server.review.entity.ReviewImage; +import com.wayble.server.review.repository.ReviewImageRepository; +import com.wayble.server.review.repository.ReviewRepository; +import com.wayble.server.user.entity.User; +import com.wayble.server.user.repository.UserRepository; +import com.wayble.server.wayblezone.entity.WaybleZone; +import com.wayble.server.wayblezone.repository.WaybleZoneRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewServiceTest { + + private final ReviewRepository reviewRepository = mock(ReviewRepository.class); + private final ReviewImageRepository reviewImageRepository = mock(ReviewImageRepository.class); + private final WaybleZoneRepository waybleZoneRepository = mock(WaybleZoneRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + + private final ReviewService sut = + new ReviewService(reviewRepository, reviewImageRepository, waybleZoneRepository, userRepository); + + @Test + @DisplayName("리뷰 등록 성공 - 평점 갱신, 카운트 증가, 이미지 저장") + void t1() { + Long zoneId = 10L; + Long userId = 5L; + + WaybleZone zone = mock(WaybleZone.class); + when(waybleZoneRepository.findById(zoneId)).thenReturn(Optional.of(zone)); + when(zone.getRating()).thenReturn(4.0); + when(zone.getReviewCount()).thenReturn(1L); + + User user = mock(User.class); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + ReviewRegisterDto dto = new ReviewRegisterDto( + "뷰가 좋고 접근성이 좋아요", + 5.0, + LocalDate.of(2025, 6, 30), + List.of("주차장 있음", "장애인 화장실 있음"), + List.of("https://image.url/review1.jpg") + ); + + doAnswer(invocation -> invocation.getArgument(0)) + .when(reviewRepository).save(any(Review.class)); + + sut.registerReview(zoneId, userId, dto); + + + verify(reviewRepository, times(1)).save(any(Review.class)); + + ArgumentCaptor ratingCaptor = ArgumentCaptor.forClass(Double.class); + verify(zone, times(1)).updateRating(ratingCaptor.capture()); + + assertEquals(4.5, ratingCaptor.getValue(), 1e-6); + + verify(zone, times(1)).addReviewCount(1L); + verify(reviewImageRepository, times(1)).save(any(ReviewImage.class)); + verify(waybleZoneRepository, times(1)).save(zone); + } + + @Test + @DisplayName("리뷰 등록 실패 - 웨이블존 없음") + void t2() { + Long zoneId = 99L; + Long userId = 1L; + when(waybleZoneRepository.findById(zoneId)).thenReturn(Optional.empty()); + + ReviewRegisterDto dto = new ReviewRegisterDto( + "좋아요", 4.0, LocalDate.now(), List.of("주차장"), List.of() + ); + + assertThrows(ApplicationException.class, + () -> sut.registerReview(zoneId, userId, dto)); + } + + @Test + @DisplayName("리뷰 등록 실패 - 유저 없음") + void t3() { + Long zoneId = 10L; + Long userId = 999L; + + WaybleZone zone = mock(WaybleZone.class); + when(waybleZoneRepository.findById(zoneId)).thenReturn(Optional.of(zone)); + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + ReviewRegisterDto dto = new ReviewRegisterDto( + "좋아요", 4.0, LocalDate.now(), List.of("주차장"), List.of() + ); + + assertThrows(ApplicationException.class, + () -> sut.registerReview(zoneId, userId, dto)); + } +} \ No newline at end of file diff --git a/src/test/java/com/wayble/server/wayblezone/controller/WaybleZoneControllerTest.java b/src/test/java/com/wayble/server/wayblezone/controller/WaybleZoneControllerTest.java new file mode 100644 index 00000000..407d1946 --- /dev/null +++ b/src/test/java/com/wayble/server/wayblezone/controller/WaybleZoneControllerTest.java @@ -0,0 +1,94 @@ +package com.wayble.server.wayblezone.controller; + +import com.wayble.server.common.dto.FacilityDto; +import com.wayble.server.common.response.CommonResponse; +import com.wayble.server.wayblezone.dto.WaybleZoneListResponseDto; +import com.wayble.server.wayblezone.service.WaybleZoneService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +public class WaybleZoneControllerTest { + @Mock + private WaybleZoneService waybleZoneService; + + @InjectMocks + private WaybleZoneController waybleZoneController; + + @Test + @DisplayName("웨이블존 목록 조회 - 성공") + void t1() { + FacilityDto facilities = FacilityDto.builder() + .hasSlope(true) + .hasNoDoorStep(true) + .hasElevator(false) + .hasTableSeat(true) + .hasDisabledToilet(false) + .floorInfo("1층") + .build(); + + WaybleZoneListResponseDto item1 = WaybleZoneListResponseDto.builder() + .waybleZoneId(1L) + .name("스타벅스 강남점") + .category("카페") + .address("서울 강남구 강남대로 446 (역삼동)") + .rating(4.5) + .reviewCount(23L) + .imageUrl("https://image.url/wayble1.jpg") + .contactNumber("02-558-2161") + .facilities(facilities) + .build(); + + WaybleZoneListResponseDto item2 = WaybleZoneListResponseDto.builder() + .waybleZoneId(2L) + .name("메가엠지씨커피 강남중앙점") + .category("카페") + .address("서울특별시 서초구 서초대로77길 35, 1층 102호 (서초동)") + .rating(4.2) + .reviewCount(520L) + .imageUrl("https://image.url/wayble1.jpg") + .contactNumber("02-533-0656") + .facilities(facilities) + .build(); + + when(waybleZoneService.getWaybleZones("서초구", "카페")) + .thenReturn(List.of(item1, item2)); + + CommonResponse> response = waybleZoneController.getWaybleZoneList("서초구", "카페"); + + assertNotNull(response); + assertNotNull(response.getData()); + assertEquals(2, response.getData().size()); + + WaybleZoneListResponseDto first = response.getData().get(0); + assertEquals(1L, first.waybleZoneId()); + assertEquals("스타벅스 강남점", first.name()); + assertEquals("카페", first.category()); + assertTrue(first.address().contains("강남대로")); + assertEquals(4.5, first.rating(), 1e-6); + assertEquals(23L, first.reviewCount()); + assertEquals("https://image.url/wayble1.jpg", first.imageUrl()); + assertEquals("02-558-2161", first.contactNumber()); + assertNotNull(first.facilities()); + assertTrue(first.facilities().hasSlope()); + assertTrue(first.facilities().hasNoDoorStep()); + assertFalse(first.facilities().hasElevator()); + assertTrue(first.facilities().hasTableSeat()); + assertFalse(first.facilities().hasDisabledToilet()); + assertEquals("1층", first.facilities().floorInfo()); + + WaybleZoneListResponseDto second = response.getData().get(1); + assertEquals(2L, second.waybleZoneId()); + assertEquals(520L, second.reviewCount()); + } +}