Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())) {
Comment thread
2ghrms marked this conversation as resolved.
redisTemplate.delete(phoneNumber);
throw new CustomAuthException(ErrorStatus.FAILED_TO_SEND_SMS);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -38,7 +38,7 @@ public class AligoSmsClient {
public AligoSendResponse sendSms(String phoneNumber, String message, String name) {
MultiValueMap<String, String> 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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClientRequest> 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<HttpMessageWriter<?>> messageWriters() {
return strategies.messageWriters();
}

@Override
public Optional<ServerHttpRequest> serverRequest() {
return Optional.empty();
}

@Override
public Map<String, Object> 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, "<html>error</html>", MediaType.TEXT_HTML);

// 2. When
AligoException exception = assertThrows(AligoException.class,
() -> client.sendSms(RECEIVER, MESSAGE, "사용자"));

// 3. Then
assertEquals(ErrorStatus.FAILED_TO_PARSE_ALIGO, exception.getCode());
}
}
Loading