Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] 2.1.0 배포 #627

Merged
merged 4 commits into from
Nov 18, 2023
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
[BE] Feature/#614 클러스터링 기능 구현 (#618)
* feat : Clustering API 뼈대 코드 작성

* feat : Clustering API 기능 구현 완료

* feat : Clustering API 메서드 분리

* refactor : 변수명 수정

* refactor : 좌표간 거리를 하버사인 공식으로 대체

* refactor : 대표 좌표를 결정하는 방식을 평균 좌표 -> 대표 핀의 좌표 로 변경

* refactor : 인접한 Pin 들을 돌면서 본인이 집합의 대표 핀이 아니라면, break 하여 최적화

* test : Cluster Test 추가

* test : Clusters 테스트 추가

* refactor : 대표 핀을 List 의 첫번째 원소로 둘 수 있도록 로직 수정

* test : Cluster Test 에 List 의 첫번째 인자가 대표 핀인지를 확인하는 로직 추가

* test : TopicQueryService Test 도 작성

* test : API 테스트 작성

* test : API 테스트 작성

* test : Clusters 예외 테스트 작성

* fix : Clusters Lombok 제거

* fix : UsingRecursiveComparison 삭제

* refactor : 필요없는 static import 제거

* refactor : 메서드, 변수 명 수정

* refactor : Clusters ParentOfPins 를 ArrayList 로 바꾸고 API URI 수정
kpeel5839 authored Nov 7, 2023
commit d5aea616cbf2ba59f6f4409a1adac3c34fc24819
3 changes: 3 additions & 0 deletions backend/src/docs/asciidoc/topic.adoc
Original file line number Diff line number Diff line change
@@ -43,3 +43,6 @@ operation::topic-controller-test/update[snippets='http-request,http-response']
=== 토픽 이미지 수정

operation::topic-controller-test/update-image[snippets='http-request,http-response']

