diff --git a/build.gradle b/build.gradle index 83107669..effb9872 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java index e84e1dc7..4b8c9e1c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.request.*; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.CreateCollectionImageResponse; @@ -17,11 +18,13 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.PresignResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse; import org.devkor.apu.saerok_server.domain.collection.application.CollectionCommandService; import org.devkor.apu.saerok_server.domain.collection.application.CollectionImageCommandService; import org.devkor.apu.saerok_server.domain.collection.application.CollectionQueryService; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.NearbyCollectionsMode; import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionWebMapper; import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; @@ -370,6 +373,54 @@ public void deleteCollectionImage( collectionImageCommandService.deleteCollectionImage(userId, collectionId, imageId); } + @PostMapping("/nearby/search") + @PermitAll + @Operation( + summary = "새 이름으로 주위 컬렉션 검색 (인증: optional)", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 현재 위치를 기준으로 새 한국어 이름에 검색어가 포함된 컬렉션을 조회합니다. + + - `radiusMeters`는 필수이며, 프론트 지도 반경을 미터 단위로 전달합니다. + - `radiusMeters`가 1.25km 미만이면 해당 반경을 한 번 검색한 뒤 1.25km로 확장합니다. + - 이후 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. + - 최대 반경에도 결과가 없으면 `noResults`가 true로 반환됩니다. + - 검색 결과는 기본 최대 60건이며, 반경 확장마다 최대 개수가 절반으로 줄고 기본 요청은 최소 10건으로 유지됩니다. + 클라이언트가 10건보다 작은 limit을 지정하면 그 값을 넘지 않습니다. + - 현재는 동정 완료된 새의 한국어 이름만 검색합니다. `tempBirdName`은 아직 검색에 포함하지 않습니다. + """, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = SearchNearbyCollectionsRequest.class)) + ), + responses = { + @ApiResponse( + responseCode = "200", description = "검색 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SearchNearbyCollectionsResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) + } + ) + public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody SearchNearbyCollectionsRequest request + ) { + Long userId = userPrincipal == null ? null : userPrincipal.getId(); + return collectionQueryService.searchNearbyCollectionsByBirdName( + new SearchNearbyCollectionsCommand( + userId, + request.getLatitude(), + request.getLongitude(), + request.getRadiusMeters(), + request.getQuery(), + request.getLimit() + ) + ); + } + @GetMapping("/nearby") @PermitAll @Operation( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java new file mode 100644 index 00000000..3ebde062 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java @@ -0,0 +1,43 @@ +package org.devkor.apu.saerok_server.domain.collection.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "새 이름으로 주위 컬렉션을 검색하는 요청 DTO") +public class SearchNearbyCollectionsRequest { + + @NotNull(message = "위도를 입력해주세요.") + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 해요.") + @DecimalMax(value = "90.0", message = "위도는 90 이하여야 해요.") + @Schema(description = "검색 중심 위도 (-90~90)", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) + private Double latitude; + + @NotNull(message = "경도를 입력해주세요.") + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 해요.") + @DecimalMax(value = "180.0", message = "경도는 180 이하여야 해요.") + @Schema(description = "검색 중심 경도 (-180~180)", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) + private Double longitude; + + @NotBlank(message = "query를 입력해주세요.") + @Schema(description = "새 이름 검색어", example = "까치", requiredMode = Schema.RequiredMode.REQUIRED) + private String query; + + @NotNull(message = "radiusMeters를 입력해주세요.") + @Positive(message = "radiusMeters는 0보다 커야 해요.") + @Schema(description = "최초 검색 반경 (m).", example = "10", requiredMode = Schema.RequiredMode.REQUIRED) + private Double radiusMeters; + + @Positive(message = "limit은 1 이상의 양수여야 해요.") + @Max(value = 60, message = "limit은 60 이하여야 해요.") + @Schema(description = "최초 검색 결과 최대 개수 (생략 시 60, 최대 60). 검색 반경이 늘어날수록 반환 개수도 줄어듭니다.", example = "60") + private Integer limit; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java new file mode 100644 index 00000000..50d010be --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java @@ -0,0 +1,22 @@ +package org.devkor.apu.saerok_server.domain.collection.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "새 이름으로 검색한 주위 컬렉션 조회 응답 DTO") +public class SearchNearbyCollectionsResponse { + + @Schema(description = "검색 결과 컬렉션 목록") + private List items; + + @Schema( + description = "최대 검색 반경까지 확장했지만 검색 결과가 없는지 여부", + example = "false" + ) + private boolean noResults; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index 57f320d6..81280608 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -4,8 +4,10 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionEditDataResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetCollectionEditDataCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.application.strategy.NearbyCollectionsStrategy; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; @@ -39,6 +41,12 @@ @Transactional(readOnly = true) public class CollectionQueryService { + private static final double BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS = 1_250; + private static final double MAX_BIRD_NAME_SEARCH_RADIUS_METERS = 150_000; + private static final int DEFAULT_BIRD_NAME_SEARCH_LIMIT = 60; + private static final int MAX_BIRD_NAME_SEARCH_LIMIT = 60; + private static final int MIN_BIRD_NAME_SEARCH_LIMIT = 10; + private final CollectionRepository collectionRepository; private final CollectionImageRepository collectionImageRepository; private final CollectionLikeRepository collectionLikeRepository; @@ -185,6 +193,69 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom return response; } + response.setItems(toNearbyCollectionItems(collections, command.userId())); + return response; + } + + public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(SearchNearbyCollectionsCommand command) { + if (command.userId() != null) { + userRepository.findById(command.userId()).orElseThrow(() -> new NotFoundException("유효하지 않은 사용자 id예요")); + } + + // 1) 근거리 컬렉션 조회 (전략 기반) + Point refPoint = PointFactory.create(command.latitude(), command.longitude()); + double radiusMeters = Math.min(command.initialRadiusMeters(), MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + int attempt = 0; + + while (true) { + List collections = collectionRepository.findNearbyByBirdName( + refPoint, + radiusMeters, + command.query().trim(), + command.userId(), + calculateBirdNameSearchLimit(command.limit(), attempt) + ); + + if (!collections.isEmpty()) { + SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse(); + response.setItems(toNearbyCollectionItems(collections, command.userId())); + response.setNoResults(false); + return response; + } + + if (radiusMeters >= MAX_BIRD_NAME_SEARCH_RADIUS_METERS) { + SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse(); + response.setItems(List.of()); + response.setNoResults(true); + return response; + } + + radiusMeters = nextBirdNameSearchRadius(radiusMeters); + attempt++; + } + } + + private double nextBirdNameSearchRadius(double currentRadiusMeters) { + if (currentRadiusMeters < BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS) { + return BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS; + } + return Math.min(currentRadiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + } + + private int calculateBirdNameSearchLimit(Integer requestedLimit, int attempt) { + int initialLimit = Math.min( + requestedLimit == null ? DEFAULT_BIRD_NAME_SEARCH_LIMIT : requestedLimit, + MAX_BIRD_NAME_SEARCH_LIMIT + ); + int minimumLimit = Math.min(initialLimit, MIN_BIRD_NAME_SEARCH_LIMIT); + return Math.max(minimumLimit, initialLimit / (1 << attempt)); + } + + private List toNearbyCollectionItems( + List collections, + Long userId + ) { + // 2) 연관(작성자/새) 엔티티를 미리 불러와(prefetch) LAZY N+1 차단 List collectionIds = collections.stream().map(UserBirdCollection::getId).toList(); collectionRepository.prefetchUserAndBirdByIds(collectionIds); @@ -198,8 +269,8 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom Map commentCounts = collectionCommentRepository.countByCollectionIds(collectionIds); // 5) 내가 좋아요 눌렀는지 배치 조회 - Map myLikeMap = (command.userId() != null) - ? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(command.userId(), collectionIds) + Map myLikeMap = (userId != null) + ? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(userId, collectionIds) : new LinkedHashMap<>(); // 6) 작성자 프로필 이미지 배치 조회 @@ -212,10 +283,10 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom .map(c -> { long likeCount = likeCounts.getOrDefault(c.getId(), 0L); long commentCount = commentCounts.getOrDefault(c.getId(), 0L); - boolean isLikedByMe = command.userId() != null && myLikeMap.getOrDefault(c.getId(), false); + boolean isLikedByMe = userId != null && myLikeMap.getOrDefault(c.getId(), false); String userProfileImageUrl = profileImageMap.get(c.getUser().getId()); String thumbnailProfileImageUrl = thumbnailProfileImageMap.get(c.getUser().getId()); - boolean isMine = command.userId() != null && command.userId().equals(c.getUser().getId()); + boolean isMine = userId != null && userId.equals(c.getUser().getId()); return collectionWebMapper.toGetNearbyCollectionsResponseItem( c, @@ -231,7 +302,6 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom }) .toList(); - response.setItems(items); - return response; + return items; } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java new file mode 100644 index 00000000..fcf0a496 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.collection.application.dto; + +public record SearchNearbyCollectionsCommand( + Long userId, + Double latitude, + Double longitude, + Double initialRadiusMeters, + String query, + Integer limit +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java index e5f1c80a..130db7f0 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java @@ -111,6 +111,53 @@ ORDER BY ST_Distance( return query.getResultList(); } + /** + * 중심점 반경 안에서 새 한국어 이름으로 컬렉션을 부분 검색한다. + * tempBirdName은 아직 사용하지 않으므로 현재 검색 대상에 포함하지 않는다. + */ + @SuppressWarnings("unchecked") + public List findNearbyByBirdName( + Point ref, + double radiusMeters, + String query, + Long userId, + Integer limit + ) { + String sql = """ + SELECT c.* + FROM user_bird_collection c + JOIN bird b ON b.id = c.bird_id + WHERE ST_DWithin( + c.location::geography, + CAST(:refPoint AS geography), + :radius + ) + AND b.korean_name LIKE :query + AND ( + c.access_level = 'PUBLIC' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) + AND ( + b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) + ORDER BY ST_Distance( + c.location::geography, + CAST(:refPoint AS geography) + ) + """; + + var nativeQuery = em.createNativeQuery(sql, UserBirdCollection.class) + .setParameter("refPoint", ref) + .setParameter("radius", radiusMeters) + .setParameter("query", "%" + query + "%") + .setParameter("userId", userId); + if (limit != null) { + nativeQuery.setMaxResults(limit); + } + return nativeQuery.getResultList(); + } + public long countNearbyCandidates(Point ref, double radiusMeters, Long userId, boolean isMineOnly) { if (isMineOnly && userId != null) { diff --git a/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql b/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql new file mode 100644 index 00000000..ba16b1a6 --- /dev/null +++ b/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_user_collection_location_geography + ON user_bird_collection USING GIST ((location::geography)); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java index 03f6cc30..8f0f792a 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java @@ -2,6 +2,8 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; @@ -31,6 +33,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @@ -257,4 +262,86 @@ void getMyCollections_includesCanSuggestBirdId() throws IllegalAccessException { .extracting(MyCollectionsResponse.Item::canSuggestBirdId) .containsExactly(true, false); } + + @Test + @DisplayName("새 이름 주변 검색은 반경 확장마다 반환 제한을 줄이고 최대 150km에서 결과 없음 플래그를 반환한다") + void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResults() { + SearchNearbyCollectionsCommand command = new SearchNearbyCollectionsCommand( + null, + 37.5665, + 126.9780, + 1_250.0, + "까치", + null + ); + given(collectionRepository.findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(60))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(2_500.0), eq("까치"), eq(null), eq(30))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(15))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + + SearchNearbyCollectionsResponse response = collectionQueryService.searchNearbyCollectionsByBirdName(command); + + assertTrue(response.isNoResults()); + assertTrue(response.getItems().isEmpty()); + + var inOrder = inOrder(collectionRepository); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(60)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(2_500.0), eq("까치"), eq(null), eq(30)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10)); + } + + @Test + @DisplayName("새 이름 주변 검색은 확장된 반경에서 결과를 찾으면 해당 결과를 반환한다") + void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansion() throws IllegalAccessException { + User owner = new User(); + userIdField.set(owner, 10L); + UserBirdCollection collection = new UserBirdCollection(); + collectionIdField.set(collection, 1L); + collectionUserField.set(collection, owner); + + SearchNearbyCollectionsCommand command = new SearchNearbyCollectionsCommand( + null, + 37.5665, + 126.9780, + 10.0, + "까치", + 5 + ); + given(collectionRepository.findNearbyByBirdName(any(), eq(10.0), eq("까치"), eq(null), eq(5))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(5))) + .willReturn(List.of(collection)); + given(collectionImageUrlService.getPrimaryImageUrlsFor(List.of(collection))).willReturn(Map.of()); + given(collectionImageUrlService.getPrimaryImageThumbnailUrlsFor(List.of(collection))).willReturn(Map.of()); + given(collectionLikeRepository.countLikesByCollectionIds(List.of(1L))).willReturn(Map.of()); + given(collectionCommentRepository.countByCollectionIds(List.of(1L))).willReturn(Map.of()); + given(userProfileImageUrlService.getProfileImageUrlsFor(List.of(owner))).willReturn(Map.of()); + given(userProfileImageUrlService.getProfileThumbnailImageUrlsFor(List.of(owner))).willReturn(Map.of()); + + SearchNearbyCollectionsResponse response = collectionQueryService.searchNearbyCollectionsByBirdName(command); + + assertFalse(response.isNoResults()); + assertEquals(1, response.getItems().size()); + + var inOrder = inOrder(collectionRepository); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(5)); + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java index c0d23ce5..a1e0e1d4 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java @@ -176,6 +176,62 @@ void findNearby_withLimit_appliesMaxResults() throws Exception { assertEquals(2, result.size()); } + @Test + @DisplayName("새 이름 주변 검색은 반경 안의 부분 일치 컬렉션만 반환한다") + void findNearbyByBirdName_returnsOnlyMatchingCollectionsWithinRadius() throws Exception { + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + Point far = gf.createPoint(new Coordinate(127.1000, 37.5665)); + + Bird matchingBird = new BirdBuilder(em).korName("까치").build(); + Bird otherBird = new BirdBuilder(em).korName("직박구리").build(); + + UserBirdCollection matchingCollection = newCollection(owner, matchingBird, near, AccessLevelType.PUBLIC); + newCollection(owner, otherBird, near, AccessLevelType.PUBLIC); + newCollection(owner, matchingBird, far, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + List result = collectionRepository.findNearbyByBirdName( + ref, + 1_000, + "까", + null, + null + ); + + assertEquals(1, result.size()); + assertEquals(matchingCollection.getId(), result.getFirst().getId()); + } + + @Test + @DisplayName("익명 새 이름 주변 검색은 공개 컬렉션만 반환한다") + void findNearbyByBirdName_anonymous_returnsOnlyPublicCollections() throws Exception { + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + Bird magpie = new BirdBuilder(em).korName("까치").build(); + + UserBirdCollection publicCollection = newCollection(owner, magpie, near, AccessLevelType.PUBLIC); + newCollection(owner, magpie, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + List result = collectionRepository.findNearbyByBirdName( + ref, + 1_000, + "까치", + null, + null + ); + + assertEquals(1, result.size()); + assertEquals(publicCollection.getId(), result.getFirst().getId()); + } + @Test @DisplayName("주변 조회는 보호등급 새 컬렉션을 결과에서 제외한다") void findNearby_excludesProtectedBirdCollections() throws Exception {