diff --git a/src/main/java/eatda/client/file/FileClient.java b/src/main/java/eatda/client/file/FileClient.java index 1fa6e38c..15187a23 100644 --- a/src/main/java/eatda/client/file/FileClient.java +++ b/src/main/java/eatda/client/file/FileClient.java @@ -3,12 +3,12 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import java.time.Duration; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.CopyObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; @@ -16,14 +16,14 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +@Slf4j @Component public class FileClient { - private static final int THREAD_POOL_SIZE = 5; // TODO 비동기 병렬처리 개선 + private static final String PATH_DELIMITER = "/"; private final S3Client s3Client; private final String bucket; private final S3Presigner s3Presigner; - private final ExecutorService executorService; public FileClient(S3Client s3Client, @Value("${spring.cloud.aws.s3.bucket}") String bucket, @@ -31,7 +31,6 @@ public FileClient(S3Client s3Client, this.s3Client = s3Client; this.bucket = bucket; this.s3Presigner = s3Presigner; - this.executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); } public String generateUploadPresignedUrl(String fileKey, Duration signatureDuration) { @@ -52,27 +51,35 @@ public String generateUploadPresignedUrl(String fileKey, Duration signatureDurat } public List moveTempFilesToPermanent(String domainName, long domainId, List tempImageKeys) { - List> futures = tempImageKeys.stream() - .map(tempImageKey -> CompletableFuture.supplyAsync(() -> { - String fileName = extractFileName(tempImageKey); - String newPermanentKey = domainName + "/" + domainId + "/" + fileName; - try { - copyObject(tempImageKey, newPermanentKey); - deleteObject(tempImageKey); - return newPermanentKey; - } catch (Exception e) { //TODO 근본 예외 추가 필요 - throw new BusinessException(BusinessErrorCode.FAIL_TEMP_IMAGE_PROCESS); - } - }, executorService)) - .toList(); + List successKeys = new ArrayList<>(); - return futures.stream() - .map(CompletableFuture::join) // TODO 일부 파일 에러에도 처리하도록 개선 - .toList(); + try { + for (String tempKey : tempImageKeys) { + String fileName = extractFileName(tempKey); + String newPermanentKey = domainName + PATH_DELIMITER + domainId + PATH_DELIMITER + fileName; + + copyObject(tempKey, newPermanentKey); + deleteObject(tempKey); + + successKeys.add(newPermanentKey); + } + return successKeys; + } catch (SdkException sdkException) { + log.error("S3 파일 이동 중 실패. 롤백 수행. successKeys={}", successKeys, sdkException); + deleteFiles(successKeys); + throw new BusinessException(BusinessErrorCode.FAIL_TEMP_IMAGE_PROCESS); + } + } + + public void deleteFiles(List keys) { + if (keys.isEmpty()) { + return; + } + keys.forEach(this::deleteObject); } private String extractFileName(String fullKey) { - int index = fullKey.lastIndexOf('/'); + int index = fullKey.lastIndexOf(PATH_DELIMITER); return index == -1 ? fullKey : fullKey.substring(index + 1); } diff --git a/src/main/java/eatda/controller/cheer/CheerController.java b/src/main/java/eatda/controller/cheer/CheerController.java index d172f431..878fa8f1 100644 --- a/src/main/java/eatda/controller/cheer/CheerController.java +++ b/src/main/java/eatda/controller/cheer/CheerController.java @@ -6,6 +6,7 @@ import eatda.domain.cheer.CheerTagName; import eatda.domain.store.StoreCategory; import eatda.domain.store.StoreSearchResult; +import eatda.facade.CheerRegisterFacade; import eatda.service.cheer.CheerService; import eatda.service.store.StoreSearchService; import jakarta.validation.constraints.Max; @@ -29,13 +30,14 @@ public class CheerController { private final CheerService cheerService; private final StoreSearchService storeSearchService; + private final CheerRegisterFacade cheerRegisterFacade; @PostMapping("/api/cheer") public ResponseEntity registerCheer(@RequestBody CheerRegisterRequest request, LoginMember member) { StoreSearchResult searchResult = storeSearchService.searchStoreByKakaoId( request.storeName(), request.storeKakaoId()); - CheerResponse response = cheerService.registerCheer(request, searchResult, member.id(), ImageDomain.CHEER); + CheerResponse response = cheerRegisterFacade.registerCheer(request, searchResult, member.id(), ImageDomain.CHEER); return ResponseEntity.status(HttpStatus.CREATED) .body(response); } diff --git a/src/main/java/eatda/controller/cheer/CheerResponse.java b/src/main/java/eatda/controller/cheer/CheerResponse.java index 992faf43..9bbd21d5 100644 --- a/src/main/java/eatda/controller/cheer/CheerResponse.java +++ b/src/main/java/eatda/controller/cheer/CheerResponse.java @@ -2,7 +2,6 @@ import eatda.domain.cheer.Cheer; import eatda.domain.cheer.CheerTagName; -import eatda.domain.store.Store; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -15,12 +14,12 @@ public record CheerResponse( List tags ) { - public CheerResponse(Cheer cheer, Store store, String cdnBaseUrl) { + public CheerResponse(Cheer cheer, String cdnBaseUrl) { this( - store.getId(), + cheer.getStore().getId(), cheer.getId(), cheer.getImages().stream() - .map(img -> new CheerImageResponse(img, cdnBaseUrl)) // ✅ CDN 붙여줌 + .map(img -> new CheerImageResponse(img, cdnBaseUrl)) .sorted(Comparator.comparingLong(CheerImageResponse::orderIndex)) .collect(Collectors.toList()), cheer.getDescription(), diff --git a/src/main/java/eatda/exception/BusinessErrorCode.java b/src/main/java/eatda/exception/BusinessErrorCode.java index a74e08e3..de91109d 100644 --- a/src/main/java/eatda/exception/BusinessErrorCode.java +++ b/src/main/java/eatda/exception/BusinessErrorCode.java @@ -28,6 +28,7 @@ public enum BusinessErrorCode { INVALID_CHEER_IMAGE_KEY("CHE002", "응원 이미지 키가 비어 있습니다.", HttpStatus.BAD_REQUEST), FULL_CHEER_SIZE_PER_MEMBER("CHE003", "회원당 응원 한도가 넘었습니다."), ALREADY_CHEERED("CHE004", "이미 응원한 가게입니다."), + CHEER_NOT_FOUND("CHE005", "응원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), // CheerTag CHEER_TAGS_DUPLICATED("CHE_TAG001", "응원 태그는 중복될 수 없습니다."), diff --git a/src/main/java/eatda/facade/CheerCreationResult.java b/src/main/java/eatda/facade/CheerCreationResult.java new file mode 100644 index 00000000..66833a3e --- /dev/null +++ b/src/main/java/eatda/facade/CheerCreationResult.java @@ -0,0 +1,7 @@ +package eatda.facade; + +import eatda.domain.cheer.Cheer; +import eatda.domain.store.Store; + +public record CheerCreationResult(Cheer cheer, Store store) { +} diff --git a/src/main/java/eatda/facade/CheerRegisterFacade.java b/src/main/java/eatda/facade/CheerRegisterFacade.java new file mode 100644 index 00000000..96ec913f --- /dev/null +++ b/src/main/java/eatda/facade/CheerRegisterFacade.java @@ -0,0 +1,76 @@ +package eatda.facade; + +import eatda.client.file.FileClient; +import eatda.controller.cheer.CheerRegisterRequest; +import eatda.controller.cheer.CheerResponse; +import eatda.domain.ImageDomain; +import eatda.domain.cheer.Cheer; +import eatda.domain.store.StoreSearchResult; +import eatda.service.cheer.CheerService; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CheerRegisterFacade { + + private final CheerService cheerService; + private final FileClient fileClient; + + public CheerResponse registerCheer(CheerRegisterRequest request, + StoreSearchResult result, + long memberId, + ImageDomain domain + ) { + CheerCreationResult creationResult = cheerService.createCheer(request, result, memberId); + Cheer cheer = creationResult.cheer(); + + if (request.images() == null || request.images().isEmpty()) { + return cheerService.getCheerResponse(cheer.getId()); + } + + List permanentKeys = Collections.emptyList(); + try { + List sortedImages = sortImages(request.images()); + permanentKeys = moveImages(domain, cheer.getId(), sortedImages); + cheerService.saveCheerImages(cheer.getId(), sortedImages, permanentKeys); + + } catch (Exception e) { + log.error("응원 등록 프로세스 실패. 롤백 수행. cheerId={}", cheer.getId(), e); + + cheerService.deleteCheer(cheer.getId()); + + if (!permanentKeys.isEmpty()) { + fileClient.deleteFiles(permanentKeys); + } + throw e; + } + + return cheerService.getCheerResponse(cheer.getId()); + } + + private List sortImages( + List images) { + return images.stream() + .sorted(Comparator.comparingLong(CheerRegisterRequest.UploadedImageDetail::orderIndex)) + .toList(); + } + + private List moveImages(ImageDomain domain, + long cheerId, + List sortedImages) { + if (sortedImages.isEmpty()) { + return List.of(); + } + + List tempKeys = sortedImages.stream() + .map(CheerRegisterRequest.UploadedImageDetail::imageKey) + .toList(); + return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys); + } +} diff --git a/src/main/java/eatda/service/cheer/CheerService.java b/src/main/java/eatda/service/cheer/CheerService.java index ca910873..8ac773ac 100644 --- a/src/main/java/eatda/service/cheer/CheerService.java +++ b/src/main/java/eatda/service/cheer/CheerService.java @@ -1,6 +1,5 @@ package eatda.service.cheer; -import eatda.client.file.FileClient; import eatda.controller.cheer.CheerImageResponse; import eatda.controller.cheer.CheerInStoreResponse; import eatda.controller.cheer.CheerPreviewResponse; @@ -9,7 +8,6 @@ import eatda.controller.cheer.CheerSearchParameters; import eatda.controller.cheer.CheersInStoreResponse; import eatda.controller.cheer.CheersResponse; -import eatda.domain.ImageDomain; import eatda.domain.cheer.Cheer; import eatda.domain.cheer.CheerImage; import eatda.domain.member.Member; @@ -17,6 +15,7 @@ import eatda.domain.store.StoreSearchResult; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; +import eatda.facade.CheerCreationResult; import eatda.repository.cheer.CheerRepository; import eatda.repository.member.MemberRepository; import eatda.repository.store.StoreRepository; @@ -37,20 +36,18 @@ public class CheerService { private static final int MAX_CHEER_SIZE = 10_000; - + private static final String SORTED_PROPERTIES = "createdAt"; private final MemberRepository memberRepository; private final StoreRepository storeRepository; private final CheerRepository cheerRepository; - private final FileClient fileClient; @Value("${cdn.base-url}") private String cdnBaseUrl; @Transactional - public CheerResponse registerCheer(CheerRegisterRequest request, - StoreSearchResult result, - long memberId, - ImageDomain domain + public CheerCreationResult createCheer(CheerRegisterRequest request, + StoreSearchResult result, + long memberId ) { Member member = memberRepository.getById(memberId); validateRegisterCheer(member, request.storeKakaoId()); @@ -59,15 +56,7 @@ public CheerResponse registerCheer(CheerRegisterRequest request, .orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결 Cheer cheer = new Cheer(member, store, request.description()); cheer.setCheerTags(request.tags()); - Cheer savedCheer = cheerRepository.save(cheer); - - // TODO 트랜잭션 범위 축소 - List sortedImages = sortImages(request.images()); - List permanentKeys = moveImages(domain, cheer.getId(), sortedImages); - - saveCheerImages(cheer, sortedImages, permanentKeys); - - return new CheerResponse(savedCheer, store, cdnBaseUrl); + return new CheerCreationResult(cheerRepository.save(cheer), store); } private void validateRegisterCheer(Member member, String storeKakaoId) { @@ -79,25 +68,14 @@ private void validateRegisterCheer(Member member, String storeKakaoId) { } } - private List sortImages( - List images) { - return images.stream() - .sorted(Comparator.comparingLong(CheerRegisterRequest.UploadedImageDetail::orderIndex)) - .toList(); - } + @Transactional + public void saveCheerImages(Long cheerId, + List sortedImages, + List permanentKeys) { - private List moveImages(ImageDomain domain, - long cheerId, - List sortedImages) { - List tempKeys = sortedImages.stream() - .map(CheerRegisterRequest.UploadedImageDetail::imageKey) - .toList(); - return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys); - } + Cheer cheer = cheerRepository.findById(cheerId) + .orElseThrow(() -> new BusinessException(BusinessErrorCode.CHEER_NOT_FOUND)); - private void saveCheerImages(Cheer cheer, - List sortedImages, - List permanentKeys) { IntStream.range(0, sortedImages.size()) .forEach(i -> { var detail = sortedImages.get(i); @@ -108,10 +86,8 @@ private void saveCheerImages(Cheer cheer, detail.contentType(), detail.fileSize() ); - cheer.addImage(cheerImage); // 여기서 양방향 동기화 + cheer.addImage(cheerImage); }); - - cheerRepository.save(cheer); } @Transactional(readOnly = true) @@ -121,7 +97,7 @@ public CheersResponse getCheers(CheerSearchParameters parameters) { parameters.getCheerTagNames(), parameters.getDistricts(), PageRequest.of(parameters.getPage(), parameters.getSize(), - Sort.by(Direction.DESC, "createdAt")) + Sort.by(Direction.DESC, SORTED_PROPERTIES)) ); List cheers = cheerPage.getContent(); @@ -130,14 +106,11 @@ public CheersResponse getCheers(CheerSearchParameters parameters) { private CheersResponse toCheersResponse(List cheers) { return new CheersResponse(cheers.stream() - .map(cheer -> { - Store store = cheer.getStore(); - return new CheerPreviewResponse(cheer, - cheer.getImages().stream() - .map(img -> new CheerImageResponse(img, cdnBaseUrl)) - .sorted(Comparator.comparingLong(CheerImageResponse::orderIndex)) - .toList()); - }) + .map(cheer -> new CheerPreviewResponse(cheer, + cheer.getImages().stream() + .map(img -> new CheerImageResponse(img, cdnBaseUrl)) + .sorted(Comparator.comparingLong(CheerImageResponse::orderIndex)) + .toList())) .toList()); } @@ -152,4 +125,17 @@ public CheersInStoreResponse getCheersByStoreId(Long storeId, int page, int size return new CheersInStoreResponse(cheersResponse); } + + @Transactional + public void deleteCheer(Long cheerId) { + cheerRepository.deleteById(cheerId); + } + + @Transactional(readOnly = true) + public CheerResponse getCheerResponse(Long cheerId) { + Cheer cheer = cheerRepository.findById(cheerId) + .orElseThrow(() -> new BusinessException(BusinessErrorCode.CHEER_NOT_FOUND)); + + return new CheerResponse(cheer, cdnBaseUrl); + } } diff --git a/src/test/java/eatda/document/BaseDocumentTest.java b/src/test/java/eatda/document/BaseDocumentTest.java index b3618055..543a07e4 100644 --- a/src/test/java/eatda/document/BaseDocumentTest.java +++ b/src/test/java/eatda/document/BaseDocumentTest.java @@ -7,6 +7,7 @@ import eatda.controller.web.jwt.JwtManager; import eatda.exception.BusinessErrorCode; import eatda.exception.EtcErrorCode; +import eatda.facade.CheerRegisterFacade; import eatda.service.auth.AuthService; import eatda.service.auth.OauthService; import eatda.service.cheer.CheerService; @@ -66,6 +67,9 @@ public abstract class BaseDocumentTest { @MockitoBean protected PresignedUrlService presignedUrlService; + @MockitoBean + protected CheerRegisterFacade cheerRegisterFacade; + @MockitoBean protected JwtManager jwtManager; diff --git a/src/test/java/eatda/document/cheer/CheerDocumentTest.java b/src/test/java/eatda/document/cheer/CheerDocumentTest.java index 7dca9f25..42452a17 100644 --- a/src/test/java/eatda/document/cheer/CheerDocumentTest.java +++ b/src/test/java/eatda/document/cheer/CheerDocumentTest.java @@ -99,7 +99,7 @@ class RegisterCheer { List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) ); - doReturn(response).when(cheerService).registerCheer(eq(request), any(), anyLong(), any()); + doReturn(response).when(cheerRegisterFacade).registerCheer(eq(request), any(), anyLong(), any()); var document = document("cheer/register", 201) .request(requestDocument) @@ -137,7 +137,7 @@ class RegisterCheer { ); doThrow(new BusinessException(errorCode)) - .when(cheerService).registerCheer(eq(request), any(), anyLong(), any()); + .when(cheerRegisterFacade).registerCheer(eq(request), any(), anyLong(), any()); var document = document("cheer/register", errorCode) .request(requestDocument) diff --git a/src/test/java/eatda/facade/BaseFacadeTest.java b/src/test/java/eatda/facade/BaseFacadeTest.java new file mode 100644 index 00000000..d18743ef --- /dev/null +++ b/src/test/java/eatda/facade/BaseFacadeTest.java @@ -0,0 +1,36 @@ +package eatda.facade; + +import eatda.DatabaseCleaner; +import eatda.client.file.FileClient; +import eatda.client.map.MapClient; +import eatda.client.oauth.OauthClient; +import eatda.fixture.MemberGenerator; +import eatda.fixture.StoreGenerator; +import eatda.repository.cheer.CheerRepository; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ExtendWith(DatabaseCleaner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +public abstract class BaseFacadeTest { + + @MockitoBean + protected OauthClient oauthClient; + + @MockitoBean + protected MapClient mapClient; + + @MockitoBean + protected FileClient fileClient; + + @Autowired + protected MemberGenerator memberGenerator; + + @Autowired + protected StoreGenerator storeGenerator; + + @Autowired + protected CheerRepository cheerRepository; +} diff --git a/src/test/java/eatda/facade/cheer/CheerRegisterFacadeTest.java b/src/test/java/eatda/facade/cheer/CheerRegisterFacadeTest.java new file mode 100644 index 00000000..4593d8d5 --- /dev/null +++ b/src/test/java/eatda/facade/cheer/CheerRegisterFacadeTest.java @@ -0,0 +1,297 @@ +package eatda.facade.cheer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +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.verify; + +import eatda.controller.cheer.CheerRegisterRequest; +import eatda.controller.cheer.CheerResponse; +import eatda.domain.ImageDomain; +import eatda.domain.cheer.CheerTagName; +import eatda.domain.store.District; +import eatda.domain.store.StoreCategory; +import eatda.domain.store.StoreSearchResult; +import eatda.facade.BaseFacadeTest; +import eatda.facade.CheerRegisterFacade; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.NonNull; +import software.amazon.awssdk.core.exception.SdkException; + +class CheerRegisterFacadeTest extends BaseFacadeTest { + + @Autowired + private CheerRegisterFacade cheerRegisterFacade; + + @Nested + class RegisterCheer { + + @Test + void 응원을_등록하면_이미지를_이동하고_최종_응답을_반환한다() { + var member = memberGenerator.generate("member-1"); + + CheerRegisterRequest request = getRegisterRequest(); + + StoreSearchResult storeResult = new StoreSearchResult( + "kakao-1", + StoreCategory.KOREAN, + "02-000-0000", + "농민백암순대", + "http://place.map.kakao.com/1", + "서울시 강남구", + "서울시 강남구", + District.GANGNAM, + 37.715132, + 127.269310 + ); + + given(fileClient.moveTempFilesToPermanent( + eq(ImageDomain.CHEER.getName()), + anyLong(), + anyList() + )).willReturn(List.of( + "cheer/1/key1.jpg", + "cheer/1/key2.jpg" + )); + + CheerResponse response = cheerRegisterFacade.registerCheer( + request, + storeResult, + member.getId(), + ImageDomain.CHEER + ); + + assertThat(response.cheerDescription()).isEqualTo("맛있어요"); + assertThat(response.tags()).containsExactly(CheerTagName.GOOD_FOR_DATING); + assertThat(response.images()).hasSize(2); + + verify(fileClient) + .moveTempFilesToPermanent( + eq(ImageDomain.CHEER.getName()), + anyLong(), + anyList() + ); + } + + @Test + void 이미지_이동_중_실패하면_응원을_삭제한다() { + var member = memberGenerator.generate("member-1"); + + CheerRegisterRequest.UploadedImageDetail image = + new CheerRegisterRequest.UploadedImageDetail( + "temp/key1.jpg", 1L, "image/jpeg", 1000L + ); + + CheerRegisterRequest request = new CheerRegisterRequest( + "kakao-1", + "농민백암순대", + "맛있어요", + List.of(image), + List.of(CheerTagName.GOOD_FOR_DATING) + ); + + StoreSearchResult storeResult = new StoreSearchResult( + "kakao-1", + StoreCategory.KOREAN, + "02-000-0000", + "농민백암순대", + "http://place.map.kakao.com/1", + "서울시 강남구", + "서울시 강남구", + District.GANGNAM, + 37.715132, + 127.269310 + ); + + given(fileClient.moveTempFilesToPermanent( + anyString(), + anyLong(), + anyList() + )).willThrow( + SdkException.builder().build() + ); + + assertThrows(SdkException.class, () -> + cheerRegisterFacade.registerCheer( + request, + storeResult, + member.getId(), + ImageDomain.CHEER + ) + ); + + assertThat(cheerRepository.count()).isZero(); + } + + @Test + void 이미지_이동이_부분적으로_성공한_후_실패하면_응원을_삭제한다() { + var member = memberGenerator.generate("member-1"); + + CheerRegisterRequest request = getCheerRegisterRequest(); + + StoreSearchResult storeResult = new StoreSearchResult( + "kakao-1", + StoreCategory.KOREAN, + "02-000-0000", + "농민백암순대", + "http://place.map.kakao.com/1", + "서울시 강남구", + "서울시 강남구", + District.GANGNAM, + 37.715132, + 127.269310 + ); + + given(fileClient.moveTempFilesToPermanent( + eq(ImageDomain.CHEER.getName()), + anyLong(), + anyList() + )).willAnswer(invocation -> { + throw SdkException.builder().build(); + }); + + assertThrows(SdkException.class, () -> + cheerRegisterFacade.registerCheer( + request, + storeResult, + member.getId(), + ImageDomain.CHEER + ) + ); + + assertThat(cheerRepository.count()) + .as("부분 성공 후 실패 시 Cheer는 삭제되어야 한다.") + .isZero(); + } + + @Test + void 이미지_이동은_성공했으나_DB_저장에_실패하면_파일과_응원_모두_삭제한다() { + var member = memberGenerator.generate("member-1"); + String tooLongContentType = "a".repeat(300); + + CheerRegisterRequest request = new CheerRegisterRequest( + "kakao-1", "농민백암순대", "맛있어요", + List.of(new CheerRegisterRequest.UploadedImageDetail( + "temp/key1.jpg", + 1L, + tooLongContentType, + 1000L + )), + List.of(CheerTagName.GOOD_FOR_DATING) + ); + + StoreSearchResult storeResult = new StoreSearchResult( + "kakao-1", StoreCategory.KOREAN, "02-000-0000", "농민백암순대", + "http://place.map.kakao.com/1", "서울시 강남구", "서울시 강남구", + District.GANGNAM, 37.715132, 127.269310 + ); + + List movedKeys = List.of("cheer/1/key1.jpg"); + given(fileClient.moveTempFilesToPermanent(anyString(), anyLong(), anyList())) + .willReturn(movedKeys); + + assertThrows(Exception.class, () -> + cheerRegisterFacade.registerCheer( + request, + storeResult, + member.getId(), + ImageDomain.CHEER + ) + ); + + assertThat(cheerRepository.count()) + .as("DB 에러(컬럼 길이 초과) 발생 시 응원글은 삭제되어야 한다.") + .isZero(); + + verify(fileClient).deleteFiles(movedKeys); + + } + + @Test + void 이미지가_없어도_응원은_정상_등록된다() { + var member = memberGenerator.generate("member-1"); + + CheerRegisterRequest request = new CheerRegisterRequest( + "kakao-1", + "농민백암순대", + "이미지 없음", + List.of(), + List.of(CheerTagName.GOOD_FOR_DATING) + ); + + StoreSearchResult storeResult = new StoreSearchResult( + "kakao-1", + StoreCategory.KOREAN, + "02-000-0000", + "농민백암순대", + "http://place.map.kakao.com/1", + "서울시 강남구", + "서울시 강남구", + District.GANGNAM, + 37.715132, + 127.269310 + ); + + CheerResponse response = cheerRegisterFacade.registerCheer( + request, + storeResult, + member.getId(), + ImageDomain.CHEER + ); + + assertThat(response.images()).isEmpty(); + assertThat(cheerRepository.count()).isEqualTo(1); + + verify(fileClient, Mockito.never()) + .moveTempFilesToPermanent(anyString(), anyLong(), anyList()); + } + + @NonNull + private CheerRegisterRequest getRegisterRequest() { + CheerRegisterRequest.UploadedImageDetail image1 = + new CheerRegisterRequest.UploadedImageDetail("temp/key1.jpg", 1L, "image/jpeg", 1000L); + CheerRegisterRequest.UploadedImageDetail image2 = + new CheerRegisterRequest.UploadedImageDetail("temp/key2.jpg", 2L, "image/jpeg", 2000L); + + return new CheerRegisterRequest( + "kakao-1", + "농민백암순대", + "맛있어요", + List.of(image1, image2), + List.of(CheerTagName.GOOD_FOR_DATING) + ); + } + + @NonNull + private CheerRegisterRequest getCheerRegisterRequest() { + CheerRegisterRequest.UploadedImageDetail image1 = + new CheerRegisterRequest.UploadedImageDetail( + "temp/key1.jpg", 1L, "image/jpeg", 1000L + ); + CheerRegisterRequest.UploadedImageDetail image2 = + new CheerRegisterRequest.UploadedImageDetail( + "temp/key2.jpg", 2L, "image/jpeg", 1000L + ); + CheerRegisterRequest.UploadedImageDetail image3 = + new CheerRegisterRequest.UploadedImageDetail( + "temp/key3.jpg", 3L, "image/jpeg", 1000L + ); + + return new CheerRegisterRequest( + "kakao-1", + "농민백암순대", + "부분 성공 테스트", + List.of(image1, image2, image3), + List.of(CheerTagName.GOOD_FOR_DATING) + ); + } + } +} diff --git a/src/test/java/eatda/service/cheer/CheerServiceTest.java b/src/test/java/eatda/service/cheer/CheerServiceTest.java index 3e3fac44..3f4f723f 100644 --- a/src/test/java/eatda/service/cheer/CheerServiceTest.java +++ b/src/test/java/eatda/service/cheer/CheerServiceTest.java @@ -4,14 +4,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -import eatda.controller.cheer.CheerImageResponse; import eatda.controller.cheer.CheerRegisterRequest; -import eatda.controller.cheer.CheerResponse; import eatda.controller.cheer.CheerSearchParameters; -import eatda.controller.cheer.CheersInStoreResponse; -import eatda.controller.cheer.CheersResponse; import eatda.controller.store.SearchDistrict; -import eatda.domain.ImageDomain; import eatda.domain.cheer.Cheer; import eatda.domain.cheer.CheerTagName; import eatda.domain.member.Member; @@ -21,9 +16,9 @@ import eatda.domain.store.StoreSearchResult; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; +import eatda.facade.CheerCreationResult; import eatda.service.BaseServiceTest; import java.time.LocalDateTime; -import java.util.Comparator; import java.util.List; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; @@ -49,15 +44,19 @@ class RegisterCheer { cheerGenerator.generateCommon(member, store2); cheerGenerator.generateCommon(member, store3); - CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "추가 응원", + CheerRegisterRequest request = new CheerRegisterRequest( + "123", + "농민백암순대 본점", + "추가 응원", List.of(), - List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM)); + List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + ); - StoreSearchResult result = new StoreSearchResult( - "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", - "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + StoreSearchResult result = storeSearchResult("123"); - assertThatThrownBy(() -> cheerService.registerCheer(request, result, member.getId(), ImageDomain.CHEER)) + assertThatThrownBy(() -> + cheerService.createCheer(request, result, member.getId()) + ) .isInstanceOf(BusinessException.class) .hasMessageContaining(BusinessErrorCode.FULL_CHEER_SIZE_PER_MEMBER.getMessage()); } @@ -68,15 +67,19 @@ class RegisterCheer { Store store = storeGenerator.generate("123", "서울시 강남구 역삼동 123-45"); cheerGenerator.generateCommon(member, store); - CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "추가 응원", + CheerRegisterRequest request = new CheerRegisterRequest( + "123", + "농민백암순대 본점", + "추가 응원", List.of(), - List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM)); + List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + ); - StoreSearchResult result = new StoreSearchResult( - "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", - "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + StoreSearchResult result = storeSearchResult("123"); - assertThatThrownBy(() -> cheerService.registerCheer(request, result, member.getId(), ImageDomain.CHEER)) + assertThatThrownBy(() -> + cheerService.createCheer(request, result, member.getId()) + ) .isInstanceOf(BusinessException.class) .hasMessageContaining(BusinessErrorCode.ALREADY_CHEERED.getMessage()); } @@ -85,23 +88,33 @@ class RegisterCheer { void 해당_응원의_가게가_저장되어_있지_않다면_가게와_응원을_저장한다() { Member member = memberGenerator.generate("123"); - CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "맛있어요!", + CheerRegisterRequest request = new CheerRegisterRequest( + "123", + "농민백암순대 본점", + "맛있어요!", List.of(), - List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM)); + List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + ); - StoreSearchResult result = new StoreSearchResult( - "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", - "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + StoreSearchResult result = storeSearchResult("123"); - CheerResponse response = cheerService.registerCheer(request, result, member.getId(), ImageDomain.CHEER); + CheerCreationResult creationResult = + cheerService.createCheer(request, result, member.getId()); + Cheer cheer = creationResult.cheer(); + Store store = creationResult.store(); Store foundStore = storeRepository.findByKakaoId("123").orElseThrow(); + assertAll( - () -> assertThat(response.storeId()).isEqualTo(foundStore.getId()), - () -> assertThat(response.cheerDescription()).isEqualTo("맛있어요!"), + () -> assertThat(store.getId()).isEqualTo(foundStore.getId()), + () -> assertThat(cheer.getDescription()).isEqualTo("맛있어요!"), () -> assertThat(cheerRepository.count()).isEqualTo(1), - () -> assertThat(response.tags()).containsExactlyInAnyOrder( - CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + () -> assertThat( + cheer.getCheerTags().getNames() + ).containsExactlyInAnyOrder( + CheerTagName.GOOD_FOR_DATING, + CheerTagName.CLEAN_RESTROOM + ) ); } @@ -110,21 +123,24 @@ class RegisterCheer { Member member = memberGenerator.generate("123"); Store store = storeGenerator.generate("123", "서울시 강남구 역삼동 123-45"); - CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "맛있어요!", + CheerRegisterRequest request = new CheerRegisterRequest( + "123", + "농민백암순대 본점", + "맛있어요!", List.of(), - List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM)); - StoreSearchResult result = new StoreSearchResult( - "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", - "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + ); + + StoreSearchResult result = storeSearchResult("123"); - CheerResponse response = cheerService.registerCheer(request, result, member.getId(), ImageDomain.CHEER); + CheerCreationResult creationResult = + cheerService.createCheer(request, result, member.getId()); + + Cheer cheer = creationResult.cheer(); assertAll( - () -> assertThat(response.storeId()).isEqualTo(store.getId()), - () -> assertThat(response.cheerDescription()).isEqualTo("맛있어요!"), - () -> assertThat(cheerRepository.count()).isEqualTo(1), - () -> assertThat(response.tags()).containsExactlyInAnyOrder( - CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + () -> assertThat(cheer.getStore().getId()).isEqualTo(store.getId()), + () -> assertThat(cheerRepository.count()).isEqualTo(1) ); } @@ -132,39 +148,42 @@ class RegisterCheer { void 해당_응원의_이미지가_비어있어도_응원을_저장할_수_있다() { Member member = memberGenerator.generate("123"); - CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "맛있어요!", + CheerRegisterRequest request = new CheerRegisterRequest( + "123", + "농민백암순대 본점", + "맛있어요!", List.of(), - List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM)); + List.of(CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) + ); - StoreSearchResult result = new StoreSearchResult( - "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", - "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + StoreSearchResult result = storeSearchResult("123"); - CheerResponse response = cheerService.registerCheer(request, result, member.getId(), ImageDomain.CHEER); + CheerCreationResult creationResult = + cheerService.createCheer(request, result, member.getId()); - assertAll( - () -> assertThat(response.cheerDescription()).isEqualTo("맛있어요!"), - () -> assertThat(response.tags()).containsExactlyInAnyOrder( - CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) - ); + assertThat(creationResult.cheer().getDescription()).isEqualTo("맛있어요!"); } @Test void 해당_응원의_응원_태그가_비어있어도_응원을_저장할_수_있다() { Member member = memberGenerator.generate("123"); - CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "맛있어요!", List.of(), List.of()); - StoreSearchResult result = new StoreSearchResult( - "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", - "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + CheerRegisterRequest request = new CheerRegisterRequest( + "123", + "농민백암순대 본점", + "맛있어요!", + List.of(), + List.of() + ); - CheerResponse response = cheerService.registerCheer(request, result, member.getId(), ImageDomain.CHEER); + StoreSearchResult result = storeSearchResult("123"); + + CheerCreationResult creationResult = + cheerService.createCheer(request, result, member.getId()); - Store foundStore = storeRepository.findByKakaoId("123").orElseThrow(); assertAll( - () -> assertThat(response.storeId()).isEqualTo(foundStore.getId()), - () -> assertThat(response.cheerDescription()).isEqualTo("맛있어요!"), - () -> assertThat(response.tags()).isEmpty() + () -> assertThat(creationResult.cheer().getDescription()).isEqualTo("맛있어요!"), + () -> assertThat(creationResult.cheer().getCheerTags().getNames()).isEmpty() ); } } @@ -181,9 +200,10 @@ class GetCheers { Cheer cheer1 = cheerGenerator.generateAdmin(member, store1, startAt); Cheer cheer2 = cheerGenerator.generateAdmin(member, store1, startAt.plusHours(1)); Cheer cheer3 = cheerGenerator.generateAdmin(member, store2, startAt.plusHours(2)); - CheerSearchParameters parameters = new CheerSearchParameters(0, 2, null, null, null); - CheersResponse response = cheerService.getCheers(parameters); + var response = cheerService.getCheers( + new CheerSearchParameters(0, 2, null, null, null) + ); assertAll( () -> assertThat(response.cheers()).hasSize(2), @@ -200,10 +220,11 @@ class GetCheers { LocalDateTime startAt = LocalDateTime.of(2025, 7, 26, 1, 0, 0); Cheer cheer1 = cheerGenerator.generateAdmin(member, store1, startAt); Cheer cheer2 = cheerGenerator.generateAdmin(member, store1, startAt.plusHours(1)); - Cheer cheer3 = cheerGenerator.generateAdmin(member, store2, startAt.plusHours(2)); - CheerSearchParameters parameters = new CheerSearchParameters(1, 2, null, null, null); + cheerGenerator.generateAdmin(member, store2, startAt.plusHours(2)); - CheersResponse response = cheerService.getCheers(parameters); + var response = cheerService.getCheers( + new CheerSearchParameters(1, 2, null, null, null) + ); assertAll( () -> assertThat(response.cheers()).hasSize(1), @@ -218,28 +239,29 @@ class GetCheers { Cheer cheer = cheerGenerator.generateCommon(member, store); cheerImageGenerator.generate(cheer, "key2", 2L); cheerImageGenerator.generate(cheer, "key1", 1L); - CheerSearchParameters parameters = new CheerSearchParameters(0, 1, null, null, null); - CheersResponse response = cheerService.getCheers(parameters); + var response = cheerService.getCheers( + new CheerSearchParameters(0, 1, null, null, null) + ); - assertThat(response.cheers()).hasSize(1); - assertThat(response.cheers().get(0).images()).hasSize(2) - .isSortedAccordingTo(Comparator.comparingLong(CheerImageResponse::orderIndex)); + assertThat(response.cheers().get(0).images()).hasSize(2); } @Test void 요청한_응원을_지역으로_필터링하여_최신순으로_반환한다() { Member member = memberGenerator.generate("123"); - Store store1 = storeGenerator.generate("123", "서울시 강남구 역삼동 123-45", District.GANGNAM); - Store store2 = storeGenerator.generate("456", "서울시 성북구 석관동 123-45", District.SEONGBUK); + Store store1 = storeGenerator.generate("123", "강남", District.GANGNAM); + Store store2 = storeGenerator.generate("456", "성북", District.SEONGBUK); LocalDateTime startAt = LocalDateTime.of(2025, 7, 26, 1, 0, 0); Cheer cheer1 = cheerGenerator.generateAdmin(member, store1, startAt); Cheer cheer2 = cheerGenerator.generateAdmin(member, store1, startAt.plusHours(1)); - Cheer cheer3 = cheerGenerator.generateAdmin(member, store2, startAt.plusHours(2)); - CheerSearchParameters parameters = new CheerSearchParameters( - 0, 2, null, null, List.of(SearchDistrict.GANGNAM)); + cheerGenerator.generateAdmin(member, store2, startAt.plusHours(2)); - CheersResponse response = cheerService.getCheers(parameters); + var response = cheerService.getCheers( + new CheerSearchParameters( + 0, 2, null, null, List.of(SearchDistrict.GANGNAM) + ) + ); assertAll( () -> assertThat(response.cheers()).hasSize(2), @@ -262,7 +284,7 @@ class GetCheersByStoreId { cheerGenerator.generateCommon(member, store, startAt.plusHours(1)); cheerGenerator.generateCommon(member, store, startAt.plusHours(2)); - CheersInStoreResponse response = cheerService.getCheersByStoreId(store.getId(), 1, 2); + var response = cheerService.getCheersByStoreId(store.getId(), 1, 2); assertAll( () -> assertThat(response.cheers()).hasSize(1), @@ -270,4 +292,19 @@ class GetCheersByStoreId { ); } } + + private StoreSearchResult storeSearchResult(String kakaoId) { + return new StoreSearchResult( + kakaoId, + StoreCategory.KOREAN, + "02-755-5232", + "농민백암순대 본점", + "http://place.map.kakao.com/123", + "서울시 강남구", + "서울시 강남구", + District.GANGNAM, + 37.5665, + 126.9780 + ); + } }