diff --git a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java index 4076c314..e697ea72 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java @@ -1,11 +1,16 @@ package until.the.eternity.auctionhistory.application.scheduler; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import until.the.eternity.auctionhistory.application.service.AuctionHistoryService; +import until.the.eternity.auctionhistory.application.service.fetcher.AuctionHistoryFetcher; +import until.the.eternity.auctionhistory.application.service.persister.AuctionHistoryPersister; +import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; +import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; @Slf4j @@ -13,29 +18,28 @@ @RequiredArgsConstructor public class AuctionHistoryScheduler { - private final AuctionHistoryService auctionHistoryService; - - @Value("${openapi.auction-history.delay-ms}") - private long delayMs; + private final AuctionHistoryService service; + private final AuctionHistoryFetcher fetcher; + private final AuctionHistoryPersister persister; @Scheduled(cron = "${openapi.auction-history.cron}", zone = "Asia/Seoul") public void fetchAndSaveAuctionHistoryAll() { + List newEntities = new ArrayList<>(); for (ItemCategory category : ItemCategory.values()) { try { - auctionHistoryService.fetchAndSaveAuctionHistory(category); + List fetchedDtos = fetcher.fetch(category); + List entities = persister.filterOutExisting(fetchedDtos, category); + newEntities.addAll(entities); } catch (Exception e) { - log.error("Error during processing category [{}]", category.getSubCategory(), e); + log.error( + "> [SCHEDULE] Error during processing category [{}]", + category.getSubCategory(), + e); } - delayBetweenRequests(); - } - } - - private void delayBetweenRequests() { - try { - Thread.sleep(delayMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted during delay between requests", e); } + service.saveAll(newEntities); + log.info( + "> [SCHEDULE] AuctionHistoryScheduler saved [{}] new auction history records complete", + newEntities.size()); } } diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java index e3199a3e..562fa593 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java @@ -9,13 +9,9 @@ import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.domain.mapper.AuctionHistoryMapper; import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; -import until.the.eternity.auctionhistory.domain.service.fetcher.AuctionHistoryFetcherPort; -import until.the.eternity.auctionhistory.domain.service.persister.AuctionHistoryPersisterPort; -import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.AuctionHistoryDetailResponse; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse; -import until.the.eternity.common.enums.ItemCategory; import until.the.eternity.common.request.PageRequestDto; import until.the.eternity.common.response.PageResponseDto; @@ -25,8 +21,6 @@ public class AuctionHistoryService { private final AuctionHistoryRepositoryPort repository; - private final AuctionHistoryFetcherPort fetcher; - private final AuctionHistoryPersisterPort persister; private final AuctionHistoryMapper mapper; @Transactional(readOnly = true) @@ -51,8 +45,7 @@ public AuctionHistoryDetailResponse findByIdOrElseThrow(Long } @Transactional - public void fetchAndSaveAuctionHistory(ItemCategory category) { - List dtoList = fetcher.fetch(category); - persister.saveIfNotExists(dtoList, category); + public void saveAll(List entities) { + repository.saveAll(entities); } } diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java b/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java index df62e12c..a790ec4d 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java @@ -23,11 +23,25 @@ public class AuctionHistoryFetcher implements AuctionHistoryFetcherPort { public List fetch(ItemCategory category) { List result = new ArrayList<>(); - String cursor = null; + String cursor = ""; + + while (true) { + var response = client.fetchAuctionHistory(category, cursor).block(); - do { - var response = client.fetchAuctionHistory(category, cursor); if (response == null || response.auctionHistory() == null) { + log.warn( + "> [SCHEDULE] [{}] response or its history is null, something is wrong with open api call", + category.getSubCategory()); + break; + } + + log.debug( + "> [SCHEDULE] [{}] fetched '{}' data", + category.getSubCategory(), + response.auctionHistory().size()); + + if (response.auctionHistory().isEmpty()) { + log.debug("> [SCHEDULE] [{}] fetched no data", category.getSubCategory()); break; } @@ -35,12 +49,21 @@ public List fetch(ItemCategory category) { result.addAll(batch); if (duplicateChecker.hasDuplicate(batch.getLast())) { - log.debug("[{}] fetched {} data", category.getSubCategory(), result.size()); + log.debug( + "> [SCHEDULE] [{}] this fetched data has duplicate data, skip to next item subcategory", + category.getSubCategory()); break; } cursor = response.nextCursor(); - } while (cursor != null); + + if (cursor == null || cursor.isEmpty()) { + log.debug( + "> [SCHEDULE] [{}] response cursor is null, fetched end", + category.getSubCategory()); + break; + } + } return result; } diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java b/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java index 59d4cba9..ea4da107 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Component; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.domain.mapper.OpenApiAuctionHistoryMapper; -import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; import until.the.eternity.auctionhistory.domain.service.AuctionHistoryDuplicateChecker; import until.the.eternity.auctionhistory.domain.service.persister.AuctionHistoryPersisterPort; import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; @@ -17,35 +16,24 @@ @Component public class AuctionHistoryPersister implements AuctionHistoryPersisterPort { - private final AuctionHistoryRepositoryPort repository; private final OpenApiAuctionHistoryMapper mapper; private final AuctionHistoryDuplicateChecker duplicateChecker; - public void saveIfNotExists( + public List filterOutExisting( List dtoList, ItemCategory category) { List entities = mapper.toEntityList(duplicateChecker.filterExisting(dtoList), category); if (entities.isEmpty()) { - log.info("[{}] No new auction history to save", category.getSubCategory()); - return; + log.info("> [SCHEDULE] [{}] No new auction history to save", category.getSubCategory()); + } else { + log.info( + "> [SCHEDULE] [{}] After remove duplicate existing '{}' new auction history records left to save", + category.getSubCategory(), + entities.size()); } - repository.saveAll(entities); - logSummary(category, entities); - } - - private void logSummary(ItemCategory category, List entities) { - int optionCnt = - entities.stream() - .mapToInt(e -> e.getItemOptions() == null ? 0 : e.getItemOptions().size()) - .sum(); - - log.info( - "[{}] Saved {} new auction history records (with {} options)", - category.getSubCategory(), - entities.size(), - optionCnt); + return entities; } } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java b/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java index f140f9a9..4db1cc37 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java @@ -25,10 +25,11 @@ public boolean hasDuplicate(OpenApiAuctionHistoryResponse lastDto) { return lastDto.dateAuctionBuy().isAfter(latestDate); } - // TODO: 로직 변경 후 점검 중인데, 뭔가 문제가 있는 거 같음 - public List filterExisting( List dtos) { + if (dtos.isEmpty()) { + return dtos; + } Instant latestDate = getLatestAuctionDateOrMin(dtos.getFirst()); return dtos.stream().filter(dto -> dto.dateAuctionBuy().isAfter(latestDate)).toList(); } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/service/persister/AuctionHistoryPersisterPort.java b/src/main/java/until/the/eternity/auctionhistory/domain/service/persister/AuctionHistoryPersisterPort.java index ea4c6d51..636f4c2d 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/service/persister/AuctionHistoryPersisterPort.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/service/persister/AuctionHistoryPersisterPort.java @@ -1,10 +1,12 @@ package until.the.eternity.auctionhistory.domain.service.persister; import java.util.List; +import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; public interface AuctionHistoryPersisterPort { - void saveIfNotExists(List dtoList, ItemCategory category); + List filterOutExisting( + List dtoList, ItemCategory category); } diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/client/AuctionHistoryClient.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/client/AuctionHistoryClient.java index 80f78e09..97c9dbcc 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/client/AuctionHistoryClient.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/client/AuctionHistoryClient.java @@ -8,13 +8,6 @@ import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryListResponse; import until.the.eternity.common.enums.ItemCategory; -/** - * Nexon OPEN API 호출 전담 클라이언트. - * - *

– 전역 WebClient 설정(필터 · 헤더 · 타임아웃 · 재시도)은 {@link - * until.the.eternity.config.openapi.OpenApiWebClientConfig} 에서 담당한다. – 이 클래스는 “엔드포인트·쿼리 파라미터·로깅” 만 - * 책임지는 SRP 구조다. - */ @Slf4j @Component @RequiredArgsConstructor @@ -28,47 +21,40 @@ public class AuctionHistoryClient { * * @param category 조회할 카테고리 * @param cursor 다음 페이지 커서(null 가능) - * @return 응답 DTO, 호출 실패 시 null + * @return 응답 DTO를 담은 Mono, 호출 실패 시 Mono.empty() */ - public OpenApiAuctionHistoryListResponse fetchAuctionHistory( + public Mono fetchAuctionHistory( ItemCategory category, String cursor) { - try { - // TODO: 하드코딩 값 변경 - log.info( - "Calling 'https://open.api.nexon.com/mabinogi/v1/auction/history?auction_item_category={} with cursor='{}'", - category.getSubCategory(), - cursor == null ? "" : "&cursor=" + cursor); + log.info( + "[SCHEDULE] [{}] Calling Nexon Open API Auction History API with cursor='{}'", + category.getSubCategory(), + cursor == null ? "" : cursor); - return openApiWebClient - .get() - .uri( - uriBuilder -> - uriBuilder - .path("/auction/history") - .queryParam( - "auction_item_category", - category.getSubCategory()) - .queryParamIfPresent( - "cursor", - Mono.justOrEmpty(cursor).blockOptional()) - .build()) - .retrieve() - .bodyToMono(OpenApiAuctionHistoryListResponse.class) - // 필터에서 재시도·타임아웃·에러로깅이 이미 적용됨 - .onErrorResume( - throwable -> { - log.warn( - "Failed to fetch auction history [category={} cursor={}]: {}", - category, - cursor, - throwable.toString()); - return Mono.empty(); // graceful fail - }) - .block(); - } catch (Exception ex) { - log.error("Unexpected exception during auction history fetch", ex); - return null; - } + return openApiWebClient + .get() + .uri( + uriBuilder -> { + uriBuilder + .path("/auction/history") + .queryParam("auction_item_category", category.getSubCategory()); + if (cursor != null) { + uriBuilder.queryParam("cursor", cursor); + } + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(OpenApiAuctionHistoryListResponse.class) + // 필터에서 재시도·타임아웃·에러로깅이 이미 적용됨 + .onErrorResume( + throwable -> { + log.warn( + "[SCHEDULE] [{}] Failed to fetch Nexon Open API Auction History API with cursor='{}': error='{}', message='{}'", + category.getSubCategory(), + cursor, + throwable.toString(), + throwable.getMessage()); + return Mono.empty(); // graceful fail + }); } } diff --git a/src/main/java/until/the/eternity/common/enums/ItemCategory.java b/src/main/java/until/the/eternity/common/enums/ItemCategory.java index 3d368814..87801b67 100644 --- a/src/main/java/until/the/eternity/common/enums/ItemCategory.java +++ b/src/main/java/until/the/eternity/common/enums/ItemCategory.java @@ -60,7 +60,7 @@ public enum ItemCategory { MARIONETTE("마리오네트", "특수 장비"), ECHOSTONE("에코스톤", "특수 장비"), EIDOS("에이도스", "특수 장비"), - PALLIASH_RELIC("팔리아스 유물", "특수 장비"), + RELIC("유물", "특수 장비"), ETC_EQUIPMENT("기타 장비", "특수 장비"), // 설치물 diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java index c14db9e8..cf7ad34d 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java @@ -21,11 +21,9 @@ import until.the.eternity.auctionhistory.domain.mapper.AuctionHistoryMapper; import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; import until.the.eternity.auctionhistory.domain.service.fetcher.AuctionHistoryFetcherPort; -import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.AuctionHistoryDetailResponse; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse; -import until.the.eternity.common.enums.ItemCategory; import until.the.eternity.common.request.PageRequestDto; import until.the.eternity.common.response.PageResponseDto; @@ -100,21 +98,21 @@ void findByIdOrElseThrow_should_throw_exception_when_not_found() { verifyNoInteractions(mapper); } - @Test - @DisplayName("경매장 fetch 및 Save 로직은 fetcher와 persister에 위임한다") - void fetchAndSaveAuctionHistory_should_delegate_to_fetcher_and_persister() { - // given - ItemCategory category = ItemCategory.ETC; - List fetchedDtoList = - List.of(mock(OpenApiAuctionHistoryResponse.class)); - - when(fetcherPort.fetch(category)).thenReturn(fetchedDtoList); - - // when - service.fetchAndSaveAuctionHistory(category); - - // then - verify(fetcherPort).fetch(category); - verify(persister).saveIfNotExists(fetchedDtoList, category); - } + // @Test + // @DisplayName("경매장 fetch 및 Save 로직은 fetcher와 persister에 위임한다") + // void fetchAndSaveAuctionHistory_should_delegate_to_fetcher_and_persister() { + // // given + // ItemCategory category = ItemCategory.ETC; + // List fetchedDtoList = + // List.of(mock(OpenApiAuctionHistoryResponse.class)); + // + // when(fetcherPort.fetch(category)).thenReturn(fetchedDtoList); + // + // // when + // service.fetchAndSaveAuctionHistory(category); + // + // // then + // verify(fetcherPort).fetch(category); + // verify(persister).saveIfNotExists(fetchedDtoList, category); + // } } diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java index e31c38c7..a5b89c0c 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import java.time.Instant; @@ -13,6 +14,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; import until.the.eternity.auctionhistory.domain.service.AuctionHistoryDuplicateChecker; import until.the.eternity.auctionhistory.infrastructure.client.AuctionHistoryClient; import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryListResponse; @@ -56,8 +58,9 @@ void fetchAllPages() { // 2번째 페이지가 끝이라 Nexon Open API가 null을 반환할 때 var page2 = new OpenApiAuctionHistoryListResponse(List.of(dummy("3")), null); - when(client.fetchAuctionHistory(ItemCategory.SWORD, null)).thenReturn(page1); - when(client.fetchAuctionHistory(ItemCategory.SWORD, "cursor-1")).thenReturn(page2); + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(client.fetchAuctionHistory(ItemCategory.SWORD, "cursor-1")) + .thenReturn(Mono.just(page2)); // 기존 데이터와 마지막 페이지 (2페이지) 데이터의 중복이 없다고 가정 when(duplicateChecker.hasDuplicate(any())).thenReturn(false); @@ -82,28 +85,29 @@ class EarlyBreakFlow { @Test @DisplayName("duplicateChecker가 true를 반환하면 수집을 중단한다") void stopOnDuplicate() { - // given - API 호출을 2번 했다고 가정 + // given - API 호출을 1번만 하고 중복으로 인해 중단 var page1 = new OpenApiAuctionHistoryListResponse( List.of(dummy("1"), dummy("2")), "cursor-1"); - when(client.fetchAuctionHistory(ItemCategory.SWORD, null)).thenReturn(page1); - // when - 기존 데이터와 마지막 페이지 (2페이지) 데이터의 중복이 있다고 가정 + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + // when - 기존 데이터와 첫 페이지 데이터의 중복이 있다고 가정 when(duplicateChecker.hasDuplicate(page1.auctionHistory().getLast())).thenReturn(true); var result = fetcher.fetch(ItemCategory.SWORD); - // then - 이 페이지까지 호출 후 종료 - assertThat(result).hasSize(2); // addAll 되기 전 중단 + // then - 첫 페이지 데이터만 수집하고 종료 + assertThat(result).hasSize(2); + assertThat(result.getFirst().auctionBuyId()).isEqualTo("1"); - verify(client, times(1)).fetchAuctionHistory(ItemCategory.SWORD, null); + verify(client, times(1)).fetchAuctionHistory(ItemCategory.SWORD, ""); verifyNoMoreInteractions(client); } @Test - @DisplayName("첫 응답이 null이면 빈 리스트를 반환한다") + @DisplayName("첫 응답이 null(Mono.empty)이면 빈 리스트를 반환한다") void responseNull() { - when(client.fetchAuctionHistory(ItemCategory.SWORD, null)).thenReturn(null); + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.empty()); var result = fetcher.fetch(ItemCategory.SWORD); @@ -112,16 +116,73 @@ void responseNull() { } @Test - @DisplayName("auctionHistory()가 null이면 빈 리스트를 반환한다") - void auctionHistoryNull() { - var page = new OpenApiAuctionHistoryListResponse(null, "ignored"); + @DisplayName("auctionHistory()가 비어있으면 빈 리스트를 반환한다") + void auctionHistoryEmpty() { + var page = new OpenApiAuctionHistoryListResponse(List.of(), "ignored"); - when(client.fetchAuctionHistory(ItemCategory.SWORD, null)).thenReturn(page); + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page)); var result = fetcher.fetch(ItemCategory.SWORD); assertThat(result).isEmpty(); verify(duplicateChecker, never()).hasDuplicate(any()); } + + @Test + @DisplayName("nextCursor가 빈 문자열이면 수집을 중단한다") + void stopWhenNextCursorIsEmptyString() { + // given + var page1 = new OpenApiAuctionHistoryListResponse(List.of(dummy("1")), ""); // 커서가 비어있음 + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(duplicateChecker.hasDuplicate(any())).thenReturn(false); + + // when + var result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result).hasSize(1); + verify(client, times(1)).fetchAuctionHistory(eq(ItemCategory.SWORD), any()); + verifyNoMoreInteractions(client); + } + + @Test + @DisplayName("중간 페이지의 auctionHistory가 비어있으면 수집을 중단한다") + void stopWhenMiddlePageIsEmpty() { + // given + var page1 = new OpenApiAuctionHistoryListResponse(List.of(dummy("1")), "cursor-1"); + var emptyPage = + new OpenApiAuctionHistoryListResponse(List.of(), "cursor-2"); // 비어있는 페이지 + + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(client.fetchAuctionHistory(ItemCategory.SWORD, "cursor-1")) + .thenReturn(Mono.just(emptyPage)); + when(duplicateChecker.hasDuplicate(any())).thenReturn(false); + + // when + var result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result).hasSize(1); + verify(client, times(2)).fetchAuctionHistory(eq(ItemCategory.SWORD), any()); + verifyNoMoreInteractions(client); + } + + @Test + @DisplayName("응답 내 auctionHistory 리스트가 null이면 수집을 중단한다") + void stopWhenAuctionHistoryListIsNull() { + // given + var pageWithNullList = new OpenApiAuctionHistoryListResponse(null, "cursor-1"); + when(client.fetchAuctionHistory(ItemCategory.SWORD, "")) + .thenReturn(Mono.just(pageWithNullList)); + + // when + var result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result).isEmpty(); + verify(client, times(1)).fetchAuctionHistory(eq(ItemCategory.SWORD), any()); + verifyNoMoreInteractions(client); + verify(duplicateChecker, never()).hasDuplicate(any()); + } } } diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java index cc46f873..0501bc5b 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java @@ -1,11 +1,8 @@ package until.the.eternity.auctionhistory.application.service.persister; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; -import java.time.Instant; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -18,7 +15,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.domain.mapper.OpenApiAuctionHistoryMapper; -import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; import until.the.eternity.auctionhistory.domain.service.AuctionHistoryDuplicateChecker; import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; @@ -26,8 +22,6 @@ @ExtendWith(MockitoExtension.class) class AuctionHistoryPersisterTest { - @Mock private AuctionHistoryRepositoryPort repository; - @Mock private OpenApiAuctionHistoryMapper mapper; @Mock private AuctionHistoryDuplicateChecker duplicateChecker; @@ -39,91 +33,59 @@ class AuctionHistoryPersisterTest { private List entities; private ItemCategory category; - // 더미 데이터 생성 메소드 - private OpenApiAuctionHistoryResponse dummy(String id) { - return new OpenApiAuctionHistoryResponse( - "페러시우스 타이탄 블레이드", // itemName - "신성한 페러시우스 타이탄 블레이드", // itemDisplayName - ItemCategory.SWORD.getSubCategory(), // itemSubCategory - 1L, // itemCount - 100L, // auctionPricePerUnit - Instant.now(), // dateAuctionBuy - id, // auctionBuyId - null // itemOption은 테스트 결과에 상관이 없으니 null 처리 - ); - } - @BeforeEach void setUp() { category = ItemCategory.SWORD; - // Mock DTO 데이터 - OpenApiAuctionHistoryResponse dto1 = dummy("1"); - OpenApiAuctionHistoryResponse dto2 = dummy("2"); + OpenApiAuctionHistoryResponse dto1 = mock(OpenApiAuctionHistoryResponse.class); + OpenApiAuctionHistoryResponse dto2 = mock(OpenApiAuctionHistoryResponse.class); dtoList = Arrays.asList(dto1, dto2); - // 중복 체크 후 필터링된 데이터 filteredDtoList = Arrays.asList(dto1); - // Mock Entity 데이터 - AuctionHistory entity1 = createMockAuctionHistory("item1", 2); - AuctionHistory entity2 = createMockAuctionHistory("item2", 3); + AuctionHistory entity1 = mock(AuctionHistory.class); + AuctionHistory entity2 = mock(AuctionHistory.class); entities = Arrays.asList(entity1, entity2); } - /** 테스트용 Mock AuctionHistory 생성 */ - private AuctionHistory createMockAuctionHistory(String itemName, int optionCount) { - AuctionHistory entity = new AuctionHistory(); - // 실제 구현에 따라 적절한 필드 설정 - // entity.setItemName(itemName); - - if (optionCount > 0) { - // Mock 옵션 리스트 설정 (실제 구현에 따라 조정) - List options = Collections.nCopies(optionCount, new Object()); - // entity.setItemOptions(options); - } else { - // entity.setItemOptions(null); - } - - return entity; - } - @Test - @DisplayName("새로운 경매 기록이 있을 때 정상적으로 저장한다") - void saveIfNotExists_WhenNewRecordsExist_ShouldSaveSuccessfully() { + @DisplayName("새로운 경매 기록이 있을 때 필터링된 엔티티 리스트를 반환한다") + void filterOutExisting_WhenNewRecordsExist_ShouldReturnFilteredEntities() { // given when(duplicateChecker.filterExisting(dtoList)).thenReturn(filteredDtoList); when(mapper.toEntityList(filteredDtoList, category)).thenReturn(entities); // when - auctionHistoryPersister.saveIfNotExists(dtoList, category); + List actualEntities = + auctionHistoryPersister.filterOutExisting(dtoList, category); // then + assertThat(actualEntities).isEqualTo(entities); verify(duplicateChecker).filterExisting(dtoList); verify(mapper).toEntityList(filteredDtoList, category); - verify(repository).saveAll(entities); } @Test - @DisplayName("저장할 새로운 경매 기록이 없을 때 저장하지 않는다") - void saveIfNotExists_WhenNoNewRecords_ShouldNotSave() { + @DisplayName("새로운 경매 기록이 없을 때 빈 리스트를 반환한다") + void filterOutExisting_WhenNoNewRecords_ShouldReturnEmptyList() { // given when(duplicateChecker.filterExisting(dtoList)).thenReturn(Collections.emptyList()); when(mapper.toEntityList(Collections.emptyList(), category)) .thenReturn(Collections.emptyList()); // when - auctionHistoryPersister.saveIfNotExists(dtoList, category); + List actualEntities = + auctionHistoryPersister.filterOutExisting(dtoList, category); // then + assertThat(actualEntities).isEmpty(); verify(duplicateChecker).filterExisting(dtoList); verify(mapper).toEntityList(Collections.emptyList(), category); - verify(repository, never()).saveAll(anyList()); } @Test - @DisplayName("빈 DTO 리스트가 주어졌을 때 아무것도 저장하지 않는다") - void saveIfNotExists_WhenEmptyDtoList_ShouldNotSave() { + @DisplayName("빈 DTO 리스트가 주어졌을 때 빈 리스트를 반환한다") + void filterOutExisting_WhenEmptyDtoList_ShouldReturnEmptyList() { // given List emptyList = Collections.emptyList(); when(duplicateChecker.filterExisting(emptyList)).thenReturn(Collections.emptyList()); @@ -131,64 +93,59 @@ void saveIfNotExists_WhenEmptyDtoList_ShouldNotSave() { .thenReturn(Collections.emptyList()); // when - auctionHistoryPersister.saveIfNotExists(emptyList, category); + List actualEntities = + auctionHistoryPersister.filterOutExisting(emptyList, category); // then + assertThat(actualEntities).isEmpty(); verify(duplicateChecker).filterExisting(emptyList); verify(mapper).toEntityList(Collections.emptyList(), category); - verify(repository, never()).saveAll(anyList()); } @Test - @DisplayName("모든 DTO가 중복일 때 저장하지 않는다") - void saveIfNotExists_WhenAllDtosAreDuplicate_ShouldNotSave() { + @DisplayName("DTO 리스트에 null이 포함되어 있어도 정상적으로 처리한다") + void filterOutExisting_WhenDtoListContainsNull_ShouldProcessCorrectly() { // given - when(duplicateChecker.filterExisting(dtoList)).thenReturn(Collections.emptyList()); - when(mapper.toEntityList(Collections.emptyList(), category)) - .thenReturn(Collections.emptyList()); + OpenApiAuctionHistoryResponse dto1 = mock(OpenApiAuctionHistoryResponse.class); + List listWithNulls = Arrays.asList(dto1, null); + List filteredList = List.of(dto1); - // when - auctionHistoryPersister.saveIfNotExists(dtoList, category); - - // then - verify(duplicateChecker).filterExisting(dtoList); - verify(mapper).toEntityList(Collections.emptyList(), category); - verify(repository, never()).saveAll(anyList()); - } - - @Test - @DisplayName("다양한 카테고리에 대해 정상적으로 처리한다") - void saveIfNotExists_WithDifferentCategories_ShouldProcessCorrectly() { - // given - ItemCategory armorCategory = ItemCategory.SWORD; - when(duplicateChecker.filterExisting(dtoList)).thenReturn(filteredDtoList); - when(mapper.toEntityList(filteredDtoList, armorCategory)).thenReturn(entities); + when(duplicateChecker.filterExisting(listWithNulls)).thenReturn(filteredList); + when(mapper.toEntityList(filteredList, category)).thenReturn(entities); // when - auctionHistoryPersister.saveIfNotExists(dtoList, armorCategory); + List actualEntities = + auctionHistoryPersister.filterOutExisting(listWithNulls, category); // then - verify(duplicateChecker).filterExisting(dtoList); - verify(mapper).toEntityList(filteredDtoList, armorCategory); - verify(repository).saveAll(entities); + assertThat(actualEntities).isEqualTo(entities); + verify(duplicateChecker).filterExisting(listWithNulls); + verify(mapper).toEntityList(filteredList, category); } @Test - @DisplayName("엔티티에 옵션이 null인 경우도 정상적으로 처리한다") - void saveIfNotExists_WithNullOptions_ShouldProcessCorrectly() { + @DisplayName("일부 DTO만 중복일 경우, 중복되지 않은 DTO만 변환한다") + void filterOutExisting_WhenSomeDtosAreDuplicate_ShouldConvertNonDuplicates() { // given - AuctionHistory entityWithNullOptions = createMockAuctionHistory("item3", 0); - List entitiesWithNullOptions = Arrays.asList(entityWithNullOptions); + OpenApiAuctionHistoryResponse dto1 = mock(OpenApiAuctionHistoryResponse.class); + OpenApiAuctionHistoryResponse dto2 = mock(OpenApiAuctionHistoryResponse.class); + OpenApiAuctionHistoryResponse dto3 = mock(OpenApiAuctionHistoryResponse.class); + List originalList = Arrays.asList(dto1, dto2, dto3); - when(duplicateChecker.filterExisting(dtoList)).thenReturn(filteredDtoList); - when(mapper.toEntityList(filteredDtoList, category)).thenReturn(entitiesWithNullOptions); + // dto2는 중복이라 가정하고, dto1, dto3만 남김 + List nonDuplicateList = Arrays.asList(dto1, dto3); + List expectedEntities = List.of(mock(AuctionHistory.class)); + + when(duplicateChecker.filterExisting(originalList)).thenReturn(nonDuplicateList); + when(mapper.toEntityList(nonDuplicateList, category)).thenReturn(expectedEntities); // when - auctionHistoryPersister.saveIfNotExists(dtoList, category); + List actualEntities = + auctionHistoryPersister.filterOutExisting(originalList, category); // then - verify(duplicateChecker).filterExisting(dtoList); - verify(mapper).toEntityList(filteredDtoList, category); - verify(repository).saveAll(entitiesWithNullOptions); + assertThat(actualEntities).isEqualTo(expectedEntities); + verify(duplicateChecker).filterExisting(originalList); + verify(mapper).toEntityList(nonDuplicateList, category); } }