From 8544c9e53b352f869d83f643a732ff3f616405a8 Mon Sep 17 00:00:00 2001 From: Tim Liang Date: Sat, 21 Mar 2026 15:00:23 -0400 Subject: [PATCH] refactor WindmillNotificationClient and implement sendEmail function with unit tests --- .../WindmillNotificationClient.java | 157 ++++++++++++------ src/main/Security/EnvUtil.java | 24 +++ .../NotifyIdPickupServiceTest.java | 4 +- .../WindmillNotificationClientTest.java | 72 ++++++-- 4 files changed, 190 insertions(+), 67 deletions(-) create mode 100644 src/main/Security/EnvUtil.java diff --git a/src/main/Notification/WindmillNotificationClient.java b/src/main/Notification/WindmillNotificationClient.java index aeb9182f..fde53705 100644 --- a/src/main/Notification/WindmillNotificationClient.java +++ b/src/main/Notification/WindmillNotificationClient.java @@ -4,7 +4,6 @@ import com.google.gson.Gson; import java.io.IOException; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; @@ -12,61 +11,100 @@ import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; +import static Security.EnvUtil.requireEnv; +import static Security.EnvUtil.requireEnvWithPattern; + @Slf4j public class WindmillNotificationClient { - private final OkHttpClient client; - private final Gson gson; + private static final Pattern PHONE_PATTERN = Pattern.compile("\\+1\\d{10}"); // +1 followed by 10 digits + private static final Pattern EMAIL_PATTERN + = Pattern.compile("^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9]([a-zA-Z0-9.\\-]*[a-zA-Z0-9])?\\.[a-zA-Z]{2,}$"); + private final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + private final Gson gson = new Gson(); private final String WINDMILL_URL; private final String WINDMILL_TOKEN; private final String TWILIO_PHONE_NUMBER; + private final String KEEPID_EMAIL_ADDRESS; private final Map twilioResource; - private final Pattern PHONE_PATTERN = Pattern.compile("\\+1\\d{10}"); // +1 followed by 10 digits + private final Map smtpResource; public WindmillNotificationClient() { - this.client = new OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build(); - this.gson = new Gson(); - this.WINDMILL_URL = System.getenv("WINDMILL_URL"); - this.WINDMILL_TOKEN = System.getenv("WINDMILL_TOKEN"); - this.TWILIO_PHONE_NUMBER = System.getenv("TWILIO_PHONE_NUMBER"); - this.twilioResource = new HashMap<>(); - String TWILIO_ACCOUNT_SID = System.getenv("ACCOUNT_SID"); - String TWILIO_AUTH_TOKEN = System.getenv("AUTH_TOKEN_TWILIO"); - this.twilioResource.put("accountSid", TWILIO_ACCOUNT_SID); - this.twilioResource.put("token", TWILIO_AUTH_TOKEN); + WINDMILL_URL = requireEnv("WINDMILL_URL"); + WINDMILL_TOKEN = requireEnv("WINDMILL_TOKEN"); + TWILIO_PHONE_NUMBER = requireEnvWithPattern("TWILIO_PHONE_NUMBER", PHONE_PATTERN); + KEEPID_EMAIL_ADDRESS = requireEnvWithPattern("EMAIL_ADDRESS", EMAIL_PATTERN); + + twilioResource = Map.of( + "accountSid", requireEnv("ACCOUNT_SID"), + "token", requireEnv("AUTH_TOKEN_TWILIO") + ); + + smtpResource = Map.of( + "host", requireEnv("EMAIL_HOST"), + "port", requireEnv("EMAIL_PORT"), + "user", KEEPID_EMAIL_ADDRESS, + "password", requireEnv("EMAIL_PASSWORD") + ); } - // Constructor for testing - public WindmillNotificationClient(String windmillUrl, String windmillToken, String twilioPhoneNumber, - String twilioAccountSid, String twilioAuthToken) { - this.client = new OkHttpClient(); - this.gson = new Gson(); + // constructor for testing + public WindmillNotificationClient(String windmillUrl, String windmillToken, + String twilioPhoneNumber, String twilioAccountSid, String twilioAuthToken, + String keepidEmailAddress, String emailHost, String emailPort, String emailPassword) { this.WINDMILL_URL = windmillUrl; this.WINDMILL_TOKEN = windmillToken; this.TWILIO_PHONE_NUMBER = twilioPhoneNumber; - this.twilioResource = new HashMap<>(); - this.twilioResource.put("accountSid", twilioAccountSid); - this.twilioResource.put("token", twilioAuthToken); + this.KEEPID_EMAIL_ADDRESS = keepidEmailAddress; + + this.twilioResource = Map.of( + "accountSid", twilioAccountSid, + "token", twilioAuthToken + ); + + this.smtpResource = Map.of( + "host", emailHost, + "password", emailPassword, + "port", emailPort, + "user", keepidEmailAddress + ); + } + + public static boolean isValidPhoneNumber(String phoneNumber) { + return phoneNumber != null && PHONE_PATTERN.matcher(phoneNumber).matches(); } - public boolean isValidPhoneNumber(String phoneNumber) { - return phoneNumber != null && this.PHONE_PATTERN.matcher(phoneNumber).matches(); + public static boolean isValidEmail(String email) { + return email != null && EMAIL_PATTERN.matcher(email).matches(); } - public void executeRequest(Request request, Callback callback) { + public void executeRequest(Request request) { + Callback callback = new Callback() { + public void onFailure(@NotNull Call call, @NotNull IOException e) { + log.error("executeRequest failed: " + e.getMessage()); + } + + public void onResponse(@NotNull Call call, @NotNull Response response) { + if (response.isSuccessful()) { + log.info("executeRequest completed successfully. Status: {}", response.code()); + } else { + log.warn("executeRequest completed but failed. Status: {}", response.code()); + } + } + }; client.newCall(request).enqueue(callback); } public void sendSms(String to, String message) { if (!isValidPhoneNumber(to)) { - log.error("sendSms failed: invalid phone number provided: {}", to); + log.error("sendSms failed: invalid phone number provided"); return; } if (message == null || message.isBlank()) { - log.error("sendSms failed: empty message provided: {}", message); + log.error("sendSms failed: empty message provided"); return; } @@ -87,26 +125,45 @@ public void sendSms(String to, String message) { .addHeader("Authorization", "Bearer " + this.WINDMILL_TOKEN) .build(); - log.info("Sending SMS to {} with message: {}", to, message); + log.info("Sending SMS notification request to windmill webhook endpoint"); - Callback callback = new Callback() { - public void onFailure(@NotNull Call call, @NotNull IOException e) { - log.error("sendSms failed: " + e.getMessage()); - } + executeRequest(request); + } - public void onResponse(@NotNull Call call, @NotNull Response response) { - try (response) { - if (response.isSuccessful()) { - log.info("sent SMS successfully. Status: {}", response.code()); - } else { - log.warn("SMS request completed but failed. Status: {}, Body: {}", - response.code(), response.body() != null ? response.body().string() : ""); - } - } catch (IOException e) { - log.error("caught error reading SMS response: " + e.getMessage()); - } - } - }; - executeRequest(request, callback); + public void sendEmail(String toEmailAddress, String message, String subjectLine) { + if (!isValidEmail(toEmailAddress)) { + log.error("sendEmail failed: invalid to email address provided"); + return; + } + if (message == null || message.isBlank()) { + log.error("sendEmail failed: empty message provided"); + return; + } + if (subjectLine == null || subjectLine.isBlank()) { + log.error("sendEmail failed: empty subject line provided"); + return; + } + + Map payload = Map.of( + "method", "email", + "message", message, + "email_config", Map.of( + "email_auth", smtpResource, + "to_email_address", toEmailAddress, + "from_email_address", this.KEEPID_EMAIL_ADDRESS, + "subject", subjectLine + ), + "sms_config", Map.of() + ); + + Request request = new Request.Builder() + .url(this.WINDMILL_URL) + .post(RequestBody.create(gson.toJson(payload), MediaType.parse("application/json"))) + .addHeader("Authorization", "Bearer " + this.WINDMILL_TOKEN) + .build(); + + log.info("Sending email notification request to windmill webhook endpoint"); + + executeRequest(request); } } diff --git a/src/main/Security/EnvUtil.java b/src/main/Security/EnvUtil.java new file mode 100644 index 00000000..fd2b2811 --- /dev/null +++ b/src/main/Security/EnvUtil.java @@ -0,0 +1,24 @@ +package Security; + +import java.util.regex.Pattern; + +public class EnvUtil { + public static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required env var: " + name); + } + return value; + } + + public static String requireEnvWithPattern(String name, Pattern pattern) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required env var: " + name); + } + if (!pattern.matcher(value).matches()) { + throw new IllegalStateException("env var: " + name + " must be of pattern: " + pattern); + } + return value; + } +} diff --git a/src/test/NotificationTest/NotifyIdPickupServiceTest.java b/src/test/NotificationTest/NotifyIdPickupServiceTest.java index 6972e49c..420fc865 100644 --- a/src/test/NotificationTest/NotifyIdPickupServiceTest.java +++ b/src/test/NotificationTest/NotifyIdPickupServiceTest.java @@ -24,7 +24,9 @@ public void setUp() { activityDao = ActivityDaoFactory.create(DeploymentLevel.IN_MEMORY); notificationClient = new WindmillNotificationClient( - "http://localhost:9999", "fake-token", "+10000000000", "fake-sid", "fake-auth"); + "http://localhost:9999", "fake-token", + "+10000000000", "fake-sid", "fake-auth", + "fake_email", "fake_host", "fake_port", "fake_password"); } @After diff --git a/src/test/NotificationTest/WindmillNotificationClientTest.java b/src/test/NotificationTest/WindmillNotificationClientTest.java index ee9ebb87..c0e19183 100644 --- a/src/test/NotificationTest/WindmillNotificationClientTest.java +++ b/src/test/NotificationTest/WindmillNotificationClientTest.java @@ -2,7 +2,6 @@ import Notification.WindmillNotificationClient; import lombok.extern.slf4j.Slf4j; -import okhttp3.Callback; import okhttp3.Request; import org.junit.Test; @@ -11,17 +10,14 @@ @Slf4j public class WindmillNotificationClientTest { - - WindmillNotificationClient client = new WindmillNotificationClient("http://localhost", - "test_windmill_token", "test_twilio_phone_number", - "test_twilio_account_sid", "test_twilio_auth_token"); @Test public void sendSMSSuccess() { - var testClient = new WindmillNotificationClient("http://localhost", - "test_windmill_token", "test_twilio_phone_number", - "test_twilio_account_sid", "test_twilio_auth_token") { + var testClient = new WindmillNotificationClient( + "http://localhost", "test_windmill_token", + "test_twilio_phone_number", "test_twilio_account_sid", "test_twilio_auth_token", + "fake_email", "fake_host", "fake_port", "fake_password") { @Override - public void executeRequest(Request request, Callback callback) { + public void executeRequest(Request request) { // Don't actually send, just verify the request looks right assertNotNull(request); assertEquals("POST", request.method()); @@ -31,18 +27,62 @@ public void executeRequest(Request request, Callback callback) { assertDoesNotThrow(() -> testClient.sendSms("+12025551234", "Test")); } + @Test + public void sendEmailSuccess() { + var testClient = new WindmillNotificationClient( + "http://localhost", "test_windmill_token", + "test_twilio_phone_number", "test_twilio_account_sid", "test_twilio_auth_token", + "fake_email", "fake_host", "fake_port", "fake_password") { + @Override + public void executeRequest(Request request) { + // Don't actually send, just verify the request looks right + assertNotNull(request); + assertEquals("POST", request.method()); + } + }; + + assertDoesNotThrow(() -> testClient.sendEmail("foo@example.com", "Test", "Test")); + } + @Test public void testValidPhoneNumbers() { - assertTrue(client.isValidPhoneNumber("+12025551234")); - assertTrue(client.isValidPhoneNumber("+19999999999")); + assertTrue(WindmillNotificationClient.isValidPhoneNumber("+12025551234")); + assertTrue(WindmillNotificationClient.isValidPhoneNumber("+19999999999")); } @Test public void testInvalidPhoneNumbers() { - assertFalse(client.isValidPhoneNumber("12025551234")); // missing + - assertFalse(client.isValidPhoneNumber("+44123456789")); // wrong country code - assertFalse(client.isValidPhoneNumber("+1202555123")); // too few digits - assertFalse(client.isValidPhoneNumber("+120255512345")); // too many digits - assertFalse(client.isValidPhoneNumber(null)); + assertFalse(WindmillNotificationClient.isValidPhoneNumber("12025551234")); // missing + + assertFalse(WindmillNotificationClient.isValidPhoneNumber("+44123456789")); // wrong country code + assertFalse(WindmillNotificationClient.isValidPhoneNumber("+1202555123")); // too few digits + assertFalse(WindmillNotificationClient.isValidPhoneNumber("+120255512345")); // too many digits + assertFalse(WindmillNotificationClient.isValidPhoneNumber(null)); + } + + @Test + public void testValidEmails() { + assertTrue(WindmillNotificationClient.isValidEmail("user@example.com")); + assertTrue(WindmillNotificationClient.isValidEmail("user.name@example.com")); + assertTrue(WindmillNotificationClient.isValidEmail("user+tag@example.com")); + assertTrue(WindmillNotificationClient.isValidEmail("user_name@example.org")); + assertTrue(WindmillNotificationClient.isValidEmail("user123@sub.domain.com")); + assertTrue(WindmillNotificationClient.isValidEmail("u@example.io")); + assertTrue(WindmillNotificationClient.isValidEmail("test@my-domain.co.uk")); + } + + @Test + public void testInvalidEmails() { + assertFalse(WindmillNotificationClient.isValidEmail("user@")); + assertFalse(WindmillNotificationClient.isValidEmail("@example.com")); + assertFalse(WindmillNotificationClient.isValidEmail("userexample.com")); + assertFalse(WindmillNotificationClient.isValidEmail("user@@example.com")); + assertFalse(WindmillNotificationClient.isValidEmail("us er@example.com")); + assertFalse(WindmillNotificationClient.isValidEmail("user@exa mple.com")); + assertFalse(WindmillNotificationClient.isValidEmail("user@.com")); + assertFalse(WindmillNotificationClient.isValidEmail("user@-example.com")); + assertFalse(WindmillNotificationClient.isValidEmail("user@example.c")); + assertFalse(WindmillNotificationClient.isValidEmail("user@example")); + assertFalse(WindmillNotificationClient.isValidEmail("")); + assertFalse(WindmillNotificationClient.isValidEmail(null)); } }