From e4d0eef32529a55387a1be700a97b22df6ff3bcc Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 29 May 2025 22:13:39 +0900 Subject: [PATCH 01/44] =?UTF-8?q?fix:=20=EC=86=8C=EC=85=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=9A=8C=EC=9B=90=20=EC=83=9D=EC=84=B1=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit providerId에 유니크 컬럼 적용 --- .../com/stcom/smartmealtable/domain/social/SocialAccount.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java b/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java index 8c5cc14..2288d7f 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java +++ b/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java @@ -31,6 +31,7 @@ public class SocialAccount extends BaseTimeEntity { private String provider; + @Column(unique = true) private String providerUserId; private String tokenType; From 295c71c432c4aee440817466d50590fc7672d9c6 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 29 May 2025 22:57:31 +0900 Subject: [PATCH 02/44] =?UTF-8?q?fix:=20GlobalExHandler=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전역 Exception Handler에서 정적 리소스 예외 잡도록 수정 --- .../smartmealtable/web/exhandler/ExControllerAdvice.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java b/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java index 8764a4e..859c326 100644 --- a/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java +++ b/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice @@ -88,4 +89,10 @@ public ApiResponse exHandler(Exception e) { log.error("[Exception] ex", e); return ApiResponse.createError("서버 내부에서 체크 예외가 발생했습니다"); } + + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public void handleNoResourceFound() { + // 오류, 로그 먹어버리기 + } } From 941b5e32175e70a496294bf7fc2190d2fdc1851b Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 16:35:09 +0900 Subject: [PATCH 03/44] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=BD=94=EB=93=9C,=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/BizLogicException.java | 12 +++++ .../exception/ExternApiStatusError.java | 12 +++++ .../PasswordFailedExceededException.java | 16 +------ .../exception/PasswordPolicyException.java | 15 +----- .../KakaoAddressApiService.java | 48 ++++++++++++------- .../infrastructure/SocialAuthService.java | 10 ++-- .../smartmealtable/service/BudgetService.java | 8 ++-- .../MemberCategoryPreferenceService.java | 2 +- .../service/MemberProfileService.java | 14 +++--- .../smartmealtable/service/MemberService.java | 9 ++-- .../service/SocialAccountService.java | 4 +- .../web/exhandler/ExControllerAdvice.java | 41 ++++++++-------- 12 files changed, 102 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/stcom/smartmealtable/exception/BizLogicException.java diff --git a/src/main/java/com/stcom/smartmealtable/exception/BizLogicException.java b/src/main/java/com/stcom/smartmealtable/exception/BizLogicException.java new file mode 100644 index 0000000..26572da --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/exception/BizLogicException.java @@ -0,0 +1,12 @@ +package com.stcom.smartmealtable.exception; + +public class BizLogicException extends RuntimeException { + + public BizLogicException() { + super(); + } + + public BizLogicException(String message) { + super(message); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java b/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java index a3b9b78..f578b05 100644 --- a/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java +++ b/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java @@ -4,4 +4,16 @@ public class ExternApiStatusError extends RuntimeException { public ExternApiStatusError(String message) { super(message); } + + public ExternApiStatusError() { + super(); + } + + public ExternApiStatusError(String message, Throwable cause) { + super(message, cause); + } + + public ExternApiStatusError(Throwable cause) { + super(cause); + } } diff --git a/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java b/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java index cb2397b..f567989 100644 --- a/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java +++ b/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java @@ -1,6 +1,6 @@ package com.stcom.smartmealtable.exception; -public class PasswordFailedExceededException extends Exception { +public class PasswordFailedExceededException extends BizLogicException { public PasswordFailedExceededException() { super("비밀번호 실패 횟수가 5회를 초과하였습니다."); } @@ -8,17 +8,5 @@ public PasswordFailedExceededException() { public PasswordFailedExceededException(String message) { super(message); } - - public PasswordFailedExceededException(String message, Throwable cause) { - super(message, cause); - } - - public PasswordFailedExceededException(Throwable cause) { - super(cause); - } - - protected PasswordFailedExceededException(String message, Throwable cause, boolean enableSuppression, - boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } + } diff --git a/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java b/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java index 7f981c3..1a44454 100644 --- a/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java +++ b/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java @@ -1,6 +1,6 @@ package com.stcom.smartmealtable.exception; -public class PasswordPolicyException extends Exception { +public class PasswordPolicyException extends BizLogicException { public PasswordPolicyException() { super(); @@ -9,17 +9,4 @@ public PasswordPolicyException() { public PasswordPolicyException(String message) { super(message); } - - public PasswordPolicyException(String message, Throwable cause) { - super(message, cause); - } - - public PasswordPolicyException(Throwable cause) { - super(cause); - } - - protected PasswordPolicyException(String message, Throwable cause, boolean enableSuppression, - boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } } diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java b/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java index f24d7b0..4020348 100644 --- a/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.exception.ExternApiStatusError; import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; import java.util.List; import lombok.AllArgsConstructor; @@ -21,28 +22,39 @@ public class KakaoAddressApiService implements AddressApiService { private final RestClient client = RestClient.create(); public Address createAddressFromRequest(AddressRequest requestDto) { - AddressSearchResponse addressSearchResponse = client.get() - .uri(uriBuilder -> uriBuilder - .scheme("https") - .host("dapi.kakao.com") - .path("/v2/local/search/address") - .queryParam("query", requestDto.getRoadAddress()) - .build()) - .header("Authorization", "KakaoAK " + clientId) - .retrieve() - .body(AddressSearchResponse.class); + try { + AddressSearchResponse addressSearchResponse = client.get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("dapi.kakao.com") + .path("/v2/local/search/address") + .queryParam("query", requestDto.getRoadAddress()) + .build()) + .header("Authorization", "KakaoAK " + clientId) + .retrieve() + .body(AddressSearchResponse.class); + validateAddressSearchResponse(addressSearchResponse); + return Address.builder() + .longitude(Double.parseDouble(addressSearchResponse.getDocuments().getFirst().getLongitude())) + .latitude(Double.parseDouble(addressSearchResponse.getDocuments().getFirst().getLatitude())) + .lotNumberAddress(addressSearchResponse.getDocuments().getFirst().getAddress().getAddressName()) + .roadAddress(addressSearchResponse.getDocuments().getFirst().getRoadAddress().getAddressName()) + .detailAddress(requestDto.getDetailAddress()) + .build(); + + } catch (RuntimeException e) { + throw new ExternApiStatusError("카카오 주소 Api 호출 중 오류가 발생했습니다.", e); + } + } + + private void validateAddressSearchResponse(AddressSearchResponse addressSearchResponse) { + if (addressSearchResponse == null) { + throw new IllegalArgumentException("조회된 결과가 없습니다"); + } if (addressSearchResponse.getMeta().getTotalCount() >= 2) { throw new IllegalArgumentException("주소가 모호합니다. 정확한 주소를 입력하세요"); } - - return Address.builder() - .longitude(Double.parseDouble(addressSearchResponse.getDocuments().getFirst().getLongitude())) - .latitude(Double.parseDouble(addressSearchResponse.getDocuments().getFirst().getLatitude())) - .lotNumberAddress(addressSearchResponse.getDocuments().getFirst().getAddress().getAddressName()) - .roadAddress(addressSearchResponse.getDocuments().getFirst().getRoadAddress().getAddressName()) - .detailAddress(requestDto.getDetailAddress()) - .build(); } @Data diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java b/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java index 3bd8937..ae9c662 100644 --- a/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java @@ -3,6 +3,7 @@ import static com.stcom.smartmealtable.infrastructure.social.SocialConst.GOOGLE; import static com.stcom.smartmealtable.infrastructure.social.SocialConst.KAKAO; +import com.stcom.smartmealtable.exception.ExternApiStatusError; import com.stcom.smartmealtable.infrastructure.dto.TokenDto; import com.stcom.smartmealtable.infrastructure.social.GoogleHttpMessage; import com.stcom.smartmealtable.infrastructure.social.KakaoHttpMessage; @@ -26,8 +27,12 @@ public class SocialAuthService { private final RestClient client = RestClient.create(); public TokenDto getTokenResponse(@NotEmpty String provider, @NotEmpty String code) { - ResponseSpec responseSpec = socialMap.get(provider).getRequestMessage(client, code).retrieve(); - return socialMap.get(provider).getTokenResponse(responseSpec); + try { + ResponseSpec responseSpec = socialMap.get(provider).getRequestMessage(client, code).retrieve(); + return socialMap.get(provider).getTokenResponse(responseSpec); + } catch (RuntimeException e) { + throw new ExternApiStatusError("소셜 로그인 서드파티 Api 호출 중 예외가 발생했습니다.", e); + } } @PostConstruct @@ -36,5 +41,4 @@ public void init() { socialMap.put(GOOGLE, googleHttpMessage); } - } diff --git a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java index 14661cc..73f33f2 100644 --- a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java +++ b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java @@ -22,18 +22,18 @@ public class BudgetService { public DailyBudget findRecentDailyBudgetByMemberProfileId(Long memberProfileId) { return budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); } public MonthlyBudget findRecentMonthlyBudgetByMemberProfileId(Long memberProfileId) { return budgetRepository.findFirstMonthlyBudgetByMemberProfileId(memberProfileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); } @Transactional public void saveMonthlyBudgetCustom(Long memberProfileId, Long limit) { MemberProfile profile = memberProfileRepository.findById(memberProfileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); MonthlyBudget monthlyBudget = new MonthlyBudget(profile, BigDecimal.valueOf(limit), YearMonth.now()); budgetRepository.save(monthlyBudget); } @@ -41,7 +41,7 @@ public void saveMonthlyBudgetCustom(Long memberProfileId, Long limit) { @Transactional public void saveDailyBudgetCustom(Long memberProfileId, Long limit) { MemberProfile profile = memberProfileRepository.findById(memberProfileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); DailyBudget dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(limit), LocalDate.now()); budgetRepository.save(dailyBudget); } diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java b/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java index f028d68..4da0e08 100644 --- a/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java +++ b/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java @@ -24,7 +24,7 @@ public class MemberCategoryPreferenceService { @Transactional public void savePreferences(Long profileId, List liked, List disliked) { MemberProfile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); preferenceRepository.deleteByMemberProfile_Id(profileId); diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java b/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java index 8176c88..4c19ce3 100644 --- a/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java +++ b/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java @@ -28,7 +28,7 @@ public class MemberProfileService { public MemberProfile getProfileFetch(Long profileId) { return memberProfileRepository.findMemberProfileEntityGraphById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); } @Transactional @@ -54,7 +54,7 @@ public void createProfile(String nickName, Long memberId, MemberType type, Long @Transactional public void changeProfile(Long profileId, String nickName, MemberType type, Long groupId) { MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); profile.changeNickName(nickName); profile.changeMemberType(type); @@ -67,7 +67,7 @@ public void changeProfile(Long profileId, String nickName, MemberType type, Long @Transactional public void changeAddressToPrimary(Long profileId, Long addressId) { MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); AddressEntity targetAddressEntity = addressEntityRepository.findById(addressId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); profile.setPrimaryAddress(targetAddressEntity); @@ -76,7 +76,7 @@ public void changeAddressToPrimary(Long profileId, Long addressId) { @Transactional public void saveNewAddress(Long profileId, Address address, String alias, AddressType addressType) { MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); AddressEntity addressEntity = AddressEntity.builder() .address(address) .alias(alias) @@ -90,7 +90,7 @@ public void saveNewAddress(Long profileId, Address address, String alias, Addres public void changeAddress(Long profileId, Long addressEntityId, Address address, String alias, AddressType addressType) { MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); AddressEntity addressEntity = addressEntityRepository.findById(addressEntityId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); profile.changeAddress(addressEntity, address, alias, addressType); @@ -99,7 +99,7 @@ public void changeAddress(Long profileId, Long addressEntityId, Address address, @Transactional public void deleteAddress(Long profileId, Long addressEntityId) { MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); AddressEntity addressEntity = addressEntityRepository.findById(addressEntityId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); profile.removeAddress(addressEntity); @@ -108,7 +108,7 @@ public void deleteAddress(Long profileId, Long addressEntityId) { @Transactional public void registerDefaultBudgets(Long profileId, Long dailyLimit, Long monthlyLimit) { MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); profile.registerDefaultBudgets(BigDecimal.valueOf(dailyLimit), BigDecimal.valueOf(monthlyLimit)); } } diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberService.java b/src/main/java/com/stcom/smartmealtable/service/MemberService.java index a358e1c..22ee0e2 100644 --- a/src/main/java/com/stcom/smartmealtable/service/MemberService.java +++ b/src/main/java/com/stcom/smartmealtable/service/MemberService.java @@ -22,8 +22,7 @@ public class MemberService { private final AddressEntityRepository addressEntityRepository; /** - * 이메일 중복 검사를 수행합니다. - * 동일한 이메일을 가진 회원이 이미 존재하면 예외를 발생시킵니다. + * 이메일 중복 검사를 수행합니다. 동일한 이메일을 가진 회원이 이미 존재하면 예외를 발생시킵니다. * * @param email 중복 검사할 이메일 * @throws IllegalArgumentException 이메일이 이미 존재하는 경우 @@ -46,20 +45,20 @@ public Member findMemberByMemberId(Long memberId) { public void checkPasswordDoubly(String password, String confirmPassword) { if (!password.equals(confirmPassword)) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다"); + throw new IllegalArgumentException("검증 비밀번호가 일치하지 않습니다"); } } public void changePassword(Long memberId, String originPassword, String newPassword) throws PasswordFailedExceededException, PasswordPolicyException { Member findMember = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalStateException("회원이 존재하지 않습니다")); + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다")); findMember.changePassword(originPassword, newPassword); } public void deleteByMemberId(Long memberId) { Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalStateException("회원이 존재하지 않습니다")); + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다")); memberProfileRepository.deleteMemberProfileByMember(member); socialAccountRepository.deleteSocialAccountByMember(member); memberRepository.delete(member); diff --git a/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java b/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java index ce16baf..f2db649 100644 --- a/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java +++ b/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java @@ -39,7 +39,7 @@ public void createNewMemberAndLinkSocialAccount(TokenDto tokenDto) { @Transactional public void linkSocialAccount(TokenDto tokenDto) { Member member = memberRepository.findByEmail(tokenDto.getEmail()) - .orElseThrow(() -> new IllegalStateException("회원이 null일 수는 없습니다")); + .orElseThrow(() -> new IllegalStateException("회원 엔티티가 존재하지 않은 상태로 소셜 계정 연결을 시도했습니다.")); SocialAccount socialAccount = SocialAccount.builder() .member(member) @@ -65,7 +65,7 @@ public boolean isNewUser(String provider, String providerUserId) { public void updateToken(Long socialAccountId, String accessToken, String refreshToken, LocalDateTime tokenExpiresAt) { SocialAccount socialAccount = socialAccountRepository.findById(socialAccountId) - .orElseThrow(() -> new IllegalStateException("확인되지 않은 계정입니다")); + .orElseThrow(() -> new IllegalArgumentException("확인되지 않은 계정입니다")); socialAccount.updateToken(accessToken, refreshToken, tokenExpiresAt); } diff --git a/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java b/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java index 859c326..65f73ce 100644 --- a/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java +++ b/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java @@ -1,5 +1,6 @@ package com.stcom.smartmealtable.web.exhandler; +import com.stcom.smartmealtable.exception.BizLogicException; import com.stcom.smartmealtable.exception.ExternApiStatusError; import com.stcom.smartmealtable.exception.PasswordFailedExceededException; import com.stcom.smartmealtable.exception.PasswordPolicyException; @@ -11,7 +12,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -40,54 +40,53 @@ public ApiResponse passwordPolicyExHandler(PasswordPolicyException e) { return ApiResponse.createError(e.getMessage()); } + @ExceptionHandler(PasswordFailedExceededException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiResponse passwordFailedExceededExHandler(PasswordFailedExceededException e) { + log.error("[PasswordFailedExceededException] ex", e); + return ApiResponse.createError(e.getMessage()); + } + + @ExceptionHandler(BizLogicException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse bizLogicExHandler(BizLogicException e) { + log.error("[BizLogicException] ex", e); + return ApiResponse.createError("불가능한 시도입니다. 사유: " + e.getMessage()); + } + @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse illegalArgumentExHandler(IllegalArgumentException e) { log.error("[IllegalArgumentException] ex", e); - return ApiResponse.createError("파라미터나 API 스펙을 확인해보세요" + e.getMessage()); + return ApiResponse.createError("잘못된 데이터가 전달되었습니다. 사유: " + e.getMessage()); } @ExceptionHandler(IllegalStateException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse illegalStateExHandler(IllegalArgumentException e) { log.error("[IllegalStateException] ex", e); - return ApiResponse.createError("파라미터나 API 스펙을 확인해보세요" + e.getMessage()); - } - - @ExceptionHandler(PasswordFailedExceededException.class) - @ResponseStatus(HttpStatus.UNAUTHORIZED) - public ApiResponse passwordFailedExceededExHandler(PasswordFailedExceededException e) { - log.error("[PasswordFailedExceededException] ex", e); - return ApiResponse.createError(e.getMessage()); + return ApiResponse.createError("서버 내부 동작 오류입니다. 사유: " + e.getMessage()); } @ExceptionHandler(ExternApiStatusError.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse externApiStatusErrorHandler(ExternApiStatusError e) { log.error("[ExternApiStatusError] ex", e); - return ApiResponse.createError("외부 API 호출 중 오류가 발생했습니다: " + e.getMessage()); - } - - @ExceptionHandler(HttpClientErrorException.BadRequest.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ApiResponse handleHttpClientErrorBadRequest(HttpClientErrorException.BadRequest e) { - log.error("[HttpClientErrorException.BadRequest] ex", e); - String responseBody = e.getResponseBodyAsString(); - return ApiResponse.createError("외부 OAuth 인증 실패: 잘못된 인증 코드입니다. " + responseBody); + return ApiResponse.createError("외부 API 호출 중 오류가 발생했습니다. 사유: " + e.getMessage()); } @ExceptionHandler(RuntimeException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse runtimeExHandler(Exception e) { log.error("[RuntimeException] ex", e); - return ApiResponse.createError("서버 내부에서 언체크 예외가 발생했습니다"); + return ApiResponse.createError("서버 내부에서 알 수 없는 오류가 발생했습니다. " + e.getMessage()); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse exHandler(Exception e) { log.error("[Exception] ex", e); - return ApiResponse.createError("서버 내부에서 체크 예외가 발생했습니다"); + return ApiResponse.createError("서버 내부에서 알 수 없는 오류가 발생했습니다. " + e.getMessage()); } @ExceptionHandler(NoResourceFoundException.class) From 282dabc3c1e138b9b2ec37ec38e8854735b3b99e Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 17:02:24 +0900 Subject: [PATCH 04/44] =?UTF-8?q?fix:=20Group=20Api=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ID 값을 포함하여 반환화도록 합니다. --- .../stcom/smartmealtable/web/controller/GroupController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java index b7e14fd..edfb746 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java @@ -33,11 +33,13 @@ public ApiResponse> searchGroup(@RequestParam String keyword) { @Data @AllArgsConstructor static class GroupDto { + private Long id; private String roadAddress; private String name; private String groupType; public GroupDto(Group group) { + this.id = group.getId(); this.groupType = group.getTypeName(); this.name = group.getName(); this.roadAddress = group.getAddress().getRoadAddress(); From 5f9a1dbd8f03cb3b6a3a55076d22532c2f09d7da Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 17:34:33 +0900 Subject: [PATCH 05/44] =?UTF-8?q?fix:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20Dto=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stcom/smartmealtable/infrastructure/dto/AddressRequest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java index 16f2761..536d5b5 100644 --- a/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java @@ -9,7 +9,5 @@ public class AddressRequest { private String roadAddress; - private String alias; - private String detailAddress; } From b123572cf88db4d44d2b1f0056d6607146258f59 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 17:35:39 +0900 Subject: [PATCH 06/44] =?UTF-8?q?refactor:=20Api=20URI=20=EB=B0=8F=20Dto?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. /profile -> /profiles 2. AddressCUReqeust -> MemberAddressCURequest --- .../controller/MemberProfileController.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index a2ad848..bb16d35 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -30,7 +30,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/members/profile") +@RequestMapping("/api/v1/members/profiles") public class MemberProfileController { private final MemberProfileService memberProfileService; @@ -46,7 +46,7 @@ public ApiResponse getMemberProfilePageInfo(@UserCont @PostMapping() public ApiResponse createMemberProfile(@UserContext MemberDto memberDto, - @Validated @RequestBody MemberProfileRequest request) { + @Validated @RequestBody MemberProfileRequest request) { memberProfileService.createProfile(request.getNickName(), memberDto.getMemberId(), request.getMemberType(), request.getGroupId()); return ApiResponse.createSuccessWithNoContent(); @@ -54,20 +54,21 @@ public ApiResponse createMemberProfile(@UserContext MemberDto memberDto, @PatchMapping("/me") public ApiResponse changeMemberProfile(@UserContext MemberDto memberDto, - @Validated @RequestBody MemberProfileRequest request) { + @Validated @RequestBody MemberProfileRequest request) { memberProfileService.changeProfile(memberDto.getProfileId(), request.getNickName(), request.getMemberType(), request.getGroupId()); return ApiResponse.createSuccessWithNoContent(); } @PostMapping("/me/addresses/{id}/primary") - public ApiResponse changePrimaryAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId) { + public ApiResponse changePrimaryAddress(@UserContext MemberDto memberDto, + @PathVariable("id") Long addressId) { memberProfileService.changeAddressToPrimary(memberDto.getProfileId(), addressId); return ApiResponse.createSuccessWithNoContent(); } @PostMapping("/me/addresses") - public ApiResponse registerAddress(@UserContext MemberDto memberDto, AddressCURequest request) { + public ApiResponse registerAddress(@UserContext MemberDto memberDto, MemberAddressCURequest request) { Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); memberProfileService.saveNewAddress(memberDto.getProfileId(), address, request.getAlias(), request.getAddressType()); @@ -76,7 +77,7 @@ public ApiResponse registerAddress(@UserContext MemberDto memberDto, Addre @PatchMapping("/me/addresses/{id}") public ApiResponse changeAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId, - AddressCURequest request) { + MemberAddressCURequest request) { Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); memberProfileService.changeAddress(memberDto.getProfileId(), addressId, address, request.getAlias(), request.getAddressType()); @@ -115,7 +116,7 @@ public ApiResponse getCategoryPreferences(@UserContext Memb @PostMapping("/me/preferences") public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDto, - @RequestBody PreferencesRequest request) { + @RequestBody PreferencesRequest request) { memberCategoryPreferenceService.savePreferences( memberDto.getProfileId(), request.getLiked(), @@ -125,7 +126,7 @@ public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDt @PostMapping("/me/budgets") public ApiResponse createDefaultBudgets(@UserContext MemberDto memberDto, - @RequestBody BudgetRequest budgetRequest) { + @RequestBody BudgetRequest budgetRequest) { memberProfileService.registerDefaultBudgets(memberDto.getProfileId(), budgetRequest.getDailyLimit(), budgetRequest.getMonthlyLimit()); return ApiResponse.createSuccessWithNoContent(); @@ -160,14 +161,14 @@ static class MemberProfileRequest { @AllArgsConstructor @Data - static class AddressCURequest { + static class MemberAddressCURequest { private String roadAddress; private AddressType addressType; private String alias; private String detailAddress; public AddressRequest toAddressApiRequest() { - return new AddressRequest(roadAddress, alias, detailAddress); + return new AddressRequest(roadAddress, detailAddress); } } From 3960006363a15e14bc97fef489529e67456e827f Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 17:38:34 +0900 Subject: [PATCH 07/44] =?UTF-8?q?feat:=20SchoolGroup=20CRUD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smartmealtable/domain/group/Group.java | 10 +++ .../domain/group/SchoolGroup.java | 10 +++ .../smartmealtable/service/GroupService.java | 11 ++++ .../service/GroupServiceImpl.java | 34 +++++++++- .../web/controller/GroupController.java | 65 +++++++++++++++++++ 5 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/Group.java b/src/main/java/com/stcom/smartmealtable/domain/group/Group.java index d221901..ad2552d 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/group/Group.java +++ b/src/main/java/com/stcom/smartmealtable/domain/group/Group.java @@ -32,6 +32,16 @@ public abstract class Group { private String name; + public Group(Address address, String name) { + this.address = address; + this.name = name; + } + public abstract String getTypeName(); + public void changeNameAndAddress(String name, Address address) { + this.name = name; + this.address = address; + } + } diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java b/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java index 17b1858..6da1a40 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java +++ b/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java @@ -1,5 +1,6 @@ package com.stcom.smartmealtable.domain.group; +import com.stcom.smartmealtable.domain.Address.Address; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -11,6 +12,11 @@ @NoArgsConstructor public class SchoolGroup extends Group { + public SchoolGroup(Address address, String name, SchoolType schoolType) { + super(address, name); + this.schoolType = schoolType; + } + @Enumerated(EnumType.STRING) private SchoolType schoolType; @@ -18,4 +24,8 @@ public class SchoolGroup extends Group { public String getTypeName() { return schoolType.name(); } + + public void changeType(SchoolType schoolType) { + this.schoolType = schoolType; + } } diff --git a/src/main/java/com/stcom/smartmealtable/service/GroupService.java b/src/main/java/com/stcom/smartmealtable/service/GroupService.java index 055cd74..e74b69c 100644 --- a/src/main/java/com/stcom/smartmealtable/service/GroupService.java +++ b/src/main/java/com/stcom/smartmealtable/service/GroupService.java @@ -1,9 +1,20 @@ package com.stcom.smartmealtable.service; import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import jakarta.validation.constraints.NotEmpty; import java.util.List; public interface GroupService { Group findGroupByGroupId(Long groupId); + List findGroupsByKeyword(String keyword); + + void createSchoolGroup(@NotEmpty AddressRequest addressRequest, @NotEmpty String name, @NotEmpty SchoolType type); + + void changeSchoolGroup(@NotEmpty Long id, AddressRequest addressRequest, @NotEmpty String name, + @NotEmpty SchoolType type); + + void deleteGroup(Long id); } diff --git a/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java b/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java index 9fccfd8..e94c5f5 100644 --- a/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java +++ b/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java @@ -1,6 +1,11 @@ package com.stcom.smartmealtable.service; +import com.stcom.smartmealtable.domain.Address.Address; import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.infrastructure.KakaoAddressApiService; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; import com.stcom.smartmealtable.repository.GroupRepository; import java.util.List; import lombok.RequiredArgsConstructor; @@ -14,6 +19,7 @@ public class GroupServiceImpl implements GroupService { private final GroupRepository groupRepository; + private final KakaoAddressApiService addressApiService; @Override public Group findGroupByGroupId(Long groupId) { @@ -25,4 +31,30 @@ public Group findGroupByGroupId(Long groupId) { public List findGroupsByKeyword(String keyword) { return groupRepository.findByNameContaining(keyword, Limit.of(10)); } -} \ No newline at end of file + + @Override + @Transactional + public void createSchoolGroup(AddressRequest request, String name, SchoolType type) { + Address address = addressApiService.createAddressFromRequest(request); + Group group = new SchoolGroup(address, name, type); + groupRepository.save(group); + } + + @Override + @Transactional + public void changeSchoolGroup(Long id, AddressRequest request, String name, SchoolType type) { + SchoolGroup group = (SchoolGroup) groupRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + Address address = addressApiService.createAddressFromRequest(request); + group.changeNameAndAddress(name, address); + group.changeType(type); + } + + @Override + @Transactional + public void deleteGroup(Long id) { + Group group = groupRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + groupRepository.delete(group); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java index edfb746..cc3c121 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java @@ -1,13 +1,22 @@ package com.stcom.smartmealtable.web.controller; import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; import com.stcom.smartmealtable.service.GroupService; import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.constraints.NotEmpty; import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -30,6 +39,28 @@ public ApiResponse> searchGroup(@RequestParam String keyword) { .toList()); } + @PostMapping("/schools") + public ApiResponse registerSchoolGroup(@RequestBody @Validated SchoolGroupCreateRequest request) { + groupService.createSchoolGroup(new AddressRequest(request.getRoadAddress(), request.getDetailAddress()), + request.getName(), request.getType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/schools/{id}") + public ApiResponse editSchoolGroup(@PathVariable("id") Long id, + @RequestBody @Validated SchoolGroupUpdateRequest request) { + groupService.changeSchoolGroup(id, + new AddressRequest(request.getRoadAddress(), request.getDetailAddress()), + request.getName(), request.getType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteGroup(@PathVariable("id") Long id) { + groupService.deleteGroup(id); + return ApiResponse.createSuccessWithNoContent(); + } + @Data @AllArgsConstructor static class GroupDto { @@ -45,4 +76,38 @@ public GroupDto(Group group) { this.roadAddress = group.getAddress().getRoadAddress(); } } + + @Data + @AllArgsConstructor + static class SchoolGroupCreateRequest { + + @NotEmpty + private String roadAddress; + + @NotEmpty + private String detailAddress; + + @NotEmpty + private String name; + + @NotEmpty + private SchoolType type; + } + + @Data + @AllArgsConstructor + static class SchoolGroupUpdateRequest { + + @NotEmpty + private String roadAddress; + + @NotEmpty + private String detailAddress; + + @NotEmpty + private String name; + + @NotEmpty + private SchoolType type; + } } From 6dfa623e44fa4e70235c30d0116d8eed93b6dcbe Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 17:54:09 +0900 Subject: [PATCH 08/44] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EB=90=9C=20=EA=B2=80=EC=A6=9D=20=EC=95=A0=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/stcom/smartmealtable/service/GroupService.java | 5 +++-- .../stcom/smartmealtable/web/controller/GroupController.java | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/service/GroupService.java b/src/main/java/com/stcom/smartmealtable/service/GroupService.java index e74b69c..40a0e29 100644 --- a/src/main/java/com/stcom/smartmealtable/service/GroupService.java +++ b/src/main/java/com/stcom/smartmealtable/service/GroupService.java @@ -4,6 +4,7 @@ import com.stcom.smartmealtable.domain.group.SchoolType; import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import java.util.List; public interface GroupService { @@ -13,8 +14,8 @@ public interface GroupService { void createSchoolGroup(@NotEmpty AddressRequest addressRequest, @NotEmpty String name, @NotEmpty SchoolType type); - void changeSchoolGroup(@NotEmpty Long id, AddressRequest addressRequest, @NotEmpty String name, - @NotEmpty SchoolType type); + void changeSchoolGroup(@NotNull Long id, AddressRequest addressRequest, @NotEmpty String name, + @NotNull SchoolType type); void deleteGroup(Long id); } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java index cc3c121..6912bf5 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java @@ -6,6 +6,7 @@ import com.stcom.smartmealtable.service.GroupService; import com.stcom.smartmealtable.web.dto.ApiResponse; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; @@ -90,7 +91,7 @@ static class SchoolGroupCreateRequest { @NotEmpty private String name; - @NotEmpty + @NotNull private SchoolType type; } @@ -107,7 +108,7 @@ static class SchoolGroupUpdateRequest { @NotEmpty private String name; - @NotEmpty + @NotNull private SchoolType type; } } From 2a911b570d1b7cb56d37739c25b799251273c4a2 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Mon, 9 Jun 2025 17:54:45 +0900 Subject: [PATCH 09/44] =?UTF-8?q?refactor:=20pattern=20variable=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐스팅 시 타입 체크를 추가하고, 동시에 pattern variable을 적용합니다. --- .../com/stcom/smartmealtable/service/GroupServiceImpl.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java b/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java index e94c5f5..3da9bfc 100644 --- a/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java +++ b/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java @@ -43,8 +43,11 @@ public void createSchoolGroup(AddressRequest request, String name, SchoolType ty @Override @Transactional public void changeSchoolGroup(Long id, AddressRequest request, String name, SchoolType type) { - SchoolGroup group = (SchoolGroup) groupRepository.findById(id) + Group foundGroup = groupRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + if (!(foundGroup instanceof SchoolGroup group)) { + throw new IllegalArgumentException("학교 그룹이 아닙니다"); + } Address address = addressApiService.createAddressFromRequest(request); group.changeNameAndAddress(name, address); group.changeType(type); From 04f145879a94212ed5be386a47b5ba8eb151bb6c Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 21:48:23 +0900 Subject: [PATCH 10/44] =?UTF-8?q?feat:=20=EC=98=88=EC=82=B0=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20CRUD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smartmealtable/domain/Budget/Budget.java | 4 + .../repository/BudgetRepository.java | 15 +++ .../smartmealtable/service/BudgetService.java | 58 +++++++++ .../controller/MemberProfileController.java | 114 ++++++++++++++++-- 4 files changed, 183 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java index 7dc44de..dbd85c9 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java @@ -66,4 +66,8 @@ public BigDecimal getAvailableAmount() { public boolean isOverLimit() { return spendAmount.compareTo(limit) > 0; } + + public void changeLimit(BigDecimal limit) { + this.limit = limit; + } } diff --git a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java index b9bc10b..10ab067 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java @@ -3,6 +3,8 @@ import com.stcom.smartmealtable.domain.Budget.Budget; import com.stcom.smartmealtable.domain.Budget.DailyBudget; import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -22,4 +24,17 @@ public interface BudgetRepository extends JpaRepository { @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :memberProfileId order by treat(b as MonthlyBudget).yearMonth desc") Optional findFirstMonthlyBudgetByMemberProfileId(@Param("memberProfileId") Long memberProfileId); + + @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date = :date") + Optional findDailyBudgetByMemberProfileIdAndDate(Long profileId, LocalDate date); + + @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :profileId and treat(b as MonthlyBudget).yearMonth = :date") + Optional findMonthlyBudgetByMemberProfileIdAndDate(Long profileId, LocalDate date); + + @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :profileId and treat(b as MonthlyBudget).yearMonth = :yearMonth") + Optional findMonthlyBudgetByMemberProfileIdAndYearMonth(Long profileId, YearMonth yearMonth); + + @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date between :startOfWeek and :endOfWeek") + List findDailyBudgetsByMemberProfileIdAndDateBetween(Long profileId, LocalDate startOfWeek, + LocalDate endOfWeek); } diff --git a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java index 73f33f2..664472e 100644 --- a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java +++ b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java @@ -1,5 +1,7 @@ package com.stcom.smartmealtable.service; +import static java.time.DayOfWeek.MONDAY; + import com.stcom.smartmealtable.domain.Budget.DailyBudget; import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; import com.stcom.smartmealtable.domain.member.MemberProfile; @@ -8,6 +10,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.YearMonth; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,4 +48,59 @@ public void saveDailyBudgetCustom(Long memberProfileId, Long limit) { DailyBudget dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(limit), LocalDate.now()); budgetRepository.save(dailyBudget); } + + + public DailyBudget getDailyBudgetBy(Long profileId, LocalDate date) { + return budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profileId, date).orElseThrow(() -> + new IllegalArgumentException("존재하지 않는 프로필로 접근") + ); + } + + @Transactional + public void registerDefaultDailyBudgetBy(Long profileId, Long dailyLimit, LocalDate startDate) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); + // 일일 예산은 오늘 ~ 이번달 말일까지 디폴트 daily Limit로 여러 개 생성해준다. + for (LocalDate date = startDate; date.getMonth() == startDate.getMonth(); date = date.plusDays(1)) { + DailyBudget dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(dailyLimit), date); + budgetRepository.save(dailyBudget); + } + } + + @Transactional + public void registerDefaultMonthlyBudgetBy(Long profileId, Long monthlyLimit, + YearMonth startYearMonth) { + + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); + // 월간 예산은 이번달에 한 번만 생성해준다. + MonthlyBudget monthlyBudget = new MonthlyBudget(profile, BigDecimal.valueOf(monthlyLimit), startYearMonth); + budgetRepository.save(monthlyBudget); + } + + public MonthlyBudget getMonthlyBudgetBy(Long profileId, YearMonth yearMonth) { + return budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profileId, yearMonth) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); + } + + public List getDailyBudgetsByWeek(Long profileId, LocalDate date) { + LocalDate startOfWeek = date.with(MONDAY); + LocalDate endOfWeek = startOfWeek.plusDays(6); + return budgetRepository.findDailyBudgetsByMemberProfileIdAndDateBetween(profileId, startOfWeek, endOfWeek); + } + + @Transactional + public void editMonthlyBudgetCustom(Long profileId, YearMonth yearMonth, Long limit) { + budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profileId, + yearMonth) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")) + .changeLimit(BigDecimal.valueOf(limit)); + } + + @Transactional + public void editDailyBudgetCustom(Long profileId, LocalDate date, Long limit) { + budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profileId, date) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")) + .changeLimit(BigDecimal.valueOf(limit)); + } } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index bb16d35..0aba454 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -2,6 +2,9 @@ import com.stcom.smartmealtable.domain.Address.Address; import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.Budget.Budget; +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; import com.stcom.smartmealtable.domain.food.PreferenceType; import com.stcom.smartmealtable.domain.member.MemberProfile; @@ -14,6 +17,8 @@ import com.stcom.smartmealtable.service.dto.MemberDto; import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; @@ -24,8 +29,10 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -124,14 +131,80 @@ public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDt return ApiResponse.createSuccessWithNoContent(); } - @PostMapping("/me/budgets") - public ApiResponse createDefaultBudgets(@UserContext MemberDto memberDto, - @RequestBody BudgetRequest budgetRequest) { - memberProfileService.registerDefaultBudgets(memberDto.getProfileId(), budgetRequest.getDailyLimit(), - budgetRequest.getMonthlyLimit()); + /** + * 일별 예산 조회 + * + * @param memberDto + * @param date (ISO_LOCAL_DATE 형식, 예: 2025-06-01) + * @return + */ + @GetMapping("/me/budgets/daily/{date}") + public ApiResponse dailyBudgetByDate(@UserContext MemberDto memberDto, + @PathVariable("date") String date) { + DailyBudget dailyBudget = budgetService.getDailyBudgetBy(memberDto.getProfileId(), LocalDate.parse(date)); + return ApiResponse.createSuccess(DailyBudgetResponse.of(dailyBudget)); + } + + @PutMapping("/me/budgets/daily/{date}/default") + public ApiResponse registerDefaultDailyBudget(@UserContext MemberDto memberDto, + @PathVariable("date") String date, + @RequestParam("limit") Long limit) { + budgetService.registerDefaultDailyBudgetBy(memberDto.getProfileId(), limit, LocalDate.parse(date)); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/me/budgets/daily/{date}") + public ApiResponse editDailyBudget(@UserContext MemberDto memberDto, + @PathVariable("date") String date, + @RequestParam("limit") Long limit) { + budgetService.editDailyBudgetCustom(memberDto.getProfileId(), LocalDate.parse(date), limit); + return ApiResponse.createSuccessWithNoContent(); + } + + // 해당 일자가 속한 일일 예산 주간 데이터 조회 + @GetMapping("/me/budgets/daily/{date}/week") + public ApiResponse> dailyBudgetWeekByDate(@UserContext MemberDto memberDto, + @PathVariable("date") String date) { + List dailyBudgets = budgetService.getDailyBudgetsByWeek(memberDto.getProfileId(), + LocalDate.parse(date)); + + List responses = dailyBudgets.stream() + .map(DailyBudgetResponse::of) + .toList(); + + return ApiResponse.createSuccess(responses); + } + + @GetMapping("/me/budgets/month/{yearMonth}") + public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, + @PathVariable("yearMonth") String yearMonth) { + MonthlyBudget monthlyBudget = budgetService.getMonthlyBudgetBy(memberDto.getProfileId(), + YearMonth.parse(yearMonth)); + + return ApiResponse.createSuccess(MonthlyBudgetResponse.of(monthlyBudget)); + } + + @PutMapping("/me/budgets/month/{yearMonth}/default") + public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto memberDto, + @PathVariable("yearMonth") String yearMonth, + @RequestParam("limit") Long limit) { + budgetService.registerDefaultMonthlyBudgetBy(memberDto.getProfileId(), + limit, YearMonth.parse(yearMonth)); + + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/me/budgets/monthly/{yearMonth}") + public ApiResponse editMonthlyBudget(@UserContext MemberDto memberDto, + @PathVariable("yearMonth") String yearMonth, + @RequestParam("limit") Long limit) { + budgetService.editMonthlyBudgetCustom(memberDto.getProfileId(), + YearMonth.parse(yearMonth), limit); + return ApiResponse.createSuccessWithNoContent(); } + @AllArgsConstructor @Data static class MemberProfilePageResponse { @@ -196,9 +269,34 @@ static class CategoryPreferenceDto { @AllArgsConstructor @Data - static class BudgetRequest { - private Long dailyLimit; - private Long monthlyLimit; + static class DailyBudgetResponse { + private Long dailySpentAmount; + private Long dailyLimitAmount; + private Long dailyAvailableAmount; + + public static DailyBudgetResponse of(Budget dailyBudget) { + return new DailyBudgetResponse( + dailyBudget.getSpendAmount().longValue(), + dailyBudget.getLimit().longValue(), + dailyBudget.getAvailableAmount().longValue() + ); + } + } + + @AllArgsConstructor + @Data + static class MonthlyBudgetResponse { + private Long monthlySpentAmount; + private Long monthlyLimitAmount; + private Long monthlyAvailableAmount; + + public static MonthlyBudgetResponse of(Budget monthlyBudget) { + return new MonthlyBudgetResponse( + monthlyBudget.getSpendAmount().longValue(), + monthlyBudget.getLimit().longValue(), + monthlyBudget.getAvailableAmount().longValue() + ); + } } } From d8f1e47cc09f9fe1855e316a83bb893bf23fee96 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 21:59:49 +0900 Subject: [PATCH 11/44] =?UTF-8?q?test:=20=EC=98=88=EC=82=B0=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BudgetRepository.java | 15 +- .../Budget/BudgetDomainIntegrationTest.java | 280 ++++++++++++ .../integration/BudgetIntegrationTest.java | 309 ++++++++++++++ .../BudgetRepositoryAdvancedTest.java | 398 ++++++++++++++++++ .../repository/BudgetRepositoryTest.java | 164 ++++++++ .../service/BudgetServiceIntegrationTest.java | 259 ++++++++++++ .../service/BudgetServiceTest.java | 33 +- 7 files changed, 1440 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/integration/BudgetIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryAdvancedTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java diff --git a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java index 10ab067..a82cd6c 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java @@ -7,6 +7,7 @@ import java.time.YearMonth; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -17,13 +18,23 @@ public interface BudgetRepository extends JpaRepository { List findDailyBudgetsViaType(@Param("memberProfileId") Long memberProfileId); @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :memberProfileId order by treat(b as DailyBudget).date desc") - Optional findFirstDailyBudgetByMemberProfileId(@Param("memberProfileId") Long memberProfileId); + List findFirstDailyBudgetByMemberProfileIdList(@Param("memberProfileId") Long memberProfileId, Pageable pageable); + + default Optional findFirstDailyBudgetByMemberProfileId(Long memberProfileId) { + List results = findFirstDailyBudgetByMemberProfileIdList(memberProfileId, Pageable.ofSize(1)); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :memberProfileId") List findMonthlyBudgetsViaType(@Param("memberProfileId") Long memberProfileId); @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :memberProfileId order by treat(b as MonthlyBudget).yearMonth desc") - Optional findFirstMonthlyBudgetByMemberProfileId(@Param("memberProfileId") Long memberProfileId); + List findFirstMonthlyBudgetByMemberProfileIdList(@Param("memberProfileId") Long memberProfileId, Pageable pageable); + + default Optional findFirstMonthlyBudgetByMemberProfileId(Long memberProfileId) { + List results = findFirstMonthlyBudgetByMemberProfileIdList(memberProfileId, Pageable.ofSize(1)); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date = :date") Optional findDailyBudgetByMemberProfileIdAndDate(Long profileId, LocalDate date); diff --git a/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java new file mode 100644 index 0000000..9706cd8 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java @@ -0,0 +1,280 @@ +package com.stcom.smartmealtable.domain.Budget; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BudgetDomainIntegrationTest { + + private MemberProfile memberProfile; + + @BeforeEach + void setUp() { + memberProfile = new MemberProfile(); + } + + @DisplayName("예산 상속 구조와 다형성이 올바르게 작동한다") + @Test + void budgetPolymorphismTest() { + // given + List budgets = new ArrayList<>(); + + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), LocalDate.now()); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(500000), YearMonth.now()); + + budgets.add(dailyBudget); + budgets.add(monthlyBudget); + + // when & then - 다형성을 통한 공통 동작 확인 + for (Budget budget : budgets) { + // 초기 상태 검증 + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(budget.isOverLimit()).isFalse(); + + // 지출 추가 + budget.addSpent(BigDecimal.valueOf(10000)); + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(10000)); + + // 사용 가능 금액 계산 + BigDecimal expectedAvailable = budget.getLimit().subtract(BigDecimal.valueOf(10000)); + assertThat(budget.getAvailableAmount()).isEqualTo(expectedAvailable); + } + + // 각 타입별 고유 속성 확인 + assertThat(dailyBudget).isInstanceOf(DailyBudget.class); + assertThat(monthlyBudget).isInstanceOf(MonthlyBudget.class); + + assertThat(dailyBudget.getDate()).isNotNull(); + assertThat(monthlyBudget.getYearMonth()).isNotNull(); + } + + @DisplayName("예산 한도 변경 시 비즈니스 로직이 올바르게 동작한다") + @Test + void budgetLimitChangeBusinessLogic() { + // given + DailyBudget budget = new DailyBudget(memberProfile, BigDecimal.valueOf(30000), LocalDate.now()); + budget.addSpent(20000); // 20,000원 지출 + + // when & then - 한도 증가 + budget.changeLimit(BigDecimal.valueOf(50000)); + assertThat(budget.getLimit()).isEqualTo(BigDecimal.valueOf(50000)); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(30000)); + assertThat(budget.isOverLimit()).isFalse(); + + // when & then - 한도 감소 (한도 초과 상황) + budget.changeLimit(BigDecimal.valueOf(15000)); + assertThat(budget.getLimit()).isEqualTo(BigDecimal.valueOf(15000)); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(-5000)); + assertThat(budget.isOverLimit()).isTrue(); + } + + @DisplayName("복잡한 지출 패턴 계산") + @Test + void complexSpendingPatternCalculation() { + // given + DailyBudget weeklyBudget = new DailyBudget( + memberProfile, + BigDecimal.valueOf(150000), + LocalDate.now() + ); + + // 다양한 금액으로 여러 번 지출 + weeklyBudget.addSpent(25000); + weeklyBudget.addSpent(15000); + weeklyBudget.addSpent(7500); + weeklyBudget.addSpent(12500); // 총 60,000원 지출 + + // when + BigDecimal totalSpent = weeklyBudget.getSpendAmount(); + BigDecimal remaining = weeklyBudget.getAvailableAmount(); + + // 사용률 계산: 60,000 / 150,000 = 0.4 (40%) + BigDecimal usageRate = totalSpent + .divide(weeklyBudget.getLimit(), 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP); + + // then - 정확한 계산 검증 + assertThat(totalSpent).isEqualTo(BigDecimal.valueOf(60000)); + assertThat(remaining).isEqualTo(BigDecimal.valueOf(90000)); // 150,000 - 60,000 + + BigDecimal expectedUsageRate = BigDecimal.valueOf(40.00); + assertThat(usageRate).isCloseTo(expectedUsageRate, within(BigDecimal.valueOf(0.01))); + } + + @DisplayName("예산 리셋 기능") + @Test + void budgetResetFunctionality() { + // given + DailyBudget budget = new DailyBudget( + memberProfile, + BigDecimal.valueOf(50000), + LocalDate.now() + ); + + budget.addSpent(30000); + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(30000)); + + // when - 예산 지출 리셋 + budget.resetSpent(); + + // then + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(50000)); + + // 사용률 = 0 / 50000 * 100 = 0% (0 나누기 문제 없음) + BigDecimal usageRate = budget.getSpendAmount() + .multiply(BigDecimal.valueOf(100)) + .divide(budget.getLimit(), 2, RoundingMode.HALF_UP); + assertThat(usageRate).isEqualTo(BigDecimal.ZERO); + } + + @DisplayName("예산의 부동소수점 정밀도 테스트") + @Test + void budgetFloatingPointPrecisionTest() { + // given + MonthlyBudget budget = new MonthlyBudget( + memberProfile, + BigDecimal.valueOf(1000000), + YearMonth.of(2025, 6) + ); + + // when - 소수점을 포함한 복잡한 계산 + budget.addSpent(333333); // 33.3333% + budget.addSpent(166667); // 추가로 16.6667% + + // then + BigDecimal totalSpent = budget.getSpendAmount(); + + // 사용률 계산: 500,000 / 1,000,000 = 0.5 (50%) + BigDecimal usageRate = totalSpent + .divide(budget.getLimit(), 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP); + + assertThat(totalSpent).isEqualTo(BigDecimal.valueOf(500000)); + + BigDecimal expectedUsageRate = BigDecimal.valueOf(50.00); + assertThat(usageRate).isCloseTo(expectedUsageRate, within(BigDecimal.valueOf(0.01))); + + // 남은 금액 검증 + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(500000)); + assertThat(budget.isOverLimit()).isFalse(); + } + + @DisplayName("예산 한도 초과 경계값 테스트") + @Test + void budgetOverLimitBoundaryTest() { + // given + DailyBudget budget = new DailyBudget(memberProfile, BigDecimal.valueOf(100000), LocalDate.now()); + + // when & then - 한도와 정확히 같은 금액 지출 + budget.addSpent(100000); + assertThat(budget.isOverLimit()).isFalse(); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.ZERO); + + // when & then - 1원 초과 + budget.addSpent(1); + assertThat(budget.isOverLimit()).isTrue(); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(-1)); + + // when & then - 리셋 후 1원 미만 지출 + budget.resetSpent(); + budget.addSpent(99999); + assertThat(budget.isOverLimit()).isFalse(); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(1)); + } + + @DisplayName("월별 예산과 일일 예산의 독립성 확인") + @Test + void monthlyAndDailyBudgetIndependence() { + // given + YearMonth currentMonth = YearMonth.now(); + LocalDate today = LocalDate.now(); + + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(500000), currentMonth); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), today); + + // when - 각각 다른 지출 추가 + monthlyBudget.addSpent(300000); + dailyBudget.addSpent(15000); + + // then - 독립적인 계산 확인 + assertThat(monthlyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(300000)); + assertThat(monthlyBudget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(200000)); + assertThat(monthlyBudget.isOverLimit()).isFalse(); + + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(15000)); + assertThat(dailyBudget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(5000)); + assertThat(dailyBudget.isOverLimit()).isFalse(); + + // 서로의 상태에 영향을 주지 않음 + monthlyBudget.addSpent(250000); // 월별 예산 초과 + assertThat(monthlyBudget.isOverLimit()).isTrue(); + assertThat(dailyBudget.isOverLimit()).isFalse(); // 일일 예산은 영향 없음 + } + + @DisplayName("예산 객체의 불변 속성 확인") + @Test + void budgetImmutablePropertiesTest() { + // given + LocalDate testDate = LocalDate.of(2025, 6, 15); + YearMonth testYearMonth = YearMonth.of(2025, 6); + + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(30000), testDate); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(800000), testYearMonth); + + // when - 예산에 지출 추가 및 리셋 + dailyBudget.addSpent(20000); + dailyBudget.resetSpent(); + dailyBudget.changeLimit(BigDecimal.valueOf(50000)); + + monthlyBudget.addSpent(400000); + monthlyBudget.resetSpent(); + monthlyBudget.changeLimit(BigDecimal.valueOf(1000000)); + + // then - 기본 속성들은 변경되지 않음 + assertThat(dailyBudget.getDate()).isEqualTo(testDate); + assertThat(dailyBudget.getMemberProfile()).isEqualTo(memberProfile); + + assertThat(monthlyBudget.getYearMonth()).isEqualTo(testYearMonth); + assertThat(monthlyBudget.getMemberProfile()).isEqualTo(memberProfile); + } + + @DisplayName("예산 생성 시 초기값 검증") + @Test + void budgetInitialValueValidation() { + // given & when + LocalDate today = LocalDate.now(); + YearMonth currentMonth = YearMonth.now(); + BigDecimal limit = BigDecimal.valueOf(100000); + + DailyBudget dailyBudget = new DailyBudget(memberProfile, limit, today); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, limit, currentMonth); + + // then - 초기값 검증 + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(dailyBudget.getLimit()).isEqualTo(limit); + assertThat(dailyBudget.getAvailableAmount()).isEqualTo(limit); + assertThat(dailyBudget.isOverLimit()).isFalse(); + assertThat(dailyBudget.getDate()).isEqualTo(today); + assertThat(dailyBudget.getMemberProfile()).isEqualTo(memberProfile); + + assertThat(monthlyBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(monthlyBudget.getLimit()).isEqualTo(limit); + assertThat(monthlyBudget.getAvailableAmount()).isEqualTo(limit); + assertThat(monthlyBudget.isOverLimit()).isFalse(); + assertThat(monthlyBudget.getYearMonth()).isEqualTo(currentMonth); + assertThat(monthlyBudget.getMemberProfile()).isEqualTo(memberProfile); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/integration/BudgetIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/integration/BudgetIntegrationTest.java new file mode 100644 index 0000000..6f77ed2 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/integration/BudgetIntegrationTest.java @@ -0,0 +1,309 @@ +package com.stcom.smartmealtable.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.service.BudgetService; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class BudgetIntegrationTest { + + @Autowired + private BudgetService budgetService; + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member member; + private MemberProfile memberProfile; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("integration@test.com") + .rawPassword("testPassword!") + .build(); + memberRepository.save(member); + + memberProfile = MemberProfile.builder() + .nickName("integrationTestUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + } + + @DisplayName("일일 예산 생성부터 지출 추가, 한도 초과 확인까지의 전체 시나리오 테스트") + @Test + void dailyBudgetCompleteScenario() { + // given - 일일 예산 생성 + LocalDate today = LocalDate.now(); + Long dailyLimit = 30000L; + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(dailyLimit), today); + budgetRepository.save(dailyBudget); + + // when & then - 1. 초기 상태 확인 + DailyBudget savedBudget = budgetService.getDailyBudgetBy(memberProfile.getId(), today); + assertThat(savedBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(savedBudget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(dailyLimit)); + assertThat(savedBudget.isOverLimit()).isFalse(); + + // when & then - 2. 첫 번째 지출 추가 (아직 한도 내) + savedBudget.addSpent(15000); + budgetRepository.save(savedBudget); + + DailyBudget afterFirstSpent = budgetService.getDailyBudgetBy(memberProfile.getId(), today); + assertThat(afterFirstSpent.getSpendAmount()).isEqualTo(BigDecimal.valueOf(15000)); + assertThat(afterFirstSpent.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(15000)); + assertThat(afterFirstSpent.isOverLimit()).isFalse(); + + // when & then - 3. 두 번째 지출 추가 (한도 초과) + afterFirstSpent.addSpent(20000); + budgetRepository.save(afterFirstSpent); + + DailyBudget afterSecondSpent = budgetService.getDailyBudgetBy(memberProfile.getId(), today); + assertThat(afterSecondSpent.getSpendAmount()).isEqualTo(BigDecimal.valueOf(35000)); + assertThat(afterSecondSpent.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(-5000)); + assertThat(afterSecondSpent.isOverLimit()).isTrue(); + + // when & then - 4. 지출 리셋 + afterSecondSpent.resetSpent(); + budgetRepository.save(afterSecondSpent); + + DailyBudget afterReset = budgetService.getDailyBudgetBy(memberProfile.getId(), today); + assertThat(afterReset.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(afterReset.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(dailyLimit)); + assertThat(afterReset.isOverLimit()).isFalse(); + } + + @DisplayName("월별 예산 생성부터 지출 추가, 한도 변경까지의 전체 시나리오 테스트") + @Test + void monthlyBudgetCompleteScenario() { + // given - 월별 예산 생성 + YearMonth currentMonth = YearMonth.now(); + Long monthlyLimit = 500000L; + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(monthlyLimit), currentMonth); + budgetRepository.save(monthlyBudget); + + // when & then - 1. 초기 상태 확인 + MonthlyBudget savedBudget = budgetService.getMonthlyBudgetBy(memberProfile.getId(), currentMonth); + assertThat(savedBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(savedBudget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(monthlyLimit)); + assertThat(savedBudget.isOverLimit()).isFalse(); + + // when & then - 2. 여러 번에 걸친 지출 추가 + savedBudget.addSpent(BigDecimal.valueOf(150000)); // 첫 번째 지출 + savedBudget.addSpent(200000); // 두 번째 지출 (int) + savedBudget.addSpent(99999.99); // 세 번째 지출 (double) + budgetRepository.save(savedBudget); + + MonthlyBudget afterSpending = budgetService.getMonthlyBudgetBy(memberProfile.getId(), currentMonth); + assertThat(afterSpending.getSpendAmount()).isEqualTo(BigDecimal.valueOf(449999.99)); + assertThat(afterSpending.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(50000.01)); + assertThat(afterSpending.isOverLimit()).isFalse(); + + // when & then - 3. 한도 변경 + Long newLimit = 400000L; + budgetService.editMonthlyBudgetCustom(memberProfile.getId(), currentMonth, newLimit); + + MonthlyBudget afterLimitChange = budgetService.getMonthlyBudgetBy(memberProfile.getId(), currentMonth); + assertThat(afterLimitChange.getLimit()).isEqualTo(BigDecimal.valueOf(newLimit)); + assertThat(afterLimitChange.getSpendAmount()).isEqualTo(BigDecimal.valueOf(449999.99)); + assertThat(afterLimitChange.isOverLimit()).isTrue(); // 새 한도로 인해 초과 상태 + } + + @DisplayName("한 달간의 일일 예산을 디폴트로 생성하고 개별 수정하는 시나리오") + @Test + void monthlyDailyBudgetManagementScenario() { + // given - 이번 달 1일부터 디폴트 일일 예산 생성 + LocalDate firstDayOfMonth = LocalDate.now().withDayOfMonth(1); + Long defaultDailyLimit = 20000L; + + // when - 디폴트 일일 예산 생성 + budgetService.registerDefaultDailyBudgetBy(memberProfile.getId(), defaultDailyLimit, firstDayOfMonth); + + // then - 1. 모든 일일 예산이 생성되었는지 확인 + List allDailyBudgets = budgetRepository.findDailyBudgetsViaType(memberProfile.getId()); + int daysInMonth = firstDayOfMonth.lengthOfMonth(); + assertThat(allDailyBudgets).hasSize(daysInMonth); + + // when & then - 2. 특정 날짜의 예산 한도 개별 수정 + LocalDate specificDate = firstDayOfMonth.plusDays(10); + Long newLimitForSpecificDate = 35000L; + budgetService.editDailyBudgetCustom(memberProfile.getId(), specificDate, newLimitForSpecificDate); + + DailyBudget modifiedBudget = budgetService.getDailyBudgetBy(memberProfile.getId(), specificDate); + assertThat(modifiedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(newLimitForSpecificDate)); + + // when & then - 3. 다른 날짜의 예산은 그대로인지 확인 + LocalDate anotherDate = firstDayOfMonth.plusDays(5); + DailyBudget unchangedBudget = budgetService.getDailyBudgetBy(memberProfile.getId(), anotherDate); + assertThat(unchangedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(defaultDailyLimit)); + } + + @DisplayName("주간 예산 조회 및 주간별 지출 패턴 분석 시나리오") + @Test + void weeklyBudgetAnalysisScenario() { + // given - 한 주간의 일일 예산 및 지출 설정 + LocalDate monday = LocalDate.of(2025, 6, 16); // 월요일 + Long dailyLimit = 25000L; + + for (int i = 0; i < 7; i++) { + LocalDate date = monday.plusDays(i); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(dailyLimit), date); + + // 요일별 다른 지출 패턴 설정 + if (i < 5) { // 평일 (월~금) + dailyBudget.addSpent(15000 + i * 2000); // 점진적 증가 + } else { // 주말 (토~일) + dailyBudget.addSpent(30000); // 주말 과소비 + } + + budgetRepository.save(dailyBudget); + } + + // when - 주간 예산 조회 (수요일 기준) + LocalDate wednesday = monday.plusDays(2); + List weeklyBudgets = budgetService.getDailyBudgetsByWeek(memberProfile.getId(), wednesday); + + // then - 주간 예산 분석 + assertThat(weeklyBudgets).hasSize(7); + + // 평일 예산 검증 + for (int i = 0; i < 5; i++) { + DailyBudget weekdayBudget = weeklyBudgets.get(i); + assertThat(weekdayBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(15000 + i * 2000)); + assertThat(weekdayBudget.isOverLimit()).isFalse(); + } + + // 주말 예산 검증 (한도 초과) + for (int i = 5; i < 7; i++) { + DailyBudget weekendBudget = weeklyBudgets.get(i); + assertThat(weekendBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(30000)); + assertThat(weekendBudget.isOverLimit()).isTrue(); + } + + // 주간 총 지출 계산 + BigDecimal totalWeeklySpent = weeklyBudgets.stream() + .map(DailyBudget::getSpendAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal expectedTotal = BigDecimal.valueOf( + 15000 + 17000 + 19000 + 21000 + 23000 + 30000 + 30000); // 155,000원 + assertThat(totalWeeklySpent).isEqualTo(expectedTotal); + } + + @DisplayName("다중 사용자 예산 격리 테스트") + @Test + void multiUserBudgetIsolationTest() { + // given - 두 번째 사용자 생성 + Member secondMember = Member.builder() + .email("second@test.com") + .rawPassword("password123!") + .build(); + memberRepository.save(secondMember); + + MemberProfile secondProfile = MemberProfile.builder() + .nickName("secondUser") + .member(secondMember) + .build(); + memberProfileRepository.save(secondProfile); + + // when - 두 사용자 모두에게 같은 날짜의 예산 생성 + LocalDate sameDate = LocalDate.now(); + DailyBudget firstUserBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), sameDate); + DailyBudget secondUserBudget = new DailyBudget(secondProfile, BigDecimal.valueOf(30000), sameDate); + + budgetRepository.save(firstUserBudget); + budgetRepository.save(secondUserBudget); + + // when - 각 사용자별로 다른 지출 추가 + firstUserBudget.addSpent(15000); + secondUserBudget.addSpent(25000); + + budgetRepository.save(firstUserBudget); + budgetRepository.save(secondUserBudget); + + // then - 각 사용자의 예산이 독립적으로 관리되는지 확인 + DailyBudget retrievedFirstBudget = budgetService.getDailyBudgetBy(memberProfile.getId(), sameDate); + DailyBudget retrievedSecondBudget = budgetService.getDailyBudgetBy(secondProfile.getId(), sameDate); + + assertThat(retrievedFirstBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(15000)); + assertThat(retrievedFirstBudget.getLimit()).isEqualTo(BigDecimal.valueOf(20000)); + assertThat(retrievedFirstBudget.isOverLimit()).isFalse(); + + assertThat(retrievedSecondBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(25000)); + assertThat(retrievedSecondBudget.getLimit()).isEqualTo(BigDecimal.valueOf(30000)); + assertThat(retrievedSecondBudget.isOverLimit()).isFalse(); + } + + @DisplayName("예산 데이터 일관성 및 동시성 테스트") + @Test + void budgetDataConsistencyTest() { + // given - 월별 예산과 여러 일일 예산 생성 + YearMonth currentMonth = YearMonth.now(); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(600000), currentMonth); + budgetRepository.save(monthlyBudget); + + LocalDate startDate = currentMonth.atDay(1); + for (int i = 0; i < 10; i++) { + LocalDate date = startDate.plusDays(i); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), date); + budgetRepository.save(dailyBudget); + } + + // when - 동시에 여러 예산에 지출 추가 + monthlyBudget.addSpent(100000); + + List dailyBudgets = budgetRepository.findDailyBudgetsViaType(memberProfile.getId()); + for (int i = 0; i < dailyBudgets.size(); i++) { + dailyBudgets.get(i).addSpent(10000 + i * 1000); + } + + // 모든 변경사항 저장 + budgetRepository.save(monthlyBudget); + budgetRepository.saveAll(dailyBudgets); + + // then - 데이터 일관성 확인 + MonthlyBudget retrievedMonthly = budgetService.getMonthlyBudgetBy(memberProfile.getId(), currentMonth); + assertThat(retrievedMonthly.getSpendAmount()).isEqualTo(BigDecimal.valueOf(100000)); + + List retrievedDailies = budgetRepository.findDailyBudgetsViaType(memberProfile.getId()); + for (int i = 0; i < retrievedDailies.size(); i++) { + assertThat(retrievedDailies.get(i).getSpendAmount()) + .isEqualTo(BigDecimal.valueOf(10000 + i * 1000)); + } + + // 총 일일 지출과 월별 지출의 독립성 확인 + BigDecimal totalDailySpent = retrievedDailies.stream() + .map(DailyBudget::getSpendAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 월별 예산과 일일 예산의 지출은 독립적으로 관리됨 + assertThat(retrievedMonthly.getSpendAmount()).isNotEqualTo(totalDailySpent); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryAdvancedTest.java b/src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryAdvancedTest.java new file mode 100644 index 0000000..31e176d --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryAdvancedTest.java @@ -0,0 +1,398 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class BudgetRepositoryAdvancedTest { + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member member1, member2; + private MemberProfile profile1, profile2; + + @BeforeEach + void setUp() { + // 첫 번째 멤버 및 프로필 + member1 = Member.builder() + .email("user1@test.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member1); + + profile1 = MemberProfile.builder() + .nickName("user1") + .member(member1) + .build(); + memberProfileRepository.save(profile1); + + // 두 번째 멤버 및 프로필 + member2 = Member.builder() + .email("user2@test.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member2); + + profile2 = MemberProfile.builder() + .nickName("user2") + .member(member2) + .build(); + memberProfileRepository.save(profile2); + } + + @DisplayName("타입별 예산 조회 쿼리가 올바르게 동작한다") + @Test + void findBudgetsByTypeQueryTest() { + // given - 다양한 타입의 예산 생성 + LocalDate today = LocalDate.now(); + YearMonth currentMonth = YearMonth.now(); + + // 일일 예산들 + for (int i = 0; i < 5; i++) { + DailyBudget dailyBudget = new DailyBudget(profile1, BigDecimal.valueOf(20000 + i * 1000), today.plusDays(i)); + budgetRepository.save(dailyBudget); + } + + // 월별 예산들 + for (int i = 0; i < 3; i++) { + MonthlyBudget monthlyBudget = new MonthlyBudget(profile1, BigDecimal.valueOf(500000 + i * 100000), currentMonth.plusMonths(i)); + budgetRepository.save(monthlyBudget); + } + + // 다른 사용자의 예산들 (격리 테스트용) + DailyBudget otherUserDaily = new DailyBudget(profile2, BigDecimal.valueOf(30000), today); + budgetRepository.save(otherUserDaily); + + // when + List dailyBudgets = budgetRepository.findDailyBudgetsViaType(profile1.getId()); + List monthlyBudgets = budgetRepository.findMonthlyBudgetsViaType(profile1.getId()); + + // then + assertThat(dailyBudgets).hasSize(5); + assertThat(monthlyBudgets).hasSize(3); + + // 타입 검증 + for (DailyBudget budget : dailyBudgets) { + assertThat(budget).isInstanceOf(DailyBudget.class); + assertThat(budget.getMemberProfile().getId()).isEqualTo(profile1.getId()); + } + + for (MonthlyBudget budget : monthlyBudgets) { + assertThat(budget).isInstanceOf(MonthlyBudget.class); + assertThat(budget.getMemberProfile().getId()).isEqualTo(profile1.getId()); + } + } + + @DisplayName("최신 예산 조회 쿼리가 올바르게 정렬하여 조회한다") + @Test + void findFirstBudgetOrderingTest() { + // given - 고유한 프로필로 테스트 + Member testMember = Member.builder() + .email("ordering@test.com") + .rawPassword("password123!") + .build(); + memberRepository.save(testMember); + + MemberProfile testProfile = MemberProfile.builder() + .nickName("orderingTestUser") + .member(testMember) + .build(); + memberProfileRepository.save(testProfile); + + // 날짜 순서가 뒤섞인 예산들 생성 + LocalDate baseDate = LocalDate.of(2025, 8, 15); + YearMonth baseMonth = YearMonth.of(2025, 8); + + // 일일 예산들 (날짜 순서 뒤섞어서 생성) + DailyBudget dailyBudget1 = new DailyBudget(testProfile, BigDecimal.valueOf(20000), baseDate.plusDays(5)); + DailyBudget dailyBudget2 = new DailyBudget(testProfile, BigDecimal.valueOf(25000), baseDate.plusDays(2)); + DailyBudget dailyBudget3 = new DailyBudget(testProfile, BigDecimal.valueOf(30000), baseDate.plusDays(10)); // 가장 최신 + + budgetRepository.save(dailyBudget1); + budgetRepository.save(dailyBudget2); + budgetRepository.save(dailyBudget3); + + // 월별 예산들 - 각각 다른 년월로 생성 + MonthlyBudget monthlyBudget1 = new MonthlyBudget(testProfile, BigDecimal.valueOf(500000), baseMonth.plusMonths(1)); + MonthlyBudget monthlyBudget2 = new MonthlyBudget(testProfile, BigDecimal.valueOf(600000), baseMonth.plusMonths(3)); // 가장 최신 + MonthlyBudget monthlyBudget3 = new MonthlyBudget(testProfile, BigDecimal.valueOf(550000), baseMonth.plusMonths(2)); + + budgetRepository.save(monthlyBudget1); + budgetRepository.save(monthlyBudget2); + budgetRepository.save(monthlyBudget3); + + // when + Optional latestDaily = budgetRepository.findFirstDailyBudgetByMemberProfileId(testProfile.getId()); + Optional latestMonthly = budgetRepository.findFirstMonthlyBudgetByMemberProfileId(testProfile.getId()); + + // then - 가장 최신 날짜/년월의 예산이 조회되어야 함 + assertThat(latestDaily).isPresent(); + assertThat(latestDaily.get().getDate()).isEqualTo(baseDate.plusDays(10)); + assertThat(latestDaily.get().getLimit()).isEqualTo(BigDecimal.valueOf(30000)); + + assertThat(latestMonthly).isPresent(); + assertThat(latestMonthly.get().getYearMonth()).isEqualTo(baseMonth.plusMonths(3)); + assertThat(latestMonthly.get().getLimit()).isEqualTo(BigDecimal.valueOf(600000)); + } + + @DisplayName("날짜 범위 조회 쿼리가 정확한 경계값으로 동작한다") + @Test + void findBudgetsByDateRangeBoundaryTest() { + // given - 다양한 날짜의 일일 예산 생성 + LocalDate baseDate = LocalDate.of(2025, 6, 15); + + for (int i = -5; i <= 5; i++) { + LocalDate date = baseDate.plusDays(i); + DailyBudget budget = new DailyBudget(profile1, BigDecimal.valueOf(10000 + Math.abs(i) * 1000), date); + budgetRepository.save(budget); + } + + // when - 정확한 범위로 조회 + LocalDate startDate = baseDate.minusDays(2); // 6월 13일 + LocalDate endDate = baseDate.plusDays(2); // 6월 17일 + + List budgetsInRange = budgetRepository.findDailyBudgetsByMemberProfileIdAndDateBetween( + profile1.getId(), startDate, endDate); + + // then - 경계값 포함하여 5개 조회되어야 함 + assertThat(budgetsInRange).hasSize(5); + + for (DailyBudget budget : budgetsInRange) { + assertThat(budget.getDate()).isBetween(startDate, endDate); + } + + // 경계값 테스트 + assertThat(budgetsInRange.stream() + .anyMatch(b -> b.getDate().equals(startDate))).isTrue(); + assertThat(budgetsInRange.stream() + .anyMatch(b -> b.getDate().equals(endDate))).isTrue(); + } + + @DisplayName("프로필별 예산 격리가 올바르게 동작한다") + @Test + void budgetIsolationByProfileTest() { + // given - 같은 날짜에 두 프로필의 예산 생성 + LocalDate sameDate = LocalDate.now(); + YearMonth sameMonth = YearMonth.now(); + + // 프로필1의 예산들 + DailyBudget daily1 = new DailyBudget(profile1, BigDecimal.valueOf(20000), sameDate); + MonthlyBudget monthly1 = new MonthlyBudget(profile1, BigDecimal.valueOf(500000), sameMonth); + budgetRepository.save(daily1); + budgetRepository.save(monthly1); + + // 프로필2의 예산들 + DailyBudget daily2 = new DailyBudget(profile2, BigDecimal.valueOf(30000), sameDate); + MonthlyBudget monthly2 = new MonthlyBudget(profile2, BigDecimal.valueOf(600000), sameMonth); + budgetRepository.save(daily2); + budgetRepository.save(monthly2); + + // when + Optional profile1Daily = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profile1.getId(), sameDate); + Optional profile2Daily = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profile2.getId(), sameDate); + + Optional profile1Monthly = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profile1.getId(), sameMonth); + Optional profile2Monthly = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profile2.getId(), sameMonth); + + // then - 각 프로필별로 올바른 예산이 조회되어야 함 + assertThat(profile1Daily).isPresent(); + assertThat(profile1Daily.get().getLimit()).isEqualTo(BigDecimal.valueOf(20000)); + + assertThat(profile2Daily).isPresent(); + assertThat(profile2Daily.get().getLimit()).isEqualTo(BigDecimal.valueOf(30000)); + + assertThat(profile1Monthly).isPresent(); + assertThat(profile1Monthly.get().getLimit()).isEqualTo(BigDecimal.valueOf(500000)); + + assertThat(profile2Monthly).isPresent(); + assertThat(profile2Monthly.get().getLimit()).isEqualTo(BigDecimal.valueOf(600000)); + + // 서로 다른 객체여야 함 + assertThat(profile1Daily.get().getId()).isNotEqualTo(profile2Daily.get().getId()); + assertThat(profile1Monthly.get().getId()).isNotEqualTo(profile2Monthly.get().getId()); + } + + @DisplayName("대량 데이터에서의 쿼리 성능 및 정확성 테스트") + @Test + void largeDataQueryPerformanceTest() { + // given - 고유한 프로필로 테스트 + Member performanceMember = Member.builder() + .email("performance@test.com") + .rawPassword("password123!") + .build(); + memberRepository.save(performanceMember); + + MemberProfile performanceProfile = MemberProfile.builder() + .nickName("performanceTestUser") + .member(performanceMember) + .build(); + memberProfileRepository.save(performanceProfile); + + // 대량의 일일 예산 데이터 생성 (서로 다른 날짜) + LocalDate startDate = LocalDate.of(2024, 1, 1); // 2024년 데이터로 변경 + + for (int i = 0; i < 365; i++) { // 1년치 데이터 + LocalDate date = startDate.plusDays(i); + DailyBudget budget = new DailyBudget(performanceProfile, BigDecimal.valueOf(20000 + i), date); + budgetRepository.save(budget); + } + + // when - 다양한 쿼리 실행 + long startTime = System.currentTimeMillis(); + + List allDailyBudgets = budgetRepository.findDailyBudgetsViaType(performanceProfile.getId()); + Optional latestBudget = budgetRepository.findFirstDailyBudgetByMemberProfileId(performanceProfile.getId()); + + // 1개월 범위 조회 + LocalDate monthStart = LocalDate.of(2024, 6, 1); + LocalDate monthEnd = LocalDate.of(2024, 6, 30); + List monthlyData = budgetRepository.findDailyBudgetsByMemberProfileIdAndDateBetween( + performanceProfile.getId(), monthStart, monthEnd); + + long endTime = System.currentTimeMillis(); + long executionTime = endTime - startTime; + + // then - 성능 및 정확성 검증 + assertThat(allDailyBudgets).hasSize(365); + assertThat(latestBudget).isPresent(); + assertThat(latestBudget.get().getDate()).isEqualTo(startDate.plusDays(364)); // 2024년 12월 31일 + + assertThat(monthlyData).hasSize(30); // 6월은 30일 + assertThat(executionTime).isLessThan(5000); // 5초 이내 실행 + + // 데이터 정확성 검증 + for (DailyBudget budget : monthlyData) { + assertThat(budget.getDate().getMonth().getValue()).isEqualTo(6); + assertThat(budget.getDate().getYear()).isEqualTo(2024); + } + } + + @DisplayName("복합 조건 쿼리 및 정렬 테스트") + @Test + void complexQueryAndSortingTest() { + // given - 고유한 프로필로 테스트 + Member complexMember = Member.builder() + .email("complex@test.com") + .rawPassword("password123!") + .build(); + memberRepository.save(complexMember); + + MemberProfile complexProfile = MemberProfile.builder() + .nickName("complexTestUser") + .member(complexMember) + .build(); + memberProfileRepository.save(complexProfile); + + // 복잡한 시나리오의 데이터 생성 + LocalDate testDate = LocalDate.of(2025, 7, 1); // 고정된 날짜 사용 + YearMonth testMonth = YearMonth.of(2025, 7); + + // 한 주간의 일일 예산들 (월요일~일요일) - 각기 다른 날짜 + LocalDate monday = testDate.with(java.time.DayOfWeek.MONDAY); + for (int i = 0; i < 7; i++) { + LocalDate date = monday.plusDays(i); + BigDecimal limit = BigDecimal.valueOf(15000 + i * 2000); // 점진적 증가 + DailyBudget budget = new DailyBudget(complexProfile, limit, date); + budget.addSpent(5000 + i * 1000); // 지출도 점진적 증가 + budgetRepository.save(budget); + } + + // 여러 월의 월별 예산들 - 각기 다른 년월 + for (int i = 0; i < 6; i++) { + YearMonth month = testMonth.plusMonths(i); // 미래 월로 변경하여 충돌 방지 + MonthlyBudget budget = new MonthlyBudget(complexProfile, BigDecimal.valueOf(400000 + i * 50000), month); + budget.addSpent(200000 + i * 30000); + budgetRepository.save(budget); + } + + // when + List weeklyBudgets = budgetRepository.findDailyBudgetsByMemberProfileIdAndDateBetween( + complexProfile.getId(), monday, monday.plusDays(6)); + + Optional latestDaily = budgetRepository.findFirstDailyBudgetByMemberProfileId(complexProfile.getId()); + Optional latestMonthly = budgetRepository.findFirstMonthlyBudgetByMemberProfileId(complexProfile.getId()); + + // then - 정렬 및 데이터 검증 + assertThat(weeklyBudgets).hasSize(7); + + // 날짜 순 정렬 확인 (쿼리에서 정렬하지 않으므로 ID 순으로 조회됨) + for (int i = 0; i < weeklyBudgets.size() - 1; i++) { + DailyBudget current = weeklyBudgets.get(i); + DailyBudget next = weeklyBudgets.get(i + 1); + // 생성 순서 확인 (ID가 증가하는 순서) + assertThat(current.getId()).isLessThan(next.getId()); + } + + // 최신 예산 검증 + assertThat(latestDaily).isPresent(); + assertThat(latestDaily.get().getDate()).isEqualTo(monday.plusDays(6)); // 일요일 + + assertThat(latestMonthly).isPresent(); + assertThat(latestMonthly.get().getYearMonth()).isEqualTo(testMonth.plusMonths(5)); // 가장 미래 월 + + // 지출 금액 검증 + for (int i = 0; i < weeklyBudgets.size(); i++) { + DailyBudget budget = weeklyBudgets.get(i); + BigDecimal expectedSpent = BigDecimal.valueOf(5000 + i * 1000); + assertThat(budget.getSpendAmount()).isEqualTo(expectedSpent); + } + } + + @DisplayName("null 값 및 edge case 처리 테스트") + @Test + void nullValueAndEdgeCaseTest() { + // given - 존재하지 않는 데이터 조회 시나리오 + + // when - 존재하지 않는 프로필 ID로 조회 + Long nonExistentProfileId = 999999L; + List emptyDailyList = budgetRepository.findDailyBudgetsViaType(nonExistentProfileId); + List emptyMonthlyList = budgetRepository.findMonthlyBudgetsViaType(nonExistentProfileId); + + Optional emptyDailyOpt = budgetRepository.findDailyBudgetByMemberProfileIdAndDate( + nonExistentProfileId, LocalDate.now()); + Optional emptyMonthlyOpt = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth( + nonExistentProfileId, YearMonth.now()); + + // then - 빈 결과 반환 + assertThat(emptyDailyList).isEmpty(); + assertThat(emptyMonthlyList).isEmpty(); + assertThat(emptyDailyOpt).isEmpty(); + assertThat(emptyMonthlyOpt).isEmpty(); + + // when - 존재하지 않는 날짜로 조회 + LocalDate futureDate = LocalDate.of(2030, 12, 31); + YearMonth futureMonth = YearMonth.of(2030, 12); + + Optional futureDailyOpt = budgetRepository.findDailyBudgetByMemberProfileIdAndDate( + profile1.getId(), futureDate); + Optional futureMonthlyOpt = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth( + profile1.getId(), futureMonth); + + // then - 빈 결과 반환 + assertThat(futureDailyOpt).isEmpty(); + assertThat(futureMonthlyOpt).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryTest.java new file mode 100644 index 0000000..0600093 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/BudgetRepositoryTest.java @@ -0,0 +1,164 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class BudgetRepositoryTest { + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("프로필 ID와 일자 정보로 일일 예산을 조회한다.") + @Test + void findDailyBudgetByMemberProfileIdAndDate() throws Exception { + // given + Member member = Member.builder() + .email("abcd@naver.com") + .rawPassword("@absdv123") + .build(); + + memberRepository.save(member); + + MemberProfile memberProfile = MemberProfile.builder() + .nickName("testUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(1000), LocalDate.now()); + budgetRepository.save(dailyBudget); + + // when + DailyBudget foundBudget = budgetRepository.findDailyBudgetByMemberProfileIdAndDate( + memberProfile.getId(), LocalDate.now()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 예산입니다")); + + // then + assertThat(foundBudget).isNotNull(); + assertThat(foundBudget.getMemberProfile()).isEqualTo(memberProfile); + assertThat(foundBudget.getLimit()).isEqualByComparingTo(BigDecimal.valueOf(1000)); + assertThat(foundBudget.getDate()).isEqualTo(LocalDate.now()); + assertThat(foundBudget.getId()).isNotNull(); + } + + @DisplayName("존재하지 않는 프로필 ID와 일자 정보로 일일 예산을 조회하면 빈 Optional을 반환한다.") + @Test + void findDailyBudgetByMemberProfileIdAndDate2() throws Exception { + // given + Member member = Member.builder() + .email("abcd@naver.com") + .rawPassword("@absdv123") + .build(); + + memberRepository.save(member); + + MemberProfile memberProfile = MemberProfile.builder() + .nickName("testUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + + LocalDate date = LocalDate.now(); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(1000), date); + budgetRepository.save(dailyBudget); + + // when + // 존재하지 않는 프로필 ID와 일자 정보로 조회 + Long nonExistentProfileId = 999L; + var foundBudget = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(nonExistentProfileId, date); + + // then + assertThat(foundBudget).isEmpty(); + } + + @DisplayName("프로필 ID와 년월 정보로 월별 예산을 조회한다") + @Test + void findMonthlyBudgetByMemberProfileIdAndYearMonth() throws Exception { + // given + // given + Member member = Member.builder() + .email("abcd@naver.com") + .rawPassword("@absdv123") + .build(); + + memberRepository.save(member); + + MemberProfile memberProfile = MemberProfile.builder() + .nickName("testUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + + YearMonth yearMonth = YearMonth.now(); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(3000), yearMonth); + budgetRepository.save(monthlyBudget); + + // when + MonthlyBudget foundBudget = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth( + memberProfile.getId(), yearMonth) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 예산입니다")); + + // then + assertThat(foundBudget).isNotNull(); + + } + + @DisplayName("주어진 날짜 기간의 예산 정보를 조회한다") + @Test + void findDailyBudgetsByMemberProfileIdAndDateBetween() throws Exception { + // given + Member member = Member.builder() + .email("abcd@naver.com") + .rawPassword("@absdv123") + .build(); + + memberRepository.save(member); + + MemberProfile memberProfile = MemberProfile.builder() + .nickName("testUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + + // when + LocalDate today = LocalDate.now(); + LocalDate startOfWeek = today.minusDays(3); // 3일 전 + LocalDate endOfWeek = today.plusDays(3); // 3일 후 + + // 예시로 5일의 일일 예산을 생성 + for (int i = 0; i < 5; i++) { + LocalDate date = today.minusDays(i); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(1000 + i * 100), date); + budgetRepository.save(dailyBudget); + } + + // 주어진 날짜 범위 내의 일일 예산을 조회 + var dailyBudgets = budgetRepository.findDailyBudgetsByMemberProfileIdAndDateBetween( + memberProfile.getId(), startOfWeek, endOfWeek); + + // then + assertThat(dailyBudgets).isNotEmpty(); + assertThat(dailyBudgets.size()).isEqualTo(4); + + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java new file mode 100644 index 0000000..32bbfe4 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java @@ -0,0 +1,259 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class BudgetServiceIntegrationTest { + + @Autowired + private BudgetService budgetService; + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member member; + private MemberProfile memberProfile; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("test@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + memberProfile = MemberProfile.builder() + .nickName("testUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + } + + @DisplayName("일일 예산을 저장하고 조회할 수 있다") + @Test + void saveDailyBudgetCustom() { + // given + Long limit = 50000L; + + // when + budgetService.saveDailyBudgetCustom(memberProfile.getId(), limit); + + // then + DailyBudget savedBudget = budgetService.findRecentDailyBudgetByMemberProfileId(memberProfile.getId()); + assertThat(savedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(limit)); + assertThat(savedBudget.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + assertThat(savedBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + } + + @DisplayName("월별 예산을 저장하고 조회할 수 있다") + @Test + void saveMonthlyBudgetCustom() { + // given + Long limit = 1000000L; + + // when + budgetService.saveMonthlyBudgetCustom(memberProfile.getId(), limit); + + // then + MonthlyBudget savedBudget = budgetService.findRecentMonthlyBudgetByMemberProfileId(memberProfile.getId()); + assertThat(savedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(limit)); + assertThat(savedBudget.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + assertThat(savedBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + } + + @DisplayName("특정 날짜의 일일 예산을 조회할 수 있다") + @Test + void getDailyBudgetByDate() { + // given + LocalDate targetDate = LocalDate.of(2025, 6, 15); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(30000), targetDate); + budgetRepository.save(dailyBudget); + + // when + DailyBudget foundBudget = budgetService.getDailyBudgetBy(memberProfile.getId(), targetDate); + + // then + assertThat(foundBudget.getDate()).isEqualTo(targetDate); + assertThat(foundBudget.getLimit()).isEqualTo(BigDecimal.valueOf(30000)); + assertThat(foundBudget.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + } + + @DisplayName("특정 년월의 월별 예산을 조회할 수 있다") + @Test + void getMonthlyBudgetByYearMonth() { + // given + YearMonth targetYearMonth = YearMonth.of(2025, 6); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(800000), targetYearMonth); + budgetRepository.save(monthlyBudget); + + // when + MonthlyBudget foundBudget = budgetService.getMonthlyBudgetBy(memberProfile.getId(), targetYearMonth); + + // then + assertThat(foundBudget.getYearMonth()).isEqualTo(targetYearMonth); + assertThat(foundBudget.getLimit()).isEqualTo(BigDecimal.valueOf(800000)); + assertThat(foundBudget.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + } + + @DisplayName("한 주간의 일일 예산 목록을 조회할 수 있다") + @Test + void getDailyBudgetsByWeek() { + // given + LocalDate monday = LocalDate.of(2025, 6, 9); // 월요일 + + // 월요일부터 일요일까지 일일 예산 생성 + for (int i = 0; i < 7; i++) { + LocalDate date = monday.plusDays(i); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(10000 + i * 1000), date); + budgetRepository.save(dailyBudget); + } + + // when - 주 중 아무 날짜나 입력해도 해당 주의 예산을 조회 + LocalDate wednesday = monday.plusDays(2); + List weeklyBudgets = budgetService.getDailyBudgetsByWeek(memberProfile.getId(), wednesday); + + // then + assertThat(weeklyBudgets).hasSize(7); + assertThat(weeklyBudgets.get(0).getDate()).isEqualTo(monday); + assertThat(weeklyBudgets.get(6).getDate()).isEqualTo(monday.plusDays(6)); + } + + @DisplayName("디폴트 일일 예산을 등록하면 해당 월의 남은 일자에 대해 일일 예산이 생성된다") + @Test + void registerDefaultDailyBudget() { + // given + Long dailyLimit = 25000L; + LocalDate startDate = LocalDate.of(2025, 6, 15); // 6월 15일부터 + + // when + budgetService.registerDefaultDailyBudgetBy(memberProfile.getId(), dailyLimit, startDate); + + // then + // 6월 15일부터 6월 30일까지 16개의 일일 예산이 생성되어야 함 + List dailyBudgets = budgetRepository.findDailyBudgetsViaType(memberProfile.getId()); + assertThat(dailyBudgets).hasSize(16); + + for (DailyBudget budget : dailyBudgets) { + assertThat(budget.getLimit()).isEqualTo(BigDecimal.valueOf(dailyLimit)); + assertThat(budget.getDate()).isBetween(startDate, LocalDate.of(2025, 6, 30)); + } + } + + @DisplayName("디폴트 월별 예산을 등록할 수 있다") + @Test + void registerDefaultMonthlyBudget() { + // given + Long monthlyLimit = 500000L; + YearMonth targetYearMonth = YearMonth.of(2025, 7); + + // when + budgetService.registerDefaultMonthlyBudgetBy(memberProfile.getId(), monthlyLimit, targetYearMonth); + + // then + MonthlyBudget savedBudget = budgetService.getMonthlyBudgetBy(memberProfile.getId(), targetYearMonth); + assertThat(savedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(monthlyLimit)); + assertThat(savedBudget.getYearMonth()).isEqualTo(targetYearMonth); + } + + @DisplayName("일일 예산 한도를 수정할 수 있다") + @Test + void editDailyBudgetCustom() { + // given + LocalDate targetDate = LocalDate.of(2025, 6, 20); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), targetDate); + budgetRepository.save(dailyBudget); + + Long newLimit = 35000L; + + // when + budgetService.editDailyBudgetCustom(memberProfile.getId(), targetDate, newLimit); + + // then + DailyBudget updatedBudget = budgetService.getDailyBudgetBy(memberProfile.getId(), targetDate); + assertThat(updatedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(newLimit)); + } + + @DisplayName("월별 예산 한도를 수정할 수 있다") + @Test + void editMonthlyBudgetCustom() { + // given + YearMonth targetYearMonth = YearMonth.of(2025, 8); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(600000), targetYearMonth); + budgetRepository.save(monthlyBudget); + + Long newLimit = 750000L; + + // when + budgetService.editMonthlyBudgetCustom(memberProfile.getId(), targetYearMonth, newLimit); + + // then + MonthlyBudget updatedBudget = budgetService.getMonthlyBudgetBy(memberProfile.getId(), targetYearMonth); + assertThat(updatedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(newLimit)); + } + + @DisplayName("존재하지 않는 프로필 ID로 예산을 조회하면 예외가 발생한다") + @Test + void getBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 999L; + LocalDate targetDate = LocalDate.now(); + + // when & then + assertThatThrownBy(() -> budgetService.getDailyBudgetBy(invalidProfileId, targetDate)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @DisplayName("존재하지 않는 날짜의 예산을 조회하면 예외가 발생한다") + @Test + void getBudgetWithInvalidDate() { + // given + LocalDate nonExistentDate = LocalDate.of(2030, 12, 31); + + // when & then + assertThatThrownBy(() -> budgetService.getDailyBudgetBy(memberProfile.getId(), nonExistentDate)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @DisplayName("존재하지 않는 프로필 ID로 예산을 등록하면 예외가 발생한다") + @Test + void saveBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 999L; + Long limit = 50000L; + + // when & then + assertThatThrownBy(() -> budgetService.saveDailyBudgetCustom(invalidProfileId, limit)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java index b917792..ab6ee53 100644 --- a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java @@ -41,9 +41,9 @@ void findRecentDailyBudgetByMemberProfileId() { Long memberProfileId = 1L; MemberProfile memberProfile = new MemberProfile(); DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(10000), LocalDate.now()); - + when(budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId)) - .thenReturn(Optional.of(dailyBudget)); + .thenReturn(Optional.of(dailyBudget)); // when DailyBudget result = budgetService.findRecentDailyBudgetByMemberProfileId(memberProfileId); @@ -58,14 +58,14 @@ void findRecentDailyBudgetByMemberProfileId() { void findRecentDailyBudgetByMemberProfileId_NotFound() { // given Long memberProfileId = 999L; - + when(budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId)) - .thenReturn(Optional.empty()); + .thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> budgetService.findRecentDailyBudgetByMemberProfileId(memberProfileId)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("존재하지 않는 프로필로 접근"); + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); } @Test @@ -75,9 +75,9 @@ void findRecentMonthlyBudgetByMemberProfileId() { Long memberProfileId = 1L; MemberProfile memberProfile = new MemberProfile(); MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(300000), YearMonth.now()); - + when(budgetRepository.findFirstMonthlyBudgetByMemberProfileId(memberProfileId)) - .thenReturn(Optional.of(monthlyBudget)); + .thenReturn(Optional.of(monthlyBudget)); // when MonthlyBudget result = budgetService.findRecentMonthlyBudgetByMemberProfileId(memberProfileId); @@ -94,9 +94,9 @@ void saveMonthlyBudgetCustom() { Long memberProfileId = 1L; Long limit = 300000L; MemberProfile memberProfile = new MemberProfile(); - + when(memberProfileRepository.findById(memberProfileId)) - .thenReturn(Optional.of(memberProfile)); + .thenReturn(Optional.of(memberProfile)); // when budgetService.saveMonthlyBudgetCustom(memberProfileId, limit); @@ -112,9 +112,9 @@ void saveDailyBudgetCustom() { Long memberProfileId = 1L; Long limit = 10000L; MemberProfile memberProfile = new MemberProfile(); - + when(memberProfileRepository.findById(memberProfileId)) - .thenReturn(Optional.of(memberProfile)); + .thenReturn(Optional.of(memberProfile)); // when budgetService.saveDailyBudgetCustom(memberProfileId, limit); @@ -129,13 +129,14 @@ void saveBudget_NotFound() { // given Long memberProfileId = 999L; Long limit = 10000L; - + when(memberProfileRepository.findById(memberProfileId)) - .thenReturn(Optional.empty()); + .thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> budgetService.saveDailyBudgetCustom(memberProfileId, limit)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("존재하지 않는 프로필로 접근"); + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); } + } \ No newline at end of file From 69431e916e0c92776a0437df275c07eaf393593b Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 22:33:51 +0900 Subject: [PATCH 12/44] =?UTF-8?q?refactor:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stcom/smartmealtable/domain/Budget/Budget.java | 3 +++ .../smartmealtable/repository/BudgetRepository.java | 13 ++++++------- .../stcom/smartmealtable/service/BudgetService.java | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java index dbd85c9..995a744 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java @@ -68,6 +68,9 @@ public boolean isOverLimit() { } public void changeLimit(BigDecimal limit) { + if (limit == null || limit.signum() < 0) { + throw new IllegalArgumentException("예산 한도는 0 이상이어야 합니다."); + } this.limit = limit; } } diff --git a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java index a82cd6c..a3510c1 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java @@ -18,8 +18,9 @@ public interface BudgetRepository extends JpaRepository { List findDailyBudgetsViaType(@Param("memberProfileId") Long memberProfileId); @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :memberProfileId order by treat(b as DailyBudget).date desc") - List findFirstDailyBudgetByMemberProfileIdList(@Param("memberProfileId") Long memberProfileId, Pageable pageable); - + List findFirstDailyBudgetByMemberProfileIdList(@Param("memberProfileId") Long memberProfileId, + Pageable pageable); + default Optional findFirstDailyBudgetByMemberProfileId(Long memberProfileId) { List results = findFirstDailyBudgetByMemberProfileIdList(memberProfileId, Pageable.ofSize(1)); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); @@ -29,8 +30,9 @@ default Optional findFirstDailyBudgetByMemberProfileId(Long memberP List findMonthlyBudgetsViaType(@Param("memberProfileId") Long memberProfileId); @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :memberProfileId order by treat(b as MonthlyBudget).yearMonth desc") - List findFirstMonthlyBudgetByMemberProfileIdList(@Param("memberProfileId") Long memberProfileId, Pageable pageable); - + List findFirstMonthlyBudgetByMemberProfileIdList(@Param("memberProfileId") Long memberProfileId, + Pageable pageable); + default Optional findFirstMonthlyBudgetByMemberProfileId(Long memberProfileId) { List results = findFirstMonthlyBudgetByMemberProfileIdList(memberProfileId, Pageable.ofSize(1)); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); @@ -39,9 +41,6 @@ default Optional findFirstMonthlyBudgetByMemberProfileId(Long mem @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date = :date") Optional findDailyBudgetByMemberProfileIdAndDate(Long profileId, LocalDate date); - @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :profileId and treat(b as MonthlyBudget).yearMonth = :date") - Optional findMonthlyBudgetByMemberProfileIdAndDate(Long profileId, LocalDate date); - @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :profileId and treat(b as MonthlyBudget).yearMonth = :yearMonth") Optional findMonthlyBudgetByMemberProfileIdAndYearMonth(Long profileId, YearMonth yearMonth); diff --git a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java index 664472e..a488590 100644 --- a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java +++ b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java @@ -52,7 +52,7 @@ public void saveDailyBudgetCustom(Long memberProfileId, Long limit) { public DailyBudget getDailyBudgetBy(Long profileId, LocalDate date) { return budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profileId, date).orElseThrow(() -> - new IllegalArgumentException("존재하지 않는 프로필로 접근") + new IllegalArgumentException("예산이 존재하지 않습니다.") ); } From d340563da7babdc4232bf85d1e1183eca7d6363d Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 22:34:16 +0900 Subject: [PATCH 13/44] =?UTF-8?q?refactor:=20YearMonth=20Format=20?= =?UTF-8?q?=EC=95=A0=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stcom/smartmealtable/web/WebConfig.java | 7 +++ .../controller/MemberProfileController.java | 17 ++++--- .../YearMonthAnnotationFormatterFactory.java | 50 +++++++++++++++++++ .../web/validation/YearMonthFormat.java | 15 ++++++ 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/stcom/smartmealtable/web/validation/YearMonthAnnotationFormatterFactory.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/validation/YearMonthFormat.java diff --git a/src/main/java/com/stcom/smartmealtable/web/WebConfig.java b/src/main/java/com/stcom/smartmealtable/web/WebConfig.java index 0be154d..ad7f411 100644 --- a/src/main/java/com/stcom/smartmealtable/web/WebConfig.java +++ b/src/main/java/com/stcom/smartmealtable/web/WebConfig.java @@ -2,9 +2,11 @@ import com.stcom.smartmealtable.web.argumentresolver.UserContextArgumentResolver; import com.stcom.smartmealtable.web.interceptor.JwtAuthenticationInterceptor; +import com.stcom.smartmealtable.web.validation.YearMonthAnnotationFormatterFactory; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; @@ -22,6 +24,11 @@ public void addArgumentResolvers(List resolvers) resolvers.add(userContextArgumentResolver); } + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addFormatterForFieldAnnotation(new YearMonthAnnotationFormatterFactory()); + } + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtAuthenticationInterceptor) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index 0aba454..c2fa9c2 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -17,12 +17,15 @@ import com.stcom.smartmealtable.service.dto.MemberDto; import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; +import com.stcom.smartmealtable.web.validation.YearMonthFormat; import java.time.LocalDate; import java.time.YearMonth; import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -140,14 +143,14 @@ public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDt */ @GetMapping("/me/budgets/daily/{date}") public ApiResponse dailyBudgetByDate(@UserContext MemberDto memberDto, - @PathVariable("date") String date) { + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { DailyBudget dailyBudget = budgetService.getDailyBudgetBy(memberDto.getProfileId(), LocalDate.parse(date)); return ApiResponse.createSuccess(DailyBudgetResponse.of(dailyBudget)); } @PutMapping("/me/budgets/daily/{date}/default") public ApiResponse registerDefaultDailyBudget(@UserContext MemberDto memberDto, - @PathVariable("date") String date, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, @RequestParam("limit") Long limit) { budgetService.registerDefaultDailyBudgetBy(memberDto.getProfileId(), limit, LocalDate.parse(date)); return ApiResponse.createSuccessWithNoContent(); @@ -155,7 +158,7 @@ public ApiResponse registerDefaultDailyBudget(@UserContext MemberDto membe @PatchMapping("/me/budgets/daily/{date}") public ApiResponse editDailyBudget(@UserContext MemberDto memberDto, - @PathVariable("date") String date, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, @RequestParam("limit") Long limit) { budgetService.editDailyBudgetCustom(memberDto.getProfileId(), LocalDate.parse(date), limit); return ApiResponse.createSuccessWithNoContent(); @@ -164,7 +167,7 @@ public ApiResponse editDailyBudget(@UserContext MemberDto memberDto, // 해당 일자가 속한 일일 예산 주간 데이터 조회 @GetMapping("/me/budgets/daily/{date}/week") public ApiResponse> dailyBudgetWeekByDate(@UserContext MemberDto memberDto, - @PathVariable("date") String date) { + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { List dailyBudgets = budgetService.getDailyBudgetsByWeek(memberDto.getProfileId(), LocalDate.parse(date)); @@ -177,7 +180,7 @@ public ApiResponse> dailyBudgetWeekByDate(@UserContext @GetMapping("/me/budgets/month/{yearMonth}") public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") String yearMonth) { + @PathVariable("yearMonth") @YearMonthFormat String yearMonth) { MonthlyBudget monthlyBudget = budgetService.getMonthlyBudgetBy(memberDto.getProfileId(), YearMonth.parse(yearMonth)); @@ -186,7 +189,7 @@ public ApiResponse monthlyBudgetByDate(@UserContext Membe @PutMapping("/me/budgets/month/{yearMonth}/default") public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") String yearMonth, + @PathVariable("yearMonth") @YearMonthFormat String yearMonth, @RequestParam("limit") Long limit) { budgetService.registerDefaultMonthlyBudgetBy(memberDto.getProfileId(), limit, YearMonth.parse(yearMonth)); @@ -196,7 +199,7 @@ public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto mem @PatchMapping("/me/budgets/monthly/{yearMonth}") public ApiResponse editMonthlyBudget(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") String yearMonth, + @PathVariable("yearMonth") @YearMonthFormat String yearMonth, @RequestParam("limit") Long limit) { budgetService.editMonthlyBudgetCustom(memberDto.getProfileId(), YearMonth.parse(yearMonth), limit); diff --git a/src/main/java/com/stcom/smartmealtable/web/validation/YearMonthAnnotationFormatterFactory.java b/src/main/java/com/stcom/smartmealtable/web/validation/YearMonthAnnotationFormatterFactory.java new file mode 100644 index 0000000..6f3b7cb --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/validation/YearMonthAnnotationFormatterFactory.java @@ -0,0 +1,50 @@ +package com.stcom.smartmealtable.web.validation; + +import java.text.ParseException; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.format.AnnotationFormatterFactory; +import org.springframework.format.Parser; +import org.springframework.format.Printer; + +public class YearMonthAnnotationFormatterFactory implements AnnotationFormatterFactory { + + private static final Set> FIELD_TYPES = + Set.copyOf(List.of(YearMonth.class)); + + @Override + public Set> getFieldTypes() { + return FIELD_TYPES; + } + + @Override + public Printer getPrinter(YearMonthFormat annotation, Class fieldType) { + return new YearMonthFormatter(annotation.pattern()); + } + + @Override + public Parser getParser(YearMonthFormat annotation, Class fieldType) { + return new YearMonthFormatter(annotation.pattern()); + } + + private static class YearMonthFormatter implements Printer, Parser { + private final DateTimeFormatter formatter; + + public YearMonthFormatter(String pattern) { + this.formatter = DateTimeFormatter.ofPattern(pattern); + } + + @Override + public String print(YearMonth yearMonth, Locale locale) { + return yearMonth.format(formatter); + } + + @Override + public YearMonth parse(String text, Locale locale) throws ParseException { + return YearMonth.parse(text, formatter); + } + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/validation/YearMonthFormat.java b/src/main/java/com/stcom/smartmealtable/web/validation/YearMonthFormat.java new file mode 100644 index 0000000..b6e990a --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/validation/YearMonthFormat.java @@ -0,0 +1,15 @@ +package com.stcom.smartmealtable.web.validation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface YearMonthFormat { + + String pattern() default "yyyy-MM"; +} From 2e958eb02db265a51b49a757a18190226adf58e5 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 22:38:06 +0900 Subject: [PATCH 14/44] =?UTF-8?q?fix:=20Api=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/MemberProfileController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index c2fa9c2..18d1e77 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -178,7 +178,7 @@ public ApiResponse> dailyBudgetWeekByDate(@UserContext return ApiResponse.createSuccess(responses); } - @GetMapping("/me/budgets/month/{yearMonth}") + @GetMapping("/me/budgets/monthly/{yearMonth}") public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, @PathVariable("yearMonth") @YearMonthFormat String yearMonth) { MonthlyBudget monthlyBudget = budgetService.getMonthlyBudgetBy(memberDto.getProfileId(), @@ -187,7 +187,7 @@ public ApiResponse monthlyBudgetByDate(@UserContext Membe return ApiResponse.createSuccess(MonthlyBudgetResponse.of(monthlyBudget)); } - @PutMapping("/me/budgets/month/{yearMonth}/default") + @PutMapping("/me/budgets/monthly/{yearMonth}/default") public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto memberDto, @PathVariable("yearMonth") @YearMonthFormat String yearMonth, @RequestParam("limit") Long limit) { From 2217fefb84a33629f7f80b5b1613b5d921264751 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 22:46:54 +0900 Subject: [PATCH 15/44] =?UTF-8?q?refactor:=20=EC=98=88=EC=82=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BudgetRepository.java | 2 +- .../service/BudgetServiceIntegrationTest.java | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java index a3510c1..014179d 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java @@ -44,7 +44,7 @@ default Optional findFirstMonthlyBudgetByMemberProfileId(Long mem @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :profileId and treat(b as MonthlyBudget).yearMonth = :yearMonth") Optional findMonthlyBudgetByMemberProfileIdAndYearMonth(Long profileId, YearMonth yearMonth); - @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date between :startOfWeek and :endOfWeek") + @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date between :startOfWeek and :endOfWeek order by treat(b as DailyBudget).date asc") List findDailyBudgetsByMemberProfileIdAndDateBetween(Long profileId, LocalDate startOfWeek, LocalDate endOfWeek); } diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java index 32bbfe4..854ae97 100644 --- a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceIntegrationTest.java @@ -128,7 +128,7 @@ void getMonthlyBudgetByYearMonth() { void getDailyBudgetsByWeek() { // given LocalDate monday = LocalDate.of(2025, 6, 9); // 월요일 - + // 월요일부터 일요일까지 일일 예산 생성 for (int i = 0; i < 7; i++) { LocalDate date = monday.plusDays(i); @@ -152,7 +152,7 @@ void registerDefaultDailyBudget() { // given Long dailyLimit = 25000L; LocalDate startDate = LocalDate.of(2025, 6, 15); // 6월 15일부터 - + // when budgetService.registerDefaultDailyBudgetBy(memberProfile.getId(), dailyLimit, startDate); @@ -160,7 +160,7 @@ void registerDefaultDailyBudget() { // 6월 15일부터 6월 30일까지 16개의 일일 예산이 생성되어야 함 List dailyBudgets = budgetRepository.findDailyBudgetsViaType(memberProfile.getId()); assertThat(dailyBudgets).hasSize(16); - + for (DailyBudget budget : dailyBudgets) { assertThat(budget.getLimit()).isEqualTo(BigDecimal.valueOf(dailyLimit)); assertThat(budget.getDate()).isBetween(startDate, LocalDate.of(2025, 6, 30)); @@ -190,7 +190,7 @@ void editDailyBudgetCustom() { LocalDate targetDate = LocalDate.of(2025, 6, 20); DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), targetDate); budgetRepository.save(dailyBudget); - + Long newLimit = 35000L; // when @@ -208,7 +208,7 @@ void editMonthlyBudgetCustom() { YearMonth targetYearMonth = YearMonth.of(2025, 8); MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(600000), targetYearMonth); budgetRepository.save(monthlyBudget); - + Long newLimit = 750000L; // when @@ -229,7 +229,7 @@ void getBudgetWithInvalidProfileId() { // when & then assertThatThrownBy(() -> budgetService.getDailyBudgetBy(invalidProfileId, targetDate)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 프로필로 접근"); + .hasMessage("예산이 존재하지 않습니다."); } @DisplayName("존재하지 않는 날짜의 예산을 조회하면 예외가 발생한다") @@ -241,7 +241,7 @@ void getBudgetWithInvalidDate() { // when & then assertThatThrownBy(() -> budgetService.getDailyBudgetBy(memberProfile.getId(), nonExistentDate)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 프로필로 접근"); + .hasMessage("예산이 존재하지 않습니다."); } @DisplayName("존재하지 않는 프로필 ID로 예산을 등록하면 예외가 발생한다") From 8e690625a376f8cd7e0421b7c6a45b3204d87250 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 22:53:11 +0900 Subject: [PATCH 16/44] =?UTF-8?q?fix:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YearMonthFormatter를 적용한 파라미터의 타입을 변경합니다. --- .../web/controller/MemberProfileController.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index 18d1e77..cc1726f 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -180,29 +180,29 @@ public ApiResponse> dailyBudgetWeekByDate(@UserContext @GetMapping("/me/budgets/monthly/{yearMonth}") public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") @YearMonthFormat String yearMonth) { + @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth) { MonthlyBudget monthlyBudget = budgetService.getMonthlyBudgetBy(memberDto.getProfileId(), - YearMonth.parse(yearMonth)); + yearMonth); return ApiResponse.createSuccess(MonthlyBudgetResponse.of(monthlyBudget)); } @PutMapping("/me/budgets/monthly/{yearMonth}/default") public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") @YearMonthFormat String yearMonth, + @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth, @RequestParam("limit") Long limit) { budgetService.registerDefaultMonthlyBudgetBy(memberDto.getProfileId(), - limit, YearMonth.parse(yearMonth)); + limit, yearMonth); return ApiResponse.createSuccessWithNoContent(); } @PatchMapping("/me/budgets/monthly/{yearMonth}") public ApiResponse editMonthlyBudget(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") @YearMonthFormat String yearMonth, + @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth, @RequestParam("limit") Long limit) { budgetService.editMonthlyBudgetCustom(memberDto.getProfileId(), - YearMonth.parse(yearMonth), limit); + yearMonth, limit); return ApiResponse.createSuccessWithNoContent(); } From fc3fd4ebb48c5f6b7f13e2c1e330aa72f1999f71 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 23:11:29 +0900 Subject: [PATCH 17/44] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20API=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemberProfileController의 책임을 분리 --- .../controller/MemberAddressController.java | 70 +++++ .../controller/MemberBudgetController.java | 127 +++++++++ .../MemberPreferenceController.java | 78 ++++++ .../controller/MemberProfileController.java | 243 +----------------- 4 files changed, 279 insertions(+), 239 deletions(-) create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/MemberAddressController.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/MemberPreferenceController.java diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberAddressController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberAddressController.java new file mode 100644 index 0000000..bbfae20 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberAddressController.java @@ -0,0 +1,70 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.infrastructure.AddressApiService; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import com.stcom.smartmealtable.service.MemberProfileService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members/me/addresses") +public class MemberAddressController { + + private final MemberProfileService memberProfileService; + private final AddressApiService addressApiService; + + @PostMapping("/{id}/primary") + public ApiResponse changePrimaryAddress(@UserContext MemberDto memberDto, + @PathVariable("id") Long addressId) { + memberProfileService.changeAddressToPrimary(memberDto.getProfileId(), addressId); + return ApiResponse.createSuccessWithNoContent(); + } + + @PostMapping + public ApiResponse registerAddress(@UserContext MemberDto memberDto, + @Validated @RequestBody MemberAddressCURequest request) { + Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); + memberProfileService.saveNewAddress(memberDto.getProfileId(), address, request.getAlias(), + request.getAddressType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/{id}") + public ApiResponse changeAddress(@UserContext MemberDto memberDto, + @PathVariable("id") Long addressId, + @Validated @RequestBody MemberAddressCURequest request) { + Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); + memberProfileService.changeAddress(memberDto.getProfileId(), addressId, address, request.getAlias(), + request.getAddressType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteAddress(@UserContext MemberDto memberDto, + @PathVariable("id") Long addressId) { + memberProfileService.deleteAddress(memberDto.getProfileId(), addressId); + return ApiResponse.createSuccessWithNoContent(); + } + + @AllArgsConstructor + @Data + static class MemberAddressCURequest { + private String roadAddress; + private AddressType addressType; + private String alias; + private String detailAddress; + + public AddressRequest toAddressApiRequest() { + return new AddressRequest(roadAddress, detailAddress); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java new file mode 100644 index 0000000..41652b3 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java @@ -0,0 +1,127 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.Budget.Budget; +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.service.BudgetService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import com.stcom.smartmealtable.web.validation.YearMonthFormat; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members/me/budgets") +public class MemberBudgetController { + + private final BudgetService budgetService; + + // 일별 예산 조회 + @GetMapping("/daily/{date}") + public ApiResponse dailyBudgetByDate(@UserContext MemberDto memberDto, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { + DailyBudget dailyBudget = budgetService.getDailyBudgetBy(memberDto.getProfileId(), LocalDate.parse(date)); + return ApiResponse.createSuccess(DailyBudgetResponse.of(dailyBudget)); + } + + @PutMapping("/daily/{date}/default") + public ApiResponse registerDefaultDailyBudget(@UserContext MemberDto memberDto, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, + @RequestParam("limit") Long limit) { + budgetService.registerDefaultDailyBudgetBy(memberDto.getProfileId(), limit, LocalDate.parse(date)); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/daily/{date}") + public ApiResponse editDailyBudget(@UserContext MemberDto memberDto, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, + @RequestParam("limit") Long limit) { + budgetService.editDailyBudgetCustom(memberDto.getProfileId(), LocalDate.parse(date), limit); + return ApiResponse.createSuccessWithNoContent(); + } + + // 해당 일자가 속한 일일 예산 주간 데이터 조회 + @GetMapping("/daily/{date}/week") + public ApiResponse> dailyBudgetWeekByDate(@UserContext MemberDto memberDto, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { + List dailyBudgets = budgetService.getDailyBudgetsByWeek(memberDto.getProfileId(), + LocalDate.parse(date)); + + List responses = dailyBudgets.stream() + .map(DailyBudgetResponse::of) + .toList(); + + return ApiResponse.createSuccess(responses); + } + + @GetMapping("/monthly/{yearMonth}") + public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, + @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth) { + MonthlyBudget monthlyBudget = budgetService.getMonthlyBudgetBy(memberDto.getProfileId(), + yearMonth); + + return ApiResponse.createSuccess(MonthlyBudgetResponse.of(monthlyBudget)); + } + + @PutMapping("/monthly/{yearMonth}/default") + public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto memberDto, + @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth, + @RequestParam("limit") Long limit) { + budgetService.registerDefaultMonthlyBudgetBy(memberDto.getProfileId(), + limit, yearMonth); + + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/monthly/{yearMonth}") + public ApiResponse editMonthlyBudget(@UserContext MemberDto memberDto, + @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth, + @RequestParam("limit") Long limit) { + budgetService.editMonthlyBudgetCustom(memberDto.getProfileId(), + yearMonth, limit); + + return ApiResponse.createSuccessWithNoContent(); + } + + @AllArgsConstructor + @Data + static class DailyBudgetResponse { + private Long dailySpentAmount; + private Long dailyLimitAmount; + private Long dailyAvailableAmount; + + public static DailyBudgetResponse of(Budget dailyBudget) { + return new DailyBudgetResponse( + dailyBudget.getSpendAmount().longValue(), + dailyBudget.getLimit().longValue(), + dailyBudget.getAvailableAmount().longValue() + ); + } + } + + @AllArgsConstructor + @Data + static class MonthlyBudgetResponse { + private Long monthlySpentAmount; + private Long monthlyLimitAmount; + private Long monthlyAvailableAmount; + + public static MonthlyBudgetResponse of(Budget monthlyBudget) { + return new MonthlyBudgetResponse( + monthlyBudget.getSpendAmount().longValue(), + monthlyBudget.getLimit().longValue(), + monthlyBudget.getAvailableAmount().longValue() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberPreferenceController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberPreferenceController.java new file mode 100644 index 0000000..b1441fa --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberPreferenceController.java @@ -0,0 +1,78 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.service.MemberCategoryPreferenceService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members/me/preferences") +public class MemberPreferenceController { + + private final MemberCategoryPreferenceService memberCategoryPreferenceService; + + @GetMapping + public ApiResponse getCategoryPreferences(@UserContext MemberDto memberDto) { + List preferences = + memberCategoryPreferenceService.getPreferences(memberDto.getProfileId()); + + List liked = preferences.stream() + .filter(p -> p.getType() == PreferenceType.LIKE) + .map(p -> new CategoryPreferenceDto( + p.getCategory().getId(), + p.getCategory().getName(), + p.getPriority())) + .toList(); + + List disliked = preferences.stream() + .filter(p -> p.getType() == PreferenceType.DISLIKE) + .map(p -> new CategoryPreferenceDto( + p.getCategory().getId(), + p.getCategory().getName(), + p.getPriority())) + .toList(); + + return ApiResponse.createSuccess(new PreferencesResponse(liked, disliked)); + } + + @PostMapping + public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDto, + @RequestBody PreferencesRequest request) { + memberCategoryPreferenceService.savePreferences( + memberDto.getProfileId(), + request.getLiked(), + request.getDisliked()); + return ApiResponse.createSuccessWithNoContent(); + } + + @AllArgsConstructor + @Data + static class PreferencesRequest { + private List liked; + private List disliked; + } + + @AllArgsConstructor + @Data + static class PreferencesResponse { + private List liked; + private List disliked; + } + + @AllArgsConstructor + @Data + static class CategoryPreferenceDto { + private Long categoryId; + private String categoryName; + private Integer priority; + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index cc1726f..c1c58e0 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -1,42 +1,16 @@ package com.stcom.smartmealtable.web.controller; -import com.stcom.smartmealtable.domain.Address.Address; -import com.stcom.smartmealtable.domain.Address.AddressType; -import com.stcom.smartmealtable.domain.Budget.Budget; -import com.stcom.smartmealtable.domain.Budget.DailyBudget; -import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; -import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; -import com.stcom.smartmealtable.domain.food.PreferenceType; import com.stcom.smartmealtable.domain.member.MemberProfile; import com.stcom.smartmealtable.domain.member.MemberType; -import com.stcom.smartmealtable.infrastructure.AddressApiService; -import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; -import com.stcom.smartmealtable.service.BudgetService; -import com.stcom.smartmealtable.service.MemberCategoryPreferenceService; import com.stcom.smartmealtable.service.MemberProfileService; import com.stcom.smartmealtable.service.dto.MemberDto; import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; -import com.stcom.smartmealtable.web.validation.YearMonthFormat; -import java.time.LocalDate; -import java.time.YearMonth; -import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.format.annotation.DateTimeFormat.ISO; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -44,9 +18,6 @@ public class MemberProfileController { private final MemberProfileService memberProfileService; - private final AddressApiService addressApiService; - private final MemberCategoryPreferenceService memberCategoryPreferenceService; - private final BudgetService budgetService; @GetMapping("/me") public ApiResponse getMemberProfilePageInfo(@UserContext MemberDto memberDto) { @@ -54,7 +25,7 @@ public ApiResponse getMemberProfilePageInfo(@UserCont return ApiResponse.createSuccess(new MemberProfilePageResponse(profile, memberDto)); } - @PostMapping() + @PostMapping public ApiResponse createMemberProfile(@UserContext MemberDto memberDto, @Validated @RequestBody MemberProfileRequest request) { memberProfileService.createProfile(request.getNickName(), memberDto.getMemberId(), request.getMemberType(), @@ -70,144 +41,6 @@ public ApiResponse changeMemberProfile(@UserContext MemberDto memberDto, return ApiResponse.createSuccessWithNoContent(); } - @PostMapping("/me/addresses/{id}/primary") - public ApiResponse changePrimaryAddress(@UserContext MemberDto memberDto, - @PathVariable("id") Long addressId) { - memberProfileService.changeAddressToPrimary(memberDto.getProfileId(), addressId); - return ApiResponse.createSuccessWithNoContent(); - } - - @PostMapping("/me/addresses") - public ApiResponse registerAddress(@UserContext MemberDto memberDto, MemberAddressCURequest request) { - Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); - memberProfileService.saveNewAddress(memberDto.getProfileId(), address, request.getAlias(), - request.getAddressType()); - return ApiResponse.createSuccessWithNoContent(); - } - - @PatchMapping("/me/addresses/{id}") - public ApiResponse changeAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId, - MemberAddressCURequest request) { - Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); - memberProfileService.changeAddress(memberDto.getProfileId(), addressId, address, request.getAlias(), - request.getAddressType()); - return ApiResponse.createSuccessWithNoContent(); - } - - @DeleteMapping("/me/addresses/{id}") - public ApiResponse deleteAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId) { - memberProfileService.deleteAddress(memberDto.getProfileId(), addressId); - return ApiResponse.createSuccessWithNoContent(); - } - - @GetMapping("/me/preferences") - public ApiResponse getCategoryPreferences(@UserContext MemberDto memberDto) { - List preferences = - memberCategoryPreferenceService.getPreferences(memberDto.getProfileId()); - - List liked = preferences.stream() - .filter(p -> p.getType() == PreferenceType.LIKE) - .map(p -> new CategoryPreferenceDto( - p.getCategory().getId(), - p.getCategory().getName(), - p.getPriority())) - .toList(); - - List disliked = preferences.stream() - .filter(p -> p.getType() == PreferenceType.DISLIKE) - .map(p -> new CategoryPreferenceDto( - p.getCategory().getId(), - p.getCategory().getName(), - p.getPriority())) - .toList(); - - return ApiResponse.createSuccess(new PreferencesResponse(liked, disliked)); - } - - @PostMapping("/me/preferences") - public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDto, - @RequestBody PreferencesRequest request) { - memberCategoryPreferenceService.savePreferences( - memberDto.getProfileId(), - request.getLiked(), - request.getDisliked()); - return ApiResponse.createSuccessWithNoContent(); - } - - /** - * 일별 예산 조회 - * - * @param memberDto - * @param date (ISO_LOCAL_DATE 형식, 예: 2025-06-01) - * @return - */ - @GetMapping("/me/budgets/daily/{date}") - public ApiResponse dailyBudgetByDate(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { - DailyBudget dailyBudget = budgetService.getDailyBudgetBy(memberDto.getProfileId(), LocalDate.parse(date)); - return ApiResponse.createSuccess(DailyBudgetResponse.of(dailyBudget)); - } - - @PutMapping("/me/budgets/daily/{date}/default") - public ApiResponse registerDefaultDailyBudget(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, - @RequestParam("limit") Long limit) { - budgetService.registerDefaultDailyBudgetBy(memberDto.getProfileId(), limit, LocalDate.parse(date)); - return ApiResponse.createSuccessWithNoContent(); - } - - @PatchMapping("/me/budgets/daily/{date}") - public ApiResponse editDailyBudget(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, - @RequestParam("limit") Long limit) { - budgetService.editDailyBudgetCustom(memberDto.getProfileId(), LocalDate.parse(date), limit); - return ApiResponse.createSuccessWithNoContent(); - } - - // 해당 일자가 속한 일일 예산 주간 데이터 조회 - @GetMapping("/me/budgets/daily/{date}/week") - public ApiResponse> dailyBudgetWeekByDate(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { - List dailyBudgets = budgetService.getDailyBudgetsByWeek(memberDto.getProfileId(), - LocalDate.parse(date)); - - List responses = dailyBudgets.stream() - .map(DailyBudgetResponse::of) - .toList(); - - return ApiResponse.createSuccess(responses); - } - - @GetMapping("/me/budgets/monthly/{yearMonth}") - public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth) { - MonthlyBudget monthlyBudget = budgetService.getMonthlyBudgetBy(memberDto.getProfileId(), - yearMonth); - - return ApiResponse.createSuccess(MonthlyBudgetResponse.of(monthlyBudget)); - } - - @PutMapping("/me/budgets/monthly/{yearMonth}/default") - public ApiResponse registerDefaultMonthlyBudget(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth, - @RequestParam("limit") Long limit) { - budgetService.registerDefaultMonthlyBudgetBy(memberDto.getProfileId(), - limit, yearMonth); - - return ApiResponse.createSuccessWithNoContent(); - } - - @PatchMapping("/me/budgets/monthly/{yearMonth}") - public ApiResponse editMonthlyBudget(@UserContext MemberDto memberDto, - @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth, - @RequestParam("limit") Long limit) { - budgetService.editMonthlyBudgetCustom(memberDto.getProfileId(), - yearMonth, limit); - - return ApiResponse.createSuccessWithNoContent(); - } - - @AllArgsConstructor @Data static class MemberProfilePageResponse { @@ -220,8 +53,8 @@ static class MemberProfilePageResponse { public MemberProfilePageResponse(MemberProfile profile, MemberDto memberDto) { this.nickName = profile.getNickName(); this.email = memberDto.getEmail(); - Address address = profile.findPrimaryAddress().getAddress(); - this.primaryAddress = address.getRoadAddress() + address.getDetailAddress(); + this.primaryAddress = profile.findPrimaryAddress().getAddress().getRoadAddress() + + profile.findPrimaryAddress().getAddress().getDetailAddress(); this.memberType = profile.getType(); this.groupName = profile.getGroup().getName(); } @@ -234,72 +67,4 @@ static class MemberProfileRequest { private Long groupId; private MemberType memberType; } - - @AllArgsConstructor - @Data - static class MemberAddressCURequest { - private String roadAddress; - private AddressType addressType; - private String alias; - private String detailAddress; - - public AddressRequest toAddressApiRequest() { - return new AddressRequest(roadAddress, detailAddress); - } - } - - @AllArgsConstructor - @Data - static class PreferencesRequest { - private List liked; - private List disliked; - } - - @AllArgsConstructor - @Data - static class PreferencesResponse { - private List liked; - private List disliked; - } - - @AllArgsConstructor - @Data - static class CategoryPreferenceDto { - private Long categoryId; - private String categoryName; - private Integer priority; - } - - @AllArgsConstructor - @Data - static class DailyBudgetResponse { - private Long dailySpentAmount; - private Long dailyLimitAmount; - private Long dailyAvailableAmount; - - public static DailyBudgetResponse of(Budget dailyBudget) { - return new DailyBudgetResponse( - dailyBudget.getSpendAmount().longValue(), - dailyBudget.getLimit().longValue(), - dailyBudget.getAvailableAmount().longValue() - ); - } - } - - @AllArgsConstructor - @Data - static class MonthlyBudgetResponse { - private Long monthlySpentAmount; - private Long monthlyLimitAmount; - private Long monthlyAvailableAmount; - - public static MonthlyBudgetResponse of(Budget monthlyBudget) { - return new MonthlyBudgetResponse( - monthlyBudget.getSpendAmount().longValue(), - monthlyBudget.getLimit().longValue(), - monthlyBudget.getAvailableAmount().longValue() - ); - } - } - } From 40e6c68c3bc0a63982161e1390c45e0cc3e27e53 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 23:19:50 +0900 Subject: [PATCH 18/44] =?UTF-8?q?refactor:=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여러번의 저장 쿼리를 saveAll 한 번으로 개선합니다. --- .../com/stcom/smartmealtable/service/BudgetService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java index a488590..914a4da 100644 --- a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java +++ b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java @@ -10,6 +10,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.YearMonth; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -61,10 +62,11 @@ public void registerDefaultDailyBudgetBy(Long profileId, Long dailyLimit, LocalD MemberProfile profile = memberProfileRepository.findById(profileId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")); // 일일 예산은 오늘 ~ 이번달 말일까지 디폴트 daily Limit로 여러 개 생성해준다. + List budgets = new ArrayList<>(); for (LocalDate date = startDate; date.getMonth() == startDate.getMonth(); date = date.plusDays(1)) { - DailyBudget dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(dailyLimit), date); - budgetRepository.save(dailyBudget); + budgets.add(new DailyBudget(profile, BigDecimal.valueOf(dailyLimit), date)); } + budgetRepository.saveAll(budgets); } @Transactional From 386c45d9dfc7479d20fd826d2d541f7b8da23f98 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 23:20:19 +0900 Subject: [PATCH 19/44] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=95=84=EB=93=9C,=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/MemberProfile.java | 12 -- .../service/MemberProfileService.java | 8 -- .../web/controller/MemberController.java | 10 +- .../service/MemberProfileServiceTest.java | 115 +++++++----------- 4 files changed, 46 insertions(+), 99 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java b/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java index 48e7af4..e5b4b54 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java +++ b/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java @@ -18,7 +18,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import lombok.Builder; @@ -52,13 +51,6 @@ public class MemberProfile extends BaseTimeEntity { @JoinColumn(name = "affiliation_id") private Group group; - @Column(name = "default_monthly_limit") - private BigDecimal defaultMonthlyLimit; - - @Column(name = "default_daily_limit") - private BigDecimal defaultDailyLimit; - - @Builder public MemberProfile(Member member, String nickName, List addressHistory, MemberType type, Group group) { @@ -134,8 +126,4 @@ public void changeGroup(Group newGroup) { this.group = newGroup; } - public void registerDefaultBudgets(BigDecimal defaultDailyLimit, BigDecimal defaultMonthlyLimit) { - this.defaultDailyLimit = defaultDailyLimit; - this.defaultMonthlyLimit = defaultMonthlyLimit; - } } diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java b/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java index 4c19ce3..448ab40 100644 --- a/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java +++ b/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java @@ -11,7 +11,6 @@ import com.stcom.smartmealtable.repository.GroupRepository; import com.stcom.smartmealtable.repository.MemberProfileRepository; import com.stcom.smartmealtable.repository.MemberRepository; -import java.math.BigDecimal; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -104,11 +103,4 @@ public void deleteAddress(Long profileId, Long addressEntityId) { .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); profile.removeAddress(addressEntity); } - - @Transactional - public void registerDefaultBudgets(Long profileId, Long dailyLimit, Long monthlyLimit) { - MemberProfile profile = memberProfileRepository.findById(profileId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필입니다")); - profile.registerDefaultBudgets(BigDecimal.valueOf(dailyLimit), BigDecimal.valueOf(monthlyLimit)); - } } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java index dd3053e..209933a 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java @@ -20,7 +20,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -49,8 +48,8 @@ public ResponseEntity> checkEmail(@Email @RequestParam String @ResponseStatus(HttpStatus.CREATED) @PostMapping() - public ApiResponse createMember(@Valid @RequestBody CreateMemberRequest request, - BindingResult bindingResult) throws PasswordPolicyException { + public ApiResponse createMember(@Valid @RequestBody CreateMemberRequest request) + throws PasswordPolicyException { memberService.validateDuplicatedEmail(request.getEmail()); memberService.checkPasswordDoubly(request.getPassword(), request.getConfirmPassword()); @@ -67,8 +66,7 @@ public ApiResponse createMember(@Valid @RequestBody CreateM } @PatchMapping("/me") - public ApiResponse editMember(@UserContext MemberDto memberDto, @Valid @RequestBody EditMemberRequest request, - BindingResult bindingResult) + public ApiResponse editMember(@UserContext MemberDto memberDto, @Valid @RequestBody EditMemberRequest request) throws PasswordPolicyException, PasswordFailedExceededException { memberService.checkPasswordDoubly(request.getNewPassword(), request.getConfirmPassword()); memberService.changePassword(memberDto.getMemberId(), request.getOriginPassword(), request.getNewPassword()); @@ -83,7 +81,7 @@ public ApiResponse deleteMember(@UserContext MemberDto memberDto) { @PostMapping("/signup") public ApiResponse signUpWithTermAgreement(@UserContext MemberDto memberDto, - @RequestBody List agreements) { + @RequestBody List agreements) { termService.agreeTerms( memberDto.getMemberId(), agreements.stream() diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java index 70995c3..85226dc 100644 --- a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java +++ b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java @@ -3,8 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -21,16 +19,9 @@ import com.stcom.smartmealtable.repository.GroupRepository; import com.stcom.smartmealtable.repository.MemberProfileRepository; import com.stcom.smartmealtable.repository.MemberRepository; - -import java.math.BigDecimal; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Optional; -import java.util.Set; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,7 +30,6 @@ import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; @@ -60,13 +50,13 @@ class MemberProfileServiceTest { @InjectMocks private MemberProfileService profileService; - + @Captor private ArgumentCaptor profileCaptor; - + @Captor private ArgumentCaptor addressEntityCaptor; - + private Member member; private Group group; private Address address; @@ -77,12 +67,12 @@ void setUp() { // 테스트 데이터 셋업 member = new Member("test@example.com"); ReflectionTestUtils.setField(member, "id", 1L); - + group = new SchoolGroup(); ReflectionTestUtils.setField(group, "id", 1L); ReflectionTestUtils.setField(group, "name", "서울대학교"); ReflectionTestUtils.setField(group, "schoolType", SchoolType.UNIVERSITY_FOUR_YEAR); - + address = createAddress("서울특별시 관악구 관악로 1", "서울특별시 관악구", "101호", 37.459, 126.952); } @@ -92,7 +82,7 @@ void getProfileFetch() { // given Long profileId = 1L; profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - + when(memberProfileRepository.findMemberProfileEntityGraphById(profileId)) .thenReturn(Optional.of(profile)); @@ -105,10 +95,10 @@ void getProfileFetch() { assertThat(fetchedProfile.getMember()).isEqualTo(member); assertThat(fetchedProfile.getType()).isEqualTo(MemberType.STUDENT); assertThat(fetchedProfile.getGroup()).isEqualTo(group); - + verify(memberProfileRepository).findMemberProfileEntityGraphById(profileId); } - + @Test @DisplayName("프로필을 생성할 수 있어야 한다 - 그룹 있음") void createProfileWithGroup() { @@ -117,7 +107,7 @@ void createProfileWithGroup() { Long memberId = 1L; MemberType type = MemberType.STUDENT; Long groupId = 1L; - + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); when(groupRepository.getReferenceById(groupId)).thenReturn(group); when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> { @@ -133,14 +123,14 @@ void createProfileWithGroup() { verify(memberRepository).findById(memberId); verify(groupRepository).getReferenceById(groupId); verify(memberProfileRepository).save(profileCaptor.capture()); - + MemberProfile savedProfile = profileCaptor.getValue(); assertThat(savedProfile.getNickName()).isEqualTo(nickName); assertThat(savedProfile.getMember()).isEqualTo(member); assertThat(savedProfile.getType()).isEqualTo(type); assertThat(savedProfile.getGroup()).isEqualTo(group); } - + @Test @DisplayName("프로필을 생성할 수 있어야 한다 - 그룹 없음") void createProfileWithoutGroup() { @@ -149,7 +139,7 @@ void createProfileWithoutGroup() { Long memberId = 1L; MemberType type = MemberType.OTHER; Long groupId = null; - + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> { MemberProfile savedProfile = invocation.getArgument(0); @@ -163,14 +153,14 @@ void createProfileWithoutGroup() { // then verify(memberRepository).findById(memberId); verify(memberProfileRepository).save(profileCaptor.capture()); - + MemberProfile savedProfile = profileCaptor.getValue(); assertThat(savedProfile.getNickName()).isEqualTo(nickName); assertThat(savedProfile.getMember()).isEqualTo(member); assertThat(savedProfile.getType()).isEqualTo(type); assertThat(savedProfile.getGroup()).isNull(); } - + @Test @DisplayName("프로필 정보를 변경할 수 있어야 한다") void changeProfile() { @@ -179,13 +169,13 @@ void changeProfile() { String newNickName = "변경된닉네임"; MemberType newType = MemberType.WORKER; Long newGroupId = 2L; - + profile = createProfile(profileId, "원래닉네임", member, MemberType.STUDENT, group); - + Group newGroup = new SchoolGroup(); ReflectionTestUtils.setField(newGroup, "id", 2L); ReflectionTestUtils.setField(newGroup, "name", "회사"); - + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); when(groupRepository.getReferenceById(newGroupId)).thenReturn(newGroup); @@ -195,12 +185,12 @@ void changeProfile() { // then verify(memberProfileRepository).findById(profileId); verify(groupRepository).getReferenceById(newGroupId); - + assertThat(profile.getNickName()).isEqualTo(newNickName); assertThat(profile.getType()).isEqualTo(newType); assertThat(profile.getGroup()).isEqualTo(newGroup); } - + @Test @DisplayName("새 주소를 추가할 수 있어야 한다") void saveNewAddress() { @@ -208,10 +198,10 @@ void saveNewAddress() { Long profileId = 1L; String alias = "집"; AddressType addressType = AddressType.HOME; - + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); ReflectionTestUtils.setField(profile, "addressHistory", new ArrayList<>()); - + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); when(addressEntityRepository.save(any(AddressEntity.class))).thenAnswer(invocation -> { AddressEntity savedAddress = invocation.getArgument(0); @@ -225,14 +215,14 @@ void saveNewAddress() { // then verify(memberProfileRepository).findById(profileId); verify(addressEntityRepository).save(addressEntityCaptor.capture()); - + AddressEntity savedAddressEntity = addressEntityCaptor.getValue(); assertThat(savedAddressEntity.getAddress()).isEqualTo(address); assertThat(savedAddressEntity.getAlias()).isEqualTo(alias); assertThat(savedAddressEntity.getType()).isEqualTo(addressType); assertThat(profile.getAddressHistory()).hasSize(1); } - + @Test @DisplayName("주소 정보를 변경할 수 있어야 한다") void changeAddress() { @@ -241,22 +231,22 @@ void changeAddress() { Long addressEntityId = 1L; String newAlias = "새집"; AddressType newType = AddressType.HOME; - + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - + AddressEntity addressEntity = AddressEntity.builder() .address(address) .alias("구집") .type(AddressType.ETC) .build(); ReflectionTestUtils.setField(addressEntity, "id", addressEntityId); - + List addresses = new ArrayList<>(); addresses.add(addressEntity); ReflectionTestUtils.setField(profile, "addressHistory", addresses); - + Address newAddress = createAddress("서울시 서초구 서초대로 123", "서초구", "202호", 37.5, 127.0); - + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); when(addressEntityRepository.findById(addressEntityId)).thenReturn(Optional.of(addressEntity)); @@ -266,35 +256,35 @@ void changeAddress() { // then verify(memberProfileRepository).findById(profileId); verify(addressEntityRepository).findById(addressEntityId); - + // 주소 정보가 업데이트되었는지 확인 assertThat(addressEntity.getAddress()).isEqualTo(newAddress); assertThat(addressEntity.getAlias()).isEqualTo(newAlias); assertThat(addressEntity.getType()).isEqualTo(newType); } - + @Test @DisplayName("주소를 삭제할 수 있어야 한다") void deleteAddress() { // given Long profileId = 1L; Long addressEntityId = 1L; - + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - + AddressEntity addressEntity = AddressEntity.builder() .address(address) .alias("집") .type(AddressType.HOME) .build(); ReflectionTestUtils.setField(addressEntity, "id", addressEntityId); - + // 주소에 primary=true 설정 ReflectionTestUtils.setField(addressEntity, "primary", true); - + List addresses = new ArrayList<>(); addresses.add(addressEntity); - + // 두번째 주소 추가 AddressEntity secondAddress = AddressEntity.builder() .address(createAddress("서울시 강남구", "강남구", "301호", 37.4, 127.1)) @@ -303,9 +293,9 @@ void deleteAddress() { .build(); ReflectionTestUtils.setField(secondAddress, "id", 2L); addresses.add(secondAddress); - + ReflectionTestUtils.setField(profile, "addressHistory", addresses); - + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); when(addressEntityRepository.findById(addressEntityId)).thenReturn(Optional.of(addressEntity)); @@ -315,34 +305,13 @@ void deleteAddress() { // then verify(memberProfileRepository).findById(profileId); verify(addressEntityRepository).findById(addressEntityId); - + // 주소가 삭제되었는지 확인 assertThat(profile.getAddressHistory()).hasSize(1); assertThat(profile.getAddressHistory().get(0)).isEqualTo(secondAddress); } - - @Test - @DisplayName("기본 예산을 설정할 수 있어야 한다") - void registerDefaultBudgets() { - // given - Long profileId = 1L; - Long dailyLimit = 10000L; - Long monthlyLimit = 300000L; - - profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - - when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - // when - profileService.registerDefaultBudgets(profileId, dailyLimit, monthlyLimit); - // then - verify(memberProfileRepository).findById(profileId); - - // 기본 예산 설정 로직은 실제로는 MemberProfile 내부 메서드에 있으므로 - // 이 테스트에서는 메서드 호출 여부만 확인 - } - @Test @DisplayName("존재하지 않는 프로필 ID로 조회 시 예외가 발생해야 한다") void getProfileFetchNotFound() { @@ -356,7 +325,7 @@ void getProfileFetchNotFound() { .isInstanceOf(IllegalStateException.class) .hasMessage("존재하지 않는 프로필입니다"); } - + @Test @DisplayName("존재하지 않는 회원 ID로 프로필 생성 시 예외가 발생해야 한다") void createProfileWithNonExistingMember() { @@ -380,9 +349,9 @@ private MemberProfile createProfile(Long id, String nickName, Member member, Mem ReflectionTestUtils.setField(profile, "id", id); return profile; } - - private Address createAddress(String roadAddress, String detailAddress, String alias, - double latitude, double longitude) { + + private Address createAddress(String roadAddress, String detailAddress, String alias, + double latitude, double longitude) { Address address = Address.builder() .roadAddress(roadAddress) .detailAddress(detailAddress) From d49d3386c11495b22bf78f06968865b79d0dd433 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Wed, 11 Jun 2025 23:54:01 +0900 Subject: [PATCH 20/44] =?UTF-8?q?refactor:=20Api=20Controller=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클래스 책임에 맞게 클래스 구조를 리팩토링 --- .../web/controller/AuthController.java | 103 ++++++++++++++++ .../web/controller/AuthTokenController.java | 112 ++++++++++++++++++ .../web/controller/GroupController.java | 77 +----------- .../web/controller/LoginController.java | 61 ---------- .../controller/MemberAccountController.java | 52 ++++++++ .../web/controller/MemberController.java | 107 ++--------------- .../web/controller/OAuth2Controller.java | 82 ------------- .../web/controller/SchoolGroupController.java | 49 ++++++++ .../web/dto/group/GroupDto.java | 24 ++++ .../dto/group/SchoolGroupCreateRequest.java | 24 ++++ .../dto/group/SchoolGroupUpdateRequest.java | 24 ++++ 11 files changed, 399 insertions(+), 316 deletions(-) create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java delete mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/MemberAccountController.java delete mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/SchoolGroupController.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/dto/group/GroupDto.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupCreateRequest.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupUpdateRequest.java diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java b/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java new file mode 100644 index 0000000..4f5c6ef --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java @@ -0,0 +1,103 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.MemberService; +import com.stcom.smartmealtable.service.TermService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 인증(로그인, 회원가입) 관련 API. + */ +@RestController +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final MemberService memberService; + private final JwtTokenService jwtTokenService; + private final TermService termService; + + @GetMapping("/email/check") + public ResponseEntity> checkEmail(@Email @RequestParam String email) { + memberService.validateDuplicatedEmail(email); + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/signup") + public ApiResponse signUp(@Valid @RequestBody SignUpRequest request) + throws PasswordPolicyException { + memberService.validateDuplicatedEmail(request.getEmail()); + memberService.checkPasswordDoubly(request.getPassword(), request.getConfirmPassword()); + + Member member = Member.builder() + .fullName(request.getFullName()) + .email(request.getEmail()) + .rawPassword(request.getPassword()) + .build(); + + memberService.saveMember(member); + JwtTokenResponseDto tokenDto = jwtTokenService.createTokenDto(member.getId(), null); + tokenDto.setNewUser(true); + return ApiResponse.createSuccess(tokenDto); + } + + @PostMapping("/signup/terms") + public ApiResponse agreeTerms(@UserContext MemberDto memberDto, + @RequestBody List agreements) { + termService.agreeTerms( + memberDto.getMemberId(), + agreements.stream() + .map(dto -> new TermAgreementRequestDto(dto.getTermId(), dto.getIsAgreed())) + .toList() + ); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/signup") + public ApiResponse cancelSignUp(@UserContext MemberDto memberDto) { + memberService.deleteByMemberId(memberDto.getMemberId()); + return ApiResponse.createSuccessWithNoContent(); + } + + @Data + @AllArgsConstructor + public static class SignUpRequest { + @Email + private String email; + private String password; + private String confirmPassword; + private String fullName; + } + + @Data + @AllArgsConstructor + public static class TermAgreementRequest { + private Long termId; + private Boolean isAgreed; + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java b/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java new file mode 100644 index 0000000..8518502 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java @@ -0,0 +1,112 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.infrastructure.SocialAuthService; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.security.JwtBlacklistService; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.LoginService; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 인증 토큰 관련 API (로그인 / 로그아웃 / 토큰 리프레시 / 소셜 로그인). + */ +@RestController +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthTokenController { + + private final LoginService loginService; + private final JwtTokenService jwtTokenService; + private final JwtBlacklistService jwtBlacklistService; + private final SocialAuthService socialAuthService; + + @PostMapping("/login") + public ApiResponse login(@RequestBody EmailLoginRequest request) { + AuthResultDto authResultDto = loginService.loginWithEmail(request.getEmail(), request.getPassword()); + JwtTokenResponseDto jwtDto = jwtTokenService.createTokenDto(authResultDto.getMemberId(), + authResultDto.getProfileId()); + if (authResultDto.isNewUser()) { + jwtDto.setNewUser(true); + } + return ApiResponse.createSuccess(jwtDto); + } + + @PostMapping("/logout") + public ApiResponse logout(HttpServletRequest request) { + String jwt = request.getHeader("Authorization"); + if (Objects.nonNull(jwt)) { + jwtBlacklistService.addToBlacklist(jwt); + } + return ApiResponse.createSuccessWithNoContent(); + } + + @PostMapping("/oauth2/code") + public ApiResponse socialLogin(@RequestBody SocialLoginRequest request) { + TokenDto token = socialAuthService.getTokenResponse(request.getProvider().toLowerCase(), + request.getAuthorizationCode()); + AuthResultDto authResultDto = loginService.socialLogin(token); + JwtTokenResponseDto jwtDto = jwtTokenService.createTokenDto(authResultDto.getMemberId(), + authResultDto.getProfileId()); + if (authResultDto.isNewUser()) { + jwtDto.setNewUser(true); + } + return ApiResponse.createSuccess(jwtDto); + } + + @PostMapping("/token/refresh") + public ApiResponse refreshAccessToken(@UserContext MemberDto memberDto, + @RequestBody RefreshTokenRequest request) { + String accessToken = jwtTokenService.createAccessToken(memberDto.getMemberId(), memberDto.getProfileId()); + return ApiResponse.createSuccess(new AccessTokenRefreshResponse(accessToken, 3600, "Bearer")); + } + + @Data + @AllArgsConstructor + public static class EmailLoginRequest { + @NotEmpty + @Email + private String email; + @NotEmpty + private String password; + } + + @Data + @AllArgsConstructor + public static class SocialLoginRequest { + @NotEmpty + private String provider; + @NotEmpty + private String authorizationCode; + } + + @Data + public static class RefreshTokenRequest { + @NotEmpty + private String refreshToken; + } + + @Data + @AllArgsConstructor + public static class AccessTokenRefreshResponse { + private String accessToken; + private int expiresIn; + private String tokenType; + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java index 6912bf5..ebbc6ac 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java @@ -1,23 +1,14 @@ package com.stcom.smartmealtable.web.controller; import com.stcom.smartmealtable.domain.group.Group; -import com.stcom.smartmealtable.domain.group.SchoolType; -import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; import com.stcom.smartmealtable.service.GroupService; import com.stcom.smartmealtable.web.dto.ApiResponse; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; +import com.stcom.smartmealtable.web.dto.group.GroupDto; import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Data; import lombok.RequiredArgsConstructor; -import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -40,75 +31,9 @@ public ApiResponse> searchGroup(@RequestParam String keyword) { .toList()); } - @PostMapping("/schools") - public ApiResponse registerSchoolGroup(@RequestBody @Validated SchoolGroupCreateRequest request) { - groupService.createSchoolGroup(new AddressRequest(request.getRoadAddress(), request.getDetailAddress()), - request.getName(), request.getType()); - return ApiResponse.createSuccessWithNoContent(); - } - - @PatchMapping("/schools/{id}") - public ApiResponse editSchoolGroup(@PathVariable("id") Long id, - @RequestBody @Validated SchoolGroupUpdateRequest request) { - groupService.changeSchoolGroup(id, - new AddressRequest(request.getRoadAddress(), request.getDetailAddress()), - request.getName(), request.getType()); - return ApiResponse.createSuccessWithNoContent(); - } - @DeleteMapping("/{id}") public ApiResponse deleteGroup(@PathVariable("id") Long id) { groupService.deleteGroup(id); return ApiResponse.createSuccessWithNoContent(); } - - @Data - @AllArgsConstructor - static class GroupDto { - private Long id; - private String roadAddress; - private String name; - private String groupType; - - public GroupDto(Group group) { - this.id = group.getId(); - this.groupType = group.getTypeName(); - this.name = group.getName(); - this.roadAddress = group.getAddress().getRoadAddress(); - } - } - - @Data - @AllArgsConstructor - static class SchoolGroupCreateRequest { - - @NotEmpty - private String roadAddress; - - @NotEmpty - private String detailAddress; - - @NotEmpty - private String name; - - @NotNull - private SchoolType type; - } - - @Data - @AllArgsConstructor - static class SchoolGroupUpdateRequest { - - @NotEmpty - private String roadAddress; - - @NotEmpty - private String detailAddress; - - @NotEmpty - private String name; - - @NotNull - private SchoolType type; - } } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java b/src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java deleted file mode 100644 index 5a6103c..0000000 --- a/src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.stcom.smartmealtable.web.controller; - -import com.stcom.smartmealtable.exception.PasswordFailedExceededException; -import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; -import com.stcom.smartmealtable.security.JwtBlacklistService; -import com.stcom.smartmealtable.security.JwtTokenService; -import com.stcom.smartmealtable.service.LoginService; -import com.stcom.smartmealtable.service.dto.AuthResultDto; -import com.stcom.smartmealtable.web.dto.ApiResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotEmpty; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@Slf4j -@RequestMapping("/api/v1/auth") -public class LoginController { - - private final LoginService loginService; - private final JwtTokenService jwtTokenService; - private final JwtBlacklistService jwtBlacklistService; - - @PostMapping("/login") - public ApiResponse login(@Validated @RequestBody LoginRequest request) - throws PasswordFailedExceededException { - AuthResultDto authResultDto = loginService.loginWithEmail(request.getEmail(), request.getPassword()); - JwtTokenResponseDto jwtTokenResponseDto = - jwtTokenService.createTokenDto(authResultDto.getMemberId(), authResultDto.getProfileId()); - if (authResultDto.isNewUser()) { - jwtTokenResponseDto.setNewUser(true); - } - return ApiResponse.createSuccess(jwtTokenResponseDto); - } - - @PostMapping("/logout") - public ApiResponse logout(HttpServletRequest request) { - String jwt = request.getHeader("Authorization"); - jwtBlacklistService.addToBlacklist(jwt); - return ApiResponse.createSuccessWithNoContent(); - } - - @Data - static class LoginRequest { - - @NotEmpty - @Email - private String email; - - @NotEmpty - private String password; - } -} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberAccountController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberAccountController.java new file mode 100644 index 0000000..ec9dc88 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberAccountController.java @@ -0,0 +1,52 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.service.MemberService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 회원 계정 관리(비밀번호 변경, 탈퇴) 전용 컨트롤러. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +public class MemberAccountController { + + private final MemberService memberService; + + @PatchMapping("/me/password") + public ApiResponse changePassword(@UserContext MemberDto memberDto, + @Valid @RequestBody PasswordChangeRequest request) + throws PasswordPolicyException, PasswordFailedExceededException { + memberService.checkPasswordDoubly(request.getNewPassword(), request.getConfirmPassword()); + memberService.changePassword(memberDto.getMemberId(), request.getOriginPassword(), request.getNewPassword()); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/me") + public ApiResponse deleteMember(@UserContext MemberDto memberDto) { + memberService.deleteByMemberId(memberDto.getMemberId()); + return ApiResponse.createSuccessWithNoContent(); + } + + + @Data + @AllArgsConstructor + public static class PasswordChangeRequest { + private String originPassword; + private String newPassword; + private String confirmPassword; + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java index 209933a..6794941 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java @@ -1,106 +1,21 @@ package com.stcom.smartmealtable.web.controller; -import com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.exception.PasswordFailedExceededException; -import com.stcom.smartmealtable.exception.PasswordPolicyException; -import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; -import com.stcom.smartmealtable.security.JwtTokenService; -import com.stcom.smartmealtable.service.MemberService; -import com.stcom.smartmealtable.service.TermService; -import com.stcom.smartmealtable.service.dto.MemberDto; -import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; -import com.stcom.smartmealtable.web.argumentresolver.UserContext; -import com.stcom.smartmealtable.web.dto.ApiResponse; -import jakarta.validation.Valid; import jakarta.validation.constraints.Email; -import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; -@RestController -@Slf4j -@RequiredArgsConstructor -@RequestMapping("/api/v1/members") -public class MemberController { +/** + * 과거 테스트 코드 및 바이너리 호환성을 위한 DTO 컨테이너임. + */ +@Deprecated +public final class MemberController { - private final MemberService memberService; - private final JwtTokenService jwtTokenService; - private final TermService termService; - - @GetMapping("/email/check") - public ResponseEntity> checkEmail(@Email @RequestParam String email) { - memberService.validateDuplicatedEmail(email); - return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); - } - - @ResponseStatus(HttpStatus.CREATED) - @PostMapping() - public ApiResponse createMember(@Valid @RequestBody CreateMemberRequest request) - throws PasswordPolicyException { - memberService.validateDuplicatedEmail(request.getEmail()); - memberService.checkPasswordDoubly(request.getPassword(), request.getConfirmPassword()); - - Member member = Member.builder() - .fullName(request.getFullName()) - .email(request.getEmail()) - .rawPassword(request.getPassword()) - .build(); - - memberService.saveMember(member); - JwtTokenResponseDto tokenDto = jwtTokenService.createTokenDto(member.getId(), null); - tokenDto.setNewUser(true); - return ApiResponse.createSuccess(tokenDto); - } - - @PatchMapping("/me") - public ApiResponse editMember(@UserContext MemberDto memberDto, @Valid @RequestBody EditMemberRequest request) - throws PasswordPolicyException, PasswordFailedExceededException { - memberService.checkPasswordDoubly(request.getNewPassword(), request.getConfirmPassword()); - memberService.changePassword(memberDto.getMemberId(), request.getOriginPassword(), request.getNewPassword()); - return ApiResponse.createSuccessWithNoContent(); - } - - @DeleteMapping("/me") - public ApiResponse deleteMember(@UserContext MemberDto memberDto) { - memberService.deleteByMemberId(memberDto.getMemberId()); - return ApiResponse.createSuccessWithNoContent(); - } - - @PostMapping("/signup") - public ApiResponse signUpWithTermAgreement(@UserContext MemberDto memberDto, - @RequestBody List agreements) { - termService.agreeTerms( - memberDto.getMemberId(), - agreements.stream() - .map(dto -> new TermAgreementRequestDto(dto.getTermId(), dto.getIsAgreed())) - .toList() - ); - return ApiResponse.createSuccessWithNoContent(); - } - - @DeleteMapping("/signup") - public ApiResponse signUpCancel(@UserContext MemberDto memberDto) { - memberService.deleteByMemberId(memberDto.getMemberId()); - return ApiResponse.createSuccessWithNoContent(); + private MemberController() { } @Data @AllArgsConstructor - static class CreateMemberRequest { - + public static class CreateMemberRequest { @Email private String email; private String password; @@ -110,8 +25,7 @@ static class CreateMemberRequest { @Data @AllArgsConstructor - static class EditMemberRequest { - + public static class EditMemberRequest { private String originPassword; private String newPassword; private String confirmPassword; @@ -119,9 +33,8 @@ static class EditMemberRequest { @Data @AllArgsConstructor - static class TermAgreementDto { + public static class TermAgreementDto { private Long termId; private Boolean isAgreed; } - -} +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java b/src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java deleted file mode 100644 index d25461d..0000000 --- a/src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.stcom.smartmealtable.web.controller; - - -import com.stcom.smartmealtable.infrastructure.SocialAuthService; -import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; -import com.stcom.smartmealtable.infrastructure.dto.TokenDto; -import com.stcom.smartmealtable.security.JwtTokenService; -import com.stcom.smartmealtable.service.LoginService; -import com.stcom.smartmealtable.service.dto.AuthResultDto; -import com.stcom.smartmealtable.service.dto.MemberDto; -import com.stcom.smartmealtable.web.argumentresolver.UserContext; -import com.stcom.smartmealtable.web.dto.ApiResponse; -import jakarta.validation.constraints.NotEmpty; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@Slf4j -@RequestMapping("/api/v1/auth") -@RequiredArgsConstructor -public class OAuth2Controller { - - private final JwtTokenService jwtTokenService; - private final SocialAuthService socialAuthService; - private final LoginService loginService; - - @PostMapping("/oauth2/code") - public ApiResponse getTokenFromSocial(@RequestBody JwtTokenRequest request) { - TokenDto token = socialAuthService.getTokenResponse( - request.getProvider().toLowerCase(), request.getAuthorizationCode()); - AuthResultDto authResultDto = loginService.socialLogin(token); - JwtTokenResponseDto jwtDto = - jwtTokenService.createTokenDto(authResultDto.getMemberId(), authResultDto.getProfileId()); - if (authResultDto.isNewUser()) { - jwtDto.setNewUser(true); - } - return ApiResponse.createSuccess(jwtDto); - } - - @PostMapping("/token/refresh") - public ApiResponse refreshAccessToken(@UserContext MemberDto memberDto, - @RequestBody JwtRefreshTokenRequest request) { - String accessToken = jwtTokenService.createAccessToken(memberDto.getMemberId(), memberDto.getProfileId()); - return ApiResponse.createSuccess( - new JwtRefreshedAccessTokenDto(accessToken, 3600, "Bearar") - ); - } - - - @Data - @AllArgsConstructor - static class JwtTokenRequest { - - @NotEmpty - private String provider; - - @NotEmpty - private String authorizationCode; - } - - @Data - @AllArgsConstructor - static class JwtRefreshedAccessTokenDto { - private String accessToken; - private int expiresIn; - private String tokenType; - } - - @Data - static class JwtRefreshTokenRequest { - - @NotEmpty - private String refreshToken; - } - -} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/SchoolGroupController.java b/src/main/java/com/stcom/smartmealtable/web/controller/SchoolGroupController.java new file mode 100644 index 0000000..fc0feaf --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/SchoolGroupController.java @@ -0,0 +1,49 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import com.stcom.smartmealtable.service.GroupService; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import com.stcom.smartmealtable.web.dto.group.SchoolGroupCreateRequest; +import com.stcom.smartmealtable.web.dto.group.SchoolGroupUpdateRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 학교 그룹 전용 API. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/schools") +public class SchoolGroupController { + + private final GroupService groupService; + + @PostMapping() + public ApiResponse registerSchoolGroup(@RequestBody @Valid SchoolGroupCreateRequest request) { + groupService.createSchoolGroup(new AddressRequest(request.getRoadAddress(), request.getDetailAddress()), + request.getName(), request.getType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/{id}") + public ApiResponse editSchoolGroup(@PathVariable("id") Long id, + @RequestBody @Valid SchoolGroupUpdateRequest request) { + groupService.changeSchoolGroup(id, + new AddressRequest(request.getRoadAddress(), request.getDetailAddress()), + request.getName(), request.getType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteSchoolGroup(@PathVariable("id") Long id) { + groupService.deleteGroup(id); + return ApiResponse.createSuccessWithNoContent(); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/dto/group/GroupDto.java b/src/main/java/com/stcom/smartmealtable/web/dto/group/GroupDto.java new file mode 100644 index 0000000..870c2a5 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/dto/group/GroupDto.java @@ -0,0 +1,24 @@ +package com.stcom.smartmealtable.web.dto.group; + +import com.stcom.smartmealtable.domain.group.Group; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 그룹 검색 응답용 DTO. + */ +@Data +@AllArgsConstructor +public class GroupDto { + private Long id; + private String roadAddress; + private String name; + private String groupType; + + public GroupDto(Group group) { + this.id = group.getId(); + this.groupType = group.getTypeName(); + this.name = group.getName(); + this.roadAddress = group.getAddress().getRoadAddress(); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupCreateRequest.java b/src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupCreateRequest.java new file mode 100644 index 0000000..76f3511 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupCreateRequest.java @@ -0,0 +1,24 @@ +package com.stcom.smartmealtable.web.dto.group; + +import com.stcom.smartmealtable.domain.group.SchoolType; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class SchoolGroupCreateRequest { + + @NotEmpty + private String roadAddress; + + @NotEmpty + private String detailAddress; + + @NotEmpty + private String name; + + @NotNull + private SchoolType type; +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupUpdateRequest.java b/src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupUpdateRequest.java new file mode 100644 index 0000000..028e4b7 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/dto/group/SchoolGroupUpdateRequest.java @@ -0,0 +1,24 @@ +package com.stcom.smartmealtable.web.dto.group; + +import com.stcom.smartmealtable.domain.group.SchoolType; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class SchoolGroupUpdateRequest { + + @NotEmpty + private String roadAddress; + + @NotEmpty + private String detailAddress; + + @NotEmpty + private String name; + + @NotNull + private SchoolType type; +} \ No newline at end of file From e1da4971ff6e70ff42486af7e51ac9b304d2a04b Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 00:39:03 +0900 Subject: [PATCH 21/44] =?UTF-8?q?fix:=20=EC=BF=BC=EB=A6=AC=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/stcom/smartmealtable/domain/member/MemberProfile.java | 2 +- .../smartmealtable/repository/MemberProfileRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java b/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java index e5b4b54..cef35c9 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java +++ b/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java @@ -56,7 +56,7 @@ public MemberProfile(Member member, String nickName, List address Group group) { linkMember(member); this.nickName = nickName; - this.addressHistory = addressHistory; + this.addressHistory = (addressHistory == null) ? new java.util.ArrayList<>() : addressHistory; this.type = type; this.group = group; } diff --git a/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java b/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java index 1a22927..a97f188 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java @@ -15,6 +15,6 @@ public interface MemberProfileRepository extends JpaRepository findMemberProfileEntityGraphById(Long id); } From a3ca754d57aed1ae9b08bca67fbb9d1aafe1d08f Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 00:39:52 +0900 Subject: [PATCH 22/44] =?UTF-8?q?test:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/group/CompanyGroupTest.java | 37 ++ .../domain/group/SchoolGroupTest.java | 37 ++ .../AddressEntityRepositoryTest.java | 50 +++ .../FoodCategoryRepositoryTest.java | 42 ++ .../repository/GroupRepositoryTest.java | 41 ++ ...emberCategoryPreferenceRepositoryTest.java | 84 ++++ .../MemberProfileRepositoryTest.java | 52 +++ .../SocialAccountRepositoryTest.java | 56 +++ .../TermAgreementRepositoryTest.java | 60 +++ .../repository/TermRepositoryTest.java | 57 +++ .../service/BudgetServiceTest.java | 142 ------- .../service/GroupServiceIntegrationTest.java | 62 +++ .../service/GroupServiceTest.java | 142 ------- .../service/LoginServiceIntegrationTest.java | 70 ++++ .../service/LoginServiceTest.java | 238 ------------ ...egoryPreferenceServiceIntegrationTest.java | 77 ++++ .../MemberCategoryPreferenceServiceTest.java | 186 --------- ...ofileServiceAdditionalIntegrationTest.java | 79 ++++ .../MemberProfileServiceIntegrationTest.java | 65 ++++ .../service/MemberProfileServiceTest.java | 363 ------------------ .../MemberServiceFailureIntegrationTest.java | 64 +++ .../service/MemberServiceIntegrationTest.java | 88 +++++ .../service/MemberServiceTest.java | 157 -------- ...countServiceAdditionalIntegrationTest.java | 80 ++++ .../SocialAccountServiceIntegrationTest.java | 49 +++ .../service/SocialAccountServiceTest.java | 302 --------------- .../service/TermServiceIntegrationTest.java | 79 ++++ .../service/TermServiceTest.java | 173 --------- .../web/controller/MemberControllerTest.java | 212 ---------- 29 files changed, 1229 insertions(+), 1915 deletions(-) create mode 100644 src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/domain/group/SchoolGroupTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/AddressEntityRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/FoodCategoryRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/GroupRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/MemberProfileRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/SocialAccountRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/TermAgreementRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/TermRepositoryTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/GroupServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/LoginServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberServiceFailureIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java delete mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java diff --git a/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java b/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java new file mode 100644 index 0000000..7b34d13 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java @@ -0,0 +1,37 @@ +package com.stcom.smartmealtable.domain.group; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CompanyGroupTest { + + @Test + @DisplayName("회사 그룹의 타입명(산업군)과 주소 변경이 정상 동작해야 한다") + void changeFields() { + // given + Address address = Address.builder() + .roadAddress("서울특별시 강남구 테헤란로 1") + .detailAddress("10층") + .build(); + CompanyGroup group = new CompanyGroup(); + org.springframework.test.util.ReflectionTestUtils.setField(group, "address", address); + org.springframework.test.util.ReflectionTestUtils.setField(group, "name", "테스트IT"); + org.springframework.test.util.ReflectionTestUtils.setField(group, "industryType", IndustryType.IT); + + // when + Address newAddress = Address.builder() + .roadAddress("서울특별시 영등포구 국제금융로 2") + .detailAddress("6층") + .build(); + group.changeNameAndAddress("테스트금융", newAddress); + org.springframework.test.util.ReflectionTestUtils.setField(group, "industryType", IndustryType.FINANCE); + + // then + assertThat(group.getName()).isEqualTo("테스트금융"); + assertThat(group.getAddress().getRoadAddress()).isEqualTo("서울특별시 영등포구 국제금융로 2"); + assertThat(group.getTypeName()).isEqualTo("FINANCE"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/group/SchoolGroupTest.java b/src/test/java/com/stcom/smartmealtable/domain/group/SchoolGroupTest.java new file mode 100644 index 0000000..3677e18 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/group/SchoolGroupTest.java @@ -0,0 +1,37 @@ +package com.stcom.smartmealtable.domain.group; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 순수 자바 단위 테스트: 엔티티의 비즈니스 로직 검증. + */ +class SchoolGroupTest { + + @Test + @DisplayName("학교 그룹 타입과 주소/이름 변경이 정상동작해야 한다") + void changeFields() { + // given + Address address = Address.builder() + .roadAddress("서울시 종로구 세종대로 1") + .detailAddress("본관 1층") + .build(); + SchoolGroup group = new SchoolGroup(address, "서울대학교", SchoolType.UNIVERSITY_FOUR_YEAR); + + // when + Address newAddress = Address.builder() + .roadAddress("서울시 관악구 관악로 1") + .detailAddress("행정관 2층") + .build(); + group.changeNameAndAddress("국민대학교", newAddress); + group.changeType(SchoolType.HIGH_SCHOOL); + + // then + assertThat(group.getName()).isEqualTo("국민대학교"); + assertThat(group.getAddress().getRoadAddress()).isEqualTo("서울시 관악구 관악로 1"); + assertThat(group.getTypeName()).isEqualTo(SchoolType.HIGH_SCHOOL.name()); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/AddressEntityRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/AddressEntityRepositoryTest.java new file mode 100644 index 0000000..8791ccd --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/AddressEntityRepositoryTest.java @@ -0,0 +1,50 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class AddressEntityRepositoryTest { + + @Autowired + private AddressEntityRepository repository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("주소 엔티티를 저장하고 조회할 수 있다") + void saveAndFind() { + // given + Address address = Address.builder() + .roadAddress("서울특별시 중구 세종대로 110") + .detailAddress("별관") + .build(); + AddressEntity entity = AddressEntity.builder() + .address(address) + .type(AddressType.HOME) + .alias("우리집") + .build(); + repository.save(entity); + em.flush(); + em.clear(); + + // when + Optional found = repository.findById(entity.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getAlias()).isEqualTo("우리집"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/FoodCategoryRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/FoodCategoryRepositoryTest.java new file mode 100644 index 0000000..6351f31 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/FoodCategoryRepositoryTest.java @@ -0,0 +1,42 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@ActiveProfiles("test") +class FoodCategoryRepositoryTest { + + @Autowired + private FoodCategoryRepository repository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("음식 카테고리를 저장하고 ID 로 조회할 수 있다") + void saveAndFind() { + // given + FoodCategory category = new FoodCategory(); + ReflectionTestUtils.setField(category, "name", "한식"); + repository.save(category); + em.flush(); + em.clear(); + + // when + Optional found = repository.findById(category.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("한식"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/GroupRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/GroupRepositoryTest.java new file mode 100644 index 0000000..41e1835 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/GroupRepositoryTest.java @@ -0,0 +1,41 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Limit; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class GroupRepositoryTest { + + @Autowired + private GroupRepository groupRepository; + + @Test + @DisplayName("이름 키워드로 그룹을 조회할 수 있어야 한다") + void findByNameContaining() { + // given + Address address = Address.builder() + .roadAddress("서울특별시 강남구 테헤란로 123") + .detailAddress("7층") + .build(); + SchoolGroup saved = groupRepository.save(new SchoolGroup(address, "테스트고등학교", SchoolType.HIGH_SCHOOL)); + + // when + List result = + groupRepository.findByNameContaining("테스트", Limit.of(10)); + + // then + assertThat(result).isNotEmpty(); + assertThat(result.get(0).getId()).isEqualTo(saved.getId()); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepositoryTest.java new file mode 100644 index 0000000..44b5479 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepositoryTest.java @@ -0,0 +1,84 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@ActiveProfiles("test") +class MemberCategoryPreferenceRepositoryTest { + + @Autowired + private MemberCategoryPreferenceRepository repository; + + @Autowired + private MemberProfileRepository profileRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private FoodCategoryRepository categoryRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("프로필 ID 로 기본 정렬 선호/비선호 카테고리를 조회할 수 있다") + void findDefaultByMemberProfileId() throws Exception { + // given + Member member = new Member("pref@test.com"); + memberRepository.save(member); + + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("닉") + .type(MemberType.WORKER) + .group(null) + .build(); + profileRepository.save(profile); + + FoodCategory korean = new FoodCategory(); + ReflectionTestUtils.setField(korean, "name", "한식"); + categoryRepository.save(korean); + FoodCategory sushi = new FoodCategory(); + ReflectionTestUtils.setField(sushi, "name", "일식"); + categoryRepository.save(sushi); + + MemberCategoryPreference like = MemberCategoryPreference.builder() + .memberProfile(profile) + .category(korean) + .type(PreferenceType.LIKE) + .priority(1) + .build(); + MemberCategoryPreference dislike = MemberCategoryPreference.builder() + .memberProfile(profile) + .category(sushi) + .type(PreferenceType.DISLIKE) + .priority(1) + .build(); + repository.saveAll(List.of(like, dislike)); + em.flush(); + em.clear(); + + // when + List prefs = repository.findDefaultByMemberProfileId(profile.getId()); + + // then + assertThat(prefs).hasSize(2); + assertThat(prefs.get(0).getType()).isEqualTo(PreferenceType.LIKE); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/MemberProfileRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/MemberProfileRepositoryTest.java new file mode 100644 index 0000000..a53401f --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/MemberProfileRepositoryTest.java @@ -0,0 +1,52 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class MemberProfileRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository profileRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("MemberProfileEntityGraph 조회시 member 가 fetch 되어야 한다") + void findEntityGraph() { + // given + Member member = new Member("graph@test.com"); + memberRepository.save(member); + + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("graph") + .type(MemberType.STUDENT) + .group(null) + .build(); + profileRepository.save(profile); + em.flush(); + em.clear(); + + // when + var found = profileRepository.findMemberProfileEntityGraphById(profile.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getMember().getEmail()).isEqualTo("graph@test.com"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/SocialAccountRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/SocialAccountRepositoryTest.java new file mode 100644 index 0000000..22ed37d --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/SocialAccountRepositoryTest.java @@ -0,0 +1,56 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class SocialAccountRepositoryTest { + + @Autowired + private SocialAccountRepository repository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("소셜 계정을 저장하고 provider/userId 로 조회할 수 있어야 한다") + void saveAndFind() { + // given + Member member = new Member("social@example.com"); + memberRepository.save(member); + + SocialAccount account = SocialAccount.builder() + .member(member) + .provider("google") + .providerUserId("12345") + .tokenType("Bearer") + .accessToken("access") + .refreshToken("refresh") + .tokenExpiresAt(LocalDateTime.now().plusDays(1)) + .build(); + repository.save(account); + em.flush(); + em.clear(); + + // when + Optional found = repository.findByProviderAndProviderUserId("google", "12345"); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getMember().getEmail()).isEqualTo("social@example.com"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/TermAgreementRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/TermAgreementRepositoryTest.java new file mode 100644 index 0000000..f1fa3f7 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/TermAgreementRepositoryTest.java @@ -0,0 +1,60 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.domain.term.TermAgreement; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@ActiveProfiles("test") +class TermAgreementRepositoryTest { + + @Autowired + private TermAgreementRepository repository; + + @Autowired + private TermRepository termRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("약관 동의 엔티티를 저장하고 조회할 수 있다") + void saveAndFind() { + // given + Member member = new Member("agree@test.com"); + memberRepository.save(member); + + Term term = new Term(); + ReflectionTestUtils.setField(term, "title", "테스트 약관"); + termRepository.save(term); + + TermAgreement agreement = TermAgreement.builder() + .member(member) + .term(term) + .isAgreed(true) + .build(); + repository.save(agreement); + em.flush(); + em.clear(); + + // when + var found = repository.findById(agreement.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getTerm().getTitle()).isEqualTo("테스트 약관"); + assertThat(found.get().getMember().getEmail()).isEqualTo("agree@test.com"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/TermRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/TermRepositoryTest.java new file mode 100644 index 0000000..30db874 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/TermRepositoryTest.java @@ -0,0 +1,57 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.term.Term; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class TermRepositoryTest { + + @Autowired + private TermRepository termRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("약관을 저장하고 조회할 수 있어야 한다") + void saveAndFind() { + // given + Term term = createTerm("테스트 약관", true); + termRepository.save(term); + em.flush(); + em.clear(); + + // when + Optional found = termRepository.findById(term.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getTitle()).isEqualTo("테스트 약관"); + assertThat(found.get().getIsRequired()).isTrue(); + } + + private Term createTerm(String title, boolean required) { + Term term = new Term(); + try { + java.lang.reflect.Field titleField = Term.class.getDeclaredField("title"); + titleField.setAccessible(true); + titleField.set(term, title); + + java.lang.reflect.Field isRequiredField = Term.class.getDeclaredField("isRequired"); + isRequiredField.setAccessible(true); + isRequiredField.set(term, required); + } catch (Exception e) { + throw new RuntimeException(e); + } + return term; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java deleted file mode 100644 index ab6ee53..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.Budget.DailyBudget; -import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; -import com.stcom.smartmealtable.domain.member.MemberProfile; -import com.stcom.smartmealtable.repository.BudgetRepository; -import com.stcom.smartmealtable.repository.MemberProfileRepository; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.YearMonth; -import java.util.Optional; -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; - -@ExtendWith(MockitoExtension.class) -class BudgetServiceTest { - - @InjectMocks - private BudgetService budgetService; - - @Mock - private BudgetRepository budgetRepository; - - @Mock - private MemberProfileRepository memberProfileRepository; - - @Test - @DisplayName("회원 프로필 ID로 최근 일일 예산을 조회할 수 있다") - void findRecentDailyBudgetByMemberProfileId() { - // given - Long memberProfileId = 1L; - MemberProfile memberProfile = new MemberProfile(); - DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(10000), LocalDate.now()); - - when(budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId)) - .thenReturn(Optional.of(dailyBudget)); - - // when - DailyBudget result = budgetService.findRecentDailyBudgetByMemberProfileId(memberProfileId); - - // then - assertThat(result).isEqualTo(dailyBudget); - assertThat(result.getLimit()).isEqualTo(BigDecimal.valueOf(10000)); - } - - @Test - @DisplayName("존재하지 않는 회원 프로필 ID로 일일 예산을 조회하면 예외가 발생한다") - void findRecentDailyBudgetByMemberProfileId_NotFound() { - // given - Long memberProfileId = 999L; - - when(budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId)) - .thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> budgetService.findRecentDailyBudgetByMemberProfileId(memberProfileId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 프로필로 접근"); - } - - @Test - @DisplayName("회원 프로필 ID로 최근 월별 예산을 조회할 수 있다") - void findRecentMonthlyBudgetByMemberProfileId() { - // given - Long memberProfileId = 1L; - MemberProfile memberProfile = new MemberProfile(); - MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(300000), YearMonth.now()); - - when(budgetRepository.findFirstMonthlyBudgetByMemberProfileId(memberProfileId)) - .thenReturn(Optional.of(monthlyBudget)); - - // when - MonthlyBudget result = budgetService.findRecentMonthlyBudgetByMemberProfileId(memberProfileId); - - // then - assertThat(result).isEqualTo(monthlyBudget); - assertThat(result.getLimit()).isEqualTo(BigDecimal.valueOf(300000)); - } - - @Test - @DisplayName("월별 예산을 저장할 수 있다") - void saveMonthlyBudgetCustom() { - // given - Long memberProfileId = 1L; - Long limit = 300000L; - MemberProfile memberProfile = new MemberProfile(); - - when(memberProfileRepository.findById(memberProfileId)) - .thenReturn(Optional.of(memberProfile)); - - // when - budgetService.saveMonthlyBudgetCustom(memberProfileId, limit); - - // then - verify(budgetRepository).save(any(MonthlyBudget.class)); - } - - @Test - @DisplayName("일일 예산을 저장할 수 있다") - void saveDailyBudgetCustom() { - // given - Long memberProfileId = 1L; - Long limit = 10000L; - MemberProfile memberProfile = new MemberProfile(); - - when(memberProfileRepository.findById(memberProfileId)) - .thenReturn(Optional.of(memberProfile)); - - // when - budgetService.saveDailyBudgetCustom(memberProfileId, limit); - - // then - verify(budgetRepository).save(any(DailyBudget.class)); - } - - @Test - @DisplayName("존재하지 않는 회원 프로필 ID로 예산을 저장하면 예외가 발생한다") - void saveBudget_NotFound() { - // given - Long memberProfileId = 999L; - Long limit = 10000L; - - when(memberProfileRepository.findById(memberProfileId)) - .thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> budgetService.saveDailyBudgetCustom(memberProfileId, limit)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 프로필로 접근"); - } - -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/GroupServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/GroupServiceIntegrationTest.java new file mode 100644 index 0000000..43f2f4d --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/GroupServiceIntegrationTest.java @@ -0,0 +1,62 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.group.CompanyGroup; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.IndustryType; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.repository.GroupRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * GroupService 통합 테스트. + */ +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class GroupServiceIntegrationTest { + + @Autowired + private GroupService groupService; + + @Autowired + private GroupRepository groupRepository; + + @Test + @DisplayName("키워드로 그룹 검색이 가능해야 한다") + void searchByKeyword() { + // given + Address address1 = Address.builder() + .roadAddress("서울특별시 종로구 세종대로 1") + .detailAddress("본관") + .build(); + Group group1 = new SchoolGroup(address1, "서울고등학교", SchoolType.HIGH_SCHOOL); + groupRepository.save(group1); + + Address address2 = Address.builder() + .roadAddress("서울특별시 서초구 강남대로 1") + .detailAddress("타워") + .build(); + Group group2 = new CompanyGroup(); + org.springframework.test.util.ReflectionTestUtils.setField(group2, "address", address2); + org.springframework.test.util.ReflectionTestUtils.setField(group2, "name", "테스트IT"); + org.springframework.test.util.ReflectionTestUtils.setField(group2, "industryType", IndustryType.IT); + groupRepository.save(group2); + + // when + List result = groupService.findGroupsByKeyword("서울"); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("서울고등학교"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java deleted file mode 100644 index 8be1f60..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.Address.Address; -import com.stcom.smartmealtable.domain.group.CompanyGroup; -import com.stcom.smartmealtable.domain.group.Group; -import com.stcom.smartmealtable.domain.group.IndustryType; -import com.stcom.smartmealtable.domain.group.SchoolGroup; -import com.stcom.smartmealtable.domain.group.SchoolType; -import com.stcom.smartmealtable.repository.GroupRepository; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -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 org.springframework.data.domain.Limit; - -@ExtendWith(MockitoExtension.class) -class GroupServiceTest { - - @Mock - private GroupRepository groupRepository; - - @InjectMocks - private GroupServiceImpl groupService; - - @Test - @DisplayName("ID로 그룹을 찾을 수 있어야 한다") - void findGroupByGroupId() { - // given - Long groupId = 1L; - - CompanyGroup companyGroup = new CompanyGroup(); - Address address = createAddress("서울시 강남구 테헤란로 123"); - - // 리플렉션으로 private 필드 설정 - org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "id", groupId); - org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "name", "IT 회사"); - org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "address", address); - org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "industryType", IndustryType.IT); - - when(groupRepository.findById(groupId)).thenReturn(Optional.of(companyGroup)); - - // when - Group foundGroup = groupService.findGroupByGroupId(groupId); - - // then - assertThat(foundGroup).isEqualTo(companyGroup); - assertThat(foundGroup.getName()).isEqualTo("IT 회사"); - assertThat(foundGroup.getTypeName()).isEqualTo("IT"); - assertThat(foundGroup.getAddress().getRoadAddress()).isEqualTo("서울시 강남구 테헤란로 123"); - verify(groupRepository, times(1)).findById(groupId); - } - - @Test - @DisplayName("존재하지 않는 그룹 ID로 조회 시 예외가 발생해야 한다") - void findGroupByGroupIdNotFound() { - // given - Long groupId = 999L; - when(groupRepository.findById(groupId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> groupService.findGroupByGroupId(groupId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회원입니다"); - } - - @Test - @DisplayName("키워드로 그룹을 검색할 수 있어야 한다") - void findGroupsByKeyword() { - // given - String keyword = "학교"; - - SchoolGroup schoolGroup1 = createSchoolGroup(1L, "서울대학교", SchoolType.UNIVERSITY_FOUR_YEAR, - createAddress("서울시 관악구 관악로 1")); - - SchoolGroup schoolGroup2 = createSchoolGroup(2L, "부산대학교", SchoolType.UNIVERSITY_FOUR_YEAR, - createAddress("부산시 금정구 부산대학로 63번길 2")); - - List expectedGroups = Arrays.asList(schoolGroup1, schoolGroup2); - - when(groupRepository.findByNameContaining(keyword, Limit.of(10))).thenReturn(expectedGroups); - - // when - List foundGroups = groupService.findGroupsByKeyword(keyword); - - // then - assertThat(foundGroups).hasSize(2); - assertThat(foundGroups).containsExactly(schoolGroup1, schoolGroup2); - - assertThat(foundGroups.get(0).getName()).isEqualTo("서울대학교"); - assertThat(foundGroups.get(0).getTypeName()).isEqualTo("UNIVERSITY_FOUR_YEAR"); - - assertThat(foundGroups.get(1).getName()).isEqualTo("부산대학교"); - assertThat(foundGroups.get(1).getAddress().getRoadAddress()).isEqualTo("부산시 금정구 부산대학로 63번길 2"); - - verify(groupRepository, times(1)).findByNameContaining(keyword, Limit.of(10)); - } - - @Test - @DisplayName("키워드 검색 결과가 없을 경우 빈 리스트를 반환해야 한다") - void findGroupsByKeywordNoResult() { - // given - String keyword = "존재하지 않는 키워드"; - when(groupRepository.findByNameContaining(keyword, Limit.of(10))).thenReturn(List.of()); - - // when - List foundGroups = groupService.findGroupsByKeyword(keyword); - - // then - assertThat(foundGroups).isEmpty(); - verify(groupRepository, times(1)).findByNameContaining(keyword, Limit.of(10)); - } - - // 테스트용 주소 생성 헬퍼 메소드 - private Address createAddress(String roadAddress) { - Address address = new Address(); - org.springframework.test.util.ReflectionTestUtils.setField(address, "roadAddress", roadAddress); - return address; - } - - // 테스트용 학교 그룹 생성 헬퍼 메소드 - private SchoolGroup createSchoolGroup(Long id, String name, SchoolType schoolType, Address address) { - SchoolGroup group = new SchoolGroup(); - org.springframework.test.util.ReflectionTestUtils.setField(group, "id", id); - org.springframework.test.util.ReflectionTestUtils.setField(group, "name", name); - org.springframework.test.util.ReflectionTestUtils.setField(group, "address", address); - org.springframework.test.util.ReflectionTestUtils.setField(group, "schoolType", schoolType); - return group; - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/LoginServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/LoginServiceIntegrationTest.java new file mode 100644 index 0000000..841a9e1 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/LoginServiceIntegrationTest.java @@ -0,0 +1,70 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class LoginServiceIntegrationTest { + + @Autowired + private LoginService loginService; + + @Autowired + private MemberService memberService; + + @Test + @DisplayName("이메일/비밀번호 로그인 성공 후 AuthResultDto 가 반환되어야 한다") + @Rollback + void loginWithEmail() throws Exception { + // given + Member member = Member.builder() + .fullName("로그인유저") + .email("login@test.com") + .rawPassword("Password1!") + .build(); + memberService.saveMember(member); + + // when + AuthResultDto dto = loginService.loginWithEmail("login@test.com", "Password1!"); + + // then + assertThat(dto.getMemberId()).isEqualTo(member.getId()); + assertThat(dto.isNewUser()).isTrue(); + } + + @Test + @DisplayName("소셜 로그인 시 신규 회원이면 newUser 플래그가 true 여야 한다") + @Rollback + void socialLogin() { + // given + TokenDto token = TokenDto.builder() + .accessToken("token") + .refreshToken("refresh") + .tokenType("Bearer") + .provider("google") + .providerUserId("g123") + .expiresIn(3600) + .email("social@test.com") + .build(); + + // when + AuthResultDto dto = loginService.socialLogin(token); + + // then + assertThat(dto.isNewUser()).isTrue(); + assertThat(dto.getMemberId()).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java deleted file mode 100644 index 86b3858..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.domain.member.MemberProfile; -import com.stcom.smartmealtable.domain.social.SocialAccount; -import com.stcom.smartmealtable.exception.PasswordPolicyException; -import com.stcom.smartmealtable.exception.PasswordFailedExceededException; -import com.stcom.smartmealtable.infrastructure.dto.TokenDto; -import com.stcom.smartmealtable.repository.MemberRepository; -import com.stcom.smartmealtable.repository.SocialAccountRepository; -import com.stcom.smartmealtable.service.dto.AuthResultDto; -import java.time.LocalDateTime; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class LoginServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private SocialAccountRepository socialAccountRepository; - - @InjectMocks - private LoginService loginService; - - @Captor - private ArgumentCaptor memberCaptor; - - @Captor - private ArgumentCaptor socialAccountCaptor; - - @Test - @DisplayName("이메일과 비밀번호로 로그인이 가능해야 한다") - void loginWithEmail() throws PasswordFailedExceededException, PasswordPolicyException { - // given - String email = "test@example.com"; - String password = "Password123!"; - - Member member = createMember(1L, email, password); - MemberProfile profile = mock(MemberProfile.class); - when(profile.getId()).thenReturn(10L); - - // 프로필이 등록되어 있지 않은 경우 - ReflectionTestUtils.setField(member, "memberProfile", profile); - - when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); - - // when - AuthResultDto result = loginService.loginWithEmail(email, password); - - // then - assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getProfileId()).isEqualTo(10L); - assertThat(result.isNewUser()).isFalse(); // 프로필이 있으므로 새 사용자가 아님 - - verify(memberRepository, times(1)).findByEmail(email); - } - - @Test - @DisplayName("프로필이 없는 경우 신규 사용자로 처리해야 한다") - void loginWithEmailNewUser() throws PasswordFailedExceededException, PasswordPolicyException { - // given - String email = "new@example.com"; - String password = "Password123!"; - - Member member = createMember(2L, email, password); - // 프로필이 등록되어 있지 않음 - - when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); - - // when - AuthResultDto result = loginService.loginWithEmail(email, password); - - // then - assertThat(result.getMemberId()).isEqualTo(2L); - assertThat(result.getProfileId()).isNull(); - assertThat(result.isNewUser()).isTrue(); // 프로필이 없으므로 신규 사용자 - } - - @Test - @DisplayName("존재하지 않는 이메일로 로그인 시도 시 예외가 발생해야 한다") - void loginWithNonExistingEmail() { - // given - String email = "nonexisting@example.com"; - String password = "Password123!"; - - when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> loginService.loginWithEmail(email, password)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회원입니다."); - } - - @Test - @DisplayName("잘못된 비밀번호로 로그인 시도 시 예외가 발생해야 한다") - void loginWithWrongPassword() throws PasswordPolicyException { - // given - String email = "test@example.com"; - String correctPassword = "Password123!"; - String wrongPassword = "WrongPassword123!"; - - Member member = createMember(1L, email, correctPassword); - - when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); - - // when & then - assertThatThrownBy(() -> loginService.loginWithEmail(email, wrongPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("비밀번호가 일치하지 않습니다"); - } - - @Test - @DisplayName("소셜 로그인 - 기존 회원인 경우") - void socialLoginExistingMember() throws PasswordPolicyException { - // given - String email = "test@example.com"; - String provider = "KAKAO"; - String providerUserId = "12345"; - - TokenDto tokenDto = createTokenDto(email, provider, providerUserId); - Member member = createMember(1L, email, null); - - SocialAccount existingSocialAccount = SocialAccount.builder() - .member(member) - .provider(provider) - .providerUserId(providerUserId) - .accessToken("old-token") - .refreshToken("old-refresh-token") - .tokenExpiresAt(LocalDateTime.now()) - .build(); - - when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); - when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) - .thenReturn(Optional.of(existingSocialAccount)); - when(socialAccountRepository.findProfileIdByProviderAndProviderUserId(provider, providerUserId)) - .thenReturn(Optional.of(10L)); - - // when - AuthResultDto result = loginService.socialLogin(tokenDto); - - // then - assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getProfileId()).isEqualTo(10L); - assertThat(result.isNewUser()).isFalse(); - - assertThat(existingSocialAccount.getAccessToken()).isEqualTo("access-token-value"); - assertThat(existingSocialAccount.getRefreshToken()).isEqualTo("refresh-token-value"); - } - - @Test - @DisplayName("소셜 로그인 - 신규 회원인 경우") - void socialLoginNewMember() throws PasswordPolicyException { - // given - String email = "new@example.com"; - String provider = "GOOGLE"; - String providerUserId = "67890"; - - TokenDto tokenDto = createTokenDto(email, provider, providerUserId); - Member newMember = createMember(2L, email, null); - - when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); - when(memberRepository.save(any())).thenReturn(newMember); - when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) - .thenReturn(Optional.empty()); - when(socialAccountRepository.save(any())).thenAnswer(invocation -> { - SocialAccount account = invocation.getArgument(0); - ReflectionTestUtils.setField(account, "id", 1L); - return account; - }); - when(socialAccountRepository.findProfileIdByProviderAndProviderUserId(provider, providerUserId)) - .thenReturn(Optional.empty()); - - // when - AuthResultDto result = loginService.socialLogin(tokenDto); - - // then - verify(memberRepository, times(1)).save(memberCaptor.capture()); - verify(socialAccountRepository, times(1)).save(socialAccountCaptor.capture()); - - Member savedMember = memberCaptor.getValue(); - SocialAccount savedAccount = socialAccountCaptor.getValue(); - - assertThat(savedMember.getEmail()).isEqualTo(email); - assertThat(savedAccount.getProvider()).isEqualTo(provider); - assertThat(savedAccount.getProviderUserId()).isEqualTo(providerUserId); - - assertThat(result.getMemberId()).isEqualTo(2L); - assertThat(result.getProfileId()).isNull(); - assertThat(result.isNewUser()).isTrue(); - } - - private Member createMember(Long id, String email, String rawPassword) throws PasswordPolicyException { - Member member; - if (rawPassword != null) { - member = Member.builder() - .email(email) - .rawPassword(rawPassword) - .build(); - } else { - member = new Member(email); - } - ReflectionTestUtils.setField(member, "id", id); - return member; - } - - private TokenDto createTokenDto(String email, String provider, String providerUserId) { - TokenDto tokenDto = new TokenDto(); - - tokenDto.setEmail(email); - tokenDto.setProvider(provider); - tokenDto.setProviderUserId(providerUserId); - tokenDto.setTokenType("Bearer"); - tokenDto.setAccessToken("access-token-value"); - tokenDto.setRefreshToken("refresh-token-value"); - tokenDto.setExpiresIn(3600); - - return tokenDto; - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceIntegrationTest.java new file mode 100644 index 0000000..4521af9 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceIntegrationTest.java @@ -0,0 +1,77 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.FoodCategoryRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.util.List; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberCategoryPreferenceServiceIntegrationTest { + + @Autowired + private MemberCategoryPreferenceService preferenceService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository profileRepository; + + @Autowired + private FoodCategoryRepository categoryRepository; + + @Test + @DisplayName("선호/비선호 음식 카테고리를 저장하고 조회할 수 있다") + @Rollback + void saveAndGetPreferences() throws Exception { + // given 회원 & 프로필 & 카테고리 + Member member = Member.builder() + .fullName("음식왕") + .email("food@test.com") + .rawPassword("Password1!") + .build(); + memberRepository.save(member); + + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("먹짱") + .type(MemberType.STUDENT) + .group(null) + .build(); + profileRepository.save(profile); + + FoodCategory catLike = new FoodCategory(); + ReflectionTestUtils.setField(catLike, "name", "한식"); + categoryRepository.save(catLike); + + FoodCategory catDislike = new FoodCategory(); + ReflectionTestUtils.setField(catDislike, "name", "멕시칸"); + categoryRepository.save(catDislike); + + // when + preferenceService.savePreferences(profile.getId(), List.of(catLike.getId()), List.of(catDislike.getId())); + List prefs = preferenceService.getPreferences(profile.getId()); + + // then + assertThat(prefs).hasSize(2); + assertThat(prefs.get(0).getType().name()).isEqualTo("LIKE"); + assertThat(prefs.get(1).getType().name()).isEqualTo("DISLIKE"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java deleted file mode 100644 index 10f852a..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java +++ /dev/null @@ -1,186 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.food.FoodCategory; -import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; -import com.stcom.smartmealtable.domain.food.PreferenceType; -import com.stcom.smartmealtable.domain.member.MemberProfile; -import com.stcom.smartmealtable.repository.FoodCategoryRepository; -import com.stcom.smartmealtable.repository.MemberCategoryPreferenceRepository; -import com.stcom.smartmealtable.repository.MemberProfileRepository; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class MemberCategoryPreferenceServiceTest { - - @Mock - private MemberCategoryPreferenceRepository preferenceRepository; - - @Mock - private FoodCategoryRepository categoryRepository; - - @Mock - private MemberProfileRepository profileRepository; - - @InjectMocks - private MemberCategoryPreferenceService preferenceService; - - @Captor - private ArgumentCaptor preferenceCaptor; - - private MemberProfile profile; - private FoodCategory koreanFood; - private FoodCategory westernFood; - private FoodCategory japaneseFood; - private FoodCategory chineseFood; - - @BeforeEach - void setUp() { - // 테스트 데이터 셋업 - profile = new MemberProfile(); - ReflectionTestUtils.setField(profile, "id", 1L); - - koreanFood = createFoodCategory(1L, "한식"); - westernFood = createFoodCategory(2L, "양식"); - japaneseFood = createFoodCategory(3L, "일식"); - chineseFood = createFoodCategory(4L, "중식"); - } - - @Test - @DisplayName("회원 음식 선호도를 저장할 수 있어야 한다") - void savePreferences() { - // given - Long profileId = 1L; - List liked = Arrays.asList(1L, 2L); // 한식, 양식 선호 - List disliked = Arrays.asList(3L); // 일식 비선호 - - when(profileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - when(categoryRepository.findById(1L)).thenReturn(Optional.of(koreanFood)); - when(categoryRepository.findById(2L)).thenReturn(Optional.of(westernFood)); - when(categoryRepository.findById(3L)).thenReturn(Optional.of(japaneseFood)); - doNothing().when(preferenceRepository).deleteByMemberProfile_Id(profileId); - when(preferenceRepository.save(any(MemberCategoryPreference.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // when - preferenceService.savePreferences(profileId, liked, disliked); - - // then - verify(profileRepository, times(1)).findById(profileId); - verify(preferenceRepository, times(1)).deleteByMemberProfile_Id(profileId); - verify(categoryRepository, times(3)).findById(anyLong()); - verify(preferenceRepository, times(3)).save(preferenceCaptor.capture()); - - List savedPreferences = preferenceCaptor.getAllValues(); - assertThat(savedPreferences).hasSize(3); - - // 선호 음식 검증 - assertThat(savedPreferences.get(0).getType()).isEqualTo(PreferenceType.LIKE); - assertThat(savedPreferences.get(0).getCategory().getName()).isEqualTo("한식"); - assertThat(savedPreferences.get(0).getPriority()).isEqualTo(1); - - assertThat(savedPreferences.get(1).getType()).isEqualTo(PreferenceType.LIKE); - assertThat(savedPreferences.get(1).getCategory().getName()).isEqualTo("양식"); - assertThat(savedPreferences.get(1).getPriority()).isEqualTo(2); - - // 비선호 음식 검증 - assertThat(savedPreferences.get(2).getType()).isEqualTo(PreferenceType.DISLIKE); - assertThat(savedPreferences.get(2).getCategory().getName()).isEqualTo("일식"); - assertThat(savedPreferences.get(2).getPriority()).isEqualTo(1); - } - - @Test - @DisplayName("존재하지 않는 프로필로 음식 선호도 저장시 예외가 발생해야 한다") - void savePreferencesWithNonExistingProfile() { - // given - Long profileId = 999L; - List liked = Arrays.asList(1L); - List disliked = List.of(); - - when(profileRepository.findById(profileId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> preferenceService.savePreferences(profileId, liked, disliked)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("존재하지 않는 프로필입니다"); - } - - @Test - @DisplayName("존재하지 않는 카테고리로 음식 선호도 저장시 예외가 발생해야 한다") - void savePreferencesWithNonExistingCategory() { - // given - Long profileId = 1L; - List liked = Arrays.asList(999L); - List disliked = List.of(); - - when(profileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - when(categoryRepository.findById(999L)).thenReturn(Optional.empty()); - doNothing().when(preferenceRepository).deleteByMemberProfile_Id(profileId); - - // when & then - assertThatThrownBy(() -> preferenceService.savePreferences(profileId, liked, disliked)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("존재하지 않는 카테고리입니다"); - } - - @Test - @DisplayName("프로필 ID로 음식 선호도를 조회할 수 있어야 한다") - void getPreferences() { - // given - Long profileId = 1L; - MemberCategoryPreference pref1 = createPreference(profile, koreanFood, PreferenceType.LIKE, 1); - MemberCategoryPreference pref2 = createPreference(profile, westernFood, PreferenceType.LIKE, 2); - MemberCategoryPreference pref3 = createPreference(profile, japaneseFood, PreferenceType.DISLIKE, 1); - - List expectedPreferences = Arrays.asList(pref1, pref2, pref3); - - when(preferenceRepository.findDefaultByMemberProfileId(profileId)).thenReturn(expectedPreferences); - - // when - List foundPreferences = preferenceService.getPreferences(profileId); - - // then - assertThat(foundPreferences).hasSize(3); - assertThat(foundPreferences).isEqualTo(expectedPreferences); - - verify(preferenceRepository, times(1)).findDefaultByMemberProfileId(profileId); - } - - private FoodCategory createFoodCategory(Long id, String name) { - FoodCategory foodCategory = new FoodCategory(); - ReflectionTestUtils.setField(foodCategory, "id", id); - ReflectionTestUtils.setField(foodCategory, "name", name); - return foodCategory; - } - - private MemberCategoryPreference createPreference(MemberProfile profile, FoodCategory category, - PreferenceType type, Integer priority) { - MemberCategoryPreference preference = MemberCategoryPreference.builder() - .memberProfile(profile) - .category(category) - .type(type) - .priority(priority) - .build(); - ReflectionTestUtils.setField(preference, "id", Long.valueOf(priority)); - return preference; - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..3de3269 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest.java @@ -0,0 +1,79 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberProfileServiceAdditionalIntegrationTest { + + @Autowired + private MemberProfileService service; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository profileRepository; + + @Test + @DisplayName("프로필 닉네임/타입/그룹 변경이 동작해야 한다") + void changeProfile() { + Member member = new Member("cp@test.com"); + memberRepository.save(member); + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("old") + .type(MemberType.STUDENT) + .group(null) + .build(); + profileRepository.save(profile); + + // when + service.changeProfile(profile.getId(), "newNick", MemberType.WORKER, null); + + // then + MemberProfile updated = profileRepository.findById(profile.getId()).orElseThrow(); + assertThat(updated.getNickName()).isEqualTo("newNick"); + assertThat(updated.getType()).isEqualTo(MemberType.WORKER); + } + + @Test + @DisplayName("주소 삭제가 동작해야 한다") + void deleteAddress() { + Member member = new Member("addr@test.com"); + memberRepository.save(member); + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("a") + .type(MemberType.STUDENT) + .group(null) + .build(); + profileRepository.save(profile); + + Address address = Address.builder().roadAddress("road").detailAddress("d").build(); + service.saveNewAddress(profile.getId(), address, "집", AddressType.HOME); + Long addressId = profileRepository.findById(profile.getId()).orElseThrow().getAddressHistory().get(0).getId(); + + // when + service.deleteAddress(profile.getId(), addressId); + + // then + MemberProfile after = profileRepository.findById(profile.getId()).orElseThrow(); + assertThat(after.getAddressHistory()).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceIntegrationTest.java new file mode 100644 index 0000000..1661955 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceIntegrationTest.java @@ -0,0 +1,65 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberProfileServiceIntegrationTest { + + @Autowired + private MemberProfileService memberProfileService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + @Test + @DisplayName("프로필 생성 후 주소 추가 및 기본 주소 설정이 가능해야 한다") + @Rollback + void createAndUpdateProfile() throws Exception { + // given 회원 저장 + Member member = Member.builder() + .fullName("사용자A") + .email("usera@example.com") + .rawPassword("Password1!") + .build(); + memberRepository.save(member); + + // when 프로필 생성 + memberProfileService.createProfile("닉네임", member.getId(), MemberType.STUDENT, null); + MemberProfile profile = memberProfileRepository.findAll().get(0); + + // 주소 추가 + Address address = Address.builder() + .roadAddress("서울 특별시 강남구 역삼동") + .detailAddress("101호") + .build(); + memberProfileService.saveNewAddress(profile.getId(), address, "집", AddressType.HOME); + + // 기본 주소 설정 + Long addressEntityId = profile.getAddressHistory().get(0).getId(); + memberProfileService.changeAddressToPrimary(profile.getId(), addressEntityId); + + // then + MemberProfile updated = memberProfileService.getProfileFetch(profile.getId()); + assertThat(updated.findPrimaryAddress().getAlias()).isEqualTo("집"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java deleted file mode 100644 index 85226dc..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java +++ /dev/null @@ -1,363 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.Address.Address; -import com.stcom.smartmealtable.domain.Address.AddressEntity; -import com.stcom.smartmealtable.domain.Address.AddressType; -import com.stcom.smartmealtable.domain.group.Group; -import com.stcom.smartmealtable.domain.group.SchoolGroup; -import com.stcom.smartmealtable.domain.group.SchoolType; -import com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.domain.member.MemberProfile; -import com.stcom.smartmealtable.domain.member.MemberType; -import com.stcom.smartmealtable.repository.AddressEntityRepository; -import com.stcom.smartmealtable.repository.GroupRepository; -import com.stcom.smartmealtable.repository.MemberProfileRepository; -import com.stcom.smartmealtable.repository.MemberRepository; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class MemberProfileServiceTest { - - @Mock - private MemberProfileRepository memberProfileRepository; - - @Mock - private GroupRepository groupRepository; - - @Mock - private MemberRepository memberRepository; - - @Mock - private AddressEntityRepository addressEntityRepository; - - @InjectMocks - private MemberProfileService profileService; - - @Captor - private ArgumentCaptor profileCaptor; - - @Captor - private ArgumentCaptor addressEntityCaptor; - - private Member member; - private Group group; - private Address address; - private MemberProfile profile; - - @BeforeEach - void setUp() { - // 테스트 데이터 셋업 - member = new Member("test@example.com"); - ReflectionTestUtils.setField(member, "id", 1L); - - group = new SchoolGroup(); - ReflectionTestUtils.setField(group, "id", 1L); - ReflectionTestUtils.setField(group, "name", "서울대학교"); - ReflectionTestUtils.setField(group, "schoolType", SchoolType.UNIVERSITY_FOUR_YEAR); - - address = createAddress("서울특별시 관악구 관악로 1", "서울특별시 관악구", "101호", 37.459, 126.952); - } - - @Test - @DisplayName("프로필 ID로 프로필 정보를 조회할 수 있어야 한다") - void getProfileFetch() { - // given - Long profileId = 1L; - profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - - when(memberProfileRepository.findMemberProfileEntityGraphById(profileId)) - .thenReturn(Optional.of(profile)); - - // when - MemberProfile fetchedProfile = profileService.getProfileFetch(profileId); - - // then - assertThat(fetchedProfile).isEqualTo(profile); - assertThat(fetchedProfile.getNickName()).isEqualTo("닉네임"); - assertThat(fetchedProfile.getMember()).isEqualTo(member); - assertThat(fetchedProfile.getType()).isEqualTo(MemberType.STUDENT); - assertThat(fetchedProfile.getGroup()).isEqualTo(group); - - verify(memberProfileRepository).findMemberProfileEntityGraphById(profileId); - } - - @Test - @DisplayName("프로필을 생성할 수 있어야 한다 - 그룹 있음") - void createProfileWithGroup() { - // given - String nickName = "새닉네임"; - Long memberId = 1L; - MemberType type = MemberType.STUDENT; - Long groupId = 1L; - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(groupRepository.getReferenceById(groupId)).thenReturn(group); - when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> { - MemberProfile savedProfile = invocation.getArgument(0); - ReflectionTestUtils.setField(savedProfile, "id", 1L); - return savedProfile; - }); - - // when - profileService.createProfile(nickName, memberId, type, groupId); - - // then - verify(memberRepository).findById(memberId); - verify(groupRepository).getReferenceById(groupId); - verify(memberProfileRepository).save(profileCaptor.capture()); - - MemberProfile savedProfile = profileCaptor.getValue(); - assertThat(savedProfile.getNickName()).isEqualTo(nickName); - assertThat(savedProfile.getMember()).isEqualTo(member); - assertThat(savedProfile.getType()).isEqualTo(type); - assertThat(savedProfile.getGroup()).isEqualTo(group); - } - - @Test - @DisplayName("프로필을 생성할 수 있어야 한다 - 그룹 없음") - void createProfileWithoutGroup() { - // given - String nickName = "새닉네임"; - Long memberId = 1L; - MemberType type = MemberType.OTHER; - Long groupId = null; - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> { - MemberProfile savedProfile = invocation.getArgument(0); - ReflectionTestUtils.setField(savedProfile, "id", 1L); - return savedProfile; - }); - - // when - profileService.createProfile(nickName, memberId, type, groupId); - - // then - verify(memberRepository).findById(memberId); - verify(memberProfileRepository).save(profileCaptor.capture()); - - MemberProfile savedProfile = profileCaptor.getValue(); - assertThat(savedProfile.getNickName()).isEqualTo(nickName); - assertThat(savedProfile.getMember()).isEqualTo(member); - assertThat(savedProfile.getType()).isEqualTo(type); - assertThat(savedProfile.getGroup()).isNull(); - } - - @Test - @DisplayName("프로필 정보를 변경할 수 있어야 한다") - void changeProfile() { - // given - Long profileId = 1L; - String newNickName = "변경된닉네임"; - MemberType newType = MemberType.WORKER; - Long newGroupId = 2L; - - profile = createProfile(profileId, "원래닉네임", member, MemberType.STUDENT, group); - - Group newGroup = new SchoolGroup(); - ReflectionTestUtils.setField(newGroup, "id", 2L); - ReflectionTestUtils.setField(newGroup, "name", "회사"); - - when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - when(groupRepository.getReferenceById(newGroupId)).thenReturn(newGroup); - - // when - profileService.changeProfile(profileId, newNickName, newType, newGroupId); - - // then - verify(memberProfileRepository).findById(profileId); - verify(groupRepository).getReferenceById(newGroupId); - - assertThat(profile.getNickName()).isEqualTo(newNickName); - assertThat(profile.getType()).isEqualTo(newType); - assertThat(profile.getGroup()).isEqualTo(newGroup); - } - - @Test - @DisplayName("새 주소를 추가할 수 있어야 한다") - void saveNewAddress() { - // given - Long profileId = 1L; - String alias = "집"; - AddressType addressType = AddressType.HOME; - - profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - ReflectionTestUtils.setField(profile, "addressHistory", new ArrayList<>()); - - when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - when(addressEntityRepository.save(any(AddressEntity.class))).thenAnswer(invocation -> { - AddressEntity savedAddress = invocation.getArgument(0); - ReflectionTestUtils.setField(savedAddress, "id", 1L); - return savedAddress; - }); - - // when - profileService.saveNewAddress(profileId, address, alias, addressType); - - // then - verify(memberProfileRepository).findById(profileId); - verify(addressEntityRepository).save(addressEntityCaptor.capture()); - - AddressEntity savedAddressEntity = addressEntityCaptor.getValue(); - assertThat(savedAddressEntity.getAddress()).isEqualTo(address); - assertThat(savedAddressEntity.getAlias()).isEqualTo(alias); - assertThat(savedAddressEntity.getType()).isEqualTo(addressType); - assertThat(profile.getAddressHistory()).hasSize(1); - } - - @Test - @DisplayName("주소 정보를 변경할 수 있어야 한다") - void changeAddress() { - // given - Long profileId = 1L; - Long addressEntityId = 1L; - String newAlias = "새집"; - AddressType newType = AddressType.HOME; - - profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - - AddressEntity addressEntity = AddressEntity.builder() - .address(address) - .alias("구집") - .type(AddressType.ETC) - .build(); - ReflectionTestUtils.setField(addressEntity, "id", addressEntityId); - - List addresses = new ArrayList<>(); - addresses.add(addressEntity); - ReflectionTestUtils.setField(profile, "addressHistory", addresses); - - Address newAddress = createAddress("서울시 서초구 서초대로 123", "서초구", "202호", 37.5, 127.0); - - when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - when(addressEntityRepository.findById(addressEntityId)).thenReturn(Optional.of(addressEntity)); - - // when - profileService.changeAddress(profileId, addressEntityId, newAddress, newAlias, newType); - - // then - verify(memberProfileRepository).findById(profileId); - verify(addressEntityRepository).findById(addressEntityId); - - // 주소 정보가 업데이트되었는지 확인 - assertThat(addressEntity.getAddress()).isEqualTo(newAddress); - assertThat(addressEntity.getAlias()).isEqualTo(newAlias); - assertThat(addressEntity.getType()).isEqualTo(newType); - } - - @Test - @DisplayName("주소를 삭제할 수 있어야 한다") - void deleteAddress() { - // given - Long profileId = 1L; - Long addressEntityId = 1L; - - profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); - - AddressEntity addressEntity = AddressEntity.builder() - .address(address) - .alias("집") - .type(AddressType.HOME) - .build(); - ReflectionTestUtils.setField(addressEntity, "id", addressEntityId); - - // 주소에 primary=true 설정 - ReflectionTestUtils.setField(addressEntity, "primary", true); - - List addresses = new ArrayList<>(); - addresses.add(addressEntity); - - // 두번째 주소 추가 - AddressEntity secondAddress = AddressEntity.builder() - .address(createAddress("서울시 강남구", "강남구", "301호", 37.4, 127.1)) - .alias("회사") - .type(AddressType.OFFICE) - .build(); - ReflectionTestUtils.setField(secondAddress, "id", 2L); - addresses.add(secondAddress); - - ReflectionTestUtils.setField(profile, "addressHistory", addresses); - - when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); - when(addressEntityRepository.findById(addressEntityId)).thenReturn(Optional.of(addressEntity)); - - // when - profileService.deleteAddress(profileId, addressEntityId); - - // then - verify(memberProfileRepository).findById(profileId); - verify(addressEntityRepository).findById(addressEntityId); - - // 주소가 삭제되었는지 확인 - assertThat(profile.getAddressHistory()).hasSize(1); - assertThat(profile.getAddressHistory().get(0)).isEqualTo(secondAddress); - } - - - @Test - @DisplayName("존재하지 않는 프로필 ID로 조회 시 예외가 발생해야 한다") - void getProfileFetchNotFound() { - // given - Long nonExistingProfileId = 999L; - when(memberProfileRepository.findMemberProfileEntityGraphById(nonExistingProfileId)) - .thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> profileService.getProfileFetch(nonExistingProfileId)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("존재하지 않는 프로필입니다"); - } - - @Test - @DisplayName("존재하지 않는 회원 ID로 프로필 생성 시 예외가 발생해야 한다") - void createProfileWithNonExistingMember() { - // given - Long nonExistingMemberId = 999L; - when(memberRepository.findById(nonExistingMemberId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> profileService.createProfile("닉네임", nonExistingMemberId, MemberType.STUDENT, 1L)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회원입니다"); - } - - private MemberProfile createProfile(Long id, String nickName, Member member, MemberType type, Group group) { - MemberProfile profile = MemberProfile.builder() - .nickName(nickName) - .member(member) - .type(type) - .group(group) - .build(); - ReflectionTestUtils.setField(profile, "id", id); - return profile; - } - - private Address createAddress(String roadAddress, String detailAddress, String alias, - double latitude, double longitude) { - Address address = Address.builder() - .roadAddress(roadAddress) - .detailAddress(detailAddress) - .latitude(latitude) - .longitude(longitude) - .build(); - return address; - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberServiceFailureIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberServiceFailureIntegrationTest.java new file mode 100644 index 0000000..fec5960 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberServiceFailureIntegrationTest.java @@ -0,0 +1,64 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.repository.MemberRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberServiceFailureIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("존재하지 않는 회원 ID 로 비밀번호 변경 시 예외가 발생해야 한다") + @Rollback + void changePassword_memberNotFound() { + assertThatThrownBy(() -> memberService.changePassword(999L, "old", "new")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("회원이 존재하지 않습니다"); + } + + @Test + @DisplayName("패스워드 중복 확인이 일치하지 않으면 예외가 발생해야 한다") + void checkPasswordDoublyMismatch() { + assertThatThrownBy(() -> memberService.checkPasswordDoubly("a", "b")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("회원 삭제시 존재하지 않는 ID 일 경우 예외가 발생한다") + void deleteMember_notFound() { + assertThatThrownBy(() -> memberService.deleteByMemberId(999L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("중복 이메일 검증에서 이미 존재하는 이메일이면 예외 발생") + @Rollback + void duplicateEmail() throws PasswordPolicyException { + Member member = Member.builder() + .fullName("dup") + .email("dup@test.com") + .rawPassword("Password1!") + .build(); + memberRepository.save(member); + + assertThatThrownBy(() -> memberService.validateDuplicatedEmail("dup@test.com")) + .isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberServiceIntegrationTest.java new file mode 100644 index 0000000..42c781d --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberServiceIntegrationTest.java @@ -0,0 +1,88 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.repository.MemberRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; + +/** + * MemberService 통합 테스트 – Mock 사용 없이 실제 DB 상호작용 검증. + */ +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("회원 생성 후 중복 이메일 검증 시 예외가 발생해야 한다") + @Rollback + void validateDuplicatedEmail() throws Exception { + // given + Member member = Member.builder() + .fullName("홍길동") + .email("duplicate@example.com") + .rawPassword("Password1!") + .build(); + memberService.saveMember(member); + + // when & then + assertThatThrownBy(() -> memberService.validateDuplicatedEmail("duplicate@example.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("이미 존재하는 이메일 입니다"); + } + + @Test + @DisplayName("비밀번호 변경이 정상적으로 수행되어야 한다") + @Rollback + void changePassword() throws Exception { + // given + Member member = Member.builder() + .fullName("김철수") + .email("changepw@example.com") + .rawPassword("Origin123!") + .build(); + memberRepository.save(member); + + // when + memberService.changePassword(member.getId(), "Origin123!", "NewPassword1!"); + + // then + Member changed = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(changed.isMatchedPassword("NewPassword1!")).isTrue(); + } + + @Test + @DisplayName("회원 삭제가 모든 연관 데이터와 함께 정상적으로 동작해야 한다") + @Rollback + void deleteMember() throws PasswordPolicyException, PasswordFailedExceededException { + // given + Member member = Member.builder() + .fullName("이영희") + .email("deleteme@example.com") + .rawPassword("Password1!") + .build(); + memberRepository.save(member); + + // when + memberService.deleteByMemberId(member.getId()); + + // then + assertThat(memberRepository.findById(member.getId())).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java deleted file mode 100644 index ece2673..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.exception.PasswordFailedExceededException; -import com.stcom.smartmealtable.exception.PasswordPolicyException; -import com.stcom.smartmealtable.repository.AddressEntityRepository; -import com.stcom.smartmealtable.repository.MemberProfileRepository; -import com.stcom.smartmealtable.repository.MemberRepository; -import com.stcom.smartmealtable.repository.SocialAccountRepository; -import java.util.Optional; -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; - -@ExtendWith(MockitoExtension.class) -class MemberServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private MemberProfileRepository memberProfileRepository; - - @Mock - private SocialAccountRepository socialAccountRepository; - - @Mock - private AddressEntityRepository addressEntityRepository; - - @InjectMocks - private MemberService memberService; - - @Test - @DisplayName("회원 정보를 정상적으로 저장할 수 있어야 한다") - void saveMember() throws PasswordPolicyException { - // given - Member member = Member.builder() - .email("test@example.com") - .fullName("홍길동") - .rawPassword("Password123!") - .build(); - - when(memberRepository.save(any(Member.class))).thenReturn(member); - - // when - memberService.saveMember(member); - - // then - verify(memberRepository, times(1)).save(any(Member.class)); - } - - @Test - @DisplayName("ID로 회원 조회 시 회원이 존재하면 회원 정보를 반환해야 한다") - void findMemberByMemberId() throws PasswordPolicyException { - // given - Long memberId = 1L; - Member member = Member.builder() - .email("test@example.com") - .fullName("홍길동") - .rawPassword("Password123!") - .build(); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - - // when - Member foundMember = memberService.findMemberByMemberId(memberId); - - // then - assertThat(foundMember).isEqualTo(member); - verify(memberRepository, times(1)).findById(memberId); - } - - @Test - @DisplayName("ID로 회원 조회 시 회원이 존재하지 않으면 예외가 발생해야 한다") - void findMemberByMemberIdNotFound() { - // given - Long memberId = 999L; - when(memberRepository.findById(memberId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberService.findMemberByMemberId(memberId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회원입니다"); - } - - @Test - @DisplayName("비밀번호 확인이 일치하지 않으면 예외가 발생해야 한다") - void checkPasswordDoublyFail() { - // given - String password = "Password123!"; - String confirmPassword = "DifferentPassword123!"; - - // when & then - assertThatThrownBy(() -> memberService.checkPasswordDoubly(password, confirmPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("비밀번호가 일치하지 않습니다"); - } - - @Test - @DisplayName("비밀번호 변경이 정상적으로 동작해야 한다") - void changePassword() throws PasswordPolicyException, PasswordFailedExceededException { - // given - Long memberId = 1L; - String originPassword = "OriginPassword123!"; - String newPassword = "NewPassword123!"; - - Member member = Member.builder() - .email("test@example.com") - .fullName("홍길동") - .rawPassword(originPassword) - .build(); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - - // when - memberService.changePassword(memberId, originPassword, newPassword); - - // then - assertThat(member.isMatchedPassword(newPassword)).isTrue(); - } - - @Test - @DisplayName("회원 삭제가 정상적으로 동작해야 한다") - void deleteByMemberId() throws PasswordPolicyException { - // given - Long memberId = 1L; - Member member = Member.builder() - .email("test@example.com") - .fullName("홍길동") - .rawPassword("Password123!") - .build(); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - doNothing().when(memberProfileRepository).deleteMemberProfileByMember(any(Member.class)); - doNothing().when(socialAccountRepository).deleteSocialAccountByMember(any(Member.class)); - doNothing().when(memberRepository).delete(any(Member.class)); - - // when - memberService.deleteByMemberId(memberId); - - // then - verify(memberProfileRepository, times(1)).deleteMemberProfileByMember(member); - verify(socialAccountRepository, times(1)).deleteSocialAccountByMember(member); - verify(memberRepository, times(1)).delete(member); - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..d9bb9c5 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceAdditionalIntegrationTest.java @@ -0,0 +1,80 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import java.time.LocalDateTime; +import java.util.List; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class SocialAccountServiceAdditionalIntegrationTest { + + @Autowired + private SocialAccountService socialAccountService; + + @Autowired + private SocialAccountRepository repository; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("소셜 계정 토큰을 업데이트 할 수 있다") + @Rollback + void updateToken() { + // given + Member member = new Member("update@test.com"); + memberRepository.save(member); + SocialAccount account = SocialAccount.builder() + .member(member) + .provider("kakao") + .providerUserId("u1") + .tokenType("Bearer") + .accessToken("old") + .refreshToken("old_r") + .tokenExpiresAt(LocalDateTime.now()) + .build(); + repository.save(account); + + // when + socialAccountService.updateToken(account.getId(), "new", "new_r", LocalDateTime.now().plusDays(1)); + + // then + SocialAccount updated = repository.findById(account.getId()).orElseThrow(); + assertThat(updated.getAccessToken()).isEqualTo("new"); + } + + @Test + @DisplayName("회원이 연동한 모든 provider 목록을 조회할 수 있다") + void findAllProviders() { + // given + Member member = new Member("providers@test.com"); + memberRepository.save(member); + repository.save(SocialAccount.builder() + .member(member).provider("google").providerUserId("g1") + .tokenType("Bearer").accessToken("a").refreshToken("r") + .tokenExpiresAt(LocalDateTime.now()).build()); + repository.save(SocialAccount.builder() + .member(member).provider("kakao").providerUserId("k1") + .tokenType("Bearer").accessToken("a").refreshToken("r") + .tokenExpiresAt(LocalDateTime.now()).build()); + + // when + List providers = socialAccountService.findAllProviders(member.getId()); + + // then + assertThat(providers).containsExactlyInAnyOrder("google", "kakao"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceIntegrationTest.java new file mode 100644 index 0000000..dd17cec --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceIntegrationTest.java @@ -0,0 +1,49 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class SocialAccountServiceIntegrationTest { + + @Autowired + private SocialAccountService socialAccountService; + + @Autowired + private SocialAccountRepository repository; + + @Test + @DisplayName("새로운 사용자를 소셜 계정으로 생성하고 조회할 수 있다") + void createNewMemberAndLink() { + // given + TokenDto token = TokenDto.builder() + .accessToken("access") + .refreshToken("refresh") + .expiresIn(3600) + .tokenType("Bearer") + .provider("kakao") + .providerUserId("k123") + .email("socialnew@example.com") + .build(); + + // when + socialAccountService.createNewMemberAndLinkSocialAccount(token); + + // then + var saved = repository.findByProviderAndProviderUserId("kakao", "k123"); + assertThat(saved).isPresent(); + assertThat(saved.get().getMember().getEmail()).isEqualTo("socialnew@example.com"); + assertThat(saved.get().getTokenExpiresAt()).isAfter(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java deleted file mode 100644 index c8bb193..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java +++ /dev/null @@ -1,302 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.domain.social.SocialAccount; -import com.stcom.smartmealtable.infrastructure.dto.TokenDto; -import com.stcom.smartmealtable.repository.MemberRepository; -import com.stcom.smartmealtable.repository.SocialAccountRepository; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class SocialAccountServiceTest { - - @Mock - private SocialAccountRepository socialAccountRepository; - - @Mock - private MemberRepository memberRepository; - - @InjectMocks - private SocialAccountService socialAccountService; - - @Captor - private ArgumentCaptor memberCaptor; - - @Captor - private ArgumentCaptor socialAccountCaptor; - - @Test - @DisplayName("새 회원 생성 및 소셜 계정 연결이 가능해야 한다") - void createNewMemberAndLinkSocialAccount() { - // given - TokenDto tokenDto = createTokenDto("test@example.com", "KAKAO", "12345"); - - when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> { - Member member = invocation.getArgument(0); - ReflectionTestUtils.setField(member, "id", 1L); - return member; - }); - - when(socialAccountRepository.save(any(SocialAccount.class))).thenAnswer(invocation -> { - SocialAccount account = invocation.getArgument(0); - ReflectionTestUtils.setField(account, "id", 1L); - return account; - }); - - // when - socialAccountService.createNewMemberAndLinkSocialAccount(tokenDto); - - // then - verify(memberRepository, times(1)).save(memberCaptor.capture()); - verify(socialAccountRepository, times(1)).save(socialAccountCaptor.capture()); - - Member savedMember = memberCaptor.getValue(); - SocialAccount savedAccount = socialAccountCaptor.getValue(); - - assertThat(savedMember.getEmail()).isEqualTo("test@example.com"); - assertThat(savedAccount.getMember()).isEqualTo(savedMember); - assertThat(savedAccount.getProvider()).isEqualTo("KAKAO"); - assertThat(savedAccount.getProviderUserId()).isEqualTo("12345"); - assertThat(savedAccount.getAccessToken()).isEqualTo("access-token-value"); - assertThat(savedAccount.getRefreshToken()).isEqualTo("refresh-token-value"); - } - - @Test - @DisplayName("기존 회원에 소셜 계정 연결이 가능해야 한다") - void linkSocialAccount() { - // given - String email = "test@example.com"; - TokenDto tokenDto = createTokenDto(email, "GOOGLE", "67890"); - - Member existingMember = new Member(email); - ReflectionTestUtils.setField(existingMember, "id", 1L); - - when(memberRepository.findByEmail(email)).thenReturn(Optional.of(existingMember)); - when(socialAccountRepository.save(any(SocialAccount.class))).thenAnswer(invocation -> { - SocialAccount account = invocation.getArgument(0); - ReflectionTestUtils.setField(account, "id", 2L); - return account; - }); - - // when - socialAccountService.linkSocialAccount(tokenDto); - - // then - verify(memberRepository, times(1)).findByEmail(email); - verify(socialAccountRepository, times(1)).save(socialAccountCaptor.capture()); - - SocialAccount savedAccount = socialAccountCaptor.getValue(); - - assertThat(savedAccount.getMember()).isEqualTo(existingMember); - assertThat(savedAccount.getProvider()).isEqualTo("GOOGLE"); - assertThat(savedAccount.getProviderUserId()).isEqualTo("67890"); - assertThat(savedAccount.getAccessToken()).isEqualTo("access-token-value"); - } - - @Test - @DisplayName("존재하지 않는 이메일로 소셜 계정 연결 시 예외가 발생해야 한다") - void linkSocialAccountWithNonExistingEmail() { - // given - String email = "nonexisting@example.com"; - TokenDto tokenDto = createTokenDto(email, "KAKAO", "12345"); - - when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> socialAccountService.linkSocialAccount(tokenDto)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("회원이 null일 수는 없습니다"); - } - - @Test - @DisplayName("Provider와 ProviderUserId로 소셜 계정을 찾을 수 있어야 한다") - void findSocialAccount() { - // given - String provider = "KAKAO"; - String providerUserId = "12345"; - - Member member = new Member("test@example.com"); - ReflectionTestUtils.setField(member, "id", 1L); - - SocialAccount socialAccount = SocialAccount.builder() - .member(member) - .provider(provider) - .providerUserId(providerUserId) - .build(); - ReflectionTestUtils.setField(socialAccount, "id", 1L); - - when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) - .thenReturn(Optional.of(socialAccount)); - - // when - SocialAccount foundAccount = socialAccountService.findSocialAccount(provider, providerUserId); - - // then - assertThat(foundAccount).isNotNull(); - assertThat(foundAccount.getProvider()).isEqualTo(provider); - assertThat(foundAccount.getProviderUserId()).isEqualTo(providerUserId); - assertThat(foundAccount.getMember()).isEqualTo(member); - - verify(socialAccountRepository, times(1)).findByProviderAndProviderUserId(provider, providerUserId); - } - - @Test - @DisplayName("존재하지 않는 소셜 계정 찾기 시 null을 반환해야 한다") - void findNonExistingSocialAccount() { - // given - String provider = "KAKAO"; - String providerUserId = "nonexisting"; - - when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) - .thenReturn(Optional.empty()); - - // when - SocialAccount foundAccount = socialAccountService.findSocialAccount(provider, providerUserId); - - // then - assertThat(foundAccount).isNull(); - } - - @Test - @DisplayName("신규 사용자 여부를 확인할 수 있어야 한다") - void isNewUser() { - // given - String existingProvider = "KAKAO"; - String existingProviderId = "12345"; - - String newProvider = "GOOGLE"; - String newProviderId = "67890"; - - when(socialAccountRepository.findByProviderAndProviderUserId(existingProvider, existingProviderId)) - .thenReturn(Optional.of(new SocialAccount())); - - when(socialAccountRepository.findByProviderAndProviderUserId(newProvider, newProviderId)) - .thenReturn(Optional.empty()); - - // when - boolean existingUserResult = socialAccountService.isNewUser(existingProvider, existingProviderId); - boolean newUserResult = socialAccountService.isNewUser(newProvider, newProviderId); - - // then - assertThat(existingUserResult).isFalse(); - assertThat(newUserResult).isTrue(); - } - - @Test - @DisplayName("토큰 정보를 업데이트할 수 있어야 한다") - void updateToken() { - // given - Long socialAccountId = 1L; - - Member member = new Member("test@example.com"); - SocialAccount socialAccount = SocialAccount.builder() - .member(member) - .provider("KAKAO") - .providerUserId("12345") - .tokenType("Bearer") - .accessToken("old-access-token") - .refreshToken("old-refresh-token") - .tokenExpiresAt(LocalDateTime.now()) - .build(); - ReflectionTestUtils.setField(socialAccount, "id", socialAccountId); - - String newAccessToken = "new-access-token"; - String newRefreshToken = "new-refresh-token"; - LocalDateTime newExpiresAt = LocalDateTime.now().plusHours(1); - - when(socialAccountRepository.findById(socialAccountId)).thenReturn(Optional.of(socialAccount)); - - // when - socialAccountService.updateToken(socialAccountId, newAccessToken, newRefreshToken, newExpiresAt); - - // then - verify(socialAccountRepository, times(1)).findById(socialAccountId); - - assertThat(socialAccount.getAccessToken()).isEqualTo(newAccessToken); - assertThat(socialAccount.getRefreshToken()).isEqualTo(newRefreshToken); - assertThat(socialAccount.getTokenExpiresAt()).isEqualTo(newExpiresAt); - } - - @Test - @DisplayName("존재하지 않는 소셜 계정 ID로 토큰 업데이트 시 예외가 발생해야 한다") - void updateTokenWithNonExistingId() { - // given - Long nonExistingId = 999L; - - when(socialAccountRepository.findById(nonExistingId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> socialAccountService.updateToken( - nonExistingId, "new-token", "new-refresh-token", LocalDateTime.now())) - .isInstanceOf(IllegalStateException.class) - .hasMessage("확인되지 않은 계정입니다"); - } - - @Test - @DisplayName("회원 ID로 연결된 모든 소셜 Provider 목록을 가져올 수 있어야 한다") - void findAllProviders() { - // given - Long memberId = 1L; - - Member member = new Member("test@example.com"); - - SocialAccount kakaoAccount = SocialAccount.builder() - .member(member) - .provider("KAKAO") - .providerUserId("kakao-12345") - .build(); - - SocialAccount googleAccount = SocialAccount.builder() - .member(member) - .provider("GOOGLE") - .providerUserId("google-67890") - .build(); - - List socialAccounts = Arrays.asList(kakaoAccount, googleAccount); - - when(socialAccountRepository.findAllByMemberId(memberId)).thenReturn(socialAccounts); - - // when - List providers = socialAccountService.findAllProviders(memberId); - - // then - assertThat(providers).hasSize(2); - assertThat(providers).containsExactly("KAKAO", "GOOGLE"); - - verify(socialAccountRepository, times(1)).findAllByMemberId(memberId); - } - - private TokenDto createTokenDto(String email, String provider, String providerUserId) { - TokenDto tokenDto = new TokenDto(); - - tokenDto.setEmail(email); - tokenDto.setProvider(provider); - tokenDto.setProviderUserId(providerUserId); - tokenDto.setTokenType("Bearer"); - tokenDto.setAccessToken("access-token-value"); - tokenDto.setRefreshToken("refresh-token-value"); - tokenDto.setExpiresIn(3600); - - return tokenDto; - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java new file mode 100644 index 0000000..ef69701 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java @@ -0,0 +1,79 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.TermRepository; +import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +/** + * 통합 테스트: 스프링 컨텍스트와 실제 JPA 구현체로 Service 계층 검증. + */ +@SpringBootTest +@Transactional +class TermServiceIntegrationTest { + + @Autowired + private TermService termService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TermRepository termRepository; + + @Test + @DisplayName("필수 약관에 동의하면 정상적으로 저장되어야 한다") + @Rollback + void agreeRequiredTerms() throws Exception { + // given + Member member = Member.builder() + .fullName("홍길동") + .email("test@example.com") + .rawPassword("Password1!") + .build(); + memberRepository.save(member); + + Term required1 = createTerm("이용약관", true); + Term required2 = createTerm("개인정보 처리방침", true); + termRepository.saveAll(List.of(required1, required2)); + + List requests = List.of( + new TermAgreementRequestDto(required1.getId(), true), + new TermAgreementRequestDto(required2.getId(), true) + ); + + // when + termService.agreeTerms(member.getId(), requests); + + // then + // TermAgreementRepository 를 통해 조회해도 되지만, 간단히 member 의 약관 동의 수를 확인 + long count = termRepository.count(); + assertThat(count).isEqualTo(2); + } + + private Term createTerm(String title, boolean required) { + Term term = new Term(); + try { + java.lang.reflect.Field titleField = Term.class.getDeclaredField("title"); + titleField.setAccessible(true); + titleField.set(term, title); + + java.lang.reflect.Field isRequiredField = Term.class.getDeclaredField("isRequired"); + isRequiredField.setAccessible(true); + isRequiredField.set(term, required); + } catch (Exception e) { + throw new RuntimeException(e); + } + return term; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java deleted file mode 100644 index b28eee9..0000000 --- a/src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.stcom.smartmealtable.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.domain.term.Term; -import com.stcom.smartmealtable.domain.term.TermAgreement; -import com.stcom.smartmealtable.repository.MemberRepository; -import com.stcom.smartmealtable.repository.TermAgreementRepository; -import com.stcom.smartmealtable.repository.TermRepository; -import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -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; - -@ExtendWith(MockitoExtension.class) -class TermServiceTest { - - @InjectMocks - private TermService termService; - - @Mock - private TermRepository termRepository; - - @Mock - private MemberRepository memberRepository; - - @Mock - private TermAgreementRepository termAgreementRepository; - - @Test - @DisplayName("모든 약관을 조회할 수 있다") - void findAll() { - // given - Term term1 = createTerm(1L, "이용약관", true); - Term term2 = createTerm(2L, "개인정보 처리방침", true); - Term term3 = createTerm(3L, "마케팅 정보 수신 동의", false); - - when(termRepository.findAll()).thenReturn(Arrays.asList(term1, term2, term3)); - - // when - List terms = termService.findAll(); - - // then - assertThat(terms).hasSize(3); - assertThat(terms.get(0).getTitle()).isEqualTo("이용약관"); - assertThat(terms.get(1).getTitle()).isEqualTo("개인정보 처리방침"); - assertThat(terms.get(2).getTitle()).isEqualTo("마케팅 정보 수신 동의"); - } - - @Test - @DisplayName("회원이 약관에 동의할 수 있다") - void agreeTerms() { - // given - Long memberId = 1L; - Member member = createMember(memberId); - - Term term1 = createTerm(1L, "이용약관", true); - Term term2 = createTerm(2L, "개인정보 처리방침", true); - Term term3 = createTerm(3L, "마케팅 정보 수신 동의", false); - - TermAgreementRequestDto dto1 = new TermAgreementRequestDto(1L, true); - TermAgreementRequestDto dto2 = new TermAgreementRequestDto(2L, true); - TermAgreementRequestDto dto3 = new TermAgreementRequestDto(3L, false); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(termRepository.findAll()).thenReturn(Arrays.asList(term1, term2, term3)); - when(termRepository.findById(1L)).thenReturn(Optional.of(term1)); - when(termRepository.findById(2L)).thenReturn(Optional.of(term2)); - when(termRepository.findById(3L)).thenReturn(Optional.of(term3)); - - // when - termService.agreeTerms(memberId, Arrays.asList(dto1, dto2, dto3)); - - // then - verify(termAgreementRepository, times(3)).save(any(TermAgreement.class)); - } - - @Test - @DisplayName("필수 약관에 동의하지 않으면 예외가 발생한다") - void agreeTerms_RequiredTermNotAgreed() { - // given - Long memberId = 1L; - Member member = createMember(memberId); - - Term term1 = createTerm(1L, "이용약관", true); - Term term2 = createTerm(2L, "개인정보 처리방침", true); - - TermAgreementRequestDto dto1 = new TermAgreementRequestDto(1L, true); - TermAgreementRequestDto dto2 = new TermAgreementRequestDto(2L, false); // 필수 약관이지만 동의하지 않음 - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(termRepository.findAll()).thenReturn(Arrays.asList(term1, term2)); - - // when & then - assertThatThrownBy(() -> termService.agreeTerms(memberId, Arrays.asList(dto1, dto2))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("필수 약관에 동의해야 합니다"); - } - - @Test - @DisplayName("존재하지 않는 회원 ID로 약관 동의를 시도하면 예외가 발생한다") - void agreeTerms_MemberNotFound() { - // given - Long nonExistentMemberId = 999L; - TermAgreementRequestDto dto = new TermAgreementRequestDto(1L, true); - - when(memberRepository.findById(nonExistentMemberId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> termService.agreeTerms(nonExistentMemberId, List.of(dto))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회원입니다"); - } - - @Test - @DisplayName("존재하지 않는 약관 ID로 약관 동의를 시도하면 예외가 발생한다") - void agreeTerms_TermNotFound() { - // given - Long memberId = 1L; - Long nonExistentTermId = 999L; - Member member = createMember(memberId); - TermAgreementRequestDto dto = new TermAgreementRequestDto(nonExistentTermId, true); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(termRepository.findAll()).thenReturn(List.of()); - when(termRepository.findById(nonExistentTermId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> termService.agreeTerms(memberId, List.of(dto))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("존재하지 않는 약관입니다"); - } - - // 테스트용 회원 생성 헬퍼 메소드 - private Member createMember(Long id) { - Member member = new Member(); - return member; - } - - // 테스트용 약관 생성 헬퍼 메소드 - private Term createTerm(Long id, String title, Boolean isRequired) { - Term term = new Term(); - // 리플렉션을 통해 private 필드에 값 설정 - try { - java.lang.reflect.Field idField = Term.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(term, id); - - java.lang.reflect.Field titleField = Term.class.getDeclaredField("title"); - titleField.setAccessible(true); - titleField.set(term, title); - - java.lang.reflect.Field isRequiredField = Term.class.getDeclaredField("isRequired"); - isRequiredField.setAccessible(true); - isRequiredField.set(term, isRequired); - } catch (Exception e) { - throw new RuntimeException(e); - } - return term; - } -} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java deleted file mode 100644 index d6f2365..0000000 --- a/src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.stcom.smartmealtable.web.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -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.result.MockMvcResultHandlers.print; -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 com.stcom.smartmealtable.domain.member.Member; -import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; -import com.stcom.smartmealtable.security.JwtTokenService; -import com.stcom.smartmealtable.service.MemberService; -import com.stcom.smartmealtable.service.TermService; -import com.stcom.smartmealtable.service.dto.MemberDto; -import com.stcom.smartmealtable.web.argumentresolver.UserContext; -import com.stcom.smartmealtable.web.argumentresolver.UserContextArgumentResolver; -import com.stcom.smartmealtable.web.controller.MemberController.CreateMemberRequest; -import com.stcom.smartmealtable.web.controller.MemberController.EditMemberRequest; -import com.stcom.smartmealtable.web.controller.MemberController.TermAgreementDto; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.filter.CharacterEncodingFilter; - -import io.jsonwebtoken.Claims; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) -@AutoConfigureMockMvc -@Transactional -@TestPropertySource(properties = { - "spring.main.allow-bean-definition-overriding=true" -}) -class MemberControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private MemberService memberService; - - @MockBean - private JwtTokenService jwtTokenService; - - @MockBean - private TermService termService; - - @Autowired - private WebApplicationContext context; - - @BeforeEach - void setup() { - // 테스트용 MockMvc 설정 - mockMvc = MockMvcBuilders - .webAppContextSetup(context) - .addFilter(new CharacterEncodingFilter("UTF-8", true)) - .build(); - - // UserContext 어노테이션 처리를 위한 설정 - // Claims 모킹 설정 - Claims claims = Mockito.mock(Claims.class); - when(claims.get("memberId", String.class)).thenReturn("1"); - when(jwtTokenService.extractClaims(anyString())).thenReturn(claims); - } - - @Test - @DisplayName("이메일 중복 확인 API가 정상적으로 동작해야 한다") - void checkEmail() throws Exception { - // given - String email = "test@example.com"; - doNothing().when(memberService).validateDuplicatedEmail(email); - - // when & then - mockMvc.perform(get("/api/v1/members/email/check") - .param("email", email)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SUCCESS")); - } - - @Test - @DisplayName("회원 생성 API가 정상적으로 동작해야 한다") - void createMember() throws Exception { - // given - CreateMemberRequest request = new CreateMemberRequest( - "test@example.com", "Password123!", "Password123!", "홍길동"); - - // Member 모킹 - Member mockMember = mock(Member.class); - when(mockMember.getId()).thenReturn(1L); - - // Member Builder 모킹 - Member.MemberBuilder mockBuilder = mock(Member.MemberBuilder.class); - when(mockBuilder.fullName(anyString())).thenReturn(mockBuilder); - when(mockBuilder.email(anyString())).thenReturn(mockBuilder); - when(mockBuilder.rawPassword(anyString())).thenReturn(mockBuilder); - when(mockBuilder.build()).thenReturn(mockMember); - - // Member 정적 메소드 모킹 - try (var mockStatic = Mockito.mockStatic(Member.class)) { - mockStatic.when(Member::builder).thenReturn(mockBuilder); - - JwtTokenResponseDto tokenDto = new JwtTokenResponseDto( - "test-access-token", - "test-refresh-token", - 3600, - "Bearer" - ); - tokenDto.setNewUser(true); - - // MemberService 모킹 - doNothing().when(memberService).validateDuplicatedEmail(anyString()); - doNothing().when(memberService).checkPasswordDoubly(anyString(), anyString()); - doNothing().when(memberService).saveMember(any(Member.class)); - - // JwtTokenService 모킹 - when(jwtTokenService.createTokenDto(anyLong(), any())).thenReturn(tokenDto); - - // when & then - mockMvc.perform(post("/api/v1/members") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.status").value("SUCCESS")) - .andExpect(jsonPath("$.data.accessToken").value("test-access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("test-refresh-token")) - .andExpect(jsonPath("$.data.newUser").value(true)); - } - } - - @Test - @DisplayName("회원 정보 수정 API가 정상적으로 동작해야 한다") - void editMember() throws Exception { - // given - EditMemberRequest request = new EditMemberRequest( - "OldPassword123!", "NewPassword123!", "NewPassword123!"); - - doNothing().when(memberService).checkPasswordDoubly(anyString(), anyString()); - doNothing().when(memberService).changePassword(anyLong(), anyString(), anyString()); - - // when & then - mockMvc.perform(patch("/api/v1/members/me") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .header("Authorization", "Bearer test-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SUCCESS")); - } - - @Test - @DisplayName("회원 탈퇴 API가 정상적으로 동작해야 한다") - void deleteMember() throws Exception { - // given - doNothing().when(memberService).deleteByMemberId(anyLong()); - - // when & then - mockMvc.perform(delete("/api/v1/members/me") - .header("Authorization", "Bearer test-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SUCCESS")); - } - - @Test - @DisplayName("약관 동의 API가 정상적으로 동작해야 한다") - void signUpWithTermAgreement() throws Exception { - // given - List agreements = Arrays.asList( - new TermAgreementDto(1L, true), - new TermAgreementDto(2L, false) - ); - - doNothing().when(termService).agreeTerms(anyLong(), any()); - - // when & then - mockMvc.perform(post("/api/v1/members/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(agreements)) - .header("Authorization", "Bearer test-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SUCCESS")); - } -} \ No newline at end of file From b4286f084dea4bc6e31370cfae1db2a2a2133d37 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 15:08:16 +0900 Subject: [PATCH 23/44] =?UTF-8?q?test:=20=ED=95=B4=ED=94=BC/=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smartmealtable/service/MemberService.java | 8 +- .../Budget/BudgetDomainIntegrationTest.java | 2 +- .../domain/group/CompanyGroupTest.java | 2 +- ...udgetServiceAdditionalIntegrationTest.java | 180 +++++++++++ ...dgetServiceAdditionalIntegrationTest2.java | 144 +++++++++ .../BudgetServiceCompleteIntegrationTest.java | 292 +++++++++++++++++ ...GroupServiceAdditionalIntegrationTest.java | 95 ++++++ .../GroupServiceCompleteIntegrationTest.java | 249 +++++++++++++++ ...LoginServiceAdditionalIntegrationTest.java | 170 ++++++++++ .../LoginServiceCompleteIntegrationTest.java | 253 +++++++++++++++ ...renceServiceAdditionalIntegrationTest.java | 208 ++++++++++++ ...fileServiceAdditionalIntegrationTest2.java | 229 ++++++++++++++ ...ProfileServiceCompleteIntegrationTest.java | 295 ++++++++++++++++++ ...emberServiceAdditionalIntegrationTest.java | 121 +++++++ ...AccountServiceCompleteIntegrationTest.java | 255 +++++++++++++++ .../TermServiceAdditionalIntegrationTest.java | 205 ++++++++++++ .../service/TermServiceIntegrationTest.java | 2 + .../exception/ResourceNotFoundException.java | 1 + 18 files changed, 2702 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest2.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/BudgetServiceCompleteIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/GroupServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/GroupServiceCompleteIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/LoginServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/LoginServiceCompleteIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest2.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceCompleteIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/MemberServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceCompleteIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/TermServiceAdditionalIntegrationTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/exception/ResourceNotFoundException.java diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberService.java b/src/main/java/com/stcom/smartmealtable/service/MemberService.java index 22ee0e2..5d3580f 100644 --- a/src/main/java/com/stcom/smartmealtable/service/MemberService.java +++ b/src/main/java/com/stcom/smartmealtable/service/MemberService.java @@ -20,13 +20,7 @@ public class MemberService { private final MemberProfileRepository memberProfileRepository; private final SocialAccountRepository socialAccountRepository; private final AddressEntityRepository addressEntityRepository; - - /** - * 이메일 중복 검사를 수행합니다. 동일한 이메일을 가진 회원이 이미 존재하면 예외를 발생시킵니다. - * - * @param email 중복 검사할 이메일 - * @throws IllegalArgumentException 이메일이 이미 존재하는 경우 - */ + public void validateDuplicatedEmail(String email) { memberRepository.findByEmail(email).ifPresent(member -> { throw new IllegalArgumentException("이미 존재하는 이메일 입니다"); diff --git a/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java index 9706cd8..fd4b547 100644 --- a/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java +++ b/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetDomainIntegrationTest.java @@ -136,7 +136,7 @@ void budgetResetFunctionality() { BigDecimal usageRate = budget.getSpendAmount() .multiply(BigDecimal.valueOf(100)) .divide(budget.getLimit(), 2, RoundingMode.HALF_UP); - assertThat(usageRate).isEqualTo(BigDecimal.ZERO); + assertThat(usageRate.stripTrailingZeros()).isEqualByComparingTo(BigDecimal.ZERO); } @DisplayName("예산의 부동소수점 정밀도 테스트") diff --git a/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java b/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java index 7b34d13..982a525 100644 --- a/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java +++ b/src/test/java/com/stcom/smartmealtable/domain/group/CompanyGroupTest.java @@ -32,6 +32,6 @@ void changeFields() { // then assertThat(group.getName()).isEqualTo("테스트금융"); assertThat(group.getAddress().getRoadAddress()).isEqualTo("서울특별시 영등포구 국제금융로 2"); - assertThat(group.getTypeName()).isEqualTo("FINANCE"); + assertThat(group.getTypeName()).isEqualTo("파이낸스"); } } \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..4bfad9e --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest.java @@ -0,0 +1,180 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class BudgetServiceAdditionalIntegrationTest { + + @Autowired + private BudgetService budgetService; + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member member; + private MemberProfile memberProfile; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("budget_test@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + memberProfile = MemberProfile.builder() + .nickName("budgetUser") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + } + + @Test + @DisplayName("존재하지 않는 프로필로 월간 예산을 조회하면 예외가 발생한다") + void findRecentMonthlyBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + + // when & then + assertThatThrownBy(() -> budgetService.findRecentMonthlyBudgetByMemberProfileId(invalidProfileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("존재하지 않는 프로필로 일일 예산을 조회하면 예외가 발생한다") + void findRecentDailyBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + + // when & then + assertThatThrownBy(() -> budgetService.findRecentDailyBudgetByMemberProfileId(invalidProfileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("존재하지 않는 프로필로 월간 예산을 저장하면 예외가 발생한다") + void saveMonthlyBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + + // when & then + assertThatThrownBy(() -> budgetService.saveMonthlyBudgetCustom(invalidProfileId, 100000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("존재하지 않는 프로필로 일일 예산을 저장하면 예외가 발생한다") + void saveDailyBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + + // when & then + assertThatThrownBy(() -> budgetService.saveDailyBudgetCustom(invalidProfileId, 10000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("존재하지 않는 프로필로 기본 일일 예산을 등록하면 예외가 발생한다") + void registerDefaultDailyBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + LocalDate startDate = LocalDate.of(2025, 6, 15); + + // when & then + assertThatThrownBy(() -> budgetService.registerDefaultDailyBudgetBy(invalidProfileId, 10000L, startDate)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("존재하지 않는 프로필로 기본 월간 예산을 등록하면 예외가 발생한다") + void registerDefaultMonthlyBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + YearMonth yearMonth = YearMonth.of(2025, 6); + + // when & then + assertThatThrownBy(() -> budgetService.registerDefaultMonthlyBudgetBy(invalidProfileId, 500000L, yearMonth)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("일일 예산과 월간 예산이 둘 다 존재하는 경우 정상 조회된다") + void findBothDailyAndMonthlyBudgets() { + // given + Long dailyLimit = 10000L; + Long monthlyLimit = 300000L; + LocalDate today = LocalDate.of(2025, 6, 15); + YearMonth thisMonth = YearMonth.of(2025, 6); + + // 예산 생성 + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(dailyLimit), today); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(monthlyLimit), thisMonth); + budgetRepository.saveAll(List.of(dailyBudget, monthlyBudget)); + + // when + DailyBudget foundDaily = budgetService.getDailyBudgetBy(memberProfile.getId(), today); + MonthlyBudget foundMonthly = budgetService.getMonthlyBudgetBy(memberProfile.getId(), thisMonth); + + // then + assertThat(foundDaily.getLimit()).isEqualTo(BigDecimal.valueOf(dailyLimit)); + assertThat(foundMonthly.getLimit()).isEqualTo(BigDecimal.valueOf(monthlyLimit)); + } + + @Test + @DisplayName("존재하지 않는 날짜의 일일 예산을 수정하려고 하면 예외가 발생한다") + void editDailyBudgetWithNonExistentDate() { + // given + LocalDate nonExistentDate = LocalDate.of(2030, 12, 31); + + // when & then + assertThatThrownBy(() -> budgetService.editDailyBudgetCustom(memberProfile.getId(), nonExistentDate, 15000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("존재하지 않는 년월의 월간 예산을 수정하려고 하면 예외가 발생한다") + void editMonthlyBudgetWithNonExistentYearMonth() { + // given + YearMonth nonExistentYearMonth = YearMonth.of(2030, 12); + + // when & then + assertThatThrownBy(() -> budgetService.editMonthlyBudgetCustom(memberProfile.getId(), nonExistentYearMonth, 500000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest2.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest2.java new file mode 100644 index 0000000..4993f0f --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceAdditionalIntegrationTest2.java @@ -0,0 +1,144 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class BudgetServiceAdditionalIntegrationTest2 { + + @Autowired + private BudgetService budgetService; + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member member; + private MemberProfile memberProfile; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("budget_test2@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + memberProfile = MemberProfile.builder() + .nickName("예산테스터2") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + } + + @Test + @DisplayName("주간 예산 조회 시 월요일부터 일요일까지의 예산이 모두 조회된다") + void getDailyBudgetsByWeek() { + // given + // 특정 주의 월요일 구하기 + LocalDate today = LocalDate.of(2025, 7, 16); // 수요일 + LocalDate monday = today.with(DayOfWeek.MONDAY); + + // 해당 주의 모든 일자에 예산 생성 (월~일) + for (int i = 0; i < 7; i++) { + LocalDate date = monday.plusDays(i); + // 요일별로 다른 금액 설정 + DailyBudget dailyBudget = new DailyBudget( + memberProfile, + BigDecimal.valueOf(10000 + i * 1000), + date); + budgetRepository.save(dailyBudget); + } + + // when - 해당 주의 중간 날짜(수요일)로 조회해도 월요일부터 일요일까지 모두 조회되어야 함 + List weekBudgets = budgetService.getDailyBudgetsByWeek(memberProfile.getId(), today); + + // then + assertThat(weekBudgets).hasSize(7); + assertThat(weekBudgets.get(0).getDate()).isEqualTo(monday); // 첫 번째는 월요일 + assertThat(weekBudgets.get(6).getDate()).isEqualTo(monday.plusDays(6)); // 마지막은 일요일 + + // 날짜순으로 정렬되어 있는지 확인 + for (int i = 0; i < weekBudgets.size(); i++) { + assertThat(weekBudgets.get(i).getDate()).isEqualTo(monday.plusDays(i)); + // 금액 확인 (10000 + i * 1000) + assertThat(weekBudgets.get(i).getLimit()).isEqualTo(BigDecimal.valueOf(10000 + i * 1000)); + } + } + + @Test + @DisplayName("빈 주간 예산 조회 시 빈 리스트가 반환된다") + void getDailyBudgetsByWeekWhenEmpty() { + // given + LocalDate date = LocalDate.of(2026, 1, 1); + + // when + List weekBudgets = budgetService.getDailyBudgetsByWeek(memberProfile.getId(), date); + + // then + assertThat(weekBudgets).isEmpty(); + } + + @Test + @DisplayName("일부 날짜만 예산이 있는 주간 조회 시 존재하는 날짜의 예산만 조회된다") + void getDailyBudgetsByWeekWithPartialData() { + // given + LocalDate monday = LocalDate.of(2025, 8, 4); + LocalDate wednesday = monday.plusDays(2); + LocalDate friday = monday.plusDays(4); + + // 수요일과 금요일에만 예산 설정 + DailyBudget wednesdayBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(15000), wednesday); + DailyBudget fridayBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), friday); + budgetRepository.saveAll(List.of(wednesdayBudget, fridayBudget)); + + // when + List weekBudgets = budgetService.getDailyBudgetsByWeek(memberProfile.getId(), monday); + + // then + assertThat(weekBudgets).hasSize(2); // 수요일, 금요일 두 개만 있어야 함 + assertThat(weekBudgets.stream().map(DailyBudget::getDate)) + .containsExactlyInAnyOrder(wednesday, friday); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 주간 예산 조회 시 빈 리스트가 반환된다") + void getDailyBudgetsByWeekWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + LocalDate date = LocalDate.now(); + + // when + List weekBudgets = budgetService.getDailyBudgetsByWeek(invalidProfileId, date); + + // then + assertThat(weekBudgets).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceCompleteIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceCompleteIntegrationTest.java new file mode 100644 index 0000000..567c046 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceCompleteIntegrationTest.java @@ -0,0 +1,292 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class BudgetServiceCompleteIntegrationTest { + + @Autowired + private BudgetService budgetService; + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member member; + private MemberProfile memberProfile; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("budget_complete_test@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + memberProfile = MemberProfile.builder() + .nickName("완전예산테스터") + .member(member) + .build(); + memberProfileRepository.save(memberProfile); + } + + @Test + @DisplayName("특정 일자의 일간 예산을 조회할 수 있다") + void getDailyBudgetBy() { + // given + LocalDate date = LocalDate.of(2025, 7, 15); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(25000), date); + budgetRepository.save(dailyBudget); + + // when + DailyBudget result = budgetService.getDailyBudgetBy(memberProfile.getId(), date); + + // then + assertThat(result.getDate()).isEqualTo(date); + assertThat(result.getLimit()).isEqualTo(BigDecimal.valueOf(25000)); + assertThat(result.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + } + + @Test + @DisplayName("존재하지 않는 일간 예산 조회 시 예외가 발생한다") + void getDailyBudgetByNotFound() { + // given + LocalDate date = LocalDate.of(2025, 7, 15); + + // when & then + assertThatThrownBy(() -> budgetService.getDailyBudgetBy(memberProfile.getId(), date)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("예산이 존재하지 않습니다."); + } + + @Test + @DisplayName("특정 월의 월간 예산을 조회할 수 있다") + void getMonthlyBudgetBy() { + // given + YearMonth yearMonth = YearMonth.of(2025, 7); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(800000), yearMonth); + budgetRepository.save(monthlyBudget); + + // when + MonthlyBudget result = budgetService.getMonthlyBudgetBy(memberProfile.getId(), yearMonth); + + // then + assertThat(result.getYearMonth()).isEqualTo(yearMonth); + assertThat(result.getLimit()).isEqualTo(BigDecimal.valueOf(800000)); + assertThat(result.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + } + + @Test + @DisplayName("존재하지 않는 월간 예산 조회 시 예외가 발생한다") + void getMonthlyBudgetByNotFound() { + // given + YearMonth yearMonth = YearMonth.of(2025, 7); + + // when & then + assertThatThrownBy(() -> budgetService.getMonthlyBudgetBy(memberProfile.getId(), yearMonth)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("기본 일간 예산을 시작일부터 월말까지 자동 생성할 수 있다") + void registerDefaultDailyBudgetBy() { + // given + LocalDate startDate = LocalDate.of(2025, 7, 15); + Long dailyLimit = 20000L; + + // when + budgetService.registerDefaultDailyBudgetBy(memberProfile.getId(), dailyLimit, startDate); + + // then + List budgets = budgetRepository.findDailyBudgetsByMemberProfileIdAndDateBetween( + memberProfile.getId(), + startDate, + startDate.withDayOfMonth(startDate.lengthOfMonth()) + ); + + // 7월 15일부터 31일까지 17일간 + assertThat(budgets).hasSize(17); + assertThat(budgets.get(0).getDate()).isEqualTo(startDate); + assertThat(budgets.get(budgets.size() - 1).getDate()).isEqualTo(LocalDate.of(2025, 7, 31)); + assertThat(budgets.stream().allMatch(b -> b.getLimit().equals(BigDecimal.valueOf(dailyLimit)))).isTrue(); + } + + @Test + @DisplayName("기본 월간 예산을 생성할 수 있다") + void registerDefaultMonthlyBudgetBy() { + // given + YearMonth startYearMonth = YearMonth.of(2025, 8); + Long monthlyLimit = 900000L; + + // when + budgetService.registerDefaultMonthlyBudgetBy(memberProfile.getId(), monthlyLimit, startYearMonth); + + // then + MonthlyBudget budget = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth( + memberProfile.getId(), startYearMonth + ).orElseThrow(); + + assertThat(budget.getYearMonth()).isEqualTo(startYearMonth); + assertThat(budget.getLimit()).isEqualTo(BigDecimal.valueOf(monthlyLimit)); + assertThat(budget.getMemberProfile().getId()).isEqualTo(memberProfile.getId()); + } + + @Test + @DisplayName("기본 예산 생성 시 존재하지 않는 프로필 ID로 시도하면 예외가 발생한다") + void registerDefaultBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + LocalDate date = LocalDate.now(); + YearMonth yearMonth = YearMonth.now(); + + // when & then + assertThatThrownBy(() -> budgetService.registerDefaultDailyBudgetBy(invalidProfileId, 20000L, date)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + + assertThatThrownBy(() -> budgetService.registerDefaultMonthlyBudgetBy(invalidProfileId, 800000L, yearMonth)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("일간 예산 한도를 수정할 수 있다") + void editDailyBudgetCustom() { + // given + LocalDate date = LocalDate.of(2025, 8, 10); + DailyBudget originalBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(20000), date); + budgetRepository.save(originalBudget); + + Long newLimit = 35000L; + + // when + budgetService.editDailyBudgetCustom(memberProfile.getId(), date, newLimit); + + // then + DailyBudget updatedBudget = budgetRepository.findDailyBudgetByMemberProfileIdAndDate( + memberProfile.getId(), date + ).orElseThrow(); + + assertThat(updatedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(newLimit)); + } + + @Test + @DisplayName("월간 예산 한도를 수정할 수 있다") + void editMonthlyBudgetCustom() { + // given + YearMonth yearMonth = YearMonth.of(2025, 8); + MonthlyBudget originalBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(800000), yearMonth); + budgetRepository.save(originalBudget); + + Long newLimit = 1200000L; + + // when + budgetService.editMonthlyBudgetCustom(memberProfile.getId(), yearMonth, newLimit); + + // then + MonthlyBudget updatedBudget = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth( + memberProfile.getId(), yearMonth + ).orElseThrow(); + + assertThat(updatedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(newLimit)); + } + + @Test + @DisplayName("존재하지 않는 예산 수정 시 예외가 발생한다") + void editBudgetCustomNotFound() { + // given + LocalDate date = LocalDate.of(2025, 8, 10); + YearMonth yearMonth = YearMonth.of(2025, 8); + + // when & then + assertThatThrownBy(() -> budgetService.editDailyBudgetCustom(memberProfile.getId(), date, 30000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + + assertThatThrownBy(() -> budgetService.editMonthlyBudgetCustom(memberProfile.getId(), yearMonth, 1000000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("사용자 정의 일간 예산을 저장할 수 있다") + void saveDailyBudgetCustom() { + // given + Long dailyLimit = 30000L; + + // when + budgetService.saveDailyBudgetCustom(memberProfile.getId(), dailyLimit); + + // then + // 오늘 날짜로 예산이 생성되는지 확인 + DailyBudget savedBudget = budgetRepository.findDailyBudgetByMemberProfileIdAndDate( + memberProfile.getId(), LocalDate.now() + ).orElseThrow(); + + assertThat(savedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(dailyLimit)); + assertThat(savedBudget.getDate()).isEqualTo(LocalDate.now()); + } + + @Test + @DisplayName("사용자 정의 월간 예산을 저장할 수 있다") + void saveMonthlyBudgetCustom() { + // given + Long monthlyLimit = 900000L; + + // when + budgetService.saveMonthlyBudgetCustom(memberProfile.getId(), monthlyLimit); + + // then + // 이번 달로 예산이 생성되는지 확인 + MonthlyBudget savedBudget = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth( + memberProfile.getId(), YearMonth.now() + ).orElseThrow(); + + assertThat(savedBudget.getLimit()).isEqualTo(BigDecimal.valueOf(monthlyLimit)); + assertThat(savedBudget.getYearMonth()).isEqualTo(YearMonth.now()); + } + + @Test + @DisplayName("사용자 정의 예산 저장 시 존재하지 않는 프로필 ID로 시도하면 예외가 발생한다") + void saveCustomBudgetWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> budgetService.saveDailyBudgetCustom(invalidProfileId, 25000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + + assertThatThrownBy(() -> budgetService.saveMonthlyBudgetCustom(invalidProfileId, 800000L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/GroupServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/GroupServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..d850a47 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/GroupServiceAdditionalIntegrationTest.java @@ -0,0 +1,95 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.infrastructure.KakaoAddressApiService; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import com.stcom.smartmealtable.repository.GroupRepository; +import java.util.List; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(GroupServiceAdditionalIntegrationTest.FakeKakaoConfig.class) +class GroupServiceAdditionalIntegrationTest { + + @TestConfiguration + static class FakeKakaoConfig { + @Bean + KakaoAddressApiService kakaoAddressApiService() { + return new KakaoAddressApiService() { + @Override + public Address createAddressFromRequest(AddressRequest requestDto) { + return Address.builder() + .roadAddress(requestDto.getRoadAddress()) + .detailAddress(requestDto.getDetailAddress()) + .build(); + } + }; + } + } + + @Autowired + private GroupService groupService; + @Autowired + private GroupRepository repository; + + @Test + @DisplayName("학교 그룹을 생성하고 삭제할 수 있다") + @Rollback + void createAndDeleteSchoolGroup() { + // when + groupService.createSchoolGroup(new AddressRequest("서울", "1"), "테스트고", SchoolType.HIGH_SCHOOL); + List saved = repository.findByNameContaining("테스트고", org.springframework.data.domain.Limit.of(10)); + Long id = saved.get(0).getId(); + + // then + assertThat(saved).hasSize(1); + + groupService.deleteGroup(id); + assertThat(repository.findById(id)).isEmpty(); + } + + @Test + @DisplayName("학교 그룹 수정 시 타입 변경") + void changeSchoolGroup() { + groupService.createSchoolGroup(new AddressRequest("부산", "detail"), "부산고", SchoolType.HIGH_SCHOOL); + Long id = repository.findByNameContaining("부산고", org.springframework.data.domain.Limit.of(10)).get(0).getId(); + + groupService.changeSchoolGroup(id, new AddressRequest("부산", "detail2"), "부산여고", SchoolType.MIDDLE_SCHOOL); + + Group changed = repository.findById(id).orElseThrow(); + assertThat(changed.getName()).isEqualTo("부산여고"); + assertThat(changed.getTypeName()).isEqualTo(SchoolType.MIDDLE_SCHOOL.name()); + } + + @Test + @DisplayName("학교 그룹 수정 시 학교 그룹이 아니면 예외") + void changeSchoolGroup_invalidType() { + // 직접 회사 그룹 저장 + Address addr = Address.builder().roadAddress("서울").build(); + Group company = new com.stcom.smartmealtable.domain.group.CompanyGroup(); + org.springframework.test.util.ReflectionTestUtils.setField(company, "address", addr); + org.springframework.test.util.ReflectionTestUtils.setField(company, "name", "컴퍼니"); + repository.save(company); + + assertThatThrownBy(() -> + groupService.changeSchoolGroup(company.getId(), new AddressRequest("a","b"), "x", SchoolType.HIGH_SCHOOL)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("학교 그룹이 아닙니다"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/GroupServiceCompleteIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/GroupServiceCompleteIntegrationTest.java new file mode 100644 index 0000000..1adaafd --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/GroupServiceCompleteIntegrationTest.java @@ -0,0 +1,249 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import com.stcom.smartmealtable.repository.GroupRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.context.annotation.Bean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import com.stcom.smartmealtable.infrastructure.KakaoAddressApiService; + +import java.util.List; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(GroupServiceCompleteIntegrationTest.TestConfig.class) +class GroupServiceCompleteIntegrationTest { + + @Autowired + private GroupService groupService; + + @Autowired + private GroupRepository groupRepository; + + private SchoolGroup schoolGroup1; + private SchoolGroup schoolGroup2; + private SchoolGroup schoolGroup3; + + @BeforeEach + void setUp() { + Address address1 = Address.builder() + .lotNumberAddress("서울시 강남구") + .roadAddress("테헤란로 123") + .detailAddress("1번 빌딩") + .build(); + + Address address2 = Address.builder() + .lotNumberAddress("서울시 서초구") + .roadAddress("강남대로 456") + .detailAddress("2번 빌딩") + .build(); + + Address address3 = Address.builder() + .lotNumberAddress("부산시 해운대구") + .roadAddress("센텀로 789") + .detailAddress("3번 빌딩") + .build(); + + schoolGroup1 = new SchoolGroup(address1, "서울대학교", SchoolType.UNIVERSITY_FOUR_YEAR); + schoolGroup2 = new SchoolGroup(address2, "연세대학교", SchoolType.UNIVERSITY_FOUR_YEAR); + schoolGroup3 = new SchoolGroup(address3, "카이스트", SchoolType.UNIVERSITY_FOUR_YEAR); + + groupRepository.save(schoolGroup1); + groupRepository.save(schoolGroup2); + groupRepository.save(schoolGroup3); + } + + @Test + @DisplayName("그룹 ID로 그룹을 조회할 수 있다") + void findGroupByGroupId() { + // when + Group foundGroup = groupService.findGroupByGroupId(schoolGroup1.getId()); + + // then + assertThat(foundGroup.getId()).isEqualTo(schoolGroup1.getId()); + assertThat(foundGroup.getName()).isEqualTo("서울대학교"); + } + + @Test + @DisplayName("존재하지 않는 그룹 ID로 조회하면 예외가 발생한다") + void findGroupByGroupIdWithInvalidId() { + // given + Long invalidGroupId = 99999L; + + // when & then + assertThatThrownBy(() -> groupService.findGroupByGroupId(invalidGroupId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("키워드로 그룹을 검색할 수 있다 - 완전 일치") + void findGroupsByKeywordExactMatch() { + // when + List groups = groupService.findGroupsByKeyword("서울대학교"); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0).getName()).isEqualTo("서울대학교"); + } + + @Test + @DisplayName("키워드로 그룹을 검색할 수 있다 - 부분 일치") + void findGroupsByKeywordPartialMatch() { + // when + List groups = groupService.findGroupsByKeyword("대학교"); + + // then + assertThat(groups).hasSize(2); + assertThat(groups).extracting(Group::getName) + .containsExactlyInAnyOrder("서울대학교", "연세대학교"); + } + + @Test + @DisplayName("키워드로 그룹을 검색할 수 있다 - 검색 결과 없음") + void findGroupsByKeywordNoResults() { + // when + List groups = groupService.findGroupsByKeyword("존재하지않는키워드"); + + // then + assertThat(groups).isEmpty(); + } + + @Test + @DisplayName("빈 키워드로 검색하면 모든 그룹을 반환한다") + void findGroupsByEmptyKeyword() { + // when + List groups = groupService.findGroupsByKeyword(""); + + // then + assertThat(groups).hasSize(3); + } + + @Test + @DisplayName("null 키워드로 검색하면 모든 그룹을 반환한다") + void findGroupsByNullKeyword() { + // when + List groups = groupService.findGroupsByKeyword(null); + + // then + assertThat(groups).isEmpty(); + } + + @Test + @DisplayName("새로운 학교 그룹을 생성할 수 있다") + void createSchoolGroup() { + // given + AddressRequest addressRequest = new AddressRequest("1234", "대전시 유성구 과학로 291"); + String name = "한국과학기술원"; + SchoolType schoolType = SchoolType.UNIVERSITY_FOUR_YEAR; + + // when + groupService.createSchoolGroup(addressRequest, name, schoolType); + + // then + List allGroups = groupRepository.findAll(); + assertThat(allGroups).hasSize(4); + Group createdGroup = allGroups.stream() + .filter(group -> group.getName().equals(name)) + .findFirst() + .orElseThrow(); + assertThat(createdGroup.getName()).isEqualTo(name); + assertThat(((SchoolGroup) createdGroup).getSchoolType()).isEqualTo(schoolType); + } + + @Test + @DisplayName("학교 그룹 정보를 변경할 수 있다") + void changeSchoolGroup() { + // given + AddressRequest newAddressRequest = new AddressRequest("5678", "서울시 동작구 상도로 369"); + String newName = "숭실대학교"; + SchoolType newType = SchoolType.UNIVERSITY_FOUR_YEAR; + + // when + groupService.changeSchoolGroup(schoolGroup1.getId(), newAddressRequest, newName, newType); + + // then + Group changedGroup = groupRepository.findById(schoolGroup1.getId()).orElseThrow(); + assertThat(changedGroup.getName()).isEqualTo(newName); + assertThat(((SchoolGroup) changedGroup).getSchoolType()).isEqualTo(newType); + } + + @Test + @DisplayName("존재하지 않는 그룹을 변경하려고 하면 예외가 발생한다") + void changeSchoolGroupWithInvalidId() { + // given + Long invalidGroupId = 99999L; + AddressRequest addressRequest = new AddressRequest("0000", "서울시 테스트로"); + + // when & then + assertThatThrownBy(() -> groupService.changeSchoolGroup(invalidGroupId, addressRequest, "테스트", SchoolType.UNIVERSITY_FOUR_YEAR)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("학교가 아닌 그룹을 학교 그룹으로 변경하려고 하면 예외가 발생한다") + void changeNonSchoolGroupToSchoolGroup() { + // 이 테스트는 현재 모든 그룹이 SchoolGroup이므로 생략하거나 + // CompanyGroup이 있을 때 다시 작성해야 함 + } + + @Test + @DisplayName("그룹을 삭제할 수 있다") + void deleteGroup() { + // given + Long groupIdToDelete = schoolGroup1.getId(); + + // when + groupService.deleteGroup(groupIdToDelete); + + // then + assertThat(groupRepository.findById(groupIdToDelete)).isEmpty(); + assertThat(groupRepository.findAll()).hasSize(2); + } + + @Test + @DisplayName("존재하지 않는 그룹을 삭제하려고 하면 예외가 발생한다") + void deleteGroupWithInvalidId() { + // given + Long invalidGroupId = 99999L; + + // when & then + assertThatThrownBy(() -> groupService.deleteGroup(invalidGroupId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @TestConfiguration + static class TestConfig { + @Bean + @org.springframework.context.annotation.Primary + public KakaoAddressApiService kakaoAddressApiService() { + return new KakaoAddressApiService() { + @Override + public Address createAddressFromRequest(AddressRequest requestDto) { + return Address.builder() + .lotNumberAddress(requestDto.getRoadAddress()) + .roadAddress(requestDto.getRoadAddress()) + .detailAddress(requestDto.getDetailAddress()) + .build(); + } + }; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/LoginServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/LoginServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..f35e84c --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/LoginServiceAdditionalIntegrationTest.java @@ -0,0 +1,170 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class LoginServiceAdditionalIntegrationTest { + + @Autowired + private LoginService loginService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + @Autowired + private SocialAccountRepository socialAccountRepository; + + private Member member; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("login_additional@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + } + + @Test + @DisplayName("존재하지 않는 이메일로 로그인 시도하면 예외가 발생한다") + void loginWithNonExistentEmail() { + // given + String nonExistentEmail = "nonexistent@example.com"; + String password = "password123!"; + + // when & then + assertThatThrownBy(() -> loginService.loginWithEmail(nonExistentEmail, password)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); + } + + @Test + @DisplayName("잘못된 비밀번호로 로그인 시도하면 예외가 발생한다") + void loginWithIncorrectPassword() { + // given + String incorrectPassword = "wrongPassword123!"; + + // when & then + assertThatThrownBy(() -> loginService.loginWithEmail(member.getEmail(), incorrectPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("프로필이 등록된 회원이 로그인하면 newUser 플래그가 false이다") + void loginWithRegisteredProfile() throws Exception { + // given + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("테스터") + .type(MemberType.STUDENT) + .build(); + memberProfileRepository.save(profile); + + // when + AuthResultDto result = loginService.loginWithEmail(member.getEmail(), "password123!"); + + // then + assertThat(result.isNewUser()).isFalse(); + assertThat(result.getProfileId()).isEqualTo(profile.getId()); + } + + @Test + @DisplayName("기존 소셜계정으로 다시 로그인하면 토큰이 갱신된다") + void socialLoginWithExistingSocialAccount() { + // given + // 기존 소셜 계정 생성 + SocialAccount existingAccount = SocialAccount.builder() + .member(member) + .provider("google") + .providerUserId("google123") + .accessToken("old-token") + .refreshToken("old-refresh") + .tokenType("Bearer") + .tokenExpiresAt(LocalDateTime.now().plusHours(1)) + .build(); + socialAccountRepository.save(existingAccount); + + // 프로필 생성 + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("소셜유저") + .type(MemberType.STUDENT) + .build(); + memberProfileRepository.save(profile); + + // 새로운 토큰 정보 + TokenDto newToken = TokenDto.builder() + .accessToken("new-token") + .refreshToken("new-refresh") + .tokenType("Bearer") + .provider("google") + .providerUserId("google123") + .expiresIn(3600) + .email(member.getEmail()) + .build(); + + // when + AuthResultDto result = loginService.socialLogin(newToken); + + // then + assertThat(result.isNewUser()).isFalse(); + assertThat(result.getProfileId()).isEqualTo(profile.getId()); + + // 토큰 업데이트 확인 + SocialAccount updatedAccount = socialAccountRepository.findByProviderAndProviderUserId( + "google", "google123").orElseThrow(); + assertThat(updatedAccount.getAccessToken()).isEqualTo("new-token"); + assertThat(updatedAccount.getRefreshToken()).isEqualTo("new-refresh"); + } + + @Test + @DisplayName("새로운 소셜계정으로 로그인하면 계정이 생성된다") + void socialLoginWithNewAccount() { + // given + TokenDto newToken = TokenDto.builder() + .accessToken("brand-new-token") + .refreshToken("brand-new-refresh") + .tokenType("Bearer") + .provider("kakao") + .providerUserId("kakao123") + .expiresIn(3600) + .email("new_social@example.com") + .build(); + + // when + AuthResultDto result = loginService.socialLogin(newToken); + + // then + assertThat(result.isNewUser()).isTrue(); + + // 새 계정 확인 + SocialAccount newAccount = socialAccountRepository.findByProviderAndProviderUserId( + "kakao", "kakao123").orElseThrow(); + assertThat(newAccount.getAccessToken()).isEqualTo("brand-new-token"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/LoginServiceCompleteIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/LoginServiceCompleteIntegrationTest.java new file mode 100644 index 0000000..603a082 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/LoginServiceCompleteIntegrationTest.java @@ -0,0 +1,253 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class LoginServiceCompleteIntegrationTest { + + @Autowired + private LoginService loginService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SocialAccountRepository socialAccountRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member testMember; + private Member memberWithProfile; + private SocialAccount existingSocialAccount; + + @BeforeEach + void setUp() { + // 기본 회원 생성 + testMember = Member.builder() + .email("test@example.com") + .rawPassword("TestPassword123!") + .build(); + memberRepository.save(testMember); + + // 프로필이 있는 회원 생성 + memberWithProfile = Member.builder() + .email("profile@example.com") + .rawPassword("TestPassword123!") + .build(); + memberRepository.save(memberWithProfile); + + // 프로필 생성 (linkMember가 memberProfile과 member를 연결함) + MemberProfile profile = MemberProfile.builder() + .nickName("TestUser") + .member(memberWithProfile) + .type(MemberType.STUDENT) + .build(); + + // 기존 소셜 계정 생성 + existingSocialAccount = SocialAccount.builder() + .member(testMember) + .provider("kakao") + .providerUserId("kakao123") + .tokenType("Bearer") + .accessToken("existing_access_token") + .refreshToken("existing_refresh_token") + .tokenExpiresAt(LocalDateTime.now().plusHours(1)) + .build(); + socialAccountRepository.save(existingSocialAccount); + + memberRepository.save(memberWithProfile); + memberProfileRepository.save(profile); + } + + @Test + @DisplayName("이메일과 비밀번호로 로그인할 수 있다") + void loginWithEmail() throws PasswordFailedExceededException { + // when + AuthResultDto result = loginService.loginWithEmail("test@example.com", "TestPassword123!"); + + // then + assertThat(result.getMemberId()).isEqualTo(testMember.getId()); + assertThat(result.getProfileId()).isNull(); + assertThat(result.isNewUser()).isTrue(); + } + + @Test + @DisplayName("프로필이 있는 회원으로 로그인하면 프로필 ID를 반환한다") + void loginWithEmailWithProfile() throws PasswordFailedExceededException { + // when + AuthResultDto result = loginService.loginWithEmail("profile@example.com", "TestPassword123!"); + + // then + assertThat(result.getMemberId()).isEqualTo(memberWithProfile.getId()); + assertThat(result.getProfileId()).isEqualTo(memberWithProfile.getMemberProfile().getId()); + assertThat(result.isNewUser()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 이메일로 로그인하면 예외가 발생한다") + void loginWithNonExistentEmail() { + // when & then + assertThatThrownBy(() -> loginService.loginWithEmail("nonexistent@example.com", "password")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); + } + + @Test + @DisplayName("잘못된 비밀번호로 로그인하면 예외가 발생한다") + void loginWithWrongPassword() { + // when & then + assertThatThrownBy(() -> loginService.loginWithEmail("test@example.com", "WrongPassword")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("기존 회원의 소셜 로그인을 할 수 있다") + void socialLoginExistingMember() { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("new_access_token") + .refreshToken("new_refresh_token") + .expiresIn(3600) + .tokenType("Bearer") + .provider("kakao") + .providerUserId("kakao123") + .email("test@example.com") + .build(); + + // when + AuthResultDto result = loginService.socialLogin(tokenDto); + + // then + assertThat(result.getMemberId()).isEqualTo(testMember.getId()); + assertThat(result.getProfileId()).isNull(); + assertThat(result.isNewUser()).isTrue(); + + // 토큰이 업데이트되었는지 확인 + SocialAccount updatedAccount = socialAccountRepository.findByProviderAndProviderUserId( + "kakao", "kakao123").orElseThrow(); + assertThat(updatedAccount.getAccessToken()).isEqualTo("new_access_token"); + assertThat(updatedAccount.getRefreshToken()).isEqualTo("new_refresh_token"); + } + + @Test + @DisplayName("새로운 회원의 소셜 로그인을 할 수 있다") + void socialLoginNewMember() { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("google_access_token") + .refreshToken("google_refresh_token") + .expiresIn(3600) + .tokenType("Bearer") + .provider("google") + .providerUserId("google123") + .email("newuser@example.com") + .build(); + + // when + AuthResultDto result = loginService.socialLogin(tokenDto); + + // then + assertThat(result.getMemberId()).isNotNull(); + assertThat(result.getProfileId()).isNull(); + assertThat(result.isNewUser()).isTrue(); + + // 새로운 회원이 생성되었는지 확인 + Member newMember = memberRepository.findByEmail("newuser@example.com").orElseThrow(); + assertThat(newMember.getEmail()).isEqualTo("newuser@example.com"); + + // 소셜 계정이 생성되었는지 확인 + SocialAccount socialAccount = socialAccountRepository.findByProviderAndProviderUserId( + "google", "google123").orElseThrow(); + assertThat(socialAccount.getMember().getId()).isEqualTo(newMember.getId()); + } + + @Test + @DisplayName("기존 소셜 계정이 없는 경우 새로 생성한다") + void socialLoginCreateNewSocialAccount() { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("naver_access_token") + .refreshToken("naver_refresh_token") + .expiresIn(3600) + .tokenType("Bearer") + .provider("naver") + .providerUserId("naver456") + .email("test@example.com") + .build(); + + // when + AuthResultDto result = loginService.socialLogin(tokenDto); + + // then + assertThat(result.getMemberId()).isEqualTo(testMember.getId()); + assertThat(result.getProfileId()).isNull(); + assertThat(result.isNewUser()).isTrue(); + + // 새로운 소셜 계정이 생성되었는지 확인 + SocialAccount naverAccount = socialAccountRepository.findByProviderAndProviderUserId( + "naver", "naver456").orElseThrow(); + assertThat(naverAccount.getMember().getId()).isEqualTo(testMember.getId()); + assertThat(naverAccount.getProvider()).isEqualTo("naver"); + assertThat(naverAccount.getProviderUserId()).isEqualTo("naver456"); + } + + @Test + @DisplayName("프로필이 있는 회원의 소셜 로그인은 프로필 ID를 반환한다") + void socialLoginMemberWithProfile() { + // given + // memberWithProfile에 소셜 계정을 먼저 연결 + SocialAccount googleAccount = SocialAccount.builder() + .member(memberWithProfile) + .provider("google") + .providerUserId("google789") + .tokenType("Bearer") + .accessToken("google_access_token") + .refreshToken("google_refresh_token") + .tokenExpiresAt(LocalDateTime.now().plusHours(1)) + .build(); + socialAccountRepository.save(googleAccount); + + TokenDto tokenDto = TokenDto.builder() + .accessToken("updated_google_access_token") + .refreshToken("updated_google_refresh_token") + .expiresIn(3600) + .tokenType("Bearer") + .provider("google") + .providerUserId("google789") + .email("profile@example.com") + .build(); + + // when + AuthResultDto result = loginService.socialLogin(tokenDto); + + // then + assertThat(result.getMemberId()).isEqualTo(memberWithProfile.getId()); + assertThat(result.getProfileId()).isEqualTo(memberWithProfile.getMemberProfile().getId()); + assertThat(result.isNewUser()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..1ea2aac --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceAdditionalIntegrationTest.java @@ -0,0 +1,208 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.FoodCategoryRepository; +import com.stcom.smartmealtable.repository.MemberCategoryPreferenceRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MemberCategoryPreferenceServiceAdditionalIntegrationTest { + + @Autowired + private MemberCategoryPreferenceService preferenceService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository profileRepository; + + @Autowired + private FoodCategoryRepository categoryRepository; + + @Autowired + private MemberCategoryPreferenceRepository preferenceRepository; + + private Member member; + private MemberProfile profile; + private FoodCategory category1; + private FoodCategory category2; + private FoodCategory category3; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("preference_test@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + profile = MemberProfile.builder() + .member(member) + .nickName("취향테스터") + .type(MemberType.STUDENT) + .build(); + profileRepository.save(profile); + + category1 = new FoodCategory(); + ReflectionTestUtils.setField(category1, "name", "한식"); + categoryRepository.save(category1); + + category2 = new FoodCategory(); + ReflectionTestUtils.setField(category2, "name", "중식"); + categoryRepository.save(category2); + + category3 = new FoodCategory(); + ReflectionTestUtils.setField(category3, "name", "일식"); + categoryRepository.save(category3); + } + + @Test + @DisplayName("선호와 비선호 카테고리를 동시에 저장하고 조회할 수 있다") + void saveBothLikedAndDislikedPreferences() { + // given + List liked = Arrays.asList(category1.getId(), category2.getId()); + List disliked = Collections.singletonList(category3.getId()); + + // when + preferenceService.savePreferences(profile.getId(), liked, disliked); + List preferences = preferenceService.getPreferences(profile.getId()); + + // then + assertThat(preferences).hasSize(3); + + // 좋아하는 카테고리 + List likedPrefs = preferences.stream() + .filter(p -> p.getType() == PreferenceType.LIKE) + .toList(); + assertThat(likedPrefs).hasSize(2); + assertThat(likedPrefs.get(0).getCategory().getName()).isEqualTo("한식"); + assertThat(likedPrefs.get(1).getCategory().getName()).isEqualTo("중식"); + + // 싫어하는 카테고리 + List dislikedPrefs = preferences.stream() + .filter(p -> p.getType() == PreferenceType.DISLIKE) + .toList(); + assertThat(dislikedPrefs).hasSize(1); + assertThat(dislikedPrefs.get(0).getCategory().getName()).isEqualTo("일식"); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 선호도를 저장하면 예외가 발생한다") + void savePreferencesWithInvalidProfileId() { + // given + Long invalidProfileId = 9999L; + List liked = Collections.singletonList(category1.getId()); + List disliked = Collections.emptyList(); + + // when & then + assertThatThrownBy(() -> preferenceService.savePreferences(invalidProfileId, liked, disliked)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 카테고리 ID로 선호도를 저장하면 예외가 발생한다") + void savePreferencesWithInvalidCategoryId() { + // given + Long invalidCategoryId = 9999L; + List liked = Collections.singletonList(invalidCategoryId); + List disliked = Collections.emptyList(); + + // when & then + assertThatThrownBy(() -> preferenceService.savePreferences(profile.getId(), liked, disliked)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 카테고리입니다"); + } + + @Test + @DisplayName("선호도를 업데이트하면 기존 선호도가 모두 삭제되고 새로운 선호도가 저장된다") + void updatePreferences() { + // given + // 초기 선호도 설정 + preferenceService.savePreferences( + profile.getId(), + Collections.singletonList(category1.getId()), + Collections.singletonList(category2.getId()) + ); + + // 초기 상태 확인 + List initialPrefs = preferenceService.getPreferences(profile.getId()); + assertThat(initialPrefs).hasSize(2); + + // when + // 선호도 업데이트 - 완전히 반대로 변경 + preferenceService.savePreferences( + profile.getId(), + Collections.singletonList(category2.getId()), + Collections.singletonList(category1.getId()) + ); + + // then + List updatedPrefs = preferenceService.getPreferences(profile.getId()); + assertThat(updatedPrefs).hasSize(2); + + // 변경된 선호도 확인 + MemberCategoryPreference likedPref = updatedPrefs.stream() + .filter(p -> p.getType() == PreferenceType.LIKE) + .findFirst() + .orElseThrow(); + assertThat(likedPref.getCategory().getId()).isEqualTo(category2.getId()); + + MemberCategoryPreference dislikedPref = updatedPrefs.stream() + .filter(p -> p.getType() == PreferenceType.DISLIKE) + .findFirst() + .orElseThrow(); + assertThat(dislikedPref.getCategory().getId()).isEqualTo(category1.getId()); + } + + @Test + @DisplayName("선호도를 비우면 기존 선호도가 모두 삭제된다") + void clearPreferences() { + // given + // 초기 선호도 설정 + preferenceService.savePreferences( + profile.getId(), + Arrays.asList(category1.getId(), category2.getId()), + Collections.singletonList(category3.getId()) + ); + + // 초기 상태 확인 + List initialPrefs = preferenceService.getPreferences(profile.getId()); + assertThat(initialPrefs).hasSize(3); + + // when + // 선호도 비우기 + preferenceService.savePreferences( + profile.getId(), + Collections.emptyList(), + Collections.emptyList() + ); + + // then + List updatedPrefs = preferenceService.getPreferences(profile.getId()); + assertThat(updatedPrefs).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest2.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest2.java new file mode 100644 index 0000000..97c93b1 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceAdditionalIntegrationTest2.java @@ -0,0 +1,229 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.AddressEntityRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberProfileServiceAdditionalIntegrationTest2 { + + @Autowired + private MemberProfileService service; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository profileRepository; + + @Autowired + private AddressEntityRepository addressEntityRepository; + + private Member member; + private MemberProfile profile; + private AddressEntity addressEntity; + + @BeforeEach + void setUp() { + // 테스트용 회원 생성 + member = Member.builder() + .email("profile_additional2@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + // 프로필 생성 + profile = MemberProfile.builder() + .member(member) + .nickName("프로필유저") + .type(MemberType.STUDENT) + .build(); + profileRepository.save(profile); + + // 주소 생성 및 추가 + Address address = Address.builder() + .roadAddress("서울시 강남구") + .detailAddress("123번지") + .build(); + + service.saveNewAddress(profile.getId(), address, "집", AddressType.HOME); + + // 저장된 주소 조회 + profile = profileRepository.findById(profile.getId()).orElseThrow(); + addressEntity = profile.getAddressHistory().get(0); + } + + @Test + @DisplayName("주소 정보를 수정할 수 있다") + void changeAddress() { + // given + Address newAddress = Address.builder() + .roadAddress("서울시 서초구") + .detailAddress("456번지") + .build(); + String newAlias = "새집"; + AddressType newType = AddressType.OFFICE; + + // when + service.changeAddress(profile.getId(), addressEntity.getId(), newAddress, newAlias, newType); + + // then + MemberProfile updatedProfile = profileRepository.findById(profile.getId()).orElseThrow(); + AddressEntity updatedAddressEntity = updatedProfile.getAddressHistory().get(0); + + assertThat(updatedAddressEntity.getAddress().getRoadAddress()).isEqualTo("서울시 서초구"); + assertThat(updatedAddressEntity.getAddress().getDetailAddress()).isEqualTo("456번지"); + assertThat(updatedAddressEntity.getAlias()).isEqualTo("새집"); + assertThat(updatedAddressEntity.getType()).isEqualTo(AddressType.OFFICE); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 getProfileFetch 호출 시 예외가 발생한다") + void getProfileFetchWithInvalidId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> service.getProfileFetch(invalidProfileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 프로필 생성 시 예외가 발생한다") + void createProfileWithInvalidMemberId() { + // given + Long invalidMemberId = 99999L; + + // when & then + assertThatThrownBy(() -> service.createProfile("테스트", invalidMemberId, MemberType.STUDENT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 프로필 변경 시 예외가 발생한다") + void changeProfileWithInvalidId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> service.changeProfile(invalidProfileId, "변경", MemberType.WORKER, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 기본 주소 변경 시 예외가 발생한다") + void changeAddressToPrimaryWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> service.changeAddressToPrimary(invalidProfileId, addressEntity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 주소 ID로 기본 주소 변경 시 예외가 발생한다") + void changeAddressToPrimaryWithInvalidAddressId() { + // given + Long invalidAddressId = 99999L; + + // when & then + assertThatThrownBy(() -> service.changeAddressToPrimary(profile.getId(), invalidAddressId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원 주소 정보입니다."); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 주소 생성 시 예외가 발생한다") + void saveNewAddressWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + Address address = Address.builder() + .roadAddress("서울시") + .detailAddress("123번지") + .build(); + + // when & then + assertThatThrownBy(() -> service.saveNewAddress(invalidProfileId, address, "집", AddressType.HOME)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 주소 변경 시 예외가 발생한다") + void changeAddressWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + Address address = Address.builder() + .roadAddress("서울시") + .detailAddress("123번지") + .build(); + + // when & then + assertThatThrownBy(() -> service.changeAddress(invalidProfileId, addressEntity.getId(), address, "집", AddressType.HOME)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 주소 ID로 주소 변경 시 예외가 발생한다") + void changeAddressWithInvalidAddressId() { + // given + Long invalidAddressId = 99999L; + Address address = Address.builder() + .roadAddress("서울시") + .detailAddress("123번지") + .build(); + + // when & then + assertThatThrownBy(() -> service.changeAddress(profile.getId(), invalidAddressId, address, "집", AddressType.HOME)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원 주소 정보입니다."); + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 주소 삭제 시 예외가 발생한다") + void deleteAddressWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> service.deleteAddress(invalidProfileId, addressEntity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 주소 ID로 주소 삭제 시 예외가 발생한다") + void deleteAddressWithInvalidAddressId() { + // given + Long invalidAddressId = 99999L; + + // when & then + assertThatThrownBy(() -> service.deleteAddress(profile.getId(), invalidAddressId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원 주소 정보입니다."); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceCompleteIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceCompleteIntegrationTest.java new file mode 100644 index 0000000..a556d61 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceCompleteIntegrationTest.java @@ -0,0 +1,295 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.AddressEntityRepository; +import com.stcom.smartmealtable.repository.GroupRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MemberProfileServiceCompleteIntegrationTest { + + @Autowired + private MemberProfileService memberProfileService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + @Autowired + private GroupRepository groupRepository; + + @Autowired + private AddressEntityRepository addressEntityRepository; + + private Member member; + private Group group; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("profile_complete_test@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + Address groupAddress = Address.builder() + .lotNumberAddress("서울시 강남구") + .roadAddress("테헤란로 123") + .detailAddress("456번지") + .build(); + group = new SchoolGroup(groupAddress, "테스트학교", SchoolType.UNIVERSITY_FOUR_YEAR); + groupRepository.save(group); + } + + @Test + @DisplayName("새로운 프로필을 생성할 수 있다") + void createProfile() { + // given + String nickName = "새프로필"; + MemberType type = MemberType.STUDENT; + + // when + memberProfileService.createProfile(nickName, member.getId(), type, group.getId()); + + // then + MemberProfile created = memberProfileRepository.findAll().stream() + .filter(p -> p.getMember().getId().equals(member.getId())) + .findFirst() + .orElseThrow(); + + assertThat(created.getNickName()).isEqualTo(nickName); + assertThat(created.getType()).isEqualTo(type); + assertThat(created.getGroup().getId()).isEqualTo(group.getId()); + } + + @Test + @DisplayName("그룹 없이도 프로필을 생성할 수 있다") + void createProfileWithoutGroup() { + // given + String nickName = "그룹없는프로필"; + MemberType type = MemberType.WORKER; + + // when + memberProfileService.createProfile(nickName, member.getId(), type, null); + + // then + MemberProfile created = memberProfileRepository.findAll().stream() + .filter(p -> p.getMember().getId().equals(member.getId())) + .findFirst() + .orElseThrow(); + + assertThat(created.getNickName()).isEqualTo(nickName); + assertThat(created.getType()).isEqualTo(type); + assertThat(created.getGroup()).isNull(); + } + + @Test + @DisplayName("존재하지 않는 회원으로 프로필을 생성하면 예외가 발생한다") + void createProfileWithInvalidMemberId() { + // given + Long invalidMemberId = 99999L; + + // when & then + assertThatThrownBy(() -> memberProfileService.createProfile("닉네임", invalidMemberId, MemberType.STUDENT, group.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("새로운 주소를 저장할 수 있다") + void saveNewAddress() { + // given + MemberProfile profile = createTestProfile(); + Address address = Address.builder() + .lotNumberAddress("부산시 해운대구") + .roadAddress("센텀로 100") + .detailAddress("101동 102호") + .build(); + String alias = "집"; + AddressType addressType = AddressType.HOME; + + // when + memberProfileService.saveNewAddress(profile.getId(), address, alias, addressType); + + // then + MemberProfile updatedProfile = memberProfileRepository.findById(profile.getId()).orElseThrow(); + assertThat(updatedProfile.getAddressHistory()).hasSize(1); + + AddressEntity savedAddress = updatedProfile.getAddressHistory().get(0); + assertThat(savedAddress.getAddress().getLotNumberAddress()).isEqualTo("부산시 해운대구"); + assertThat(savedAddress.getAlias()).isEqualTo(alias); + assertThat(savedAddress.getType()).isEqualTo(addressType); + } + + @Test + @DisplayName("주소를 기본 주소로 설정할 수 있다") + void changeAddressToPrimary() { + // given + MemberProfile profile = createTestProfile(); + AddressEntity addressEntity = createTestAddress(profile); + + // when + memberProfileService.changeAddressToPrimary(profile.getId(), addressEntity.getId()); + + // then + MemberProfile updatedProfile = memberProfileRepository.findById(profile.getId()).orElseThrow(); + assertThat(updatedProfile.findPrimaryAddress().getId()).isEqualTo(addressEntity.getId()); + } + + @Test + @DisplayName("존재하지 않는 프로필로 주소 관련 작업을 하면 예외가 발생한다") + void addressOperationsWithInvalidProfileId() { + // given + Long invalidProfileId = 99999L; + Address address = Address.builder() + .lotNumberAddress("테스트시") + .roadAddress("테스트로") + .detailAddress("테스트빌딩") + .build(); + + // when & then + assertThatThrownBy(() -> memberProfileService.saveNewAddress(invalidProfileId, address, "별칭", AddressType.HOME)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + + assertThatThrownBy(() -> memberProfileService.changeAddressToPrimary(invalidProfileId, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 주소로 기본 주소 설정을 하면 예외가 발생한다") + void changeAddressToPrimaryWithInvalidAddressId() { + // given + MemberProfile profile = createTestProfile(); + Long invalidAddressId = 99999L; + + // when & then + assertThatThrownBy(() -> memberProfileService.changeAddressToPrimary(profile.getId(), invalidAddressId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원 주소 정보입니다."); + } + + @Test + @DisplayName("존재하지 않는 주소 엔티티로 주소 수정을 하면 예외가 발생한다") + void changeAddressWithInvalidAddressEntityId() { + // given + MemberProfile profile = createTestProfile(); + Long invalidAddressEntityId = 99999L; + Address newAddress = Address.builder() + .lotNumberAddress("새주소시") + .roadAddress("새주소로") + .detailAddress("새주소빌딩") + .build(); + + // when & then + assertThatThrownBy(() -> memberProfileService.changeAddress( + profile.getId(), invalidAddressEntityId, newAddress, "새별칭", AddressType.OFFICE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원 주소 정보입니다."); + } + + @Test + @DisplayName("존재하지 않는 주소 엔티티로 주소 삭제를 하면 예외가 발생한다") + void deleteAddressWithInvalidAddressEntityId() { + // given + MemberProfile profile = createTestProfile(); + Long invalidAddressEntityId = 99999L; + + // when & then + assertThatThrownBy(() -> memberProfileService.deleteAddress(profile.getId(), invalidAddressEntityId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원 주소 정보입니다."); + } + + @Test + @DisplayName("프로필 조회 시 존재하지 않는 프로필 ID로 조회하면 예외가 발생한다") + void getProfileFetchWithInvalidId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> memberProfileService.getProfileFetch(invalidProfileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("프로필을 정상적으로 조회할 수 있다") + void getProfileFetch() { + // given + MemberProfile profile = createTestProfile(); + + // when + MemberProfile retrieved = memberProfileService.getProfileFetch(profile.getId()); + + // then + assertThat(retrieved.getId()).isEqualTo(profile.getId()); + assertThat(retrieved.getNickName()).isEqualTo(profile.getNickName()); + assertThat(retrieved.getType()).isEqualTo(profile.getType()); + } + + @Test + @DisplayName("프로필 변경 시 존재하지 않는 프로필 ID로 시도하면 예외가 발생한다") + void changeProfileWithInvalidId() { + // given + Long invalidProfileId = 99999L; + + // when & then + assertThatThrownBy(() -> memberProfileService.changeProfile( + invalidProfileId, "새닉네임", MemberType.WORKER, group.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + private MemberProfile createTestProfile() { + MemberProfile profile = MemberProfile.builder() + .nickName("테스트프로필") + .member(member) + .type(MemberType.STUDENT) + .group(group) + .build(); + return memberProfileRepository.save(profile); + } + + private AddressEntity createTestAddress(MemberProfile profile) { + Address address = Address.builder() + .lotNumberAddress("서울시") + .roadAddress("테스트로") + .detailAddress("테스트빌딩") + .build(); + AddressEntity addressEntity = AddressEntity.builder() + .address(address) + .alias("테스트주소") + .type(AddressType.HOME) + .build(); + + AddressEntity saved = addressEntityRepository.save(addressEntity); + profile.addAddress(saved); + memberProfileRepository.save(profile); + return saved; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..49df970 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberServiceAdditionalIntegrationTest.java @@ -0,0 +1,121 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.repository.MemberRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class MemberServiceAdditionalIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + private Member savedMember; + + @BeforeEach + void setUp() { + // 테스트에 사용할 회원 데이터 저장 + Member member = Member.builder() + .fullName("테스트회원") + .email("additional_test@example.com") + .rawPassword("Password123!") + .build(); + savedMember = memberRepository.save(member); + } + + @Test + @DisplayName("ID로 회원을 찾을 수 있다") + void findMemberByMemberId() { + // when + Member foundMember = memberService.findMemberByMemberId(savedMember.getId()); + + // then + assertThat(foundMember).isNotNull(); + assertThat(foundMember.getId()).isEqualTo(savedMember.getId()); + assertThat(foundMember.getEmail()).isEqualTo("additional_test@example.com"); + } + + @Test + @DisplayName("존재하지 않는 ID로 회원 조회 시 예외가 발생한다") + void findMemberByMemberIdThrowsExceptionWhenIdNotExists() { + // given + Long nonExistentId = 99999L; + + // when & then + assertThatThrownBy(() -> memberService.findMemberByMemberId(nonExistentId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("비밀번호 확인이 일치하면 정상 처리된다") + void checkPasswordDoublySuccess() { + // when & then + // 예외가 발생하지 않으면 테스트 성공 + memberService.checkPasswordDoubly("Password123!", "Password123!"); + } + + @Test + @DisplayName("비밀번호 확인이 일치하지 않으면 예외가 발생한다") + void checkPasswordDoublyThrowsExceptionWhenPasswordsDoNotMatch() { + // given + String password = "Password123!"; + String confirmPassword = "DifferentPassword123!"; + + // when & then + assertThatThrownBy(() -> memberService.checkPasswordDoubly(password, confirmPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("검증 비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("중복되지 않은 이메일은 유효성 검증을 통과한다") + void validateDuplicatedEmailSuccess() { + // given + String uniqueEmail = "unique_email@example.com"; + + // when & then + // 예외가 발생하지 않으면 테스트 성공 + memberService.validateDuplicatedEmail(uniqueEmail); + } + + @Test + @DisplayName("비밀번호 변경 시 원래 비밀번호가 일치하지 않으면 예외가 발생한다") + void changePasswordThrowsExceptionWhenOriginalPasswordDoesNotMatch() { + // given + String wrongOriginalPassword = "WrongPassword123!"; + String newPassword = "NewPassword123!"; + + // when & then + assertThatThrownBy(() -> memberService.changePassword(savedMember.getId(), wrongOriginalPassword, newPassword)) + .isInstanceOf(PasswordFailedExceededException.class) + .hasMessage("기존 비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 회원 삭제 시 예외가 발생한다") + void deleteByMemberIdThrowsExceptionWhenIdNotExists() { + // given + Long nonExistentId = 99999L; + + // when & then + assertThatThrownBy(() -> memberService.deleteByMemberId(nonExistentId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("회원이 존재하지 않습니다"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceCompleteIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceCompleteIntegrationTest.java new file mode 100644 index 0000000..15851e6 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceCompleteIntegrationTest.java @@ -0,0 +1,255 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class SocialAccountServiceCompleteIntegrationTest { + + @Autowired + private SocialAccountService socialAccountService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SocialAccountRepository socialAccountRepository; + + private Member testMember; + private SocialAccount kakaoAccount; + private SocialAccount naverAccount; + + @BeforeEach + void setUp() { + testMember = new Member("test@example.com"); + memberRepository.save(testMember); + + kakaoAccount = SocialAccount.builder() + .member(testMember) + .provider("kakao") + .providerUserId("kakao123") + .tokenType("Bearer") + .accessToken("kakao_access_token") + .refreshToken("kakao_refresh_token") + .tokenExpiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + naverAccount = SocialAccount.builder() + .member(testMember) + .provider("naver") + .providerUserId("naver456") + .tokenType("Bearer") + .accessToken("naver_access_token") + .refreshToken("naver_refresh_token") + .tokenExpiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + socialAccountRepository.save(kakaoAccount); + socialAccountRepository.save(naverAccount); + } + + @Test + @DisplayName("새로운 회원과 소셜 계정을 생성할 수 있다") + void createNewMemberAndLinkSocialAccount() { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("google_access_token") + .refreshToken("google_refresh_token") + .expiresIn(3600) + .tokenType("Bearer") + .provider("google") + .providerUserId("google123") + .email("newuser@example.com") + .build(); + + // when + socialAccountService.createNewMemberAndLinkSocialAccount(tokenDto); + + // then + Member newMember = memberRepository.findByEmail("newuser@example.com").orElseThrow(); + assertThat(newMember.getEmail()).isEqualTo("newuser@example.com"); + + SocialAccount socialAccount = socialAccountRepository.findByProviderAndProviderUserId( + "google", "google123").orElseThrow(); + assertThat(socialAccount.getProvider()).isEqualTo("google"); + assertThat(socialAccount.getProviderUserId()).isEqualTo("google123"); + assertThat(socialAccount.getMember().getId()).isEqualTo(newMember.getId()); + } + + @Test + @DisplayName("기존 회원에게 소셜 계정을 연결할 수 있다") + void linkSocialAccount() { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("google_access_token_2") + .refreshToken("google_refresh_token_2") + .expiresIn(3600) + .tokenType("Bearer") + .provider("google") + .providerUserId("google789") + .email(testMember.getEmail()) + .build(); + + // when + socialAccountService.linkSocialAccount(tokenDto); + + // then + SocialAccount linkedAccount = socialAccountRepository.findByProviderAndProviderUserId( + "google", "google789").orElseThrow(); + assertThat(linkedAccount.getProvider()).isEqualTo("google"); + assertThat(linkedAccount.getProviderUserId()).isEqualTo("google789"); + assertThat(linkedAccount.getMember().getId()).isEqualTo(testMember.getId()); + } + + @Test + @DisplayName("존재하지 않는 회원으로 소셜 계정을 연결하려고 하면 예외가 발생한다") + void linkSocialAccountWithNonExistentMember() { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("google_access_token_3") + .refreshToken("google_refresh_token_3") + .expiresIn(3600) + .tokenType("Bearer") + .provider("google") + .providerUserId("google999") + .email("nonexistent@example.com") + .build(); + + // when & then + assertThatThrownBy(() -> socialAccountService.linkSocialAccount(tokenDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("회원 엔티티가 존재하지 않은 상태로 소셜 계정 연결을 시도했습니다."); + } + + @Test + @DisplayName("소셜 계정을 조회할 수 있다") + void findSocialAccount() { + // when + SocialAccount foundAccount = socialAccountService.findSocialAccount("kakao", "kakao123"); + + // then + assertThat(foundAccount).isNotNull(); + assertThat(foundAccount.getProvider()).isEqualTo("kakao"); + assertThat(foundAccount.getProviderUserId()).isEqualTo("kakao123"); + assertThat(foundAccount.getMember().getId()).isEqualTo(testMember.getId()); + } + + @Test + @DisplayName("존재하지 않는 소셜 계정을 조회하면 null을 반환한다") + void findSocialAccountNotFound() { + // when + SocialAccount foundAccount = socialAccountService.findSocialAccount("twitter", "twitter123"); + + // then + assertThat(foundAccount).isNull(); + } + + @Test + @DisplayName("신규 사용자인지 확인할 수 있다") + void isNewUser() { + // when + boolean isNew = socialAccountService.isNewUser("twitter", "twitter123"); + + // then + assertThat(isNew).isTrue(); + } + + @Test + @DisplayName("기존 사용자인지 확인할 수 있다") + void isExistingUser() { + // when + boolean isNew = socialAccountService.isNewUser("kakao", "kakao123"); + + // then + assertThat(isNew).isFalse(); + } + + @Test + @DisplayName("토큰을 업데이트할 수 있다") + void updateToken() { + // given + String newAccessToken = "new_kakao_access_token"; + String newRefreshToken = "new_kakao_refresh_token"; + LocalDateTime newExpiresAt = LocalDateTime.now().plusHours(2); + + // when + socialAccountService.updateToken(kakaoAccount.getId(), newAccessToken, newRefreshToken, newExpiresAt); + + // then + SocialAccount updatedAccount = socialAccountRepository.findById(kakaoAccount.getId()).orElseThrow(); + assertThat(updatedAccount.getAccessToken()).isEqualTo(newAccessToken); + assertThat(updatedAccount.getRefreshToken()).isEqualTo(newRefreshToken); + assertThat(updatedAccount.getTokenExpiresAt()).isEqualTo(newExpiresAt); + } + + @Test + @DisplayName("존재하지 않는 소셜 계정의 토큰을 업데이트하려고 하면 예외가 발생한다") + void updateTokenWithInvalidAccountId() { + // given + Long invalidAccountId = 99999L; + String accessToken = "test_token"; + String refreshToken = "test_refresh"; + LocalDateTime expiresAt = LocalDateTime.now(); + + // when & then + assertThatThrownBy(() -> socialAccountService.updateToken(invalidAccountId, accessToken, refreshToken, expiresAt)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("확인되지 않은 계정입니다"); + } + + @Test + @DisplayName("회원의 모든 소셜 제공자를 조회할 수 있다") + void findAllProviders() { + // when + List providers = socialAccountService.findAllProviders(testMember.getId()); + + // then + assertThat(providers).hasSize(2); + assertThat(providers).containsExactlyInAnyOrder("kakao", "naver"); + } + + @Test + @DisplayName("소셜 계정이 없는 회원의 제공자 목록은 빈 리스트를 반환한다") + void findAllProvidersForMemberWithNoSocialAccounts() { + // given + Member memberWithoutSocial = new Member("nosocial@example.com"); + memberRepository.save(memberWithoutSocial); + + // when + List providers = socialAccountService.findAllProviders(memberWithoutSocial.getId()); + + // then + assertThat(providers).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 회원의 제공자를 조회하면 빈 리스트를 반환한다") + void findAllProvidersForNonExistentMember() { + // given + Long nonExistentMemberId = 99999L; + + // when + List providers = socialAccountService.findAllProviders(nonExistentMemberId); + + // then + assertThat(providers).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/TermServiceAdditionalIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/TermServiceAdditionalIntegrationTest.java new file mode 100644 index 0000000..b9bd8e3 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/TermServiceAdditionalIntegrationTest.java @@ -0,0 +1,205 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.domain.term.TermAgreement; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.TermAgreementRepository; +import com.stcom.smartmealtable.repository.TermRepository; +import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class TermServiceAdditionalIntegrationTest { + + @Autowired + private TermService termService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TermRepository termRepository; + + @Autowired + private TermAgreementRepository termAgreementRepository; + + private Member member; + private Term requiredTerm; + private Term optionalTerm; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("term_test@example.com") + .rawPassword("password123!") + .build(); + memberRepository.save(member); + + requiredTerm = createTerm("필수 약관", true); + optionalTerm = createTerm("선택 약관", false); + termRepository.saveAll(Arrays.asList(requiredTerm, optionalTerm)); + } + + @Test + @DisplayName("필수 약관에 동의하지 않으면 예외가 발생한다") + void agreeTermsWithoutRequiredTerms() { + // given + List requests = Collections.singletonList( + new TermAgreementRequestDto(requiredTerm.getId(), false) + ); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(member.getId(), requests)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("필수 약관에 동의해야 합니다"); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 약관 동의를 시도하면 예외가 발생한다") + void agreeTermsWithInvalidMemberId() { + // given + Long invalidMemberId = 9999L; + List requests = Collections.singletonList( + new TermAgreementRequestDto(requiredTerm.getId(), true) + ); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(invalidMemberId, requests)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("존재하지 않는 약관 ID로 약관 동의를 시도하면 예외가 발생한다") + void agreeTermsWithInvalidTermId() { + // given + Long invalidTermId = 9999L; + + // 모든 필수 약관에 동의하는 리스트를 만든 다음 존재하지 않는 약관 ID를 추가 + List requests = new java.util.ArrayList<>(); + // 필수 약관에 먼저 동의 (필수 약관 검증을 통과하기 위해) + requests.add(new TermAgreementRequestDto(requiredTerm.getId(), true)); + // 존재하지 않는 약관 ID 추가 + requests.add(new TermAgreementRequestDto(invalidTermId, true)); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(member.getId(), requests)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 약관입니다"); + } + + @Test + @DisplayName("필수 약관을 포함하지 않고 약관 동의를 시도하면 예외가 발생한다") + void agreeTermsWithMissingRequiredTerm() { + // given + // 선택 약관만 동의 + List requests = Collections.singletonList( + new TermAgreementRequestDto(optionalTerm.getId(), true) + ); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(member.getId(), requests)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("필수 약관에 동의해야 합니다"); + } + + @Test + @DisplayName("모든 약관에 동의하면 정상적으로 저장된다") + void agreeAllTerms() { + // given + List requests = Arrays.asList( + new TermAgreementRequestDto(requiredTerm.getId(), true), + new TermAgreementRequestDto(optionalTerm.getId(), true) + ); + + // when + termService.agreeTerms(member.getId(), requests); + + // then + List agreements = termAgreementRepository.findAll().stream() + .filter(a -> a.getMember().getId().equals(member.getId())) + .collect(Collectors.toList()); + assertThat(agreements).hasSize(2); + assertThat(agreements.stream().allMatch(TermAgreement::getIsAgreed)).isTrue(); + } + + @Test + @DisplayName("필수 약관에만 동의하고 선택 약관에는 동의하지 않아도 정상적으로 저장된다") + void agreeRequiredTermsOnly() { + // given + List requests = Arrays.asList( + new TermAgreementRequestDto(requiredTerm.getId(), true), + new TermAgreementRequestDto(optionalTerm.getId(), false) + ); + + // when + termService.agreeTerms(member.getId(), requests); + + // then + List agreements = termAgreementRepository.findAll().stream() + .filter(a -> a.getMember().getId().equals(member.getId())) + .collect(Collectors.toList()); + assertThat(agreements).hasSize(2); + + TermAgreement requiredAgreement = agreements.stream() + .filter(a -> a.getTerm().getId().equals(requiredTerm.getId())) + .findFirst() + .orElseThrow(); + assertThat(requiredAgreement.getIsAgreed()).isTrue(); + + TermAgreement optionalAgreement = agreements.stream() + .filter(a -> a.getTerm().getId().equals(optionalTerm.getId())) + .findFirst() + .orElseThrow(); + assertThat(optionalAgreement.getIsAgreed()).isFalse(); + } + + @Test + @DisplayName("모든 약관 목록을 조회할 수 있다") + void findAllTerms() { + // given + // 추가 약관 생성 + Term additionalTerm = createTerm("추가 약관", true); + termRepository.save(additionalTerm); + + // when + List terms = termService.findAll(); + + // then + assertThat(terms).hasSize(3); + assertThat(terms.stream().map(Term::getTitle).toList()) + .contains("필수 약관", "선택 약관", "추가 약관"); + } + + private Term createTerm(String title, boolean required) { + Term term = new Term(); + try { + java.lang.reflect.Field titleField = Term.class.getDeclaredField("title"); + titleField.setAccessible(true); + titleField.set(term, title); + + java.lang.reflect.Field isRequiredField = Term.class.getDeclaredField("isRequired"); + isRequiredField.setAccessible(true); + isRequiredField.set(term, required); + } catch (Exception e) { + throw new RuntimeException(e); + } + return term; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java index ef69701..4ac44f5 100644 --- a/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java +++ b/src/test/java/com/stcom/smartmealtable/service/TermServiceIntegrationTest.java @@ -13,12 +13,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; /** * 통합 테스트: 스프링 컨텍스트와 실제 JPA 구현체로 Service 계층 검증. */ @SpringBootTest +@ActiveProfiles("test") @Transactional class TermServiceIntegrationTest { diff --git a/src/test/java/com/stcom/smartmealtable/service/exception/ResourceNotFoundException.java b/src/test/java/com/stcom/smartmealtable/service/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/exception/ResourceNotFoundException.java @@ -0,0 +1 @@ + \ No newline at end of file From ff0ca7a3c2557cfb410aab1a055f76fdaad29734 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:51:16 +0900 Subject: [PATCH 24/44] =?UTF-8?q?chore:=20Spring=20AI=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build.gradle b/build.gradle index 5e1b84f..b028841 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,10 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } +ext { + springAiVersion = "1.0.0" +} + group = 'com.stcom' version = '0.0.1-SNAPSHOT' @@ -33,6 +37,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' + implementation 'org.springframework.ai:spring-ai-bom:1.0.0' + implementation 'org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 직렬화/역직렬화에 Jackson 사용 compileOnly 'org.projectlombok:lombok' @@ -51,4 +57,10 @@ tasks.named('test') { jar { enabled = false +} + +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" + } } \ No newline at end of file From 289b268ab453a8d059c07adafc44c7f1e653e21c Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:51:57 +0900 Subject: [PATCH 25/44] =?UTF-8?q?feat:=20=EA=B5=AD=EB=AF=BC=EC=9D=80?= =?UTF-8?q?=ED=96=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creditmessage/KBCreditMessageParser.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParser.java diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParser.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParser.java new file mode 100644 index 0000000..2f1e330 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParser.java @@ -0,0 +1,50 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class KBCreditMessageParser implements CreditMessageParser { + + private static final Pattern KB_PATTERN = Pattern.compile( + // 1: MM/dd, 2: HH:mm, 3: amount, 4: trade name + "\\[KB국민카드]\\s*(\\d{2}/\\d{2})\\s*(\\d{2}:\\d{2})\\s*승인\\s*([\\d,]+)원\\s*[가-힣A-Za-z]*\\s*(.+)" + ); + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"); + + @Override + public boolean checkVendor(String message) { + // 메시지에 "KB"가 포함되어 있는지 확인 + return message != null && message.contains("KB"); + } + + @Override + public ExpenditureDto parse(String message) { + if (message == null) { + throw new IllegalArgumentException("메시지가 비어있습니다."); + } + + Matcher matcher = KB_PATTERN.matcher(message); + if (!matcher.find()) { + throw new IllegalArgumentException("올바르지 않은 메시지 포맷입니다. " + message); + } + + String datePart = matcher.group(1); + String timePart = matcher.group(2); + String amountPart = matcher.group(3).replace(",", ""); // “11,000” → “11000” + String tradeName = matcher.group(4).trim(); + + int currentYear = LocalDate.now().getYear(); + LocalDateTime dateTime = LocalDateTime.parse( + currentYear + "/" + datePart + " " + timePart, FORMATTER + ); + + long amount = Long.parseLong(amountPart); + + return new ExpenditureDto("KB", dateTime, amount, tradeName); + } +} From 797c8e8d1fa8ab5b67e0bb58ca777ad5a4db2806 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:52:03 +0900 Subject: [PATCH 26/44] =?UTF-8?q?feat:=20=EC=8B=A0=ED=95=9C=EC=9D=80?= =?UTF-8?q?=ED=96=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creditmessage/SHCreditMessageParser.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParser.java diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParser.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParser.java new file mode 100644 index 0000000..8eb97f7 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParser.java @@ -0,0 +1,46 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SHCreditMessageParser implements CreditMessageParser { + + private static final Pattern SH_PATTERN = Pattern.compile( + "신한카드.*?승인.*?([\\d,]+)원(?:\\([^)]*\\))?\\s*(\\d{2}/\\d{2})\\s*(\\d{2}:\\d{2})\\s+(.+?)(?:\\s+(?:누적|잔여).*)?$" + ); + + private static final DateTimeFormatter FMT = + DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"); + + @Override + public boolean checkVendor(String message) { + return message != null && message.contains("신한카드"); + } + + @Override + public ExpenditureDto parse(String message) { + if (message == null) { + throw new IllegalArgumentException("메시지가 비어 있습니다."); + } + + Matcher m = SH_PATTERN.matcher(message); + if (!m.find()) { + throw new IllegalArgumentException("신한카드 SMS 형식을 인식하지 못했습니다: " + message); + } + + long amount = Long.parseLong(m.group(1).replace(",", "")); + int year = LocalDate.now().getYear(); + LocalDateTime dateTime = LocalDateTime.parse( + year + "/" + m.group(2) + " " + m.group(3), FMT + ); + + String tradeName = m.group(4).trim() + .replaceAll("\\s*(누적|잔여|잔액).*", "") // 혹시 남은 잔여표기가 끼어들면 제거 + .trim(); + + return new ExpenditureDto("SH", dateTime, amount, tradeName); + } +} From 25c51e065b591fad3ea24a65f6e9e39b9f4a56e3 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:52:08 +0900 Subject: [PATCH 27/44] =?UTF-8?q?feat:=20=EB=86=8D=ED=98=91=EC=9D=80?= =?UTF-8?q?=ED=96=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creditmessage/CreditMessageParser.java | 9 ++++ .../creditmessage/ExpenditureDto.java | 18 ++++++++ .../creditmessage/NHCreditMessageParser.java | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageParser.java create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParser.java diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageParser.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageParser.java new file mode 100644 index 0000000..2d0faf1 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageParser.java @@ -0,0 +1,9 @@ +package com.stcom.smartmealtable.component.creditmessage; + +public interface CreditMessageParser { + + boolean checkVendor(String message); + + ExpenditureDto parse(String message); + +} diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java new file mode 100644 index 0000000..a0faf68 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java @@ -0,0 +1,18 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.ToString; + +@Data +@AllArgsConstructor +@ToString +public class ExpenditureDto { + + private String vendor; + private LocalDateTime dateTime; + private Long amount; + private String tradeName; + +} diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParser.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParser.java new file mode 100644 index 0000000..860dfa4 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParser.java @@ -0,0 +1,43 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class NHCreditMessageParser implements CreditMessageParser { + + private static final Pattern NH_PATTERN = Pattern.compile( + "NH(?:농협)?카드.*?승인.*?([\\d,]+)원.*?(\\d{2}/\\d{2})\\s*(\\d{2}:\\d{2})\\s+(.+?)(?:\\s+(?:총누적|잔여).*)?$" + ); + + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"); + + @Override + public boolean checkVendor(String message) { + return message != null && (message.contains("NH") || message.contains("농협")); + } + + @Override + public ExpenditureDto parse(String message) { + if (message == null) { + throw new IllegalArgumentException("메시지가 비어 있습니다."); + } + + Matcher m = NH_PATTERN.matcher(message); + if (!m.find()) { + throw new IllegalArgumentException("농협카드 SMS 형식을 인식하지 못했습니다: " + message); + } + + long amount = Long.parseLong(m.group(1).replace(",", "")); + int thisYear = LocalDate.now().getYear(); + LocalDateTime dateTime = LocalDateTime.parse( + thisYear + "/" + m.group(2) + " " + m.group(3), FMT + ); + + String trade = m.group(4).trim(); + + return new ExpenditureDto("NH", dateTime, amount, trade); + } +} From c1f44f9562e7acf55203ed9182e4b7587566c05f Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:54:22 +0900 Subject: [PATCH 28/44] =?UTF-8?q?feat:=20AI=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=ED=8C=8C=EC=8B=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GeminiCreditMessageParser.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParser.java diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParser.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParser.java new file mode 100644 index 0000000..663c901 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParser.java @@ -0,0 +1,61 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClient.Builder; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class GeminiCreditMessageParser implements CreditMessageParser { + + private final Builder chatClientBuilder; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public boolean checkVendor(String message) { + return true; + } + + @Override + public ExpenditureDto parse(String message) { + ChatClient chatClient = chatClientBuilder.build(); + + String prompt = String.format(""" + 너는 대한민국의 신용카드 승인 문자(SMS)를 파싱해서 JSON 형태로 반환하는 전문가야. + 반드시 아래 형식의 JSON 만 출력해. 설명 문구나 코드 블록 표시(```)는 절대 포함하면 안 돼. + + { + \"vendor\": \"<카드사 영문 약어, 예: KB, NH, SH, UNKNOWN>\", + \"dateTime\": \"\", + \"amount\": <숫자형 원화 금액>, + \"tradeName\": \"<가맹점명>\" + } + + 다음은 파싱 대상 SMS 원문이다: + %s + """, message); + + String jsonResponse = chatClient.prompt() + .user(prompt) + .call() + .content(); + try { + JsonNode root = MAPPER.readTree(jsonResponse); + String vendor = root.path("vendor").asText("UNKNOWN"); + String dateTimeStr = root.path("dateTime").asText(); + long amount = root.path("amount").asLong(); + String tradeName = root.path("tradeName").asText(); + + LocalDateTime dateTime = LocalDateTime.parse(dateTimeStr); + return new ExpenditureDto(vendor, dateTime, amount, tradeName); + } catch (Exception e) { + throw new IllegalArgumentException("Gemini 파싱 실패: " + e.getMessage(), e); + } + } +} \ No newline at end of file From 243124c05837f49aee7fdd8a5bc0e85a9d240976 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:54:36 +0900 Subject: [PATCH 29/44] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creditmessage/CreditMessageManager.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManager.java diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManager.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManager.java new file mode 100644 index 0000000..99e3e9a --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManager.java @@ -0,0 +1,37 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CreditMessageManager { + + private final GeminiCreditMessageParser geminiParser; + + private final Map parsers = Map.of( + "KB", new KBCreditMessageParser(), + "NH", new NHCreditMessageParser(), + "SH", new SHCreditMessageParser() + ); + + public ExpenditureDto parseMessage(String message) { + if (message == null || message.isEmpty()) { + throw new IllegalArgumentException("메시지가 비어 있습니다."); + } + + for (CreditMessageParser parser : parsers.values()) { + if (parser.checkVendor(message)) { + try { + return parser.parse(message); + } catch (Exception ignore) { + // 룰 기반 파싱 실패 – Gemini 로 fallback + break; + } + } + } + + return geminiParser.parse(message); + } +} From ccf4323c98d631537744fa1fa24049efef1e4a54 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 16:59:02 +0900 Subject: [PATCH 30/44] =?UTF-8?q?test:=20=EA=B2=B0=EC=A0=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=8C=8C=EC=8B=B1=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreditMessageManagerTest.java | 72 +++++++++++++++++++ .../GeminiCreditMessageParserTest.java | 55 ++++++++++++++ .../KBCreditMessageParserTest.java | 46 ++++++++++++ .../NHCreditMessageParserTest.java | 47 ++++++++++++ .../SHCreditMessageParserTest.java | 47 ++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 src/test/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManagerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManagerTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManagerTest.java new file mode 100644 index 0000000..a2de592 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/CreditMessageManagerTest.java @@ -0,0 +1,72 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.chat.client.ChatClient; + +class CreditMessageManagerTest { + + private ChatClient.Builder builder; + private ChatClient chatClient; + private GeminiCreditMessageParser geminiParser; + private CreditMessageManager manager; + + @BeforeEach + void setUp() { + // LLM 호출 모킹을 위한 deep stub + chatClient = mock(ChatClient.class, Mockito.RETURNS_DEEP_STUBS); + builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + + geminiParser = spy(new GeminiCreditMessageParser(builder)); + manager = new CreditMessageManager(geminiParser); + } + + @Test + @DisplayName("KB 메시지는 룰 파서가 처리하고 Gemini 는 호출되지 않는다") + void ruleBasedParsingWorks() { + // given + String sms = "[KB국민카드] 06/12 10:20 승인 11,000원 스타벅스"; + + // when + ExpenditureDto dto = manager.parseMessage(sms); + + // then + assertEquals("KB", dto.getVendor()); + assertEquals(11000L, dto.getAmount()); + verify(geminiParser, never()).parse(anyString()); + } + + @Test + @DisplayName("룰 파서 실패 시 Gemini fallback 이 동작한다") + void fallbackToGemini() { + // given + String llmJson = "{" + + "\"vendor\":\"UNKNOWN\"," + + "\"dateTime\":\"2025-06-12T10:20:00\"," + + "\"amount\":5000," + + "\"tradeName\":\"GS25\"}"; + + // when + when(chatClient.prompt().user(anyString()).call().content()).thenReturn(llmJson); + + String sms = "알 수 없는 카드사 메시지"; + ExpenditureDto dto = manager.parseMessage(sms); + + // then + assertEquals("UNKNOWN", dto.getVendor()); + assertEquals(5000L, dto.getAmount()); + verify(geminiParser, times(1)).parse(anyString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java new file mode 100644 index 0000000..c1e8ccb --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java @@ -0,0 +1,55 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.chat.client.ChatClient; + +class GeminiCreditMessageParserTest { + + private ChatClient.Builder builder; + private ChatClient chatClient; + private GeminiCreditMessageParser parser; + + @BeforeEach + void setUp() { + // Deep-stub 으로 체이닝 메서드 mock + chatClient = mock(ChatClient.class, Mockito.RETURNS_DEEP_STUBS); + builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + + parser = new GeminiCreditMessageParser(builder); + } + + @Test + @DisplayName("LLM JSON 응답을 DTO 로 변환한다") + void parseSuccess() { + // given + String llmJson = "{" + + "\"vendor\":\"KB\"," + + "\"dateTime\":\"2025-06-12T10:20:00\"," + + "\"amount\":11000," + + "\"tradeName\":\"스타벅스\"}"; + + // when + when(chatClient.prompt().user(anyString()).call().content()).thenReturn(llmJson); + + String sms = "[KB국민카드] 06/12 10:20 승인 11,000원 스타벅스"; + ExpenditureDto dto = parser.parse(sms); + + // then + assertAll( + () -> assertEquals("KB", dto.getVendor()), + () -> assertEquals(11000L, dto.getAmount()), + () -> assertEquals("스타벅스", dto.getTradeName()), + () -> assertEquals("2025-06-12T10:20", dto.getDateTime().toString().substring(0, 16)) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java new file mode 100644 index 0000000..182b4bc --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java @@ -0,0 +1,46 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class KBCreditMessageParserTest { + + KBCreditMessageParser parser = new KBCreditMessageParser(); + + @DisplayName("국민은행 결제 메시지인지 판별한다.") + @Test + void checkVendor() throws Exception { + // given + String kbMessage = "[KB국민카드] 07/16 12:28 승인 11,000원 일시불 롯데시네마 평촌"; + // when & then + assertThat(parser.checkVendor(kbMessage)).isTrue(); + } + + @DisplayName("잘못된 벤더사의 메시지인지 확인한다.") + @Test + void checkVendor2() throws Exception { + // given + String illegalMessage = "[우리] 07/16 12:28 승인 11,000원 일시불 롯데시네마 평촌"; + // when & then + assertThat(parser.checkVendor(illegalMessage)).isFalse(); + + } + + @DisplayName("국민은행 결제 메시지를 파싱한다.") + @Test + void parse() throws Exception { + // given + String kbMessage = "[KB국민카드] 07/16 12:28 승인 11,000원 일시불 롯데시네마 평촌"; + // when + ExpenditureDto expenditure = parser.parse(kbMessage); + // then + assertThat(expenditure.getVendor()).isEqualTo("KB"); + assertThat(expenditure.getDateTime()).isEqualTo(LocalDateTime.of(LocalDateTime.now().getYear(), 7, 16, 12, 28)); + assertThat(expenditure.getAmount()).isEqualTo(11000); + assertThat(expenditure.getTradeName()).isEqualTo("롯데시네마 평촌"); + + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java new file mode 100644 index 0000000..c22c3bf --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java @@ -0,0 +1,47 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NHCreditMessageParserTest { + + NHCreditMessageParser parser = new NHCreditMessageParser(); + + @DisplayName("농협 결제 메시지인지 판별한다.") + @Test + void checkVendor() throws Exception { + // given + String nhMessage = "NH카드595승인 가나다 5,700원 일시불 10/21 08:33 (주)티머니 개인택 총누적1,000,000원"; + // when & then + assertThat(parser.checkVendor(nhMessage)).isTrue(); + } + + @DisplayName("잘못된 벤더사의 메시지인지 확인한다.") + @Test + void checkVendor2() throws Exception { + // given + String illegalMessage = "[우리] 07/16 12:28 승인 11,000원 일시불 롯데시네마 평촌"; + // when & then + assertThat(parser.checkVendor(illegalMessage)).isFalse(); + + } + + @DisplayName("농협 결제 메시지를 파싱한다.") + @Test + void parse() throws Exception { + // given + String nhMessage = "NH농협카드5*5승인 가나다 5,700원 일시불 10/21 08:33 (주)티머니 개인택 총누적1,000,000원"; + // when + ExpenditureDto expenditure = parser.parse(nhMessage); + // then + assertThat(expenditure.getVendor()).isEqualTo("NH"); + assertThat(expenditure.getDateTime()).isEqualTo(LocalDateTime.of(LocalDateTime.now().getYear(), 10, 21, 8, 33)); + assertThat(expenditure.getAmount()).isEqualTo(5700); + assertThat(expenditure.getTradeName()).isEqualTo("(주)티머니 개인택"); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java new file mode 100644 index 0000000..bc983c3 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java @@ -0,0 +1,47 @@ +package com.stcom.smartmealtable.component.creditmessage; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SHCreditMessageParserTest { + + SHCreditMessageParser parser = new SHCreditMessageParser(); + + @DisplayName("농협 결제 메시지인지 판별한다.") + @Test + void checkVendor() throws Exception { + // given + String shMessage = "신한카드(6193)승인 가나다 5,700원(일시불)10/21 08:33 (주)티머니 개인택 누적1,000,000원"; + // when & then + assertThat(parser.checkVendor(shMessage)).isTrue(); + } + + @DisplayName("잘못된 벤더사의 메시지인지 확인한다.") + @Test + void checkVendor2() throws Exception { + // given + String illegalMessage = "[우리] 07/16 12:28 승인 11,000원 일시불 롯데시네마 평촌"; + // when & then + assertThat(parser.checkVendor(illegalMessage)).isFalse(); + + } + + @DisplayName("농협 결제 메시지를 파싱한다.") + @Test + void parse() throws Exception { + // given + String shMessage = "신한카드(6193)승인 가나다 5,700원(일시불)10/21 08:33 (주)티머니 개인택 누적1,000,000원"; + // when + ExpenditureDto expenditure = parser.parse(shMessage); + // then + assertThat(expenditure.getVendor()).isEqualTo("SH"); + assertThat(expenditure.getDateTime()).isEqualTo(LocalDateTime.of(LocalDateTime.now().getYear(), 10, 21, 8, 33)); + assertThat(expenditure.getAmount()).isEqualTo(5700); + assertThat(expenditure.getTradeName()).isEqualTo("(주)티머니 개인택"); + + } + +} \ No newline at end of file From c2568e04526a2c763f2b4c6c783473a4bb385326 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 19:46:40 +0900 Subject: [PATCH 31/44] =?UTF-8?q?fix:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=88=9C=ED=99=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index b028841..6bf3743 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' implementation 'org.springframework.ai:spring-ai-bom:1.0.0' implementation 'org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini' + implementation 'com.google.protobuf:protobuf-java:3.25.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 직렬화/역직렬화에 Jackson 사용 compileOnly 'org.projectlombok:lombok' From f36c70a3dccebc697c83ef1b4b7c439d067fb0cc Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 22:09:56 +0900 Subject: [PATCH 32/44] =?UTF-8?q?refactor:=20=ED=95=84=EB=93=9C=EB=AA=85?= =?UTF-8?q?=20=EC=A7=81=EA=B4=80=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smartmealtable/component/creditmessage/ExpenditureDto.java | 2 +- .../component/creditmessage/GeminiCreditMessageParserTest.java | 2 +- .../component/creditmessage/KBCreditMessageParserTest.java | 3 ++- .../component/creditmessage/NHCreditMessageParserTest.java | 3 ++- .../component/creditmessage/SHCreditMessageParserTest.java | 3 ++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java b/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java index a0faf68..e1a9a93 100644 --- a/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java +++ b/src/main/java/com/stcom/smartmealtable/component/creditmessage/ExpenditureDto.java @@ -11,7 +11,7 @@ public class ExpenditureDto { private String vendor; - private LocalDateTime dateTime; + private LocalDateTime spentDate; private Long amount; private String tradeName; diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java index c1e8ccb..d6d195b 100644 --- a/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/GeminiCreditMessageParserTest.java @@ -49,7 +49,7 @@ void parseSuccess() { () -> assertEquals("KB", dto.getVendor()), () -> assertEquals(11000L, dto.getAmount()), () -> assertEquals("스타벅스", dto.getTradeName()), - () -> assertEquals("2025-06-12T10:20", dto.getDateTime().toString().substring(0, 16)) + () -> assertEquals("2025-06-12T10:20", dto.getSpentDate().toString().substring(0, 16)) ); } } \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java index 182b4bc..3dbb964 100644 --- a/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/KBCreditMessageParserTest.java @@ -38,7 +38,8 @@ void parse() throws Exception { ExpenditureDto expenditure = parser.parse(kbMessage); // then assertThat(expenditure.getVendor()).isEqualTo("KB"); - assertThat(expenditure.getDateTime()).isEqualTo(LocalDateTime.of(LocalDateTime.now().getYear(), 7, 16, 12, 28)); + assertThat(expenditure.getSpentDate()).isEqualTo( + LocalDateTime.of(LocalDateTime.now().getYear(), 7, 16, 12, 28)); assertThat(expenditure.getAmount()).isEqualTo(11000); assertThat(expenditure.getTradeName()).isEqualTo("롯데시네마 평촌"); diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java index c22c3bf..7dfa3f4 100644 --- a/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/NHCreditMessageParserTest.java @@ -38,7 +38,8 @@ void parse() throws Exception { ExpenditureDto expenditure = parser.parse(nhMessage); // then assertThat(expenditure.getVendor()).isEqualTo("NH"); - assertThat(expenditure.getDateTime()).isEqualTo(LocalDateTime.of(LocalDateTime.now().getYear(), 10, 21, 8, 33)); + assertThat(expenditure.getSpentDate()).isEqualTo( + LocalDateTime.of(LocalDateTime.now().getYear(), 10, 21, 8, 33)); assertThat(expenditure.getAmount()).isEqualTo(5700); assertThat(expenditure.getTradeName()).isEqualTo("(주)티머니 개인택"); diff --git a/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java b/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java index bc983c3..f1ab96a 100644 --- a/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java +++ b/src/test/java/com/stcom/smartmealtable/component/creditmessage/SHCreditMessageParserTest.java @@ -38,7 +38,8 @@ void parse() throws Exception { ExpenditureDto expenditure = parser.parse(shMessage); // then assertThat(expenditure.getVendor()).isEqualTo("SH"); - assertThat(expenditure.getDateTime()).isEqualTo(LocalDateTime.of(LocalDateTime.now().getYear(), 10, 21, 8, 33)); + assertThat(expenditure.getSpentDate()).isEqualTo( + LocalDateTime.of(LocalDateTime.now().getYear(), 10, 21, 8, 33)); assertThat(expenditure.getAmount()).isEqualTo(5700); assertThat(expenditure.getTradeName()).isEqualTo("(주)티머니 개인택"); From 3493d07dd8bf530ddc113067eedf086a22042b7c Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 22:11:35 +0900 Subject: [PATCH 33/44] =?UTF-8?q?feat:=20=EC=A7=80=EC=B6=9C=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20CRUD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 무한 스크롤 페이징 지출 내역 조회 API 2. 결제 메시지 파싱 API 3. 지출 내역 등록 API 4. 지출 내역 수정 API 5. 지출 내역 삭제 API --- .../smartmealtable/domain/Budget/Budget.java | 5 + .../domain/Budget/DailyBudget.java | 1 + .../domain/Budget/Expenditure.java | 72 +++++++++++ .../domain/Budget/MonthlyBudget.java | 1 + .../repository/ExpenditureRepository.java | 14 +++ .../service/ExpenditureService.java | 84 +++++++++++++ .../MemberExpenditureController.java | 114 ++++++++++++++++++ 7 files changed, 291 insertions(+) create mode 100644 src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java create mode 100644 src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java create mode 100644 src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java create mode 100644 src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java index 995a744..b1255b7 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java @@ -38,6 +38,7 @@ public abstract class Budget extends BaseTimeEntity { @Column(name = "budget_limit") private BigDecimal limit; + protected Budget(MemberProfile memberProfile, BigDecimal limit) { this.memberProfile = memberProfile; this.limit = limit; @@ -73,4 +74,8 @@ public void changeLimit(BigDecimal limit) { } this.limit = limit; } + + public void subtractSpent(BigDecimal spent) { + this.spendAmount = this.spendAmount.subtract(spent); + } } diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java index 6477ae1..1be1a58 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java @@ -21,4 +21,5 @@ public DailyBudget(MemberProfile memberProfile, BigDecimal limit, @Column(name = "daily_budget_date") private LocalDate date; + } diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java new file mode 100644 index 0000000..43543eb --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java @@ -0,0 +1,72 @@ +package com.stcom.smartmealtable.domain.Budget; + +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Expenditure extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "expenditure_id") + private Long id; + + private LocalDateTime spentDate; + + private Long amount; + + private String tradeName; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "daily_budget_id") + private DailyBudget dailyBudget; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "monthly_budget_id") + private MonthlyBudget monthlyBudget; + + + @Builder + public Expenditure(LocalDateTime spentDate, Long amount, String tradeName, DailyBudget dailyBudget, + MonthlyBudget monthlyBudget) { + this.spentDate = spentDate; + this.amount = amount; + this.tradeName = tradeName; + this.dailyBudget = dailyBudget; + this.monthlyBudget = monthlyBudget; + } + + private void updateSpentDate(LocalDateTime spentDate) { + this.spentDate = spentDate; + } + + private void updateAmount(Long originAmount, Long afterAmount) { + dailyBudget.addSpent(afterAmount - originAmount); + monthlyBudget.addSpent(afterAmount - originAmount); + this.amount = afterAmount; + } + + private void updateTradeName(String tradeName) { + this.tradeName = tradeName; + } + + public void edit(LocalDateTime spentDate, Long amount, String tradeName) { + updateSpentDate(spentDate); + updateAmount(this.amount, amount); + updateTradeName(tradeName); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java index beb87b7..7d73453 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java @@ -24,4 +24,5 @@ public MonthlyBudget(MemberProfile memberProfile, BigDecimal limit, @Convert(converter = YearMonthConverter.class) @Column(name = "budget_year_month") private YearMonth yearMonth; + } diff --git a/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java b/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java new file mode 100644 index 0000000..77501f0 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java @@ -0,0 +1,14 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.Budget.Expenditure; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExpenditureRepository extends JpaRepository { + + Slice findByDailyBudget_MemberProfile_IdOrderBySpentDateDesc(Long profileId, Pageable pageable); + + List findExpendituresById(Long id); +} diff --git a/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java b/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java new file mode 100644 index 0000000..b5207cc --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java @@ -0,0 +1,84 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.Expenditure; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.ExpenditureRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExpenditureService { + + private final ExpenditureRepository expenditureRepository; + private final BudgetRepository budgetRepository; + + @Transactional + public void registerExpenditure(Long profileId, + LocalDateTime spentDate, + Long amount, + String tradeName) { + + LocalDate date = spentDate.toLocalDate(); + YearMonth yearMonth = YearMonth.from(spentDate); + + DailyBudget dailyBudget = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profileId, date) + .orElseThrow(() -> new IllegalArgumentException("일일 예산이 존재하지 않습니다.")); + MonthlyBudget monthlyBudget = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profileId, + yearMonth) + .orElseThrow(() -> new IllegalArgumentException("월별 예산이 존재하지 않습니다.")); + + Expenditure expenditure = Expenditure.builder() + .spentDate(spentDate) + .amount(amount) + .tradeName(tradeName) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + expenditureRepository.save(expenditure); + + BigDecimal spent = BigDecimal.valueOf(amount); + dailyBudget.addSpent(spent); + monthlyBudget.addSpent(spent); + } + + public Slice getExpenditures(Long profileId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "spentDate")); + return expenditureRepository.findByDailyBudget_MemberProfile_IdOrderBySpentDateDesc(profileId, pageable); + } + + @Transactional + public void editExpenditure(Long profileId, Long expenditureId, LocalDateTime spentDate, Long amount, + String tradeName) { + Expenditure expenditure = expenditureRepository.findById(expenditureId) + .orElseThrow(() -> new IllegalArgumentException("지출 내역이 존재하지 않습니다.")); + expenditure.edit(spentDate, amount, tradeName); + } + + @Transactional + public void deleteExpenditure(Long profileId, Long expenditureId) { + Expenditure expenditure = expenditureRepository.findById(expenditureId) + .orElseThrow(() -> new IllegalArgumentException("지출 내역이 존재하지 않습니다.")); + + DailyBudget dailyBudget = expenditure.getDailyBudget(); + MonthlyBudget monthlyBudget = expenditure.getMonthlyBudget(); + + BigDecimal spent = BigDecimal.valueOf(expenditure.getAmount()); + dailyBudget.subtractSpent(spent); + monthlyBudget.subtractSpent(spent); + + expenditureRepository.delete(expenditure); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java new file mode 100644 index 0000000..1d9ab80 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java @@ -0,0 +1,114 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.component.creditmessage.CreditMessageManager; +import com.stcom.smartmealtable.component.creditmessage.ExpenditureDto; +import com.stcom.smartmealtable.domain.Budget.Expenditure; +import com.stcom.smartmealtable.service.ExpenditureService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.constraints.NotEmpty; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/v1/members/me/expenditures") +public class MemberExpenditureController { + + private final ExpenditureService expenditureService; + private final CreditMessageManager creditMessageManager; + + @GetMapping("/messages/parse") + public ApiResponse parseCreditMessage(@RequestBody ParseRequest request) { + return ApiResponse.createSuccess(creditMessageManager.parseMessage(request.getMessage())); + } + + @GetMapping + public ApiResponse> getExpenditures(@UserContext MemberDto memberDto, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + Slice slice = expenditureService.getExpenditures(memberDto.getProfileId(), page, size); + Slice responseSlice = slice.map(ExpenditureResponse::of); + return ApiResponse.createSuccess(responseSlice); + } + + @PostMapping + public ApiResponse registerExpenditure(@UserContext MemberDto memberDto, + @RequestBody ExpenditureRequest request) { + expenditureService.registerExpenditure( + memberDto.getProfileId(), + request.getSpentDate(), + request.getAmount(), + request.getTradeName() + ); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/{id}") + public ApiResponse editExpenditure(@UserContext MemberDto memberDto, @PathVariable("id") Long expenditureId, + @RequestBody ExpenditureRequest request) { + expenditureService.editExpenditure( + memberDto.getProfileId(), + expenditureId, + request.getSpentDate(), + request.getAmount(), + request.getTradeName() + ); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteExpenditure(@UserContext MemberDto memberDto, + @PathVariable("id") Long expenditureId) { + expenditureService.deleteExpenditure(memberDto.getProfileId(), expenditureId); + return ApiResponse.createSuccessWithNoContent(); + } + + @Data + static class ParseRequest { + + @NotEmpty + private String message; + + } + + @Data + static class ExpenditureRequest { + @DateTimeFormat(iso = ISO.DATE_TIME) + private LocalDateTime spentDate; + private Long amount; + private String tradeName; + } + + @Data + @AllArgsConstructor + static class ExpenditureResponse { + + private Long id; + private LocalDateTime spentDate; + private Long amount; + private String tradeName; + + public static ExpenditureResponse of(Expenditure expenditure) { + return new ExpenditureResponse(expenditure.getId(), expenditure.getSpentDate(), expenditure.getAmount(), + expenditure.getTradeName()); + } + } +} From d4288cfefa187ff228c1dbcf88203f43e0312156 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 22:15:01 +0900 Subject: [PATCH 34/44] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=85=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/stcom/smartmealtable/domain/Budget/Expenditure.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java index 43543eb..b3b7474 100644 --- a/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/Expenditure.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import java.math.BigDecimal; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -55,8 +56,9 @@ private void updateSpentDate(LocalDateTime spentDate) { } private void updateAmount(Long originAmount, Long afterAmount) { - dailyBudget.addSpent(afterAmount - originAmount); - monthlyBudget.addSpent(afterAmount - originAmount); + Long difference = afterAmount - originAmount; + dailyBudget.addSpent(BigDecimal.valueOf(difference)); + monthlyBudget.addSpent(BigDecimal.valueOf(difference)); this.amount = afterAmount; } From 34bde9a1df49297af794a1a82a7391a23318cf17 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 22:15:25 +0900 Subject: [PATCH 35/44] =?UTF-8?q?test:=20=EC=A7=80=EC=B6=9C=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20CRUD=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/Budget/ExpenditureTest.java | 202 +++++++++++++++++ .../repository/ExpenditureRepositoryTest.java | 101 +++++++++ .../ExpenditureServiceIntegrationTest.java | 208 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 src/test/java/com/stcom/smartmealtable/domain/Budget/ExpenditureTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/repository/ExpenditureRepositoryTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/service/ExpenditureServiceIntegrationTest.java diff --git a/src/test/java/com/stcom/smartmealtable/domain/Budget/ExpenditureTest.java b/src/test/java/com/stcom/smartmealtable/domain/Budget/ExpenditureTest.java new file mode 100644 index 0000000..a1b0f2b --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/Budget/ExpenditureTest.java @@ -0,0 +1,202 @@ +package com.stcom.smartmealtable.domain.Budget; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ExpenditureTest { + + private MemberProfile memberProfile; + private DailyBudget dailyBudget; + private MonthlyBudget monthlyBudget; + + @BeforeEach + void setUp() { + memberProfile = new MemberProfile(); + LocalDate today = LocalDate.now(); + YearMonth thisMonth = YearMonth.from(today); + + dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(50_000), today); + monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(1_000_000), thisMonth); + } + + @Test + @DisplayName("Expenditure 객체가 정상적으로 생성된다") + void createExpenditure() { + // given + LocalDateTime spentDate = LocalDateTime.now(); + Long amount = 12000L; + String tradeName = "Lunch"; + + // when + Expenditure expenditure = Expenditure.builder() + .spentDate(spentDate) + .amount(amount) + .tradeName(tradeName) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + + // then + assertThat(expenditure.getSpentDate()).isEqualTo(spentDate); + assertThat(expenditure.getAmount()).isEqualTo(amount); + assertThat(expenditure.getTradeName()).isEqualTo(tradeName); + assertThat(expenditure.getDailyBudget()).isEqualTo(dailyBudget); + assertThat(expenditure.getMonthlyBudget()).isEqualTo(monthlyBudget); + } + + @Test + @DisplayName("지출 내역 수정 시 예산에 차액이 반영된다") + void editExpenditure_AmountChanged() { + // given + LocalDateTime originalSpentDate = LocalDateTime.now(); + Long originalAmount = 12000L; + String originalTradeName = "Lunch"; + + Expenditure expenditure = Expenditure.builder() + .spentDate(originalSpentDate) + .amount(originalAmount) + .tradeName(originalTradeName) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + + // 초기 예산에 지출 추가 + dailyBudget.addSpent(BigDecimal.valueOf(originalAmount)); + monthlyBudget.addSpent(BigDecimal.valueOf(originalAmount)); + + LocalDateTime newSpentDate = LocalDateTime.now().plusHours(1); + Long newAmount = 15000L; // 3000원 증가 + String newTradeName = "Dinner"; + + // when + expenditure.edit(newSpentDate, newAmount, newTradeName); + + // then + assertThat(expenditure.getSpentDate()).isEqualTo(newSpentDate); + assertThat(expenditure.getAmount()).isEqualTo(newAmount); + assertThat(expenditure.getTradeName()).isEqualTo(newTradeName); + + // 예산에 차액(3000원)이 추가로 반영되어야 함 + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(15000)); + assertThat(monthlyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(15000)); + } + + @Test + @DisplayName("지출 금액을 줄여서 수정하면 예산에서 차액만큼 차감된다") + void editExpenditure_AmountDecreased() { + // given + LocalDateTime originalSpentDate = LocalDateTime.now(); + Long originalAmount = 15000L; + String originalTradeName = "Dinner"; + + Expenditure expenditure = Expenditure.builder() + .spentDate(originalSpentDate) + .amount(originalAmount) + .tradeName(originalTradeName) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + + // 초기 예산에 지출 추가 + dailyBudget.addSpent(BigDecimal.valueOf(originalAmount)); + monthlyBudget.addSpent(BigDecimal.valueOf(originalAmount)); + + LocalDateTime newSpentDate = LocalDateTime.now().plusHours(1); + Long newAmount = 10000L; // 5000원 감소 + String newTradeName = "Lunch"; + + // when + expenditure.edit(newSpentDate, newAmount, newTradeName); + + // then + assertThat(expenditure.getAmount()).isEqualTo(newAmount); + + // 예산에서 차액(5000원)이 차감되어야 함 + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(10000)); + assertThat(monthlyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(10000)); + } + + @Test + @DisplayName("지출 금액을 동일하게 수정해도 예산에 변화가 없다") + void editExpenditure_SameAmount() { + // given + LocalDateTime originalSpentDate = LocalDateTime.now(); + Long originalAmount = 12000L; + String originalTradeName = "Lunch"; + + Expenditure expenditure = Expenditure.builder() + .spentDate(originalSpentDate) + .amount(originalAmount) + .tradeName(originalTradeName) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + + // 초기 예산에 지출 추가 + dailyBudget.addSpent(BigDecimal.valueOf(originalAmount)); + monthlyBudget.addSpent(BigDecimal.valueOf(originalAmount)); + + LocalDateTime newSpentDate = LocalDateTime.now().plusHours(1); + Long newAmount = 12000L; // 동일한 금액 + String newTradeName = "Brunch"; + + // when + expenditure.edit(newSpentDate, newAmount, newTradeName); + + // then + assertThat(expenditure.getSpentDate()).isEqualTo(newSpentDate); + assertThat(expenditure.getAmount()).isEqualTo(newAmount); + assertThat(expenditure.getTradeName()).isEqualTo(newTradeName); + + // 예산에 변화가 없어야 함 + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(originalAmount)); + assertThat(monthlyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(originalAmount)); + } + + @Test + @DisplayName("지출 날짜와 상호명만 수정하고 금액은 그대로 두면 예산에 변화가 없다") + void editExpenditure_OnlyDateAndTradeName() { + // given + LocalDateTime originalSpentDate = LocalDateTime.now(); + Long amount = 12000L; + String originalTradeName = "Lunch"; + + Expenditure expenditure = Expenditure.builder() + .spentDate(originalSpentDate) + .amount(amount) + .tradeName(originalTradeName) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + + // 초기 예산에 지출 추가 + dailyBudget.addSpent(BigDecimal.valueOf(amount)); + monthlyBudget.addSpent(BigDecimal.valueOf(amount)); + + BigDecimal originalDailySpent = dailyBudget.getSpendAmount(); + BigDecimal originalMonthlySpent = monthlyBudget.getSpendAmount(); + + LocalDateTime newSpentDate = LocalDateTime.now().plusHours(2); + String newTradeName = "Late Lunch"; + + // when + expenditure.edit(newSpentDate, amount, newTradeName); + + // then + assertThat(expenditure.getSpentDate()).isEqualTo(newSpentDate); + assertThat(expenditure.getAmount()).isEqualTo(amount); + assertThat(expenditure.getTradeName()).isEqualTo(newTradeName); + + // 예산에 변화가 없어야 함 + assertThat(dailyBudget.getSpendAmount()).isEqualTo(originalDailySpent); + assertThat(monthlyBudget.getSpendAmount()).isEqualTo(originalMonthlySpent); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/ExpenditureRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/ExpenditureRepositoryTest.java new file mode 100644 index 0000000..a98668d --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/ExpenditureRepositoryTest.java @@ -0,0 +1,101 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.Expenditure; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.Comparator; +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; + +@DataJpaTest +@ActiveProfiles("test") +class ExpenditureRepositoryTest { + + @Autowired + private ExpenditureRepository expenditureRepository; + + @Autowired + private BudgetRepository budgetRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberProfileRepository memberProfileRepository; + + @DisplayName("Slice 기반 무한스크롤 조회가 최신순으로 올바르게 동작한다") + @Test + void slicePaginationByProfile() { + // given + Member member = Member.builder() + .email("slice@test.com") + .rawPassword("@Password1") + .build(); + memberRepository.save(member); + + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("tester") + .type(MemberType.OTHER) + .build(); + memberProfileRepository.save(profile); + + LocalDate today = LocalDate.now(); + YearMonth thisMonth = YearMonth.now(); + + DailyBudget dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(10_000), today); + MonthlyBudget monthlyBudget = new MonthlyBudget(profile, BigDecimal.valueOf(300_000), thisMonth); + budgetRepository.save(dailyBudget); + budgetRepository.save(monthlyBudget); + + // 15개의 지출내역 생성 (1분 간격으로 시간 차이) + IntStream.range(0, 15).forEach(i -> { + LocalDateTime spentDate = LocalDateTime.now().minusMinutes(i); + Expenditure expenditure = Expenditure.builder() + .spentDate(spentDate) + .amount(1000L + i) + .tradeName("coffee" + i) + .dailyBudget(dailyBudget) + .monthlyBudget(monthlyBudget) + .build(); + expenditureRepository.save(expenditure); + }); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "spentDate")); + + // when + Slice slice = expenditureRepository + .findByDailyBudget_MemberProfile_IdOrderBySpentDateDesc(profile.getId(), pageable); + + // then + assertThat(slice).isNotNull(); + assertThat(slice.getContent()).hasSize(10); + assertThat(slice.hasNext()).isTrue(); + + // spentDate 가 내림차순인지 확인 + boolean sortedDesc = slice.getContent().stream() + .sorted(Comparator.comparing(Expenditure::getSpentDate).reversed()) + .toList() + .equals(slice.getContent()); + assertThat(sortedDesc).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/ExpenditureServiceIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/service/ExpenditureServiceIntegrationTest.java new file mode 100644 index 0000000..367efd9 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/ExpenditureServiceIntegrationTest.java @@ -0,0 +1,208 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.Expenditure; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.ExpenditureRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@Import(ExpenditureService.class) +class ExpenditureServiceIntegrationTest { + + @Autowired + private ExpenditureService expenditureService; + @Autowired + private ExpenditureRepository expenditureRepository; + @Autowired + private BudgetRepository budgetRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemberProfileRepository memberProfileRepository; + + private Member member; + private MemberProfile profile; + private DailyBudget dailyBudget; + private MonthlyBudget monthlyBudget; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("exptest@example.com") + .rawPassword("P@ssw0rd!") + .build(); + memberRepository.save(member); + + profile = MemberProfile.builder() + .member(member) + .nickName("tester") + .build(); + memberProfileRepository.save(profile); + + LocalDate today = LocalDate.now(); + YearMonth thisMonth = YearMonth.from(today); + + dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(50_000), today); + monthlyBudget = new MonthlyBudget(profile, BigDecimal.valueOf(1_000_000), thisMonth); + budgetRepository.save(dailyBudget); + budgetRepository.save(monthlyBudget); + } + + @DisplayName("지출 등록 시 Expenditure 저장 및 해당 예산 사용금액이 증가한다") + @Test + void registerExpenditure() { + // given + LocalDateTime spentDate = LocalDateTime.now(); + Long amount = 12000L; + String tradeName = "Lunch"; + + // when + expenditureService.registerExpenditure(profile.getId(), spentDate, amount, tradeName); + + // then + Expenditure saved = expenditureRepository.findAll().getFirst(); + assertThat(saved).isNotNull(); + assertThat(saved.getTradeName()).isEqualTo(tradeName); + assertThat(saved.getAmount()).isEqualTo(amount); + assertThat(saved.getDailyBudget().getId()).isEqualTo(dailyBudget.getId()); + assertThat(saved.getMonthlyBudget().getId()).isEqualTo(monthlyBudget.getId()); + + DailyBudget reloadedDaily = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profile.getId(), + dailyBudget.getDate()).orElseThrow(); + MonthlyBudget reloadedMonthly = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profile.getId(), + monthlyBudget.getYearMonth()).orElseThrow(); + + assertThat(reloadedDaily.getSpendAmount()).isEqualTo(BigDecimal.valueOf(amount)); + assertThat(reloadedMonthly.getSpendAmount()).isEqualTo(BigDecimal.valueOf(amount)); + } + + @DisplayName("지출 내역을 페이징으로 조회할 수 있다") + @Test + void getExpenditures() { + // given + LocalDateTime spentDate1 = LocalDateTime.now().withHour(9).withMinute(0); + LocalDateTime spentDate2 = LocalDateTime.now().withHour(12).withMinute(0); + LocalDateTime spentDate3 = LocalDateTime.now().withHour(18).withMinute(0); + + expenditureService.registerExpenditure(profile.getId(), spentDate1, 10000L, "Breakfast"); + expenditureService.registerExpenditure(profile.getId(), spentDate2, 15000L, "Lunch"); + expenditureService.registerExpenditure(profile.getId(), spentDate3, 20000L, "Dinner"); + + // when + var result = expenditureService.getExpenditures(profile.getId(), 0, 2); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).getTradeName()).isEqualTo("Dinner"); // 최신순 + assertThat(result.getContent().get(1).getTradeName()).isEqualTo("Lunch"); + assertThat(result.hasNext()).isTrue(); + } + + @DisplayName("지출 내역을 수정할 수 있다") + @Test + void editExpenditure() { + // given + LocalDateTime originalSpentDate = LocalDateTime.now(); + Long originalAmount = 12000L; + String originalTradeName = "Lunch"; + + expenditureService.registerExpenditure(profile.getId(), originalSpentDate, originalAmount, originalTradeName); + Expenditure savedExpenditure = expenditureRepository.findAll().getFirst(); + + LocalDateTime newSpentDate = LocalDateTime.now().plusHours(1); + Long newAmount = 15000L; + String newTradeName = "Dinner"; + + // when + expenditureService.editExpenditure(profile.getId(), savedExpenditure.getId(), newSpentDate, newAmount, newTradeName); + + // then + Expenditure updatedExpenditure = expenditureRepository.findById(savedExpenditure.getId()).orElseThrow(); + assertThat(updatedExpenditure.getSpentDate()).isEqualTo(newSpentDate); + assertThat(updatedExpenditure.getAmount()).isEqualTo(newAmount); + assertThat(updatedExpenditure.getTradeName()).isEqualTo(newTradeName); + } + + @DisplayName("지출 내역을 삭제하면 예산에서 해당 금액이 차감된다") + @Test + void deleteExpenditure() { + // given + LocalDateTime spentDate = LocalDateTime.now(); + Long amount = 12000L; + String tradeName = "Lunch"; + + expenditureService.registerExpenditure(profile.getId(), spentDate, amount, tradeName); + Expenditure savedExpenditure = expenditureRepository.findAll().getFirst(); + + // 삭제 전 예산 확인 + DailyBudget beforeDeleteDaily = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profile.getId(), + dailyBudget.getDate()).orElseThrow(); + MonthlyBudget beforeDeleteMonthly = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profile.getId(), + monthlyBudget.getYearMonth()).orElseThrow(); + + assertThat(beforeDeleteDaily.getSpendAmount()).isEqualTo(BigDecimal.valueOf(amount)); + assertThat(beforeDeleteMonthly.getSpendAmount()).isEqualTo(BigDecimal.valueOf(amount)); + + // when + expenditureService.deleteExpenditure(profile.getId(), savedExpenditure.getId()); + + // then + assertThat(expenditureRepository.findById(savedExpenditure.getId())).isEmpty(); + + DailyBudget afterDeleteDaily = budgetRepository.findDailyBudgetByMemberProfileIdAndDate(profile.getId(), + dailyBudget.getDate()).orElseThrow(); + MonthlyBudget afterDeleteMonthly = budgetRepository.findMonthlyBudgetByMemberProfileIdAndYearMonth(profile.getId(), + monthlyBudget.getYearMonth()).orElseThrow(); + + assertThat(afterDeleteDaily.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(afterDeleteMonthly.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + } + + @DisplayName("존재하지 않는 지출 내역 수정 시 예외가 발생한다") + @Test + void editExpenditure_NotFound() { + // given + Long nonExistentExpenditureId = 999L; + LocalDateTime spentDate = LocalDateTime.now(); + Long amount = 12000L; + String tradeName = "Lunch"; + + // when & then + assertThatThrownBy(() -> expenditureService.editExpenditure(profile.getId(), nonExistentExpenditureId, spentDate, amount, tradeName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("지출 내역이 존재하지 않습니다."); + } + + @DisplayName("존재하지 않는 지출 내역 삭제 시 예외가 발생한다") + @Test + void deleteExpenditure_NotFound() { + // given + Long nonExistentExpenditureId = 999L; + + // when & then + assertThatThrownBy(() -> expenditureService.deleteExpenditure(profile.getId(), nonExistentExpenditureId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("지출 내역이 존재하지 않습니다."); + } + +} \ No newline at end of file From 5711da454c04bfdfb34fab7906ca30d90299d80b Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 22:34:33 +0900 Subject: [PATCH 36/44] =?UTF-8?q?refactor:=20=EC=A7=80=EC=B6=9C=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 불필요한 쿼리 메서드 제거 2. 접근 권한 예외 처리 추가 3. 검증 애노테이션 추가 --- .../repository/ExpenditureRepository.java | 3 --- .../service/ExpenditureService.java | 9 +++++++++ .../controller/MemberExpenditureController.java | 16 +++++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java b/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java index 77501f0..b30e6e5 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/ExpenditureRepository.java @@ -1,7 +1,6 @@ package com.stcom.smartmealtable.repository; import com.stcom.smartmealtable.domain.Budget.Expenditure; -import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +8,4 @@ public interface ExpenditureRepository extends JpaRepository { Slice findByDailyBudget_MemberProfile_IdOrderBySpentDateDesc(Long profileId, Pageable pageable); - - List findExpendituresById(Long id); } diff --git a/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java b/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java index b5207cc..15a855c 100644 --- a/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java +++ b/src/main/java/com/stcom/smartmealtable/service/ExpenditureService.java @@ -64,6 +64,11 @@ public void editExpenditure(Long profileId, Long expenditureId, LocalDateTime sp String tradeName) { Expenditure expenditure = expenditureRepository.findById(expenditureId) .orElseThrow(() -> new IllegalArgumentException("지출 내역이 존재하지 않습니다.")); + + if (!expenditure.getDailyBudget().getMemberProfile().getId().equals(profileId)) { + throw new IllegalArgumentException("해당 지출 내역 등록자와 접근자가 다릅니다."); + } + expenditure.edit(spentDate, amount, tradeName); } @@ -75,6 +80,10 @@ public void deleteExpenditure(Long profileId, Long expenditureId) { DailyBudget dailyBudget = expenditure.getDailyBudget(); MonthlyBudget monthlyBudget = expenditure.getMonthlyBudget(); + if (!dailyBudget.getMemberProfile().getId().equals(profileId)) { + throw new IllegalArgumentException("해당 지출 내역 등록자와 접근자가 다릅니다."); + } + BigDecimal spent = BigDecimal.valueOf(expenditure.getAmount()); dailyBudget.subtractSpent(spent); monthlyBudget.subtractSpent(spent); diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java index 1d9ab80..d1e27b6 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberExpenditureController.java @@ -8,6 +8,8 @@ import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Data; @@ -16,6 +18,7 @@ import org.springframework.data.domain.Slice; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -35,7 +38,7 @@ public class MemberExpenditureController { private final ExpenditureService expenditureService; private final CreditMessageManager creditMessageManager; - @GetMapping("/messages/parse") + @PostMapping("/messages/parse") public ApiResponse parseCreditMessage(@RequestBody ParseRequest request) { return ApiResponse.createSuccess(creditMessageManager.parseMessage(request.getMessage())); } @@ -51,7 +54,7 @@ public ApiResponse> getExpenditures(@UserContext Memb @PostMapping public ApiResponse registerExpenditure(@UserContext MemberDto memberDto, - @RequestBody ExpenditureRequest request) { + @RequestBody @Validated ExpenditureRequest request) { expenditureService.registerExpenditure( memberDto.getProfileId(), request.getSpentDate(), @@ -63,7 +66,7 @@ public ApiResponse registerExpenditure(@UserContext MemberDto memberDto, @PatchMapping("/{id}") public ApiResponse editExpenditure(@UserContext MemberDto memberDto, @PathVariable("id") Long expenditureId, - @RequestBody ExpenditureRequest request) { + @RequestBody @Validated ExpenditureRequest request) { expenditureService.editExpenditure( memberDto.getProfileId(), expenditureId, @@ -91,9 +94,16 @@ static class ParseRequest { @Data static class ExpenditureRequest { + @DateTimeFormat(iso = ISO.DATE_TIME) + @NotNull private LocalDateTime spentDate; + + @NotNull + @Positive private Long amount; + + @NotEmpty private String tradeName; } From 2546db7c1fd0d24ea472afe6c238b498954455ef Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 23:55:13 +0900 Subject: [PATCH 37/44] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BudgetRepository.java | 3 ++ .../smartmealtable/service/BudgetService.java | 5 +++ .../controller/MemberBudgetController.java | 41 ++++++++++++++----- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java index 014179d..a9aa6ad 100644 --- a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java +++ b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java @@ -47,4 +47,7 @@ default Optional findFirstMonthlyBudgetByMemberProfileId(Long mem @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :profileId and treat(b as DailyBudget).date between :startOfWeek and :endOfWeek order by treat(b as DailyBudget).date asc") List findDailyBudgetsByMemberProfileIdAndDateBetween(Long profileId, LocalDate startOfWeek, LocalDate endOfWeek); + + @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :profileId and treat(b as MonthlyBudget).yearMonth < :from order by treat(b as MonthlyBudget).yearMonth desc ") + List findMonthlyBudgetsByMemberProfileIdAndYearMonthBefore(Long profileId, YearMonth from); } diff --git a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java index 914a4da..f47ce79 100644 --- a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java +++ b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java @@ -105,4 +105,9 @@ public void editDailyBudgetCustom(Long profileId, LocalDate date, Long limit) { .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 프로필로 접근")) .changeLimit(BigDecimal.valueOf(limit)); } + + public List getMonthlyBudgetsBy(Long profileId, LocalDate parse, int count) { + return budgetRepository.findMonthlyBudgetsByMemberProfileIdAndYearMonthBefore( + profileId, YearMonth.from(parse)).stream().limit(count).toList(); + } } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java index 41652b3..d75fdcf 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java @@ -8,16 +8,21 @@ import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; import com.stcom.smartmealtable.web.validation.YearMonthFormat; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDate; -import java.time.YearMonth; -import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @@ -29,16 +34,16 @@ public class MemberBudgetController { // 일별 예산 조회 @GetMapping("/daily/{date}") public ApiResponse dailyBudgetByDate(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { - DailyBudget dailyBudget = budgetService.getDailyBudgetBy(memberDto.getProfileId(), LocalDate.parse(date)); + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) LocalDate date) { + DailyBudget dailyBudget = budgetService.getDailyBudgetBy(memberDto.getProfileId(), date); return ApiResponse.createSuccess(DailyBudgetResponse.of(dailyBudget)); } @PutMapping("/daily/{date}/default") public ApiResponse registerDefaultDailyBudget(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) LocalDate date, @RequestParam("limit") Long limit) { - budgetService.registerDefaultDailyBudgetBy(memberDto.getProfileId(), limit, LocalDate.parse(date)); + budgetService.registerDefaultDailyBudgetBy(memberDto.getProfileId(), limit, date); return ApiResponse.createSuccessWithNoContent(); } @@ -53,9 +58,9 @@ public ApiResponse editDailyBudget(@UserContext MemberDto memberDto, // 해당 일자가 속한 일일 예산 주간 데이터 조회 @GetMapping("/daily/{date}/week") public ApiResponse> dailyBudgetWeekByDate(@UserContext MemberDto memberDto, - @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) String date) { + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) LocalDate date) { List dailyBudgets = budgetService.getDailyBudgetsByWeek(memberDto.getProfileId(), - LocalDate.parse(date)); + date); List responses = dailyBudgets.stream() .map(DailyBudgetResponse::of) @@ -64,6 +69,20 @@ public ApiResponse> dailyBudgetWeekByDate(@UserContext return ApiResponse.createSuccess(responses); } + // 해당 일자가 속한 달을 포함하여, 이전 6개월 조회 + @GetMapping("/montly") + public ApiResponse> monthlyBudgetsByDate(@UserContext MemberDto memberDto, + @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) LocalDate date) { + List monthlyBudgets = budgetService.getMonthlyBudgetsBy(memberDto.getProfileId(), + date, 6); + + List responses = monthlyBudgets.stream() + .map(MonthlyBudgetResponse::of) + .toList(); + + return ApiResponse.createSuccess(responses); + } + @GetMapping("/monthly/{yearMonth}") public ApiResponse monthlyBudgetByDate(@UserContext MemberDto memberDto, @PathVariable("yearMonth") @YearMonthFormat YearMonth yearMonth) { From 99a6daa469c4576738c8a8e5a569e4f001e14504 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Thu, 12 Jun 2025 23:55:25 +0900 Subject: [PATCH 38/44] =?UTF-8?q?test:=20Infra=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KakaoAddressApiServiceTest.java | 87 ++++++++++++++++--- .../infrastructure/SocialAuthServiceTest.java | 80 +++++++++++++++++ 2 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/stcom/smartmealtable/infrastructure/SocialAuthServiceTest.java diff --git a/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java b/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java index 780c790..4423c2f 100644 --- a/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java +++ b/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java @@ -1,26 +1,91 @@ package com.stcom.smartmealtable.infrastructure; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.exception.ExternApiStatusError; import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; 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.junit.jupiter.MockitoExtension; +import org.mockito.Mockito; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.RequestHeadersSpec; +import org.springframework.web.client.RestClient.RequestHeadersUriSpec; +import org.springframework.web.client.RestClient.ResponseSpec; -@ExtendWith(MockitoExtension.class) +@SuppressWarnings("unchecked") class KakaoAddressApiServiceTest { - @InjectMocks private KakaoAddressApiService kakaoAddressApiService; + private RestClient mockClient; + private RequestHeadersUriSpec uriSpec; + private RequestHeadersSpec headersSpec; + private ResponseSpec responseSpec; + + @BeforeEach + void setUp() { + kakaoAddressApiService = new KakaoAddressApiService(); + mockClient = Mockito.mock(RestClient.class); + uriSpec = Mockito.mock(RequestHeadersUriSpec.class); + headersSpec = Mockito.mock(RequestHeadersSpec.class); + responseSpec = Mockito.mock(ResponseSpec.class); + + // stub common chain + Mockito.when(mockClient.get()).thenAnswer(invocation -> uriSpec); + Mockito.when((uriSpec).uri(Mockito.any(Function.class))).thenAnswer(invocation -> headersSpec); + Mockito.when(headersSpec.header(Mockito.eq("Authorization"), Mockito.anyString())).thenAnswer(invocation -> headersSpec); + + ReflectionTestUtils.setField(kakaoAddressApiService, "client", mockClient); + ReflectionTestUtils.setField(kakaoAddressApiService, "clientId", "testKey"); + } + @DisplayName("정상적으로 주소를 생성한다") @Test - @DisplayName("카카오 주소 API 서비스 테스트") - void kakaoAddressApiServiceTest() { + void createAddressFromRequest_success() { // given - ReflectionTestUtils.setField(kakaoAddressApiService, "clientId", "test-client-id"); - - // 실제 API 호출이 필요한 테스트는 통합 테스트에서 수행해야 합니다. - // 이 단위 테스트에서는 RestClient 모킹이 복잡하므로 생략합니다. + KakaoAddressApiService.Meta meta = new KakaoAddressApiService.Meta(1, 1, true); + KakaoAddressApiService.LotAddress lotAddress = new KakaoAddressApiService.LotAddress( + "lotaddr", "reg1", "reg2", "reg3", "reg3H", "hCode", "bCode", "N", + "123", "4", "127.123", "37.123"); + KakaoAddressApiService.RoadAddress roadAddress = new KakaoAddressApiService.RoadAddress( + "roadaddr", "reg1", "reg2", "reg3", "road", "N", "1", "2", "building", + "12345", "127.123", "37.123"); + KakaoAddressApiService.Document document = new KakaoAddressApiService.Document( + "address", "type", "127.123", "37.123", lotAddress, roadAddress); + KakaoAddressApiService.AddressSearchResponse response = new KakaoAddressApiService.AddressSearchResponse( + meta, List.of(document)); + + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(KakaoAddressApiService.AddressSearchResponse.class)) + .thenReturn(response); + + AddressRequest request = new AddressRequest("roadaddr", "detailaddr"); + + // when + Address result = kakaoAddressApiService.createAddressFromRequest(request); + + // then + assertEquals(127.123, result.getLongitude()); + assertEquals(37.123, result.getLatitude()); + assertEquals("lotaddr", result.getLotNumberAddress()); + assertEquals("roadaddr", result.getRoadAddress()); + assertEquals("detailaddr", result.getDetailAddress()); + } + + @DisplayName("외부 API 호출 실패 시 ExternApiStatusError를 던진다") + @Test + void createAddressFromRequest_fail() { + // given + Mockito.when(headersSpec.retrieve()).thenThrow(new RuntimeException("connection error")); + + AddressRequest request = new AddressRequest("roadaddr", "detailaddr"); + + // when & then + assertThrows(ExternApiStatusError.class, () -> kakaoAddressApiService.createAddressFromRequest(request)); } } \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/infrastructure/SocialAuthServiceTest.java b/src/test/java/com/stcom/smartmealtable/infrastructure/SocialAuthServiceTest.java new file mode 100644 index 0000000..f53ee5c --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/infrastructure/SocialAuthServiceTest.java @@ -0,0 +1,80 @@ +package com.stcom.smartmealtable.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +// ... existing code ... + +import com.stcom.smartmealtable.exception.ExternApiStatusError; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.infrastructure.social.GoogleHttpMessage; +import com.stcom.smartmealtable.infrastructure.social.KakaoHttpMessage; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.web.client.RestClient; + +@ExtendWith(MockitoExtension.class) +class SocialAuthServiceTest { + + @Mock + private KakaoHttpMessage kakaoHttpMessage; + + @Mock + private GoogleHttpMessage googleHttpMessage; + + @Mock + private RestClient.RequestBodySpec requestBodySpec; + + @Mock + private RestClient.ResponseSpec responseSpec; + + @InjectMocks + private SocialAuthService socialAuthService; + + @BeforeEach + void setUp() { + socialAuthService.init(); + } + + @DisplayName("정상적으로 토큰을 반환한다") + @Test + void getTokenResponse_success() { + // given + TokenDto expected = TokenDto.builder() + .accessToken("access") + .refreshToken("refresh") + .expiresIn(3600) + .provider("kakao") + .build(); + + when(kakaoHttpMessage.getRequestMessage(any(RestClient.class), eq("authCode"))) + .thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + when(kakaoHttpMessage.getTokenResponse(responseSpec)).thenReturn(expected); + + // when + TokenDto actual = socialAuthService.getTokenResponse("kakao", "authCode"); + + // then + assertEquals(expected, actual); + } + + @DisplayName("외부 API 예외 발생 시 ExternApiStatusError를 던진다") + @Test + void getTokenResponse_fail() { + // given + when(kakaoHttpMessage.getRequestMessage(any(RestClient.class), eq("authCode"))) + .thenThrow(new RuntimeException("api error")); + + // when & then + assertThrows(ExternApiStatusError.class, + () -> socialAuthService.getTokenResponse("kakao", "authCode")); + } +} \ No newline at end of file From 806207803bd387849a06893f08ba2fdcf1355015 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Fri, 13 Jun 2025 01:23:39 +0900 Subject: [PATCH 39/44] =?UTF-8?q?test:=20Persistence=20Layer=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/ControllerTestSupport.java | 50 ++++++ .../web/controller/GroupControllerTest.java | 167 +++--------------- .../MemberAccountControllerTest.java | 47 +++++ .../MemberAddressControllerTest.java | 76 ++++++++ .../MemberBudgetControllerTest.java | 119 +++++++++++++ .../MemberExpenditureControllerTest.java | 106 +++++++++++ .../MemberPreferenceControllerTest.java | 62 +++++++ .../controller/SchoolGroupControllerTest.java | 62 +++++++ 8 files changed, 549 insertions(+), 140 deletions(-) create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/ControllerTestSupport.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberAccountControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberAddressControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberExpenditureControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberPreferenceControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/SchoolGroupControllerTest.java diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/ControllerTestSupport.java b/src/test/java/com/stcom/smartmealtable/web/controller/ControllerTestSupport.java new file mode 100644 index 0000000..9625330 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/ControllerTestSupport.java @@ -0,0 +1,50 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.core.MethodParameter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.bind.support.WebDataBinderFactory; + +/** + * Controller 테스트를 위한 공통 지원 클래스. + * 각 테스트 클래스에서 controller 인스턴스를 넘기면 MockMvc 를 만들어준다. + */ +public abstract class ControllerTestSupport { + + protected MockMvc mockMvc; + private final HandlerMethodArgumentResolver userContextResolver = new StubUserContextResolver(); + + protected void setUp(Object... controllers) { + this.mockMvc = MockMvcBuilders.standaloneSetup(controllers) + .setCustomArgumentResolvers(userContextResolver) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .build(); + } + + /** + * UserContext 를 무시하고 고정된 MemberDto 를 반환하는 스텁 ArgumentResolver. + */ + private static class StubUserContextResolver implements HandlerMethodArgumentResolver { + private final MemberDto stub = new MemberDto(1L, 1L, "test@example.com"); + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserContext.class) && + MemberDto.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return stub; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java index a5e777f..f6ed185 100644 --- a/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java +++ b/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java @@ -1,169 +1,56 @@ package com.stcom.smartmealtable.web.controller; -import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.anyString; 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.result.MockMvcResultHandlers.print; 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 com.stcom.smartmealtable.domain.Address.Address; -import com.stcom.smartmealtable.domain.group.CompanyGroup; import com.stcom.smartmealtable.domain.group.Group; -import com.stcom.smartmealtable.domain.group.IndustryType; -import com.stcom.smartmealtable.domain.group.SchoolGroup; -import com.stcom.smartmealtable.domain.group.SchoolType; -import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.domain.Address.Address; import com.stcom.smartmealtable.service.GroupService; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.filter.CharacterEncodingFilter; - -import io.jsonwebtoken.Claims; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) -@AutoConfigureMockMvc -@Transactional -@TestPropertySource(properties = { - "spring.main.allow-bean-definition-overriding=true" -}) -class GroupControllerTest { +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; - @Autowired - private MockMvc mockMvc; +@SpringJUnitConfig +class GroupControllerTest extends ControllerTestSupport { - @Autowired - private ObjectMapper objectMapper; - - @MockBean private GroupService groupService; - - @MockBean - private JwtTokenService jwtTokenService; - - @Autowired - private WebApplicationContext context; - + @BeforeEach - void setup() { - // 테스트용 MockMvc 설정 - mockMvc = MockMvcBuilders - .webAppContextSetup(context) - .addFilter(new CharacterEncodingFilter("UTF-8", true)) - .build(); - - // UserContext 어노테이션 처리를 위한 설정 - // Claims 모킹 설정 - Claims claims = Mockito.mock(Claims.class); - when(claims.get("memberId", String.class)).thenReturn("1"); - when(jwtTokenService.extractClaims(Mockito.anyString())).thenReturn(claims); + void init() { + groupService = Mockito.mock(GroupService.class); + GroupController controller = new GroupController(groupService); + super.setUp(controller); } @Test - @DisplayName("키워드로 그룹을 검색할 수 있다") + @DisplayName("GET /api/v1/groups?keyword= - 그룹 검색") void searchGroup() throws Exception { - // given - String keyword = "테스트"; - - // 테스트용 그룹 생성 - CompanyGroup companyGroup = createCompanyGroup("테스트 회사", IndustryType.IT, - createAddress("서울시 강남구 테헤란로 123")); - - SchoolGroup schoolGroup = createSchoolGroup("테스트 학교", SchoolType.UNIVERSITY_FOUR_YEAR, - createAddress("서울시 서초구 방배로 456")); - - when(groupService.findGroupsByKeyword(keyword)) - .thenReturn(List.of(companyGroup, schoolGroup)); - - // when & then - mockMvc.perform(get("/api/v1/groups") - .param("keyword", keyword) - .contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SUCCESS")) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(2)) - .andExpect(jsonPath("$.data[0].name").value("테스트 회사")) - .andExpect(jsonPath("$.data[0].groupType").value("IT")) - .andExpect(jsonPath("$.data[0].roadAddress").value("서울시 강남구 테헤란로 123")) - .andExpect(jsonPath("$.data[1].name").value("테스트 학교")) - .andExpect(jsonPath("$.data[1].groupType").value("UNIVERSITY_FOUR_YEAR")) - .andExpect(jsonPath("$.data[1].roadAddress").value("서울시 서초구 방배로 456")); - } - - @Test - @DisplayName("빈 키워드로 그룹 검색시 에러가 발생한다") - void searchGroup_EmptyKeyword() throws Exception { - // given - String keyword = ""; - - // when & then - mockMvc.perform(get("/api/v1/groups") - .param("keyword", keyword) - .contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) + Group g = Mockito.mock(Group.class); + Address addr = Mockito.mock(Address.class); + Mockito.when(addr.getRoadAddress()).thenReturn("도로명"); + Mockito.when(g.getAddress()).thenReturn(addr); + Mockito.when(g.getName()).thenReturn("그룹"); + Mockito.when(g.getTypeName()).thenReturn("TYPE"); + Mockito.when(g.getId()).thenReturn(1L); + when(groupService.findGroupsByKeyword(anyString())).thenReturn(List.of(g)); + + mockMvc.perform(get("/api/v1/groups").param("keyword", "school")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("ERROR")) - .andExpect(jsonPath("$.message").value("키워드가 비어있습니다. 키워드를 입력해주세요")); + .andExpect(jsonPath("$.status").value("SUCCESS")); } @Test - @DisplayName("키워드로 그룹 검색시 결과가 없으면 빈 리스트를 반환한다") - void searchGroup_NoResults() throws Exception { - // given - String keyword = "존재하지 않는 키워드"; - - when(groupService.findGroupsByKeyword(keyword)) - .thenReturn(List.of()); - - // when & then - mockMvc.perform(get("/api/v1/groups") - .param("keyword", keyword) - .contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) + @DisplayName("DELETE /api/v1/groups/{id} - 그룹 삭제") + void deleteGroup() throws Exception { + mockMvc.perform(delete("/api/v1/groups/1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SUCCESS")) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(0)); - } - - // 테스트용 주소 생성 헬퍼 메소드 - private Address createAddress(String roadAddress) { - Address address = mock(Address.class); - when(address.getRoadAddress()).thenReturn(roadAddress); - return address; - } - - // 테스트용 회사 그룹 생성 헬퍼 메소드 - private CompanyGroup createCompanyGroup(String name, IndustryType industryType, Address address) { - CompanyGroup group = mock(CompanyGroup.class); - when(group.getName()).thenReturn(name); - when(group.getTypeName()).thenReturn(industryType.getDescription()); - when(group.getAddress()).thenReturn(address); - return group; - } - - // 테스트용 학교 그룹 생성 헬퍼 메소드 - private SchoolGroup createSchoolGroup(String name, SchoolType schoolType, Address address) { - SchoolGroup group = mock(SchoolGroup.class); - when(group.getName()).thenReturn(name); - when(group.getTypeName()).thenReturn(schoolType.name()); - when(group.getAddress()).thenReturn(address); - return group; + .andExpect(jsonPath("$.status").value("SUCCESS")); } } \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberAccountControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberAccountControllerTest.java new file mode 100644 index 0000000..893082f --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberAccountControllerTest.java @@ -0,0 +1,47 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class MemberAccountControllerTest extends ControllerTestSupport { + + private MemberService memberService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + memberService = Mockito.mock(MemberService.class); + MemberAccountController controller = new MemberAccountController(memberService); + super.setUp(controller); + } + + @Test + @DisplayName("PATCH /api/v1/members/me/password - 비밀번호 변경") + void changePassword() throws Exception { + String body = "{\"originPassword\":\"old\", \"newPassword\":\"newPass1!\", \"confirmPassword\":\"newPass1!\"}"; + mockMvc.perform(patch("/api/v1/members/me/password") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("DELETE /api/v1/members/me - 회원 탈퇴") + void deleteMember() throws Exception { + mockMvc.perform(delete("/api/v1/members/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberAddressControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberAddressControllerTest.java new file mode 100644 index 0000000..fe4cf10 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberAddressControllerTest.java @@ -0,0 +1,76 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.infrastructure.AddressApiService; +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.service.MemberProfileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class MemberAddressControllerTest extends ControllerTestSupport { + + private MemberProfileService profileService; + private AddressApiService addressApiService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + profileService = Mockito.mock(MemberProfileService.class); + addressApiService = Mockito.mock(AddressApiService.class); + MemberAddressController controller = new MemberAddressController(profileService, addressApiService); + super.setUp(controller); + } + + @Test + @DisplayName("POST /{id}/primary - 기본 주소 변경") + void changePrimary() throws Exception { + mockMvc.perform(post("/api/v1/members/me/addresses/1/primary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("POST /api/v1/members/me/addresses - 주소 등록") + void registerAddress() throws Exception { + Address addr = Mockito.mock(Address.class); + Mockito.when(addressApiService.createAddressFromRequest(Mockito.any())).thenReturn(addr); + String body = "{\"roadAddress\":\"도로명\", \"addressType\":\"HOME\", \"alias\":\"집\", \"detailAddress\":\"101호\"}"; + mockMvc.perform(post("/api/v1/members/me/addresses") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PATCH /{id} - 주소 수정") + void editAddress() throws Exception { + Address addr = Mockito.mock(Address.class); + Mockito.when(addressApiService.createAddressFromRequest(Mockito.any())).thenReturn(addr); + String body = "{\"roadAddress\":\"도로명\", \"addressType\":\"HOME\", \"alias\":\"학교\", \"detailAddress\":\"102호\"}"; + mockMvc.perform(patch("/api/v1/members/me/addresses/1") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("DELETE /{id} - 주소 삭제") + void deleteAddress() throws Exception { + mockMvc.perform(delete("/api/v1/members/me/addresses/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java new file mode 100644 index 0000000..46a7ed0 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java @@ -0,0 +1,119 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +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.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.service.BudgetService; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +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.Mock; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class MemberBudgetControllerTest extends ControllerTestSupport { + + @Mock + private BudgetService budgetService; + + @BeforeEach + void setUpTest() { + budgetService = Mockito.mock(BudgetService.class); + MemberBudgetController controller = new MemberBudgetController(budgetService); + super.setUp(controller); + } + + @Test + @DisplayName("GET /daily/{date} - 일별 예산 조회") + void dailyBudget() throws Exception { + DailyBudget budget = Mockito.mock(DailyBudget.class); + when(budget.getSpendAmount()).thenReturn(BigDecimal.valueOf(1000)); + when(budget.getLimit()).thenReturn(BigDecimal.valueOf(10000)); + when(budget.getAvailableAmount()).thenReturn(BigDecimal.valueOf(9000)); + when(budgetService.getDailyBudgetBy(anyLong(), any(LocalDate.class))).thenReturn(budget); + + mockMvc.perform(get("/api/v1/members/me/budgets/daily/2025-06-12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PUT /daily/{date}/default - 기본 일별 예산 등록") + void registerDefaultDaily() throws Exception { + mockMvc.perform(put("/api/v1/members/me/budgets/daily/2025-06-12/default") + .param("limit", "10000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PATCH /daily/{date} - 일별 예산 수정") + void editDaily() throws Exception { + mockMvc.perform(patch("/api/v1/members/me/budgets/daily/2025-06-12") + .param("limit", "15000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("GET /daily/{date}/week - 주간 일별 예산 리스트") + void dailyWeek() throws Exception { + DailyBudget budget = Mockito.mock(DailyBudget.class); + when(budget.getSpendAmount()).thenReturn(BigDecimal.valueOf(1000)); + when(budget.getLimit()).thenReturn(BigDecimal.valueOf(10000)); + when(budget.getAvailableAmount()).thenReturn(BigDecimal.valueOf(9000)); + when(budgetService.getDailyBudgetsByWeek(anyLong(), any(LocalDate.class))).thenReturn(List.of(budget)); + + mockMvc.perform(get("/api/v1/members/me/budgets/daily/2025-06-12/week")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("GET /monthly/{yearMonth} - 월별 예산 조회") + void monthly() throws Exception { + MonthlyBudget mb = Mockito.mock(MonthlyBudget.class); + when(mb.getSpendAmount()).thenReturn(BigDecimal.valueOf(1000)); + when(mb.getLimit()).thenReturn(BigDecimal.valueOf(10000)); + when(mb.getAvailableAmount()).thenReturn(BigDecimal.valueOf(9000)); + when(budgetService.getMonthlyBudgetBy(anyLong(), any(YearMonth.class))).thenReturn(mb); + + mockMvc.perform(get("/api/v1/members/me/budgets/monthly/2025-06")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PUT /monthly/{yearMonth}/default - 기본 월별 예산 등록") + void registerDefaultMonthly() throws Exception { + mockMvc.perform(put("/api/v1/members/me/budgets/monthly/2025-06/default") + .param("limit", "300000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PATCH /monthly/{yearMonth} - 월별 예산 수정") + void editMonthly() throws Exception { + mockMvc.perform(patch("/api/v1/members/me/budgets/monthly/2025-06") + .param("limit", "350000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberExpenditureControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberExpenditureControllerTest.java new file mode 100644 index 0000000..6211bac --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberExpenditureControllerTest.java @@ -0,0 +1,106 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.component.creditmessage.CreditMessageManager; +import com.stcom.smartmealtable.component.creditmessage.ExpenditureDto; +import com.stcom.smartmealtable.domain.Budget.Expenditure; +import com.stcom.smartmealtable.service.ExpenditureService; +import java.time.LocalDateTime; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class MemberExpenditureControllerTest extends ControllerTestSupport { + + private ExpenditureService expenditureService; + private CreditMessageManager creditMessageManager; + + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + expenditureService = Mockito.mock(ExpenditureService.class); + creditMessageManager = Mockito.mock(CreditMessageManager.class); + MemberExpenditureController controller = new MemberExpenditureController(expenditureService, creditMessageManager); + super.setUp(controller); + } + + @Test + @DisplayName("POST /messages/parse - 카드 메시지 파싱") + void parseMessage() throws Exception { + when(creditMessageManager.parseMessage(any())).thenReturn(new ExpenditureDto("vendor", LocalDateTime.now(), 1000L, "trade")); + mockMvc.perform(post("/api/v1/members/me/expenditures/messages/parse") + .contentType("application/json") + .content("{\"message\":\"some msg\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("GET /api/v1/members/me/expenditures - 목록 조회") + void listExpenditures() throws Exception { + Expenditure ex = Mockito.mock(Expenditure.class); + Mockito.when(ex.getId()).thenReturn(1L); + Mockito.when(ex.getSpentDate()).thenReturn(LocalDateTime.now()); + Mockito.when(ex.getAmount()).thenReturn(1000L); + Mockito.when(ex.getTradeName()).thenReturn("점심"); + + // Pageable.unpaged() 대신 PageRequest.of() 사용 (JSON 직렬화 문제 해결) + Pageable pageable = PageRequest.of(0, 10); + Slice slice = new SliceImpl<>(Collections.singletonList(ex), pageable, false); + when(expenditureService.getExpenditures(anyLong(), anyInt(), anyInt())).thenReturn(slice); + + mockMvc.perform(get("/api/v1/members/me/expenditures")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("POST /api/v1/members/me/expenditures - 지출 등록") + void registerExpenditure() throws Exception { + String body = "{\"spentDate\":\"2025-06-12T12:00:00\", \"amount\":1000, \"tradeName\":\"점심\"}"; + mockMvc.perform(post("/api/v1/members/me/expenditures") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PATCH /{id} - 지출 수정") + void editExpenditure() throws Exception { + String body = "{\"spentDate\":\"2025-06-12T18:00:00\", \"amount\":1500, \"tradeName\":\"저녁\"}"; + mockMvc.perform(patch("/api/v1/members/me/expenditures/1") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("DELETE /{id} - 지출 삭제") + void deleteExpenditure() throws Exception { + mockMvc.perform(delete("/api/v1/members/me/expenditures/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberPreferenceControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberPreferenceControllerTest.java new file mode 100644 index 0000000..12808fd --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberPreferenceControllerTest.java @@ -0,0 +1,62 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.anyLong; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.food.FoodCategory; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.service.MemberCategoryPreferenceService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class MemberPreferenceControllerTest extends ControllerTestSupport { + + private MemberCategoryPreferenceService preferenceService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + preferenceService = Mockito.mock(MemberCategoryPreferenceService.class); + MemberPreferenceController controller = new MemberPreferenceController(preferenceService); + super.setUp(controller); + } + + @Test + @DisplayName("GET /api/v1/members/me/preferences - 선호 카테고리 조회") + void getPreferences() throws Exception { + MemberCategoryPreference pref = Mockito.mock(MemberCategoryPreference.class); + FoodCategory cat = Mockito.mock(FoodCategory.class); + Mockito.when(cat.getId()).thenReturn(1L); + Mockito.when(cat.getName()).thenReturn("카테고리"); + when(pref.getType()).thenReturn(PreferenceType.LIKE); + when(pref.getPriority()).thenReturn(1); + when(pref.getCategory()).thenReturn(cat); + when(preferenceService.getPreferences(anyLong())).thenReturn(List.of(pref)); + + mockMvc.perform(get("/api/v1/members/me/preferences")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("POST /api/v1/members/me/preferences - 선호 카테고리 저장") + void savePreferences() throws Exception { + String body = "{\"liked\":[1,2], \"disliked\":[3]}"; + mockMvc.perform(post("/api/v1/members/me/preferences") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/SchoolGroupControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/SchoolGroupControllerTest.java new file mode 100644 index 0000000..da8402d --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/SchoolGroupControllerTest.java @@ -0,0 +1,62 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.service.GroupService; +import com.stcom.smartmealtable.web.dto.group.SchoolGroupCreateRequest; +import com.stcom.smartmealtable.web.dto.group.SchoolGroupUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class SchoolGroupControllerTest extends ControllerTestSupport { + + private GroupService groupService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + groupService = Mockito.mock(GroupService.class); + SchoolGroupController controller = new SchoolGroupController(groupService); + super.setUp(controller); + } + + @Test + @DisplayName("POST /api/v1/schools - 학교 그룹 등록") + void registerSchool() throws Exception { + SchoolGroupCreateRequest req = new SchoolGroupCreateRequest("road", "detail", "학교", SchoolType.UNIVERSITY_FOUR_YEAR); + mockMvc.perform(post("/api/v1/schools") + .contentType("application/json") + .content(om.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("PATCH /api/v1/schools/{id} - 학교 그룹 수정") + void editSchool() throws Exception { + SchoolGroupUpdateRequest req = new SchoolGroupUpdateRequest("road", "detail", "학교", SchoolType.UNIVERSITY_FOUR_YEAR); + mockMvc.perform(patch("/api/v1/schools/1") + .contentType("application/json") + .content(om.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("DELETE /api/v1/schools/{id} - 학교 그룹 삭제") + void deleteSchool() throws Exception { + mockMvc.perform(delete("/api/v1/schools/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file From 1b71d8d16d314d7162dbc7e73f5b29868d33bbaf Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Fri, 13 Jun 2025 02:20:11 +0900 Subject: [PATCH 40/44] =?UTF-8?q?fix:=20Api=20Request=20validation?= =?UTF-8?q?=EC=9D=84=20Controller=EC=97=90=EC=84=9C=EB=8F=84=20=EC=88=98?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/AuthController.java | 7 ++++++- .../web/controller/AuthTokenController.java | 19 ++++++++++--------- .../controller/MemberProfileController.java | 2 ++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java b/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java index 4f5c6ef..d1eb56e 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/AuthController.java @@ -12,6 +12,7 @@ import com.stcom.smartmealtable.web.dto.ApiResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; @@ -87,10 +88,14 @@ public ApiResponse cancelSignUp(@UserContext MemberDto memberDto) { @Data @AllArgsConstructor public static class SignUpRequest { - @Email + @Email(message = "유효한 이메일 형식이 아닙니다") + @NotEmpty(message = "이메일은 비어있을 수 없습니다") private String email; + @NotEmpty(message = "비밀번호는 비어있을 수 없습니다") private String password; + @NotEmpty(message = "비밀번호 확인은 비어있을 수 없습니다") private String confirmPassword; + @NotEmpty(message = "이름은 비어있을 수 없습니다") private String fullName; } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java b/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java index 8518502..d201cac 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/AuthTokenController.java @@ -11,6 +11,7 @@ import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotEmpty; import java.util.Objects; @@ -38,7 +39,7 @@ public class AuthTokenController { private final SocialAuthService socialAuthService; @PostMapping("/login") - public ApiResponse login(@RequestBody EmailLoginRequest request) { + public ApiResponse login(@Valid @RequestBody EmailLoginRequest request) { AuthResultDto authResultDto = loginService.loginWithEmail(request.getEmail(), request.getPassword()); JwtTokenResponseDto jwtDto = jwtTokenService.createTokenDto(authResultDto.getMemberId(), authResultDto.getProfileId()); @@ -58,7 +59,7 @@ public ApiResponse logout(HttpServletRequest request) { } @PostMapping("/oauth2/code") - public ApiResponse socialLogin(@RequestBody SocialLoginRequest request) { + public ApiResponse socialLogin(@Valid @RequestBody SocialLoginRequest request) { TokenDto token = socialAuthService.getTokenResponse(request.getProvider().toLowerCase(), request.getAuthorizationCode()); AuthResultDto authResultDto = loginService.socialLogin(token); @@ -72,7 +73,7 @@ public ApiResponse socialLogin(@RequestBody SocialLoginRequ @PostMapping("/token/refresh") public ApiResponse refreshAccessToken(@UserContext MemberDto memberDto, - @RequestBody RefreshTokenRequest request) { + @Valid @RequestBody RefreshTokenRequest request) { String accessToken = jwtTokenService.createAccessToken(memberDto.getMemberId(), memberDto.getProfileId()); return ApiResponse.createSuccess(new AccessTokenRefreshResponse(accessToken, 3600, "Bearer")); } @@ -80,25 +81,25 @@ public ApiResponse refreshAccessToken(@UserContext M @Data @AllArgsConstructor public static class EmailLoginRequest { - @NotEmpty - @Email + @NotEmpty(message = "이메일은 비어있을 수 없습니다") + @Email(message = "유효한 이메일 형식이 아닙니다") private String email; - @NotEmpty + @NotEmpty(message = "비밀번호는 비어있을 수 없습니다") private String password; } @Data @AllArgsConstructor public static class SocialLoginRequest { - @NotEmpty + @NotEmpty(message = "Provider는 비어있을 수 없습니다") private String provider; - @NotEmpty + @NotEmpty(message = "인증 코드는 비어있을 수 없습니다") private String authorizationCode; } @Data public static class RefreshTokenRequest { - @NotEmpty + @NotEmpty(message = "리프레시 토큰은 비어있을 수 없습니다") private String refreshToken; } diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java index c1c58e0..6870752 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -6,6 +6,7 @@ import com.stcom.smartmealtable.service.dto.MemberDto; import com.stcom.smartmealtable.web.argumentresolver.UserContext; import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -63,6 +64,7 @@ public MemberProfilePageResponse(MemberProfile profile, MemberDto memberDto) { @AllArgsConstructor @Data static class MemberProfileRequest { + @NotEmpty(message = "닉네임은 비어있을 수 없습니다") private String nickName; private Long groupId; private MemberType memberType; From 8a622350b431a90747a931a26e129a11616e13da Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Fri, 13 Jun 2025 02:20:35 +0900 Subject: [PATCH 41/44] =?UTF-8?q?test:=20Controller=20Validation=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/AuthControllerTest.java | 174 +++++++++++++++ .../controller/AuthTokenControllerTest.java | 198 ++++++++++++++++++ .../MemberProfileControllerTest.java | 136 ++++++++++++ 3 files changed, 508 insertions(+) create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/AuthControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/AuthTokenControllerTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/controller/MemberProfileControllerTest.java diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/AuthControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/AuthControllerTest.java new file mode 100644 index 0000000..7236be6 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/AuthControllerTest.java @@ -0,0 +1,174 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.MemberService; +import com.stcom.smartmealtable.service.TermService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +@SpringJUnitConfig +class AuthControllerTest extends ControllerTestSupport { + + private MemberService memberService; + private JwtTokenService jwtTokenService; + private TermService termService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + memberService = Mockito.mock(MemberService.class); + jwtTokenService = Mockito.mock(JwtTokenService.class); + termService = Mockito.mock(TermService.class); + + AuthController controller = new AuthController(memberService, jwtTokenService, termService); + super.setUp(controller); + } + + @Test + @DisplayName("GET /api/v1/auth/email/check - 이메일 중복 확인 성공") + void checkEmail_Available() throws Exception { + // given + doNothing().when(memberService).validateDuplicatedEmail(anyString()); + + // when & then + mockMvc.perform(get("/api/v1/auth/email/check") + .param("email", "available@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(memberService).validateDuplicatedEmail("available@example.com"); + } + + @Test + @DisplayName("GET /api/v1/auth/email/check - 유효하지 않은 이메일 형식") + void checkEmail_InvalidFormat() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/auth/email/check") + .param("email", "invalid-email")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /api/v1/auth/signup - 회원가입 성공") + void signUp_Success() throws Exception { + // given + JwtTokenResponseDto tokenDto = new JwtTokenResponseDto("accessToken", "refreshToken", 3600, "Bearer"); + + doNothing().when(memberService).validateDuplicatedEmail(anyString()); + doNothing().when(memberService).checkPasswordDoubly(anyString(), anyString()); + + // Member를 저장할 때 ID를 설정하는 동작을 모킹 + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + doAnswer(invocation -> { + Member member = invocation.getArgument(0); + // Reflection을 사용하여 ID 설정 (테스트에서만 사용) + ReflectionTestUtils.setField(member, "id", 1L); + return null; + }).when(memberService).saveMember(memberCaptor.capture()); + + when(jwtTokenService.createTokenDto(eq(1L), isNull())).thenReturn(tokenDto); + + String requestBody = om.writeValueAsString( + new AuthController.SignUpRequest("test@example.com", "password123!", "password123!", "테스트 사용자")); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.accessToken").value("accessToken")) + .andExpect(jsonPath("$.data.newUser").value(true)); + } + + @Test + @DisplayName("POST /api/v1/auth/signup - 유효하지 않은 이메일 형식으로 회원가입 실패") + void signUp_InvalidEmail() throws Exception { + // given + String requestBody = om.writeValueAsString( + new AuthController.SignUpRequest("invalid-email", "password123!", "password123!", "테스트 사용자")); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /api/v1/auth/signup/terms - 약관 동의") + void agreeTerms() throws Exception { + // given + List agreements = List.of( + new AuthController.TermAgreementRequest(1L, true), + new AuthController.TermAgreementRequest(2L, false) + ); + + String requestBody = om.writeValueAsString(agreements); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup/terms") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(termService).agreeTerms(eq(1L), anyList()); + } + + @Test + @DisplayName("DELETE /api/v1/auth/signup - 회원가입 취소") + void cancelSignUp() throws Exception { + // when & then + mockMvc.perform(delete("/api/v1/auth/signup")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(memberService).deleteByMemberId(1L); + } + + @Test + @DisplayName("POST /api/v1/auth/signup - 빈 이름으로 회원가입 실패") + void signUp_EmptyFullName() throws Exception { + // given + String requestBody = om.writeValueAsString( + new AuthController.SignUpRequest("test@example.com", "password123!", "password123!", "")); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /api/v1/auth/signup/terms - 빈 약관 동의 목록") + void agreeTerms_EmptyList() throws Exception { + // given + String requestBody = "[]"; + + // when & then + mockMvc.perform(post("/api/v1/auth/signup/terms") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(termService).agreeTerms(eq(1L), anyList()); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/AuthTokenControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/AuthTokenControllerTest.java new file mode 100644 index 0000000..ed6afbf --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/AuthTokenControllerTest.java @@ -0,0 +1,198 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.infrastructure.SocialAuthService; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.security.JwtBlacklistService; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.LoginService; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class AuthTokenControllerTest extends ControllerTestSupport { + + private LoginService loginService; + private JwtTokenService jwtTokenService; + private JwtBlacklistService jwtBlacklistService; + private SocialAuthService socialAuthService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + loginService = Mockito.mock(LoginService.class); + jwtTokenService = Mockito.mock(JwtTokenService.class); + jwtBlacklistService = Mockito.mock(JwtBlacklistService.class); + socialAuthService = Mockito.mock(SocialAuthService.class); + + AuthTokenController controller = new AuthTokenController( + loginService, jwtTokenService, jwtBlacklistService, socialAuthService); + super.setUp(controller); + } + + @Test + @DisplayName("POST /api/v1/auth/login - 이메일 로그인") + void login() throws Exception { + // given + AuthResultDto authResult = new AuthResultDto(1L, 1L, false); + JwtTokenResponseDto tokenDto = new JwtTokenResponseDto("accessToken", "refreshToken", 3600, "Bearer"); + + when(loginService.loginWithEmail(anyString(), anyString())).thenReturn(authResult); + when(jwtTokenService.createTokenDto(anyLong(), anyLong())).thenReturn(tokenDto); + + String requestBody = om.writeValueAsString( + new AuthTokenController.EmailLoginRequest("test@example.com", "password123")); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.accessToken").value("accessToken")) + .andExpect(jsonPath("$.data.refreshToken").value("refreshToken")); + } + + @Test + @DisplayName("POST /api/v1/auth/login - 신규 사용자 로그인") + void login_newUser() throws Exception { + // given + AuthResultDto authResult = new AuthResultDto(1L, 1L, true); + JwtTokenResponseDto tokenDto = new JwtTokenResponseDto("accessToken", "refreshToken", 3600, "Bearer"); + + when(loginService.loginWithEmail(anyString(), anyString())).thenReturn(authResult); + when(jwtTokenService.createTokenDto(anyLong(), anyLong())).thenReturn(tokenDto); + + String requestBody = om.writeValueAsString( + new AuthTokenController.EmailLoginRequest("newuser@example.com", "password123")); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.newUser").value(true)); + } + + @Test + @DisplayName("POST /api/v1/auth/logout - 로그아웃") + void logout() throws Exception { + // when & then + mockMvc.perform(post("/api/v1/auth/logout") + .header("Authorization", "Bearer token123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(jwtBlacklistService).addToBlacklist("Bearer token123"); + } + + @Test + @DisplayName("POST /api/v1/auth/logout - Authorization 헤더 없이 로그아웃") + void logout_withoutAuthHeader() throws Exception { + // when & then + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(jwtBlacklistService, never()).addToBlacklist(any()); + } + + @Test + @DisplayName("POST /api/v1/auth/oauth2/code - 소셜 로그인") + void socialLogin() throws Exception { + // given + TokenDto tokenDto = TokenDto.builder() + .accessToken("socialAccessToken") + .tokenType("Bearer") + .expiresIn(3600) + .provider("google") + .build(); + AuthResultDto authResult = new AuthResultDto(2L, 2L, false); + JwtTokenResponseDto jwtTokenDto = new JwtTokenResponseDto("jwtAccessToken", "jwtRefreshToken", 3600, "Bearer"); + + when(socialAuthService.getTokenResponse(eq("google"), anyString())).thenReturn(tokenDto); + when(loginService.socialLogin(any(TokenDto.class))).thenReturn(authResult); + when(jwtTokenService.createTokenDto(anyLong(), anyLong())).thenReturn(jwtTokenDto); + + String requestBody = om.writeValueAsString( + new AuthTokenController.SocialLoginRequest("google", "authCode123")); + + // when & then + mockMvc.perform(post("/api/v1/auth/oauth2/code") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.accessToken").value("jwtAccessToken")); + } + + @Test + @DisplayName("POST /api/v1/auth/token/refresh - 액세스 토큰 갱신") + void refreshAccessToken() throws Exception { + // given + when(jwtTokenService.createAccessToken(anyLong(), anyLong())).thenReturn("newAccessToken"); + + AuthTokenController.RefreshTokenRequest request = new AuthTokenController.RefreshTokenRequest(); + // RefreshTokenRequest 필드 설정을 위해 reflection 사용하거나 JSON 문자열 사용 + String requestBody = "{\"refreshToken\":\"refreshToken123\"}"; + + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.accessToken").value("newAccessToken")) + .andExpect(jsonPath("$.data.expiresIn").value(3600)); + } + + @Test + @DisplayName("POST /api/v1/auth/login - 유효하지 않은 이메일 형식") + void login_invalidEmail() throws Exception { + // given + String requestBody = "{\"email\":\"invalid-email\", \"password\":\"password123\"}"; + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /api/v1/auth/login - 빈 비밀번호") + void login_emptyPassword() throws Exception { + // given + String requestBody = "{\"email\":\"test@example.com\", \"password\":\"\"}"; + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /api/v1/auth/oauth2/code - 빈 provider") + void socialLogin_emptyProvider() throws Exception { + // given + String requestBody = "{\"provider\":\"\", \"authorizationCode\":\"code123\"}"; + + // when & then + mockMvc.perform(post("/api/v1/auth/oauth2/code") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberProfileControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberProfileControllerTest.java new file mode 100644 index 0000000..6f5ef17 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberProfileControllerTest.java @@ -0,0 +1,136 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.service.MemberProfileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class MemberProfileControllerTest extends ControllerTestSupport { + + private MemberProfileService memberProfileService; + private final ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void init() { + memberProfileService = Mockito.mock(MemberProfileService.class); + MemberProfileController controller = new MemberProfileController(memberProfileService); + super.setUp(controller); + } + + @Test + @DisplayName("GET /api/v1/members/profiles/me - 회원 프로필 페이지 정보 조회") + void getMemberProfilePageInfo() throws Exception { + // given + Group group = Mockito.mock(Group.class); + when(group.getName()).thenReturn("테스트 그룹"); + + Address address = Address.builder() + .roadAddress("도로명주소") + .detailAddress("상세주소") + .build(); + AddressEntity addressEntity = AddressEntity.builder() + .address(address) + .alias("집") + .type(AddressType.HOME) + .build(); + addressEntity.markPrimary(); + + Member member = Mockito.mock(Member.class); + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickName("테스트닉네임") + .type(MemberType.STUDENT) + .group(group) + .build(); + profile.addAddress(addressEntity); + + when(memberProfileService.getProfileFetch(anyLong())).thenReturn(profile); + + // when & then + mockMvc.perform(get("/api/v1/members/profiles/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.nickName").value("테스트닉네임")) + .andExpect(jsonPath("$.data.email").value("test@example.com")) + .andExpect(jsonPath("$.data.memberType").value("STUDENT")) + .andExpect(jsonPath("$.data.groupName").value("테스트 그룹")); + } + + @Test + @DisplayName("POST /api/v1/members/profiles - 회원 프로필 생성") + void createMemberProfile() throws Exception { + // given + String requestBody = om.writeValueAsString( + new MemberProfileController.MemberProfileRequest("새닉네임", 1L, MemberType.WORKER) + ); + + // when & then + mockMvc.perform(post("/api/v1/members/profiles") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(memberProfileService).createProfile("새닉네임", 1L, MemberType.WORKER, 1L); + } + + @Test + @DisplayName("PATCH /api/v1/members/profiles/me - 회원 프로필 수정") + void changeMemberProfile() throws Exception { + // given + String requestBody = om.writeValueAsString( + new MemberProfileController.MemberProfileRequest("수정된닉네임", 2L, MemberType.STUDENT) + ); + + // when & then + mockMvc.perform(patch("/api/v1/members/profiles/me") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + verify(memberProfileService).changeProfile(1L, "수정된닉네임", MemberType.STUDENT, 2L); + } + + @Test + @DisplayName("POST /api/v1/members/profiles - 유효하지 않은 요청으로 프로필 생성 실패") + void createMemberProfile_ValidationError() throws Exception { + // given - 빈 닉네임 + String requestBody = "{\"nickName\":\"\", \"groupId\":1, \"memberType\":\"STUDENT\"}"; + + // when & then + mockMvc.perform(post("/api/v1/members/profiles") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PATCH /api/v1/members/profiles/me - 유효하지 않은 요청으로 프로필 수정 실패") + void changeMemberProfile_ValidationError() throws Exception { + // given - 빈 닉네임 + String requestBody = "{\"nickName\":\"\", \"groupId\":1, \"memberType\":\"STUDENT\"}"; + + // when & then + mockMvc.perform(patch("/api/v1/members/profiles/me") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file From e0e140bd4dd71e467dffb2b956233f67f2039584 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Fri, 13 Jun 2025 02:26:55 +0900 Subject: [PATCH 42/44] =?UTF-8?q?fix:=20Api=20URI=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MemberBudgetController.java | 2 +- .../MemberBudgetControllerTest.java | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java index d75fdcf..d0e654a 100644 --- a/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberBudgetController.java @@ -70,7 +70,7 @@ public ApiResponse> dailyBudgetWeekByDate(@UserContext } // 해당 일자가 속한 달을 포함하여, 이전 6개월 조회 - @GetMapping("/montly") + @GetMapping("/montly/{date}") public ApiResponse> monthlyBudgetsByDate(@UserContext MemberDto memberDto, @PathVariable("date") @DateTimeFormat(iso = ISO.DATE) LocalDate date) { List monthlyBudgets = budgetService.getMonthlyBudgetsBy(memberDto.getProfileId(), diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java index 46a7ed0..4d10e9e 100644 --- a/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberBudgetControllerTest.java @@ -116,4 +116,26 @@ void editMonthly() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("SUCCESS")); } + + @Test + @DisplayName("GET /montly - 이전 6개월 월별 예산 조회") + void monthlyBudgetsPreviousMonths() throws Exception { + MonthlyBudget mb1 = Mockito.mock(MonthlyBudget.class); + when(mb1.getSpendAmount()).thenReturn(BigDecimal.valueOf(1000)); + when(mb1.getLimit()).thenReturn(BigDecimal.valueOf(10000)); + when(mb1.getAvailableAmount()).thenReturn(BigDecimal.valueOf(9000)); + + MonthlyBudget mb2 = Mockito.mock(MonthlyBudget.class); + when(mb2.getSpendAmount()).thenReturn(BigDecimal.valueOf(2000)); + when(mb2.getLimit()).thenReturn(BigDecimal.valueOf(15000)); + when(mb2.getAvailableAmount()).thenReturn(BigDecimal.valueOf(13000)); + + when(budgetService.getMonthlyBudgetsBy(anyLong(), any(LocalDate.class), anyInt())).thenReturn(List.of(mb1, mb2)); + + mockMvc.perform(get("/api/v1/members/me/budgets/montly/2025-06-12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)); + } } \ No newline at end of file From ecad3565df0f4faee67015eacbdec971290d693e Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Fri, 13 Jun 2025 02:29:54 +0900 Subject: [PATCH 43/44] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20API=20URI=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stcom/smartmealtable/integration/MemberIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java index 30685a0..5a8ac55 100644 --- a/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java +++ b/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java @@ -50,7 +50,7 @@ void createMember() throws Exception { request.put("fullName", fullName); // when & then - mockMvc.perform(post("/api/v1/members") + mockMvc.perform(post("/api/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) From 4e14ffcdb983f6f8cefc854596a6233663850962 Mon Sep 17 00:00:00 2001 From: Junseo Bae Date: Fri, 13 Jun 2025 02:55:18 +0900 Subject: [PATCH 44/44] =?UTF-8?q?test:=20=EC=A3=BC=EC=86=8C=EB=B3=80?= =?UTF-8?q?=ED=99=98=20Api,=20Spring=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=B9=88=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KakaoAddressApiServiceTest.java | 125 ++++++++++++ .../persistence/YearMonthConverterTest.java | 115 +++++++++++ .../web/exhandler/ExControllerAdviceTest.java | 191 ++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 src/test/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverterTest.java create mode 100644 src/test/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdviceTest.java diff --git a/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java b/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java index 4423c2f..6bc9d8a 100644 --- a/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java +++ b/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java @@ -88,4 +88,129 @@ void createAddressFromRequest_fail() { // when & then assertThrows(ExternApiStatusError.class, () -> kakaoAddressApiService.createAddressFromRequest(request)); } + + @DisplayName("조회 결과가 null인 경우 IllegalArgumentException을 던진다") + @Test + void createAddressFromRequest_nullResponse() { + // given + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(KakaoAddressApiService.AddressSearchResponse.class)) + .thenReturn(null); + + AddressRequest request = new AddressRequest("roadaddr", "detailaddr"); + + // when & then + ExternApiStatusError exception = assertThrows(ExternApiStatusError.class, + () -> kakaoAddressApiService.createAddressFromRequest(request)); + assertEquals("카카오 주소 Api 호출 중 오류가 발생했습니다.", exception.getMessage()); + } + + @DisplayName("조회 결과가 2개 이상인 경우 IllegalArgumentException을 던진다") + @Test + void createAddressFromRequest_ambiguousAddress() { + // given + KakaoAddressApiService.Meta meta = new KakaoAddressApiService.Meta(2, 2, true); + KakaoAddressApiService.LotAddress lotAddress = new KakaoAddressApiService.LotAddress( + "lotaddr", "reg1", "reg2", "reg3", "reg3H", "hCode", "bCode", "N", + "123", "4", "127.123", "37.123"); + KakaoAddressApiService.RoadAddress roadAddress = new KakaoAddressApiService.RoadAddress( + "roadaddr", "reg1", "reg2", "reg3", "road", "N", "1", "2", "building", + "12345", "127.123", "37.123"); + KakaoAddressApiService.Document document = new KakaoAddressApiService.Document( + "address", "type", "127.123", "37.123", lotAddress, roadAddress); + KakaoAddressApiService.AddressSearchResponse response = new KakaoAddressApiService.AddressSearchResponse( + meta, List.of(document, document)); + + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(KakaoAddressApiService.AddressSearchResponse.class)) + .thenReturn(response); + + AddressRequest request = new AddressRequest("roadaddr", "detailaddr"); + + // when & then + ExternApiStatusError exception = assertThrows(ExternApiStatusError.class, + () -> kakaoAddressApiService.createAddressFromRequest(request)); + assertEquals("카카오 주소 Api 호출 중 오류가 발생했습니다.", exception.getMessage()); + } + + @DisplayName("조회 결과가 0개인 경우 정상 처리된다") + @Test + void createAddressFromRequest_noResults() { + // given + KakaoAddressApiService.Meta meta = new KakaoAddressApiService.Meta(0, 0, true); + KakaoAddressApiService.AddressSearchResponse response = new KakaoAddressApiService.AddressSearchResponse( + meta, List.of()); + + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(KakaoAddressApiService.AddressSearchResponse.class)) + .thenReturn(response); + + AddressRequest request = new AddressRequest("roadaddr", "detailaddr"); + + // when & then + assertThrows(ExternApiStatusError.class, + () -> kakaoAddressApiService.createAddressFromRequest(request)); + } + + @DisplayName("좌표값이 문자열로 전달되어도 정상 변환된다") + @Test + void createAddressFromRequest_stringCoordinates() { + // given + KakaoAddressApiService.Meta meta = new KakaoAddressApiService.Meta(1, 1, true); + KakaoAddressApiService.LotAddress lotAddress = new KakaoAddressApiService.LotAddress( + "서울특별시 강남구 역삼동 123-4", "서울특별시", "강남구", "역삼동", "", + "1168010500", "1168010500", "N", "123", "4", "127.033333", "37.500000"); + KakaoAddressApiService.RoadAddress roadAddress = new KakaoAddressApiService.RoadAddress( + "서울특별시 강남구 테헤란로 123", "서울특별시", "강남구", "역삼동", "테헤란로", + "N", "123", "", "타워빌딩", "06142", "127.033333", "37.500000"); + KakaoAddressApiService.Document document = new KakaoAddressApiService.Document( + "서울특별시 강남구 역삼동 123-4", "ROAD_ADDR", "127.033333", "37.500000", + lotAddress, roadAddress); + KakaoAddressApiService.AddressSearchResponse response = new KakaoAddressApiService.AddressSearchResponse( + meta, List.of(document)); + + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(KakaoAddressApiService.AddressSearchResponse.class)) + .thenReturn(response); + + AddressRequest request = new AddressRequest("서울특별시 강남구 테헤란로 123", "101호"); + + // when + Address result = kakaoAddressApiService.createAddressFromRequest(request); + + // then + assertEquals(127.033333, result.getLongitude()); + assertEquals(37.500000, result.getLatitude()); + assertEquals("서울특별시 강남구 역삼동 123-4", result.getLotNumberAddress()); + assertEquals("서울특별시 강남구 테헤란로 123", result.getRoadAddress()); + assertEquals("101호", result.getDetailAddress()); + } + + @DisplayName("숫자 변환 오류 시 ExternApiStatusError를 던진다") + @Test + void createAddressFromRequest_invalidCoordinates() { + // given + KakaoAddressApiService.Meta meta = new KakaoAddressApiService.Meta(1, 1, true); + KakaoAddressApiService.LotAddress lotAddress = new KakaoAddressApiService.LotAddress( + "lotaddr", "reg1", "reg2", "reg3", "reg3H", "hCode", "bCode", "N", + "123", "4", "invalid", "invalid"); + KakaoAddressApiService.RoadAddress roadAddress = new KakaoAddressApiService.RoadAddress( + "roadaddr", "reg1", "reg2", "reg3", "road", "N", "1", "2", "building", + "12345", "invalid", "invalid"); + KakaoAddressApiService.Document document = new KakaoAddressApiService.Document( + "address", "type", "invalid", "invalid", lotAddress, roadAddress); + KakaoAddressApiService.AddressSearchResponse response = new KakaoAddressApiService.AddressSearchResponse( + meta, List.of(document)); + + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(KakaoAddressApiService.AddressSearchResponse.class)) + .thenReturn(response); + + AddressRequest request = new AddressRequest("roadaddr", "detailaddr"); + + // when & then + ExternApiStatusError exception = assertThrows(ExternApiStatusError.class, + () -> kakaoAddressApiService.createAddressFromRequest(request)); + assertEquals("카카오 주소 Api 호출 중 오류가 발생했습니다.", exception.getMessage()); + } } \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverterTest.java b/src/test/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverterTest.java new file mode 100644 index 0000000..315a077 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverterTest.java @@ -0,0 +1,115 @@ +package com.stcom.smartmealtable.infrastructure.persistence; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.sql.Date; +import java.time.LocalDate; +import java.time.YearMonth; + +import static org.assertj.core.api.Assertions.*; + +class YearMonthConverterTest { + + private YearMonthConverter converter; + + @BeforeEach + void setUp() { + converter = new YearMonthConverter(); + } + + @Test + @DisplayName("YearMonth를 Date로 변환 - 정상 케이스") + void convertToDatabaseColumn_success() { + // given + YearMonth yearMonth = YearMonth.of(2024, 12); + + // when + Date result = converter.convertToDatabaseColumn(yearMonth); + + // then + assertThat(result).isEqualTo(Date.valueOf(LocalDate.of(2024, 12, 1))); + } + + @Test + @DisplayName("YearMonth가 null인 경우 null 반환") + void convertToDatabaseColumn_nullInput() { + // when + Date result = converter.convertToDatabaseColumn(null); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Date를 YearMonth로 변환 - 정상 케이스") + void convertToEntityAttribute_success() { + // given + Date dbData = Date.valueOf(LocalDate.of(2024, 12, 15)); + + // when + YearMonth result = converter.convertToEntityAttribute(dbData); + + // then + assertThat(result).isEqualTo(YearMonth.of(2024, 12)); + } + + @Test + @DisplayName("데이터베이스 값이 null인 경우 null 반환") + void convertToEntityAttribute_nullInput() { + // when + YearMonth result = converter.convertToEntityAttribute(null); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("YearMonth 양방향 변환 테스트") + void bidirectionalConversion() { + // given + YearMonth original = YearMonth.of(2024, 6); + + // when + Date dbValue = converter.convertToDatabaseColumn(original); + YearMonth converted = converter.convertToEntityAttribute(dbValue); + + // then + assertThat(converted).isEqualTo(original); + } + + @Test + @DisplayName("월의 첫번째 날로 변환 확인") + void convertToDatabaseColumn_firstDayOfMonth() { + // given + YearMonth yearMonth = YearMonth.of(2024, 3); + + // when + Date result = converter.convertToDatabaseColumn(yearMonth); + + // then + LocalDate expectedDate = LocalDate.of(2024, 3, 1); + assertThat(result).isEqualTo(Date.valueOf(expectedDate)); + } + + @Test + @DisplayName("Date의 일자와 상관없이 YearMonth 변환") + void convertToEntityAttribute_anyDayOfMonth() { + // given + Date lastDay = Date.valueOf(LocalDate.of(2024, 2, 29)); // 윤년 2월 마지막날 + Date middleDay = Date.valueOf(LocalDate.of(2024, 2, 15)); + Date firstDay = Date.valueOf(LocalDate.of(2024, 2, 1)); + + // when + YearMonth fromLastDay = converter.convertToEntityAttribute(lastDay); + YearMonth fromMiddleDay = converter.convertToEntityAttribute(middleDay); + YearMonth fromFirstDay = converter.convertToEntityAttribute(firstDay); + + // then + YearMonth expected = YearMonth.of(2024, 2); + assertThat(fromLastDay).isEqualTo(expected); + assertThat(fromMiddleDay).isEqualTo(expected); + assertThat(fromFirstDay).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdviceTest.java b/src/test/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdviceTest.java new file mode 100644 index 0000000..3c36493 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdviceTest.java @@ -0,0 +1,191 @@ +package com.stcom.smartmealtable.web.exhandler; + +import static org.assertj.core.api.Assertions.*; + +import com.stcom.smartmealtable.exception.BizLogicException; +import com.stcom.smartmealtable.exception.ExternApiStatusError; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.*; + +class ExControllerAdviceTest { + + private ExControllerAdvice exControllerAdvice; + + @BeforeEach + void setUp() { + exControllerAdvice = new ExControllerAdvice(); + } + + @Test + @DisplayName("MethodArgumentNotValidException 처리 테스트") + void processValidationError() { + // given + BindingResult bindingResult = mock(BindingResult.class); + FieldError fieldError1 = new FieldError("object", "name", "이름은 비어있을 수 없습니다"); + FieldError fieldError2 = new FieldError("object", "email", "유효한 이메일 형식이 아닙니다"); + when(bindingResult.getFieldErrors()).thenReturn(List.of(fieldError1, fieldError2)); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(null, bindingResult); + + // when + ApiResponse response = exControllerAdvice.processValidationError(exception); + + // then + assertThat(response.getStatus()).isEqualTo("FAIL"); + assertThat(response.getData()).isNotNull(); + } + + @Test + @DisplayName("HandlerMethodValidationException 처리 테스트") + void processValidationValidatorError() { + // given + HandlerMethodValidationException exception = mock(HandlerMethodValidationException.class); + when(exception.getMessage()).thenReturn("Validation failed"); + + // when + ApiResponse response = exControllerAdvice.processValidationValidatorError(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("Validation failed"); + } + + @Test + @DisplayName("PasswordPolicyException 처리 테스트") + void passwordPolicyExHandler() { + // given + PasswordPolicyException exception = new PasswordPolicyException("비밀번호 정책을 위반했습니다"); + + // when + ApiResponse response = exControllerAdvice.passwordPolicyExHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("비밀번호 정책을 위반했습니다"); + } + + @Test + @DisplayName("PasswordFailedExceededException 처리 테스트") + void passwordFailedExceededExHandler() { + // given + PasswordFailedExceededException exception = new PasswordFailedExceededException("비밀번호 시도 횟수를 초과했습니다"); + + // when + ApiResponse response = exControllerAdvice.passwordFailedExceededExHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("비밀번호 시도 횟수를 초과했습니다"); + } + + @Test + @DisplayName("BizLogicException 처리 테스트") + void bizLogicExHandler() { + // given + BizLogicException exception = new BizLogicException("비즈니스 로직 오류"); + + // when + ApiResponse response = exControllerAdvice.bizLogicExHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("불가능한 시도입니다. 사유: 비즈니스 로직 오류"); + } + + @Test + @DisplayName("IllegalArgumentException 처리 테스트") + void illegalArgumentExHandler() { + // given + IllegalArgumentException exception = new IllegalArgumentException("잘못된 인수"); + + // when + ApiResponse response = exControllerAdvice.illegalArgumentExHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("잘못된 데이터가 전달되었습니다. 사유: 잘못된 인수"); + } + + @Test + @DisplayName("IllegalStateException 처리 테스트") + void illegalStateExHandler() { + // given + IllegalArgumentException exception = new IllegalArgumentException("잘못된 상태"); + + // when + ApiResponse response = exControllerAdvice.illegalStateExHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("서버 내부 동작 오류입니다. 사유: 잘못된 상태"); + } + + @Test + @DisplayName("ExternApiStatusError 처리 테스트") + void externApiStatusErrorHandler() { + // given + ExternApiStatusError exception = new ExternApiStatusError("외부 API 오류"); + + // when + ApiResponse response = exControllerAdvice.externApiStatusErrorHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).isEqualTo("외부 API 호출 중 오류가 발생했습니다. 사유: 외부 API 오류"); + } + + @Test + @DisplayName("RuntimeException 처리 테스트") + void runtimeExHandler() { + // given + RuntimeException exception = new RuntimeException("런타임 오류"); + + // when + ApiResponse response = exControllerAdvice.runtimeExHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).contains("서버 내부에서 알 수 없는 오류가 발생했습니다"); + } + + @Test + @DisplayName("Exception 처리 테스트") + void exHandler() throws Exception { + // given + Exception exception = new Exception("일반 예외"); + + // when + ApiResponse response = exControllerAdvice.exHandler(exception); + + // then + assertThat(response.getStatus()).isEqualTo("ERROR"); + assertThat(response.getMessage()).contains("서버 내부에서 알 수 없는 오류가 발생했습니다"); + } + + @Test + @DisplayName("NoResourceFoundException 처리 테스트") + void handleNoResourceFound() { + // given & when & then + // void 메서드이므로 예외가 발생하지 않는지만 확인 + assertThatCode(() -> exControllerAdvice.handleNoResourceFound()).doesNotThrowAnyException(); + } +} \ No newline at end of file