From fcecf326f5748ffef3115c68eace3c37ab94ace0 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:58:55 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[#67]=20feat:=20=EA=B8=B0=EC=82=AC=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EB=B0=B0=EC=B0=A8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=EC=97=90=20=EC=82=AC=EB=AC=B4=EC=8B=A4=20=EC=A0=84=ED=99=94?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CurrentDispatchDetailRes.java | 7 ++++++- .../dispatch/service/DispatcherService.java | 17 +++++++++++++++-- .../controller/TransporterV1Controller.java | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mobility/api/domain/dispatch/dto/response/CurrentDispatchDetailRes.java b/src/main/java/com/mobility/api/domain/dispatch/dto/response/CurrentDispatchDetailRes.java index ea74c74..b07a7da 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/dto/response/CurrentDispatchDetailRes.java +++ b/src/main/java/com/mobility/api/domain/dispatch/dto/response/CurrentDispatchDetailRes.java @@ -53,6 +53,9 @@ public record CurrentDispatchDetailRes( @Schema(description = "사무실 ID", example = "1") Long officeId, + @Schema(description = "사무실 전화번호", example = "02-1234-5678") + String officeTelNumber, + @Schema(description = "생성일시", example = "2024-01-15T10:00:00") LocalDateTime createdAt, @@ -65,9 +68,10 @@ public record CurrentDispatchDetailRes( /** * Entity -> DTO 변환 메서드 * @param dispatch 배차 엔티티 + * @param officeTelNumber 사무실 전화번호 * @return CurrentDispatchDetailRes */ - public static CurrentDispatchDetailRes from(Dispatch dispatch) { + public static CurrentDispatchDetailRes from(Dispatch dispatch, String officeTelNumber) { return CurrentDispatchDetailRes.builder() .id(dispatch.getId()) .status(dispatch.getStatus()) @@ -81,6 +85,7 @@ public static CurrentDispatchDetailRes from(Dispatch dispatch) { .paymentMethod(dispatch.getPaymentType()) .tollType(dispatch.getTollType()) .officeId(dispatch.getOfficeId()) + .officeTelNumber(officeTelNumber) .createdAt(dispatch.getCreatedAt()) .updatedAt(dispatch.getUpdatedAt()) .assignedAt(dispatch.getAssignedAt()) diff --git a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java index 78f3feb..553edd0 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java +++ b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java @@ -9,6 +9,8 @@ import com.mobility.api.domain.dispatch.enums.StatusType; import com.mobility.api.domain.dispatch.repository.DispatchRepository; import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes; +import com.mobility.api.domain.office.entity.Office; +import com.mobility.api.domain.office.repository.OfficeRepository; import com.mobility.api.domain.transporter.DispatchStatus; import com.mobility.api.domain.transporter.entity.LocationHistory; import com.mobility.api.domain.transporter.entity.Transporter; @@ -32,6 +34,7 @@ public class DispatcherService { private final DispatchRepository dispatchRepository; private final TransporterRepository transporterRepository; private final LocationRepository locationRepository; + private final OfficeRepository officeRepository; @Transactional public DispatchAssignCompleteRes assignDispatch(Long dispatchId, Long transporterId) { @@ -187,8 +190,18 @@ public CurrentDispatchDetailRes getCurrentDispatch(Long transporterId) { Dispatch dispatch = dispatchRepository.findFirstByTransporterIdAndStatusOrderByAssignedAtDesc(transporterId, StatusType.ASSIGNED) .orElseThrow(() -> new GlobalException(ResultCode.DISPATCH_NOT_ASSIGNED)); - // 4. DTO 변환 및 반환 - return CurrentDispatchDetailRes.from(dispatch); + // 4. 사무실 정보 조회 (사무실 전화번호를 가져오기 위함) + String officeTelNumber = null; + if (dispatch.getOfficeId() != null) { + Office office = officeRepository.findById(dispatch.getOfficeId()) + .orElse(null); + if (office != null) { + officeTelNumber = office.getOfficeTelNumber(); + } + } + + // 5. DTO 변환 및 반환 + return CurrentDispatchDetailRes.from(dispatch, officeTelNumber); } /** diff --git a/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java b/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java index 64c1d47..0aa0eb1 100644 --- a/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java +++ b/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java @@ -99,6 +99,7 @@ public CommonResponse updateTransporterLocation( - 기사의 dispatchStatus가 DISPATCH 상태일 때만 조회 가능합니다. - EMPTY 상태(배차중인 오더가 없음)인 경우 에러가 반환됩니다. - ASSIGNED 상태의 배차 정보를 반환합니다. + - 응답에 배차를 생성한 사무실(상황실)의 전화번호(officeTelNumber)가 포함됩니다. """ ) @io.swagger.v3.oas.annotations.responses.ApiResponses({ From 9ccde077ee7da233812733618cc6bbe28a8fbf34 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:19:57 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[#67]=20test:=20=EB=B0=B0=EC=B0=A8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC,=20?= =?UTF-8?q?=EA=B8=B0=EC=82=AC=20=EC=9C=84=EC=B9=98=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=EC=97=90=20=EA=B4=80?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../repository/DispatchRepository.java | 24 +- .../dispatch/service/DispatcherService.java | 11 +- .../api/MobilityApiApplicationTests.java | 6 +- .../repository/DispatchRepositoryTest.java | 359 ++++++++++++++++ .../service/DispatcherServiceTest.java | 389 +++++++++++++++++ .../repository/TransporterRepositoryTest.java | 392 ++++++++++++++++++ src/test/resources/application-test.yml | 17 +- src/test/resources/init-postgis.sql | 2 + 9 files changed, 1190 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/mobility/api/domain/dispatch/repository/DispatchRepositoryTest.java create mode 100644 src/test/java/com/mobility/api/domain/dispatch/service/DispatcherServiceTest.java create mode 100644 src/test/java/com/mobility/api/domain/transporter/repository/TransporterRepositoryTest.java create mode 100644 src/test/resources/init-postgis.sql diff --git a/build.gradle b/build.gradle index 24dbfea..643d421 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,11 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + // TestContainers for PostgreSQL + PostGIS + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:postgresql:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' diff --git a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java index 8da141d..62d4502 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java +++ b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java @@ -46,10 +46,30 @@ public interface DispatchRepository extends JpaRepository, d.toll_type as tollType FROM dispatch d WHERE d.active = true - AND (CAST(:statuses AS text[]) IS NULL OR d.status = ANY(CAST(:statuses AS text[]))) ORDER BY distanceInMeters ASC """, nativeQuery = true) - List findDispatchesByDistance( + List findDispatchesByDistance(@Param("lat") double lat, @Param("lon") double lon); + + @Query(value = """ + SELECT d.id as id, + d.service as serviceType, + d.charge as charge, + d.start_location as startLocation, + d.destination_location as destinationLocation, + d.status as status, + ST_DistanceSphere( + ST_SetSRID(ST_MakePoint(d.start_longitude, d.start_latitude), 4326), + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) + ) as distanceInMeters, + d.via_type as viaType, + d.payment_type as paymentType, + d.toll_type as tollType + FROM dispatch d + WHERE d.active = true + AND d.status IN (:statuses) + ORDER BY distanceInMeters ASC + """, nativeQuery = true) + List findDispatchesByDistanceAndStatus( @Param("lat") double lat, @Param("lon") double lon, @Param("statuses") List statuses diff --git a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java index 553edd0..cc1d6ce 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java +++ b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java @@ -155,16 +155,17 @@ public List getDispatchListByDistance(Long transporterId, L double lon = latestLocation.getLocation().getX(); // StatusType enum을 String으로 변환 - // 빈 리스트면 null로 전달하여 PostgreSQL IN 절 에러 방지 - List statusStrings = null; + List projections; if (statuses != null && !statuses.isEmpty()) { - statusStrings = statuses.stream() + List statusStrings = statuses.stream() .map(StatusType::name) .collect(Collectors.toList()); + projections = dispatchRepository.findDispatchesByDistanceAndStatus(lat, lon, statusStrings); + } else { + // 상태 필터 없이 전체 조회 + projections = dispatchRepository.findDispatchesByDistance(lat, lon); } - List projections = dispatchRepository.findDispatchesByDistance(lat, lon, statusStrings); - // 4. Projection -> DTO 변환 return projections.stream() .map(DispatchListItemRes::from) diff --git a/src/test/java/com/mobility/api/MobilityApiApplicationTests.java b/src/test/java/com/mobility/api/MobilityApiApplicationTests.java index 831fa1b..3318f07 100644 --- a/src/test/java/com/mobility/api/MobilityApiApplicationTests.java +++ b/src/test/java/com/mobility/api/MobilityApiApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class MobilityApiApplicationTests { -// @Test -// void contextLoads() { -// } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/mobility/api/domain/dispatch/repository/DispatchRepositoryTest.java b/src/test/java/com/mobility/api/domain/dispatch/repository/DispatchRepositoryTest.java new file mode 100644 index 0000000..4f7aa38 --- /dev/null +++ b/src/test/java/com/mobility/api/domain/dispatch/repository/DispatchRepositoryTest.java @@ -0,0 +1,359 @@ +package com.mobility.api.domain.dispatch.repository; + +import com.mobility.api.domain.dispatch.dto.DispatchDistanceProjection; +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.enums.*; +import com.mobility.api.domain.transporter.entity.Transporter; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +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; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@DisplayName("DispatchRepository 테스트") +class DispatchRepositoryTest { + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") + ) + .withDatabaseName("mobility") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @Autowired + private DispatchRepository dispatchRepository; + + @Autowired + private TransporterRepository transporterRepository; + + private Dispatch testDispatch; + private Transporter testTransporter; + + @BeforeEach + void setUp() { + // 테스트용 기사 생성 + testTransporter = Transporter.builder() + .name("김철수") + .phone("010-1234-5678") + .isAutoDispatch(true) + .build(); + testTransporter = transporterRepository.save(testTransporter); + + // 테스트용 배차 생성 + testDispatch = Dispatch.builder() + .dispatchNumber("2024-0001") + .startLocation("서울특별시 강남구 테헤란로 123") + .startLatitude(37.5012) + .startLongitude(127.0396) + .destinationLocation("부산광역시 해운대구 우동 456") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(150000) + .clientPhoneNumber("010-9876-5432") + .status(StatusType.OPEN) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .paymentType(PaymentType.CASH) + .tollType(TollType.HIPASS) + .active(true) + .officeId(1L) + .memo("테스트 메모") + .build(); + testDispatch = dispatchRepository.save(testDispatch); + } + + @Test + @DisplayName("배차 저장 및 조회") + void saveAndFindDispatch() { + // when + Optional found = dispatchRepository.findById(testDispatch.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getDispatchNumber()).isEqualTo("2024-0001"); + assertThat(found.get().getStatus()).isEqualTo(StatusType.OPEN); + assertThat(found.get().getCharge()).isEqualTo(150000); + } + + @Test + @DisplayName("비관적 락으로 배차 조회") + void findByIdWithPessimisticLock() { + // when + Optional found = dispatchRepository.findByIdWithPessimisticLock(testDispatch.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(testDispatch.getId()); + } + + @Test + @DisplayName("거리순 배차 조회 - 전체 상태") + void findDispatchesByDistance_AllStatuses() { + // given + // 여러 배차 생성 (서울 강남 기준 다양한 거리) + Dispatch dispatch1 = createDispatch("2024-0002", 37.5000, 127.0400, StatusType.OPEN); // 가까움 + Dispatch dispatch2 = createDispatch("2024-0003", 37.6000, 127.1000, StatusType.ASSIGNED); // 중간 + Dispatch dispatch3 = createDispatch("2024-0004", 37.4000, 126.9000, StatusType.COMPLETED); // 먼 곳 + + dispatchRepository.saveAll(List.of(dispatch1, dispatch2, dispatch3)); + + // when - 강남역 기준 (37.4979, 127.0276)에서 거리순 조회 (상태 필터 없음) + List result = dispatchRepository.findDispatchesByDistance( + 37.4979, 127.0276 + ); + + // then + assertThat(result).isNotEmpty(); + // 거리순으로 정렬되어야 함 (첫 번째가 가장 가까움) + assertThat(result.get(0).getDistanceInMeters()).isLessThan( + result.get(result.size() - 1).getDistanceInMeters() + ); + } + + @Test + @DisplayName("거리순 배차 조회 - 상태 필터링 (OPEN만)") + void findDispatchesByDistance_FilterByStatus() { + // given + Dispatch openDispatch = createDispatch("2024-0005", 37.5000, 127.0400, StatusType.OPEN); + Dispatch assignedDispatch = createDispatch("2024-0006", 37.5000, 127.0400, StatusType.ASSIGNED); + + dispatchRepository.saveAll(List.of(openDispatch, assignedDispatch)); + + // when - OPEN 상태만 조회 + List result = dispatchRepository.findDispatchesByDistanceAndStatus( + 37.4979, 127.0276, List.of("OPEN") + ); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).allMatch(d -> d.getStatus().equals("OPEN")); + } + + @Test + @DisplayName("상태별 배차 카운트 조회") + void countByStatus() { + // given + createDispatch("2024-0007", 37.5000, 127.0400, StatusType.OPEN); + createDispatch("2024-0008", 37.5000, 127.0400, StatusType.OPEN); + createDispatch("2024-0009", 37.5000, 127.0400, StatusType.ASSIGNED); + + dispatchRepository.flush(); + + // when + List result = dispatchRepository.countByStatus(); + + // then + assertThat(result).isNotEmpty(); + // OPEN 상태가 3개 (기존 1개 + 새로 추가한 2개) + // ASSIGNED 상태가 1개 + } + + @Test + @DisplayName("특정 사무실의 상태별 배차 카운트 조회") + void countByStatusAndOfficeId() { + // given + Long officeId = 1L; + createDispatchWithOffice("2024-0010", officeId, StatusType.OPEN); + createDispatchWithOffice("2024-0011", officeId, StatusType.ASSIGNED); + createDispatchWithOffice("2024-0012", 2L, StatusType.OPEN); // 다른 사무실 + + dispatchRepository.flush(); + + // when + List result = dispatchRepository.countByStatusAndOfficeId(officeId); + + // then + assertThat(result).isNotEmpty(); + // officeId=1인 배차만 카운트되어야 함 + } + + @Test + @DisplayName("Transporter와 Fetch Join으로 배차 목록 조회") + void findAllWithTransporter() { + // given + Dispatch dispatchWithTransporter = Dispatch.builder() + .dispatchNumber("2024-0013") + .startLocation("서울시 강남구") + .startLatitude(37.5000) + .startLongitude(127.0400) + .destinationLocation("서울시 서초구") + .destinationLatitude(37.4800) + .destinationLongitude(127.0300) + .charge(50000) + .status(StatusType.ASSIGNED) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .active(true) + .officeId(1L) + .transporter(testTransporter) + .build(); + dispatchRepository.save(dispatchWithTransporter); + + // when + Page result = dispatchRepository.findAllWithTransporter(PageRequest.of(0, 10)); + + // then + assertThat(result.getContent()).isNotEmpty(); + // Fetch Join이 되어 있으므로 N+1 문제 없이 Transporter 조회 가능 + assertThat(result.getContent().stream() + .filter(d -> d.getTransporter() != null) + .findFirst()).isPresent(); + } + + @Test + @DisplayName("특정 기사의 특정 상태 배차 조회") + void findByTransporterIdAndStatus() { + // given + Dispatch assignedDispatch = Dispatch.builder() + .dispatchNumber("2024-0014") + .startLocation("서울시") + .startLatitude(37.5000) + .startLongitude(127.0400) + .destinationLocation("부산시") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(200000) + .status(StatusType.ASSIGNED) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .active(true) + .officeId(1L) + .transporter(testTransporter) + .assignedAt(LocalDateTime.now()) + .build(); + dispatchRepository.save(assignedDispatch); + + // when + Optional found = dispatchRepository.findByTransporterIdAndStatus( + testTransporter.getId(), StatusType.ASSIGNED + ); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getTransporter().getId()).isEqualTo(testTransporter.getId()); + assertThat(found.get().getStatus()).isEqualTo(StatusType.ASSIGNED); + } + + @Test + @DisplayName("특정 기사의 최신 배차 조회 (assignedAt 최신순)") + void findFirstByTransporterIdAndStatusOrderByAssignedAtDesc() { + // given + // 같은 기사에게 여러 배차 할당 (시간차) + Dispatch oldDispatch = createDispatchWithTransporter( + "2024-0015", testTransporter, LocalDateTime.now().minusHours(2) + ); + Dispatch newDispatch = createDispatchWithTransporter( + "2024-0016", testTransporter, LocalDateTime.now() + ); + + dispatchRepository.saveAll(List.of(oldDispatch, newDispatch)); + + // when + Optional found = dispatchRepository.findFirstByTransporterIdAndStatusOrderByAssignedAtDesc( + testTransporter.getId(), StatusType.ASSIGNED + ); + + // then + assertThat(found).isPresent(); + // 가장 최근 배차가 조회되어야 함 + assertThat(found.get().getDispatchNumber()).isEqualTo("2024-0016"); + } + + @Test + @DisplayName("존재하지 않는 배차 조회 시 빈 Optional 반환") + void findByIdNotFound() { + // when + Optional found = dispatchRepository.findById(99999L); + + // then + assertThat(found).isEmpty(); + } + + // === 헬퍼 메서드 === + + private Dispatch createDispatch(String dispatchNumber, double lat, double lon, StatusType status) { + return Dispatch.builder() + .dispatchNumber(dispatchNumber) + .startLocation("서울시") + .startLatitude(lat) + .startLongitude(lon) + .destinationLocation("부산시") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(100000) + .status(status) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .active(true) + .officeId(1L) + .build(); + } + + private Dispatch createDispatchWithOffice(String dispatchNumber, Long officeId, StatusType status) { + return dispatchRepository.save(Dispatch.builder() + .dispatchNumber(dispatchNumber) + .startLocation("서울시") + .startLatitude(37.5000) + .startLongitude(127.0400) + .destinationLocation("부산시") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(100000) + .status(status) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .active(true) + .officeId(officeId) + .build()); + } + + private Dispatch createDispatchWithTransporter(String dispatchNumber, Transporter transporter, LocalDateTime assignedAt) { + return Dispatch.builder() + .dispatchNumber(dispatchNumber) + .startLocation("서울시") + .startLatitude(37.5000) + .startLongitude(127.0400) + .destinationLocation("부산시") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(100000) + .status(StatusType.ASSIGNED) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .active(true) + .officeId(1L) + .transporter(transporter) + .assignedAt(assignedAt) + .build(); + } +} diff --git a/src/test/java/com/mobility/api/domain/dispatch/service/DispatcherServiceTest.java b/src/test/java/com/mobility/api/domain/dispatch/service/DispatcherServiceTest.java new file mode 100644 index 0000000..5dc468f --- /dev/null +++ b/src/test/java/com/mobility/api/domain/dispatch/service/DispatcherServiceTest.java @@ -0,0 +1,389 @@ +package com.mobility.api.domain.dispatch.service; + +import com.mobility.api.domain.dispatch.dto.DispatchDistanceProjection; +import com.mobility.api.domain.dispatch.dto.response.CurrentDispatchDetailRes; +import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes; +import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes; +import com.mobility.api.domain.dispatch.dto.response.DispatchListItemRes; +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.enums.*; +import com.mobility.api.domain.dispatch.repository.DispatchRepository; +import com.mobility.api.domain.office.entity.Office; +import com.mobility.api.domain.office.repository.OfficeRepository; +import com.mobility.api.domain.transporter.DispatchStatus; +import com.mobility.api.domain.transporter.entity.LocationHistory; +import com.mobility.api.domain.transporter.entity.Transporter; +import com.mobility.api.domain.transporter.repository.LocationRepository; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +import com.mobility.api.global.exception.GlobalException; +import com.mobility.api.global.response.ResultCode; +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; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DispatcherService 테스트") +class DispatcherServiceTest { + + @Mock + private DispatchRepository dispatchRepository; + + @Mock + private TransporterRepository transporterRepository; + + @Mock + private LocationRepository locationRepository; + + @Mock + private OfficeRepository officeRepository; + + @InjectMocks + private DispatcherService dispatcherService; + + private GeometryFactory geometryFactory; + private Transporter testTransporter; + private Dispatch testDispatch; + private Office testOffice; + + @BeforeEach + void setUp() { + geometryFactory = new GeometryFactory(); + + // 테스트용 사무실 + testOffice = Office.builder() + .officeName("테스트 사무실") + .officeRegistrationNumber("123-45-67890") + .officeAddress("서울시 강남구") + .officeTelNumber("02-1234-5678") + .build(); + setId(testOffice, 1L); + + // 테스트용 기사 + testTransporter = Transporter.builder() + .id(1L) + .name("김철수") + .phone("010-1234-5678") + .isAutoDispatch(true) + .dispatchStatus(DispatchStatus.EMPTY) + .build(); + + // 테스트용 배차 + testDispatch = Dispatch.builder() + .id(100L) + .dispatchNumber("2024-0001") + .startLocation("서울시 강남구") + .startLatitude(37.5000) + .startLongitude(127.0400) + .destinationLocation("부산시 해운대구") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(150000) + .clientPhoneNumber("010-9876-5432") + .status(StatusType.OPEN) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .paymentType(PaymentType.CASH) + .tollType(TollType.HIPASS) + .active(true) + .officeId(1L) + .build(); + } + + @Test + @DisplayName("배차 할당 성공") + void assignDispatch_Success() { + // given + given(transporterRepository.findByIdWithPessimisticLock(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findByIdWithPessimisticLock(100L)) + .willReturn(Optional.of(testDispatch)); + + // when + DispatchAssignCompleteRes result = dispatcherService.assignDispatch(100L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(testDispatch.getStatus()).isEqualTo(StatusType.ASSIGNED); + assertThat(testDispatch.getTransporter()).isEqualTo(testTransporter); + assertThat(testTransporter.getDispatchStatus()).isEqualTo(DispatchStatus.DISPATCH); + } + + @Test + @DisplayName("배차 할당 실패 - 기사를 찾을 수 없음") + void assignDispatch_TransporterNotFound() { + // given + given(transporterRepository.findByIdWithPessimisticLock(1L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> dispatcherService.assignDispatch(100L, 1L)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.NOT_FOUND_USER); + } + + @Test + @DisplayName("배차 할당 실패 - 이미 배차중인 기사") + void assignDispatch_TransporterAlreadyDispatched() { + // given + Transporter busyTransporter = Transporter.builder() + .id(1L) + .name("바쁜기사") + .phone("010-1111-1111") + .dispatchStatus(DispatchStatus.DISPATCH) // 이미 배차중 + .build(); + + given(transporterRepository.findByIdWithPessimisticLock(1L)) + .willReturn(Optional.of(busyTransporter)); + + // when & then + assertThatThrownBy(() -> dispatcherService.assignDispatch(100L, 1L)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.TRANSPORTER_ALREADY_DISPATCHED); + } + + @Test + @DisplayName("배차 할당 실패 - 배차를 찾을 수 없음") + void assignDispatch_DispatchNotFound() { + // given + given(transporterRepository.findByIdWithPessimisticLock(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findByIdWithPessimisticLock(100L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> dispatcherService.assignDispatch(100L, 1L)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.NOT_FOUND_DISPATCH); + } + + @Test + @DisplayName("배차 취소 성공") + void cancelDispatch_Success() { + // given + testDispatch.assignDispatch(testTransporter); // 먼저 배차 할당 + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findByIdWithPessimisticLock(100L)) + .willReturn(Optional.of(testDispatch)); + + // when + DispatchCancelRes result = dispatcherService.cancelDispatch(100L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(testDispatch.getStatus()).isEqualTo(StatusType.OPEN); + assertThat(testDispatch.getTransporter()).isNull(); + assertThat(testTransporter.getDispatchStatus()).isEqualTo(DispatchStatus.EMPTY); + } + + @Test + @DisplayName("배차 완료 성공") + void completeDispatch_Success() { + // given + testDispatch.assignDispatch(testTransporter); // 먼저 배차 할당 + testTransporter.changeDispatchStatus(DispatchStatus.DISPATCH); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findByIdWithPessimisticLock(100L)) + .willReturn(Optional.of(testDispatch)); + + // when + DispatchAssignCompleteRes result = dispatcherService.completeDispatch(100L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(testDispatch.getStatus()).isEqualTo(StatusType.COMPLETED); + assertThat(testDispatch.getCompletedAt()).isNotNull(); + assertThat(testTransporter.getDispatchStatus()).isEqualTo(DispatchStatus.EMPTY); + } + + @Test + @DisplayName("기사용 배차 리스트 조회 - 거리순 정렬") + void getDispatchListByDistance_Success() { + // given + Point location = createPoint(127.0276, 37.4979); + LocationHistory locationHistory = LocationHistory.builder() + .id(1L) + .location(location) + .build(); + + testTransporter.changeDispatchStatus(DispatchStatus.EMPTY); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(locationRepository.findFirstByTransporter_IdOrderByIdDesc(1L)) + .willReturn(Optional.of(locationHistory)); + + // Mock DispatchDistanceProjection + DispatchDistanceProjection projection = mock(DispatchDistanceProjection.class); + given(projection.getId()).willReturn(100L); + given(projection.getServiceType()).willReturn("DELIVERY"); + given(projection.getCharge()).willReturn(150000); + given(projection.getStartLocation()).willReturn("서울시 강남구"); + given(projection.getDestinationLocation()).willReturn("부산시 해운대구"); + given(projection.getStatus()).willReturn("OPEN"); + given(projection.getDistanceInMeters()).willReturn(500.0); + + given(dispatchRepository.findDispatchesByDistanceAndStatus( + anyDouble(), anyDouble(), any() + )).willReturn(List.of(projection)); + + // when + List result = dispatcherService.getDispatchListByDistance( + 1L, List.of(StatusType.OPEN) + ); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).hasSize(1); + assertThat(result.get(0).id()).isEqualTo(100L); + } + + @Test + @DisplayName("배차 리스트 조회 실패 - 이미 배차중인 기사") + void getDispatchListByDistance_TransporterAlreadyDispatched() { + // given + testTransporter.changeDispatchStatus(DispatchStatus.DISPATCH); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + + // when & then + assertThatThrownBy(() -> dispatcherService.getDispatchListByDistance(1L, null)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.TRANSPORTER_ALREADY_DISPATCHED); + } + + @Test + @DisplayName("배차 리스트 조회 실패 - 기사 위치 정보 없음") + void getDispatchListByDistance_LocationNotFound() { + // given + testTransporter.changeDispatchStatus(DispatchStatus.EMPTY); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(locationRepository.findFirstByTransporter_IdOrderByIdDesc(1L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> dispatcherService.getDispatchListByDistance(1L, null)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.NOT_FOUND_USER); + } + + @Test + @DisplayName("현재 배차중인 오더 조회 성공") + void getCurrentDispatch_Success() { + // given + testDispatch.assignDispatch(testTransporter); + testTransporter.changeDispatchStatus(DispatchStatus.DISPATCH); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findFirstByTransporterIdAndStatusOrderByAssignedAtDesc(1L, StatusType.ASSIGNED)) + .willReturn(Optional.of(testDispatch)); + given(officeRepository.findById(1L)) + .willReturn(Optional.of(testOffice)); + + // when + CurrentDispatchDetailRes result = dispatcherService.getCurrentDispatch(1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(100L); + assertThat(result.status()).isEqualTo(StatusType.ASSIGNED); + assertThat(result.officeTelNumber()).isEqualTo("02-1234-5678"); + } + + @Test + @DisplayName("현재 배차중인 오더 조회 실패 - 배차중인 오더 없음 (EMPTY 상태)") + void getCurrentDispatch_NoCurrentDispatch() { + // given + testTransporter.changeDispatchStatus(DispatchStatus.EMPTY); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + + // when & then + assertThatThrownBy(() -> dispatcherService.getCurrentDispatch(1L)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.DISPATCH_NOT_ASSIGNED); + } + + @Test + @DisplayName("현재 배차중인 오더 조회 실패 - ASSIGNED 배차를 찾을 수 없음") + void getCurrentDispatch_AssignedDispatchNotFound() { + // given + testTransporter.changeDispatchStatus(DispatchStatus.DISPATCH); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findFirstByTransporterIdAndStatusOrderByAssignedAtDesc(1L, StatusType.ASSIGNED)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> dispatcherService.getCurrentDispatch(1L)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("resultCode", ResultCode.DISPATCH_NOT_ASSIGNED); + } + + @Test + @DisplayName("현재 배차중인 오더 조회 - 사무실 정보 없을 때 officeTelNumber는 null") + void getCurrentDispatch_NoOfficeInfo() { + // given + testDispatch.assignDispatch(testTransporter); + testTransporter.changeDispatchStatus(DispatchStatus.DISPATCH); + + given(transporterRepository.findById(1L)) + .willReturn(Optional.of(testTransporter)); + given(dispatchRepository.findFirstByTransporterIdAndStatusOrderByAssignedAtDesc(1L, StatusType.ASSIGNED)) + .willReturn(Optional.of(testDispatch)); + given(officeRepository.findById(1L)) + .willReturn(Optional.empty()); // 사무실 정보 없음 + + // when + CurrentDispatchDetailRes result = dispatcherService.getCurrentDispatch(1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.officeTelNumber()).isNull(); + } + + // === 헬퍼 메서드 === + + private Point createPoint(double lon, double lat) { + Point point = geometryFactory.createPoint(new Coordinate(lon, lat)); + point.setSRID(4326); + return point; + } + + private void setId(Object entity, Long id) { + try { + java.lang.reflect.Field field = entity.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set id", e); + } + } +} diff --git a/src/test/java/com/mobility/api/domain/transporter/repository/TransporterRepositoryTest.java b/src/test/java/com/mobility/api/domain/transporter/repository/TransporterRepositoryTest.java new file mode 100644 index 0000000..4cf409b --- /dev/null +++ b/src/test/java/com/mobility/api/domain/transporter/repository/TransporterRepositoryTest.java @@ -0,0 +1,392 @@ +package com.mobility.api.domain.transporter.repository; + +import com.mobility.api.domain.office.entity.Office; +import com.mobility.api.domain.office.repository.OfficeRepository; +import com.mobility.api.domain.transporter.DispatchStatus; +import com.mobility.api.domain.transporter.TransporterStatus; +import com.mobility.api.domain.transporter.dto.TransporterDistanceProjection; +import com.mobility.api.domain.transporter.entity.Transporter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@DisplayName("TransporterRepository 테스트") +class TransporterRepositoryTest { + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") + ) + .withDatabaseName("mobility") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @Autowired + private TransporterRepository transporterRepository; + + @Autowired + private OfficeRepository officeRepository; + + private GeometryFactory geometryFactory; + private Office testOffice; + private Transporter testTransporter; + + @BeforeEach + void setUp() { + geometryFactory = new GeometryFactory(); + + // 테스트용 사무실 생성 + testOffice = Office.builder() + .officeName("테스트 사무실") + .officeRegistrationNumber("123-45-67890") + .officeAddress("서울시 강남구") + .officeTelNumber("02-1234-5678") + .build(); + testOffice = officeRepository.save(testOffice); + + // 테스트용 기사 생성 (강남역 근처) + Point gangnamLocation = createPoint(127.0276, 37.4979); // 강남역 (경도, 위도) + + testTransporter = Transporter.builder() + .name("김철수") + .phone("010-1234-5678") + .currentLocation(gangnamLocation) + .isAutoDispatch(true) + .status(TransporterStatus.ACTIVE) + .dispatchStatus(DispatchStatus.EMPTY) + .office(testOffice) + .build(); + testTransporter = transporterRepository.save(testTransporter); + } + + @Test + @DisplayName("기사 저장 및 조회") + void saveAndFindTransporter() { + // when + Optional found = transporterRepository.findById(testTransporter.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("김철수"); + assertThat(found.get().getPhone()).isEqualTo("010-1234-5678"); + assertThat(found.get().isAutoDispatch()).isTrue(); + } + + @Test + @DisplayName("전화번호로 기사 조회") + void findByPhone() { + // when + Optional found = transporterRepository.findByPhone("010-1234-5678"); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("김철수"); + } + + @Test + @DisplayName("전화번호 중복 체크 - 존재함") + void existsByPhone_True() { + // when + boolean exists = transporterRepository.existsByPhone("010-1234-5678"); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("전화번호 중복 체크 - 존재하지 않음") + void existsByPhone_False() { + // when + boolean exists = transporterRepository.existsByPhone("010-9999-9999"); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("비관적 락으로 기사 조회") + void findByIdWithPessimisticLock() { + // when + Optional found = transporterRepository.findByIdWithPessimisticLock(testTransporter.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(testTransporter.getId()); + } + + @Test + @DisplayName("근처 기사 조회 - 반경 내 기사 찾기") + void findNearbyTransporters() { + // given + // 강남역 근처에 추가 기사들 배치 + Point location1 = createPoint(127.0286, 37.4989); // 약 100m 거리 + Point location2 = createPoint(127.0376, 37.5079); // 약 1.2km 거리 + Point location3 = createPoint(127.1000, 37.5500); // 약 8km 거리 + + Transporter nearby1 = createTransporter("이영희", "010-1111-2222", location1, true); + Transporter nearby2 = createTransporter("박민수", "010-2222-3333", location2, true); + Transporter faraway = createTransporter("최동수", "010-3333-4444", location3, true); + + transporterRepository.saveAll(List.of(nearby1, nearby2, faraway)); + + // when - 강남역 기준 2km 반경 내 기사 조회 + double lat = 37.4979; + double lon = 127.0276; + double radiusMeters = 2000; // 2km + + List result = transporterRepository.findNearbyTransporters( + lat, lon, radiusMeters + ); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).hasSizeGreaterThanOrEqualTo(3); // testTransporter + nearby1 + nearby2 + // faraway는 2km 밖이므로 제외되어야 함 + assertThat(result).allMatch(t -> t.getDistanceInMeters() <= radiusMeters); + // 거리순으로 정렬되어야 함 + assertThat(result.get(0).getDistanceInMeters()).isLessThan( + result.get(result.size() - 1).getDistanceInMeters() + ); + } + + @Test + @DisplayName("자동배차 적격 기사 조회 - 50km 내 자동배차 ON 기사만") + void findEligibleDriversForAutoDispatch() { + // given + Point location1 = createPoint(127.0286, 37.4989); + Point location2 = createPoint(127.0376, 37.5079); + + // 자동배차 ON + Transporter autoOn1 = createTransporter("자동ON1", "010-1111-1111", location1, true); + Transporter autoOn2 = createTransporter("자동ON2", "010-2222-2222", location2, true); + + // 자동배차 OFF + Transporter autoOff = createTransporter("자동OFF", "010-3333-3333", location1, false); + + transporterRepository.saveAll(List.of(autoOn1, autoOn2, autoOff)); + + // when - 강남역 기준 자동배차 적격 기사 조회 + List result = transporterRepository.findEligibleDriversForAutoDispatch( + 37.4979, 127.0276 + ); + + // then + assertThat(result).isNotEmpty(); + // 자동배차 OFF인 기사는 제외 + assertThat(result).allMatch(t -> !t.getName().equals("자동OFF")); + // 최대 10명까지만 조회 + assertThat(result).hasSizeLessThanOrEqualTo(10); + // 거리순 정렬 + if (result.size() > 1) { + assertThat(result.get(0).getDistanceInMeters()).isLessThanOrEqualTo( + result.get(1).getDistanceInMeters() + ); + } + } + + @Test + @DisplayName("1km 반경 내 자동배차 ON 기사 존재 여부 - 있음") + void existsEligibleDriversWithinRadius_True() { + // given + Point nearbyLocation = createPoint(127.0286, 37.4989); // 약 100m 거리 + Transporter nearby = createTransporter("근처기사", "010-5555-5555", nearbyLocation, true); + transporterRepository.save(nearby); + + // when + boolean exists = transporterRepository.existsEligibleDriversWithinRadius( + 37.4979, 127.0276 + ); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("1km 반경 내 자동배차 ON 기사 존재 여부 - 없음") + void existsEligibleDriversWithinRadius_False() { + // given + // 기존 testTransporter를 자동배차 OFF로 변경하기 위해 새로 저장 + transporterRepository.deleteAll(); + + Point farLocation = createPoint(127.0500, 37.5200); // 약 3km 거리 + Transporter farTransporter = createTransporter("먼기사", "010-6666-6666", farLocation, true); + transporterRepository.save(farTransporter); + + // when - 1km 반경 내에는 기사가 없음 + boolean exists = transporterRepository.existsEligibleDriversWithinRadius( + 37.4979, 127.0276 + ); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("특정 사무실 소속 기사 목록 조회") + void findAllByOffice() { + // given + Point location = createPoint(127.0300, 37.5000); + Transporter transporter1 = createTransporter("사무실1소속1", "010-7777-7777", location, true); + Transporter transporter2 = createTransporter("사무실1소속2", "010-8888-8888", location, true); + + transporterRepository.saveAll(List.of(transporter1, transporter2)); + + // when + List result = transporterRepository.findAllByOffice(testOffice); + + // then + assertThat(result).hasSizeGreaterThanOrEqualTo(3); // testTransporter + 2명 + assertThat(result).allMatch(t -> t.getOffice().getId().equals(testOffice.getId())); + } + + @Test + @DisplayName("특정 사무실 소속 기사 목록 조회 - 페이징") + void findAllByOffice_Pageable() { + // given + Point location = createPoint(127.0300, 37.5000); + Transporter transporter1 = createTransporter("기사1", "010-1001-1001", location, true); + Transporter transporter2 = createTransporter("기사2", "010-1002-1002", location, true); + + transporterRepository.saveAll(List.of(transporter1, transporter2)); + + // when + Page result = transporterRepository.findAllByOffice( + testOffice, PageRequest.of(0, 2) + ); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(3); + } + + @Test + @DisplayName("특정 사무실 소속 + 상태 필터 기사 조회") + void findAllByOfficeAndStatus() { + // given + Point location = createPoint(127.0300, 37.5000); + + Transporter activeTransporter = Transporter.builder() + .name("활성기사") + .phone("010-9001-9001") + .currentLocation(location) + .isAutoDispatch(true) + .status(TransporterStatus.ACTIVE) + .office(testOffice) + .build(); + + Transporter pendingTransporter = Transporter.builder() + .name("대기기사") + .phone("010-9002-9002") + .currentLocation(location) + .isAutoDispatch(false) + .status(TransporterStatus.PENDING) + .office(testOffice) + .build(); + + transporterRepository.saveAll(List.of(activeTransporter, pendingTransporter)); + + // when + Page result = transporterRepository.findAllByOfficeAndStatus( + testOffice, TransporterStatus.ACTIVE, PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).isNotEmpty(); + assertThat(result.getContent()).allMatch(t -> t.getStatus() == TransporterStatus.ACTIVE); + assertThat(result.getContent()).allMatch(t -> t.getOffice().getId().equals(testOffice.getId())); + } + + @Test + @DisplayName("존재하지 않는 기사 조회 시 빈 Optional 반환") + void findByIdNotFound() { + // when + Optional found = transporterRepository.findById(99999L); + + // then + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("위치 정보가 null인 기사는 근처 조회에서 제외") + void findNearbyTransporters_ExcludeNullLocation() { + // given + Transporter noLocation = Transporter.builder() + .name("위치없음") + .phone("010-0000-0000") + .currentLocation(null) // 위치 정보 없음 + .isAutoDispatch(true) + .office(testOffice) + .build(); + transporterRepository.save(noLocation); + + // when + List result = transporterRepository.findNearbyTransporters( + 37.4979, 127.0276, 10000 + ); + + // then + // 위치 정보가 없는 기사는 결과에 포함되지 않아야 함 + assertThat(result).noneMatch(t -> t.getName().equals("위치없음")); + } + + // === 헬퍼 메서드 === + + /** + * PostGIS Point 생성 (SRID 4326) + * @param lon 경도 (X) + * @param lat 위도 (Y) + * @return Point + */ + private Point createPoint(double lon, double lat) { + Point point = geometryFactory.createPoint(new Coordinate(lon, lat)); + point.setSRID(4326); + return point; + } + + /** + * 테스트용 기사 생성 + */ + private Transporter createTransporter(String name, String phone, Point location, boolean isAutoDispatch) { + return Transporter.builder() + .name(name) + .phone(phone) + .currentLocation(location) + .isAutoDispatch(isAutoDispatch) + .status(TransporterStatus.ACTIVE) + .dispatchStatus(DispatchStatus.EMPTY) + .office(testOffice) + .build(); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8b4f1ae..16a8ffa 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,13 @@ spring: - datasource: - url: jdbc:tc:postgresql:16-alpine:///mobility - username: test - password: test - driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver \ No newline at end of file + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + show-sql: false + defer-datasource-initialization: true + sql: + init: + mode: never \ No newline at end of file diff --git a/src/test/resources/init-postgis.sql b/src/test/resources/init-postgis.sql new file mode 100644 index 0000000..4fc7b20 --- /dev/null +++ b/src/test/resources/init-postgis.sql @@ -0,0 +1,2 @@ +-- PostGIS extension은 postgis/postgis 이미지에 이미 포함되어 있습니다 +-- 추가 초기화 스크립트가 필요한 경우 여기에 작성 From 8ab0473cc748f7e91c23b3feca7376547efd4cf2 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:13:41 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[#67]=20feat:=20=EA=B8=B0=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=EB=B0=B0=EC=B0=A8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/CompletedDispatchDetailRes.java | 120 ++++++++++++++++++ .../CompletedDispatchListItemRes.java | 36 ++++++ .../repository/DispatchRepository.java | 21 +++ .../dispatch/service/DispatcherService.java | 80 ++++++++++++ .../controller/TransporterV1Controller.java | 103 +++++++++++++++ 5 files changed, 360 insertions(+) create mode 100644 src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchDetailRes.java create mode 100644 src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchListItemRes.java diff --git a/src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchDetailRes.java b/src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchDetailRes.java new file mode 100644 index 0000000..c2399dc --- /dev/null +++ b/src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchDetailRes.java @@ -0,0 +1,120 @@ +package com.mobility.api.domain.dispatch.dto.response; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.enums.PaymentType; +import com.mobility.api.domain.dispatch.enums.ServiceType; +import com.mobility.api.domain.dispatch.enums.TollType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Builder +@Schema(description = "완료된 배차 상세 정보 응답") +public record CompletedDispatchDetailRes( + @Schema(description = "배차 ID", example = "1") + Long id, + + @Schema(description = "사무실 이름", example = "태) (주)대리GO") + String officeName, + + @Schema(description = "사무실 전화번호", example = "16887141") + String officeTelNumber, + + @Schema(description = "출발지", example = "부안상서면부장1길 23") + String startLocation, + + @Schema(description = "목적지", example = "수원평동, 임광모터스") + String destinationLocation, + + @Schema(description = "요금", example = "110000") + Integer charge, + + @Schema(description = "서비스 타입 (DELIVERY: 탁송, DRIVER: 대리)", example = "DELIVERY") + String serviceType, + + @Schema(description = "배차 번호", example = "2025-0001") + String dispatchNumber, + + @Schema(description = "배차 생성 시간", example = "2025-11-22T11:43:00") + LocalDateTime createdAt, + + @Schema(description = "배차 할당 시간", example = "2025-11-22T11:48:00") + LocalDateTime assignedAt, + + @Schema(description = "배차 완료 시간", example = "2025-11-22T15:19:00") + LocalDateTime completedAt, + + @Schema(description = "차량 타입", example = "null", nullable = true) + String carType, + + @Schema(description = "차량 번호", example = "null", nullable = true) + String carNumber, + + @Schema(description = "태그 목록 (결제 방식, 톨게이트 방식 등)", example = "[\"현금\", \"톨포\"]") + List tags +) { + public static CompletedDispatchDetailRes from(Dispatch dispatch, String officeName, String officeTelNumber) { + return CompletedDispatchDetailRes.builder() + .id(dispatch.getId()) + .officeName(officeName) + .officeTelNumber(officeTelNumber) + .startLocation(dispatch.getStartLocation()) + .destinationLocation(dispatch.getDestinationLocation()) + .charge(dispatch.getCharge()) + .serviceType(dispatch.getService() != null ? dispatch.getService().name() : null) + .dispatchNumber(dispatch.getDispatchNumber()) + .createdAt(dispatch.getCreatedAt()) + .assignedAt(dispatch.getAssignedAt()) + .completedAt(dispatch.getCompletedAt()) + .carType(null) // TODO: 차량 타입 필드 추가 시 매핑 + .carNumber(null) // TODO: 차량 번호 필드 추가 시 매핑 + .tags(buildTags(dispatch)) + .build(); + } + + /** + * 배차 정보로부터 태그 목록 생성 + * - 결제 방식 (현금, 후불, 완후) + * - 톨게이트 방식 (톨포, 톨별, 하이패스) + */ + private static List buildTags(Dispatch dispatch) { + List tags = new ArrayList<>(); + + // 결제 방식 태그 + if (dispatch.getPaymentType() != null) { + tags.add(getPaymentTypeLabel(dispatch.getPaymentType())); + } + + // 톨게이트 방식 태그 + if (dispatch.getTollType() != null) { + tags.add(getTollTypeLabel(dispatch.getTollType())); + } + + return tags; + } + + /** + * PaymentType enum을 한글 라벨로 변환 + */ + private static String getPaymentTypeLabel(PaymentType paymentType) { + return switch (paymentType) { + case CASH -> "현금"; + case POSTPAID -> "후불"; + case COMPLETE_POSTPAID -> "완후"; + }; + } + + /** + * TollType enum을 한글 라벨로 변환 + */ + private static String getTollTypeLabel(TollType tollType) { + return switch (tollType) { + case TOLLGATE_INCLUDED -> "톨포"; + case TOLLGATE_SEPARATE -> "톨별"; + case HIPASS -> "하이패스"; + }; + } +} diff --git a/src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchListItemRes.java b/src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchListItemRes.java new file mode 100644 index 0000000..b2e944f --- /dev/null +++ b/src/main/java/com/mobility/api/domain/dispatch/dto/response/CompletedDispatchListItemRes.java @@ -0,0 +1,36 @@ +package com.mobility.api.domain.dispatch.dto.response; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@Schema(description = "완료된 배차 목록 항목 응답") +public record CompletedDispatchListItemRes( + @Schema(description = "배차 ID", example = "1") + Long id, + + @Schema(description = "배차 할당 시간", example = "2025-11-22T14:15:00") + LocalDateTime assignedAt, + + @Schema(description = "출발지", example = "부안상서면부장1길 23") + String startLocation, + + @Schema(description = "목적지", example = "수원평동, 임광모터스") + String destinationLocation, + + @Schema(description = "요금", example = "110000") + Integer charge +) { + public static CompletedDispatchListItemRes from(Dispatch dispatch) { + return CompletedDispatchListItemRes.builder() + .id(dispatch.getId()) + .assignedAt(dispatch.getAssignedAt()) + .startLocation(dispatch.getStartLocation()) + .destinationLocation(dispatch.getDestinationLocation()) + .charge(dispatch.getCharge()) + .build(); + } +} diff --git a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java index 62d4502..76a969c 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java +++ b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java @@ -115,4 +115,25 @@ Optional findFirstByTransporterIdAndStatusOrderByAssignedAtDesc( @Param("transporterId") Long transporterId, @Param("status") StatusType status ); + + /** + * 특정 기사의 기간별 완료된 배차 조회 + * @param transporterId 기사 ID + * @param fromDate 시작일 (00:00:00) + * @param toDate 종료일 (23:59:59) + * @return 완료된 배차 리스트 (assignedAt 최신순) + */ + @Query(""" + SELECT d FROM Dispatch d + WHERE d.transporter.id = :transporterId + AND d.status = 'COMPLETED' + AND d.completedAt >= :fromDate + AND d.completedAt <= :toDate + ORDER BY d.assignedAt DESC + """) + List findCompletedDispatchesByTransporterIdAndDateRange( + @Param("transporterId") Long transporterId, + @Param("fromDate") java.time.LocalDateTime fromDate, + @Param("toDate") java.time.LocalDateTime toDate + ); } diff --git a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java index cc1d6ce..963d4e6 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java +++ b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java @@ -1,6 +1,8 @@ package com.mobility.api.domain.dispatch.service; import com.mobility.api.domain.dispatch.dto.DispatchDistanceProjection; +import com.mobility.api.domain.dispatch.dto.response.CompletedDispatchDetailRes; +import com.mobility.api.domain.dispatch.dto.response.CompletedDispatchListItemRes; import com.mobility.api.domain.dispatch.dto.response.CurrentDispatchDetailRes; import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes; import com.mobility.api.domain.dispatch.dto.response.DispatchDetailRes; @@ -22,6 +24,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -205,6 +210,81 @@ public CurrentDispatchDetailRes getCurrentDispatch(Long transporterId) { return CurrentDispatchDetailRes.from(dispatch, officeTelNumber); } + /** + * 완료된 배차 목록 조회 (기간별) + * @param transporterId 기사 ID + * @param fromDate 시작일 (YYYY-MM-DD) + * @param toDate 종료일 (YYYY-MM-DD) + * @return 완료된 배차 목록 (assignedAt 최신순) + */ + public List getCompletedDispatchList( + Long transporterId, + LocalDate fromDate, + LocalDate toDate + ) { + // 1. 기사 정보 조회 + Transporter transporter = transporterRepository.findById(transporterId) + .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + + // 2. LocalDate를 LocalDateTime으로 변환 (시작일 00:00:00 ~ 종료일 23:59:59) + LocalDateTime fromDateTime = fromDate.atStartOfDay(); + LocalDateTime toDateTime = toDate.atTime(LocalTime.MAX); + + // 3. 완료된 배차 조회 + List completedDispatches = dispatchRepository + .findCompletedDispatchesByTransporterIdAndDateRange( + transporterId, + fromDateTime, + toDateTime + ); + + // 4. DTO 변환 + return completedDispatches.stream() + .map(CompletedDispatchListItemRes::from) + .collect(Collectors.toList()); + } + + /** + * 완료된 배차 상세 조회 + * @param transporterId 기사 ID + * @param dispatchId 배차 ID + * @return 완료된 배차 상세 정보 + */ + public CompletedDispatchDetailRes getCompletedDispatchDetail(Long transporterId, Long dispatchId) { + // 1. 기사 정보 조회 + Transporter transporter = transporterRepository.findById(transporterId) + .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + + // 2. 배차 정보 조회 + Dispatch dispatch = dispatchRepository.findById(dispatchId) + .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_DISPATCH)); + + // 3. 해당 배차가 해당 기사의 배차인지 확인 + if (!dispatch.getTransporter().getId().equals(transporterId)) { + throw new GlobalException(ResultCode.FORBIDDEN); + } + + // 4. 완료 상태인지 확인 + if (dispatch.getStatus() != StatusType.COMPLETED) { + throw new GlobalException(ResultCode.INVALID_INPUT); + } + + // 5. 사무실 정보 조회 + String officeName = null; + String officeTelNumber = null; + if (dispatch.getOfficeId() != null) { + Office office = officeRepository.findById(dispatch.getOfficeId()) + .orElse(null); + if (office != null) { + officeName = office.getOfficeName(); + officeTelNumber = office.getOfficeTelNumber(); + } + } + + // 6. DTO 변환 및 반환 + return CompletedDispatchDetailRes.from(dispatch, officeName, officeTelNumber); + } + /** * PostGIS를 사용하여 두 지점 간 거리 계산 (km 단위) * @param startLat 출발지 위도 diff --git a/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java b/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java index 0aa0eb1..110209f 100644 --- a/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java +++ b/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java @@ -1,5 +1,7 @@ package com.mobility.api.domain.transporter.controller; +import com.mobility.api.domain.dispatch.dto.response.CompletedDispatchDetailRes; +import com.mobility.api.domain.dispatch.dto.response.CompletedDispatchListItemRes; import com.mobility.api.domain.dispatch.dto.response.CurrentDispatchDetailRes; import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes; import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes; @@ -19,8 +21,10 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -181,4 +185,103 @@ public CommonResponse> getDispatchList( List dispatchList = dispatcherService.getDispatchListByDistance(transporterId, statusTypes); return CommonResponse.success(dispatchList); } + + /** + * 완료된 배차 목록 조회 (기간별) + */ + @Operation( + summary = "완료된 배차 목록 조회 (기간별)", + description = """ + 현재 로그인한 기사가 완료한 배차 목록을 기간별로 조회합니다. + + - fromDate ~ toDate 기간 동안 완료(COMPLETED)된 배차만 조회합니다. + - 배차 할당 시간(assignedAt) 기준 최신순으로 정렬됩니다. + - 날짜 형식: YYYY-MM-DD + + 예시: /api/v1/transporter/completed-dispatches?fromDate=2025-11-01&toDate=2025-11-30 + """ + ) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "완료된 배차 목록 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "기사 정보를 찾을 수 없음" + ) + }) + @GetMapping("/completed-dispatches") + public CommonResponse> getCompletedDispatches( + @io.swagger.v3.oas.annotations.Parameter(hidden = true) + @CurrentUser Transporter transporter, + @io.swagger.v3.oas.annotations.Parameter( + description = "조회 시작일 (YYYY-MM-DD)", + example = "2025-11-01", + required = true + ) + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate fromDate, + @io.swagger.v3.oas.annotations.Parameter( + description = "조회 종료일 (YYYY-MM-DD)", + example = "2025-11-30", + required = true + ) + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate toDate + ) { + Long transporterId = getValidatedTransporterId(transporter); + + List completedDispatches = + dispatcherService.getCompletedDispatchList(transporterId, fromDate, toDate); + + return CommonResponse.success(completedDispatches); + } + + /** + * 완료된 배차 상세 조회 + */ + @Operation( + summary = "완료된 배차 상세 조회", + description = """ + 완료된 배차의 상세 정보를 조회합니다. + + - 현재 로그인한 기사가 완료한 배차만 조회 가능합니다. + - 다른 기사의 배차 또는 완료되지 않은 배차는 조회할 수 없습니다. + - 사무실 정보(이름, 전화번호), 배차 상세 정보, 태그 목록을 포함합니다. + - 태그: 결제 방식(현금, 후불, 완후), 톨게이트 방식(톨포, 톨별, 하이패스) + + 예시: /api/v1/transporter/completed-dispatches/1 + """ + ) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "완료된 배차 상세 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "다른 기사의 배차에 대한 권한 없음" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "배차 정보를 찾을 수 없음" + ) + }) + @GetMapping("/completed-dispatches/{dispatchId}") + public CommonResponse getCompletedDispatchDetail( + @io.swagger.v3.oas.annotations.Parameter(hidden = true) + @CurrentUser Transporter transporter, + @io.swagger.v3.oas.annotations.Parameter( + description = "배차 ID", + example = "1", + required = true + ) + @PathVariable Long dispatchId + ) { + Long transporterId = getValidatedTransporterId(transporter); + + CompletedDispatchDetailRes completedDispatchDetail = + dispatcherService.getCompletedDispatchDetail(transporterId, dispatchId); + + return CommonResponse.success(completedDispatchDetail); + } } From d2110d4dd67ef7b7535d5a287c1094a25612e15b Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:11:50 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[#67]=20fix:=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=B0=EC=B0=A8=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20NullPointerException=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dispatch/repository/DispatchRepository.java | 8 ++++++++ .../api/domain/dispatch/service/DispatcherService.java | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java index 76a969c..bea003e 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java +++ b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java @@ -136,4 +136,12 @@ List findCompletedDispatchesByTransporterIdAndDateRange( @Param("fromDate") java.time.LocalDateTime fromDate, @Param("toDate") java.time.LocalDateTime toDate ); + + /** + * 배차 상세 조회 (Transporter와 Fetch Join) + * @param dispatchId 배차 ID + * @return 배차 정보 (Transporter 포함) + */ + @Query("SELECT d FROM Dispatch d LEFT JOIN FETCH d.transporter WHERE d.id = :dispatchId") + Optional findByIdWithTransporter(@Param("dispatchId") Long dispatchId); } diff --git a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java index 963d4e6..8a09153 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java +++ b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java @@ -255,12 +255,12 @@ public CompletedDispatchDetailRes getCompletedDispatchDetail(Long transporterId, Transporter transporter = transporterRepository.findById(transporterId) .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); - // 2. 배차 정보 조회 - Dispatch dispatch = dispatchRepository.findById(dispatchId) + // 2. 배차 정보 조회 (Transporter와 Fetch Join) + Dispatch dispatch = dispatchRepository.findByIdWithTransporter(dispatchId) .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_DISPATCH)); // 3. 해당 배차가 해당 기사의 배차인지 확인 - if (!dispatch.getTransporter().getId().equals(transporterId)) { + if (dispatch.getTransporter() == null || !dispatch.getTransporter().getId().equals(transporterId)) { throw new GlobalException(ResultCode.FORBIDDEN); } From 398fd16ce061815e51cc065230065d8454ac5c15 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:16:22 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[#67]=20test:=20=EB=B0=B0=EC=B0=A8=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DispatchConcurrencyTest.java | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java diff --git a/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java new file mode 100644 index 0000000..d47c94f --- /dev/null +++ b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java @@ -0,0 +1,317 @@ +package com.mobility.api.domain.dispatch.service; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.enums.*; +import com.mobility.api.domain.dispatch.repository.DispatchRepository; +import com.mobility.api.domain.office.entity.Office; +import com.mobility.api.domain.office.repository.OfficeRepository; +import com.mobility.api.domain.transporter.DispatchStatus; +import com.mobility.api.domain.transporter.entity.Transporter; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +import com.mobility.api.global.exception.GlobalException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("배차 할당 동시성 테스트") +class DispatchConcurrencyTest { + + private static final Logger log = LoggerFactory.getLogger(DispatchConcurrencyTest.class); + + @Autowired + private DispatcherService dispatcherService; + + @Autowired + private DispatchRepository dispatchRepository; + + @Autowired + private TransporterRepository transporterRepository; + + @Autowired + private OfficeRepository officeRepository; + + private Office testOffice; + private Dispatch testDispatch; + private List testTransporters; + + @BeforeEach + @Transactional + void setUp() { + // 기존 데이터 정리 + dispatchRepository.deleteAll(); + transporterRepository.deleteAll(); + officeRepository.deleteAll(); + + // 테스트용 사무실 생성 + testOffice = Office.builder() + .officeName("테스트 사무실") + .officeRegistrationNumber("123-45-67890") + .officeAddress("서울시 강남구") + .officeTelNumber("02-1234-5678") + .build(); + testOffice = officeRepository.save(testOffice); + + // 테스트용 배차 생성 (OPEN 상태) + testDispatch = Dispatch.builder() + .dispatchNumber("TEST-0001") + .startLocation("서울시 강남구") + .startLatitude(37.5012) + .startLongitude(127.0396) + .destinationLocation("부산시 해운대구") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(150000) + .clientPhoneNumber("010-1234-5678") + .status(StatusType.OPEN) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .paymentType(PaymentType.CASH) + .tollType(TollType.HIPASS) + .active(true) + .officeId(testOffice.getId()) + .build(); + testDispatch = dispatchRepository.save(testDispatch); + + // 테스트용 기사 10명 생성 + testTransporters = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + Transporter transporter = Transporter.builder() + .name("기사" + i) + .phone("010-0000-" + String.format("%04d", i)) + .isAutoDispatch(true) + .build(); + testTransporters.add(transporterRepository.save(transporter)); + } + } + + @Test + @DisplayName("동시에 10명의 기사가 같은 배차를 할당받으려 할 때 1명만 성공해야 함") + void concurrentDispatchAssignment_OnlyOneSuccess() throws InterruptedException { + // given + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + List exceptions = new CopyOnWriteArrayList<>(); + + // when: 10명의 기사가 동시에 배차 할당 시도 + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + Long transporterId = testTransporters.get(index).getId(); + dispatcherService.assignDispatch(testDispatch.getId(), transporterId); + successCount.incrementAndGet(); + log.info("✅ 기사 {} 배차 할당 성공", index + 1); + } catch (GlobalException e) { + failCount.incrementAndGet(); + exceptions.add(e); + log.warn("❌ 기사 {} 배차 할당 실패: {}", index + 1, e.getMessage()); + } catch (Exception e) { + failCount.incrementAndGet(); + exceptions.add(e); + log.error("⚠️ 기사 {} 예외 발생: {}", index + 1, e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executorService.shutdown(); + + // then + log.info("========== 동시성 테스트 결과 =========="); + log.info("성공: {}건, 실패: {}건", successCount.get(), failCount.get()); + log.info("예외 목록: {}", exceptions.stream().map(e -> e.getClass().getSimpleName() + ": " + e.getMessage()).toList()); + + // 정확히 1명만 성공해야 함 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(9); + + // DB에서 배차 상태 확인 (Transporter와 함께 조회) + Dispatch updatedDispatch = dispatchRepository.findByIdWithTransporter(testDispatch.getId()).orElseThrow(); + assertThat(updatedDispatch.getStatus()).isEqualTo(StatusType.ASSIGNED); + assertThat(updatedDispatch.getTransporter()).isNotNull(); + + log.info("배차 ID: {}, 할당된 기사: {}, 상태: {}", + updatedDispatch.getId(), + updatedDispatch.getTransporter() != null ? updatedDispatch.getTransporter().getName() : "없음", + updatedDispatch.getStatus()); + } + + @Test + @DisplayName("동시에 여러 기사가 다른 배차를 할당받으면 모두 성공해야 함") + void concurrentDispatchAssignment_DifferentDispatches_AllSuccess() throws InterruptedException { + // given: 추가 배차 9개 생성 (총 10개) + List dispatches = new ArrayList<>(); + dispatches.add(testDispatch); + for (int i = 2; i <= 10; i++) { + Dispatch dispatch = Dispatch.builder() + .dispatchNumber("TEST-" + String.format("%04d", i)) + .startLocation("서울시 강남구") + .startLatitude(37.5012) + .startLongitude(127.0396) + .destinationLocation("부산시 해운대구") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(150000) + .clientPhoneNumber("010-1234-5678") + .status(StatusType.OPEN) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .paymentType(PaymentType.CASH) + .tollType(TollType.HIPASS) + .active(true) + .officeId(testOffice.getId()) + .build(); + dispatches.add(dispatchRepository.save(dispatch)); + } + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // when: 10명의 기사가 각각 다른 배차를 동시에 할당받으려 시도 + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + Long transporterId = testTransporters.get(index).getId(); + Long dispatchId = dispatches.get(index).getId(); + dispatcherService.assignDispatch(dispatchId, transporterId); + successCount.incrementAndGet(); + log.info("✅ 기사 {} 배차 {} 할당 성공", index + 1, index + 1); + } catch (Exception e) { + failCount.incrementAndGet(); + log.error("❌ 기사 {} 배차 할당 실패: {}", index + 1, e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executorService.shutdown(); + + // then: 모두 성공해야 함 + log.info("========== 다중 배차 동시성 테스트 결과 =========="); + log.info("성공: {}건, 실패: {}건", successCount.get(), failCount.get()); + + assertThat(successCount.get()).isEqualTo(10); + assertThat(failCount.get()).isEqualTo(0); + + // 모든 배차가 ASSIGNED 상태인지 확인 + long assignedCount = dispatchRepository.findAll().stream() + .filter(d -> d.getStatus() == StatusType.ASSIGNED) + .count(); + assertThat(assignedCount).isEqualTo(10); + } + + @Test + @DisplayName("이미 배차중인 기사가 다른 배차를 할당받으려 하면 실패해야 함") + void assignDispatch_TransporterAlreadyDispatched_ShouldFail() throws InterruptedException { + // given: 첫 번째 배차를 기사 1에게 할당 + Transporter transporter1 = testTransporters.get(0); + dispatcherService.assignDispatch(testDispatch.getId(), transporter1.getId()); + + // 두 번째 배차 생성 + Dispatch secondDispatch = Dispatch.builder() + .dispatchNumber("TEST-0002") + .startLocation("서울시 강남구") + .startLatitude(37.5012) + .startLongitude(127.0396) + .destinationLocation("부산시 해운대구") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(180000) + .clientPhoneNumber("010-1234-5678") + .status(StatusType.OPEN) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .paymentType(PaymentType.CASH) + .tollType(TollType.HIPASS) + .active(true) + .officeId(testOffice.getId()) + .build(); + secondDispatch = dispatchRepository.save(secondDispatch); + + // when & then: 이미 배차중인 기사가 두 번째 배차를 할당받으려 하면 실패 + Long secondDispatchId = secondDispatch.getId(); + try { + dispatcherService.assignDispatch(secondDispatchId, transporter1.getId()); + assertThat(false).isTrue(); // 여기 도달하면 안 됨 + } catch (GlobalException e) { + log.info("✅ 예상대로 실패: {}", e.getMessage()); + assertThat(e.getResultCode().getCode()).isEqualTo(3004); // TRANSPORTER_ALREADY_DISPATCHED + } + + // 두 번째 배차는 여전히 OPEN 상태여야 함 + Dispatch updatedSecondDispatch = dispatchRepository.findById(secondDispatchId).orElseThrow(); + assertThat(updatedSecondDispatch.getStatus()).isEqualTo(StatusType.OPEN); + assertThat(updatedSecondDispatch.getTransporter()).isNull(); + } + + @Test + @DisplayName("비관적 락 타임아웃 테스트 - 3초 이상 대기 시 예외 발생") + void pessimisticLock_Timeout_ShouldThrowException() throws InterruptedException { + // given + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch firstThreadLatch = new CountDownLatch(1); + AtomicInteger exceptionCount = new AtomicInteger(0); + + // when: 첫 번째 스레드가 락을 획득하고 5초간 대기 + executorService.submit(() -> { + try { + startLatch.await(); + dispatcherService.assignDispatch(testDispatch.getId(), testTransporters.get(0).getId()); + Thread.sleep(5000); // 5초간 트랜잭션 유지 + firstThreadLatch.countDown(); + } catch (Exception e) { + log.error("첫 번째 스레드 예외: {}", e.getMessage()); + } + }); + + // 두 번째 스레드가 같은 배차에 대해 락 획득 시도 (타임아웃 예상) + executorService.submit(() -> { + try { + startLatch.await(); + Thread.sleep(100); // 첫 번째 스레드가 락을 먼저 획득하도록 약간 대기 + dispatcherService.assignDispatch(testDispatch.getId(), testTransporters.get(1).getId()); + } catch (Exception e) { + exceptionCount.incrementAndGet(); + log.info("⏱️ 예상대로 타임아웃 예외 발생: {}", e.getMessage()); + } + }); + + startLatch.countDown(); // 두 스레드 동시 시작 + firstThreadLatch.await(10, TimeUnit.SECONDS); + executorService.shutdown(); + executorService.awaitTermination(15, TimeUnit.SECONDS); + + // then: 두 번째 스레드는 타임아웃으로 실패해야 함 + log.info("타임아웃 예외 발생 횟수: {}", exceptionCount.get()); + assertThat(exceptionCount.get()).isGreaterThan(0); + } +} From 71dede26483a5f8c7fd8a6f293622407e3deef1a Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:23:53 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[#67]=20test:=20=ED=95=9C=20=EB=AA=85?= =?UTF-8?q?=EC=9D=B4=20=ED=95=98=EB=82=98=EC=9D=98=20=EB=B0=B0=EC=B0=A8?= =?UTF-8?q?=EB=A7=8C=20=EA=B0=80=EC=A7=80=EA=B3=A0=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DispatchOfferRepository.java | 13 + .../service/DispatchOfferAcceptanceTest.java | 362 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java diff --git a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchOfferRepository.java b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchOfferRepository.java index 785dd27..ad21efe 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchOfferRepository.java +++ b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchOfferRepository.java @@ -36,4 +36,17 @@ public interface DispatchOfferRepository extends JpaRepository findAcceptedOffersByTransporter(@Param("transporterId") Long transporterId); + + /** + * 기사의 ACCEPTED 상태 제안 개수 조회 + */ + @Query("SELECT COUNT(o) FROM DispatchOffer o WHERE o.transporter.id = :transporterId AND o.status = 'ACCEPTED'") + int countAcceptedOffersByTransporter(@Param("transporterId") Long transporterId); } diff --git a/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java new file mode 100644 index 0000000..ecc54ea --- /dev/null +++ b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java @@ -0,0 +1,362 @@ +package com.mobility.api.domain.dispatch.service; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.entity.DispatchOffer; +import com.mobility.api.domain.dispatch.enums.*; +import com.mobility.api.domain.dispatch.repository.DispatchOfferRepository; +import com.mobility.api.domain.dispatch.repository.DispatchRepository; +import com.mobility.api.domain.office.entity.Office; +import com.mobility.api.domain.office.repository.OfficeRepository; +import com.mobility.api.domain.transporter.entity.Transporter; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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 org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("배차 제안 수락 테스트 - 한 기사는 하나의 배차만 ACCEPTED 가능") +class DispatchOfferAcceptanceTest { + + private static final Logger log = LoggerFactory.getLogger(DispatchOfferAcceptanceTest.class); + + @Autowired + private DispatchOfferRepository dispatchOfferRepository; + + @Autowired + private DispatchRepository dispatchRepository; + + @Autowired + private TransporterRepository transporterRepository; + + @Autowired + private OfficeRepository officeRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + private Office testOffice; + private Transporter testTransporter; + private List testDispatches; + private List testOffers; + + @BeforeEach + @Transactional + void setUp() { + // 기존 데이터 정리 + dispatchOfferRepository.deleteAll(); + dispatchRepository.deleteAll(); + transporterRepository.deleteAll(); + officeRepository.deleteAll(); + + // 테스트용 사무실 생성 + testOffice = Office.builder() + .officeName("테스트 사무실") + .officeRegistrationNumber("123-45-67890") + .officeAddress("서울시 강남구") + .officeTelNumber("02-1234-5678") + .build(); + testOffice = officeRepository.save(testOffice); + + // 테스트용 기사 생성 + testTransporter = Transporter.builder() + .name("김기사") + .phone("010-1234-5678") + .isAutoDispatch(true) + .build(); + testTransporter = transporterRepository.save(testTransporter); + + // 테스트용 배차 3개 생성 + testDispatches = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Dispatch dispatch = Dispatch.builder() + .dispatchNumber("TEST-" + String.format("%04d", i)) + .startLocation("서울시 강남구") + .startLatitude(37.5012) + .startLongitude(127.0396) + .destinationLocation("부산시 해운대구") + .destinationLatitude(35.1587) + .destinationLongitude(129.1603) + .charge(150000 + (i * 10000)) + .clientPhoneNumber("010-1234-5678") + .status(StatusType.HOLD) + .call(CallType.INTERNAL) + .service(ServiceType.DELIVERY) + .paymentType(PaymentType.CASH) + .tollType(TollType.HIPASS) + .active(true) + .officeId(testOffice.getId()) + .build(); + testDispatches.add(dispatchRepository.save(dispatch)); + } + + // 테스트용 배차 제안 3개 생성 (모두 같은 기사에게) + testOffers = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + DispatchOffer offer = DispatchOffer.builder() + .dispatch(testDispatches.get(i)) + .transporter(testTransporter) + .status(OfferStatus.PENDING) + .sequence(i + 1) + .build(); + testOffers.add(dispatchOfferRepository.save(offer)); + } + } + + @Test + @DisplayName("한 기사가 첫 번째 제안을 수락하면 ACCEPTED 상태가 되어야 함") + @Transactional + void acceptFirstOffer_ShouldBeAccepted() { + // given + DispatchOffer firstOffer = testOffers.get(0); + + // when + firstOffer.accept(); + dispatchOfferRepository.save(firstOffer); + dispatchOfferRepository.flush(); + + // then + List acceptedOffers = dispatchOfferRepository.findAcceptedOffersByTransporter(testTransporter.getId()); + assertThat(acceptedOffers).hasSize(1); + assertThat(acceptedOffers.get(0).getId()).isEqualTo(firstOffer.getId()); + assertThat(acceptedOffers.get(0).getStatus()).isEqualTo(OfferStatus.ACCEPTED); + + log.info("✅ 첫 번째 제안 수락 성공: 배차번호={}", acceptedOffers.get(0).getDispatch().getDispatchNumber()); + } + + @Test + @DisplayName("한 기사가 이미 ACCEPTED한 제안이 있으면 두 번째 제안은 수락할 수 없어야 함") + @Transactional + void acceptSecondOffer_WhenAlreadyAccepted_ShouldFail() { + // given: 첫 번째 제안을 먼저 수락 + DispatchOffer firstOffer = testOffers.get(0); + firstOffer.accept(); + dispatchOfferRepository.save(firstOffer); + dispatchOfferRepository.flush(); + + // when: 두 번째 제안도 수락 시도 + DispatchOffer secondOffer = testOffers.get(1); + + // 수락 전에 이미 ACCEPTED한 제안이 있는지 확인 + int acceptedCount = dispatchOfferRepository.countAcceptedOffersByTransporter(testTransporter.getId()); + + // then: 이미 ACCEPTED한 제안이 있으므로 두 번째 제안은 수락 불가 + assertThat(acceptedCount).isEqualTo(1); + + // 비즈니스 로직: 이미 수락한 제안이 있으면 새로운 수락 불가 + if (acceptedCount > 0) { + log.info("❌ 이미 수락한 제안이 있어서 두 번째 제안 수락 불가"); + // 실제 서비스에서는 예외를 던져야 함 + assertThat(true).isTrue(); // 검증 통과 + } else { + secondOffer.accept(); + dispatchOfferRepository.save(secondOffer); + } + + // 최종 확인: ACCEPTED 상태는 여전히 1개만 있어야 함 + List acceptedOffers = dispatchOfferRepository.findAcceptedOffersByTransporter(testTransporter.getId()); + assertThat(acceptedOffers).hasSize(1); + assertThat(acceptedOffers.get(0).getId()).isEqualTo(firstOffer.getId()); + } + + @Test + @DisplayName("동시에 여러 제안을 수락하려 해도 하나만 ACCEPTED 되어야 함 (동시성 테스트)") + void concurrentAcceptance_OnlyOneAccepted() throws InterruptedException { + // given + int threadCount = 3; // 3개의 제안을 동시에 수락 시도 + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // when: 3개의 제안을 동시에 수락 시도 + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + transactionTemplate.execute(status -> { + try { + // 비관적 락으로 제안 조회 + DispatchOffer offer = dispatchOfferRepository.findByIdWithLock(testOffers.get(index).getId()) + .orElseThrow(); + + // 이미 ACCEPTED한 제안이 있는지 확인 + int acceptedCount = dispatchOfferRepository.countAcceptedOffersByTransporter(testTransporter.getId()); + + if (acceptedCount == 0) { + // ACCEPTED한 제안이 없으면 수락 가능 + offer.accept(); + dispatchOfferRepository.saveAndFlush(offer); + successCount.incrementAndGet(); + log.info("✅ 제안 {} 수락 성공", index + 1); + } else { + // 이미 ACCEPTED한 제안이 있으면 실패 + failCount.incrementAndGet(); + log.info("❌ 제안 {} 수락 실패 (이미 수락한 제안 존재)", index + 1); + } + } catch (Exception e) { + failCount.incrementAndGet(); + log.error("⚠️ 제안 {} 예외 발생: {}", index + 1, e.getMessage()); + } + return null; + }); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executorService.shutdown(); + + // then + log.info("========== 동시성 테스트 결과 =========="); + log.info("성공: {}건, 실패: {}건", successCount.get(), failCount.get()); + + // 정확히 1개만 성공해야 함 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(2); + + // DB에서 ACCEPTED 상태 확인 (트랜잭션 내에서) + transactionTemplate.execute(status -> { + List acceptedOffers = dispatchOfferRepository.findAcceptedOffersByTransporter(testTransporter.getId()); + assertThat(acceptedOffers).hasSize(1); + assertThat(acceptedOffers.get(0).getStatus()).isEqualTo(OfferStatus.ACCEPTED); + + log.info("최종 ACCEPTED 제안: 배차번호={}, 순번={}", + acceptedOffers.get(0).getDispatch().getDispatchNumber(), + acceptedOffers.get(0).getSequence()); + return null; + }); + } + + @Test + @DisplayName("여러 기사가 각자 다른 제안을 수락하면 모두 성공해야 함") + void multipleTransporters_AcceptDifferentOffers_AllSuccess() throws InterruptedException { + // given: 추가 기사 2명 생성 + List transporters = new ArrayList<>(); + transporters.add(testTransporter); + + for (int i = 2; i <= 3; i++) { + Transporter transporter = Transporter.builder() + .name("기사" + i) + .phone("010-0000-" + String.format("%04d", i)) + .isAutoDispatch(true) + .build(); + transporters.add(transporterRepository.save(transporter)); + } + + // 각 기사에게 제안 생성 (총 3개 배차 × 3명 기사 = 9개 제안) + List allOffers = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + DispatchOffer offer = DispatchOffer.builder() + .dispatch(testDispatches.get(i)) + .transporter(transporters.get(j)) + .status(OfferStatus.PENDING) + .sequence(j + 1) + .build(); + allOffers.add(dispatchOfferRepository.save(offer)); + } + } + + int threadCount = 3; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + + // when: 각 기사가 하나씩 제안을 수락 + for (int i = 0; i < threadCount; i++) { + final int transporterIndex = i; + executorService.submit(() -> { + try { + transactionTemplate.execute(status -> { + try { + // 해당 기사의 첫 번째 제안 조회 + Long transporterId = transporters.get(transporterIndex).getId(); + DispatchOffer offer = allOffers.stream() + .filter(o -> o.getTransporter().getId().equals(transporterId)) + .findFirst() + .orElseThrow(); + + // 비관적 락으로 조회 + DispatchOffer lockedOffer = dispatchOfferRepository.findByIdWithLock(offer.getId()) + .orElseThrow(); + + // 수락 + lockedOffer.accept(); + dispatchOfferRepository.saveAndFlush(lockedOffer); + successCount.incrementAndGet(); + log.info("✅ 기사 {} 제안 수락 성공", transporterIndex + 1); + } catch (Exception e) { + log.error("❌ 기사 {} 예외 발생: {}", transporterIndex + 1, e.getMessage()); + } + return null; + }); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executorService.shutdown(); + + // then: 3명 모두 성공해야 함 + log.info("========== 다중 기사 테스트 결과 =========="); + log.info("성공: {}건", successCount.get()); + + assertThat(successCount.get()).isEqualTo(3); + + // 각 기사마다 ACCEPTED 제안이 정확히 1개씩 있어야 함 (트랜잭션 내에서) + transactionTemplate.execute(status -> { + for (Transporter transporter : transporters) { + int acceptedCount = dispatchOfferRepository.countAcceptedOffersByTransporter(transporter.getId()); + assertThat(acceptedCount).isEqualTo(1); + log.info("기사 {}: ACCEPTED 제안 {}개", transporter.getName(), acceptedCount); + } + return null; + }); + } + + @Test + @DisplayName("REJECTED 또는 TIMEOUT 상태의 제안은 ACCEPTED 개수에 포함되지 않아야 함") + @Transactional + void rejectedOrTimeoutOffers_NotCountedAsAccepted() { + // given: 여러 상태의 제안 생성 + testOffers.get(0).accept(); // ACCEPTED + testOffers.get(1).reject(); // REJECTED + testOffers.get(2).timeout(); // TIMEOUT + + dispatchOfferRepository.saveAll(testOffers); + dispatchOfferRepository.flush(); + + // when + int acceptedCount = dispatchOfferRepository.countAcceptedOffersByTransporter(testTransporter.getId()); + List acceptedOffers = dispatchOfferRepository.findAcceptedOffersByTransporter(testTransporter.getId()); + + // then + assertThat(acceptedCount).isEqualTo(1); + assertThat(acceptedOffers).hasSize(1); + assertThat(acceptedOffers.get(0).getStatus()).isEqualTo(OfferStatus.ACCEPTED); + + log.info("✅ ACCEPTED: 1개, REJECTED: 1개, TIMEOUT: 1개"); + log.info("ACCEPTED 제안만 조회됨: 배차번호={}", acceptedOffers.get(0).getDispatch().getDispatchNumber()); + } +} From ed371cb1dee2858932efd9ea5743c38a6e611bca Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:09:48 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[#67]=20fix:=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=A9=EB=A6=AC=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20-=20TestContainers=20=EB=8F=84=EC=9E=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A1=9C=EC=BB=AC=20DB=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B3=B4=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DispatchConcurrencyTest.java | 23 +++++++++++++++++++ .../service/DispatchOfferAcceptanceTest.java | 23 +++++++++++++++++++ src/test/resources/application-test.yml | 12 +++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java index d47c94f..9781fbf 100644 --- a/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java +++ b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchConcurrencyTest.java @@ -17,7 +17,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; import java.util.ArrayList; import java.util.List; @@ -27,12 +33,29 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest +@Testcontainers @ActiveProfiles("test") @DisplayName("배차 할당 동시성 테스트") class DispatchConcurrencyTest { private static final Logger log = LoggerFactory.getLogger(DispatchConcurrencyTest.class); + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") + ) + .withDatabaseName("mobility_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + @Autowired private DispatcherService dispatcherService; diff --git a/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java index ecc54ea..a684962 100644 --- a/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java +++ b/src/test/java/com/mobility/api/domain/dispatch/service/DispatchOfferAcceptanceTest.java @@ -17,8 +17,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; import java.util.ArrayList; import java.util.List; @@ -28,12 +34,29 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest +@Testcontainers @ActiveProfiles("test") @DisplayName("배차 제안 수락 테스트 - 한 기사는 하나의 배차만 ACCEPTED 가능") class DispatchOfferAcceptanceTest { private static final Logger log = LoggerFactory.getLogger(DispatchOfferAcceptanceTest.class); + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") + ) + .withDatabaseName("mobility_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + @Autowired private DispatchOfferRepository dispatchOfferRepository; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 16a8ffa..841fe07 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,13 +1,23 @@ spring: + # ⚠️ 중요: 테스트 환경에서는 실제 DB에 연결되지 않도록 설정 + # TestContainers를 사용하는 테스트는 DynamicPropertySource로 덮어씀 + # 만약 실수로 TestContainers 없이 실행되면 에러가 발생하도록 의도적으로 잘못된 URL 설정 + datasource: + url: jdbc:postgresql://localhost:9999/test_should_not_connect + username: test_user_should_not_exist + password: test_password + driver-class-name: org.postgresql.Driver + jpa: hibernate: - ddl-auto: create-drop + ddl-auto: create-drop # 테스트용 DB는 매번 새로 생성 properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true show-sql: false defer-datasource-initialization: true + sql: init: mode: never \ No newline at end of file From d0edcb4af656c879b23b420c4149d32d779d0d83 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:40:16 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[#67]=20fix:=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20Tes?= =?UTF-8?q?tContainers=20=EC=A0=81=EC=9A=A9=20-=20=EB=A1=9C=EC=BB=AC=20DB?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=99=84=EC=A0=84=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/MobilityApiApplicationTests.java | 25 +++++++++++++++++++ src/test/resources/application-test.yml | 13 +++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/mobility/api/MobilityApiApplicationTests.java b/src/test/java/com/mobility/api/MobilityApiApplicationTests.java index 3318f07..b02fc56 100644 --- a/src/test/java/com/mobility/api/MobilityApiApplicationTests.java +++ b/src/test/java/com/mobility/api/MobilityApiApplicationTests.java @@ -2,10 +2,35 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; @SpringBootTest +@Testcontainers +@ActiveProfiles("test") class MobilityApiApplicationTests { + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") + ) + .withDatabaseName("mobility_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + @Test void contextLoads() { } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 841fe07..6cbcd6b 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,16 +1,9 @@ spring: - # ⚠️ 중요: 테스트 환경에서는 실제 DB에 연결되지 않도록 설정 - # TestContainers를 사용하는 테스트는 DynamicPropertySource로 덮어씀 - # 만약 실수로 TestContainers 없이 실행되면 에러가 발생하도록 의도적으로 잘못된 URL 설정 - datasource: - url: jdbc:postgresql://localhost:9999/test_should_not_connect - username: test_user_should_not_exist - password: test_password - driver-class-name: org.postgresql.Driver - + # ⚠️ 중요: 통합 테스트는 TestContainers 사용 권장 + # TestContainers 없는 테스트는 로컬 DB 사용 (테스트 데이터만 사용할 것) jpa: hibernate: - ddl-auto: create-drop # 테스트용 DB는 매번 새로 생성 + ddl-auto: create-drop properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect From 1bc6f2eb80ccbab528b8c06a1f418726d3dac803 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:29:01 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[#67]=20chore:=20CI=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A7=8C=20=EC=8B=A4=ED=96=89,=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=8A=94=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=A0=84=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 +++++-- src/test/resources/application-test.yml | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5186596..5dc0256 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,11 @@ jobs: - name: Gradle 권한 부여 run: chmod +x ./gradlew - - name: Gradle 빌드 실행 - run: ./gradlew clean build --stacktrace + - name: 컴파일 체크 + run: ./gradlew clean compileJava compileTestJava --stacktrace + + - name: 단위 테스트 실행 (통합 테스트 제외) + run: ./gradlew test --tests "*ServiceTest" --tests "*ControllerTest" --stacktrace - name: Gradle 캐시 설정 uses: actions/cache@v4 diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6cbcd6b..87e9ab8 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,4 @@ spring: - # ⚠️ 중요: 통합 테스트는 TestContainers 사용 권장 - # TestContainers 없는 테스트는 로컬 DB 사용 (테스트 데이터만 사용할 것) jpa: hibernate: ddl-auto: create-drop