diff --git a/src/main/java/com/soongsil/CoffeeChat/CoffeeChatApplication.java b/src/main/java/com/soongsil/CoffeeChat/CoffeeChatApplication.java index e3b76cd..56f006f 100644 --- a/src/main/java/com/soongsil/CoffeeChat/CoffeeChatApplication.java +++ b/src/main/java/com/soongsil/CoffeeChat/CoffeeChatApplication.java @@ -9,9 +9,6 @@ import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.servers.Server; - @SpringBootApplication @EnableAsync @EnableScheduling diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/controller/AssignedCouponController.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/controller/AssignedCouponController.java new file mode 100644 index 0000000..693cc05 --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/controller/AssignedCouponController.java @@ -0,0 +1,103 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.controller; + +import java.util.List; + +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.*; + +import com.soongsil.CoffeeChat.domain.assignedcoupon.dto.*; +import com.soongsil.CoffeeChat.domain.assignedcoupon.service.AssignedCouponService; +import com.soongsil.CoffeeChat.global.api.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/assigned-coupons") +@Tag(name = "ASSIGNED COUPON", description = "축제 전용 지정 커피 쿠폰 관련 API") +public class AssignedCouponController { + + private final AssignedCouponService assignedCouponService; + + // 유저 - 보관함 진입 시 발급 자격 확인 + @GetMapping("/eligibility") + @Operation( + summary = "지정 쿠폰 발급 자격 확인", + description = + "보관함 진입 시 호출합니다. 본인의 name + phoneNum 이 사전 등록된 대상자 명단과 일치하는지 확인합니다." + + " 발급 이력이 있는 경우 발급 정보를 반환합니다.") + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공 (result.eligible=true && !alreadyIssued 일 때만 발급 버튼 활성화)") + public ResponseEntity> checkEligibility( + Authentication authentication) { + return ResponseEntity.ok() + .body( + ApiResponse.onSuccessOK( + assignedCouponService.checkEligibility( + authentication.getName()))); + } + + // 유저 - 매장 PIN 인증으로 지정 쿠폰 발급 (=사용 처리) + @PostMapping("/coupons") + @Operation( + summary = "지정 쿠폰 발급 (매장 PIN 인증)", + description = "매장 직원의 핀 번호를 입력받아 최종 검증 후 쿠폰을 발급합니다. 발급과 동시에 사용 처리되어 재사용 불가합니다.") + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "발급된 쿠폰 정보 반환") + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "EVENT_400_1: 핀 번호 불일치 | ASSIGNED_COUPON_400: 전화번호 미등록", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "ASSIGNED_COUPON_404: 발급 대상자 아님", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "ASSIGNED_COUPON_409: 이미 발급된 쿠폰", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "503", + description = "EVENT_503: 동시성 처리 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity> issueCoupon( + @Valid @RequestBody AssignedCouponIssueRequest request, + Authentication authentication) { + return ResponseEntity.ok() + .body( + ApiResponse.onSuccessOK( + assignedCouponService.issueCoupon( + authentication.getName(), request.storePin()))); + } + + // ************ 관리자용 api ************ + + @PostMapping("/admin/register") + @Operation( + summary = "지정 쿠폰 대상자 일괄 등록 (관리자)", + description = "대상자(이름 + 전화번호) 목록을 받아 Redis에 등록합니다. 전화번호는 하이픈 유무 무관.") + public ResponseEntity> registerTargets( + @RequestBody List targets) { + return ResponseEntity.ok() + .body(ApiResponse.onSuccessOK(assignedCouponService.registerTargets(targets))); + } + + @PostMapping("/admin/register/one") + @Operation(summary = "지정 쿠폰 대상자 단건 등록 (관리자)") + public ResponseEntity> registerOneTarget( + @Valid @RequestBody AssignedCouponTargetRequest target) { + return ResponseEntity.ok() + .body( + ApiResponse.onSuccessOK( + assignedCouponService.registerTargets(List.of(target)))); + } +} \ No newline at end of file diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponCheckResponse.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponCheckResponse.java new file mode 100644 index 0000000..c4d73fc --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponCheckResponse.java @@ -0,0 +1,25 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AssignedCouponCheckResponse { + + private boolean eligible; + private boolean alreadyIssued; + private String name; + private String couponNumber; + private String status; + private LocalDateTime issuedAt; + private LocalDateTime usedAt; + + public static AssignedCouponCheckResponse notEligible() { + return AssignedCouponCheckResponse.builder().eligible(false).alreadyIssued(false).build(); + } +} diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponIssueRequest.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponIssueRequest.java new file mode 100644 index 0000000..fccbaa4 --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponIssueRequest.java @@ -0,0 +1,7 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AssignedCouponIssueRequest( + @NotBlank String storePin) { +} diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponRegisterResult.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponRegisterResult.java new file mode 100644 index 0000000..4097235 --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponRegisterResult.java @@ -0,0 +1,10 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.dto; + +import java.util.List; + +public record AssignedCouponRegisterResult( + int totalRequested, + int newlyRegistered, + int duplicated, + List failedPhoneNums) { +} \ No newline at end of file diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponResponse.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponResponse.java new file mode 100644 index 0000000..45e5af2 --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponResponse.java @@ -0,0 +1,19 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AssignedCouponResponse { + + private String couponNumber; + private String name; + private String status; + private LocalDateTime issuedAt; + private LocalDateTime usedAt; +} \ No newline at end of file diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponTargetRequest.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponTargetRequest.java new file mode 100644 index 0000000..b64a41a --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/dto/AssignedCouponTargetRequest.java @@ -0,0 +1,8 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AssignedCouponTargetRequest( + @NotBlank String name, + @NotBlank String phoneNum) { +} \ No newline at end of file diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssueEventListener.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssueEventListener.java new file mode 100644 index 0000000..cede33e --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssueEventListener.java @@ -0,0 +1,45 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.message; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soongsil.CoffeeChat.infra.aws.s3.service.AmazonS3Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AssignedCouponIssueEventListener { + + private final AmazonS3Service amazonS3Service; + private final ObjectMapper objectMapper; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Async + @EventListener + public void handleAssignedCouponIssuedEvent(AssignedCouponIssuedEvent event) { + try { + // AssignedCouponIssuedEvent JSON 직렬화 + String logJson = objectMapper.writeValueAsString(event); + + String fileNamePrefix = "assigned-coupon-" + event.couponNumber(); + + // event-logs/assigned-coupon 디렉토리에 저장 + String fileUrl = + amazonS3Service.uploadJsonFile( + logJson, "event-logs/assigned-coupon", fileNamePrefix); + + log.info("S3 지정 쿠폰 발급 로그 업로드 완료: {} (URL: {})", fileNamePrefix, fileUrl); + + } catch (Exception e) { + log.error("S3 지정 쿠폰 발급 로그 업로드 실패. couponNumber: {}", event.couponNumber(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssuedEvent.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssuedEvent.java new file mode 100644 index 0000000..dd14daa --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssuedEvent.java @@ -0,0 +1,12 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.message; + +import java.time.LocalDateTime; + +// 지정 쿠폰 비동기 로깅용 dto +public record AssignedCouponIssuedEvent( + String username, + String name, + String phoneNum, + String couponNumber, + LocalDateTime issuedAt) { +} \ No newline at end of file diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/service/AssignedCouponService.java b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/service/AssignedCouponService.java new file mode 100644 index 0000000..f652c17 --- /dev/null +++ b/src/main/java/com/soongsil/CoffeeChat/domain/assignedcoupon/service/AssignedCouponService.java @@ -0,0 +1,242 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.soongsil.CoffeeChat.domain.assignedcoupon.dto.*; +import com.soongsil.CoffeeChat.domain.assignedcoupon.message.AssignedCouponIssuedEvent; +import com.soongsil.CoffeeChat.domain.user.entity.User; +import com.soongsil.CoffeeChat.domain.user.repository.UserRepository; +import com.soongsil.CoffeeChat.global.exception.GlobalErrorCode; +import com.soongsil.CoffeeChat.global.exception.GlobalException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AssignedCouponService { + private final StringRedisTemplate redisTemplate; + private final RedissonClient redissonClient; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @Value("${event.store.pin}") + private String storePin; + + private User findUserByUsername(String username) { + return userRepository + .findByUsernameAndIsDeletedFalse(username) + .orElseThrow(() -> new GlobalException(GlobalErrorCode.USER_NOT_FOUND)); + } + + // 전화번호 정규화 (하이픈, 공백 제거) + private String normalizePhoneNum(String phoneNum) { + if (phoneNum == null) return null; + return phoneNum.replaceAll("[^0-9]", ""); + } + + private LocalDateTime parseDateTimeOrNull(String value) { + if (value == null || value.isBlank()) return null; + try { + return LocalDateTime.parse(value); + } catch (Exception e) { + return null; + } + } + + // 관리자: 지정 쿠폰 대상자 일괄 등록 + public AssignedCouponRegisterResult registerTargets(List targets) { + int newCount = 0; + int dupCount = 0; + List failed = new ArrayList<>(); + + for (AssignedCouponTargetRequest target : targets) { + // null 요소 가드 (배치 중단 방지) + if (target == null) { + failed.add(null); + continue; + } + + String phoneNum = normalizePhoneNum(target.phoneNum()); + String name = target.name(); + + // name, phoneNum blank 검증 + if (phoneNum == null + || phoneNum.isBlank() + || name == null + || name.isBlank()) { + failed.add(target.phoneNum()); + continue; + } + + String targetKey = "assigned-coupon:target:" + phoneNum; + + try { + // hash 등록 시도 -> ticket TARGETED 로 초기화 + if (redisTemplate.hasKey(targetKey)) { + dupCount++; + continue; + } + + redisTemplate + .opsForHash() + .putAll( + targetKey, + Map.of( + "name", target.name(), + "status", "TARGETED", + "registeredAt", LocalDateTime.now().toString())); + newCount++; + + } catch (Exception e) { + // hash 등록 실패 + log.error("지정 쿠폰 대상자 등록 실패. phoneNum={}", phoneNum, e); + failed.add(phoneNum); + } + } + + return new AssignedCouponRegisterResult(targets.size(), newCount, dupCount, failed); + } + + // 유저: 보관한 진입 -> 지정 쿠폰 발급 자격 확인 + @Transactional(readOnly = true) + public AssignedCouponCheckResponse checkEligibility(String username) { + User user = findUserByUsername(username); + + if (user.getPhoneNum() == null || user.getName() == null) { + return AssignedCouponCheckResponse.notEligible(); + } + + String phoneNum = normalizePhoneNum(user.getPhoneNum()); + String targetKey = "assigned-coupon:target:" + phoneNum; + Map target = redisTemplate.opsForHash().entries(targetKey); + + if (target.isEmpty()) { + return AssignedCouponCheckResponse.notEligible(); + } + + // phoneNum -> 이름 일치 검증 + String targetName = (String) target.get("name"); + if (!user.getName().equals(targetName)) { + log.warn( + "지정 쿠폰 이름 불일치 - username={}, userName={}, targetName={}", + username, + user.getName(), + targetName); + return AssignedCouponCheckResponse.notEligible(); + } + + String status = (String) target.get("status"); + boolean alreadyIssued = "USED".equals(status); + + return AssignedCouponCheckResponse.builder() + .eligible(true) + .alreadyIssued(alreadyIssued) + .name(targetName) + .couponNumber((String) target.get("couponNumber")) + .status(status) + .issuedAt(parseDateTimeOrNull((String) target.get("issuedAt"))) + .usedAt(parseDateTimeOrNull((String) target.get("usedAt"))) + .build(); + } + + // 유저: 매장 PIN 인증 -> 지정 쿠폰 발급 및 사용 처리 (재사용 불가) + @Transactional(readOnly = true) + public AssignedCouponResponse issueCoupon(String username, String inputPin) { + if (!storePin.equals(inputPin)) { + throw new GlobalException(GlobalErrorCode.EVENT_PIN_MISMATCH); + } + + User user = findUserByUsername(username); + if (user.getPhoneNum() == null || user.getName() == null) { + throw new GlobalException(GlobalErrorCode.ASSIGNED_COUPON_PHONE_NOT_SET); + } + + String phoneNum = normalizePhoneNum(user.getPhoneNum()); + String targetKey = "assigned-coupon:target:" + phoneNum; + + // 분산락 -> fine-grained locking + String lockKey = "lock:assigned-coupon:issue:" + phoneNum; + RLock lock = redissonClient.getLock(lockKey); + + try { + if (!lock.tryLock(3, TimeUnit.SECONDS)) { + throw new GlobalException(GlobalErrorCode.EVENT_CONCURRENCY_ERROR); + } + + // 락 내 대상자, 이름 일치 재검증 + Map target = redisTemplate.opsForHash().entries(targetKey); + if (target.isEmpty()) { + throw new GlobalException(GlobalErrorCode.ASSIGNED_COUPON_NOT_TARGET); + } + if (!user.getName().equals(target.get("name"))) { + throw new GlobalException(GlobalErrorCode.ASSIGNED_COUPON_NOT_TARGET); + } + + // 이미 사용 되었는가 확인 + String currentStatus = (String) target.get("status"); + if ("USED".equals(currentStatus)) { + throw new GlobalException(GlobalErrorCode.ASSIGNED_COUPON_ALREADY_ISSUED); + } + + // Redis INCR -> 순차 번호 발급 + Long currentSeq = redisTemplate.opsForValue().increment("assigned-coupon:seq"); + String couponNumber = String.format("AC-%04d", currentSeq != null ? currentSeq : 0); + LocalDateTime now = LocalDateTime.now(); + + // 상태 업데이트 -> 사용 처리 및 카운터 기록 + redisTemplate + .opsForHash() + .putAll( + targetKey, + Map.of( + "status", + "USED", + "couponNumber", + couponNumber, + "issuedAt", + now.toString(), + "usedAt", + now.toString(), + "claimedBy", + username)); + redisTemplate.opsForValue().increment("assigned-coupon:used:count"); + + // 비동기 로깅 이벤트 발행 + eventPublisher.publishEvent( + new AssignedCouponIssuedEvent( + username, user.getName(), phoneNum, couponNumber, now)); + + return AssignedCouponResponse.builder() + .couponNumber(couponNumber) + .name(user.getName()) + .status("USED") + .issuedAt(now) + .usedAt(now) + .build(); + + } catch (InterruptedException e) { + // 인터럽트 처리 -> 플래그 복원 + Thread.currentThread().interrupt(); + throw new GlobalException(GlobalErrorCode.EVENT_CONCURRENCY_ERROR); + } finally { + // 락 소유권 확인후 해제 + if (lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/chat/entity/ChatRoomUser.java b/src/main/java/com/soongsil/CoffeeChat/domain/chat/entity/ChatRoomUser.java index e3f4979..f35ec0e 100644 --- a/src/main/java/com/soongsil/CoffeeChat/domain/chat/entity/ChatRoomUser.java +++ b/src/main/java/com/soongsil/CoffeeChat/domain/chat/entity/ChatRoomUser.java @@ -38,5 +38,4 @@ public void updateLastReadChatId(Long chatId) { lastReadChatId = chatId; } } - } diff --git a/src/main/java/com/soongsil/CoffeeChat/domain/possibleDate/entity/PossibleDate.java b/src/main/java/com/soongsil/CoffeeChat/domain/possibleDate/entity/PossibleDate.java index 3a726bc..d5b641f 100644 --- a/src/main/java/com/soongsil/CoffeeChat/domain/possibleDate/entity/PossibleDate.java +++ b/src/main/java/com/soongsil/CoffeeChat/domain/possibleDate/entity/PossibleDate.java @@ -16,9 +16,9 @@ @Entity @Table( indexes = { - @Index( - name = "idx_pd_mentor_datetime", - columnList = "mentor_id, date, start_time, end_time") + @Index( + name = "idx_pd_mentor_datetime", + columnList = "mentor_id, date, start_time, end_time") }) @Builder @NoArgsConstructor diff --git a/src/main/java/com/soongsil/CoffeeChat/global/dev/DevDataInitializer.java b/src/main/java/com/soongsil/CoffeeChat/global/dev/DevDataInitializer.java index 1411df1..be2e8c2 100644 --- a/src/main/java/com/soongsil/CoffeeChat/global/dev/DevDataInitializer.java +++ b/src/main/java/com/soongsil/CoffeeChat/global/dev/DevDataInitializer.java @@ -1,5 +1,9 @@ package com.soongsil.CoffeeChat.global.dev; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + import com.soongsil.CoffeeChat.domain.auth.enums.Role; import com.soongsil.CoffeeChat.domain.mentee.dto.MenteeRequest; import com.soongsil.CoffeeChat.domain.mentor.dto.MentorRequest; @@ -7,11 +11,9 @@ import com.soongsil.CoffeeChat.domain.mentor.enums.PartEnum; import com.soongsil.CoffeeChat.domain.user.entity.User; import com.soongsil.CoffeeChat.domain.user.repository.UserRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; @Component @Profile("local") @@ -31,15 +33,17 @@ public void run(String... args) { private void createDevUser(String username, String name, Role role) { if (userRepository.findByUsernameAndIsDeletedFalse(username).isEmpty()) { - User user = User.builder() - .username(username) - .name(name) - .email(username + "@dev.local") - .role(role) - .build(); + User user = + User.builder() + .username(username) + .name(name) + .email(username + "@dev.local") + .role(role) + .build(); if (role == Role.ROLE_MENTOR) { - user.registerAsMentor(new MentorRequest.MentorJoinRequest(PartEnum.BE, ClubEnum.GDGoC)); + user.registerAsMentor( + new MentorRequest.MentorJoinRequest(PartEnum.BE, ClubEnum.GDGoC)); } if (role == Role.ROLE_MENTEE) { user.registerAsMentee(new MenteeRequest.MenteeJoinRequest(PartEnum.BE)); @@ -48,4 +52,4 @@ private void createDevUser(String username, String name, Role role) { userRepository.save(user); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalErrorCode.java b/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalErrorCode.java index 5c7f653..cb9214b 100644 --- a/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalErrorCode.java +++ b/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalErrorCode.java @@ -40,7 +40,7 @@ public enum GlobalErrorCode { CHATROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CHATROOM_404", "채팅방을 찾을 수 없습니다."), CHATROOM_NOT_PARTICIPANT(HttpStatus.FORBIDDEN, "CHATROOM_403", "채팅방 참여자가 아닙니다."), - // 이벤트 및 쿠폰 관련 + // 2인 이벤트 및 쿠폰 관련 EVENT_PIN_MISMATCH(HttpStatus.BAD_REQUEST, "EVENT_400_1", "매장 핀 번호가 일치하지 않습니다."), EVENT_QR_EXPIRED(HttpStatus.BAD_REQUEST, "EVENT_400_2", "유효하지 않거나 만료된 QR 코드입니다."), EVENT_APPLICATION_EXPIRED(HttpStatus.BAD_REQUEST, "EVENT_400_3", "만료 기간(1주일)이 지난 커피챗입니다."), @@ -53,6 +53,11 @@ public enum GlobalErrorCode { EVENT_CONCURRENCY_ERROR( HttpStatus.SERVICE_UNAVAILABLE, "EVENT_503", "현재 처리 중인 요청입니다. 잠시 후 다시 시도해주세요."), + // 1인 지정자 이벤트 및 쿠폰 관련 + ASSIGNED_COUPON_PHONE_NOT_SET(HttpStatus.BAD_REQUEST, "FESTIVAL_400_1", "전화번호가 등록되어 있지 않습니다."), + ASSIGNED_COUPON_NOT_TARGET(HttpStatus.NOT_FOUND, "FESTIVAL_404", "축제 쿠폰 발급 대상이 아닙니다."), + ASSIGNED_COUPON_ALREADY_ISSUED(HttpStatus.CONFLICT, "FESTIVAL_409", "이미 발급된 쿠폰입니다."), + // JWT 관련 JWT_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "JWT_001", "유효하지 않는 토큰입니다."), JWT_MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, "JWT_002", "잘못된 형식의 토큰입니다."), diff --git a/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalExceptionAdvice.java b/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalExceptionAdvice.java index 7173f62..63210ae 100644 --- a/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalExceptionAdvice.java +++ b/src/main/java/com/soongsil/CoffeeChat/global/exception/GlobalExceptionAdvice.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -16,6 +15,8 @@ import com.fasterxml.jackson.core.JsonParseException; +import lombok.extern.slf4j.Slf4j; + @Slf4j @RestControllerAdvice(annotations = {RestController.class}) public class GlobalExceptionAdvice { diff --git a/src/test/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssueEventListenerTest.java b/src/test/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssueEventListenerTest.java new file mode 100644 index 0000000..4fbb84b --- /dev/null +++ b/src/test/java/com/soongsil/CoffeeChat/domain/assignedcoupon/message/AssignedCouponIssueEventListenerTest.java @@ -0,0 +1,98 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.message; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soongsil.CoffeeChat.infra.aws.s3.service.AmazonS3Service; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssignedCouponIssueEventListener 테스트") +class AssignedCouponIssueEventListenerTest { + + @Mock private AmazonS3Service amazonS3Service; + @Mock private ObjectMapper objectMapper; + + @InjectMocks private AssignedCouponIssueEventListener listener; + + @Test + @DisplayName("이벤트 수신 시 JSON 직렬화 후 event-logs/assigned-coupon/ 경로에 업로드") + void handleEvent_uploadsToCorrectS3Path() throws Exception { + // given + AssignedCouponIssuedEvent event = + new AssignedCouponIssuedEvent( + "test_user_001", + "가나다", + "01011112222", + "AC-0001", + LocalDateTime.of(2025, 5, 14, 10, 0)); + + String expectedJson = + "{\"username\":\"test_user_001\",\"name\":\"가나다\",\"phoneNum\":\"01011112222\"," + + "\"couponNumber\":\"AC-0001\",\"issuedAt\":\"2025-05-14T10:00:00\"}"; + + given(objectMapper.writeValueAsString(event)).willReturn(expectedJson); + given(amazonS3Service.uploadJsonFile(anyString(), anyString(), anyString())) + .willReturn( + "https://cloudfront.example.com/event-logs/assigned-coupon/assigned-coupon-AC-0001_xxx.json"); + + // when + listener.handleAssignedCouponIssuedEvent(event); + + // then + ArgumentCaptor jsonCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor dirCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor prefixCaptor = ArgumentCaptor.forClass(String.class); + + verify(amazonS3Service) + .uploadJsonFile(jsonCaptor.capture(), dirCaptor.capture(), prefixCaptor.capture()); + + assertThat(jsonCaptor.getValue()).isEqualTo(expectedJson); + assertThat(dirCaptor.getValue()).isEqualTo("event-logs/assigned-coupon"); + assertThat(prefixCaptor.getValue()).isEqualTo("assigned-coupon-AC-0001"); + } + + @Test + @DisplayName("JSON 직렬화 실패해도 예외를 외부로 던지지 않음 (로깅만)") + void handleEvent_swallowsSerializationException() throws Exception { + // given + AssignedCouponIssuedEvent event = + new AssignedCouponIssuedEvent( + "u1", "n", "01000000000", "AC-0001", LocalDateTime.now()); + given(objectMapper.writeValueAsString(event)) + .willThrow(new RuntimeException("serialize failed")); + + // when & then + assertThatCode(() -> listener.handleAssignedCouponIssuedEvent(event)) + .doesNotThrowAnyException(); + + verifyNoInteractions(amazonS3Service); + } + + @Test + @DisplayName("S3 업로드 실패해도 예외를 외부로 던지지 않음 (로깅만)") + void handleEvent_swallowsS3UploadException() throws Exception { + // given + AssignedCouponIssuedEvent event = + new AssignedCouponIssuedEvent( + "u1", "n", "01000000000", "AC-0001", LocalDateTime.now()); + given(objectMapper.writeValueAsString(event)).willReturn("{}"); + given(amazonS3Service.uploadJsonFile(anyString(), anyString(), anyString())) + .willThrow(new RuntimeException("S3 error")); + + // when & then + assertThatCode(() -> listener.handleAssignedCouponIssuedEvent(event)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/soongsil/CoffeeChat/domain/assignedcoupon/service/AssignedCouponServiceTest.java b/src/test/java/com/soongsil/CoffeeChat/domain/assignedcoupon/service/AssignedCouponServiceTest.java new file mode 100644 index 0000000..66532da --- /dev/null +++ b/src/test/java/com/soongsil/CoffeeChat/domain/assignedcoupon/service/AssignedCouponServiceTest.java @@ -0,0 +1,523 @@ +package com.soongsil.CoffeeChat.domain.assignedcoupon.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import com.soongsil.CoffeeChat.domain.assignedcoupon.dto.AssignedCouponCheckResponse; +import com.soongsil.CoffeeChat.domain.assignedcoupon.dto.AssignedCouponRegisterResult; +import com.soongsil.CoffeeChat.domain.assignedcoupon.dto.AssignedCouponResponse; +import com.soongsil.CoffeeChat.domain.assignedcoupon.dto.AssignedCouponTargetRequest; +import com.soongsil.CoffeeChat.domain.assignedcoupon.message.AssignedCouponIssuedEvent; +import com.soongsil.CoffeeChat.domain.user.entity.User; +import com.soongsil.CoffeeChat.domain.user.repository.UserRepository; +import com.soongsil.CoffeeChat.global.exception.GlobalErrorCode; +import com.soongsil.CoffeeChat.global.exception.GlobalException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssignedCouponService 테스트") +class AssignedCouponServiceTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private RedissonClient redissonClient; + @Mock private UserRepository userRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + @Mock private ValueOperations valueOperations; + @Mock private HashOperations hashOperations; + @Mock private RLock rLock; + + @InjectMocks private AssignedCouponService assignedCouponService; + + private static final String STORE_PIN = "1234"; + private static final String USERNAME = "test_user_001"; + private static final String USER_NAME = "가나다"; + private static final String PHONE_NUM_RAW = "010-1111-2222"; + private static final String PHONE_NUM_NORMALIZED = "01011112222"; + private static final String TARGET_KEY = "assigned-coupon:target:" + PHONE_NUM_NORMALIZED; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(assignedCouponService, "storePin", STORE_PIN); + } + + private User mockUser(String name, String phoneNum) { + User user = mock(User.class); + lenient().when(user.getName()).thenReturn(name); + lenient().when(user.getPhoneNum()).thenReturn(phoneNum); + return user; + } + + private Map targetedHash() { + Map map = new HashMap<>(); + map.put("name", USER_NAME); + map.put("status", "TARGETED"); + map.put("registeredAt", LocalDateTime.now().toString()); + return map; + } + + private Map usedHash(String couponNumber) { + Map map = new HashMap<>(); + map.put("name", USER_NAME); + map.put("status", "USED"); + map.put("couponNumber", couponNumber); + map.put("issuedAt", LocalDateTime.now().toString()); + map.put("usedAt", LocalDateTime.now().toString()); + map.put("claimedBy", USERNAME); + return map; + } + + @Nested + @DisplayName("대상자 등록 (registerTargets)") + class RegisterTargets { + + @Test + @DisplayName("신규 대상자 등록 성공 - 전화번호 정규화 확인") + void registerNewTargets_success() { + // given + List requests = + List.of( + new AssignedCouponTargetRequest("가나다", "010-1111-2222"), + new AssignedCouponTargetRequest("홍길동", "01022223333")); + given(redisTemplate.hasKey(anyString())).willReturn(false); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + + // when + AssignedCouponRegisterResult result = assignedCouponService.registerTargets(requests); + + // then + assertThat(result.totalRequested()).isEqualTo(2); + assertThat(result.newlyRegistered()).isEqualTo(2); + assertThat(result.duplicated()).isZero(); + assertThat(result.failedPhoneNums()).isEmpty(); + + verify(hashOperations).putAll(eq("assigned-coupon:target:01011112222"), anyMap()); + verify(hashOperations).putAll(eq("assigned-coupon:target:01022223333"), anyMap()); + } + + @Test + @DisplayName("중복 대상자는 duplicated 카운트로 분류") + void registerDuplicatedTarget() { + // given + List requests = + List.of(new AssignedCouponTargetRequest("가나다", "010-1111-2222")); + given(redisTemplate.hasKey(TARGET_KEY)).willReturn(true); + + // when + AssignedCouponRegisterResult result = assignedCouponService.registerTargets(requests); + + // then + assertThat(result.newlyRegistered()).isZero(); + assertThat(result.duplicated()).isEqualTo(1); + verify(hashOperations, never()).putAll(anyString(), anyMap()); + } + + @Test + @DisplayName("name 누락 시 failed 처리") + void registerWithMissingName() { + // given + List requests = + List.of(new AssignedCouponTargetRequest(null, "010-1111-2222")); + + // when + AssignedCouponRegisterResult result = assignedCouponService.registerTargets(requests); + + // then + assertThat(result.newlyRegistered()).isZero(); + assertThat(result.failedPhoneNums()).containsExactly("010-1111-2222"); + } + + @Test + @DisplayName("phoneNum 누락 시 failed 처리") + void registerWithMissingPhoneNum() { + // given + List requests = + List.of(new AssignedCouponTargetRequest("가나다", null)); + + // when + AssignedCouponRegisterResult result = assignedCouponService.registerTargets(requests); + + // then + assertThat(result.newlyRegistered()).isZero(); + assertThat(result.failedPhoneNums()).hasSize(1); + } + } + + @Nested + @DisplayName("자격 확인 (checkEligibility)") + class CheckEligibility { + + @Test + @DisplayName("대상자 명단에 없으면 eligible=false") + void notInTargetList() { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(new HashMap<>()); + + // when + AssignedCouponCheckResponse response = assignedCouponService.checkEligibility(USERNAME); + + // then + assertThat(response.isEligible()).isFalse(); + assertThat(response.isAlreadyIssued()).isFalse(); + } + + @Test + @DisplayName("phoneNum이 null 인 유저는 eligible=false") + void userWithoutPhoneNum() { + // given + User user = mockUser(USER_NAME, null); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + + // when + AssignedCouponCheckResponse response = assignedCouponService.checkEligibility(USERNAME); + + // then + assertThat(response.isEligible()).isFalse(); + verify(redisTemplate, never()).opsForHash(); + } + + @Test + @DisplayName("phoneNum 일치하나 name 다르면 eligible=false") + void nameMismatch() { + // given + User user = mockUser("악의적사용자", PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(targetedHash()); + + // when + AssignedCouponCheckResponse response = assignedCouponService.checkEligibility(USERNAME); + + // then + assertThat(response.isEligible()).isFalse(); + } + + @Test + @DisplayName("대상자이고 미발급 상태면 eligible=true, alreadyIssued=false") + void eligibleAndNotIssued() { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(targetedHash()); + + // when + AssignedCouponCheckResponse response = assignedCouponService.checkEligibility(USERNAME); + + // then + assertThat(response.isEligible()).isTrue(); + assertThat(response.isAlreadyIssued()).isFalse(); + assertThat(response.getName()).isEqualTo(USER_NAME); + assertThat(response.getStatus()).isEqualTo("TARGETED"); + assertThat(response.getCouponNumber()).isNull(); + } + + @Test + @DisplayName("이미 발급된 대상자는 eligible=true, alreadyIssued=true (쿠폰 번호 반환)") + void alreadyIssuedTarget() { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(usedHash("AC-0001")); + + // when + AssignedCouponCheckResponse response = assignedCouponService.checkEligibility(USERNAME); + + // then + assertThat(response.isEligible()).isTrue(); + assertThat(response.isAlreadyIssued()).isTrue(); + assertThat(response.getCouponNumber()).isEqualTo("AC-0001"); + assertThat(response.getStatus()).isEqualTo("USED"); + } + } + + @Nested + @DisplayName("쿠폰 발급 (issueCoupon)") + class IssueCoupon { + + @Test + @DisplayName("PIN 불일치 시 EVENT_PIN_MISMATCH 예외") + void wrongPin() { + // when, then + assertThatThrownBy(() -> assignedCouponService.issueCoupon(USERNAME, "WRONG_PIN")) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue( + "globalErrorCode", GlobalErrorCode.EVENT_PIN_MISMATCH); + + verifyNoInteractions(userRepository, redisTemplate, redissonClient, eventPublisher); + } + + @Test + @DisplayName("phoneNum이 null 이면 ASSIGNED_COUPON_PHONE_NOT_SET 예외") + void noPhoneNum() { + // given + User user = mockUser(USER_NAME, null); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + + // when, then + assertThatThrownBy(() -> assignedCouponService.issueCoupon(USERNAME, STORE_PIN)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue( + "globalErrorCode", GlobalErrorCode.ASSIGNED_COUPON_PHONE_NOT_SET); + + verifyNoInteractions(redissonClient, eventPublisher); + } + + @Test + @DisplayName("락 획득 실패 시 EVENT_CONCURRENCY_ERROR 예외") + void lockTimeout() throws InterruptedException { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(false); + + // when, then + assertThatThrownBy(() -> assignedCouponService.issueCoupon(USERNAME, STORE_PIN)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue( + "globalErrorCode", GlobalErrorCode.EVENT_CONCURRENCY_ERROR); + + verify(rLock, never()).unlock(); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("대상자 명단에 없으면 ASSIGNED_COUPON_NOT_TARGET 예외") + void notInTargetList() throws InterruptedException { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(true); + given(rLock.isLocked()).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(new HashMap<>()); + + // when, then + assertThatThrownBy(() -> assignedCouponService.issueCoupon(USERNAME, STORE_PIN)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue( + "globalErrorCode", GlobalErrorCode.ASSIGNED_COUPON_NOT_TARGET); + + verify(rLock).unlock(); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("이름 불일치 시 ASSIGNED_COUPON_NOT_TARGET 예외 (락 안에서 재검증)") + void nameMismatchInsideLock() throws InterruptedException { + // given + User user = mockUser("테스트악의적사용자", PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(true); + given(rLock.isLocked()).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(targetedHash()); + + // when, then + assertThatThrownBy(() -> assignedCouponService.issueCoupon(USERNAME, STORE_PIN)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue( + "globalErrorCode", GlobalErrorCode.ASSIGNED_COUPON_NOT_TARGET); + + verify(rLock).unlock(); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("이미 발급된 쿠폰은 ASSIGNED_COUPON_ALREADY_ISSUED 예외 (재발급 차단)") + void alreadyIssued() throws InterruptedException { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(true); + given(rLock.isLocked()).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(usedHash("AC-0001")); + + // when, then + assertThatThrownBy(() -> assignedCouponService.issueCoupon(USERNAME, STORE_PIN)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue( + "globalErrorCode", GlobalErrorCode.ASSIGNED_COUPON_ALREADY_ISSUED); + + verify(rLock).unlock(); + verifyNoInteractions(eventPublisher); + verify(valueOperations, never()).increment(anyString()); + } + + @Test + @DisplayName("정상 발급 - 쿠폰 번호 AC-포맷, Redis 갱신, S3 이벤트 발행, 락 해제") + void issueSuccess() throws InterruptedException { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(true); + given(rLock.isLocked()).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(targetedHash()); + given(valueOperations.increment("assigned-coupon:seq")).willReturn(1L); + given(valueOperations.increment("assigned-coupon:used:count")).willReturn(1L); + + // when + AssignedCouponResponse response = + assignedCouponService.issueCoupon(USERNAME, STORE_PIN); + + // then - 응답 검증 + assertThat(response.getCouponNumber()).isEqualTo("AC-0001"); + assertThat(response.getName()).isEqualTo(USER_NAME); + assertThat(response.getStatus()).isEqualTo("USED"); + assertThat(response.getIssuedAt()).isNotNull(); + assertThat(response.getUsedAt()).isNotNull(); + + // then - Redis 상태 갱신 검증 + ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); + verify(hashOperations).putAll(eq(TARGET_KEY), mapCaptor.capture()); + Map updated = mapCaptor.getValue(); + assertThat(updated.get("status")).isEqualTo("USED"); + assertThat(updated.get("couponNumber")).isEqualTo("AC-0001"); + assertThat(updated.get("claimedBy")).isEqualTo(USERNAME); + + // then - S3 로깅 이벤트 발행 검증 + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(AssignedCouponIssuedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + AssignedCouponIssuedEvent event = eventCaptor.getValue(); + assertThat(event.username()).isEqualTo(USERNAME); + assertThat(event.name()).isEqualTo(USER_NAME); + assertThat(event.phoneNum()).isEqualTo(PHONE_NUM_NORMALIZED); + assertThat(event.couponNumber()).isEqualTo("AC-0001"); + assertThat(event.issuedAt()).isNotNull(); + + // then - 락 해제 확인 + verify(rLock).unlock(); + } + + @Test + @DisplayName("발급 성공 시 사용 카운트 +1 증가") + void incrementUsedCount() throws InterruptedException { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(true); + given(rLock.isLocked()).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(targetedHash()); + given(valueOperations.increment("assigned-coupon:seq")).willReturn(3L); + + // when + assignedCouponService.issueCoupon(USERNAME, STORE_PIN); + + // then + verify(valueOperations).increment("assigned-coupon:used:count"); + } + + @Test + @DisplayName("쿠폰 번호 포맷 - AC-XXXX (4자리 0 패딩)") + void couponNumberFormatting() throws InterruptedException { + // given + User user = mockUser(USER_NAME, PHONE_NUM_RAW); + given(userRepository.findByUsernameAndIsDeletedFalse(USERNAME)) + .willReturn(Optional.of(user)); + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), any(TimeUnit.class))).willReturn(true); + given(rLock.isLocked()).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(hashOperations.entries(TARGET_KEY)).willReturn(targetedHash()); + given(valueOperations.increment("assigned-coupon:seq")).willReturn(42L); + + // when + AssignedCouponResponse response = + assignedCouponService.issueCoupon(USERNAME, STORE_PIN); + + // then + assertThat(response.getCouponNumber()).isEqualTo("AC-0042"); + } + } + + @Test + @DisplayName("null 요소가 섞여있어도 배치가 중단되지 않고 failed로 처리") + void registerWithNullElement() { + // given + List requests = new ArrayList<>(); + requests.add(null); + requests.add(new AssignedCouponTargetRequest("가나다", "010-1111-2222")); + given(redisTemplate.hasKey(anyString())).willReturn(false); + given(redisTemplate.opsForHash()).willReturn(hashOperations); + + // when + AssignedCouponRegisterResult result = + assignedCouponService.registerTargets(requests); + + // then + assertThat(result.newlyRegistered()).isEqualTo(1); + assertThat(result.failedPhoneNums()).contains((String) null); + } + + @Test + @DisplayName("공백 이름은 failed 처리 (좀비 데이터 방지)") + void registerWithBlankName() { + // given + List requests = + List.of(new AssignedCouponTargetRequest(" ", "010-1111-2222")); + + // when + AssignedCouponRegisterResult result = + assignedCouponService.registerTargets(requests); + + // then + assertThat(result.newlyRegistered()).isZero(); + assertThat(result.failedPhoneNums()).containsExactly("010-1111-2222"); + } +}