diff --git a/src/main/java/com/example/solidconnection/admin/university/controller/AdminHostUniversityController.java b/src/main/java/com/example/solidconnection/admin/university/controller/AdminHostUniversityController.java index 57035537d..4803fb9ae 100644 --- a/src/main/java/com/example/solidconnection/admin/university/controller/AdminHostUniversityController.java +++ b/src/main/java/com/example/solidconnection/admin/university/controller/AdminHostUniversityController.java @@ -11,15 +11,17 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; 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.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.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor @RequestMapping("/admin/host-universities") @@ -44,20 +46,33 @@ public ResponseEntity getHostUniversity( return ResponseEntity.ok(response); } - @PostMapping + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createHostUniversity( - @Valid @RequestBody AdminHostUniversityCreateRequest request + @Valid @RequestPart("request") AdminHostUniversityCreateRequest request, + @RequestPart("logoFile") MultipartFile logoFile, + @RequestPart("backgroundFile") MultipartFile backgroundFile ) { - AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(request); + AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity( + request, + logoFile, + backgroundFile + ); return ResponseEntity.ok(response); } - @PutMapping("/{host-university-id}") + @PutMapping(value = "/{host-university-id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateHostUniversity( @PathVariable("host-university-id") Long hostUniversityId, - @Valid @RequestBody AdminHostUniversityUpdateRequest request + @Valid @RequestPart("request") AdminHostUniversityUpdateRequest request, + @RequestPart(value = "logoFile", required = false) MultipartFile logoFile, + @RequestPart(value = "backgroundFile", required = false) MultipartFile backgroundFile ) { - AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity(hostUniversityId, request); + AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity( + hostUniversityId, + request, + logoFile, + backgroundFile + ); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java index 01b8c4e24..8538360f1 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java @@ -25,14 +25,6 @@ public record AdminHostUniversityCreateRequest( @Size(max = 500, message = "숙소 URL은 500자 이하여야 합니다") String accommodationUrl, - @NotBlank(message = "로고 이미지 URL은 필수입니다") - @Size(max = 500, message = "로고 이미지 URL은 500자 이하여야 합니다") - String logoImageUrl, - - @NotBlank(message = "배경 이미지 URL은 필수입니다") - @Size(max = 500, message = "배경 이미지 URL은 500자 이하여야 합니다") - String backgroundImageUrl, - @Size(max = 1000, message = "상세 정보는 1000자 이하여야 합니다") String detailsForLocal, diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java index 0e75d846c..29688a117 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java @@ -25,14 +25,6 @@ public record AdminHostUniversityUpdateRequest( @Size(max = 500, message = "숙소 URL은 500자 이하여야 합니다") String accommodationUrl, - @NotBlank(message = "로고 이미지 URL은 필수입니다") - @Size(max = 500, message = "로고 이미지 URL은 500자 이하여야 합니다") - String logoImageUrl, - - @NotBlank(message = "배경 이미지 URL은 필수입니다") - @Size(max = 500, message = "배경 이미지 URL은 500자 이하여야 합니다") - String backgroundImageUrl, - @Size(max = 1000, message = "상세 정보는 1000자 이하여야 합니다") String detailsForLocal, diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java index 416ba8fb8..55726ed11 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java @@ -18,18 +18,25 @@ import com.example.solidconnection.location.country.repository.CountryRepository; import com.example.solidconnection.location.region.domain.Region; import com.example.solidconnection.location.region.repository.RegionRepository; +import com.example.solidconnection.s3.domain.UploadDirectoryName; +import com.example.solidconnection.s3.domain.UploadPath; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.university.domain.HostUniversity; import com.example.solidconnection.university.repository.HostUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor +@Slf4j public class AdminHostUniversityService { private final HostUniversityRepository hostUniversityRepository; @@ -37,6 +44,7 @@ public class AdminHostUniversityService { private final RegionRepository regionRepository; private final UnivApplyInfoRepository univApplyInfoRepository; private final CustomCacheManager cacheManager; + private final S3Service s3Service; @Transactional(readOnly = true) public Page getHostUniversities( @@ -65,29 +73,51 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) { cacheManager = "customCacheManager", prefix = true ) - public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) { + public AdminHostUniversityDetailResponse createHostUniversity( + AdminHostUniversityCreateRequest request, + MultipartFile logoFile, + MultipartFile backgroundFile + ) { validateKoreanNameNotExists(request.koreanName()); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); + String directoryName = UploadDirectoryName.fromUniversityNames(request.englishName(), request.koreanName()); + UploadedFileUrlResponse logoImage = null; + UploadedFileUrlResponse backgroundImage = null; - HostUniversity hostUniversity = new HostUniversity( - null, - request.koreanName(), - request.englishName(), - request.formatName(), - request.homepageUrl(), - request.englishCourseUrl(), - request.accommodationUrl(), - request.logoImageUrl(), - request.backgroundImageUrl(), - request.detailsForLocal(), - country, - region - ); + try { + logoImage = uploadUniversityImage( + logoFile, + UploadPath.ADMIN_UNIVERSITY_LOGO, + directoryName + ); + backgroundImage = uploadUniversityImage( + backgroundFile, + UploadPath.ADMIN_UNIVERSITY_BACKGROUND, + directoryName + ); - HostUniversity savedHostUniversity = hostUniversityRepository.save(hostUniversity); - return AdminHostUniversityDetailResponse.from(savedHostUniversity); + HostUniversity hostUniversity = new HostUniversity( + null, + request.koreanName(), + request.englishName(), + request.formatName(), + request.homepageUrl(), + request.englishCourseUrl(), + request.accommodationUrl(), + logoImage.fileUrl(), + backgroundImage.fileUrl(), + request.detailsForLocal(), + country, + region + ); + HostUniversity savedHostUniversity = hostUniversityRepository.saveAndFlush(hostUniversity); + return AdminHostUniversityDetailResponse.from(savedHostUniversity); + } catch (RuntimeException e) { + deleteUploadedImages(logoImage, backgroundImage); + throw e; + } } private void validateKoreanNameNotExists(String koreanName) { @@ -103,7 +133,12 @@ private void validateKoreanNameNotExists(String koreanName) { cacheManager = "customCacheManager", prefix = true ) - public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHostUniversityUpdateRequest request) { + public AdminHostUniversityDetailResponse updateHostUniversity( + Long id, + AdminHostUniversityUpdateRequest request, + MultipartFile logoFile, + MultipartFile backgroundFile + ) { HostUniversity hostUniversity = hostUniversityRepository.findById(id) .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); @@ -111,24 +146,84 @@ public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHost Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); + String directoryName = UploadDirectoryName.fromUniversityNames(request.englishName(), request.koreanName()); + UploadedFileUrlResponse logoImage = null; + UploadedFileUrlResponse backgroundImage = null; - hostUniversity.update( - request.koreanName(), - request.englishName(), - request.formatName(), - request.homepageUrl(), - request.englishCourseUrl(), - request.accommodationUrl(), - request.logoImageUrl(), - request.backgroundImageUrl(), - request.detailsForLocal(), - country, - region - ); + try { + logoImage = uploadUniversityImageIfExists( + logoFile, + UploadPath.ADMIN_UNIVERSITY_LOGO, + directoryName + ); + backgroundImage = uploadUniversityImageIfExists( + backgroundFile, + UploadPath.ADMIN_UNIVERSITY_BACKGROUND, + directoryName + ); - evictUnivApplyInfoDetailCaches(id); + hostUniversity.update( + request.koreanName(), + request.englishName(), + request.formatName(), + request.homepageUrl(), + request.englishCourseUrl(), + request.accommodationUrl(), + getImageUrlOrDefault(logoImage, hostUniversity.getLogoImageUrl()), + getImageUrlOrDefault(backgroundImage, hostUniversity.getBackgroundImageUrl()), + request.detailsForLocal(), + country, + region + ); + hostUniversityRepository.flush(); + evictUnivApplyInfoDetailCaches(id); + return AdminHostUniversityDetailResponse.from(hostUniversity); + } catch (RuntimeException e) { + deleteUploadedImages(logoImage, backgroundImage); + throw e; + } + } - return AdminHostUniversityDetailResponse.from(hostUniversity); + private UploadedFileUrlResponse uploadUniversityImage( + MultipartFile imageFile, + UploadPath uploadPath, + String directoryName + ) { + return s3Service.uploadFile(imageFile, uploadPath, directoryName); + } + + private UploadedFileUrlResponse uploadUniversityImageIfExists( + MultipartFile imageFile, + UploadPath uploadPath, + String directoryName + ) { + if (imageFile == null || imageFile.isEmpty()) { + return null; + } + return uploadUniversityImage(imageFile, uploadPath, directoryName); + } + + private String getImageUrlOrDefault(UploadedFileUrlResponse uploadedImage, String defaultImageUrl) { + if (uploadedImage == null) { + return defaultImageUrl; + } + return uploadedImage.fileUrl(); + } + + private void deleteUploadedImages(UploadedFileUrlResponse... uploadedImages) { + for (UploadedFileUrlResponse uploadedImage : uploadedImages) { + if (uploadedImage != null) { + try { + s3Service.deleteUploadedFile(uploadedImage); + } catch (RuntimeException deleteException) { + log.warn( + "Failed to delete uploaded university image. fileUrl={}", + uploadedImage.fileUrl(), + deleteException + ); + } + } + } } private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId) { diff --git a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java index 3b5832718..8e98c863b 100644 --- a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java @@ -1,13 +1,10 @@ package com.example.solidconnection.s3.controller; import com.example.solidconnection.common.resolver.AuthorizedUser; -import com.example.solidconnection.s3.domain.UploadDirectoryName; import com.example.solidconnection.s3.domain.UploadPath; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.dto.UrlPrefixResponse; import com.example.solidconnection.s3.service.S3Service; -import com.example.solidconnection.security.annotation.RequireRoleAccess; -import com.example.solidconnection.siteuser.domain.Role; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -80,38 +77,6 @@ public ResponseEntity> uploadChatFile( return ResponseEntity.ok(chatImageUrls); } - @RequireRoleAccess(roles = Role.ADMIN) - @PostMapping("/admin/university/logo") - public ResponseEntity uploadAdminUniversityLogo( - @AuthorizedUser long adminId, - @RequestParam("file") MultipartFile imageFile, - @RequestParam("englishName") String englishName - ) { - String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); - UploadedFileUrlResponse logoImageUrl = s3Service.uploadFile( - imageFile, - UploadPath.ADMIN_UNIVERSITY_LOGO, - directoryName - ); - return ResponseEntity.ok(logoImageUrl); - } - - @RequireRoleAccess(roles = Role.ADMIN) - @PostMapping("/admin/university/background") - public ResponseEntity uploadAdminUniversityBackground( - @AuthorizedUser long adminId, - @RequestParam("file") MultipartFile imageFile, - @RequestParam("englishName") String englishName - ) { - String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); - UploadedFileUrlResponse backgroundImageUrl = s3Service.uploadFile( - imageFile, - UploadPath.ADMIN_UNIVERSITY_BACKGROUND, - directoryName - ); - return ResponseEntity.ok(backgroundImageUrl); - } - @GetMapping("/s3-url-prefix") public ResponseEntity getS3UrlPrefix() { return ResponseEntity.ok(new UrlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded)); diff --git a/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java b/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java index 4b53396dc..9b09a7ea9 100644 --- a/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java +++ b/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java @@ -14,10 +14,13 @@ public final class UploadDirectoryName { private UploadDirectoryName() { } - public static String fromUniversityEnglishName(String englishName) { + public static String fromUniversityNames(String englishName, String koreanName) { if (englishName == null || englishName.isBlank()) { throw new CustomException(ErrorCode.INVALID_INPUT); } + if (koreanName == null || koreanName.isBlank()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } String directoryName = englishName.trim() .toLowerCase() @@ -30,7 +33,7 @@ public static String fromUniversityEnglishName(String englishName) { throw new CustomException(ErrorCode.INVALID_INPUT); } - return directoryName + "_" + hash(englishName.trim()); + return directoryName + "_" + hash(koreanName.trim()); } private static String hash(String value) { diff --git a/src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java b/src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java index 0a4722807..9d1cdd11a 100644 --- a/src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java +++ b/src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java @@ -1,6 +1,12 @@ package com.example.solidconnection.s3.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; + public record UploadedFileUrlResponse( - String fileUrl) { + String fileUrl, + @JsonIgnore String deletionKey) { + public UploadedFileUrlResponse(String fileUrl) { + this(fileUrl, fileUrl); + } } diff --git a/src/main/java/com/example/solidconnection/s3/service/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java index b5ad88d85..df05dab83 100644 --- a/src/main/java/com/example/solidconnection/s3/service/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -78,7 +78,7 @@ public UploadedFileUrlResponse uploadFile( fileUploadService.uploadFile(bucket, originalPath, bytes, contentType); - return new UploadedFileUrlResponse(returnPath); + return new UploadedFileUrlResponse(returnPath, originalPath); } private String createFileName(UploadPath uploadPath, String subDirectory, String baseFileName) { @@ -141,6 +141,13 @@ public void deletePostImage(String url) { deleteFile(url); } + public void deleteUploadedFile(UploadedFileUrlResponse uploadedFile) { + if (uploadedFile == null) { + return; + } + deleteFile(uploadedFile.deletionKey()); + } + private void deleteFile(String fileName) { try { DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java index 29a1ae163..d8a9ad2e6 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java @@ -2,7 +2,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.times; import com.example.solidconnection.admin.university.dto.AdminHostUniversityCreateRequest; @@ -18,6 +23,9 @@ import com.example.solidconnection.location.country.fixture.CountryFixture; import com.example.solidconnection.location.region.domain.Region; import com.example.solidconnection.location.region.fixture.RegionFixture; +import com.example.solidconnection.s3.domain.UploadPath; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.university.domain.HostUniversity; import com.example.solidconnection.university.domain.UnivApplyInfo; @@ -31,6 +39,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @TestContainerSpringBootTest @@ -58,6 +68,29 @@ class AdminHostUniversityServiceTest { @MockitoSpyBean private CustomCacheManager cacheManager; + @MockitoBean + private S3Service s3Service; + + private MockMultipartFile createImageFile(String name) { + return new MockMultipartFile("file", name, "image/png", new byte[100]); + } + + private void stubUniversityImageUpload() { + given(s3Service.uploadFile(any(), eq(UploadPath.ADMIN_UNIVERSITY_LOGO), anyString())) + .willReturn(new UploadedFileUrlResponse("admin/logo/test/logo.png")); + given(s3Service.uploadFile(any(), eq(UploadPath.ADMIN_UNIVERSITY_BACKGROUND), anyString())) + .willReturn(new UploadedFileUrlResponse("admin/background/test/background.png")); + } + + private UploadedFileUrlResponse stubLogoUploadAndBackgroundUploadFailure() { + UploadedFileUrlResponse logoImage = new UploadedFileUrlResponse("admin/logo/test/logo.png"); + given(s3Service.uploadFile(any(), eq(UploadPath.ADMIN_UNIVERSITY_LOGO), anyString())) + .willReturn(logoImage); + given(s3Service.uploadFile(any(), eq(UploadPath.ADMIN_UNIVERSITY_BACKGROUND), anyString())) + .willThrow(new RuntimeException("background upload failed")); + return logoImage; + } + @Nested class 목록_조회 { @@ -206,6 +239,7 @@ class 생성 { // given Country country = countryFixture.미국(); Region region = regionFixture.영미권(); + stubUniversityImageUpload(); AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest( "테스트 대학", @@ -214,15 +248,17 @@ class 생성 { "https://homepage.com", "https://english-course.com", "https://accommodation.com", - "https://logo.com/image.png", - "https://background.com/image.png", "상세 정보", country.getCode(), region.getCode() ); // when - AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(request); + AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity( + request, + createImageFile("logo.png"), + createImageFile("background.png") + ); // then assertThat(response.koreanName()).isEqualTo(request.koreanName()); @@ -230,6 +266,8 @@ class 생성 { HostUniversity savedUniversity = hostUniversityRepository.findById(response.id()).orElseThrow(); assertThat(savedUniversity.getKoreanName()).isEqualTo(request.koreanName()); + assertThat(savedUniversity.getLogoImageUrl()).isEqualTo("admin/logo/test/logo.png"); + assertThat(savedUniversity.getBackgroundImageUrl()).isEqualTo("admin/background/test/background.png"); } @Test @@ -244,15 +282,17 @@ class 생성 { "New English Name", "표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, country.getCode(), region.getCode() ); // when & then - assertThatCode(() -> adminHostUniversityService.createHostUniversity(request)) + assertThatCode(() -> adminHostUniversityService.createHostUniversity( + request, + createImageFile("logo.png"), + createImageFile("background.png") + )) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); } @@ -263,26 +303,87 @@ class 생성 { HostUniversity existing = universityFixture.괌_대학(); Country country = countryFixture.미국(); Region region = regionFixture.영미권(); + stubUniversityImageUpload(); AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest( "신규 대학", existing.getEnglishName(), "표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, country.getCode(), region.getCode() ); // when - AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(request); + AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity( + request, + createImageFile("logo.png"), + createImageFile("background.png") + ); // then assertThat(response.koreanName()).isEqualTo(request.koreanName()); assertThat(response.englishName()).isEqualTo(existing.getEnglishName()); } + + @Test + void 배경_이미지_업로드가_실패하면_이미_업로드된_로고_이미지를_삭제한다() { + // given + Country country = countryFixture.미국(); + Region region = regionFixture.영미권(); + UploadedFileUrlResponse logoImage = stubLogoUploadAndBackgroundUploadFailure(); + + AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest( + "테스트 대학", + "Test University", + "테스트 대학", + null, null, null, + null, + country.getCode(), + region.getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.createHostUniversity( + request, + createImageFile("logo.png"), + createImageFile("background.png") + )) + .isInstanceOf(RuntimeException.class) + .hasMessage("background upload failed"); + then(s3Service).should().deleteUploadedFile(logoImage); + } + + @Test + void 보상_삭제가_실패해도_원래_예외를_반환한다() { + // given + Country country = countryFixture.미국(); + Region region = regionFixture.영미권(); + UploadedFileUrlResponse logoImage = stubLogoUploadAndBackgroundUploadFailure(); + willThrow(new RuntimeException("delete failed")) + .given(s3Service) + .deleteUploadedFile(logoImage); + + AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest( + "테스트 대학", + "Test University", + "테스트 대학", + null, null, null, + null, + country.getCode(), + region.getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.createHostUniversity( + request, + createImageFile("logo.png"), + createImageFile("background.png") + )) + .isInstanceOf(RuntimeException.class) + .hasMessage("background upload failed"); + } } @Nested @@ -292,6 +393,8 @@ class 수정 { void 유효한_정보로_대학을_수정하면_성공한다() { // given HostUniversity university = universityFixture.괌_대학(); + String originLogoImageUrl = university.getLogoImageUrl(); + String originBackgroundImageUrl = university.getBackgroundImageUrl(); Country country = countryFixture.일본(); Region region = regionFixture.아시아(); @@ -301,8 +404,6 @@ class 수정 { "수정된 표시명", "https://new-homepage.com", null, null, - "https://new-logo.com/image.png", - "https://new-background.com/image.png", "수정된 상세 정보", country.getCode(), region.getCode() @@ -310,7 +411,11 @@ class 수정 { // when AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity( - university.getId(), request); + university.getId(), + request, + null, + null + ); // then assertThat(response.koreanName()).isEqualTo(request.koreanName()); @@ -318,6 +423,66 @@ class 수정 { HostUniversity updatedUniversity = hostUniversityRepository.findById(university.getId()).orElseThrow(); assertThat(updatedUniversity.getKoreanName()).isEqualTo(request.koreanName()); + assertThat(updatedUniversity.getLogoImageUrl()).isEqualTo(originLogoImageUrl); + assertThat(updatedUniversity.getBackgroundImageUrl()).isEqualTo(originBackgroundImageUrl); + } + + @Test + void 이미지_파일을_함께_전달하면_업로드된_이미지_URL로_수정한다() { + // given + HostUniversity university = universityFixture.괌_대학(); + stubUniversityImageUpload(); + + AdminHostUniversityUpdateRequest request = new AdminHostUniversityUpdateRequest( + university.getKoreanName(), + university.getEnglishName(), + "수정된 표시명", + null, null, null, + null, + university.getCountry().getCode(), + university.getRegion().getCode() + ); + + // when + adminHostUniversityService.updateHostUniversity( + university.getId(), + request, + createImageFile("new-logo.png"), + createImageFile("new-background.png") + ); + + // then + HostUniversity updatedUniversity = hostUniversityRepository.findById(university.getId()).orElseThrow(); + assertThat(updatedUniversity.getLogoImageUrl()).isEqualTo("admin/logo/test/logo.png"); + assertThat(updatedUniversity.getBackgroundImageUrl()).isEqualTo("admin/background/test/background.png"); + } + + @Test + void 배경_이미지_업로드가_실패하면_이미_업로드된_로고_이미지를_삭제한다() { + // given + HostUniversity university = universityFixture.괌_대학(); + UploadedFileUrlResponse logoImage = stubLogoUploadAndBackgroundUploadFailure(); + + AdminHostUniversityUpdateRequest request = new AdminHostUniversityUpdateRequest( + university.getKoreanName(), + university.getEnglishName(), + "수정된 표시명", + null, null, null, + null, + university.getCountry().getCode(), + university.getRegion().getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.updateHostUniversity( + university.getId(), + request, + createImageFile("new-logo.png"), + createImageFile("new-background.png") + )) + .isInstanceOf(RuntimeException.class) + .hasMessage("background upload failed"); + then(s3Service).should().deleteUploadedFile(logoImage); } @Test @@ -331,15 +496,13 @@ class 수정 { "Updated University", "수정된 표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, country.getCode(), region.getCode() ); // when & then - assertThatCode(() -> adminHostUniversityService.updateHostUniversity(999L, request)) + assertThatCode(() -> adminHostUniversityService.updateHostUniversity(999L, request, null, null)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.UNIVERSITY_NOT_FOUND.getMessage()); } @@ -355,15 +518,13 @@ class 수정 { "Updated University", "수정된 표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, university1.getCountry().getCode(), university1.getRegion().getCode() ); // when & then - assertThatCode(() -> adminHostUniversityService.updateHostUniversity(university1.getId(), request)) + assertThatCode(() -> adminHostUniversityService.updateHostUniversity(university1.getId(), request, null, null)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); } @@ -379,8 +540,6 @@ class 수정 { university2.getEnglishName(), "수정된 표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, university1.getCountry().getCode(), university1.getRegion().getCode() @@ -388,7 +547,11 @@ class 수정 { // when AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity( - university1.getId(), request); + university1.getId(), + request, + null, + null + ); // then assertThat(response.koreanName()).isEqualTo(university1.getKoreanName()); @@ -405,8 +568,6 @@ class 수정 { "Updated English Name", "수정된 표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, university.getCountry().getCode(), university.getRegion().getCode() @@ -414,7 +575,11 @@ class 수정 { // when AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity( - university.getId(), request); + university.getId(), + request, + null, + null + ); // then assertThat(response.koreanName()).isEqualTo(university.getKoreanName()); @@ -477,15 +642,18 @@ class 캐시_무효화 { "캐시 테스트 대학", "https://homepage.com", null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, country.getCode(), region.getCode() ); + stubUniversityImageUpload(); // when - adminHostUniversityService.createHostUniversity(request); + adminHostUniversityService.createHostUniversity( + request, + createImageFile("logo.png"), + createImageFile("background.png") + ); // then then(cacheManager).should(times(1)).evictUsingPrefix("univApplyInfoTextSearch"); @@ -510,15 +678,13 @@ class 캐시_무효화 { "Updated University", "수정된 표시명", null, null, null, - "https://logo.com/image.png", - "https://background.com/image.png", null, country.getCode(), region.getCode() ); // when - adminHostUniversityService.updateHostUniversity(university.getId(), request); + adminHostUniversityService.updateHostUniversity(university.getId(), request, null, null); // then then(cacheManager).should(times(1)).evictUsingPrefix("univApplyInfoTextSearch"); diff --git a/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java b/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java index 041b7c42f..fc969fd25 100644 --- a/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java +++ b/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java @@ -18,9 +18,10 @@ class 대학_영문명_변환_테스트 { void 대학_영문명의_공백을_언더스코어로_변환한다() { // given String englishName = "University of Tokyo"; + String koreanName = "도쿄대학교"; // when - String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + String directoryName = UploadDirectoryName.fromUniversityNames(englishName, koreanName); // then assertThat(directoryName) @@ -32,9 +33,10 @@ class 대학_영문명_변환_테스트 { void 특수문자를_제거하고_앰퍼샌드는_and로_변환한다() { // given String englishName = "Texas A&M University, Austin"; + String koreanName = "텍사스 A&M 대학교 오스틴"; // when - String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + String directoryName = UploadDirectoryName.fromUniversityNames(englishName, koreanName); // then assertThat(directoryName) @@ -43,17 +45,25 @@ class 대학_영문명_변환_테스트 { } @Test - void 같은_slug로_변환되는_서로_다른_영문명은_다른_디렉토리명을_반환한다() { + void 같은_영문명이어도_한글명이_다르면_다른_디렉토리명을_반환한다() { // given - String englishName = "Texas A&M University"; - String normalizedCollisionName = "Texas A and M University"; + String englishName = "University of California"; + String koreanName = "캘리포니아대학교"; + String duplicateEnglishKoreanName = "캘리포니아대학"; // when - String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); - String collisionDirectoryName = UploadDirectoryName.fromUniversityEnglishName(normalizedCollisionName); + String directoryName = UploadDirectoryName.fromUniversityNames(englishName, koreanName); + String duplicateEnglishDirectoryName = UploadDirectoryName.fromUniversityNames( + englishName, + duplicateEnglishKoreanName + ); // then - assertThat(directoryName).isNotEqualTo(collisionDirectoryName); + assertThat(directoryName) + .startsWith("university_of_california_") + .isNotEqualTo(duplicateEnglishDirectoryName); + assertThat(duplicateEnglishDirectoryName) + .startsWith("university_of_california_"); } @Test @@ -62,7 +72,17 @@ class 대학_영문명_변환_테스트 { String blankName = " "; // when & then - assertThatThrownBy(() -> UploadDirectoryName.fromUniversityEnglishName(blankName)) + assertThatThrownBy(() -> UploadDirectoryName.fromUniversityNames(blankName, "한글명")) + .isInstanceOf(CustomException.class); + } + + @Test + void 한글명이_공백_문자열이면_예외가_발생한다() { + // given + String blankKoreanName = " "; + + // when & then + assertThatThrownBy(() -> UploadDirectoryName.fromUniversityNames("University of Tokyo", blankKoreanName)) .isInstanceOf(CustomException.class); } } diff --git a/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java b/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java index 543aa924f..513210353 100644 --- a/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java +++ b/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.then; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.s3.domain.UploadPath; @@ -12,10 +13,13 @@ import org.junit.jupiter.api.Nested; 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; import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; @DisplayName("S3 서비스 테스트") @ExtendWith(MockitoExtension.class) @@ -25,6 +29,8 @@ public class S3ServiceTest { @InjectMocks private S3Service s3Service; @Mock + private S3Client s3Client; + @Mock private FileUploadService fileUploadService; private MockMultipartFile createMockFile(String originalName, long size) { @@ -151,4 +157,25 @@ class 파일_검증 { ); } } + + @Nested + class 파일_삭제 { + + @Test + void 업로드된_파일을_삭제할_때_삭제용_key를_사용한다() { + // given + UploadedFileUrlResponse uploadedFile = new UploadedFileUrlResponse( + "resize/profile/test.webp", + "original/profile/test.jpg" + ); + + // when + s3Service.deleteUploadedFile(uploadedFile); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + then(s3Client).should().deleteObject(requestCaptor.capture()); + assertThat(requestCaptor.getValue().key()).isEqualTo("original/profile/test.jpg"); + } + } }