=== 토픽 클러스터링 조회
operation::topic-controller-test/get-clusters-of-pins[snippets='http-request,http-response']
Original file line number Diff line number Diff line change
@@ -21,6 +21,8 @@ public class Coordinate {
private static final double LATITUDE_UPPER_BOUND = 43;
private static final double LONGITUDE_LOWER_BOUND = 124;
private static final double LONGITUDE_UPPER_BOUND = 132;
private static final double EARTH_RADIUS = 6371.0;
private static final int CONVERT_TO_METER = 1000;

/*
* 4326은 데이터베이스에서 사용하는 여러 SRID 값 중, 일반적인 GPS기반의 위/경도 좌표를 저장할 때 쓰이는 값입니다.
@@ -34,7 +36,6 @@ private Coordinate(Point point) {
this.coordinate = point;
}


public static Coordinate of(double latitude, double longitude) {
validateRange(latitude, longitude);

@@ -62,4 +63,13 @@ public double getLongitude() {
return coordinate.getX();
}

public double calculateDistance(Coordinate coordinate) {
return Math.acos(
Math.sin(Math.toRadians(coordinate.getLatitude())) * Math.sin(Math.toRadians(this.getLatitude()))
+ (Math.cos(Math.toRadians(coordinate.getLatitude())) * Math.cos(
Math.toRadians(this.getLatitude())) * Math.cos(
Math.toRadians(coordinate.getLongitude() - this.getLongitude())))
) * EARTH_RADIUS * CONVERT_TO_METER;
}

}
Original file line number Diff line number Diff line change
@@ -8,8 +8,11 @@
import com.mapbefine.mapbefine.bookmark.domain.BookmarkRepository;
import com.mapbefine.mapbefine.member.domain.Member;
import com.mapbefine.mapbefine.member.domain.MemberRepository;
import com.mapbefine.mapbefine.pin.domain.Pin;
import com.mapbefine.mapbefine.topic.domain.Clusters;
import com.mapbefine.mapbefine.topic.domain.Topic;
import com.mapbefine.mapbefine.topic.domain.TopicRepository;
import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse;
import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse;
import com.mapbefine.mapbefine.topic.dto.response.TopicResponse;
import com.mapbefine.mapbefine.topic.exception.TopicException.TopicForbiddenException;
@@ -241,4 +244,24 @@ private List<TopicResponse> getUserBestTopicResponse(AuthMember authMember) {
)).toList();
}

public List<ClusterResponse> findClustersPinsByIds(
AuthMember authMember,
List<Long> topicIds,
Double imageDiameter
) {
List<Topic> topics = topicRepository.findByIdIn(topicIds);
topics.forEach(topic -> validateReadableTopic(authMember, topic));

List<Pin> allPins = topics.stream()
.map(Topic::getPins)
.flatMap(List::stream)
.toList();

return Clusters.from(allPins, imageDiameter)
.getClusters()
.stream()
.map(ClusterResponse::from)
.toList();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.mapbefine.mapbefine.topic.domain;

import com.mapbefine.mapbefine.pin.domain.Pin;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import lombok.Getter;

@Getter
public class Cluster {

private final double latitude;
private final double longitude;
private final List<Pin> pins;

private Cluster(double latitude, double longitude, List<Pin> pins) {
this.latitude = latitude;
this.longitude = longitude;
this.pins = pins;
}

public static Cluster from(Pin representPin, List<Pin> pins) {
return new Cluster(representPin.getLatitude(), representPin.getLongitude(), rearrangePins(representPin, pins));
}

private static List<Pin> rearrangePins(Pin representPin, List<Pin> pins) {
List<Pin> arrangePins = new ArrayList<>(List.of(representPin));

pins.stream()
.filter(pin -> !Objects.equals(representPin, pin))
.forEach(arrangePins::add);

return arrangePins;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.mapbefine.mapbefine.topic.domain;

import com.mapbefine.mapbefine.location.domain.Coordinate;
import com.mapbefine.mapbefine.pin.domain.Pin;
import com.mapbefine.mapbefine.topic.exception.TopicErrorCode;
import com.mapbefine.mapbefine.topic.exception.TopicException.TopicBadRequestException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.Getter;

@Getter
public class Clusters {

private final List<Cluster> clusters;

private Clusters(List<Cluster> clusters) {
this.clusters = clusters;
}

public static Clusters from(List<Pin> pins, Double diameterInMeter) {
validateDiameter(diameterInMeter);
List<Cluster> clusters = executeCluster(pins, diameterInMeter);

return new Clusters(clusters);
}

private static void validateDiameter(Double diameterInMeter) {
if (Objects.nonNull(diameterInMeter)) {
return;
}

throw new TopicBadRequestException(TopicErrorCode.ILLEGAL_DIAMETER_NULL);
}

private static List<Cluster> executeCluster(List<Pin> pins, double diameterInMeter) {
List<Integer> parentOfPins = getParentOfPins(pins, diameterInMeter);

return getClustersByParentOfPins(pins, parentOfPins);
}

private static List<Integer> getParentOfPins(List<Pin> pins, double diameterInMeter) {
List<Integer> parentOfPins = getInitializeParentOfPins(pins.size());

for (int i = 0; i < pins.size(); i++) {
for (int j = 0; j < pins.size(); j++) {
if (isNotRepresentPin(parentOfPins, i)) {
break;
}

if (i == j) {
continue;
}

int firstParentPinIndex = findParentOfSet(parentOfPins, i);
int secondParentPinIndex = findParentOfSet(parentOfPins, j);

if (isReachTwoPin(pins.get(firstParentPinIndex), pins.get(secondParentPinIndex), diameterInMeter)) {
union(parentOfPins, firstParentPinIndex, secondParentPinIndex, pins);
}
}
}

return parentOfPins;
}

private static boolean isNotRepresentPin(List<Integer> parentOfPins, int i) {
return parentOfPins.get(i) != i;
}

private static List<Integer> getInitializeParentOfPins(int pinsSize) {
List<Integer> parentOfPins = new ArrayList<>();

for (int i = 0; i < pinsSize; i++) {
parentOfPins.add(i);
}

return parentOfPins;
}

private static boolean isReachTwoPin(Pin firstPin, Pin secondPin, double diameterInMeter) {
Coordinate firstPinCoordinate = firstPin.getLocation().getCoordinate();
Coordinate secondPinCoordinate = secondPin.getLocation().getCoordinate();

return firstPinCoordinate.calculateDistance(secondPinCoordinate) <= diameterInMeter;
}

private static int findParentOfSet(List<Integer> parentOfPins, int pinIndex) {
if (parentOfPins.get(pinIndex) == pinIndex) {
return pinIndex;
}

parentOfPins.set(pinIndex, findParentOfSet(parentOfPins, parentOfPins.get(pinIndex)));
return parentOfPins.get(pinIndex);
}

private static void union(List<Integer> parentOfPins, int firstPinIndex, int secondPinIndex, List<Pin> pins) {
if (firstPinIndex == secondPinIndex) {
return;
}
Pin firstPin = pins.get(firstPinIndex);
Pin secondPin = pins.get(secondPinIndex);
if (isFirstPinOnLeft(firstPin, secondPin)) {
parentOfPins.set(secondPinIndex, firstPinIndex);
return;
}
parentOfPins.set(firstPinIndex, secondPinIndex);
}

private static boolean isFirstPinOnLeft(Pin firstPin, Pin secondPin) {
Coordinate firstPinCoordinate = firstPin.getLocation().getCoordinate();
Coordinate secondPinCoordinate = secondPin.getLocation().getCoordinate();

return firstPinCoordinate.getLongitude() < secondPinCoordinate.getLongitude();
}

private static List<Cluster> getClustersByParentOfPins(List<Pin> pins, List<Integer> parentOfPins) {
Map<Pin, List<Pin>> clusters = new HashMap<>();

for (int pinIndex = 0; pinIndex < pins.size(); pinIndex++) {
int parentPinIndex = findParentOfSet(parentOfPins, pinIndex);
Pin parentPin = pins.get(parentPinIndex);
clusters.computeIfAbsent(parentPin, ignored -> new ArrayList<>());
clusters.get(parentPin).add(pins.get(pinIndex));
}

return clusters.entrySet()
.stream()
.map(clustersEntry -> Cluster.from(clustersEntry.getKey(), clustersEntry.getValue()))
.toList();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.mapbefine.mapbefine.topic.dto.response;

import com.mapbefine.mapbefine.topic.domain.Cluster;
import java.util.List;

public record ClusterResponse(
double latitude,
double longitude,
List<RenderPinResponse> pins
) {

public static ClusterResponse from(Cluster cluster) {
return new ClusterResponse(
cluster.getLatitude(),
cluster.getLongitude(),
cluster.getPins()
.stream()
.map(RenderPinResponse::from)
.toList()
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.mapbefine.mapbefine.topic.dto.response;

import com.mapbefine.mapbefine.pin.domain.Pin;

public record RenderPinResponse(
Long id,
String name,
Long topicId
) {

public static RenderPinResponse from(Pin pin) {
return new RenderPinResponse(
pin.getId(),
pin.getPinInfo().getName(),
pin.getTopic().getId()
);
}

}
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ public enum TopicErrorCode {
ILLEGAL_PERMISSION_FOR_PUBLICITY_PRIVATE("09007", "비공개 지도인 경우, 권한 설정이 소속 회원이어야합니다."),
ILLEGAL_PUBLICITY_FOR_PERMISSION_ALL_MEMBERS("09008", "권한 범위가 모든 회원인 경우, 비공개 지도로 설정할 수 없습니다."),
ILLEGAL_PERMISSION_UPDATE("09009", "권한 범위를 모든 회원에서 소속 회원으로 수정할 수 없습니다."),
ILLEGAL_DIAMETER_NULL("09010", "이미지의 실제 크기는 필수로 입력해야합니다."),
FORBIDDEN_TOPIC_CREATE("09300", "로그인하지 않은 사용자는 지도를 생성할 수 없습니다."),
FORBIDDEN_TOPIC_UPDATE("09301", "지도 수정 권한이 없습니다."),
FORBIDDEN_TOPIC_DELETE("09302", "지도 삭제 권한이 없습니다."),
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

import com.mapbefine.mapbefine.auth.domain.AuthMember;
import com.mapbefine.mapbefine.common.interceptor.LoginRequired;
import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse;
import com.mapbefine.mapbefine.topic.application.TopicCommandService;
import com.mapbefine.mapbefine.topic.application.TopicQueryService;
import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequest;
@@ -146,6 +147,21 @@ public ResponseEntity<List<TopicResponse>> findAllBestTopics(AuthMember authMemb
return ResponseEntity.ok(responses);
}

@GetMapping("/clusters")
public ResponseEntity<List<ClusterResponse>> getClustersOfPins(
AuthMember authMember,
@RequestParam("ids") List<Long> topicIds,
@RequestParam("image-diameter") Double imageDiameter
) {
List<ClusterResponse> responses = topicQueryService.findClustersPinsByIds(
authMember,
topicIds,
imageDiameter
);

return ResponseEntity.ok(responses);
}

@LoginRequired
@PutMapping(
value = "/images/{topicId}",
Original file line number Diff line number Diff line change
@@ -540,4 +540,33 @@ void updateTopicImage_Success() {
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
}

@Test
@DisplayName("Topic의 클러스터링 된 핀들을 조회할 때 200을 반환한다.")
void getClustersOfPins() {
ExtractableResponse<Response> newTopic = createNewTopic(
new TopicCreateRequestWithoutImage(
"매튜의 헬스장",
"맛있는 음식들이 즐비한 헬스장!",
Publicity.PUBLIC,
PermissionType.ALL_MEMBERS,
Collections.emptyList()
),
authHeader
);
long topicId = Long.parseLong(newTopic.header("Location").split("/")[2]);

// when
ExtractableResponse<Response> response = RestAssured
.given().log().all()
.header(AUTHORIZATION, authHeader)
.param("ids", List.of(topicId))
.param("image-diameter", 1)
.when().get("/topics/clusters")
.then().log().all()
.extract();

// then
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.mapbefine.mapbefine.topic.application;

import static com.mapbefine.mapbefine.member.MemberFixture.createUser;
import static java.util.List.of;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;

import com.mapbefine.mapbefine.TestDatabaseContainer;
import com.mapbefine.mapbefine.auth.domain.AuthMember;
@@ -22,8 +25,10 @@
import com.mapbefine.mapbefine.pin.domain.Pin;
import com.mapbefine.mapbefine.pin.domain.PinRepository;
import com.mapbefine.mapbefine.topic.TopicFixture;
import com.mapbefine.mapbefine.topic.domain.Cluster;
import com.mapbefine.mapbefine.topic.domain.Topic;
import com.mapbefine.mapbefine.topic.domain.TopicRepository;
import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse;
import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse;
import com.mapbefine.mapbefine.topic.dto.response.TopicResponse;
import com.mapbefine.mapbefine.topic.exception.TopicException.TopicForbiddenException;
@@ -88,7 +93,7 @@ void findAllReadable_User_Success() {
saveAllReadableTopicOfCount(1);
saveOnlyMemberReadableTopicOfCount(2);

AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);

//when
List<TopicResponse> topics = topicQueryService.findAllReadable(user);
@@ -174,7 +179,7 @@ void findAllReadableWithBookmark_Success() {
bookmarkRepository.save(bookmark);

//when then
AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);
List<TopicResponse> topics = topicQueryService.findAllReadable(user);

assertThat(topics).hasSize(2);
@@ -288,7 +293,7 @@ void findDetailById_WithOutSoftDeleted_Success() {
topicRepository.deleteById(topic.getId());

//when then
AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);
assertThatThrownBy(() -> topicQueryService.findDetailById(user, topic.getId()))
.isInstanceOf(TopicNotFoundException.class);
}
@@ -304,7 +309,7 @@ void findDetailById_WithBookmarkStatus_Success() {
bookmarkRepository.save(bookmark);

//when then
AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);
TopicDetailResponse topicDetail = topicQueryService.findDetailById(user, topic.getId());

assertThat(topicDetail.id()).isEqualTo(topic.getId());
@@ -348,7 +353,7 @@ void findDetailsByIds_Guest_Success() {

List<TopicDetailResponse> details = topicQueryService.findDetailsByIds(
guest,
List.of(topic1.getId(), topic2.getId())
of(topic1.getId(), topic2.getId())
);

//then
@@ -368,11 +373,11 @@ void findDetailsByIds_User_Success() {
topicRepository.save(topic2);

//when
AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);

List<TopicDetailResponse> details = topicQueryService.findDetailsByIds(
user,
List.of(topic1.getId(), topic2.getId())
of(topic1.getId(), topic2.getId())
);

//then
@@ -393,7 +398,7 @@ void findDetailsByIds_FailByForbidden() {

//when then
AuthMember guest = new Guest();
List<Long> topicIds = List.of(topic1.getId(), topic2.getId());
List<Long> topicIds = of(topic1.getId(), topic2.getId());

assertThatThrownBy(() -> topicQueryService.findDetailsByIds(guest, topicIds))
.isInstanceOf(TopicForbiddenException.class);
@@ -411,8 +416,8 @@ void findDetailsByIds_FailByNotFound() {
topicRepository.deleteById(topic2.getId());

//when then
AuthMember user = MemberFixture.createUser(member);
List<Long> topicIds = List.of(topic1.getId(), topic2.getId());
AuthMember user = createUser(member);
List<Long> topicIds = of(topic1.getId(), topic2.getId());

assertThatThrownBy(() -> topicQueryService.findDetailsByIds(user, topicIds))
.isInstanceOf(TopicNotFoundException.class);
@@ -431,9 +436,9 @@ void findDetailsByIds_WithBookmarkStatus_Success() {
bookmarkRepository.save(bookmark);

//when //then
AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);
List<TopicDetailResponse> topicDetails =
topicQueryService.findDetailsByIds(user, List.of(topic1.getId(), topic2.getId()));
topicQueryService.findDetailsByIds(user, of(topic1.getId(), topic2.getId()));

assertThat(topicDetails).hasSize(2);
assertThat(topicDetails).extractingResultOf("id")
@@ -457,7 +462,7 @@ void findDetailsByIds_WithoutBookmarkStatus_Success() {
//when //then
AuthMember guest = new Guest();
List<TopicDetailResponse> topicDetails =
topicQueryService.findDetailsByIds(guest, List.of(topic1.getId(), topic2.getId()));
topicQueryService.findDetailsByIds(guest, of(topic1.getId(), topic2.getId()));

assertThat(topicDetails).hasSize(2);
assertThat(topicDetails).extractingResultOf("id")
@@ -472,7 +477,7 @@ void findAllTopicsByMemberId_Success() {
//given
AuthMember authMember = new Admin(member.getId());

List<Topic> expected = topicRepository.saveAll(List.of(
List<Topic> expected = topicRepository.saveAll(of(
TopicFixture.createPublicAndAllMembersTopic(member),
TopicFixture.createPublicAndAllMembersTopic(member),
TopicFixture.createPublicAndAllMembersTopic(member)
@@ -497,7 +502,7 @@ void findAllByOrderByUpdatedAtDesc_Success() {
Location location = LocationFixture.create();
locationRepository.save(location);

List<Topic> topics = List.of(
List<Topic> topics = of(
TopicFixture.createByName("5등", member),
TopicFixture.createByName("4등", member),
TopicFixture.createByName("3등", member),
@@ -544,7 +549,7 @@ void findAllBestTopics_Success1() {
saveBookmark(topicWithTwoBookmark, otherMember);

//when
AuthMember user = MemberFixture.createUser(member);
AuthMember user = createUser(member);
List<TopicResponse> actual = topicQueryService.findAllBestTopics(user);

//then
@@ -575,7 +580,7 @@ void findAllBestTopics_Success2() {
saveBookmark(privateTopicWithOneBookmark, member);

//when
AuthMember otherUser = MemberFixture.createUser(otherMember);
AuthMember otherUser = createUser(otherMember);

List<TopicResponse> actual = topicQueryService.findAllBestTopics(otherUser);
List<TopicResponse> expect = topicQueryService.findAllReadable(otherUser);
@@ -585,6 +590,65 @@ void findAllBestTopics_Success2() {
.isEqualTo(expect);
}

@Test
@DisplayName("여러 지도를 한번에 렌더링 할 때, 클러스터링 된 결과를 반환받는다.")
void findClusteringPinsByIds_success() {
// given
Topic firstTopic = topicRepository.save(TopicFixture.createByName("firstTopic", member));
Topic secondTopic = topicRepository.save(TopicFixture.createByName("secondTopic", member));
Pin representPinOfSet1 = pinRepository.save(PinFixture.create(
locationRepository.save(LocationFixture.createByCoordinate(36, 124)), firstTopic, member)
);
Pin pinOfSet1 = pinRepository.save(PinFixture.create(
locationRepository.save(LocationFixture.createByCoordinate(36, 124.1)), secondTopic, member)
);
Pin representPinOfSet2 = pinRepository.save(PinFixture.create(
locationRepository.save(LocationFixture.createByCoordinate(36, 124.2)), firstTopic, member)
);
Pin representPinOfSet3 = pinRepository.save(PinFixture.create(
locationRepository.save(LocationFixture.createByCoordinate(38, 124)), firstTopic, member)
);
Pin pinOfSet3 = pinRepository.save(PinFixture.create(
locationRepository.save(LocationFixture.createByCoordinate(38, 124.1)), secondTopic, member)
);

// when
List<ClusterResponse> actual = topicQueryService.findClustersPinsByIds(
createUser(member),
List.of(firstTopic.getId(), secondTopic.getId()),
9000D
);

// then
List<ClusterResponse> expected = List.of(
ClusterResponse.from(Cluster.from(representPinOfSet1, List.of(representPinOfSet1, pinOfSet1))),
ClusterResponse.from(Cluster.from(representPinOfSet2, List.of(representPinOfSet2))),
ClusterResponse.from(Cluster.from(representPinOfSet3, List.of(representPinOfSet3, pinOfSet3)))
);
assertAll(
() -> assertThat(actual).hasSize(3),
() -> assertThat(actual).usingRecursiveComparison()
.ignoringCollectionOrder()
.isEqualTo(expected)
);
}

@Test
@DisplayName("여러 지도를 한번에 렌더링 할 떄, 조회하지 못하는 지도가 있어 예외가 발생한다.")
void findClusteringPinsByIds_fail() {
// given
Member nonCreator = memberRepository.save(MemberFixture.create("nonCreator", "nonCreator@naver.com", Role.USER));
Member creator = memberRepository.save(MemberFixture.create("creator", "creator@naver.com", Role.USER));
Topic topic = topicRepository.save(TopicFixture.createPrivateAndGroupOnlyTopic(creator));

// when then
assertThatThrownBy(() -> topicQueryService.findClustersPinsByIds(
createUser(nonCreator),
List.of(topic.getId()),
9000D
)).isInstanceOf(TopicForbiddenException.class);
}

private Bookmark saveBookmark(Topic topic, Member member) {
return bookmarkRepository.save(Bookmark.createWithAssociatedTopicAndMember(topic, member));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.mapbefine.mapbefine.topic.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

import com.mapbefine.mapbefine.location.LocationFixture;
import com.mapbefine.mapbefine.location.domain.Location;
import com.mapbefine.mapbefine.member.MemberFixture;
import com.mapbefine.mapbefine.member.domain.Member;
import com.mapbefine.mapbefine.member.domain.Role;
import com.mapbefine.mapbefine.pin.PinFixture;
import com.mapbefine.mapbefine.pin.domain.Pin;
import com.mapbefine.mapbefine.topic.TopicFixture;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class ClusterTest {

@Test
@DisplayName("대표 핀과, 해당 좌표에 묶인 핀들의 집합을 받으면 정상적으로 생성된다.")
void createCluster() {
// given
Member member = MemberFixture.create("member", "member@naver.com", Role.USER);
Topic topic = TopicFixture.createByName("topic", member);
Location firstLocation = LocationFixture.createByCoordinate(36, 124);
Location secondLocation = LocationFixture.createByCoordinate(36, 124.1);
List<Pin> pins = List.of(
PinFixture.create(firstLocation, topic, member),
PinFixture.create(secondLocation, topic, member)
);

// when
Cluster actual = Cluster.from(pins.get(0), pins);

// then
assertAll(
() -> assertThat(actual.getLatitude()).isEqualTo(36),
() -> assertThat(actual.getLongitude()).isEqualTo(124),
() -> assertThat(actual.getPins().get(0)).isEqualTo(pins.get(0)),
() -> assertThat(actual.getPins()).usingRecursiveComparison()
.isEqualTo(pins)
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.mapbefine.mapbefine.topic.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.groups.Tuple.tuple;
import static org.junit.jupiter.api.Assertions.assertAll;

import com.mapbefine.mapbefine.location.LocationFixture;
import com.mapbefine.mapbefine.member.MemberFixture;
import com.mapbefine.mapbefine.member.domain.Member;
import com.mapbefine.mapbefine.member.domain.Role;
import com.mapbefine.mapbefine.pin.PinFixture;
import com.mapbefine.mapbefine.pin.domain.Pin;
import com.mapbefine.mapbefine.topic.TopicFixture;
import com.mapbefine.mapbefine.topic.exception.TopicException.TopicBadRequestException;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class ClustersTest {

@Test
@DisplayName("각기 약 9000미터 떨어져있는 핀 5개를 받아 3개의 집합이 완성된다.")
void createClusters_success() {
// given
Member member = MemberFixture.create("member", "member@naver.com", Role.USER);
Topic topic = TopicFixture.createByName("topic", member);
List<Pin> pins = List.of(
PinFixture.create(LocationFixture.createByCoordinate(36, 124), topic, member),
PinFixture.create(LocationFixture.createByCoordinate(36, 124.1), topic, member),
PinFixture.create(LocationFixture.createByCoordinate(36, 124.2), topic, member),
PinFixture.create(LocationFixture.createByCoordinate(38, 124), topic, member),
PinFixture.create(LocationFixture.createByCoordinate(38, 124.1), topic, member)
);

// when
List<Cluster> actual = Clusters.from(pins, 9000D)
.getClusters();

// then
assertAll(
() -> assertThat(actual).hasSize(3),
() -> assertThat(actual)
.extracting(Cluster::getLatitude, Cluster::getLongitude)
.contains(tuple(36d, 124d), tuple(36d, 124.2d), tuple(38d, 124d))
);
}

@Test
@DisplayName("이미지의 크기를 입력하지 않으면 예외가 발생한다.")
void createClusters_fail() {
// given
Member member = MemberFixture.create("member", "member@naver.com", Role.USER);
Topic topic = TopicFixture.createByName("topic", member);
List<Pin> pins = List.of(
PinFixture.create(LocationFixture.createByCoordinate(36, 124), topic, member)
);

// when then
assertThatThrownBy(() -> Clusters.from(pins, null))
.isInstanceOf(TopicBadRequestException.class);
}

}
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

import static org.apache.http.HttpHeaders.AUTHORIZATION;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.BDDMockito.given;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT;
@@ -18,6 +19,8 @@
import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequestWithoutImage;
import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequestWithoutImage;
import com.mapbefine.mapbefine.topic.dto.request.TopicUpdateRequest;
import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse;
import com.mapbefine.mapbefine.topic.dto.response.RenderPinResponse;
import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse;
import com.mapbefine.mapbefine.topic.dto.response.TopicResponse;
import java.time.LocalDateTime;
@@ -243,4 +246,41 @@ void findAllBestTopics() throws Exception {
.andDo(restDocs.document());
}

@Test
@DisplayName("클러스터링 된 핀들을 조회할 수 있다.")
void getClustersOfPins() throws Exception {
given(topicQueryService.findClustersPinsByIds(any(), any(), anyDouble())).willReturn(
List.of(
new ClusterResponse(
36.0,
124.0,
List.of(
new RenderPinResponse(1L, "firstTopic의 핀", 1L),
new RenderPinResponse(2L, "secondTopic의 핀", 2L)
)
),
new ClusterResponse(
36.0,
124.2,
List.of(
new RenderPinResponse(3L, "firstTopic의 핀", 1L)
)
),
new ClusterResponse(
38.0,
124.0,
List.of(
new RenderPinResponse(4L, "firstTopic의 핀", 1L),
new RenderPinResponse(5L, "secondTopic의 핀", 2L)
)
)
)
);

mockMvc.perform(MockMvcRequestBuilders.get("/topics/clusters?ids=1,2&image-diameter=9000")
.header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(restDocs.document());
}

}