diff --git a/.gitignore b/.gitignore index 28a2321..f63580f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ out/ /nbdist/ /.nb-gradle/ +src/main/resources/application.properties + ### VS Code ### .vscode/ diff --git a/build.gradle b/build.gradle index 0513397..906cb7d 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { // spring security & OAuth2 implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + testImplementation 'org.springframework.security:spring-security-test' // jwt implementation 'com.auth0:java-jwt:4.2.1' @@ -58,6 +59,9 @@ dependencies { implementation 'javax.xml.bind:jaxb-api:2.3.1' implementation 'org.glassfish.jaxb:jaxb-runtime:2.3.1' + //Kafka + implementation 'org.springframework.kafka:spring-kafka:3.0.7' + // //Querydsl 추가 // implementation 'com.querydsl:querydsl-core:5.0.0' // implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1d09a62 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,25 @@ +version: '3' +services: + zookeeper: + image: confluentinc/cp-zookeeper:latest + container_name: zookeeper + ports: + - "2181:2181" + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + + kafka: + image: confluentinc/cp-kafka:latest + container_name: kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + # 클러스터 내부와 외부에서 접근할 listener 설정 (내부: kafka:29092, 외부: localhost:9092) + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 diff --git a/src/main/java/com/readyvery/readyverydemo/domain/CeoInfo.java b/src/main/java/com/readyvery/readyverydemo/domain/CeoInfo.java index a6fbd54..cdc6691 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/CeoInfo.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/CeoInfo.java @@ -97,6 +97,7 @@ public class CeoInfo extends BaseTimeEntity { private LocalDateTime lastLoginDate; // 사장님 가게 연관관계 매핑 + @Builder.Default @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "store_idx") private Store store = null; diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Order.java b/src/main/java/com/readyvery/readyverydemo/domain/Order.java index ec953b4..8891e9d 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/Order.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/Order.java @@ -2,6 +2,9 @@ import java.time.LocalDateTime; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -111,6 +114,16 @@ public class Order extends BaseTimeEntity { @OneToOne(mappedBy = "order", fetch = FetchType.LAZY) private Receipt receipt; + @Builder.Default + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt = LocalDateTime.now(); + + @Builder.Default + @LastModifiedDate + @Column(name = "last_modified_at") + private LocalDateTime lastModifiedAt = LocalDateTime.now(); + public void completeOrder(Progress progress) { this.progress = progress; } diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Store.java b/src/main/java/com/readyvery/readyverydemo/domain/Store.java index 25b810c..7467cc9 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/Store.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/Store.java @@ -15,6 +15,7 @@ import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,6 +26,7 @@ @Table(name = "STORE") @AllArgsConstructor @Slf4j +@Builder public class Store extends BaseTimeEntity { @Id @@ -101,4 +103,5 @@ public class Store extends BaseTimeEntity { public void updateStatus(boolean status) { this.status = status; } + } diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java index 8217678..8dcd51c 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java @@ -7,10 +7,8 @@ import org.springframework.context.annotation.Configuration; import com.auth0.jwt.JWT; -import com.fasterxml.jackson.databind.ObjectMapper; import com.readyvery.readyverydemo.config.JwtConfig; import com.readyvery.readyverydemo.domain.Role; -import com.readyvery.readyverydemo.security.jwt.service.create.JwtTokenGenerator; import jakarta.servlet.http.HttpServletResponse; import lombok.Getter; @@ -23,9 +21,7 @@ @RequiredArgsConstructor public class JwtTokenizer { - private final JwtTokenGenerator tokenGenerator; private final TokenSendManager tokenSendManager; - private final ObjectMapper objectMapper; private final JwtConfig jwtConfig; public void addAccessTokenCookie(HttpServletResponse response, String accessToken) { diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java index ec956ea..3f743ce 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java @@ -5,7 +5,6 @@ import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; -import com.readyvery.readyverydemo.config.JwtConfig; import com.readyvery.readyverydemo.domain.Role; import com.readyvery.readyverydemo.security.jwt.dto.CeoLoginSuccessRes; @@ -18,7 +17,7 @@ @Component @Slf4j public class TokenSendManager { - private final JwtConfig jwtConfig; + private final ObjectMapper objectMapper; public void addTokenCookie(HttpServletResponse response, String name, String value, String path, int maxAge, diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/BrowserCapabilityService.java b/src/main/java/com/readyvery/readyverydemo/src/order/BrowserCapabilityService.java new file mode 100644 index 0000000..c1c4e7c --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/order/BrowserCapabilityService.java @@ -0,0 +1,116 @@ +package com.readyvery.readyverydemo.src.order; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; + +@Service +public class BrowserCapabilityService { + + private static final int MIN_CHROMIUM_VERSION_FOR_SSE = 90; + private static final int MIN_FIREFOX_VERSION_FOR_SSE = 6; + private static final int MIN_SAFARI_VERSION_FOR_SSE = 5; + + private static final Pattern CHROMIUM_CH_PATTERN = + Pattern.compile("(Chromium|Google Chrome)\"?;v=\"?(\\d+)"); + private static final Pattern FIREFOX_CH_PATTERN = + Pattern.compile("(Firefox)\"?;v=\"?(\\d+)"); + private static final Pattern SAFARI_CH_PATTERN = + Pattern.compile("(Safari)\"?;v=\"?(\\d+)"); + + private static final Pattern CHROME_UA_PATTERN = + Pattern.compile("Chrome/(\\d+)"); + + private static final Pattern FIREFOX_UA_PATTERN = + Pattern.compile("Firefox/(\\d+)"); + + private static final Pattern SAFARI_VERSION_UA_PATTERN = + Pattern.compile("Version/(\\d+).*Safari/"); + + /** + * Client Hints와 User-Agent 정보를 종합해서 SSE 지원 여부를 판별. + */ + public boolean isSseCapableBrowser(HttpServletRequest request) { + // 1) 우선 Sec-CH-UA (Client Hints)를 확인 + String secChUa = request.getHeader("Sec-CH-UA"); + if (secChUa != null) { + // Chromium/Chrome + Integer chromiumVersion = parseVersionFromCh(secChUa, CHROMIUM_CH_PATTERN); + if (chromiumVersion != null && chromiumVersion >= MIN_CHROMIUM_VERSION_FOR_SSE) { + return true; + } + + // Firefox + Integer firefoxVersion = parseVersionFromCh(secChUa, FIREFOX_CH_PATTERN); + if (firefoxVersion != null && firefoxVersion >= MIN_FIREFOX_VERSION_FOR_SSE) { + return true; + } + + // Safari + Integer safariVersion = parseVersionFromCh(secChUa, SAFARI_CH_PATTERN); + if (safariVersion != null && safariVersion >= MIN_SAFARI_VERSION_FOR_SSE) { + return true; + } + } + + // 2) Client Hints가 없거나 파싱 실패 → User-Agent로 폴백 + String userAgent = request.getHeader("User-Agent"); + if (userAgent == null) { + return false; + } + + // 구형 IE (Trident, MSIE) → SSE 미지원 + if (userAgent.contains("Trident") || userAgent.contains("MSIE")) { + return false; + } + + // Firefox 판별 + Matcher fxMatcher = FIREFOX_UA_PATTERN.matcher(userAgent); + if (fxMatcher.find()) { + int fxVersion = Integer.parseInt(fxMatcher.group(1)); + return fxVersion >= MIN_FIREFOX_VERSION_FOR_SSE; + } + + // Safari 판별 + // Safari UA 문자열엔 "Safari"가 포함되지만 + // Chrome 계열에도 "Safari" 문자열이 들어갈 수 있으므로 + // "Chrome/" 또는 "Chromium"이 있는지 먼저 확인이 필요 + if (userAgent.contains("Safari") && !userAgent.contains("Chrome")) { + Matcher safariMatcher = SAFARI_VERSION_UA_PATTERN.matcher(userAgent); + if (safariMatcher.find()) { + int safariVersion = Integer.parseInt(safariMatcher.group(1)); + return safariVersion >= MIN_SAFARI_VERSION_FOR_SSE; + } + // Safari 문자열은 있는데 Version/이 없으면 구버전이거나 별도 케이스 + // 일단 미지원으로 처리 + return false; + } + + // 2-4) Chromium/Chrome 판별 + Matcher chromeMatcher = CHROME_UA_PATTERN.matcher(userAgent); + if (chromeMatcher.find()) { + int chromeVersion = Integer.parseInt(chromeMatcher.group(1)); + return chromeVersion >= MIN_CHROMIUM_VERSION_FOR_SSE; + } + + // 3) 그 외 오페라, Edge(Chromium), 기타 브라우저 등은 별도 처리 가능 + return false; + } + + /** + * Sec-CH-UA 문자열에서 특정 브랜드(Chromium, Firefox, Safari 등) 버전을 찾는 메서드 + */ + private Integer parseVersionFromCh(String secChUa, Pattern pattern) { + Matcher matcher = pattern.matcher(secChUa); + if (matcher.find()) { + try { + return Integer.parseInt(matcher.group(2)); + } catch (NumberFormatException ignored) { + } + } + return null; + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/OrderControllerV2.java b/src/main/java/com/readyvery/readyverydemo/src/order/OrderControllerV2.java new file mode 100644 index 0000000..0149a59 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/order/OrderControllerV2.java @@ -0,0 +1,41 @@ +package com.readyvery.readyverydemo.src.order; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import com.readyvery.readyverydemo.domain.CeoInfo; +import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; +import com.readyvery.readyverydemo.src.ceo.CeoServiceFacade; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2") +public class OrderControllerV2 { + + private final StoreSseEmitterManager storeSseEmitterManager; + private final CeoServiceFacade ceoServiceFacade; + + @Operation(summary = "사장님 전용 주문 SSE", + description = "Kafka로 전달되는 주문 상태 변경을 실시간 수신 (사장님 권한 + 가게 소유권 검증)", + tags = "Order") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK") + }) + @PreAuthorize("hasRole('CEO')") + @GetMapping("/order/stream") + public SseEmitter streamOrderUpdates(@AuthenticationPrincipal CustomUserDetails userDetails) { + + CeoInfo ceoInfo = ceoServiceFacade.getCeoInfo(userDetails.getId()); + // 검증 완료 후, storeSseEmitterManager에 등록 + return storeSseEmitterManager.createEmitterForStore(ceoInfo.getStore().getId()); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/OrderKafkaConsumer.java b/src/main/java/com/readyvery/readyverydemo/src/order/OrderKafkaConsumer.java new file mode 100644 index 0000000..8086a8a --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/order/OrderKafkaConsumer.java @@ -0,0 +1,29 @@ +package com.readyvery.readyverydemo.src.order; + +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.domain.Progress; +import com.readyvery.readyverydemo.src.order.dto.OrderRegisterRes; +import com.readyvery.readyverydemo.src.store.dto.OrderUpdateMessage; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderKafkaConsumer { + + private final StoreSseEmitterManager storeSseEmitterManager; + private final OrderService orderServiceImpl; + + @KafkaListener(topics = "order_updates", groupId = "store_owner_group") + public void processOrderUpdate(OrderUpdateMessage message) { + log.info("[OrderKafkaConsumer] Received message for storeId: {}, orderId: {}, status: {}", + message.getStoreId(), message.getOrderId(), message.getStatus()); + + OrderRegisterRes orderList = orderServiceImpl.getOrdersV2(message.getStoreId(), Progress.INTEGRATION); + storeSseEmitterManager.sendEventToStore(message.getStoreId(), orderList); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/OrderService.java b/src/main/java/com/readyvery/readyverydemo/src/order/OrderService.java index cf66194..a6e3792 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/order/OrderService.java +++ b/src/main/java/com/readyvery/readyverydemo/src/order/OrderService.java @@ -10,6 +10,8 @@ public interface OrderService { OrderRegisterRes getOrders(Long id, Progress progress); + OrderRegisterRes getOrdersV2(Long id, Progress progress); + OrderStatusRes completeOrder(Long id, OrderStatusUpdateReq request); OrderStatusRes cancelOrder(Long id, OrderStatusUpdateReq request); diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java index 920667f..1857838 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java @@ -84,6 +84,29 @@ public OrderRegisterRes getOrders(Long id, Progress progress) { return orderMapper.orderToOrderRegisterRes(ceoInfo.getStore().getId(), orders, Collections.emptyList()); } + @Override + public OrderRegisterRes getOrdersV2(Long id, Progress progress) { + + if (progress == null) { + throw new BusinessLogicException(ExceptionCode.NOT_PROGRESS_ORDER); + } + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1).minusNanos(1); + + if (progress == Progress.INTEGRATION) { + List waitOrders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween( + Progress.ORDER, id, startOfDay, endOfDay); + List makeOrders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween( + Progress.MAKE, id, startOfDay, endOfDay); + + return orderMapper.orderToOrderRegisterRes(id, waitOrders, makeOrders); + } + + List orders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween( + progress, id, startOfDay, endOfDay); + return orderMapper.orderToOrderRegisterRes(id, orders, Collections.emptyList()); + } + @Override @Transactional public OrderStatusRes completeOrder(Long id, OrderStatusUpdateReq request) { @@ -325,7 +348,7 @@ private TosspaymentDto requestTossPaymentCancel(String paymentKey, String reject return restTemplate.postForObject(TossPaymentConfig.PAYMENT_URL + paymentKey + "/cancel", new HttpEntity<>(params, headers), TosspaymentDto.class); - } catch (HttpClientErrorException e) { + } catch (HttpClientErrorException e) { /* * 취소 실패 시, 이미 취소된 거래라면 결제 정보 조회 * 취소된 정보 재적용 diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/StoreSseEmitterManager.java b/src/main/java/com/readyvery/readyverydemo/src/order/StoreSseEmitterManager.java new file mode 100644 index 0000000..eaa142c --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/order/StoreSseEmitterManager.java @@ -0,0 +1,72 @@ +package com.readyvery.readyverydemo.src.order; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class StoreSseEmitterManager { + + // storeId -> SseEmitter 목록 + private final Map> storeEmitterMap = new ConcurrentHashMap<>(); + + /** + * 특정 가게에 대한 SSE Emitter를 생성하고 등록. + */ + public SseEmitter createEmitterForStore(Long storeId) { + // 타임아웃 60초(60000L) 예시 + SseEmitter emitter = new SseEmitter(60000L); + + // storeEmitterMap에서 storeId 키가 없으면 새 ArrayList 생성 후 emitter 추가 + storeEmitterMap.computeIfAbsent(storeId, id -> new CopyOnWriteArrayList<>()) + .add(emitter); + + // SSE 완료/타임아웃/에러 시 삭제 처리 + emitter.onCompletion(() -> removeEmitter(storeId, emitter)); + emitter.onTimeout(() -> removeEmitter(storeId, emitter)); + emitter.onError((ex) -> removeEmitter(storeId, emitter)); + + return emitter; + } + + private void removeEmitter(Long storeId, SseEmitter emitter) { + List emitterList = storeEmitterMap.get(storeId); + if (emitterList != null) { + emitterList.remove(emitter); + // 빈 리스트가 되면 맵에서 제거할 수도 있음 + if (emitterList.isEmpty()) { + storeEmitterMap.remove(storeId); + } + } + } + + /** + * 특정 가게(storeId)에만 이벤트를 전송 + */ + public void sendEventToStore(Long storeId, Object event) { + List emitterList = storeEmitterMap.get(storeId); + if (emitterList == null) { + return; // 해당 storeId로 구독 중인 Emitter가 없음 + } + + for (SseEmitter emitter : emitterList) { + try { + // 이벤트명 "orderUpdate"로 설정 + emitter.send(SseEmitter.event().name("orderUpdate").data(event)); + } catch (IOException e) { + log.warn("[StoreSseEmitterManager] sendEventToStore IOException: {}", e.getMessage()); + emitter.completeWithError(e); + emitterList.remove(emitter); + } + } + } +} + diff --git a/src/main/java/com/readyvery/readyverydemo/src/store/dto/OrderUpdateMessage.java b/src/main/java/com/readyvery/readyverydemo/src/store/dto/OrderUpdateMessage.java new file mode 100644 index 0000000..2990bbc --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/store/dto/OrderUpdateMessage.java @@ -0,0 +1,11 @@ +package com.readyvery.readyverydemo.src.store.dto; + +import lombok.Data; + +@Data +public class OrderUpdateMessage { + private String orderId; // 주문 식별자 + private Long storeId; // 어떤 가게의 주문인지 + private String status; // 주문 상태 (ORDER, MAKE, COMPLETE 등) + private String message; // 기타 메시지 +} diff --git a/src/test/java/com/readyvery/readyverydemo/ceoservice/rolechange/CeoMetaInfoRejectTest.java b/src/test/java/com/readyvery/readyverydemo/ceoservice/rolechange/CeoMetaInfoRejectTest.java index 755f249..95468ab 100644 --- a/src/test/java/com/readyvery/readyverydemo/ceoservice/rolechange/CeoMetaInfoRejectTest.java +++ b/src/test/java/com/readyvery/readyverydemo/ceoservice/rolechange/CeoMetaInfoRejectTest.java @@ -69,6 +69,7 @@ public void testEntryReject_SaveThrowsException() { ceoService.entryReject(id); }); assertEquals("Database error", exception.getMessage()); + } } diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderControllerTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderControllerTest.java new file mode 100644 index 0000000..d00b6db --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderControllerTest.java @@ -0,0 +1,4 @@ +package com.readyvery.readyverydemo.orderservice.completeorder; + +public class CompleteOrderControllerTest { +} diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderRepositoryTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderRepositoryTest.java new file mode 100644 index 0000000..bf9a935 --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderRepositoryTest.java @@ -0,0 +1,5 @@ +package com.readyvery.readyverydemo.orderservice.completeorder; + +public class CompleteOrderRepositoryTest { + +} diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderServiceTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderServiceTest.java new file mode 100644 index 0000000..7b716aa --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/completeorder/CompleteOrderServiceTest.java @@ -0,0 +1,8 @@ +package com.readyvery.readyverydemo.orderservice.completeorder; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class CompleteOrderServiceTest { +} diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/domain/OrderTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/domain/OrderTest.java new file mode 100644 index 0000000..c5ea41b --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/domain/OrderTest.java @@ -0,0 +1,63 @@ +package com.readyvery.readyverydemo.orderservice.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.readyvery.readyverydemo.domain.Cart; +import com.readyvery.readyverydemo.domain.Coupon; +import com.readyvery.readyverydemo.domain.Order; +import com.readyvery.readyverydemo.domain.Progress; +import com.readyvery.readyverydemo.domain.Store; +import com.readyvery.readyverydemo.domain.UserInfo; + +class OrderTest { + + @DisplayName("총 금액은 null이 아니어야 한다.") + @Test + void validateTotalAmount() { + Store store = Store.builder() + .name("testStore") + .address("testAddress") + .phone("testPhone") + .time("09:00-21:00") + .status(true) + .build(); + + UserInfo userInfo = UserInfo.builder() + .nickName("testUser") + .email("test@example.com") + .build(); + + Cart cart = Cart.builder().build(); + Coupon coupon = Coupon.builder().build(); + + Order order = Order.builder() + .amount(500L) + .paymentKey("testPaymentKey") + .orderId("testOrderId") + .orderName("testOrderName") + .totalAmount(100L) + .method("testMethod") + .orderNumber("testOrderNumber") + .progress(Progress.ORDER) + .payStatus(true) + .estimatedTime(LocalDateTime.now()) + .inOut(1L) + .message("testMessage") + .store(store) + .userInfo(userInfo) + .cart(cart) + .coupon(coupon) + .build(); + + assertThatCode(() -> { + if (order.getTotalAmount() == null) { + throw new AssertionError("Total amount should not be null"); + } + }).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/getorder/GetOrderControllerTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/order/GetOrderControllerTest.java similarity index 98% rename from src/test/java/com/readyvery/readyverydemo/orderservice/getorder/GetOrderControllerTest.java rename to src/test/java/com/readyvery/readyverydemo/orderservice/order/GetOrderControllerTest.java index 2e84e86..adfcc3d 100644 --- a/src/test/java/com/readyvery/readyverydemo/orderservice/getorder/GetOrderControllerTest.java +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/order/GetOrderControllerTest.java @@ -1,4 +1,4 @@ -package com.readyvery.readyverydemo.orderservice.getorder; +package com.readyvery.readyverydemo.orderservice.order; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/order/GetOrderRepositoryTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/order/GetOrderRepositoryTest.java new file mode 100644 index 0000000..31d1433 --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/order/GetOrderRepositoryTest.java @@ -0,0 +1,189 @@ +package com.readyvery.readyverydemo.orderservice.order; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import com.readyvery.readyverydemo.domain.Order; +import com.readyvery.readyverydemo.domain.Progress; +import com.readyvery.readyverydemo.domain.Store; +import com.readyvery.readyverydemo.domain.repository.OrderRepository; +import com.readyvery.readyverydemo.src.sale.dto.SaleManagementDto; + +import jakarta.persistence.EntityManager; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class GetOrderRepositoryTest { + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("ID로 모든 주문을 조회한다.") + @Test + void testFindAllById() { + // given + Order order = Order.builder() + .id(1L) + .createdAt(LocalDateTime.now()) + .lastModifiedAt(LocalDateTime.now()) + .build(); + entityManager.persist(order); + entityManager.flush(); + entityManager.clear(); + + // when + List orders = orderRepository.findAllById(1L); + + // then + assertThat(orders).hasSize(1); + } + + @DisplayName("진행 상태, 상점 ID, 생성 일자 범위로 주문을 조회한다.") + @Test + void testFindAllByProgressAndStoreIdAndCreatedAtBetween() { + // given + LocalDateTime now = LocalDateTime.now(); + Store store = Store.builder() + .id(1L) + .build(); + Order order = Order.builder() + .progress(Progress.ORDER) + .store(store) + .createdAt(now) + .lastModifiedAt(now) + .build(); + entityManager.persist(order); + entityManager.flush(); + entityManager.clear(); + + // when + List orders = orderRepository.findAllByProgressAndStoreIdAndCreatedAtBetween( + Progress.ORDER, 1L, now.minusDays(1), now.plusDays(1)); + + // then + assertThat(orders).hasSize(1); + } + + @DisplayName("주문 ID로 주문을 조회한다.") + @Test + @Transactional + void testFindByOrderId() { + // given + Order order = Order.builder() + .orderId("12345") + .createdAt(LocalDateTime.now()) + .lastModifiedAt(LocalDateTime.now()) + .build(); + entityManager.persist(order); + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundOrder = orderRepository.findByOrderId("12345"); + + // then + assertThat(foundOrder).isPresent(); + } + + @DisplayName("상점 ID와 예상 시간으로 총 금액을 합산한다.") + @Test + void testSumTotalAmountByStoreIdAndEstimatedTime() { + // given + LocalDateTime fixedTime = LocalDateTime.now().withNano(0); // 고정된 시간을 설정합니다. + + Store store = Store.builder().id(1L) + .name("Test Store") + .address("123 Test Street") + .phone("123-456-7890") + .time("09:00-21:00") + .status(true) + .build(); + store = entityManager.merge(store); // Store 엔티티를 영속성 컨텍스트에 병합합니다. + + Order order = Order.builder() + .store(store) + .totalAmount(100L) + .estimatedTime(fixedTime) + .createdAt(fixedTime) + .lastModifiedAt(fixedTime) + .build(); + entityManager.persist(order); + entityManager.flush(); + entityManager.clear(); + + // when + Optional totalAmount = orderRepository.sumTotalAmountByStoreIdAndEstimatedTime(store.getId()); + + // then + assertThat(totalAmount).isPresent().hasValue(100L); + } + + @DisplayName("상점 ID와 날짜 범위로 일별 총 금액을 합산한다.") + @Test + void testSumTotalAmountPerDayBetweenDates() { + // given + LocalDateTime now = LocalDateTime.now(); + Order order = Order.builder() + .store(Store.builder().id(1L).build()) + .totalAmount(100L) + .estimatedTime(now) + .createdAt(now) + .lastModifiedAt(now) + .build(); + entityManager.persist(order); + entityManager.flush(); + entityManager.clear(); + + // when + List sales = orderRepository.sumTotalAmountPerDayBetweenDates(1L, now.minusDays(1), + now.plusDays(1)); + + // then + assertThat(sales).isNotEmpty(); + } + + @DisplayName("상점 ID와 월간 범위로 총 금액을 합산한다.") + @Test + void testSumTotalAmountByStoreIdForMonth() { + // given + LocalDateTime now = LocalDateTime.now(); + Order order = Order.builder() + .store(Store.builder().id(1L).build()) + .totalAmount(100L) + .createdAt(now) + .estimatedTime(now) + .lastModifiedAt(now) + .build(); + entityManager.persist(order); + entityManager.flush(); + entityManager.clear(); + + System.out.println("Order created at: " + order.getCreatedAt()); + System.out.println("Order estimated time: " + order.getEstimatedTime()); + + // when + LocalDateTime startOfMonth = now.withDayOfMonth(1); + LocalDateTime endOfMonth = now.withDayOfMonth(now.toLocalDate().lengthOfMonth()); + System.out.println("Querying from " + startOfMonth + " to " + endOfMonth); + + Optional totalAmount = orderRepository.sumTotalAmountByStoreIdForMonth(1L, startOfMonth, endOfMonth); + + // then + assertThat(totalAmount).isPresent().hasValue(100L); + } +} diff --git a/src/test/java/com/readyvery/readyverydemo/orderservice/order/OrderServiceImplTest.java b/src/test/java/com/readyvery/readyverydemo/orderservice/order/OrderServiceImplTest.java new file mode 100644 index 0000000..a70b730 --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/orderservice/order/OrderServiceImplTest.java @@ -0,0 +1,159 @@ +package com.readyvery.readyverydemo.orderservice.order; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.readyvery.readyverydemo.domain.CeoInfo; +import com.readyvery.readyverydemo.domain.Order; +import com.readyvery.readyverydemo.domain.Progress; +import com.readyvery.readyverydemo.domain.Store; +import com.readyvery.readyverydemo.domain.repository.OrderRepository; +import com.readyvery.readyverydemo.global.exception.BusinessLogicException; +import com.readyvery.readyverydemo.global.exception.ExceptionCode; +import com.readyvery.readyverydemo.src.ceo.CeoServiceFacade; +import com.readyvery.readyverydemo.src.order.OrderServiceImpl; +import com.readyvery.readyverydemo.src.order.dto.OrderMapper; +import com.readyvery.readyverydemo.src.order.dto.OrderStatusRes; +import com.readyvery.readyverydemo.src.order.dto.OrderStatusUpdateReq; +import com.readyvery.readyverydemo.src.point.PointService; + +@ExtendWith(MockitoExtension.class) +class OrderServiceImplTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderMapper orderMapper; + + @Mock + private CeoServiceFacade ceoServiceFacade; + + @Mock + private PointService pointService; + + @InjectMocks + private OrderServiceImpl orderServiceImpl; + + static Stream orderStatusProvider() { + return Stream.of( + Arguments.of(Progress.ORDER, Progress.MAKE), + Arguments.of(Progress.ORDER, Progress.CANCEL), + Arguments.of(Progress.MAKE, Progress.COMPLETE), + Arguments.of(Progress.COMPLETE, Progress.PICKUP) + ); + } + + @DisplayName("새 주문을 생성하고 저장한다.") + @Test + void createOrder() { + CeoInfo ceoInfo = CeoInfo.builder().store(Store.builder().id(1L).build()).build(); + when(ceoServiceFacade.getCeoInfo(anyLong())).thenReturn(ceoInfo); + + Order order = Order.builder() + .store(ceoInfo.getStore()) + .totalAmount(100L) + .estimatedTime(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .lastModifiedAt(LocalDateTime.now()) + .build(); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + OrderStatusUpdateReq request = new OrderStatusUpdateReq(1L, Progress.ORDER, 30L, "Test reason"); + + OrderStatusRes response = orderServiceImpl.completeOrder(1L, request); + + assertAll( + () -> assertThat(response.isSuccess()).isTrue(), + () -> verify(orderRepository, times(1)).save(any(Order.class)) + ); + } + + @DisplayName("주문 상태에 따라 주문을 업데이트한다.") + @MethodSource("orderStatusProvider") + @ParameterizedTest + void updateOrderStatus(Progress currentStatus, Progress newStatus) { + Store store = Store.builder().id(1L).build(); + CeoInfo ceoInfo = CeoInfo.builder().store(store).build(); + when(ceoServiceFacade.getCeoInfo(anyLong())).thenReturn(ceoInfo); + + Order order = Order.builder() + .store(store) + .progress(currentStatus) + .totalAmount(100L) + .build(); + when(orderRepository.findByOrderId(anyString())).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + OrderStatusUpdateReq request = new OrderStatusUpdateReq(1L, newStatus, 30L, "Test reason"); + + OrderStatusRes response = orderServiceImpl.completeOrder(1L, request); + + assertAll( + () -> assertThat(response.isSuccess()).isTrue(), + () -> verify(orderRepository, times(1)).save(any(Order.class)) + ); + } + + @DisplayName("주문을 취소하고 결제 취소를 적용한다.") + @Test + void cancelOrder() { + Store store = Store.builder().id(1L).build(); + CeoInfo ceoInfo = CeoInfo.builder().store(store).build(); + when(ceoServiceFacade.getCeoInfo(anyLong())).thenReturn(ceoInfo); + + Order order = Order.builder() + .store(store) + .progress(Progress.ORDER) + .totalAmount(100L) + .paymentKey("testPaymentKey") + .build(); + when(orderRepository.findByOrderId(anyString())).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + OrderStatusUpdateReq request = new OrderStatusUpdateReq(1L, Progress.CANCEL, null, "Customer requested"); + + OrderStatusRes response = orderServiceImpl.cancelOrder(1L, request); + + assertAll( + () -> assertThat(response.isSuccess()).isTrue(), + () -> verify(orderRepository, times(1)).save(any(Order.class)), + () -> verify(pointService, times(1)).cancelOrderPoint(any(Order.class)) + ); + } + + @DisplayName("존재하지 않는 주문을 취소하려 할 때 예외를 던진다.") + @Test + void cancelOrderThrowsExceptionForNonExistentOrder() { + when(orderRepository.findByOrderId(anyString())).thenReturn(Optional.empty()); + + OrderStatusUpdateReq request = new OrderStatusUpdateReq(1L, Progress.CANCEL, null, "Customer requested"); + + assertThatThrownBy(() -> orderServiceImpl.cancelOrder(1L, request)) + .isInstanceOf(BusinessLogicException.class) + .hasMessageContaining(ExceptionCode.NOT_FOUND_ORDER.getMessage()); + + verify(orderRepository, never()).save(any(Order.class)); + } + + // 더 많은 테스트 케이스를 추가하여 OrderServiceImpl의 기능을 검증할 수 있습니다. +} +