diff --git a/src/main/java/com/back/domain/board/post/service/PostService.java b/src/main/java/com/back/domain/board/post/service/PostService.java index 0f4fb0cd..337a2ece 100644 --- a/src/main/java/com/back/domain/board/post/service/PostService.java +++ b/src/main/java/com/back/domain/board/post/service/PostService.java @@ -17,6 +17,7 @@ import com.back.domain.file.entity.FileAttachment; import com.back.domain.file.repository.AttachmentMappingRepository; import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.service.FileService; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.repository.UserRepository; import com.back.global.exception.CustomException; @@ -40,6 +41,7 @@ public class PostService { private final PostCategoryRepository postCategoryRepository; private final FileAttachmentRepository fileAttachmentRepository; private final AttachmentMappingRepository attachmentMappingRepository; + private final FileService fileService; /** * 게시글 생성 서비스 @@ -183,7 +185,15 @@ public void deletePost(Long postId, Long userId) { throw new CustomException(ErrorCode.POST_NO_PERMISSION); } - // AttachmentMapping 매핑 제거 + // 첨부 파일 삭제 + List mappings = + attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); + for (AttachmentMapping mapping : mappings) { + FileAttachment fileAttachment = mapping.getFileAttachment(); + if (fileAttachment != null) { + fileService.deleteFile(fileAttachment.getId(), userId); + } + } attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); // Post 삭제 diff --git a/src/main/java/com/back/domain/file/entity/AttachmentMapping.java b/src/main/java/com/back/domain/file/entity/AttachmentMapping.java index 104d0f9e..e88d76af 100644 --- a/src/main/java/com/back/domain/file/entity/AttachmentMapping.java +++ b/src/main/java/com/back/domain/file/entity/AttachmentMapping.java @@ -9,7 +9,8 @@ @Getter @NoArgsConstructor public class AttachmentMapping extends BaseEntity { - @OneToOne(fetch = FetchType.LAZY, mappedBy = "attachmentMapping", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "attachment_id", nullable = false) private FileAttachment fileAttachment; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/back/domain/file/entity/FileAttachment.java b/src/main/java/com/back/domain/file/entity/FileAttachment.java index 5c28bc86..faf1e0fb 100644 --- a/src/main/java/com/back/domain/file/entity/FileAttachment.java +++ b/src/main/java/com/back/domain/file/entity/FileAttachment.java @@ -7,9 +7,6 @@ import lombok.NoArgsConstructor; import org.springframework.web.multipart.MultipartFile; -import java.util.ArrayList; -import java.util.List; - @Entity @Getter @NoArgsConstructor @@ -29,8 +26,7 @@ public class FileAttachment extends BaseEntity { @JoinColumn(name = "uploaded_by") private User user; - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "attachmentMapping_id") + @OneToOne(mappedBy = "fileAttachment", fetch = FetchType.LAZY) private AttachmentMapping attachmentMapping; public FileAttachment( diff --git a/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java b/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java index a0b60f17..e0fa595e 100644 --- a/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java +++ b/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java @@ -9,6 +9,7 @@ public interface AttachmentMappingRepository extends JpaRepository { List findAllByEntityTypeAndEntityId(EntityType entityType, Long entityId); + Optional findByEntityTypeAndEntityId(EntityType entityType, Long entityId); void deleteAllByEntityTypeAndEntityId(EntityType entityType, Long entityId); } \ No newline at end of file diff --git a/src/main/java/com/back/domain/file/repository/FileAttachmentRepository.java b/src/main/java/com/back/domain/file/repository/FileAttachmentRepository.java index 0a97f0dc..f617ebfc 100644 --- a/src/main/java/com/back/domain/file/repository/FileAttachmentRepository.java +++ b/src/main/java/com/back/domain/file/repository/FileAttachmentRepository.java @@ -3,5 +3,8 @@ import com.back.domain.file.entity.FileAttachment; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface FileAttachmentRepository extends JpaRepository { + Optional findByPublicURL(String publicUrl); } \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/account/controller/AccountController.java b/src/main/java/com/back/domain/user/account/controller/AccountController.java index f519b913..6cb04634 100644 --- a/src/main/java/com/back/domain/user/account/controller/AccountController.java +++ b/src/main/java/com/back/domain/user/account/controller/AccountController.java @@ -5,7 +5,7 @@ import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.account.controller.docs.AccountControllerDocs; import com.back.domain.user.account.dto.ChangePasswordRequest; -import com.back.domain.user.account.dto.UpdateUserProfileRequest; +import com.back.domain.user.account.dto.UserProfileRequest; import com.back.domain.user.account.dto.UserDetailResponse; import com.back.domain.user.account.service.AccountService; import com.back.global.common.dto.RsData; @@ -42,7 +42,7 @@ public ResponseEntity> getMyInfo( @PatchMapping public ResponseEntity> updateMyProfile( @AuthenticationPrincipal CustomUserDetails user, - @Valid @RequestBody UpdateUserProfileRequest request + @Valid @RequestBody UserProfileRequest request ) { UserDetailResponse updated = accountService.updateUserProfile(user.getUserId(), request); return ResponseEntity diff --git a/src/main/java/com/back/domain/user/account/controller/docs/AccountControllerDocs.java b/src/main/java/com/back/domain/user/account/controller/docs/AccountControllerDocs.java index d46b3909..b7a8c621 100644 --- a/src/main/java/com/back/domain/user/account/controller/docs/AccountControllerDocs.java +++ b/src/main/java/com/back/domain/user/account/controller/docs/AccountControllerDocs.java @@ -4,7 +4,7 @@ import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.account.dto.ChangePasswordRequest; -import com.back.domain.user.account.dto.UpdateUserProfileRequest; +import com.back.domain.user.account.dto.UserProfileRequest; import com.back.domain.user.account.dto.UserDetailResponse; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; @@ -307,7 +307,7 @@ ResponseEntity> getMyInfo( }) ResponseEntity> updateMyProfile( @AuthenticationPrincipal CustomUserDetails user, - @Valid @RequestBody UpdateUserProfileRequest request + @Valid @RequestBody UserProfileRequest request ); @Operation( diff --git a/src/main/java/com/back/domain/user/account/dto/UpdateUserProfileRequest.java b/src/main/java/com/back/domain/user/account/dto/UserProfileRequest.java similarity index 93% rename from src/main/java/com/back/domain/user/account/dto/UpdateUserProfileRequest.java rename to src/main/java/com/back/domain/user/account/dto/UserProfileRequest.java index 93955e08..8a0fee67 100644 --- a/src/main/java/com/back/domain/user/account/dto/UpdateUserProfileRequest.java +++ b/src/main/java/com/back/domain/user/account/dto/UserProfileRequest.java @@ -13,7 +13,7 @@ * @param bio 사용자의 자기소개 * @param birthDate 사용자의 생년월일 */ -public record UpdateUserProfileRequest( +public record UserProfileRequest( @NotBlank @Size(max = 20) String nickname, String profileImageUrl, @Size(max = 255) String bio, diff --git a/src/main/java/com/back/domain/user/account/service/AccountService.java b/src/main/java/com/back/domain/user/account/service/AccountService.java index 9b177fb9..2c78461b 100644 --- a/src/main/java/com/back/domain/user/account/service/AccountService.java +++ b/src/main/java/com/back/domain/user/account/service/AccountService.java @@ -5,8 +5,14 @@ import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.file.entity.AttachmentMapping; +import com.back.domain.file.entity.EntityType; +import com.back.domain.file.entity.FileAttachment; +import com.back.domain.file.repository.AttachmentMappingRepository; +import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.service.FileService; import com.back.domain.user.account.dto.ChangePasswordRequest; -import com.back.domain.user.account.dto.UpdateUserProfileRequest; +import com.back.domain.user.account.dto.UserProfileRequest; import com.back.domain.user.account.dto.UserDetailResponse; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.entity.UserProfile; @@ -23,6 +29,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional @@ -31,6 +39,9 @@ public class AccountService { private final UserProfileRepository userProfileRepository; private final CommentRepository commentRepository; private final PostRepository postRepository; + private final FileAttachmentRepository fileAttachmentRepository; + private final AttachmentMappingRepository attachmentMappingRepository; + private final FileService fileService; private final PasswordEncoder passwordEncoder; /** @@ -54,7 +65,7 @@ public UserDetailResponse getUserInfo(Long userId) { * 3. UserProfile 업데이트 * 4. UserDetailResponse 변환 및 반환 */ - public UserDetailResponse updateUserProfile(Long userId, UpdateUserProfileRequest request) { + public UserDetailResponse updateUserProfile(Long userId, UserProfileRequest request) { // 사용자 조회 및 상태 검증 User user = getValidUser(userId); @@ -71,10 +82,48 @@ public UserDetailResponse updateUserProfile(Long userId, UpdateUserProfileReques profile.setBio(request.bio()); profile.setBirthDate(request.birthDate()); + // 프로필 이미지 및 매핑 업데이트 + updateProfileImage(userId, request.profileImageUrl()); + // UserDetailResponse로 변환하여 반환 return UserDetailResponse.from(user); } + /** + * 프로필 이미지 및 매핑 교체 로직 + * - 기존 이미지(S3 + FileAttachment + Mapping) 삭제 후 새 매핑 생성 + */ + private void updateProfileImage(Long userId, String newImageUrl) { + + // 기존 매핑 및 파일 삭제 + attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, userId) + .ifPresent(existingMapping -> { + FileAttachment oldAttachment = existingMapping.getFileAttachment(); + if (oldAttachment != null) { + fileService.deleteFile(oldAttachment.getId(), userId); + } + attachmentMappingRepository.delete(existingMapping); + }); + + // 새 이미지가 없는 경우 + if (newImageUrl == null || newImageUrl.isBlank()) { + return; + } + + // 새 파일 조회 및 검증 + FileAttachment newAttachment = fileAttachmentRepository + .findByPublicURL(newImageUrl) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + if (!newAttachment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); + } + + // 새 매핑 생성 및 저장 + AttachmentMapping newMapping = new AttachmentMapping(newAttachment, EntityType.PROFILE, userId); + attachmentMappingRepository.save(newMapping); + } + /** * 비밀번호 변경 서비스 * 1. 사용자 조회 및 상태 검증 @@ -114,6 +163,9 @@ public void deleteUser(Long userId) { // 사용자 조회 및 상태 검증 User user = getValidUser(userId); + // 프로필 이미지 및 매핑 삭제 + deleteProfileImage(userId); + // 상태 변경 (soft delete) user.setUserStatus(UserStatus.DELETED); @@ -133,6 +185,20 @@ public void deleteUser(Long userId) { } } + /** + * 프로필 이미지 및 매핑 삭제 + */ + private void deleteProfileImage(Long userId) { + attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, userId) + .ifPresent(mapping -> { + FileAttachment attachment = mapping.getFileAttachment(); + if (attachment != null) { + fileService.deleteFile(attachment.getId(), userId); + } + attachmentMappingRepository.delete(mapping); + }); + } + /** * 내 게시글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 diff --git a/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java b/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java index f091ba53..dadd478d 100644 --- a/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java +++ b/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java @@ -8,6 +8,7 @@ import com.back.domain.board.post.repository.PostRepository; import com.back.domain.file.entity.FileAttachment; import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.service.FileService; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.entity.UserProfile; import com.back.domain.user.common.enums.UserStatus; @@ -19,6 +20,7 @@ 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.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; @@ -64,6 +66,9 @@ class PostControllerTest { @Autowired private ObjectMapper objectMapper; + @MockBean + private FileService fileService; + private String generateAccessToken(User user) { return testJwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); } diff --git a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java index cce05313..977f7d99 100644 --- a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java +++ b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java @@ -15,6 +15,7 @@ import com.back.domain.file.entity.FileAttachment; import com.back.domain.file.repository.AttachmentMappingRepository; import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.service.FileService; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.entity.UserProfile; import com.back.domain.user.common.enums.UserStatus; @@ -25,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -59,6 +61,9 @@ class PostServiceTest { @Autowired private AttachmentMappingRepository attachmentMappingRepository; + @MockBean + private FileService fileService; + // ====================== 게시글 생성 테스트 ====================== @Test @@ -91,7 +96,7 @@ void createPost_success_withCategoriesAndImages() { // when PostResponse response = postService.createPost(request, user.getId()); - // then + // then — DTO 검증 assertThat(response.title()).isEqualTo("제목"); assertThat(response.content()).isEqualTo("내용"); assertThat(response.author().nickname()).isEqualTo("작성자"); @@ -99,6 +104,20 @@ void createPost_success_withCategoriesAndImages() { assertThat(response.categories().getFirst().name()).isEqualTo("공지"); assertThat(response.images()).hasSize(2); assertThat(response.images().getFirst().id()).isEqualTo(img1.getId()); + + // then — DB 매핑 검증 + List mappings = attachmentMappingRepository + .findAllByEntityTypeAndEntityId(EntityType.POST, response.postId()); + + assertThat(mappings).hasSize(2); // 이미지 2개 → 매핑 2개 + assertThat(mappings) + .allSatisfy(mapping -> { + assertThat(mapping.getEntityType()).isEqualTo(EntityType.POST); + assertThat(mapping.getEntityId()).isEqualTo(response.postId()); + assertThat(mapping.getFileAttachment()).isNotNull(); + assertThat(mapping.getFileAttachment().getId()) + .isIn(img1.getId(), img2.getId()); + }); } @Test @@ -199,11 +218,25 @@ void getPost_success() { PostCategory category = new PostCategory("공지", CategoryType.SUBJECT); postCategoryRepository.save(category); + // 게시글 생성 Post post = new Post(user, "조회용 제목", "조회용 내용", null); post.updateCategories(List.of(category)); postRepository.save(post); - // when + // 첨부 이미지 추가 + MockMultipartFile file1 = new MockMultipartFile("file", "img1.png", "image/png", "dummy".getBytes()); + FileAttachment attachment1 = new FileAttachment("stored_img1.png", file1, user, "https://cdn.example.com/img1.png"); + fileAttachmentRepository.save(attachment1); + + MockMultipartFile file2 = new MockMultipartFile("file", "img2.png", "image/png", "dummy".getBytes()); + FileAttachment attachment2 = new FileAttachment("stored_img2.png", file2, user, "https://cdn.example.com/img2.png"); + fileAttachmentRepository.save(attachment2); + + // 매핑 저장 (EntityType.POST) + attachmentMappingRepository.save(new AttachmentMapping(attachment1, EntityType.POST, post.getId())); + attachmentMappingRepository.save(new AttachmentMapping(attachment2, EntityType.POST, post.getId())); + + // when: 비로그인 상태에서 조회 PostDetailResponse response = postService.getPost(post.getId(), null); // then @@ -212,9 +245,19 @@ void getPost_success() { assertThat(response.content()).isEqualTo("조회용 내용"); assertThat(response.author().nickname()).isEqualTo("독자"); assertThat(response.categories()).extracting("name").containsExactly("공지"); + + // 첨부 이미지 검증 + assertThat(response.images()).hasSize(2); + assertThat(response.images()) + .extracting("url") + .containsExactlyInAnyOrder("https://cdn.example.com/img1.png", "https://cdn.example.com/img2.png"); + + // 기본 상호작용 상태 검증 assertThat(response.likeCount()).isZero(); assertThat(response.bookmarkCount()).isZero(); assertThat(response.commentCount()).isZero(); + assertThat(response.likedByMe()).isFalse(); + assertThat(response.bookmarkedByMe()).isFalse(); } @Test diff --git a/src/test/java/com/back/domain/user/account/controller/AccountControllerTest.java b/src/test/java/com/back/domain/user/account/controller/AccountControllerTest.java index 287e3c1a..f0edf216 100644 --- a/src/test/java/com/back/domain/user/account/controller/AccountControllerTest.java +++ b/src/test/java/com/back/domain/user/account/controller/AccountControllerTest.java @@ -6,8 +6,10 @@ import com.back.domain.board.post.entity.PostBookmark; import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.file.entity.FileAttachment; +import com.back.domain.file.repository.FileAttachmentRepository; import com.back.domain.user.account.dto.ChangePasswordRequest; -import com.back.domain.user.account.dto.UpdateUserProfileRequest; +import com.back.domain.user.account.dto.UserProfileRequest; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.entity.UserProfile; import com.back.domain.user.common.enums.UserStatus; @@ -20,11 +22,13 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; import java.util.List; @@ -58,6 +62,9 @@ class AccountControllerTest { @Autowired private CommentRepository commentRepository; + @Autowired + private FileAttachmentRepository fileAttachmentRepository; + @Autowired private TestJwtTokenProvider testJwtTokenProvider; @@ -71,6 +78,10 @@ private String generateAccessToken(User user) { return testJwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); } + private MultipartFile mockMultipartFile(String filename) { + return new MockMultipartFile(filename, filename, "image/png", new byte[]{1, 2, 3}); + } + // ====================== 내 정보 조회 테스트 ====================== @Test @@ -95,7 +106,8 @@ void getMyInfo_success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.username").value("myinfo")) - .andExpect(jsonPath("$.data.profile.nickname").value("홍길동")); + .andExpect(jsonPath("$.data.profile.nickname").value("홍길동")) + .andExpect(jsonPath("$.data.profile.profileImageUrl").value("https://cdn.example.com/1.png")); } @Test @@ -190,7 +202,10 @@ void updateMyProfile_success() throws Exception { String accessToken = generateAccessToken(user); - UpdateUserProfileRequest request = new UpdateUserProfileRequest( + FileAttachment attachment = new FileAttachment("profile_uuid_img.png", mockMultipartFile("profile.png"), user, "https://cdn.example.com/profile/new.png"); + fileAttachmentRepository.save(attachment); + + UserProfileRequest request = new UserProfileRequest( "새닉네임", "https://cdn.example.com/profile/new.png", "저는 개발자입니다!", @@ -212,7 +227,8 @@ void updateMyProfile_success() throws Exception { .andExpect(jsonPath("$.message").value("회원 정보를 수정했습니다.")) .andExpect(jsonPath("$.data.profile.nickname").value("새닉네임")) .andExpect(jsonPath("$.data.profile.bio").value("저는 개발자입니다!")) - .andExpect(jsonPath("$.data.profile.birthDate").value("2000-05-10")); + .andExpect(jsonPath("$.data.profile.birthDate").value("2000-05-10")) + .andExpect(jsonPath("$.data.profile.profileImageUrl").value("https://cdn.example.com/profile/new.png")); } @Test @@ -231,7 +247,7 @@ void updateMyProfile_duplicateNickname() throws Exception { String accessToken = generateAccessToken(user2); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("닉1", null, null, null); + UserProfileRequest request = new UserProfileRequest("닉1", null, null, null); // when & then mvc.perform(patch("/api/users/me") @@ -255,7 +271,7 @@ void updateMyProfile_deletedUser() throws Exception { String accessToken = generateAccessToken(user); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then mvc.perform(patch("/api/users/me") @@ -279,7 +295,7 @@ void updateMyProfile_suspendedUser() throws Exception { String accessToken = generateAccessToken(user); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then mvc.perform(patch("/api/users/me") @@ -296,7 +312,7 @@ void updateMyProfile_suspendedUser() throws Exception { @DisplayName("AccessToken 없음으로 프로필 수정 → 401 Unauthorized (AUTH_001)") void updateMyProfile_noAccessToken() throws Exception { // given: 요청 바디 준비 - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then mvc.perform(patch("/api/users/me") @@ -312,7 +328,7 @@ void updateMyProfile_noAccessToken() throws Exception { @DisplayName("잘못된 AccessToken으로 프로필 수정 → 401 Unauthorized (AUTH_002)") void updateMyProfile_invalidAccessToken() throws Exception { // given - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then mvc.perform(patch("/api/users/me") @@ -338,7 +354,7 @@ void updateMyProfile_expiredAccessToken() throws Exception { user.getId(), user.getUsername(), user.getRole().name() ); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then mvc.perform(patch("/api/users/me") diff --git a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java index ee4b10f6..6fb1952b 100644 --- a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java +++ b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java @@ -9,26 +9,34 @@ import com.back.domain.board.post.entity.PostBookmark; import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.file.entity.AttachmentMapping; +import com.back.domain.file.entity.EntityType; +import com.back.domain.file.entity.FileAttachment; +import com.back.domain.file.repository.AttachmentMappingRepository; +import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.service.FileService; import com.back.domain.user.account.dto.ChangePasswordRequest; -import com.back.domain.user.account.dto.UpdateUserProfileRequest; +import com.back.domain.user.account.dto.UserProfileRequest; import com.back.domain.user.account.dto.UserDetailResponse; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.entity.UserProfile; import com.back.domain.user.common.enums.UserStatus; import com.back.domain.user.common.repository.UserRepository; -import com.back.domain.user.account.service.AccountService; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; 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.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; import java.util.List; @@ -56,9 +64,22 @@ class AccountServiceTest { @Autowired private CommentRepository commentRepository; + @Autowired + private FileAttachmentRepository fileAttachmentRepository; + + @Autowired + private AttachmentMappingRepository attachmentMappingRepository; + @Autowired private PasswordEncoder passwordEncoder; + @MockBean + private FileService fileService; + + private MultipartFile mockMultipartFile(String filename) { + return new MockMultipartFile(filename, filename, "image/png", new byte[]{1, 2, 3}); + } + // ====================== 사용자 정보 조회 테스트 ====================== @Test @@ -128,19 +149,32 @@ void updateUserProfile_success() { user.setUserStatus(UserStatus.ACTIVE); userRepository.save(user); - UpdateUserProfileRequest request = new UpdateUserProfileRequest( - "새닉네임", "https://cdn.example.com/new.png", "자기소개", LocalDate.of(1999, 5, 10) - ); + // 기존 프로필 이미지 매핑 설정 + FileAttachment oldAttachment = new FileAttachment("old_uuid_img.png", mockMultipartFile("old.png"), user, "https://cdn.example.com/old.png"); + fileAttachmentRepository.save(oldAttachment); + attachmentMappingRepository.save(new AttachmentMapping(oldAttachment, EntityType.PROFILE, user.getId())); + + // 새 프로필 이미지 업로드된 파일 가정 + FileAttachment newAttachment = new FileAttachment("new_uuid_img.png", mockMultipartFile("new.png"), user, "https://cdn.example.com/new.png"); + fileAttachmentRepository.save(newAttachment); + + UserProfileRequest request = new UserProfileRequest("새닉네임", newAttachment.getPublicURL(), "자기소개", LocalDate.of(1999, 5, 10)); // when: 서비스 호출 UserDetailResponse response = accountService.updateUserProfile(user.getId(), request); - // then: 응답 및 DB 값 검증 - assertThat(response.profile().nickname()).isEqualTo("새닉네임"); - assertThat(response.profile().bio()).isEqualTo("자기소개"); - + // then User updated = userRepository.findById(user.getId()).orElseThrow(); assertThat(updated.getUserProfile().getNickname()).isEqualTo("새닉네임"); + assertThat(response.profile().nickname()).isEqualTo("새닉네임"); + + // 새 매핑이 존재하고 기존 매핑은 삭제되었는지 검증 + List mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, user.getId()); + assertThat(mappings).hasSize(1); + assertThat(mappings.get(0).getFileAttachment().getPublicURL()).isEqualTo(newAttachment.getPublicURL()); + + // 기존 이미지가 삭제되었는지 확인 (테스트 환경에서는 DB 삭제만 검증) + assertThat(fileAttachmentRepository.findByPublicURL("https://cdn.example.com/old.png")).isEmpty(); } @Test @@ -157,7 +191,7 @@ void updateUserProfile_duplicateNickname() { user2.setUserStatus(UserStatus.ACTIVE); userRepository.save(user2); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("닉1", null, null, null); + UserProfileRequest request = new UserProfileRequest("닉1", null, null, null); // when & then assertThatThrownBy(() -> accountService.updateUserProfile(user2.getId(), request)) @@ -174,7 +208,7 @@ void updateUserProfile_deletedUser() { user.setUserStatus(UserStatus.DELETED); userRepository.save(user); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then assertThatThrownBy(() -> accountService.updateUserProfile(user.getId(), request)) @@ -191,7 +225,7 @@ void updateUserProfile_suspendedUser() { user.setUserStatus(UserStatus.SUSPENDED); userRepository.save(user); - UpdateUserProfileRequest request = new UpdateUserProfileRequest("새닉", null, null, null); + UserProfileRequest request = new UserProfileRequest("새닉", null, null, null); // when & then assertThatThrownBy(() -> accountService.updateUserProfile(user.getId(), request)) @@ -323,10 +357,15 @@ void changePassword_userNotFound() { void deleteUser_success() { // given: 정상 상태의 유저 저장 User user = User.createUser("deleteuser", "delete@example.com", passwordEncoder.encode("P@ssw0rd!")); - user.setUserProfile(new UserProfile(user, "홍길동", "https://cdn.example.com/profile.png", "소개글", LocalDate.of(1995, 3, 15), 500)); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(1995, 3, 15), 500)); user.setUserStatus(UserStatus.ACTIVE); userRepository.save(user); + // 프로필 이미지 매핑 설정 + FileAttachment attachment = new FileAttachment("profile_uuid_img.png", mockMultipartFile("profile.png"), user, "https://cdn.example.com/profile.png"); + fileAttachmentRepository.save(attachment); + attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.PROFILE, user.getId())); + // when: 탈퇴 처리 accountService.deleteUser(user.getId()); @@ -337,12 +376,17 @@ void deleteUser_success() { assertThat(deleted.getEmail()).startsWith("deleted_"); assertThat(deleted.getProvider()).startsWith("deleted_"); assertThat(deleted.getProviderId()).startsWith("deleted_"); + assertThat(deleted.getUserProfile().getNickname()).isEqualTo("탈퇴한 회원"); UserProfile profile = deleted.getUserProfile(); assertThat(profile.getNickname()).isEqualTo("탈퇴한 회원"); assertThat(profile.getProfileImageUrl()).isNull(); assertThat(profile.getBio()).isNull(); assertThat(profile.getBirthDate()).isNull(); + + // 프로필 이미지 및 매핑 삭제 검증 + assertThat(attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, user.getId())).isEmpty(); + assertThat(fileAttachmentRepository.findByPublicURL("https://cdn.example.com/profile.png")).isEmpty(); } @Test