diff --git a/config b/config index f85f4ad9..e94fd01c 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit f85f4ad9772dc105636672d8226b74027bbdbed7 +Subproject commit e94fd01c17a2d7adb82387bdbbaea8a9203c38d7 diff --git a/manifest b/manifest index 942cc46b..781a2b2b 160000 --- a/manifest +++ b/manifest @@ -1 +1 @@ -Subproject commit 942cc46beb5679586634b05e0366380f596a3e2e +Subproject commit 781a2b2be444ffec836367114e0c2baadb588b21 diff --git a/src/main/java/com/assu/server/domain/auth/controller/AuthController.java b/src/main/java/com/assu/server/domain/auth/controller/AuthController.java index 85fffe86..e70b22c7 100644 --- a/src/main/java/com/assu/server/domain/auth/controller/AuthController.java +++ b/src/main/java/com/assu/server/domain/auth/controller/AuthController.java @@ -1,5 +1,7 @@ package com.assu.server.domain.auth.controller; +import java.util.List; + import com.assu.server.domain.auth.dto.login.CommonLoginRequestDTO; import com.assu.server.domain.auth.dto.login.LoginResponseDTO; import com.assu.server.domain.auth.dto.login.RefreshResponseDTO; @@ -13,7 +15,13 @@ import com.assu.server.domain.auth.dto.ssu.USaintAuthRequestDTO; import com.assu.server.domain.auth.dto.ssu.USaintAuthResponseDTO; import com.assu.server.domain.auth.dto.email.EmailVerificationCheckRequestDTO; -import com.assu.server.domain.auth.service.*; +import com.assu.server.domain.auth.service.EmailAuthService; +import com.assu.server.domain.auth.service.LoginService; +import com.assu.server.domain.auth.service.LogoutService; +import com.assu.server.domain.auth.service.PhoneAuthService; +import com.assu.server.domain.auth.service.SSUAuthService; +import com.assu.server.domain.auth.service.SignUpService; +import com.assu.server.domain.auth.service.WithdrawalService; import com.assu.server.domain.common.entity.enums.University; import com.assu.server.global.apiPayload.BaseResponse; import com.assu.server.global.apiPayload.code.status.SuccessStatus; @@ -22,6 +30,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.RequestBody; import jakarta.validation.Valid; diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/PartnerBatchSignUpItemDTO.java b/src/main/java/com/assu/server/domain/auth/dto/signup/PartnerBatchSignUpItemDTO.java new file mode 100644 index 00000000..d106b799 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/PartnerBatchSignUpItemDTO.java @@ -0,0 +1,35 @@ +package com.assu.server.domain.auth.dto.signup; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "제휴업체 단체 가입 개별 요청 정보") +public record PartnerBatchSignUpItemDTO( + @Schema(description = "이메일 주소", example = "partner1@example.com") + @Email(message = "올바른 이메일 형식이 아닙니다.") + @NotBlank(message = "이메일은 필수입니다.") + String email, + + @Schema(description = "비밀번호(평문)", example = "Password123!") + @Size(min = 8, max = 72, message = "비밀번호는 8~72자여야 합니다.") + @NotBlank(message = "비밀번호는 필수입니다.") + String password, + + @Schema(description = "업체명", example = "숭실카페 1호점") + @Size(min = 1, max = 50, message = "업체명은 1~50자여야 합니다.") + @NotBlank(message = "업체명은 필수입니다.") + String name, + + @Schema(description = "도로명 주소", example = "서울특별시 동작구 상도로 369") + String roadAddress, + + @Schema(description = "위도", example = "37.4963") + Double latitude, + + @Schema(description = "경도", example = "126.9573") + Double longitude +) { +} diff --git a/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java index 9dfe4f59..a8c16e83 100644 --- a/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java @@ -7,6 +7,7 @@ import com.assu.server.domain.auth.exception.CustomAuthException; import com.assu.server.infra.aligo.client.AligoSmsClient; import com.assu.server.infra.aligo.dto.AligoSendResponse; +import com.assu.server.infra.aligo.exception.AligoException; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; @@ -41,10 +42,16 @@ public void checkAndSendAuthNumber(String phoneNumber) { String message = "[ASSU] 인증번호: " + authNumber; - AligoSendResponse response = aligoSmsClient.sendSms(phoneNumber, message, "사용자"); + AligoSendResponse response; + try { + response = aligoSmsClient.sendSms(phoneNumber, message, "사용자"); + } catch (AligoException e) { + redisTemplate.delete(phoneNumber); + throw e; + } // 실패 처리 - if (!response.getResult_code().equals("1")) { + if (!"1".equals(response.getResult_code())) { redisTemplate.delete(phoneNumber); throw new CustomAuthException(ErrorStatus.FAILED_TO_SEND_SMS); } diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpService.java b/src/main/java/com/assu/server/domain/auth/service/SignUpService.java index 8995c942..5ba216a5 100644 --- a/src/main/java/com/assu/server/domain/auth/service/SignUpService.java +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpService.java @@ -1,15 +1,20 @@ package com.assu.server.domain.auth.service; import com.assu.server.domain.auth.dto.signup.AdminSignUpRequestDTO; +import com.assu.server.domain.auth.dto.signup.PartnerBatchSignUpItemDTO; import com.assu.server.domain.auth.dto.signup.PartnerSignUpRequestDTO; import com.assu.server.domain.auth.dto.signup.SignUpResponseDTO; import com.assu.server.domain.auth.dto.signup.StudentTokenSignUpRequestDTO; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + public interface SignUpService { SignUpResponseDTO signupSsuStudent(StudentTokenSignUpRequestDTO req); SignUpResponseDTO signupPartner(PartnerSignUpRequestDTO req, MultipartFile licenseImage); + List signupBatchPartner(List requests); + SignUpResponseDTO signupAdmin(AdminSignUpRequestDTO req, MultipartFile signImage); } diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java index 0d0ea292..70b5b63a 100644 --- a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java @@ -198,6 +198,66 @@ public SignUpResponseDTO signupPartner(PartnerSignUpRequestDTO req, MultipartFil return SignUpResponseDTO.from(member, null); } + @Override + public List signupBatchPartner(List requests) { + return requests.stream().map(req -> { + Member member = memberRepository.save( + Member.builder() + .isLocationTermAgreed(true) + .isMarketingTermAgreed(true) + .role(UserRole.PARTNER) + .isActivated(ActivationStatus.SUSPEND) + .build()); + + RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); + adapter.registerCredentials(member, req.email(), req.password()); + + String roadAddress = req.roadAddress() != null ? req.roadAddress() : ""; + Double lat = req.latitude() != null ? req.latitude() : 0.0; + Double lng = req.longitude() != null ? req.longitude() : 0.0; + Point point = toPoint(lat, lng); + + Partner partner = partnerRepository.save( + Partner.builder() + .member(member) + .name(req.name()) + .phoneNum(null) + .isPhoneVerified(false) + .address(roadAddress) + .detailAddress(null) + .licenseUrl(null) + .point(point) + .latitude(lat) + .longitude(lng) + .build()); + member.setProfile(partner); + + Optional storeOpt = storeRepository.findBySameAddress(roadAddress, null); + if (storeOpt.isPresent()) { + Store store = storeOpt.get(); + store.linkPartner(partner); + store.setName(req.name()); + store.setGeo(lat, lng, point); + storeRepository.save(store); + } else { + Store newly = Store.builder() + .partner(partner) + .rate(0) + .isActivate(ActivationStatus.SUSPEND) + .name(req.name()) + .address(roadAddress) + .detailAddress(null) + .latitude(lat) + .longitude(lng) + .point(point) + .build(); + storeRepository.save(newly); + } + + return SignUpResponseDTO.from(member, null); + }).toList(); + } + @Override public SignUpResponseDTO signupAdmin(AdminSignUpRequestDTO req, MultipartFile signImage) { if (partnerRepository.existsByPhoneNum(req.phoneNumber()) diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnerController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnerController.java index b5597b89..7ab8e338 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnerController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnerController.java @@ -1,5 +1,8 @@ package com.assu.server.domain.backoffice.controller; +import com.assu.server.domain.auth.dto.signup.PartnerBatchSignUpItemDTO; +import com.assu.server.domain.auth.dto.signup.SignUpResponseDTO; +import com.assu.server.domain.auth.service.SignUpService; import com.assu.server.domain.backoffice.annotation.BackofficeAudited; import com.assu.server.domain.backoffice.dto.BackofficeDocumentUrlResponseDTO; import com.assu.server.domain.backoffice.dto.BackofficeMemberSummaryDTO; @@ -9,14 +12,20 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "Backoffice", description = "백오피스 운영 API") @RestController @RequiredArgsConstructor @@ -25,6 +34,7 @@ public class BackofficePartnerController { private final BackofficeMemberService backofficeMemberService; + private final SignUpService signUpService; @Operation( summary = "사업자등록증 조회 API", @@ -68,4 +78,18 @@ public BaseResponse getLicenseUrl( public BaseResponse verifyLicense(@PathVariable Long memberId) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficeMemberService.verifyPartnerLicense(memberId)); } + + @BackofficeAudited(action = "PARTNER_BATCH_SIGNUP") + @Operation( + summary = "제휴업체 단체 회원가입 API", + description = "이메일, 비밀번호, 업체명, 도로명 주소, 위도, 경도 목록을 받아 제휴업체 계정들을 일괄 생성합니다." + ) + @PostMapping(value = "/batch-signup", consumes = MediaType.APPLICATION_JSON_VALUE) + public BaseResponse> signupBatchPartner( + @RequestBody + @Valid + List requests + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, signUpService.signupBatchPartner(requests)); + } } diff --git a/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java b/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java index 83c6038a..52debe29 100644 --- a/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java +++ b/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java @@ -101,11 +101,12 @@ public CertificationProgressResponseDTO handleCertification(GroupSessionRequest if (!matched) { List currentCertifiedUserIds = sessionManager.snapshotUserIds(sessionId); CertificationProgressResponseDTO response = new CertificationProgressResponseDTO( - "fail", - currentCertifiedUserIds.size(), "mismatch", + currentCertifiedUserIds.size(), + "학생과 매치되지 않는 정보입니다.", currentCertifiedUserIds ); + messagingTemplate.convertAndSend("/certification/progress/" + sessionId, response); return response; } diff --git a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java index 203ae355..5be33532 100644 --- a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java +++ b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java @@ -299,6 +299,22 @@ public BaseResponse deletePartnership( return BaseResponse.onSuccess(SuccessStatus._OK, null); } + @Operation( + summary = "대기 중인 제휴 계약서 삭제 API", + description = "- 관리자 관점에서 대기 중(SUSPEND) 상태인 제휴 계약서를 삭제합니다.\n" + + "- 로그인한 관리자의 ID와 계약서의 관리자 ID가 일치해야 합니다.\n" + + "- 계약서의 상태가 SUSPEND(대기 중)여야 삭제가 가능합니다.\n" + ) + @DeleteMapping("/suspended/{paperId}") + @PreAuthorize("hasRole('ADMIN')") + public BaseResponse deleteSuspendedPaper( + @PathVariable @Parameter(required = true) Long paperId, + @AuthenticationPrincipal PrincipalDetails pd + ) { + partnershipService.deleteSuspendedPaper(paperId, pd.getId()); + return BaseResponse.onSuccess(SuccessStatus._OK, null); + } + @Operation( summary = "제휴 중인 가게 조회 API", description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-2241197c19ed81b1b9adf724adc4600c)\n" + diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java index c82e7c3e..5d9a2d8e 100644 --- a/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java @@ -35,6 +35,8 @@ PartnershipStatusUpdateResponseDTO updatePartnershipStatus( void deletePartnership(Long paperId, Long memberId, UserRole role); + void deleteSuspendedPaper(Long paperId, Long adminId); + AdminPartnershipCheckResponseDTO checkPartnershipWithPartner(Long adminId, Long partnerId); PartnerPartnershipCheckResponseDTO checkPartnershipWithAdmin(Long partnerId, Long adminId); } diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java index e51569cc..c29a4f0c 100644 --- a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java @@ -425,6 +425,44 @@ public void deletePartnership(Long paperId, Long memberId, UserRole role) { } } + @Override + @Transactional + public void deleteSuspendedPaper(Long paperId, Long adminId) { + Paper paper = paperRepository.findById(paperId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + + if (!paper.getAdmin().getId().equals(adminId)) { + throw new GeneralException(ErrorStatus._FORBIDDEN); + } + + if (paper.getIsActivated() != ActivationStatus.SUSPEND) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + List contentsToDelete = paperContentRepository.findByPaperId(paperId); + if (contentsToDelete != null && !contentsToDelete.isEmpty()) { + List contentIds = contentsToDelete.stream() + .map(PaperContent::getId) + .toList(); + + goodsRepository.deleteAllByContentIds(contentIds); + paperContentRepository.deleteAll(contentsToDelete); + } + + Store store = paper.getStore(); + boolean isTempStore = (store != null && paper.getPartner() == null); + + paperRepository.delete(paper); + + if (isTempStore) { + Long storeId = store.getId(); + long remainingPaperRefs = paperRepository.countByStore_Id(storeId); + if (remainingPaperRefs == 0) { + storeRepository.delete(store); + } + } + } + // Todo: 추후 checkPartnershipWithPartner와 checkPartnershipWithAdmin를 Role 기반 로직으로 변경하여 메소드 합칠 것 @Override @Transactional(readOnly = true) diff --git a/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java b/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java index d5c89c0a..f5e6a3e8 100644 --- a/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java +++ b/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java @@ -1,9 +1,9 @@ package com.assu.server.infra.aligo.client; -import com.assu.server.domain.auth.exception.CustomAuthException; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.infra.aligo.dto.AligoSendResponse; import com.assu.server.infra.aligo.exception.AligoException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +22,7 @@ public class AligoSmsClient { private final WebClient webClient; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Value("${aligo.key}") private String apiKey; @@ -38,7 +38,7 @@ public class AligoSmsClient { public AligoSendResponse sendSms(String phoneNumber, String message, String name) { MultiValueMap params = new LinkedMultiValueMap<>(); params.add("key", apiKey); - params.add("userid", userId); + params.add("user_id", userId); params.add("sender", sender); params.add("receiver", phoneNumber); params.add("msg", message); @@ -52,16 +52,20 @@ public AligoSendResponse sendSms(String phoneNumber, String message, String name .retrieve() .onStatus( status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse.bodyToMono(String.class).flatMap(errorBody -> { - log.error("Aligo API 호출 실패. status={}, body={}", clientResponse.statusCode(), errorBody); - return Mono.error(new AligoException(ErrorStatus.FAILED_TO_SEND_SMS)); - }) + clientResponse -> clientResponse.bodyToMono(String.class) + .defaultIfEmpty("") + .flatMap(errorBody -> { + log.error("Aligo API 호출 실패. status={}, body={}", clientResponse.statusCode(), errorBody); + return Mono.error(new AligoException(ErrorStatus.FAILED_TO_SEND_SMS)); + }) ) .bodyToMono(String.class) .block(); try { - return objectMapper.readValue(body, AligoSendResponse.class); + return objectMapper.readerFor(AligoSendResponse.class) + .without(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(body); } catch (Exception e) { log.error("Aligo 응답 파싱 실패. 원본 body: {}", body, e); throw new AligoException(ErrorStatus.FAILED_TO_PARSE_ALIGO); diff --git a/src/test/java/com/assu/server/domain/auth/service/PhoneAuthServiceImplTest.java b/src/test/java/com/assu/server/domain/auth/service/PhoneAuthServiceImplTest.java index a21e8dde..f4374c96 100644 --- a/src/test/java/com/assu/server/domain/auth/service/PhoneAuthServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/auth/service/PhoneAuthServiceImplTest.java @@ -24,6 +24,7 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.infra.aligo.client.AligoSmsClient; import com.assu.server.infra.aligo.dto.AligoSendResponse; +import com.assu.server.infra.aligo.exception.AligoException; @ExtendWith(MockitoExtension.class) class PhoneAuthServiceImplTest { @@ -111,6 +112,25 @@ void checkAndSendAuthNumber_SmsFailed_DeletesCodeAndThrows() { verify(redisTemplate, times(1)).delete(PHONE); } + @Test + @DisplayName("SMS 발송 중 알리고 예외가 발생하면 저장했던 인증번호를 삭제하고 예외를 전파한다") + void checkAndSendAuthNumber_SmsThrowsAligoException_DeletesCodeAndRethrows() { + // 1. Given + when(partnerRepository.existsByPhoneNum(PHONE)).thenReturn(false); + when(adminRepository.existsByPhoneNum(PHONE)).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(aligoSmsClient.sendSms(eq(PHONE), anyString(), anyString())) + .thenThrow(new AligoException(ErrorStatus.FAILED_TO_PARSE_ALIGO)); + + // 2. When + AligoException exception = assertThrows(AligoException.class, + () -> phoneAuthService.checkAndSendAuthNumber(PHONE)); + + // 3. Then + assertEquals(ErrorStatus.FAILED_TO_PARSE_ALIGO, exception.getCode()); + verify(redisTemplate, times(1)).delete(PHONE); + } + @Test @DisplayName("저장된 인증번호가 없으면 NOT_VERIFIED_PHONE_NUMBER 예외가 발생한다") void verifyAuthNumber_NoStoredCode_ThrowsException() { diff --git a/src/test/java/com/assu/server/infra/aligo/client/AligoSmsClientTest.java b/src/test/java/com/assu/server/infra/aligo/client/AligoSmsClientTest.java new file mode 100644 index 00000000..7dbac52e --- /dev/null +++ b/src/test/java/com/assu/server/infra/aligo/client/AligoSmsClientTest.java @@ -0,0 +1,201 @@ +package com.assu.server.infra.aligo.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.aligo.dto.AligoSendResponse; +import com.assu.server.infra.aligo.exception.AligoException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import reactor.core.publisher.Mono; + +class AligoSmsClientTest { + + private static final String API_KEY = "test-api-key"; + private static final String USER_ID = "test-user-id"; + private static final String SENDER = "0212345678"; + private static final String RECEIVER = "01012345678"; + private static final String MESSAGE = "[ASSU] 인증번호: 123456"; + + private final AtomicReference capturedRequest = new AtomicReference<>(); + + private AligoSmsClient buildClient(HttpStatus status, String responseBody, MediaType contentType) { + ExchangeFunction exchangeFunction = request -> { + capturedRequest.set(request); + ClientResponse.Builder builder = ClientResponse.create(status); + if (contentType != null) { + builder.header(HttpHeaders.CONTENT_TYPE, contentType.toString()); + } + return Mono.just(builder.body(responseBody).build()); + }; + + WebClient webClient = WebClient.builder().exchangeFunction(exchangeFunction).build(); + AligoSmsClient client = new AligoSmsClient(webClient, new ObjectMapper()); + ReflectionTestUtils.setField(client, "apiKey", API_KEY); + ReflectionTestUtils.setField(client, "userId", USER_ID); + ReflectionTestUtils.setField(client, "sender", SENDER); + return client; + } + + private String capturedFormBody() { + ClientRequest request = capturedRequest.get(); + assertNotNull(request, "요청이 전송되지 않았습니다."); + + MockClientHttpRequest mockRequest = new MockClientHttpRequest(HttpMethod.POST, "/"); + ExchangeStrategies strategies = ExchangeStrategies.withDefaults(); + + request.body().insert(mockRequest, new BodyInserter.Context() { + @Override + public List> messageWriters() { + return strategies.messageWriters(); + } + + @Override + public Optional serverRequest() { + return Optional.empty(); + } + + @Override + public Map hints() { + return Collections.emptyMap(); + } + }).block(); + + return mockRequest.getBodyAsString().block(); + } + + @BeforeEach + void resetCapture() { + capturedRequest.set(null); + } + + @Test + @DisplayName("알리고 규격대로 사용자 ID를 user_id 파라미터로 전송한다") + void sendSms_SendsUserIdWithSpecCompliantParameterName() { + // 1. Given + AligoSmsClient client = buildClient( + HttpStatus.OK, + "{\"result_code\":1,\"message\":\"success\",\"msg_id\":123,\"success_cnt\":1,\"error_cnt\":0,\"msg_type\":\"SMS\"}", + MediaType.APPLICATION_JSON); + + // 2. When + client.sendSms(RECEIVER, MESSAGE, "사용자"); + + // 3. Then + String body = capturedFormBody(); + assertTrue(body.contains("user_id=" + USER_ID), "알리고 규격 파라미터명은 user_id 입니다. 실제 전송 body: " + body); + assertFalse(body.contains("userid="), "규격에 없는 userid 파라미터가 전송되었습니다. 실제 전송 body: " + body); + } + + @Test + @DisplayName("알리고 필수 파라미터가 폼 데이터로 모두 전송된다") + void sendSms_SendsAllRequiredParameters() { + // 1. Given + AligoSmsClient client = buildClient( + HttpStatus.OK, + "{\"result_code\":1,\"message\":\"success\"}", + MediaType.APPLICATION_JSON); + + // 2. When + client.sendSms(RECEIVER, MESSAGE, "사용자"); + + // 3. Then + String body = capturedFormBody(); + assertTrue(body.contains("key=" + API_KEY), body); + assertTrue(body.contains("sender=" + SENDER), body); + assertTrue(body.contains("receiver=" + RECEIVER), body); + assertTrue(body.contains("msg_type=SMS"), body); + assertTrue(body.contains("msg="), body); + + assertEquals(MediaType.APPLICATION_FORM_URLENCODED, capturedRequest.get().headers().getContentType()); + } + + @Test + @DisplayName("result_code가 숫자 타입인 알리고 성공 응답을 파싱한다") + void sendSms_ParsesNumericResultCodeResponse() { + // 1. Given + AligoSmsClient client = buildClient( + HttpStatus.OK, + "{\"result_code\":1,\"message\":\"success\",\"msg_id\":123,\"success_cnt\":1,\"error_cnt\":0,\"msg_type\":\"SMS\"}", + MediaType.APPLICATION_JSON); + + // 2. When + AligoSendResponse response = client.sendSms(RECEIVER, MESSAGE, "사용자"); + + // 3. Then + assertEquals("1", response.getResult_code()); + assertEquals("success", response.getMessage()); + } + + @Test + @DisplayName("응답에 규격 외 필드가 포함되어도 파싱에 실패하지 않는다") + void sendSms_IgnoresUnknownResponseFields() { + // 1. Given + AligoSmsClient client = buildClient( + HttpStatus.OK, + "{\"result_code\":-101,\"message\":\"인증오류입니다.\",\"unknown_field\":\"x\"}", + MediaType.APPLICATION_JSON); + + // 2. When + AligoSendResponse response = client.sendSms(RECEIVER, MESSAGE, "사용자"); + + // 3. Then + assertEquals("-101", response.getResult_code()); + } + + @Test + @DisplayName("HTTP 에러 응답의 body가 비어 있어도 SMS 전송 실패 예외가 발생한다") + void sendSms_EmptyErrorBody_ThrowsSendFailure() { + // 1. Given + AligoSmsClient client = buildClient(HttpStatus.INTERNAL_SERVER_ERROR, "", null); + + // 2. When + AligoException exception = assertThrows(AligoException.class, + () -> client.sendSms(RECEIVER, MESSAGE, "사용자")); + + // 3. Then + assertEquals(ErrorStatus.FAILED_TO_SEND_SMS, exception.getCode()); + } + + @Test + @DisplayName("응답 본문이 JSON이 아니면 파싱 실패 예외가 발생한다") + void sendSms_NonJsonBody_ThrowsParseFailure() { + // 1. Given + AligoSmsClient client = buildClient(HttpStatus.OK, "error", MediaType.TEXT_HTML); + + // 2. When + AligoException exception = assertThrows(AligoException.class, + () -> client.sendSms(RECEIVER, MESSAGE, "사용자")); + + // 3. Then + assertEquals(ErrorStatus.FAILED_TO_PARSE_ALIGO, exception.getCode()); + } +}