From 15391c5d7986d9ddbd58bff69ac4f876dffc28c0 Mon Sep 17 00:00:00 2001 From: hwanggyuun Date: Sun, 4 Oct 2026 16:03:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=A0=ED=83=9D=20=EA=B0=92=EC=9D=84?= =?UTF-8?q?=20=EB=B9=84=EC=9A=B8=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=82=AC=EB=A7=9D=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=9D=84=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자 메시지와 프로필 연락처·사진은 한 번 저장하면 없는 상태로 되돌릴 수 없었다. 사망확인 상태 응답에는 기록함이 이미 가진 승인 시각이 없었다. Co-authored-by: Cursor --- .../dto/DeliveryVerificationResponse.java | 16 ++- .../service/DeliveryVerificationService.java | 3 +- .../user/controller/UserController.java | 7 +- .../user/dto/UserUpdateProfileRequest.java | 78 ++++++++-- .../dto/UserUpdateReceiverMessageRequest.java | 44 +++++- .../com/afternote/domain/user/model/User.java | 12 +- .../domain/user/service/UserService.java | 8 +- .../dto/DeliveryVerificationResponseTest.java | 61 ++++++++ .../user/controller/UserControllerTest.java | 54 +++++++ .../UserUpdateReceiverMessageRequestTest.java | 52 +++++++ .../service/UserServiceUpdateProfileTest.java | 134 ++++++++++++++++++ .../UserServiceUpdateReceiverMessageTest.java | 121 ++++++++++++++++ .../config/OpenApiContractAnnotationTest.java | 7 + .../ReceiverOpenApiGeneratedDocsTest.java | 5 + .../config/UserOpenApiGeneratedDocsTest.java | 42 ++++++ 15 files changed, 616 insertions(+), 28 deletions(-) create mode 100644 src/test/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponseTest.java create mode 100644 src/test/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequestTest.java create mode 100644 src/test/java/com/afternote/domain/user/service/UserServiceUpdateProfileTest.java create mode 100644 src/test/java/com/afternote/domain/user/service/UserServiceUpdateReceiverMessageTest.java diff --git a/src/main/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponse.java b/src/main/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponse.java index cd3da48c..ddddfac3 100644 --- a/src/main/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponse.java +++ b/src/main/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponse.java @@ -38,7 +38,16 @@ public record DeliveryVerificationResponse( format = ReceivedRecordBoxResponse.LOCAL_DATE_TIME_FORMAT, requiredMode = Schema.RequiredMode.REQUIRED ) - LocalDateTime createdAt + LocalDateTime createdAt, + @Schema( + description = ReceivedRecordBoxResponse.APPROVED_AT_DESCRIPTION, + example = ReceivedRecordBoxResponse.LOCAL_DATE_TIME_EXAMPLE, + type = "string", + format = ReceivedRecordBoxResponse.LOCAL_DATE_TIME_FORMAT, + nullable = true, + requiredMode = Schema.RequiredMode.NOT_REQUIRED + ) + LocalDateTime approvedAt ) { public static DeliveryVerificationResponse from(DeliveryVerification verification) { return from(verification, Function.identity()); @@ -51,7 +60,10 @@ public static DeliveryVerificationResponse from(DeliveryVerification verificatio urlResolver.apply(verification.getDeathCertificateUrl()), urlResolver.apply(verification.getFamilyRelationCertificateUrl()), verification.getAdminNote(), - verification.getCreatedAt() + verification.getCreatedAt(), + verification.getStatus() == VerificationStatus.APPROVED + ? verification.getUpdatedAt() + : null ); } } diff --git a/src/main/java/com/afternote/domain/receiver/service/DeliveryVerificationService.java b/src/main/java/com/afternote/domain/receiver/service/DeliveryVerificationService.java index 1213a043..6f050d6a 100644 --- a/src/main/java/com/afternote/domain/receiver/service/DeliveryVerificationService.java +++ b/src/main/java/com/afternote/domain/receiver/service/DeliveryVerificationService.java @@ -69,7 +69,8 @@ public DeliveryVerification submitVerification( .familyRelationCertificateUrl(familyRelationCertKey) .build(); - return deliveryVerificationRepository.save(verification); + // createdAt은 flush 때 채워진다. 제출 응답에 null이 나가지 않도록 여기서 flush한다. + return deliveryVerificationRepository.saveAndFlush(verification); } public DeliveryVerification getVerificationStatus(Receiver receiver) { diff --git a/src/main/java/com/afternote/domain/user/controller/UserController.java b/src/main/java/com/afternote/domain/user/controller/UserController.java index b4f1d3df..8c43e37d 100644 --- a/src/main/java/com/afternote/domain/user/controller/UserController.java +++ b/src/main/java/com/afternote/domain/user/controller/UserController.java @@ -46,7 +46,9 @@ public ApiResponse getMyProfile( @Operation( summary = "프로필 수정 API", - description = "로그인한 사용자의 프로필 정보를 수정합니다." + description = "로그인한 사용자의 프로필 정보를 수정합니다. " + + "이름은 생략하면 유지하고 공백은 거부합니다. 연락처와 프로필 이미지는 " + + UserUpdateProfileRequest.OPTIONAL_CLEAR_DESCRIPTION ) @PatchMapping("/me") public ApiResponse updateMyProfile( @@ -223,7 +225,8 @@ public ApiResponse getReceiverDetail( @Operation( summary = "수신자 메시지 수정 API", - description = "특정 수신자에게 남길 메시지를 등록하거나 수정합니다." + description = "특정 수신자에게 남길 메시지를 등록하거나 수정합니다. " + + UserUpdateReceiverMessageRequest.MESSAGE_DESCRIPTION ) @PatchMapping("/receivers/{receiverId}/message") public ApiResponse updateReceiverMessage( diff --git a/src/main/java/com/afternote/domain/user/dto/UserUpdateProfileRequest.java b/src/main/java/com/afternote/domain/user/dto/UserUpdateProfileRequest.java index ef7659a7..d33614b4 100644 --- a/src/main/java/com/afternote/domain/user/dto/UserUpdateProfileRequest.java +++ b/src/main/java/com/afternote/domain/user/dto/UserUpdateProfileRequest.java @@ -1,20 +1,74 @@ package com.afternote.domain.user.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; -public record UserUpdateProfileRequest( - @Schema(description = "사용자 이름", example = "김소희", nullable = true) - @Getter - String name, +@Schema(description = "프로필 수정 요청") +public class UserUpdateProfileRequest { - @Schema(description = "연락처", example = "01012345678", nullable = true) - @Getter - String phone, + public static final String OPTIONAL_CLEAR_DESCRIPTION = + "필드를 생략하면 기존 값을 유지한다. null 또는 공백 문자열이면 비워 조회 시 null이 된다."; - @Schema(description = "프로필 이미지 URL", - example = "https://cdn.example.com/profile/1.png", nullable = true) - @Getter - String profileImageUrl -) { + @Schema(description = "사용자 이름. 생략하거나 null이면 유지하고, 공백이면 거부한다.", example = "김소희", nullable = true) + @Getter + private String name; + + @Schema( + description = "연락처. " + OPTIONAL_CLEAR_DESCRIPTION, + example = "01012345678", + nullable = true, + requiredMode = Schema.RequiredMode.NOT_REQUIRED + ) + @Getter + private String phone; + + @Schema( + description = "프로필 이미지 URL. " + OPTIONAL_CLEAR_DESCRIPTION, + example = "https://cdn.example.com/profile/1.png", + nullable = true, + requiredMode = Schema.RequiredMode.NOT_REQUIRED + ) + @Getter + private String profileImageUrl; + + private boolean phoneSpecified; + private boolean profileImageUrlSpecified; + + @JsonProperty("name") + public void setName(String name) { + this.name = name; + } + + @JsonProperty("phone") + public void setPhone(String phone) { + this.phoneSpecified = true; + this.phone = blankToNull(phone); + } + + @JsonProperty("profileImageUrl") + public void setProfileImageUrl(String profileImageUrl) { + this.profileImageUrlSpecified = true; + this.profileImageUrl = blankToNull(profileImageUrl); + } + + @JsonIgnore + @Schema(hidden = true) + public boolean isPhoneSpecified() { + return phoneSpecified; + } + + @JsonIgnore + @Schema(hidden = true) + public boolean isProfileImageUrlSpecified() { + return profileImageUrlSpecified; + } + + private static String blankToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value; + } } diff --git a/src/main/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequest.java b/src/main/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequest.java index 4dbcbab3..6d131dde 100644 --- a/src/main/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequest.java +++ b/src/main/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequest.java @@ -1,14 +1,44 @@ package com.afternote.domain.user.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; import lombok.Getter; -public record UserUpdateReceiverMessageRequest( - @Schema(description = "수신자에게 남길 메시지", example = "사랑하는 딸에게...") - @NotNull - @Getter - String message -) { +@Schema(description = "수신자 메시지 수정 요청") +public class UserUpdateReceiverMessageRequest { + public static final String MESSAGE_DESCRIPTION = + "수신자에게 남길 메시지. 필드를 생략하면 기존 값을 유지한다. " + + "null 또는 공백 문자열이면 메시지를 비워, 상세 조회의 message는 null이 된다."; + + @Schema( + description = MESSAGE_DESCRIPTION, + example = "사랑하는 딸에게...", + nullable = true, + requiredMode = Schema.RequiredMode.NOT_REQUIRED + ) + @Getter + private String message; + + private boolean messageSpecified; + + @JsonProperty("message") + public void setMessage(String message) { + this.messageSpecified = true; + this.message = normalize(message); + } + + @JsonIgnore + @Schema(hidden = true) + public boolean isMessageSpecified() { + return messageSpecified; + } + + private static String normalize(String message) { + if (message == null || message.isBlank()) { + return null; + } + return message; + } } diff --git a/src/main/java/com/afternote/domain/user/model/User.java b/src/main/java/com/afternote/domain/user/model/User.java index 79c95ab6..c03aa434 100644 --- a/src/main/java/com/afternote/domain/user/model/User.java +++ b/src/main/java/com/afternote/domain/user/model/User.java @@ -120,17 +120,23 @@ public User(String email, String password, String name, String phone, this.marketingPushEnabled = false; } - public void updateProfile(String name, String phone, String profileImageUrl) { + public void updateProfile( + String name, + boolean phoneSpecified, + String phone, + boolean profileImageSpecified, + String profileImageUrl + ) { if (name != null) { if (name.isBlank()) { throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); } this.name = name; } - if (phone != null && !phone.isBlank()) { + if (phoneSpecified) { this.phone = phone; } - if (profileImageUrl != null && !profileImageUrl.isBlank()) { + if (profileImageSpecified) { this.profileImageUrl = profileImageUrl; } } diff --git a/src/main/java/com/afternote/domain/user/service/UserService.java b/src/main/java/com/afternote/domain/user/service/UserService.java index 385cbf88..e69f5b36 100644 --- a/src/main/java/com/afternote/domain/user/service/UserService.java +++ b/src/main/java/com/afternote/domain/user/service/UserService.java @@ -51,13 +51,15 @@ public UserResponse updateMyProfile(Long userId, UserUpdateProfileRequest reques User user = findUserById(userId); String profileImageKey = request.getProfileImageUrl(); - if (profileImageKey != null && !profileImageKey.isBlank()) { + if (request.isProfileImageUrlSpecified() && profileImageKey != null) { profileImageKey = s3Service.promoteManagedMediaKey("profiles", userId, profileImageKey); } user.updateProfile( request.getName(), + request.isPhoneSpecified(), request.getPhone(), + request.isProfileImageUrlSpecified(), profileImageKey ); @@ -207,6 +209,10 @@ public void updateReceiverMessage(Long userId, Long receiverId, UserUpdateReceiv userReceiverRepository.findByUserAndReceiverId(user, receiverId) .orElseThrow(() -> new CustomException(ErrorCode.RECEIVER_NOT_FOUND)); + if (!request.isMessageSpecified()) { + return; + } + Receiver receiver = userReceiver.getReceiver(); receiver.updateMessage(request.getMessage()); } diff --git a/src/test/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponseTest.java b/src/test/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponseTest.java new file mode 100644 index 00000000..c0e61319 --- /dev/null +++ b/src/test/java/com/afternote/domain/receiver/dto/DeliveryVerificationResponseTest.java @@ -0,0 +1,61 @@ +package com.afternote.domain.receiver.dto; + +import com.afternote.domain.receiver.model.DeliveryVerification; +import com.afternote.domain.receiver.model.VerificationStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeliveryVerificationResponseTest { + + @Test + @DisplayName("승인 전에는 approvedAt이 null이고 생성 시각은 그대로 내려간다") + void pending_ApprovedAtIsNull() { + DeliveryVerification verification = verification(); + + DeliveryVerificationResponse response = DeliveryVerificationResponse.from(verification); + + assertThat(response.status()).isEqualTo(VerificationStatus.PENDING); + assertThat(response.createdAt()).isEqualTo(LocalDateTime.of(2026, 6, 21, 3, 7, 26)); + assertThat(response.approvedAt()).isNull(); + } + + @Test + @DisplayName("승인 상태의 approvedAt은 기록함과 같이 updatedAt이다") + void approved_ApprovedAtUsesUpdatedAt() { + DeliveryVerification verification = verification(); + verification.approve("서류 확인 완료"); + + DeliveryVerificationResponse response = DeliveryVerificationResponse.from(verification); + + assertThat(response.status()).isEqualTo(VerificationStatus.APPROVED); + assertThat(response.approvedAt()).isEqualTo(LocalDateTime.of(2026, 7, 29, 16, 0, 10)); + assertThat(response.adminNote()).isEqualTo("서류 확인 완료"); + } + + @Test + @DisplayName("거절 상태에는 approvedAt을 채우지 않는다") + void rejected_ApprovedAtIsNull() { + DeliveryVerification verification = verification(); + verification.reject("서류 불일치"); + + assertThat(DeliveryVerificationResponse.from(verification).approvedAt()).isNull(); + } + + private static DeliveryVerification verification() { + DeliveryVerification verification = DeliveryVerification.builder() + .userId(1L) + .receiverId(7L) + .deathCertificateUrl("documents/death") + .familyRelationCertificateUrl("documents/family") + .build(); + ReflectionTestUtils.setField(verification, "id", 4L); + ReflectionTestUtils.setField(verification, "createdAt", LocalDateTime.of(2026, 6, 21, 3, 7, 26)); + ReflectionTestUtils.setField(verification, "updatedAt", LocalDateTime.of(2026, 7, 29, 16, 0, 10)); + return verification; + } +} diff --git a/src/test/java/com/afternote/domain/user/controller/UserControllerTest.java b/src/test/java/com/afternote/domain/user/controller/UserControllerTest.java index fc11a7b0..efc9488b 100644 --- a/src/test/java/com/afternote/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/afternote/domain/user/controller/UserControllerTest.java @@ -4,10 +4,13 @@ import com.afternote.domain.notification.service.UserPushTokenService; import com.afternote.global.resolver.UserId; import com.afternote.global.resolver.UserIdArgumentResolver; +import com.afternote.domain.user.dto.UserUpdateProfileRequest; +import com.afternote.domain.user.dto.UserUpdateReceiverMessageRequest; 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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -22,6 +25,7 @@ import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -83,6 +87,25 @@ void updateMyProfile_Success() throws Exception { verify(userService).updateMyProfile(eq(USER_ID), any()); } + @Test + @DisplayName("프로필 연락처 null은 비우기 요청으로 전달된다") + void updateMyProfile_ExplicitNullPhone_ReachesService() throws Exception { + given(userService.updateMyProfile(eq(USER_ID), any())).willReturn(null); + + mockMvc.perform(patch("/api/v1/users/me") + .requestAttr(UserIdArgumentResolver.USER_ID_ATTRIBUTE, USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":null}")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UserUpdateProfileRequest.class); + verify(userService).updateMyProfile(eq(USER_ID), captor.capture()); + assertThat(captor.getValue().isPhoneSpecified()).isTrue(); + assertThat(captor.getValue().getPhone()).isNull(); + assertThat(captor.getValue().isProfileImageUrlSpecified()).isFalse(); + } + @Test @DisplayName("푸시 알림 설정 조회 API 성공") void getMyPushSettings_Success() throws Exception { @@ -206,6 +229,37 @@ void updateReceiverMessage_Success() throws Exception { verify(userService).updateReceiverMessage(eq(USER_ID), eq(2L), any()); } + @Test + @DisplayName("수신자 메시지 null은 비우기 요청으로 전달된다") + void updateReceiverMessage_ExplicitNull_ReachesService() throws Exception { + mockMvc.perform(patch("/api/v1/users/receivers/{receiverId}/message", 2L) + .requestAttr(UserIdArgumentResolver.USER_ID_ATTRIBUTE, USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":null}")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UserUpdateReceiverMessageRequest.class); + verify(userService).updateReceiverMessage(eq(USER_ID), eq(2L), captor.capture()); + assertThat(captor.getValue().isMessageSpecified()).isTrue(); + assertThat(captor.getValue().getMessage()).isNull(); + } + + @Test + @DisplayName("수신자 메시지 필드를 생략하면 미지정으로 전달된다") + void updateReceiverMessage_Omitted_ReachesService() throws Exception { + mockMvc.perform(patch("/api/v1/users/receivers/{receiverId}/message", 2L) + .requestAttr(UserIdArgumentResolver.USER_ID_ATTRIBUTE, USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UserUpdateReceiverMessageRequest.class); + verify(userService).updateReceiverMessage(eq(USER_ID), eq(2L), captor.capture()); + assertThat(captor.getValue().isMessageSpecified()).isFalse(); + } + @Test @DisplayName("수신자 관계 수정 API 성공") void updateReceiverRelation_Success() throws Exception { diff --git a/src/test/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequestTest.java b/src/test/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequestTest.java new file mode 100644 index 00000000..69a408e4 --- /dev/null +++ b/src/test/java/com/afternote/domain/user/dto/UserUpdateReceiverMessageRequestTest.java @@ -0,0 +1,52 @@ +package com.afternote.domain.user.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserUpdateReceiverMessageRequestTest { + + private final ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().build(); + + @Test + @DisplayName("message를 생략하면 미지정이다") + void omittedMessage_IsUnspecified() throws Exception { + UserUpdateReceiverMessageRequest request = objectMapper.readValue("{}", UserUpdateReceiverMessageRequest.class); + + assertThat(request.isMessageSpecified()).isFalse(); + assertThat(request.getMessage()).isNull(); + } + + @Test + @DisplayName("명시적 null은 비우기로 지정된다") + void explicitNull_Clears() throws Exception { + UserUpdateReceiverMessageRequest request = objectMapper.readValue( + "{\"message\":null}", UserUpdateReceiverMessageRequest.class); + + assertThat(request.isMessageSpecified()).isTrue(); + assertThat(request.getMessage()).isNull(); + } + + @Test + @DisplayName("공백 문자열도 null로 정규화한다") + void blank_NormalizesToNull() throws Exception { + UserUpdateReceiverMessageRequest request = objectMapper.readValue( + "{\"message\":\" \"}", UserUpdateReceiverMessageRequest.class); + + assertThat(request.isMessageSpecified()).isTrue(); + assertThat(request.getMessage()).isNull(); + } + + @Test + @DisplayName("값이 있으면 그 문자열을 유지한다") + void value_IsKept() throws Exception { + UserUpdateReceiverMessageRequest request = objectMapper.readValue( + "{\"message\":\"사랑하는 딸에게\"}", UserUpdateReceiverMessageRequest.class); + + assertThat(request.isMessageSpecified()).isTrue(); + assertThat(request.getMessage()).isEqualTo("사랑하는 딸에게"); + } +} diff --git a/src/test/java/com/afternote/domain/user/service/UserServiceUpdateProfileTest.java b/src/test/java/com/afternote/domain/user/service/UserServiceUpdateProfileTest.java new file mode 100644 index 00000000..ffabc0f0 --- /dev/null +++ b/src/test/java/com/afternote/domain/user/service/UserServiceUpdateProfileTest.java @@ -0,0 +1,134 @@ +package com.afternote.domain.user.service; + +import com.afternote.domain.auth.service.TokenService; +import com.afternote.domain.auth.service.social.SocialLoginFactory; +import com.afternote.domain.image.service.S3Service; +import com.afternote.domain.receiver.repository.ReceiverRepository; +import com.afternote.domain.receiver.repository.UserReceiverRepository; +import com.afternote.domain.user.dto.UserUpdateProfileRequest; +import com.afternote.domain.user.model.User; +import com.afternote.domain.user.repository.UserProviderRepository; +import com.afternote.domain.user.repository.UserRepository; +import com.afternote.global.exception.CustomException; +import com.afternote.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserServiceUpdateProfileTest { + + private static final long USER_ID = 1L; + + @InjectMocks + private UserService userService; + + @Mock private UserRepository userRepository; + @Mock private UserReceiverRepository userReceiverRepository; + @Mock private ReceiverRepository receiverRepository; + @Mock private S3Service s3Service; + @Mock private TokenService tokenService; + @Mock private SocialLoginFactory socialLoginFactory; + @Mock private UserProviderRepository userProviderRepository; + @Mock private AccountWithdrawalService accountWithdrawalService; + @Mock private ReceiverDeletionService receiverDeletionService; + @Mock private ActivityTouchService activityTouchService; + + private final ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().build(); + + private User user; + + @BeforeEach + void setUp() { + user = User.builder() + .email("user@test.com") + .name("기존이름") + .phone("01012345678") + .profileImageUrl("profiles/old") + .build(); + ReflectionTestUtils.setField(user, "id", USER_ID); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + lenient().when(s3Service.generateGetPresignedUrl("profiles/old")).thenReturn("https://signed/old"); + } + + @Test + @DisplayName("연락처만 비우면 이름과 프로필 사진은 유지된다") + void clearPhone_KeepsNameAndImage() throws Exception { + userService.updateMyProfile(USER_ID, request("{\"phone\":null}")); + + assertThat(user.getPhone()).isNull(); + assertThat(user.getName()).isEqualTo("기존이름"); + assertThat(user.getProfileImageUrl()).isEqualTo("profiles/old"); + verify(s3Service, never()).promoteManagedMediaKey(anyString(), anyLong(), anyString()); + } + + @Test + @DisplayName("빈 프로필 이미지 URL은 null로 비운다") + void blankProfileImage_Clears() throws Exception { + userService.updateMyProfile(USER_ID, request("{\"profileImageUrl\":\"\"}")); + + assertThat(user.getProfileImageUrl()).isNull(); + assertThat(user.getPhone()).isEqualTo("01012345678"); + assertThat(user.getName()).isEqualTo("기존이름"); + } + + @Test + @DisplayName("이름만 보내면 연락처와 사진은 유지된다") + void nameOnly_KeepsOptionalFields() throws Exception { + userService.updateMyProfile(USER_ID, request("{\"name\":\"새이름\"}")); + + assertThat(user.getName()).isEqualTo("새이름"); + assertThat(user.getPhone()).isEqualTo("01012345678"); + assertThat(user.getProfileImageUrl()).isEqualTo("profiles/old"); + } + + @Test + @DisplayName("새 프로필 이미지는 승격한 키로 저장한다") + void newProfileImage_IsPromoted() throws Exception { + given(s3Service.promoteManagedMediaKey("profiles", USER_ID, "profiles/new")).willReturn("profiles/promoted"); + given(s3Service.generateGetPresignedUrl("profiles/promoted")).willReturn("https://signed/new"); + + userService.updateMyProfile(USER_ID, request("{\"profileImageUrl\":\"profiles/new\"}")); + + assertThat(user.getProfileImageUrl()).isEqualTo("profiles/promoted"); + assertThat(user.getPhone()).isEqualTo("01012345678"); + verify(s3Service).promoteManagedMediaKey(eq("profiles"), eq(USER_ID), eq("profiles/new")); + } + + @Test + @DisplayName("공백 이름은 거부하고 다른 필드는 바꾸지 않는다") + void blankName_Rejected() throws Exception { + UserUpdateProfileRequest request = request("{\"name\":\" \",\"phone\":null}"); + + assertThatThrownBy(() -> userService.updateMyProfile(USER_ID, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + assertThat(user.getName()).isEqualTo("기존이름"); + assertThat(user.getPhone()).isEqualTo("01012345678"); + } + + private UserUpdateProfileRequest request(String json) throws Exception { + return objectMapper.readValue(json, UserUpdateProfileRequest.class); + } +} diff --git a/src/test/java/com/afternote/domain/user/service/UserServiceUpdateReceiverMessageTest.java b/src/test/java/com/afternote/domain/user/service/UserServiceUpdateReceiverMessageTest.java new file mode 100644 index 00000000..92c85e3b --- /dev/null +++ b/src/test/java/com/afternote/domain/user/service/UserServiceUpdateReceiverMessageTest.java @@ -0,0 +1,121 @@ +package com.afternote.domain.user.service; + +import com.afternote.domain.auth.service.TokenService; +import com.afternote.domain.auth.service.social.SocialLoginFactory; +import com.afternote.domain.image.service.S3Service; +import com.afternote.domain.receiver.model.Receiver; +import com.afternote.domain.receiver.model.UserReceiver; +import com.afternote.domain.receiver.repository.ReceiverRepository; +import com.afternote.domain.receiver.repository.UserReceiverRepository; +import com.afternote.domain.user.dto.UserUpdateReceiverMessageRequest; +import com.afternote.domain.user.model.User; +import com.afternote.domain.user.repository.UserProviderRepository; +import com.afternote.domain.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class UserServiceUpdateReceiverMessageTest { + + private static final long USER_ID = 1L; + private static final long RECEIVER_ID = 2L; + + @InjectMocks + private UserService userService; + + @Mock private UserRepository userRepository; + @Mock private UserReceiverRepository userReceiverRepository; + @Mock private ReceiverRepository receiverRepository; + @Mock private S3Service s3Service; + @Mock private TokenService tokenService; + @Mock private SocialLoginFactory socialLoginFactory; + @Mock private UserProviderRepository userProviderRepository; + @Mock private AccountWithdrawalService accountWithdrawalService; + @Mock private ReceiverDeletionService receiverDeletionService; + @Mock private ActivityTouchService activityTouchService; + + private final ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().build(); + + private Receiver receiver; + + @BeforeEach + void setUp() { + User user = User.builder().email("sender@test.com").name("발신자").build(); + ReflectionTestUtils.setField(user, "id", USER_ID); + receiver = Receiver.builder() + .name("수신자") + .relation("DAUGHTER") + .phone("010-1111-2222") + .email("receiver@test.com") + .message("기존 메시지") + .userId(USER_ID) + .build(); + ReflectionTestUtils.setField(receiver, "id", RECEIVER_ID); + UserReceiver link = UserReceiver.builder().user(user).receiver(receiver).build(); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userReceiverRepository.findByUserAndReceiverId(user, RECEIVER_ID)).willReturn(Optional.of(link)); + } + + @Test + @DisplayName("메시지를 지정하면 그 값으로 바꾸고 이름·연락처·ID는 유지한다") + void specifiedValue_UpdatesMessageOnly() throws Exception { + userService.updateReceiverMessage(USER_ID, RECEIVER_ID, request("{\"message\":\"새 메시지\"}")); + + assertThat(receiver.getMessage()).isEqualTo("새 메시지"); + assertIdentityUnchanged(); + } + + @Test + @DisplayName("명시적 null은 메시지를 null로 비운다") + void explicitNull_ClearsMessage() throws Exception { + userService.updateReceiverMessage(USER_ID, RECEIVER_ID, request("{\"message\":null}")); + + assertThat(receiver.getMessage()).isNull(); + assertIdentityUnchanged(); + } + + @Test + @DisplayName("빈 문자열은 null로 저장한다") + void blank_ClearsMessage() throws Exception { + userService.updateReceiverMessage(USER_ID, RECEIVER_ID, request("{\"message\":\"\"}")); + + assertThat(receiver.getMessage()).isNull(); + assertIdentityUnchanged(); + } + + @Test + @DisplayName("message를 생략하면 기존 메시지를 유지한다") + void omitted_KeepsMessage() throws Exception { + userService.updateReceiverMessage(USER_ID, RECEIVER_ID, request("{}")); + + assertThat(receiver.getMessage()).isEqualTo("기존 메시지"); + assertIdentityUnchanged(); + } + + private UserUpdateReceiverMessageRequest request(String json) throws Exception { + return objectMapper.readValue(json, UserUpdateReceiverMessageRequest.class); + } + + private void assertIdentityUnchanged() { + assertThat(receiver.getId()).isEqualTo(RECEIVER_ID); + assertThat(receiver.getName()).isEqualTo("수신자"); + assertThat(receiver.getRelation()).isEqualTo("DAUGHTER"); + assertThat(receiver.getPhone()).isEqualTo("010-1111-2222"); + assertThat(receiver.getEmail()).isEqualTo("receiver@test.com"); + assertThat(receiver.getAfternoteReceivers()).isEmpty(); + } +} diff --git a/src/test/java/com/afternote/global/config/OpenApiContractAnnotationTest.java b/src/test/java/com/afternote/global/config/OpenApiContractAnnotationTest.java index 64469915..da363d9a 100644 --- a/src/test/java/com/afternote/global/config/OpenApiContractAnnotationTest.java +++ b/src/test/java/com/afternote/global/config/OpenApiContractAnnotationTest.java @@ -305,6 +305,13 @@ void receiverResponses_DocumentRequiredAndNullableContract() throws Exception { assertThat(deathCertificate.nullable()).isTrue(); Schema verificationId = DeliveryVerificationResponse.class.getDeclaredMethod("id").getAnnotation(Schema.class); assertThat(verificationId.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); + Schema verificationApprovedAt = DeliveryVerificationResponse.class + .getDeclaredMethod("approvedAt") + .getAnnotation(Schema.class); + assertThat(verificationApprovedAt.nullable()).isTrue(); + assertThat(verificationApprovedAt.requiredMode()).isEqualTo(Schema.RequiredMode.NOT_REQUIRED); + assertThat(verificationApprovedAt.format()).isEqualTo(ReceivedRecordBoxResponse.LOCAL_DATE_TIME_FORMAT); + assertThat(verificationApprovedAt.description()).contains("APPROVED").contains("오프셋 없는"); Schema playlist = ReceivedAfternoteDetailResponse.class.getDeclaredMethod("playlist").getAnnotation(Schema.class); assertThat(playlist.nullable()).isTrue(); diff --git a/src/test/java/com/afternote/global/config/ReceiverOpenApiGeneratedDocsTest.java b/src/test/java/com/afternote/global/config/ReceiverOpenApiGeneratedDocsTest.java index 67bddebf..67285d29 100644 --- a/src/test/java/com/afternote/global/config/ReceiverOpenApiGeneratedDocsTest.java +++ b/src/test/java/com/afternote/global/config/ReceiverOpenApiGeneratedDocsTest.java @@ -109,8 +109,13 @@ void generatedOpenApi_OtherReceiverResponsesKeepNullable() throws Exception { JsonNode verification = schemas.get("DeliveryVerificationResponse"); assertThat(textValues(verification.path("required"))).contains("id", "status", "createdAt"); + assertThat(textValues(verification.path("required"))).doesNotContain("approvedAt"); assertThat(verification.at("/properties/deathCertificateUrl/nullable").asBoolean()).isTrue(); assertThat(verification.at("/properties/adminNote/nullable").asBoolean()).isTrue(); + assertThat(verification.at("/properties/approvedAt/nullable").asBoolean()).isTrue(); + assertThat(verification.at("/properties/approvedAt/format").asText()) + .isEqualTo(ReceivedRecordBoxResponse.LOCAL_DATE_TIME_FORMAT); + assertThat(verification.at("/properties/approvedAt/description").asText()).contains("APPROVED"); JsonNode playlist = schemas.at("/ReceivedAfternoteDetailResponse/properties/playlist"); assertThat(playlist.has("$ref")) diff --git a/src/test/java/com/afternote/global/config/UserOpenApiGeneratedDocsTest.java b/src/test/java/com/afternote/global/config/UserOpenApiGeneratedDocsTest.java index 0e998561..bb25b2e9 100644 --- a/src/test/java/com/afternote/global/config/UserOpenApiGeneratedDocsTest.java +++ b/src/test/java/com/afternote/global/config/UserOpenApiGeneratedDocsTest.java @@ -74,4 +74,46 @@ void generatedOpenApi_DeleteUsersMeDocumentsS3Purge() throws Exception { assertThat(deleteMe.at("/responses/401").isMissingNode()).isFalse(); assertThat(docs.at("/security/0/bearer-key").isArray()).isTrue(); } + + @Test + @DisplayName("수신자 메시지 수정 스키마는 생략·null 비우기를 구분한다") + void generatedOpenApi_ReceiverMessagePatchDocumentsClear() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode docs = objectMapper.readTree(result.getResponse().getContentAsByteArray()); + JsonNode schema = docs.at("/components/schemas/UserUpdateReceiverMessageRequest"); + JsonNode message = schema.at("/properties/message"); + + assertThat(schema.at("/properties/messageSpecified").isMissingNode()).isTrue(); + assertThat(message.path("nullable").asBoolean()).isTrue(); + assertThat(textValues(schema.path("required"))).doesNotContain("message"); + assertThat(message.path("description").asText()).contains("생략").contains("null"); + assertThat(docs.at("/paths/~1api~1v1~1users~1receivers~1{receiverId}~1message/patch/description").asText()) + .contains("null"); + } + + @Test + @DisplayName("프로필 수정 스키마는 연락처·사진의 생략과 비우기를 구분한다") + void generatedOpenApi_ProfilePatchDocumentsClear() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode docs = objectMapper.readTree(result.getResponse().getContentAsByteArray()); + JsonNode schema = docs.at("/components/schemas/UserUpdateProfileRequest"); + + assertThat(schema.at("/properties/phoneSpecified").isMissingNode()).isTrue(); + assertThat(schema.at("/properties/phone/nullable").asBoolean()).isTrue(); + assertThat(schema.at("/properties/profileImageUrl/nullable").asBoolean()).isTrue(); + assertThat(schema.at("/properties/phone/description").asText()).contains("생략").contains("null"); + assertThat(textValues(schema.path("required"))).doesNotContain("phone", "profileImageUrl"); + } + + private static java.util.List textValues(JsonNode array) { + java.util.List values = new java.util.ArrayList<>(); + for (JsonNode item : array) { + values.add(item.asText()); + } + return values; + } }