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

#39 헌혈인증 내역 조회 API 개발 #40

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

import lombok.RequiredArgsConstructor;

import com.zoopi.client.certification.api.BloodDonationCertApi;
import com.zoopi.ResultCode;
import com.zoopi.ResultResponse;
import com.zoopi.client.certification.api.BloodDonationCertApi;
import com.zoopi.client.certification.service.BloodDonationCertService;

@RestController
Expand All @@ -17,8 +18,10 @@ public class BloodDonationCertController implements BloodDonationCertApi {
private final BloodDonationCertService certService;

@GetMapping("/{petId}")
public ResponseEntity<ResultResponse> findCertification(Long petId) {
return ResponseEntity.ok(ResultResponse.of(null));
public ResponseEntity<ResultResponse> retrieveCertification(Long petId) {
return ResponseEntity.ok(
ResultResponse.of(ResultCode.OK, certService.retrieveCertification(petId))
);
}

}
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
package com.zoopi.client.certification.service;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

import com.zoopi.domain.certification.repository.BloodDonationCertRepository;
import com.zoopi.client.certification.model.CertificationResponse;
import com.zoopi.domain.certification.entity.BloodDonationHistory;
import com.zoopi.domain.certification.service.CertificationService;

@Service
@RequiredArgsConstructor
public class BloodDonationCertService {

private final BloodDonationCertRepository certRepository;
private final CertificationService certificationService;

public List<CertificationResponse> retrieveCertification(Long petId) {
List<BloodDonationHistory> histories = certificationService.findAllHistoryBy(petId);
return certificationService.mapHistoryAndDetail(histories)
.entrySet().stream()
.map(CertificationResponse::of)
.collect(Collectors.toList());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.zoopi.domain.certification.service;

import static com.zoopi.domain.certification.entity.BloodDonationType.*;
import static com.zoopi.util.FunctionalUtils.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.junit.jupiter.api.Test;

import com.zoopi.domain.certification.dto.CertDetailDto;
import com.zoopi.domain.certification.entity.BloodDonationDetail;
import com.zoopi.domain.certification.entity.BloodDonationHistory;
import com.zoopi.domain.certification.repository.BloodDonationDetailRepository;
import com.zoopi.domain.certification.repository.BloodDonationHistoryRepository;
import com.zoopi.domain.chat.entity.ChatMessage;
import com.zoopi.domain.chat.entity.ChatRoom;
import com.zoopi.domain.chat.entity.ChatRoomStatus;
import com.zoopi.domain.chat.entity.MessageType;
import com.zoopi.domain.chat.repository.ChatMessageRepository;
import com.zoopi.domain.hospital.entity.Hospital;
import com.zoopi.domain.pet.entity.Pet;

class CertificationServiceTest {

private final BloodDonationHistoryRepository historyRepository = mock(BloodDonationHistoryRepository.class);
private final BloodDonationDetailRepository detailRepository = mock(BloodDonationDetailRepository.class);
private final ChatMessageRepository chatMessageRepository = mock(ChatMessageRepository.class);

private final CertificationService certificationService = new CertificationService(
historyRepository,
detailRepository,
chatMessageRepository
);
slolee marked this conversation as resolved.
Show resolved Hide resolved

private final Pet donorPet = Pet.builder().build();
private final Pet receiverPet = Pet.builder().build();
private final Hospital hospital1 = new Hospital(1L, "충북대학교 동물병원", "충북 청주시 서원구 충대로1, 충북대학교 수의과대학 동물병원");
private final Hospital hospital2 = new Hospital(2L, "건국대학교 부속 동물병원", "서울 광진구 능동로 120 건국대학교 수의학관");
private final List<BloodDonationHistory> histories = List.of(
new BloodDonationHistory(1L, donorPet, "https://smaple.com/1", hospital1, LocalDate.now(), APPOINT),
new BloodDonationHistory(2L, donorPet, "https://smaple.com/2", hospital2, LocalDate.now(), APPOINT),
new BloodDonationHistory(3L, donorPet, "https://smaple.com/3", hospital2, LocalDate.now(), GENERAL),
new BloodDonationHistory(4L, donorPet, "https://smaple.com/4", hospital2, LocalDate.now(), APPOINT),
new BloodDonationHistory(5L, donorPet, "https://smaple.com/5", hospital1, LocalDate.now(), GENERAL)
);
private final List<BloodDonationDetail> details = List.of(
new BloodDonationDetail(1L, receiverPet, 100L, "", histories.get(0)),
new BloodDonationDetail(2L, receiverPet, 200L, "", histories.get(1)),
new BloodDonationDetail(3L, receiverPet, 300L, "", histories.get(3))
);
private final ChatRoom chatRoom = new ChatRoom(200L, 2L, donorPet, true, ChatRoomStatus.DONE);
private final ChatMessage chatMessage = new ChatMessage(1000L, chatRoom, 1L, "헌혈해주셔서 감사합니다 :)", MessageType.THANKS, true);

@Test
public void mapHistoryAndDetail_happy_case() {
Copy link
Member

Choose a reason for hiding this comment

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

JUnit 5부터 접근제한자 public을 생략해도 되는 것으로 알고 있습니다.🙂

+ 메소드명을 통해 어떤 상황에서 무슨 결과를 검증하는 테스트인지 조금이나마 짐작할 수 있으면 좋을 것 같아요 ㅠ_ㅠ

컨벤션 참고: (메소드명)_(상황|행동|상태)_(예측결과|예외발생)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

음.. 충분히 예측가능한 케이스라고 생각했는데 좀더 디테일하게 이름을 지어보도록할게요!

// given
when(detailRepository.findByHistoryIdsIn(mapFrom(histories, BloodDonationHistory::getId))).thenReturn(details);
when(chatMessageRepository.findByChatRoomIdAndType(100L, MessageType.THANKS)).thenReturn(null);
when(chatMessageRepository.findByChatRoomIdAndType(200L, MessageType.THANKS)).thenReturn(chatMessage);
when(chatMessageRepository.findByChatRoomIdAndType(300L, MessageType.THANKS)).thenReturn(null);
slolee marked this conversation as resolved.
Show resolved Hide resolved

// when
final Map<BloodDonationHistory, CertDetailDto> res = certificationService.mapHistoryAndDetail(histories);

// then
res.forEach((history, dto) -> {
if (history.getType().equals(APPOINT)) {
assertNotNull(dto);
if (history.getId() == 2) assertEquals(dto.getThanksMessage(), "헌혈해주셔서 감사합니다 :)");
else assertNull(dto.getThanksMessage());
}
else if (history.getType().equals(GENERAL)) assertNull(dto);
});
slolee marked this conversation as resolved.
Show resolved Hide resolved
}

}
25 changes: 25 additions & 0 deletions module-common/src/main/java/com/zoopi/util/FunctionalUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.zoopi.util;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class FunctionalUtils {

public static <T, R> List<R> mapFrom(List<T> list, Function<T, R> func) {
return list.stream().map(func).collect(Collectors.toList());
}

public static <T> List<T> filter(List<T> list, Predicate<T> func) {
return list.stream().filter(func).collect(Collectors.toList());
}

public static <T, K, V> Map<K, V> associateFrom(List<T> list, Function<T, K> key, Function<T, V> value) {
return list.stream().collect(HashMap::new, (map, t) -> map.put(key.apply(t), value.apply(t)), HashMap::putAll);
}
Comment on lines +20 to +22
Copy link
Member

Choose a reason for hiding this comment

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

혹시 list를 map으로 변환할 때, key가 중복되는 경우도 허용하려는 의도로 위와 같이 구현하신 걸까요??

mapFrom, filter 메소드에서는 Collectors.toList() 메소드를 사용하셨는데, associateFrom 메소드에서는 Collectors.toMap() 메소드를 사용하지 않으신 이유도 궁금합니다..!

public static <T, K, V> Map<K, V> associateFrom(List<T> list, Function<T, K> key, Function<T, V> value) {
    return list.stream().collect(Collectors.toMap(key, value));
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

제가 원하는 결과는 Value 가 있으면 (Key, Value) 가 나오고 없으면 (Key, null) 이 나오도록 하길 원했는데, toMap 같은 경우에 Key 에 대한 Value 가 없을 경우 NPE 가 발생하더라구요. 그래서 직접 HashMap 을 만들도록 구현했습니다 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

아하 그런 의도였군요 ㅎㅎ 알겠습니다. 주석으로 부연설명도 추가해 주시면 나중에 까먹더라도 기억할 수 있을테니 좋을 것 같아요!


}

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.zoopi.domain.certification.dto;

import java.time.LocalDateTime;
import java.util.Optional;

import lombok.Getter;

import com.zoopi.domain.certification.entity.BloodDonationDetail;
import com.zoopi.domain.chat.entity.ChatMessage;
import com.zoopi.domain.pet.entity.Pet;

@Getter
public class CertDetailDto {

private final Long bloodDonationDetailId;
private final Pet receiverPet;
private String thanksMessage = null;
private LocalDateTime thanksMessageAt = null;

public CertDetailDto(BloodDonationDetail detail, ChatMessage thanksMessage) {
this.bloodDonationDetailId = detail.getId();
this.receiverPet = detail.getReceiverPet();

Optional.ofNullable(thanksMessage).ifPresent(thanks -> {
this.thanksMessage = thanks.getMessage();
this.thanksMessageAt = thanks.getCreatedAt();
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.OneToOne;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import com.zoopi.domain.BaseEntity;
import com.zoopi.domain.pet.entity.Pet;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class BloodDonationDetail extends BaseEntity {

Comment on lines 21 to 26
Copy link
Member

Choose a reason for hiding this comment

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

테스트 코드 작성 편의 목적으로 @AllArgsConstructor을 추가하신 것으로 생각되는데 맞을까요..?

개인적으로 Dto의 경우 @AllArgsConstructor@Setter가 있어도 큰 문제가 없다고 생각하지만,
Entity의 경우 Id 주입 시점이 DB에 row를 저장한 시점(IDENTITY)만 가능하기 때문에, @AllArgsConstructor을 열어두는 것은 좋은 코드라는 생각이 들지 않네요 ㅠ_ㅠ

@AllArgsConstructor을 열어두는 방법 대신, 테스트 코드에서 ReflectionTestUtils을 이용하여 Id를 주입해 주는 방식은 어떠실까요..?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

말씀해주신 내용에 대해서는 충분히 이해가 가는 내용입니다! 엄격하게 따졌을 때 굉장히 중요한 내용인것 같고, 잘못 개발 되었을 때 발생할 수 있는 문제를 잘 잡아주신것 같아요!

개인적으로는 이러한 부분을 엄격하게 챙기는 것과 어느정도 개발 편의성을 가져가는 것에 트레이드 오프가 필요하다고 생각하는데요,, JPA 에 대한 이해도가 있는 상태라면 도메인 Entity 객체를 생성할 때 ID 를 넣지는 않도록 관리가 되어질수는 있을것 같다는 생각입니다!

실제로 코틀린에서는 일반적으로 data class 를 이용해 Entity 클래스를 만드는데, data class 에는 기본적으로 AllArgsArgument 생성자를 가집니다..! 그럼에도 data class 를 이용해 Entity 클래스를 만드는 이유는 이로인해 따라오는 개발 편의성이 매우 크기 때문입니다. 추가로 Reflection 은 개인적으로 별로 선호하지 않는 방법입니다!!

이 부분에 대해서는 약간의 타협점이 필요해보이네요! 🤔

Copy link
Member

Choose a reason for hiding this comment

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

음.. 저희끼리만(혹은 JPA를 잘 아는 사람들끼리) 개발 및 유지보수 한다는 가정 하에는 개발 편의성을 선택하는 것도 방법이겠군요..
그럼 이 부분은 제가 작성한 코드에도 적용하도록 하겠습니다!

그리고 혹시 Reflection을 선호하지 않으시는 이유도 여쭤봐도 될까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

굳이 맞출필요는 없는 부분이고 선필님이 작업하시는 범위는 하시는대로 진행해주셔도 될것 같아요 :) 크게 충돌이 날만한 부분은 아닌것 같아서요!

제가 Reflection 을 선호하지 않는 이유는 컴파일시점에 타입에 대한 체크가 어려워서입니다! 물론 id 같은 경우엔 Long 타입이 거의 고정시되어있어서 크게 의미가 있는 부분은 아닌것 같지만요. 특히 테스트코드가 아니라 비즈니스 로직에서는 더 안좋은 영향을 많이 주는것 같아요.

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "blood_dontation_detail_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_pet_id", referencedColumnName = "pet_id")
private Pet receiverPet;

private Long chatRoomId;

private String message;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "blood_donation_history_id", referencedColumnName = "blood_donation_history_id")
private BloodDonationHistory history;

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,40 @@
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import javax.persistence.ManyToOne;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import com.zoopi.domain.BaseEntity;
import com.zoopi.domain.hospital.entity.Hospital;
import com.zoopi.domain.pet.entity.Pet;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class BloodDonationHistory extends BaseEntity {

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
slolee marked this conversation as resolved.
Show resolved Hide resolved
@Column(name = "blood_donation_history_id")
private Long id;

private Long petId;
private Long receiverPetId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pet_id", referencedColumnName = "pet_id")
private Pet pet;

@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "blood_donation_detail_id")
private BloodDonationDetail detail;

@Column(name = "image")
private String imageUrl;

private Long hospitalId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "hospital_id", referencedColumnName = "hospital_id")
private Hospital hospital;

private LocalDate bloodDonationAt;

@Enumerated(EnumType.STRING)
private BloodDonationType type;
}

}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.zoopi.domain.certification.repository;

import java.util.List;

import org.springframework.stereotype.Repository;

import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;

import com.zoopi.domain.certification.entity.BloodDonationDetail;
import com.zoopi.domain.certification.entity.QBloodDonationDetail;
import com.zoopi.domain.certification.entity.QBloodDonationHistory;
import com.zoopi.domain.chat.entity.QChatMessage;
import com.zoopi.domain.chat.entity.QChatRoom;
import com.zoopi.domain.pet.entity.QPet;

@Repository
@RequiredArgsConstructor
public class BloodDonationDetailRepository {

private final JPAQueryFactory jpaQueryFactory;
private final QBloodDonationHistory bloodDonationHistory = QBloodDonationHistory.bloodDonationHistory;
private final QBloodDonationDetail bloodDonationDetail = QBloodDonationDetail.bloodDonationDetail;
private final QPet pet = QPet.pet;
private final QChatRoom chatRoom = QChatRoom.chatRoom;
private final QChatMessage chatMessage = QChatMessage.chatMessage;

public List<BloodDonationDetail> findByHistoryIdsIn(List<Long> ids) {
return jpaQueryFactory.selectFrom(bloodDonationDetail)
.innerJoin(bloodDonationHistory).on(bloodDonationHistory.id.eq(bloodDonationDetail.history.id))
.fetchJoin()
.innerJoin(pet).on(pet.id.eq(bloodDonationDetail.receiverPet.id))
.fetchJoin()
.where(bloodDonationDetail.history.id.in(ids))
.fetch();
}

}
slolee marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.zoopi.domain.certification.repository;

import java.util.List;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import com.zoopi.domain.certification.entity.BloodDonationHistory;

@Repository
public interface BloodDonationHistoryRepository extends CrudRepository<BloodDonationHistory, Long> {

@Query("select h from BloodDonationHistory h join fetch h.hospital join fetch h.pet where h.pet.id = :petId")
List<BloodDonationHistory> findAllByPetId(Long petId);
}
Copy link
Member

Choose a reason for hiding this comment

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

위와 동일한 내용입니다!

추가)
fetch join을 이용하는 경우 메소드 네이밍에 차별점을 주는 건 어떨까요?
fetch join 없이 조회하는 메소드와 공존하는 경우도 고려하면 좋을 것 같습니다!

ex) findAllFetchPetByPetId

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fetch Join 이 발생한다는게 해당 메소드를 호출하는쪽에서 관심 가질만한 부분인지 잘 모르겠습니다.. 🤔 사용하는쪽의 입장을 생각하고 메소드이름을 짓는편이 비즈니스 로직의 가독성이 더 좋아진다고 생각해서.. 고민스러운 부분이네요!

Copy link
Member

Choose a reason for hiding this comment

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

fetch join이 필요 없는 곳에서도 해당 메소드를 사용하면 아무래도 불필요한 데이터까지 db에서 fetch 하는게 비효율적이라.. 저는 구분하는 걸 선호하는편입니다 ㅠ_ㅠ

Copy link
Contributor Author

Choose a reason for hiding this comment

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

음....🤔 이 부분은 말씀해주신대로 findAllFetchPetBy(Long petId) 정도로 정리해볼게요~

Loading