From e38139749158e2b6bbae6fee943124ede2e6e789 Mon Sep 17 00:00:00 2001 From: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:30:20 +0000 Subject: [PATCH] fix: add sms provider --- .prettierrc | 2 + account-service/pom.xml | 5 + .../com/drinkool/services/AccountService.java | 5 +- .../com/drinkool/services/OtpService.java | 13 ++- .../java/com/drinkool/utils/SpeedSMSAPI.java | 110 ++++++++++++++++++ .../services/CustomerServiceTest.java | 38 +++++- .../com/drinkool/services/OtpServiceTest.java | 76 ++++++++++-- .../src/test/resources/application.properties | 3 + k8s.yaml | 5 + 9 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 account-service/src/main/java/com/drinkool/utils/SpeedSMSAPI.java diff --git a/.prettierrc b/.prettierrc index 7c25d7e..fcb9da8 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,5 +5,7 @@ "tabWidth": 2, "trailingComma": "all", "useTabs": true, + "xmlSortAttributesByKey": true, + "xmlQuoteAttributes": "double", "plugins": ["prettier-plugin-java", "@prettier/plugin-xml"] } diff --git a/account-service/pom.xml b/account-service/pom.xml index 2a3da27..4d7f284 100644 --- a/account-service/pom.xml +++ b/account-service/pom.xml @@ -49,6 +49,11 @@ lib ${project.version} + + io.quarkus + quarkus-junit5-mockito + test + io.quarkus quarkus-jdbc-h2 diff --git a/account-service/src/main/java/com/drinkool/services/AccountService.java b/account-service/src/main/java/com/drinkool/services/AccountService.java index 5c7cb22..3cd57ec 100644 --- a/account-service/src/main/java/com/drinkool/services/AccountService.java +++ b/account-service/src/main/java/com/drinkool/services/AccountService.java @@ -22,10 +22,7 @@ public class AccountService { .entity("InvalidPhoneNumber") .build(); - String otp = otpService.generateAndSaveOtp(input.phone); - - // Ở đây bạn sẽ gọi Kafka để gửi SMS thực tế - System.out.println("OTP cho " + input.phone + " là: " + otp); + otpService.generateAndSaveOtp(input.phone); return Response.accepted().build(); } diff --git a/account-service/src/main/java/com/drinkool/services/OtpService.java b/account-service/src/main/java/com/drinkool/services/OtpService.java index e97346b..edfb843 100644 --- a/account-service/src/main/java/com/drinkool/services/OtpService.java +++ b/account-service/src/main/java/com/drinkool/services/OtpService.java @@ -1,9 +1,11 @@ package com.drinkool.services; +import com.drinkool.utils.SpeedSMSAPI; import io.quarkus.redis.datasource.RedisDataSource; import io.quarkus.redis.datasource.value.ValueCommands; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import java.io.IOException; import java.util.Random; @ApplicationScoped @@ -11,18 +13,25 @@ public class OtpService { private final ValueCommands countCommands; + @Inject + SpeedSMSAPI smsApi; + @Inject public OtpService(RedisDataSource ds) { this.countCommands = ds.value(String.class); } - public String generateAndSaveOtp(String phone) { + public void generateAndSaveOtp(String phone) { String otp = String.format("%06d", new Random().nextInt(1000000)); String key = "otp:" + phone; countCommands.setex(key, 300, otp); - return otp; + try { + smsApi.sendSMS(phone, "Mã OTP drinkool của bạn là " + otp, 2, "Drinkool"); + } catch (IOException e) { + e.printStackTrace(); + } } public boolean verifyOtp(String phone, String inputOtp) { diff --git a/account-service/src/main/java/com/drinkool/utils/SpeedSMSAPI.java b/account-service/src/main/java/com/drinkool/utils/SpeedSMSAPI.java new file mode 100644 index 0000000..f1d69a9 --- /dev/null +++ b/account-service/src/main/java/com/drinkool/utils/SpeedSMSAPI.java @@ -0,0 +1,110 @@ +package com.drinkool.utils; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.xml.bind.DatatypeConverter; +import java.io.*; +import java.net.*; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class SpeedSMSAPI { + + public static final String API_URL = "https://api.speedsms.vn/index.php"; + protected String mAccessToken; + + public SpeedSMSAPI( + @ConfigProperty(name = "sms.api.token") String accessToken + ) { + this.mAccessToken = accessToken; + } + + /** + * Get user information + * @param: none + * @return: json string + * */ + public String getUserInfo() throws IOException { + URI uri = URI.create(API_URL + "/user/info"); + HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); + conn.setRequestMethod("GET"); + String userCredentials = mAccessToken + ":x"; + String basicAuth = + "Basic " + + DatatypeConverter.printBase64Binary(userCredentials.getBytes()); + conn.setRequestProperty("Authorization", basicAuth); + + BufferedReader in = new BufferedReader( + new InputStreamReader(conn.getInputStream()) + ); + String inputLine = ""; + StringBuffer buffer = new StringBuffer(); + + while ((inputLine = in.readLine()) != null) { + buffer.append(inputLine); + } + in.close(); + return buffer.toString(); + } + + /** + * Send SMS + * @param + * @return: json string + * */ + public String sendSMS(String to, String content, int type, String sender) + throws IOException { + String json = + "{\"to\": [\"" + + to + + "\"], \"content\": \"" + + EncodeNonAsciiCharacters(content) + + "\", \"type\":" + + type + + ", \"brandname\":\"" + + sender + + "\"}"; + URI uri = URI.create(API_URL + "/sms/send"); + HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); + conn.setRequestMethod("POST"); + String userCredentials = mAccessToken + ":x"; + String basicAuth = + "Basic " + + DatatypeConverter.printBase64Binary(userCredentials.getBytes()); + conn.setRequestProperty("Authorization", basicAuth); + conn.setRequestProperty("Content-Type", "application/json"); + + conn.setDoOutput(true); + DataOutputStream wr = new DataOutputStream(conn.getOutputStream()); + wr.writeBytes(json); + wr.flush(); + wr.close(); + + BufferedReader in = new BufferedReader( + new InputStreamReader(conn.getInputStream()) + ); + String inputLine = ""; + StringBuffer buffer = new StringBuffer(); + + while ((inputLine = in.readLine()) != null) { + buffer.append(inputLine); + } + in.close(); + return buffer.toString(); + } + + private String EncodeNonAsciiCharacters(String value) { + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + int unit = (int) c; + if (unit > 127) { + String hex = String.format("%04x", (int) unit); + String encodedValue = "\\u" + hex; + sb.append(encodedValue); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/account-service/src/test/java/com/drinkool/services/CustomerServiceTest.java b/account-service/src/test/java/com/drinkool/services/CustomerServiceTest.java index a823961..767f990 100644 --- a/account-service/src/test/java/com/drinkool/services/CustomerServiceTest.java +++ b/account-service/src/test/java/com/drinkool/services/CustomerServiceTest.java @@ -1,16 +1,19 @@ package com.drinkool.services; import static io.restassured.RestAssured.*; -import static org.hamcrest.CoreMatchers.*; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; import com.drinkool.*; import com.drinkool.dtos.*; import com.drinkool.entities.CustomerEntity; -import io.quarkus.test.junit.QuarkusTest; +import com.drinkool.utils.SpeedSMSAPI; +import io.quarkus.test.junit.*; import io.restassured.http.ContentType; import jakarta.inject.Inject; -import org.junit.jupiter.api.Test; +import java.io.IOException; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; @QuarkusTest public class CustomerServiceTest { @@ -18,6 +21,15 @@ public class CustomerServiceTest { @Inject OtpService otpService; + SpeedSMSAPI smsApiMock; + + @BeforeEach + public void setup() { + SpeedSMSAPI mock = mock(SpeedSMSAPI.class); + QuarkusMock.installMockForType(mock, SpeedSMSAPI.class); + this.smsApiMock = mock; + } + @Test void testSignup() { String phone = Utils.generateRandomPhone(); @@ -114,7 +126,7 @@ public class CustomerServiceTest { } @Test - public void testQuickLogin_Success() { + public void testQuickLogin_Success() throws IOException { String phone = Utils.generateRandomPhone(); QuickSignup input = new QuickSignup(); @@ -129,7 +141,23 @@ public class CustomerServiceTest { QuickLogin payload = new QuickLogin(); payload.phone = phone; - payload.otp = otpService.generateAndSaveOtp(phone); + otpService.generateAndSaveOtp(phone); + + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass( + String.class + ); + + verify(smsApiMock).sendSMS( + eq(phone), + contentCaptor.capture(), + anyInt(), + anyString() + ); + + // 4. Trích xuất mã OTP từ nội dung tin nhắn đã bắt được + // Giả sử tin nhắn có định dạng: "... là 123456" + String sentMessage = contentCaptor.getValue(); + payload.otp = sentMessage.substring(sentMessage.length() - 6); given() .contentType(ContentType.JSON) diff --git a/account-service/src/test/java/com/drinkool/services/OtpServiceTest.java b/account-service/src/test/java/com/drinkool/services/OtpServiceTest.java index 0057f97..aa647d3 100644 --- a/account-service/src/test/java/com/drinkool/services/OtpServiceTest.java +++ b/account-service/src/test/java/com/drinkool/services/OtpServiceTest.java @@ -1,11 +1,17 @@ package com.drinkool.services; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; -import io.quarkus.test.junit.QuarkusTest; +import com.drinkool.utils.SpeedSMSAPI; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.junit.*; import jakarta.inject.Inject; +import java.io.IOException; import java.util.Random; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; @QuarkusTest public class OtpServiceTest { @@ -13,24 +19,58 @@ public class OtpServiceTest { @Inject OtpService otpService; + @Inject + RedisDataSource redisDataSource; + + SpeedSMSAPI smsApiMock; + + @BeforeEach + public void setup() { + SpeedSMSAPI mock = mock(SpeedSMSAPI.class); + QuarkusMock.installMockForType(mock, SpeedSMSAPI.class); + this.smsApiMock = mock; + } + // 1. Test case: Tạo và lưu OTP thành công @Test public void testGenerateAndSaveOtp() { String phone = "+8477" + String.format("%07d", new Random().nextInt(10000000)); - String otp = otpService.generateAndSaveOtp(phone); + String redisKey = "otp:" + phone; - assertNotNull(otp, "OTP sinh ra không được null"); - assertEquals(6, otp.length(), "OTP phải có độ dài đúng 6 ký tự"); - assertTrue(otp.matches("\\d{6}"), "OTP chỉ được chứa các chữ số"); + otpService.generateAndSaveOtp(phone); + + String otpFromRedis = redisDataSource.value(String.class).get(redisKey); + + assertNotNull(otpFromRedis, "Mã OTP phải tồn tại trong Redis"); + assertEquals(6, otpFromRedis.length(), "Mã OTP phải có 6 chữ số"); + + long ttl = redisDataSource.key().ttl(redisKey); + assertTrue(ttl > 0 && ttl <= 300, "TTL của OTP phải hợp lệ"); } // 2. Test case: Xác thực OTP thành công @Test - public void testVerifyOtpSuccess() { + public void testVerifyOtpSuccess() throws IOException { String phone = "+8477" + String.format("%07d", new Random().nextInt(10000000)); - String generatedOtp = otpService.generateAndSaveOtp(phone); + otpService.generateAndSaveOtp(phone); + + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass( + String.class + ); + + verify(smsApiMock).sendSMS( + eq(phone), + contentCaptor.capture(), + anyInt(), + anyString() + ); + + // 4. Trích xuất mã OTP từ nội dung tin nhắn đã bắt được + // Giả sử tin nhắn có định dạng: "... là 123456" + String sentMessage = contentCaptor.getValue(); + String generatedOtp = sentMessage.substring(sentMessage.length() - 6); // Xác thực với OTP vừa tạo ra boolean verifyResult = otpService.verifyOtp(phone, generatedOtp); @@ -68,10 +108,26 @@ public class OtpServiceTest { // 5. Test case: Đảm bảo OTP chỉ được sử dụng 1 lần (đã bị xóa sau khi verify thành công bằng lệnh getdel) @Test - public void testVerifyOtpConsumedAfterSuccess() { + public void testVerifyOtpConsumedAfterSuccess() throws IOException { String phone = "+8477" + String.format("%07d", new Random().nextInt(10000000)); - String generatedOtp = otpService.generateAndSaveOtp(phone); + otpService.generateAndSaveOtp(phone); + + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass( + String.class + ); + + verify(smsApiMock).sendSMS( + eq(phone), + contentCaptor.capture(), + anyInt(), + anyString() + ); + + // 4. Trích xuất mã OTP từ nội dung tin nhắn đã bắt được + // Giả sử tin nhắn có định dạng: "... là 123456" + String sentMessage = contentCaptor.getValue(); + String generatedOtp = sentMessage.substring(sentMessage.length() - 6); // Lần 1: Xác thực thành công assertTrue(otpService.verifyOtp(phone, generatedOtp)); diff --git a/account-service/src/test/resources/application.properties b/account-service/src/test/resources/application.properties index 59bc331..ddce574 100644 --- a/account-service/src/test/resources/application.properties +++ b/account-service/src/test/resources/application.properties @@ -8,3 +8,6 @@ quarkus.http.host=0.0.0.0 ## Orm %test.quarkus.hibernate-orm.database.generation=drop-and-create + +# Sms API +sms.api.token=your_sms_api_token_here \ No newline at end of file diff --git a/k8s.yaml b/k8s.yaml index 25dbdb6..decfebe 100644 --- a/k8s.yaml +++ b/k8s.yaml @@ -37,6 +37,11 @@ spec: secretKeyRef: name: postgres-credentials key: url + - name: SMS_API_TOKEN + valueFrom: + secretKeyRef: + name: sms-api-token + key: value resources: requests: memory: "32Mi"