From 4adb9116998476ad63ca80e63ea2d0d1798b1ead Mon Sep 17 00:00:00 2001 From: whoamixzerone Date: Wed, 22 Oct 2025 21:00:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refator:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EA=B2=BD=EA=B3=84=20=EB=B2=94=EC=9C=84=20=EC=9E=AC?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20&=20s3=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/co/pinup/config/AsyncConfig.java | 28 + .../locations/service/LocationService.java | 1 + .../service/StoreCategoryService.java | 4 + .../service/StoreImageService.java | 11 +- .../stores/controller/StoreApiController.java | 10 +- .../stores/model/dto/StoreCreateResponse.java | 11 + .../co/pinup/stores/service/StoreService.java | 79 ++- .../StoreImageIntegrationTest.java | 42 +- .../service/StoreImageServiceTest.java | 10 +- .../pinup/stores/StoreApiIntegrationTest.java | 174 +++--- .../stores/StoreTransactionIsolationTest.java | 512 ++++++++++++++++++ .../controller/StoreApiControllerTest.java | 57 +- .../stores/service/StoreServiceTest.java | 117 +++- 13 files changed, 801 insertions(+), 255 deletions(-) create mode 100644 src/main/java/kr/co/pinup/config/AsyncConfig.java create mode 100644 src/main/java/kr/co/pinup/stores/model/dto/StoreCreateResponse.java create mode 100644 src/test/java/kr/co/pinup/stores/StoreTransactionIsolationTest.java diff --git a/src/main/java/kr/co/pinup/config/AsyncConfig.java b/src/main/java/kr/co/pinup/config/AsyncConfig.java new file mode 100644 index 00000000..251bb7cd --- /dev/null +++ b/src/main/java/kr/co/pinup/config/AsyncConfig.java @@ -0,0 +1,28 @@ +package kr.co.pinup.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +public class AsyncConfig { + + @Bean + public ThreadPoolTaskExecutor taskExecutor() { + final ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); + taskExecutor.setCorePoolSize(2); + taskExecutor.setMaxPoolSize(4); + taskExecutor.setQueueCapacity(10); + + taskExecutor.setThreadNamePrefix("s3-upload-"); + taskExecutor.setWaitForTasksToCompleteOnShutdown(true); + taskExecutor.setAwaitTerminationSeconds(30); + + taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + taskExecutor.initialize(); + + return taskExecutor; + } +} diff --git a/src/main/java/kr/co/pinup/locations/service/LocationService.java b/src/main/java/kr/co/pinup/locations/service/LocationService.java index 50ce51f3..ef526031 100644 --- a/src/main/java/kr/co/pinup/locations/service/LocationService.java +++ b/src/main/java/kr/co/pinup/locations/service/LocationService.java @@ -32,6 +32,7 @@ public LocationResponse createLocation(CreateLocationRequest request) { return LocationResponse.from(savedLocation); } + @Transactional(readOnly = true) public Location getLocation(Long id) { return locationRepository.findById(id) .orElseThrow(LocationNotFoundException::new); diff --git a/src/main/java/kr/co/pinup/storecategories/service/StoreCategoryService.java b/src/main/java/kr/co/pinup/storecategories/service/StoreCategoryService.java index cc82528b..bcdd130d 100644 --- a/src/main/java/kr/co/pinup/storecategories/service/StoreCategoryService.java +++ b/src/main/java/kr/co/pinup/storecategories/service/StoreCategoryService.java @@ -6,6 +6,7 @@ import kr.co.pinup.storecategories.repository.StoreCategoryRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -15,17 +16,20 @@ public class StoreCategoryService { private final StoreCategoryRepository storeCategoryRepository; + @Transactional(readOnly = true) public List getCategories() { return storeCategoryRepository.findAll().stream() .map(StoreCategoryResponse::from) .toList(); } + @Transactional(readOnly = true) public StoreCategory findCategoryById(Long categoryId) { return storeCategoryRepository.findById(categoryId) .orElseThrow(StoreCategoryNotFoundException::new); } + @Transactional(readOnly = true) public StoreCategoryResponse getCategory(Long categoryId) { return storeCategoryRepository.findById(categoryId) .map(StoreCategoryResponse::from) diff --git a/src/main/java/kr/co/pinup/storeimages/service/StoreImageService.java b/src/main/java/kr/co/pinup/storeimages/service/StoreImageService.java index 5b085c69..daddcbd2 100644 --- a/src/main/java/kr/co/pinup/storeimages/service/StoreImageService.java +++ b/src/main/java/kr/co/pinup/storeimages/service/StoreImageService.java @@ -43,21 +43,14 @@ public StoreImageResponse getStoreThumbnailImage(Long storeId) { return StoreImageResponse.from(storeImage); } - @Transactional - public List createUploadImages(final Store store, final List images, Long thumbnailIndex) { - final List uploadUrls = s3UploadFiles(images); - - final List storeImages = IntStream.range(0, uploadUrls.size()) + public List createUploadImages(final Store store, final List uploadUrls, Long thumbnailIndex) { + return IntStream.range(0, uploadUrls.size()) .mapToObj(i -> StoreImage.builder() .imageUrl(uploadUrls.get(i)) .isThumbnail(i == thumbnailIndex) .store(store) .build()) .toList(); - - storeImageRepository.saveAll(storeImages); - - return storeImages; } @Transactional diff --git a/src/main/java/kr/co/pinup/stores/controller/StoreApiController.java b/src/main/java/kr/co/pinup/stores/controller/StoreApiController.java index ca4225d4..cde8fa38 100644 --- a/src/main/java/kr/co/pinup/stores/controller/StoreApiController.java +++ b/src/main/java/kr/co/pinup/stores/controller/StoreApiController.java @@ -3,10 +3,7 @@ import jakarta.validation.Valid; import kr.co.pinup.annotation.ValidImageFile; -import kr.co.pinup.stores.model.dto.StoreRequest; -import kr.co.pinup.stores.model.dto.StoreResponse; -import kr.co.pinup.stores.model.dto.StoreThumbnailResponse; -import kr.co.pinup.stores.model.dto.StoreUpdateRequest; +import kr.co.pinup.stores.model.dto.*; import kr.co.pinup.stores.service.StoreService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,6 +14,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -47,13 +45,13 @@ public ResponseEntity getStoreById(@PathVariable Long id) { @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") @PostMapping - public ResponseEntity createStore( + public ResponseEntity createStore( @Valid @RequestPart("storeRequest") StoreRequest request, @ValidImageFile @RequestParam(value = "images") List images) { log.debug("createStore StoreRequest={}, images size={}", request, images.size()); return ResponseEntity.status(HttpStatus.CREATED) - .body(storeService.createStore(request, images)); + .body(storeService.createStore(request, images, LocalDate.now())); } @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") diff --git a/src/main/java/kr/co/pinup/stores/model/dto/StoreCreateResponse.java b/src/main/java/kr/co/pinup/stores/model/dto/StoreCreateResponse.java new file mode 100644 index 00000000..f4641077 --- /dev/null +++ b/src/main/java/kr/co/pinup/stores/model/dto/StoreCreateResponse.java @@ -0,0 +1,11 @@ +package kr.co.pinup.stores.model.dto; + +import kr.co.pinup.stores.Store; + +import java.time.LocalDateTime; + +public record StoreCreateResponse(Long id, LocalDateTime createdAt) { + public static StoreCreateResponse from(Store store) { + return new StoreCreateResponse(store.getId(), store.getCreatedAt()); + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/pinup/stores/service/StoreService.java b/src/main/java/kr/co/pinup/stores/service/StoreService.java index 1d998b68..f335d8c5 100644 --- a/src/main/java/kr/co/pinup/stores/service/StoreService.java +++ b/src/main/java/kr/co/pinup/stores/service/StoreService.java @@ -1,5 +1,6 @@ package kr.co.pinup.stores.service; +import kr.co.pinup.custom.s3.S3Service; import kr.co.pinup.locations.Location; import kr.co.pinup.locations.service.LocationService; import kr.co.pinup.storecategories.StoreCategory; @@ -10,14 +11,12 @@ import kr.co.pinup.storeoperatinghour.service.StoreOperatingHourService; import kr.co.pinup.stores.Store; import kr.co.pinup.stores.exception.StoreNotFoundException; -import kr.co.pinup.stores.model.dto.StoreRequest; -import kr.co.pinup.stores.model.dto.StoreResponse; -import kr.co.pinup.stores.model.dto.StoreThumbnailResponse; -import kr.co.pinup.stores.model.dto.StoreUpdateRequest; +import kr.co.pinup.stores.model.dto.*; import kr.co.pinup.stores.model.enums.StoreStatus; import kr.co.pinup.stores.repository.StoreRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -25,19 +24,25 @@ import java.time.LocalDate; import java.util.Comparator; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; @Slf4j @Service -@Transactional(readOnly = true) @RequiredArgsConstructor public class StoreService { + private static final String S3_PATH_PREFIX = "store"; + private final StoreRepository storeRepository; private final StoreCategoryService categoryService; private final LocationService locationService; private final StoreImageService storeImageService; private final StoreOperatingHourService operatingHourService; + private final S3Service s3Service; + private final ThreadPoolTaskExecutor taskExecutor; + @Transactional(readOnly = true) public List getStores() { return storeRepository.findAllByIsDeletedFalse().stream() .map(StoreResponse::from) @@ -56,6 +61,7 @@ public List findAll(final StoreStatus selectedStatus, fi return getStoresSortedByStatusPriority(); } + @Transactional(readOnly = true) public List getStoresThumbnailWithLimit(final int limit) { return storeRepository.findAllByIsDeletedFalse().stream() .sorted(Comparator.comparingInt(store -> getStoreStatusOrder(store.getStoreStatus()))) @@ -64,6 +70,7 @@ public List getStoresThumbnailWithLimit(final int limit) .toList(); } + @Transactional(readOnly = true) public List getStoresSortedByStatusPriority() { return storeRepository.findAllByIsDeletedFalse().stream() .sorted(Comparator.comparingInt(store -> getStoreStatusOrder(store.getStoreStatus()))) @@ -71,12 +78,14 @@ public List getStoresSortedByStatusPriority() { .toList(); } + @Transactional(readOnly = true) public List getStoresByStatus(StoreStatus status) { return storeRepository.findAllByStoreStatusAndIsDeletedFalse(status).stream() .map(StoreThumbnailResponse::from) .toList(); } + @Transactional(readOnly = true) public StoreResponse getStoreById(Long id) { Store store = storeRepository.findById(id) .orElseThrow(StoreNotFoundException::new); @@ -84,6 +93,7 @@ public StoreResponse getStoreById(Long id) { return StoreResponse.from(store); } + @Transactional(readOnly = true) public List getStoresByStatusAndLocationBySigungu( final StoreStatus selectedStatus, final String sigungu) { return storeRepository.findAllByLocation_SigunguAndStoreStatusAndIsDeletedFalse(sigungu, selectedStatus) @@ -92,6 +102,7 @@ public List getStoresByStatusAndLocationBySigungu( .toList(); } + @Transactional(readOnly = true) public List getStoresByLocationBySigungu(final String sigungu) { return storeRepository.findAllByLocation_SigunguAndIsDeletedFalse(sigungu).stream() .sorted(Comparator.comparingInt(store -> getStoreStatusOrder(store.getStoreStatus()))) @@ -99,23 +110,15 @@ public List getStoresByLocationBySigungu(final String si .toList(); } - @Transactional - public StoreResponse createStore(StoreRequest request, List images) { + public StoreCreateResponse createStore(StoreRequest request, List images, LocalDate today) { + final StoreStatus storeStatus = calculateStatus(today, request.startDate(), request.endDate()); + log.debug("storeStatus: {}", storeStatus); + + final List> s3UploadFutures = asyncS3UploadFiles(images); + final StoreCategory category = categoryService.findCategoryById(request.categoryId()); final Location location = locationService.getLocation(request.locationId()); - final LocalDate now = LocalDate.now(); - final LocalDate startDate = request.startDate(); - StoreStatus storeStatus; - if (request.startDate().isAfter(now)) { - storeStatus = StoreStatus.PENDING; - } else if (request.endDate().isBefore(now)) { - storeStatus = StoreStatus.DISMISSED; - } else { - storeStatus = StoreStatus.RESOLVED; - } - log.debug("createStore storeStatus={}", storeStatus); - Store store = Store.builder() .name(request.name()) .description(request.description()) @@ -127,19 +130,33 @@ public StoreResponse createStore(StoreRequest request, List image .category(category) .location(location) .build(); - storeRepository.save(store); + final List uploadUrls = s3UploadFutures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + createStoreTransactional(request, uploadUrls, store); + + return StoreCreateResponse.from(store); + } + + @Transactional + public void createStoreTransactional( + final StoreRequest request, + final List uploadUrls, + final Store store + ) { final List operatingHours = operatingHourService.createOperatingHours(store, request.operatingHours()); log.debug("createStore operatingHours={}", operatingHours); store.addOperatingHours(operatingHours); final List storeImages = - storeImageService.createUploadImages(store, images, request.thumbnailIndex()); + storeImageService.createUploadImages(store, uploadUrls, request.thumbnailIndex()); log.debug("createStore storeImages={}", storeImages); store.addImages(storeImages); - return StoreResponse.from(store); + storeRepository.save(store); } @Transactional @@ -193,4 +210,22 @@ private int getStoreStatusOrder(final StoreStatus status) { }; } + private StoreStatus calculateStatus(final LocalDate now, final LocalDate startDate, final LocalDate endDate) { + if (startDate.isAfter(now)) { + return StoreStatus.PENDING; + } else if (endDate.isBefore(now)) { + return StoreStatus.DISMISSED; + } + + return StoreStatus.RESOLVED; + } + + private List> asyncS3UploadFiles(List images) { + return images.stream() + .map(image -> CompletableFuture.supplyAsync( + () -> s3Service.uploadFile(image, S3_PATH_PREFIX), + taskExecutor + )) + .collect(Collectors.toList()); + } } diff --git a/src/test/java/kr/co/pinup/storeimages/StoreImageIntegrationTest.java b/src/test/java/kr/co/pinup/storeimages/StoreImageIntegrationTest.java index 1849a0fa..e089c5ca 100644 --- a/src/test/java/kr/co/pinup/storeimages/StoreImageIntegrationTest.java +++ b/src/test/java/kr/co/pinup/storeimages/StoreImageIntegrationTest.java @@ -15,24 +15,15 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.web.multipart.MultipartFile; import org.testcontainers.containers.localstack.LocalStackContainer; import org.testcontainers.containers.localstack.LocalStackContainer.Service; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.CreateBucketRequest; -import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; -import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import java.time.LocalDate; import java.util.List; @@ -81,44 +72,23 @@ static void overrideProperties(DynamicPropertyRegistry registry) { registry.add("cloud.aws.s3.endpoint", () -> localstack.getEndpointOverride(Service.S3).toString()); } - @DisplayName("S3에 업로드된 이미지들의 url을 받아 스토어 이미지들을 저장한다.") + @DisplayName("S3에 업로드된 이미지들의 url을 받아 스토어 이미지 영속성 리스트를 반환한다.") @Test void createUploadImages() { // Arrange - final S3Client s3Client = S3Client.builder() - .endpointOverride(localstack.getEndpointOverride(Service.S3)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) - )) - .region(Region.of(localstack.getRegion())) - .build(); - s3Client.createBucket(CreateBucketRequest.builder() - .bucket("pinup-test") - .build()); - final Location location = locationRepository.save(getLocation()); final StoreCategory storeCategory = storeCategoryRepository.save(getStoreCategory()); final Store store = storeRepository.save(getStore(storeCategory, location)); - - final List images = List.of( - new MockMultipartFile("file", "test1.jpg", "image/jpeg", "image1".getBytes()), - new MockMultipartFile("file", "test2.jpg", "image/jpeg", "image2".getBytes()) - ); - final long thumbnailIndex = 1L; + final List uploadUrl = List.of("http://127.0.0.1:4566/pinup/store/image.png"); + final long thumbnailIndex = 0L; // Act - final List result = storeImageService.createUploadImages(store, images, thumbnailIndex); + final List result = storeImageService.createUploadImages(store, uploadUrl, thumbnailIndex); // Assert - assertThat(result).hasSize(2); + assertThat(result).hasSize(1); assertThat(result.get((int) thumbnailIndex).isThumbnail()).isTrue(); - assertThat(result.get(0).getImageUrl()).contains("test1.jpg"); - - final ListObjectsV2Response list = s3Client.listObjectsV2(ListObjectsV2Request.builder() - .bucket("pinup-test") - .build()); - - assertThat(list.contents()).hasSize(2); + assertThat(result.get(0).getImageUrl()).contains("http://127.0.0.1:4566/pinup/store/image.png"); } private StoreCategory getStoreCategory() { diff --git a/src/test/java/kr/co/pinup/storeimages/service/StoreImageServiceTest.java b/src/test/java/kr/co/pinup/storeimages/service/StoreImageServiceTest.java index 163e15ee..de682a48 100644 --- a/src/test/java/kr/co/pinup/storeimages/service/StoreImageServiceTest.java +++ b/src/test/java/kr/co/pinup/storeimages/service/StoreImageServiceTest.java @@ -115,23 +115,21 @@ void getStoreThumbnailImageWithNonExistStoreId() { .hasMessage("해당 스토어 ID에 썸네일 이미지가 존재하지 않습니다."); } - @DisplayName("S3에 이미지를 업로드하고 스토어 이미지들을 저장한다.") + @DisplayName("스토어 이미지 영속성 리스트을 반환한다.") @Test void createUploadImages() { // Arrange final Store store = getStore(getStoreCategory(), getLocation()); final List images = List.of(mock(MultipartFile.class)); - final String uploadUrl = "http://127.0.0.1:4566/pinup/store/image.png"; - - given(s3Service.uploadFile(any(MultipartFile.class), anyString())).willReturn(uploadUrl); + final List uploadUrl = List.of("http://127.0.0.1:4566/pinup/store/image.png"); // Act - final List result = storeImageService.createUploadImages(store, images, 0L); + final List result = storeImageService.createUploadImages(store, uploadUrl, 0L); // Assert assertThat(result).isNotNull(); assertThat(result).hasSize(1); - assertThat(result.get(0).getImageUrl()).isEqualTo(uploadUrl); + assertThat(result.get(0).getImageUrl()).isEqualTo(uploadUrl.get(0)); } @DisplayName("썸네일을 변경한다") diff --git a/src/test/java/kr/co/pinup/stores/StoreApiIntegrationTest.java b/src/test/java/kr/co/pinup/stores/StoreApiIntegrationTest.java index da32e813..eb40dbc8 100644 --- a/src/test/java/kr/co/pinup/stores/StoreApiIntegrationTest.java +++ b/src/test/java/kr/co/pinup/stores/StoreApiIntegrationTest.java @@ -3,22 +3,18 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pinup.exception.ErrorResponse; import kr.co.pinup.locations.Location; -import kr.co.pinup.locations.model.dto.LocationResponse; import kr.co.pinup.locations.reposiotry.LocationRepository; import kr.co.pinup.members.custom.WithMockMember; import kr.co.pinup.storecategories.StoreCategory; -import kr.co.pinup.storecategories.model.dto.StoreCategoryResponse; import kr.co.pinup.storecategories.repository.StoreCategoryRepository; import kr.co.pinup.storeimages.StoreImage; -import kr.co.pinup.storeimages.model.dto.StoreImageResponse; import kr.co.pinup.storeimages.repository.StoreImageRepository; import kr.co.pinup.storeoperatinghour.model.dto.StoreOperatingHourRequest; -import kr.co.pinup.storeoperatinghour.model.dto.StoreOperatingHourResponse; import kr.co.pinup.storeoperatinghour.repository.StoreOperatingHourRepository; import kr.co.pinup.stores.model.dto.StoreRequest; -import kr.co.pinup.stores.model.dto.StoreResponse; import kr.co.pinup.stores.model.enums.StoreStatus; import kr.co.pinup.stores.repository.StoreRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -26,22 +22,18 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; import java.util.Objects; -import java.util.Optional; import static java.nio.charset.StandardCharsets.UTF_8; import static kr.co.pinup.members.model.enums.MemberRole.ROLE_ADMIN; import static kr.co.pinup.stores.model.enums.StoreStatus.*; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.MediaType.IMAGE_JPEG_VALUE; import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; @@ -63,33 +55,42 @@ public class StoreApiIntegrationTest { @Autowired private ObjectMapper objectMapper; - @MockitoBean + @Autowired private StoreRepository storeRepository; - @MockitoBean + @Autowired private StoreCategoryRepository categoryRepository; - @MockitoBean - private LocationRepository locationRepository; + @Autowired + private StoreImageRepository imageRepository; - @MockitoBean + @Autowired private StoreOperatingHourRepository operatingHourRepository; - @MockitoBean - private StoreImageRepository imageRepository; + @Autowired + private LocationRepository locationRepository; + + @BeforeEach + void setUp() { + imageRepository.deleteAllInBatch(); + operatingHourRepository.deleteAllInBatch(); + storeRepository.deleteAllInBatch(); + locationRepository.deleteAllInBatch(); + categoryRepository.deleteAllInBatch(); + } @WithMockMember(role = ROLE_ADMIN) @DisplayName("POST /api/stores 요청 시 201 Created와 응답 정보를 반환한다.") @Test void createStore() throws Exception { // Arrange - final StoreCategory category = createCategory(); - final Location location = createLocation(); + final StoreCategory category = categoryRepository.save(createCategory()); + final Location location = locationRepository.save(createLocation()); final MockMultipartFile images = new MockMultipartFile( "images", "image.jpeg", IMAGE_JPEG_VALUE, "data".getBytes()); - final StoreRequest storeRequest = getStoreRequest(); + final StoreRequest storeRequest = getStoreRequest(category.getId(), location.getId()); final MockMultipartFile request = new MockMultipartFile( "storeRequest", "storeRequest.json", @@ -97,9 +98,6 @@ void createStore() throws Exception { objectMapper.writeValueAsString(storeRequest) .getBytes(UTF_8)); - given(categoryRepository.findById(1L)).willReturn(Optional.ofNullable(category)); - given(locationRepository.findById(1L)).willReturn(Optional.ofNullable(location)); - // Act & Assert mockMvc.perform(multipart("/api/stores") .file(images) @@ -107,18 +105,8 @@ void createStore() throws Exception { .contentType(MULTIPART_FORM_DATA)) .andDo(print()) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.name").exists()) - .andExpect(jsonPath("$.description").exists()) - .andExpect(jsonPath("$.status").exists()) - .andExpect(jsonPath("$.startDate").exists()) - .andExpect(jsonPath("$.endDate").exists()) - .andExpect(jsonPath("$.websiteUrl").exists()) - .andExpect(jsonPath("$.snsUrl").exists()) - .andExpect(jsonPath("$.viewCount").exists()) - .andExpect(jsonPath("$.category").exists()) - .andExpect(jsonPath("$.location").exists()) - .andExpect(jsonPath("$.operatingHours").exists()) - .andExpect(jsonPath("$.storeImages").exists()); + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.createdAt").exists()); } @WithMockMember @@ -157,10 +145,12 @@ void shouldReturnForbiddenWhenRoleUserOnCreateStore() throws Exception { @Test void getStores() throws Exception { // Arrange - final Store store1 = getStore("store1", "description1", PENDING); - final Store store2 = getStore("store2", "description2", PENDING); + final StoreCategory category = categoryRepository.save(createCategory()); + final Location location = locationRepository.save(createLocation()); - given(storeRepository.findAllByIsDeletedFalse()).willReturn(List.of(store1, store2)); + final Store store1 = getStore("store1", "description1", PENDING, category, location); + final Store store2 = getStore("store2", "description2", PENDING, category, location); + storeRepository.saveAll(List.of(store1, store2)); // Act & Assert mockMvc.perform(get("/api/stores")) @@ -184,11 +174,13 @@ void getStores() throws Exception { @Test void getStoreThumbnails() throws Exception { // Arrange - final Store store1 = getStoreWithThumbnail("store1", "description1", PENDING); - final Store store2 = getStoreWithThumbnail("store2", "description2", RESOLVED); - final Store store3 = getStoreWithThumbnail("store2", "description2", DISMISSED); + final StoreCategory category = categoryRepository.save(createCategory()); + final Location location = locationRepository.save(createLocation()); - given(storeRepository.findAllByIsDeletedFalse()).willReturn(List.of(store1, store2, store3)); + final Store store1 = getStoreWithThumbnail("store1", "description1", PENDING, category, location); + final Store store2 = getStoreWithThumbnail("store2", "description2", RESOLVED, category, location); + final Store store3 = getStoreWithThumbnail("store2", "description2", DISMISSED, category, location); + storeRepository.saveAll(List.of(store1, store2, store3)); // Act & Assert mockMvc.perform(get("/api/stores/summary") @@ -209,14 +201,14 @@ void getStoreThumbnails() throws Exception { @Test void getStoreById() throws Exception { // Arrange - final long id = 1L; - - final Store store1 = getStore("store", "description", RESOLVED); + final StoreCategory category = categoryRepository.save(createCategory()); + final Location location = locationRepository.save(createLocation()); - given(storeRepository.findById(id)).willReturn(Optional.of(store1)); + final Store store = getStore("store", "description", RESOLVED, category, location); + storeRepository.save(store); // Act & Assert - mockMvc.perform(get("/api/stores/{id}", id)) + mockMvc.perform(get("/api/stores/{id}", store.getId())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").exists()) @@ -250,7 +242,13 @@ private Location createLocation() { .build(); } - private Store getStore(final String store, final String description, final StoreStatus storeStatus) { + private Store getStore( + final String store, + final String description, + final StoreStatus storeStatus, + final StoreCategory category, + final Location location + ) { return Store.builder() .name(store) .description(description) @@ -258,13 +256,19 @@ private Store getStore(final String store, final String description, final Store .startDate(LocalDate.now()) .endDate(LocalDate.now().plusDays(10)) .snsUrl("https://instgram.com/test") - .category(createCategory()) - .location(createLocation()) + .category(category) + .location(location) .build(); } - private Store getStoreWithThumbnail(final String store, final String description, final StoreStatus storeStatus) { - final Store storeEntity = getStore(store, description, storeStatus); + private Store getStoreWithThumbnail( + final String store, + final String description, + final StoreStatus storeStatus, + final StoreCategory category, + final Location location + ) { + final Store storeEntity = getStore(store, description, storeStatus, category, location); final StoreImage storeImage = StoreImage.builder() .imageUrl("http://127.0.0.1:4566/pinup/store/image1.png") @@ -295,58 +299,22 @@ private StoreRequest getStoreRequest() { .build(); } - private StoreResponse getStoreResponse() { - return new StoreResponse( - 1L, - "store", - "description", - RESOLVED, - LocalDate.now(), - LocalDate.now().plusDays(10), - "", - "https://instgram.com/test", - 0, - getStoreCategoryResponse(), - getLocationResponse(), - List.of(getStoreOperatingHourResponse()), - List.of(getStoreImageResponse()), - LocalDateTime.now(), - null - ); - } - - private StoreCategoryResponse getStoreCategoryResponse() { - return new StoreCategoryResponse(1L, "뷰티", LocalDateTime.now(), null); - } - - private LocationResponse getLocationResponse() { - return LocationResponse.builder() - .id(1L) - .name("서울 송파구 올림픽로 300") - .zonecode("05551") - .sido("서울") - .sigungu("송파구") - .address("서울 송파구 올림픽로 300") - .longitude(127.104302) - .latitude(37.513713) - .addressDetail("") - .build(); - } - - private StoreOperatingHourResponse getStoreOperatingHourResponse() { - return new StoreOperatingHourResponse( - "월~금", - LocalTime.now(), - LocalTime.now().plusHours(10) - ); - } - - private StoreImageResponse getStoreImageResponse() { - return StoreImageResponse.builder() - .id(1L) - .storeId(1L) - .imageUrl("http://127.0.0.1:4566/pinup/store/image1.png") - .isThumbnail(true) + private StoreRequest getStoreRequest(final long categoryId, final long locationId) { + return StoreRequest.builder() + .name("store") + .description("description") + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(10)) + .websiteUrl("") + .snsUrl("") + .thumbnailIndex(0L) + .categoryId(categoryId) + .locationId(locationId) + .operatingHours(List.of(StoreOperatingHourRequest.builder() + .days("월~금") + .startTime(LocalTime.now()) + .endTime(LocalTime.now().plusHours(10)) + .build())) .build(); } diff --git a/src/test/java/kr/co/pinup/stores/StoreTransactionIsolationTest.java b/src/test/java/kr/co/pinup/stores/StoreTransactionIsolationTest.java new file mode 100644 index 00000000..b26e250d --- /dev/null +++ b/src/test/java/kr/co/pinup/stores/StoreTransactionIsolationTest.java @@ -0,0 +1,512 @@ +package kr.co.pinup.stores; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.co.pinup.locations.Location; +import kr.co.pinup.locations.reposiotry.LocationRepository; +import kr.co.pinup.storecategories.StoreCategory; +import kr.co.pinup.storecategories.repository.StoreCategoryRepository; +import kr.co.pinup.stores.model.enums.StoreStatus; +import kr.co.pinup.stores.repository.StoreRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +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.orm.ObjectOptimisticLockingFailureException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static kr.co.pinup.stores.model.enums.StoreStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Disabled +@SpringBootTest +@ActiveProfiles("test") +public class StoreTransactionIsolationTest { + + @Autowired + StoreRepository storeRepository; + + @Autowired + LocationRepository locationRepository; + + @Autowired + StoreCategoryRepository storeCategoryRepository; + + @Autowired + PlatformTransactionManager transactionManager; + + @PersistenceContext + EntityManager entityManager; + + @AfterEach + void tearDown() { + storeRepository.deleteAllInBatch(); + storeCategoryRepository.deleteAllInBatch(); + locationRepository.deleteAllInBatch(); + } + + @DisplayName("Read Committed level에서 Non-Repeatable read 현상 발생") + @Test + void readCommittedNonRepeatableRead() throws Exception { + // Arrange + final Long storeId = getStoreId(); + + CountDownLatch readLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + + AtomicReference firstRead = new AtomicReference<>(); + AtomicReference secondReadd = new AtomicReference<>(); + + // Act + CompletableFuture reader = CompletableFuture.runAsync(() -> { + TransactionTemplate transaction1 = new TransactionTemplate(transactionManager); + + transaction1.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + firstRead.set(store.getStoreStatus()); + readLatch.countDown(); + + try { + updateLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + entityManager.refresh(store); + secondReadd.set(store.getStoreStatus()); + }); + }); + + CompletableFuture updater = CompletableFuture.runAsync(() -> { + try { + readLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionTemplate transaction2 = new TransactionTemplate(transactionManager); + + transaction2.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + store.updateStatus(DISMISSED); + storeRepository.save(store); + }); + updateLatch.countDown(); + }); + + reader.get(); + updater.get(); + + // Assert + assertThat(firstRead.get()).isEqualTo(RESOLVED); + assertThat(secondReadd.get()).isEqualTo(DISMISSED); + assertThat(firstRead.get()).isNotEqualTo(secondReadd.get()); + } + + @DisplayName("Repeatable Read level에서 non-repeatable read 현상 방지") + @Test + void repeatableReadWithoutNonRepeatableRead() throws Exception { + // Arrange + final Long storeId = getStoreId(); + + CountDownLatch readLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + + AtomicReference firstRead = new AtomicReference<>(); + AtomicReference secondReadd = new AtomicReference<>(); + + // Act + CompletableFuture reader = CompletableFuture.runAsync(() -> { + TransactionTemplate transaction1 = new TransactionTemplate(transactionManager); + transaction1.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + + transaction1.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + firstRead.set(store.getStoreStatus()); + readLatch.countDown(); + + try { + updateLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + entityManager.refresh(store); + secondReadd.set(store.getStoreStatus()); + }); + }); + + CompletableFuture updater = CompletableFuture.runAsync(() -> { + try { + readLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionTemplate transaction2 = new TransactionTemplate(transactionManager); + transaction2.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + + transaction2.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + store.updateStatus(DISMISSED); + storeRepository.save(store); + }); + updateLatch.countDown(); + }); + + reader.get(); + updater.get(); + + // Assert + assertThat(firstRead.get()).isEqualTo(RESOLVED); + assertThat(secondReadd.get()).isEqualTo(RESOLVED); + assertThat(firstRead.get()).isEqualTo(secondReadd.get()); + } + + @DisplayName("Read Committed level에서 Phantom Read 현상 재현") + @Test + void readCommittedPhantomRead() throws Exception { + // Arrange + final Location location = createLocation(); + locationRepository.save(location); + + final StoreCategory category = createCategory(); + storeCategoryRepository.save(category); + + storeRepository.save(createStore( + "Store1", + "Store Description1", + RESOLVED, + category, + location + )); + + CountDownLatch readLatch = new CountDownLatch(1); + CountDownLatch insertLatch = new CountDownLatch(1); + + AtomicReference> firstRead = new AtomicReference<>(); + AtomicReference> secondRead = new AtomicReference<>(); + + // Act + CompletableFuture reader = CompletableFuture.runAsync(() -> { + TransactionTemplate transaction1 = new TransactionTemplate(transactionManager); + + transaction1.executeWithoutResult(status -> { + final List stores1 = storeRepository.findAll(); + firstRead.set(stores1); + readLatch.countDown(); + + try { + insertLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + final List stores2 = storeRepository.findAll(); + secondRead.set(stores2); + }); + }); + + CompletableFuture writer = CompletableFuture.runAsync(() -> { + try { + readLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionTemplate transaction2 = new TransactionTemplate(transactionManager); + + transaction2.executeWithoutResult(status -> { + storeRepository.save(createStore( + "Store2", + "Store Description2", + RESOLVED, + category, + location + )); + }); + insertLatch.countDown(); + }); + + reader.get(); + writer.get(); + + // Assert + assertThat(firstRead.get()).hasSize(1); + assertThat(secondRead.get()).hasSize(2); + } + + @DisplayName("Repeatable Read level에서 Phantom Read 현상 방지") + @Test + void repeatableReadWithoutPhantomRead() throws Exception { + // Arrange + final Location location = createLocation(); + locationRepository.save(location); + + final StoreCategory category = createCategory(); + storeCategoryRepository.save(category); + + storeRepository.save(createStore( + "Store1", + "Store Description1", + RESOLVED, + category, + location + )); + + CountDownLatch readLatch = new CountDownLatch(1); + CountDownLatch insertLatch = new CountDownLatch(1); + + AtomicReference> firstRead = new AtomicReference<>(); + AtomicReference> secondRead = new AtomicReference<>(); + + // Act + CompletableFuture reader = CompletableFuture.runAsync(() -> { + TransactionTemplate transaction1 = new TransactionTemplate(transactionManager); + transaction1.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + + transaction1.executeWithoutResult(status -> { + final List stores1 = storeRepository.findAll(); + firstRead.set(stores1); + + readLatch.countDown(); + + try { + insertLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + final List stores2 = storeRepository.findAll(); + secondRead.set(stores2); + }); + }); + + CompletableFuture writer = CompletableFuture.runAsync(() -> { + try { + readLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionTemplate transaction2 = new TransactionTemplate(transactionManager); + transaction2.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + + transaction2.executeWithoutResult(status -> { + storeRepository.save(createStore( + "Store2", + "Store Description2", + RESOLVED, + category, + location + )); + }); + insertLatch.countDown(); + }); + + reader.get(); + writer.get(); + + // Assert + assertThat(firstRead.get()).hasSize(1); + assertThat(secondRead.get()).hasSize(1); + assertThat(firstRead.get().size()).isEqualTo(secondRead.get().size()); + } + + @DisplayName("Read Committed level에서 Lost Update 현상 재현") + @Test + void readCommittedLostUpdate() throws Exception { + // Arrange + final Long storeId = getStoreId(); + + CountDownLatch updateLatch1 = new CountDownLatch(1); + CountDownLatch updateLatch2 = new CountDownLatch(1); + + // Act + CompletableFuture future1 = CompletableFuture.runAsync(() -> { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + store.updateStatus(PENDING); + + updateLatch1.countDown(); + + try { + updateLatch2.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + storeRepository.saveAndFlush(store); + }); + }); + + CompletableFuture future2 = CompletableFuture.runAsync(() -> { + try { + updateLatch1.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + store.updateStatus(DISMISSED); + storeRepository.saveAndFlush(store); + }); + updateLatch2.countDown(); + }); + + future1.get(); + future2.get(); + + // Assert + final Store result = storeRepository.findById(storeId) + .orElseThrow(); + assertThat(result.getStoreStatus()).isEqualTo(PENDING); + } + + /** + * TODO : 낙관적 락 사용해 Lost Update 현상 방지를 위한 테스트 + * TODO : Store Entity에 Version을 사용해 Lost Update 현상 방지 가능 + */ + @Disabled("낙관적 락 사용하기 위해선 엔티티에 Version이 존재해야 하지만 존재하지 않음.") + @DisplayName("Read Committed level에서 Optimistic Lock 사용해 Lost Update 현상 방지") + @Test + void readCommittedOptimisticLockWithoutLostUpdate() throws Exception { + // Arrange + final Long storeId = getStoreId(); + + CountDownLatch updateLatch1 = new CountDownLatch(1); + CountDownLatch updateLatch2 = new CountDownLatch(1); + + // Act + CompletableFuture future1 = CompletableFuture.runAsync(() -> { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + assertThatThrownBy(() -> + transactionTemplate.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + store.updateStatus(PENDING); + + updateLatch1.countDown(); + + try { + updateLatch2.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + storeRepository.saveAndFlush(store); + }) + ).isInstanceOf(ObjectOptimisticLockingFailureException.class); + }); + + CompletableFuture future2 = CompletableFuture.runAsync(() -> { + try { + updateLatch1.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.executeWithoutResult(status -> { + final Store store = storeRepository.findById(storeId) + .orElseThrow(); + store.updateStatus(DISMISSED); + storeRepository.saveAndFlush(store); + }); + updateLatch2.countDown(); + }); + + future1.get(); + future2.get(); + + // Assert + final Store result = storeRepository.findById(storeId) + .orElseThrow(); + assertThat(result.getStoreStatus()).isEqualTo(DISMISSED); + } + + private Long getStoreId() { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + return transactionTemplate.execute(status -> { + final Location location = createLocation(); + locationRepository.save(location); + + final StoreCategory category = createCategory(); + storeCategoryRepository.save(category); + + final Store store = createStore( + "Store1", + "Store Description1", + RESOLVED, + category, + location + ); + storeRepository.save(store); + + return store.getId(); + }); + } + + private Store createStore( + final String name, + final String description, + final StoreStatus status, + final StoreCategory category, + final Location location + ) { + return Store.builder() + .name(name) + .description(description) + .storeStatus(status) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(10)) + .snsUrl("https://instgram.com/test") + .category(category) + .location(location) + .build(); + } + + private StoreCategory createCategory() { + return StoreCategory.builder() + .name("뷰티") + .build(); + } + + private Location createLocation() { + return Location.builder() + .name("서울 송파구 올림픽로 300") + .zonecode("05551") + .sido("서울") + .sigungu("송파구") + .address("서울 송파구 올림픽로 300") + .longitude(127.104302) + .latitude(37.513713) + .build(); + } +} diff --git a/src/test/java/kr/co/pinup/stores/controller/StoreApiControllerTest.java b/src/test/java/kr/co/pinup/stores/controller/StoreApiControllerTest.java index 9f6d1ceb..0c754d19 100644 --- a/src/test/java/kr/co/pinup/stores/controller/StoreApiControllerTest.java +++ b/src/test/java/kr/co/pinup/stores/controller/StoreApiControllerTest.java @@ -10,10 +10,7 @@ import kr.co.pinup.storeoperatinghour.model.dto.StoreOperatingHourRequest; import kr.co.pinup.storeoperatinghour.model.dto.StoreOperatingHourResponse; import kr.co.pinup.stores.exception.StoreNotFoundException; -import kr.co.pinup.stores.model.dto.StoreRequest; -import kr.co.pinup.stores.model.dto.StoreResponse; -import kr.co.pinup.stores.model.dto.StoreThumbnailResponse; -import kr.co.pinup.stores.model.dto.StoreUpdateRequest; +import kr.co.pinup.stores.model.dto.*; import kr.co.pinup.stores.service.StoreService; import kr.co.pinup.support.RestDocsSupport; import org.junit.jupiter.api.BeforeEach; @@ -309,9 +306,9 @@ void createStore() throws Exception { objectMapper.writeValueAsString(storeRequest) .getBytes(UTF_8)); - final StoreResponse storeResponse = getStoreResponse(); + final StoreCreateResponse storeResponse = new StoreCreateResponse(1L, LocalDateTime.now()); - given(storeService.createStore(any(StoreRequest.class), any(List.class))) + given(storeService.createStore(any(StoreRequest.class), any(List.class), any(LocalDate.class))) .willReturn(storeResponse); // Act & Assert @@ -321,22 +318,11 @@ void createStore() throws Exception { .contentType(MULTIPART_FORM_DATA) ) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.name").exists()) - .andExpect(jsonPath("$.description").exists()) - .andExpect(jsonPath("$.status").exists()) - .andExpect(jsonPath("$.startDate").exists()) - .andExpect(jsonPath("$.endDate").exists()) - .andExpect(jsonPath("$.websiteUrl").exists()) - .andExpect(jsonPath("$.snsUrl").exists()) - .andExpect(jsonPath("$.viewCount").exists()) - .andExpect(jsonPath("$.category").exists()) - .andExpect(jsonPath("$.location").exists()) - .andExpect(jsonPath("$.operatingHours").exists()) - .andExpect(jsonPath("$.storeImages").exists()) - .andExpect(jsonPath("$.createdAt").exists()); + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.createdAt").isNotEmpty()); then(storeService).should(times(1)) - .createStore(any(StoreRequest.class), any(List.class)); + .createStore(any(StoreRequest.class), any(List.class), any(LocalDate.class)); result.andDo(restDocs.document( requestParts( @@ -380,36 +366,7 @@ void createStore() throws Exception { ), responseFields( fieldWithPath("id").type(NUMBER).description("팝업스토어 아이디"), - fieldWithPath("name").type(STRING).description("팝업스토어명"), - fieldWithPath("description").type(STRING).description("팝업스토어 설명"), - fieldWithPath("status").type(STRING).description("팝업스토어 상태"), - fieldWithPath("startDate").type(STRING).description("팝업스토어 시작날짜"), - fieldWithPath("endDate").type(STRING).description("팝업스토어 종료날짜"), - fieldWithPath("websiteUrl").type(STRING).optional().description("팝업스토어 참고 홈페이지 주소"), - fieldWithPath("snsUrl").type(STRING).optional().description("팝업스토어 참고 SNS 주소"), - fieldWithPath("viewCount").type(NUMBER).description("팝업스토어 조회수"), - fieldWithPath("category.id").type(NUMBER).description("팝업스토어 카테고리 아이디"), - fieldWithPath("category.name").type(STRING).description("팝업스토어 카테고리명"), - fieldWithPath("category.createdAt").type(STRING).description("팝업스토어 카테고리 등록날짜"), - fieldWithPath("category.updatedAt").type(STRING).optional().description("팝업스토어 카테고리 수정날짜"), - fieldWithPath("location.id").type(NUMBER).description("위치 아이디"), - fieldWithPath("location.name").type(STRING).description("위치명"), - fieldWithPath("location.zonecode").type(STRING).description("위치 우편번호"), - fieldWithPath("location.sido").type(STRING).description("위치 시/도"), - fieldWithPath("location.sigungu").type(STRING).description("위치 시/군/구"), - fieldWithPath("location.latitude").type(NUMBER).description("위치 위도"), - fieldWithPath("location.longitude").type(NUMBER).description("위치 경도"), - fieldWithPath("location.address").type(STRING).description("위치 주소"), - fieldWithPath("location.addressDetail").type(STRING).description("위치 상세주소"), - fieldWithPath("operatingHours[].days").type(STRING).description("팝업스토어 운영 날짜"), - fieldWithPath("operatingHours[].startTime").type(STRING).description("팝업스토어 운영 오픈시간"), - fieldWithPath("operatingHours[].endTime").type(STRING).description("팝업스토어 운영 마감시간"), - fieldWithPath("storeImages[].id").type(NUMBER).description("팝업스토어 이미지 아이디"), - fieldWithPath("storeImages[].storeId").type(NUMBER).description("팝업스토어 아이디"), - fieldWithPath("storeImages[].imageUrl").type(STRING).description("팝업스토어 이미지 URL"), - fieldWithPath("storeImages[].isThumbnail").type(BOOLEAN).description("팝업스토어 썸네일 이미지 여부"), - fieldWithPath("createdAt").type(STRING).description("팝업스토어 등록날짜"), - fieldWithPath("updatedAt").type(STRING).optional().description("팝업스토어 수정날짜") + fieldWithPath("createdAt").type(STRING).description("팝업스토어 등록날짜") ) )); } diff --git a/src/test/java/kr/co/pinup/stores/service/StoreServiceTest.java b/src/test/java/kr/co/pinup/stores/service/StoreServiceTest.java index fb1a0f39..6e1f0ae9 100644 --- a/src/test/java/kr/co/pinup/stores/service/StoreServiceTest.java +++ b/src/test/java/kr/co/pinup/stores/service/StoreServiceTest.java @@ -1,5 +1,6 @@ package kr.co.pinup.stores.service; +import kr.co.pinup.custom.s3.S3Service; import kr.co.pinup.locations.Location; import kr.co.pinup.locations.service.LocationService; import kr.co.pinup.storecategories.StoreCategory; @@ -11,12 +12,11 @@ import kr.co.pinup.storeoperatinghour.service.StoreOperatingHourService; import kr.co.pinup.stores.Store; import kr.co.pinup.stores.exception.StoreNotFoundException; -import kr.co.pinup.stores.model.dto.StoreRequest; -import kr.co.pinup.stores.model.dto.StoreResponse; -import kr.co.pinup.stores.model.dto.StoreThumbnailResponse; -import kr.co.pinup.stores.model.dto.StoreUpdateRequest; +import kr.co.pinup.stores.model.dto.*; import kr.co.pinup.stores.model.enums.StoreStatus; import kr.co.pinup.stores.repository.StoreRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,9 +24,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -58,9 +61,41 @@ public class StoreServiceTest { @Mock private StoreOperatingHourService operatingHourService; + @Mock + private S3Service s3Service; + + private ThreadPoolTaskExecutor taskExecutor; + @InjectMocks private StoreService storeService; + @BeforeEach + void setUp() { + taskExecutor = new ThreadPoolTaskExecutor(); + taskExecutor.setCorePoolSize(2); + taskExecutor.setMaxPoolSize(4); + taskExecutor.setQueueCapacity(10); + taskExecutor.setThreadNamePrefix("test-s3-upload-"); + taskExecutor.initialize(); + + storeService = new StoreService( + storeRepository, + categoryService, + locationService, + imageService, + operatingHourService, + s3Service, + taskExecutor + ); + } + + @AfterEach + void tearDown() { + if (taskExecutor != null) { + taskExecutor.shutdown(); + } + } + @DisplayName("팝업스토어 전체 조회") @Test void getStores() { @@ -381,48 +416,84 @@ void getStoresByLocationBySigungu() { .findAllByLocation_SigunguAndIsDeletedFalse(sigungu); } - @DisplayName("팝업스토어 정보를 저장한다") + @DisplayName("S3 업로드 및 팝업스토어 저장을 위한 선행 작업") @Test void createStore() { // Arrange final StoreRequest request = createStoreRequest(); + final LocalDate today = LocalDate.now(); final StoreCategory mockCategory = mock(StoreCategory.class); final Location mockLocation = mock(Location.class); - final List mockOperatingHours = List.of(mock(StoreOperatingHour.class)); - final Store savedStore = mock(Store.class); - final StoreImage mockImage = mock(StoreImage.class); - given(mockImage.getStore()).willReturn(savedStore); - - final List mockImages = List.of(mockImage); + given(s3Service.uploadFile(any(MultipartFile.class), anyString())) + .willReturn("http://s3.amazonaws.com/bucket/image.jpg"); given(categoryService.findCategoryById(1L)).willReturn(mockCategory); given(locationService.getLocation(1L)).willReturn(mockLocation); - - final ArgumentCaptor storeCaptor = ArgumentCaptor.forClass(Store.class); - given(storeRepository.save(any(Store.class))).willAnswer(invocation -> invocation.getArgument(0)); - - given(operatingHourService.createOperatingHours(any(), any())).willReturn(mockOperatingHours); - given(imageService.createUploadImages(any(), any(), anyLong())).willReturn(mockImages); + given(storeRepository.save(any(Store.class))) + .willAnswer(invocation -> { + Store store = invocation.getArgument(0); + ReflectionTestUtils.setField(store, "id", 1L); + ReflectionTestUtils.setField(store, "createdAt", LocalDateTime.now()); + return store; + }); // Act - final StoreResponse result = storeService.createStore(request, List.of(mock(MultipartFile.class))); + final StoreCreateResponse result = storeService.createStore( + request, + List.of(mock(MultipartFile.class)), + today + ); // Assert + assertThat(result).isNotNull(); + assertThat(result.id()).isNotNull(); + assertThat(result.createdAt()).isNotNull(); + + then(s3Service).should(times(1)) + .uploadFile(any(MultipartFile.class), anyString()); then(categoryService).should(times(1)) .findCategoryById(1L); then(locationService).should(times(1)) .getLocation(1L); - then(storeRepository).should(times(1)) - .save(storeCaptor.capture()); then(operatingHourService).should(times(1)) .createOperatingHours(any(), any()); then(imageService).should(times(1)) .createUploadImages(any(), any(), eq(0L)); + then(storeRepository).should(times(1)) + .save(any(Store.class)); + } - final Store saved = storeCaptor.getValue(); - assertThat(saved.getName()).isEqualTo(request.name()); - assertThat(saved.getDescription()).isEqualTo(request.description()); + @DisplayName("하나의 트랜잭션에서 팝업스토어를 저장한다") + @Test + void createStoreTransactional() { + // Arrange + final StoreRequest request = createStoreRequest(); + final List uploadUrls = List.of("http://127.0.0.1:4566/pinup/store/image.png"); + final Store store = Store.builder().build(); + + final List mockOperatingHours = List.of(mock(StoreOperatingHour.class)); + final List mockStoreImages = List.of(mock(StoreImage.class)); + + given(operatingHourService.createOperatingHours(eq(store), any())) + .willReturn(mockOperatingHours); + given(imageService.createUploadImages(store, uploadUrls, 0L)) + .willReturn(mockStoreImages); + given(storeRepository.save(store)).willReturn(store); + + // Act + storeService.createStoreTransactional(request, uploadUrls, store); + + // Assert + assertThat(store.getOperatingHours()).containsExactlyElementsOf(mockOperatingHours); + assertThat(store.getStoreImages()).containsExactlyElementsOf(mockStoreImages); + + then(operatingHourService).should(times(1)) + .createOperatingHours(eq(store), any()); + then(imageService).should(times(1)) + .createUploadImages(store, uploadUrls, 0L); + then(storeRepository).should(times(1)) + .save(store); } @DisplayName("팝업스토어 ID로 팝업스토어 정보를 수정한다") From d49f0838a65a46cf58dd195f3aa68ad411fd9149 Mon Sep 17 00:00:00 2001 From: whoamixzerone Date: Wed, 22 Oct 2025 21:10:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[#133]refactor:=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EC=96=B4=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=A7=81=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/pinup/config/SchedulerConfig.java | 19 ++ .../stores/repository/StoreRepository.java | 2 + .../scheduler/StoreStatusScheduler.java | 68 ++++-- .../scheduler/StoreStatusSchedulerTest.java | 208 ++++++++++++++++++ 4 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 src/main/java/kr/co/pinup/config/SchedulerConfig.java create mode 100644 src/test/java/kr/co/pinup/stores/scheduler/StoreStatusSchedulerTest.java diff --git a/src/main/java/kr/co/pinup/config/SchedulerConfig.java b/src/main/java/kr/co/pinup/config/SchedulerConfig.java new file mode 100644 index 00000000..f504998b --- /dev/null +++ b/src/main/java/kr/co/pinup/config/SchedulerConfig.java @@ -0,0 +1,19 @@ +package kr.co.pinup.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.function.Supplier; + +@Configuration +public class SchedulerConfig { + + private static final String ASIA_SEOUL_ZONE = "Asia/Seoul"; + + @Bean + public Supplier todaySupplier() { + return () -> LocalDate.now(ZoneId.of(ASIA_SEOUL_ZONE)); + } +} diff --git a/src/main/java/kr/co/pinup/stores/repository/StoreRepository.java b/src/main/java/kr/co/pinup/stores/repository/StoreRepository.java index fc10a691..eef8dc2d 100644 --- a/src/main/java/kr/co/pinup/stores/repository/StoreRepository.java +++ b/src/main/java/kr/co/pinup/stores/repository/StoreRepository.java @@ -17,4 +17,6 @@ public interface StoreRepository extends JpaRepository { List findAllByLocation_SigunguAndStoreStatusAndIsDeletedFalse(String sigungu, StoreStatus selectedStatus); List findAllByLocation_SigunguAndIsDeletedFalse(String sigungu); + + List findByStoreStatusInAndIsDeletedFalse(List storeStatuses); } diff --git a/src/main/java/kr/co/pinup/stores/scheduler/StoreStatusScheduler.java b/src/main/java/kr/co/pinup/stores/scheduler/StoreStatusScheduler.java index 21e06b78..2d719d44 100644 --- a/src/main/java/kr/co/pinup/stores/scheduler/StoreStatusScheduler.java +++ b/src/main/java/kr/co/pinup/stores/scheduler/StoreStatusScheduler.java @@ -3,43 +3,69 @@ import kr.co.pinup.stores.Store; import kr.co.pinup.stores.model.enums.StoreStatus; import kr.co.pinup.stores.repository.StoreRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.util.List; +import java.util.function.Supplier; @Slf4j @Component -@RequiredArgsConstructor public class StoreStatusScheduler { private final StoreRepository storeRepository; + private final Supplier todaySupplier; + + public StoreStatusScheduler( + final StoreRepository storeRepository, + @Qualifier("todaySupplier") final Supplier todaySupplier + ) { + this.storeRepository = storeRepository; + this.todaySupplier = todaySupplier; + } + + @Transactional @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") public void updateStoreStatuses() { - List stores = storeRepository.findAll(); - LocalDate today = LocalDate.now(); - - for (Store store : stores) { - StoreStatus newStatus; - - if (store.getStartDate().isAfter(today)) { - newStatus = StoreStatus.PENDING; - } else if (store.getEndDate().isBefore(today)) { - newStatus = StoreStatus.DISMISSED; - } else { - newStatus = StoreStatus.RESOLVED; - } - - if (store.getStoreStatus() != newStatus) { - store.updateStatus(newStatus); - log.info("스토어 [{}] 상태 {}로 변경", store.getId(), newStatus); - } + final LocalDate today = todaySupplier.get(); + + final List stores = storeRepository.findByStoreStatusInAndIsDeletedFalse( + List.of(StoreStatus.PENDING, StoreStatus.RESOLVED) + ); + + final long updatedCount = stores.stream() + .filter(store -> { + final StoreStatus changeStoreStatus = calculateStoreStatus(store, today); + if (store.getStoreStatus() != changeStoreStatus) { + store.updateStatus(changeStoreStatus); + log.info("스토어 [{}] 상태 {}로 변경", store.getId(), changeStoreStatus); + return true; + } + + return false; + }) + .count(); + + log.info("총 {}개의 스토어 상태를 갱신했습니다.", updatedCount); + } + + private StoreStatus calculateStoreStatus(final Store store, final LocalDate today) { + final StoreStatus storeStatus = store.getStoreStatus(); + if (storeStatus == StoreStatus.PENDING && + !store.getStartDate().isAfter(today)) { + return StoreStatus.RESOLVED; + } + + if (storeStatus == StoreStatus.RESOLVED && + store.getEndDate().isBefore(today)) { + return StoreStatus.DISMISSED; } - storeRepository.saveAll(stores); + return storeStatus; } } diff --git a/src/test/java/kr/co/pinup/stores/scheduler/StoreStatusSchedulerTest.java b/src/test/java/kr/co/pinup/stores/scheduler/StoreStatusSchedulerTest.java new file mode 100644 index 00000000..45620b45 --- /dev/null +++ b/src/test/java/kr/co/pinup/stores/scheduler/StoreStatusSchedulerTest.java @@ -0,0 +1,208 @@ +package kr.co.pinup.stores.scheduler; + +import kr.co.pinup.locations.Location; +import kr.co.pinup.locations.reposiotry.LocationRepository; +import kr.co.pinup.storecategories.StoreCategory; +import kr.co.pinup.storecategories.repository.StoreCategoryRepository; +import kr.co.pinup.stores.Store; +import kr.co.pinup.stores.model.enums.StoreStatus; +import kr.co.pinup.stores.repository.StoreRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static kr.co.pinup.stores.model.enums.StoreStatus.*; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class StoreStatusSchedulerTest { + + @Autowired + private StoreRepository storeRepository; + + @Autowired + private StoreCategoryRepository categoryRepository; + + @Autowired + private LocationRepository locationRepository; + + @DisplayName("오늘 날짜가 시작 날짜와 같거나 이후라면 스토어 상태를 RESOLVED로 업데이트한다.") + @Test + void updateStoreStatusResolved() { + // Arrange + final StoreStatusScheduler scheduler = new StoreStatusScheduler( + storeRepository, + () -> LocalDate.of(2025, 10, 7) + ); + + final StoreCategory category = createCategory(); + categoryRepository.save(category); + + final Location location = createLocation(); + locationRepository.save(location); + + final Store store = getStore( + PENDING, + LocalDate.of(2025, 10, 7), + LocalDate.of(2025, 10, 20), + category, + location + ); + storeRepository.save(store); + + // Act + scheduler.updateStoreStatuses(); + + // Assert + final Store result = storeRepository.findById(store.getId()).get(); + + assertThat(result).isNotNull(); + assertThat(result.getStoreStatus()).isEqualTo(RESOLVED); + } + + @DisplayName("오늘 날짜가 종료 날짜보다 이후라면 스토어 상태를 DISMISSED로 업데이트한다.") + @Test + void updateStoreStatusDismissed() { + // Arrange + final StoreStatusScheduler scheduler = new StoreStatusScheduler( + storeRepository, + () -> LocalDate.of(2025, 10, 21) + ); + + final StoreCategory category = createCategory(); + categoryRepository.save(category); + + final Location location = createLocation(); + locationRepository.save(location); + + final Store store = getStore( + RESOLVED, + LocalDate.of(2025, 10, 7), + LocalDate.of(2025, 10, 20), + category, + location + ); + storeRepository.save(store); + + // Act + scheduler.updateStoreStatuses(); + + // Assert + final Store result = storeRepository.findById(store.getId()).get(); + + assertThat(result).isNotNull(); + assertThat(result.getStoreStatus()).isEqualTo(DISMISSED); + } + + @DisplayName("오늘 날짜가 시작 날짜보다 이전이면 스토어 상태를 업데이트하지 않는다.") + @Test + void notUpdateStoreStatusWithPending() { + // Arrange + final StoreStatusScheduler scheduler = new StoreStatusScheduler( + storeRepository, + () -> LocalDate.of(2025, 10, 6) + ); + + final StoreCategory category = createCategory(); + categoryRepository.save(category); + + final Location location = createLocation(); + locationRepository.save(location); + + final Store store = getStore( + PENDING, + LocalDate.of(2025, 10, 7), + LocalDate.of(2025, 10, 20), + category, + location + ); + storeRepository.save(store); + + // Act + scheduler.updateStoreStatuses(); + + // Assert + final Store result = storeRepository.findById(store.getId()).get(); + + assertThat(result).isNotNull(); + assertThat(result.getStoreStatus()).isEqualTo(PENDING); + } + + @DisplayName("오늘 날짜가 시작 & 종료 날짜 사이면 스토어 상태를 업데이트하지 않는다.") + @Test + void notUpdateStoreStatusWithResolved() { + // Arrange + final StoreStatusScheduler scheduler = new StoreStatusScheduler( + storeRepository, + () -> LocalDate.of(2025, 10, 10) + ); + + final StoreCategory category = createCategory(); + categoryRepository.save(category); + + final Location location = createLocation(); + locationRepository.save(location); + + final Store store = getStore( + RESOLVED, + LocalDate.of(2025, 10, 7), + LocalDate.of(2025, 10, 20), + category, + location + ); + storeRepository.save(store); + + // Act + scheduler.updateStoreStatuses(); + + // Assert + final Store result = storeRepository.findById(store.getId()).get(); + + assertThat(result).isNotNull(); + assertThat(result.getStoreStatus()).isEqualTo(RESOLVED); + } + + private StoreCategory createCategory() { + return StoreCategory.builder() + .name("뷰티") + .build(); + } + + private Location createLocation() { + return Location.builder() + .name("서울 송파구 올림픽로 300") + .zonecode("05551") + .sido("서울") + .sigungu("송파구") + .address("서울 송파구 올림픽로 300") + .longitude(127.104302) + .latitude(37.513713) + .build(); + } + + private Store getStore( + final StoreStatus storeStatus, + final LocalDate startDate, + final LocalDate endDate, + final StoreCategory category, + final Location location + ) { + return Store.builder() + .name("store 1") + .description("store description 1") + .storeStatus(storeStatus) + .startDate(startDate) + .endDate(endDate) + .snsUrl("https://instgram.com/test") + .category(category) + .location(location) + .build(); + } +} \ No newline at end of file