The rate limiter buckets requests by the key returned here. Two strategies + * are provided so both README requirements are covered: + * + *
The active strategy is chosen in {@code application.yml} via + * {@code key-resolver: "#{@systemKeyResolver}"} (or {@code #{@clientKeyResolver}}). + * {@code systemKeyResolver} is {@link Primary} so it is used if a route omits an + * explicit key resolver. + */ @Configuration public class RateLimiterConfig { - private static final String SYSTEM_BUCKET = "system"; - private static final String CLIENT_HEADER = "X-Client-Id"; + static final String SYSTEM_BUCKET = "system"; + static final String CLIENT_HEADER = "X-Client-Id"; + static final String UNKNOWN_CLIENT = "unknown"; @Bean @Primary @@ -26,7 +47,7 @@ public KeyResolver clientKeyResolver() { if (clientId == null || clientId.isBlank()) { clientId = exchange.getRequest().getRemoteAddress() != null ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() - : "unknown"; + : UNKNOWN_CLIENT; } return Mono.just(clientId); }; diff --git a/api-gateway/src/test/java/com/redjanvier/apigateway/RateLimitingIntegrationTest.java b/api-gateway/src/test/java/com/redjanvier/apigateway/RateLimitingIntegrationTest.java new file mode 100644 index 0000000..1875a27 --- /dev/null +++ b/api-gateway/src/test/java/com/redjanvier/apigateway/RateLimitingIntegrationTest.java @@ -0,0 +1,113 @@ +package com.redjanvier.apigateway; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.ServerSocket; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.reactive.server.WebTestClient; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import redis.embedded.RedisServer; + +/** + * End-to-end test of the gateway's two headline responsibilities: + *
Uses a real, in-process Redis (embedded-redis, no Docker) and a stubbed + * downstream (MockWebServer), so it exercises the actual {@code RequestRateLimiter} + * Lua script and runs anywhere — locally and in CI. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class RateLimitingIntegrationTest { + + static RedisServer redisServer; + static MockWebServer downstream; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) throws IOException { + int redisPort = findFreePort(); + redisServer = new RedisServer(redisPort); + redisServer.start(); + + downstream = new MockWebServer(); + downstream.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200).setBody("ok"); + } + }); + downstream.start(); + + String base = "http://" + downstream.getHostName() + ":" + downstream.getPort(); + registry.add("spring.data.redis.host", () -> "localhost"); + registry.add("spring.data.redis.port", () -> redisPort); + registry.add("SMS_SERVICE_URI", () -> base); + registry.add("EMAIL_SERVICE_URI", () -> base); + } + + @AfterAll + static void tearDown() throws IOException { + if (downstream != null) { + downstream.shutdown(); + } + if (redisServer != null) { + redisServer.stop(); + } + } + + private static int findFreePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + @Autowired + private WebTestClient client; + + @Test + void routesToDownstreamThenRateLimitsTheBurst() { + // 1) Routing: the first request is proxied to the stub downstream. + client.post().uri("/api/v1/notifications/sms") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("ok"); + + // 2) Rate limiting: a rapid burst must be partly rejected with 429. + int ok = 1; + int limited = 0; + for (int i = 0; i < 40; i++) { + int status = client.post().uri("/api/v1/notifications/sms") + .exchange() + .returnResult(String.class) + .getStatus() + .value(); + if (status == 429) { + limited++; + } else if (status == 200) { + ok++; + } + } + + assertThat(limited) + .as("the rate limiter must reject part of a 41-request burst") + .isGreaterThan(0); + assertThat(ok) + .as("some requests should still pass through") + .isGreaterThanOrEqualTo(1) + .isLessThan(41); + } +} diff --git a/api-gateway/src/test/java/com/redjanvier/apigateway/config/RateLimiterConfigTest.java b/api-gateway/src/test/java/com/redjanvier/apigateway/config/RateLimiterConfigTest.java new file mode 100644 index 0000000..a03588d --- /dev/null +++ b/api-gateway/src/test/java/com/redjanvier/apigateway/config/RateLimiterConfigTest.java @@ -0,0 +1,75 @@ +package com.redjanvier.apigateway.config; + +import java.net.InetSocketAddress; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import reactor.test.StepVerifier; + +/** + * Unit tests for the rate-limiter key resolvers — no Spring context or Redis needed. + */ +class RateLimiterConfigTest { + + private final RateLimiterConfig config = new RateLimiterConfig(); + + @Test + void systemKeyResolver_alwaysReturnsTheSharedBucket() { + KeyResolver resolver = config.systemKeyResolver(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/api/v1/notifications/sms")); + + StepVerifier.create(resolver.resolve(exchange)) + .expectNext("system") + .verifyComplete(); + } + + @Test + void clientKeyResolver_usesClientIdHeaderWhenPresent() { + KeyResolver resolver = config.clientKeyResolver(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/x").header("X-Client-Id", "client-42")); + + StepVerifier.create(resolver.resolve(exchange)) + .expectNext("client-42") + .verifyComplete(); + } + + @Test + void clientKeyResolver_fallsBackToRemoteIpWhenHeaderMissing() { + KeyResolver resolver = config.clientKeyResolver(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/x").remoteAddress(new InetSocketAddress("10.1.2.3", 9999))); + + StepVerifier.create(resolver.resolve(exchange)) + .expectNext("10.1.2.3") + .verifyComplete(); + } + + @Test + void clientKeyResolver_fallsBackToRemoteIpWhenHeaderBlank() { + KeyResolver resolver = config.clientKeyResolver(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/x") + .header("X-Client-Id", " ") + .remoteAddress(new InetSocketAddress("10.9.8.7", 1234))); + + StepVerifier.create(resolver.resolve(exchange)) + .expectNext("10.9.8.7") + .verifyComplete(); + } + + @Test + void clientKeyResolver_returnsUnknownWhenNoHeaderAndNoRemoteAddress() { + KeyResolver resolver = config.clientKeyResolver(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/x")); + + StepVerifier.create(resolver.resolve(exchange)) + .expectNext("unknown") + .verifyComplete(); + } +} diff --git a/email-service/src/test/java/com/redjanvier/emailservice/EmailSendFailurePathTest.java b/email-service/src/test/java/com/redjanvier/emailservice/EmailSendFailurePathTest.java new file mode 100644 index 0000000..3b801f4 --- /dev/null +++ b/email-service/src/test/java/com/redjanvier/emailservice/EmailSendFailurePathTest.java @@ -0,0 +1,49 @@ +package com.redjanvier.emailservice; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * When the SMTP send throws, the record must still be persisted with status + * FAILED and the endpoint must answer 502 Bad Gateway. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class EmailSendFailurePathTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private JavaMailSender mailSender; + + @Test + void smtpFailure_persistsFailedAndReturns502() throws Exception { + doThrow(new MailSendException("smtp unreachable")) + .when(mailSender).send(any(SimpleMailMessage.class)); + + mockMvc.perform(post("/api/v1/notifications/email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"user@example.com\",\"subject\":\"Hi\",\"body\":\"will fail\"}")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.status", is("FAILED"))) + .andExpect(jsonPath("$.error", is("smtp unreachable"))); + } +} diff --git a/email-service/src/test/java/com/redjanvier/emailservice/EmailServiceApplicationTests.java b/email-service/src/test/java/com/redjanvier/emailservice/EmailServiceApplicationTests.java index 9e0a82b..ad66619 100644 --- a/email-service/src/test/java/com/redjanvier/emailservice/EmailServiceApplicationTests.java +++ b/email-service/src/test/java/com/redjanvier/emailservice/EmailServiceApplicationTests.java @@ -1,7 +1,11 @@ package com.redjanvier.emailservice; +import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -16,15 +20,20 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +/** + * Web-layer tests for the happy paths, history/read endpoints and every + * validation error. The mail sender is mocked so no SMTP server is required. + */ @SpringBootTest @AutoConfigureMockMvc +@Transactional class EmailServiceApplicationTests { @Autowired private MockMvc mockMvc; - // Mock the mail sender so tests don't need a live SMTP server. @MockBean private JavaMailSender mailSender; @@ -33,26 +42,84 @@ void contextLoads() { } @Test - void sendPersistsAndReturnsCreated() throws Exception { - doNothing().when(mailSender).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + void send_persistsAndReturnsCreated() throws Exception { + doNothing().when(mailSender).send(any(SimpleMailMessage.class)); mockMvc.perform(post("/api/v1/notifications/email") .contentType(MediaType.APPLICATION_JSON) .content("{\"to\":\"user@example.com\",\"subject\":\"Hi\",\"body\":\"Hello there\"}")) .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) .andExpect(jsonPath("$.status", is("SENT"))) - .andExpect(jsonPath("$.recipient", is("user@example.com"))); + .andExpect(jsonPath("$.recipient", is("user@example.com"))) + .andExpect(jsonPath("$.error").doesNotExist()); + + verify(mailSender).send(any(SimpleMailMessage.class)); + } + + @Test + void history_returnsSavedRecords() throws Exception { + doNothing().when(mailSender).send(any(SimpleMailMessage.class)); + + mockMvc.perform(post("/api/v1/notifications/email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"a@example.com\",\"subject\":\"First\",\"body\":\"x\"}")) + .andExpect(status().isCreated()); mockMvc.perform(get("/api/v1/notifications/email")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].subject", is("Hi"))); + .andExpect(jsonPath("$.length()", greaterThan(0))) + .andExpect(jsonPath("$[0].subject", is("First"))); } @Test - void invalidEmailReturnsBadRequest() throws Exception { + void getById_returnsRecordWhenFound() throws Exception { + doNothing().when(mailSender).send(any(SimpleMailMessage.class)); + + String body = mockMvc.perform(post("/api/v1/notifications/email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"find@example.com\",\"subject\":\"Find\",\"body\":\"x\"}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + Integer id = com.jayway.jsonpath.JsonPath.read(body, "$.id"); + + mockMvc.perform(get("/api/v1/notifications/email/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subject", is("Find"))); + } + + @Test + void getById_returns404WhenMissing() throws Exception { + mockMvc.perform(get("/api/v1/notifications/email/{id}", 999999)) + .andExpect(status().isNotFound()); + } + + @Test + void send_invalidEmail_returns400() throws Exception { mockMvc.perform(post("/api/v1/notifications/email") .contentType(MediaType.APPLICATION_JSON) .content("{\"to\":\"not-an-email\",\"subject\":\"Hi\",\"body\":\"x\"}")) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", is("Validation failed"))) + .andExpect(jsonPath("$.fields.to", notNullValue())); + } + + @Test + void send_missingSubject_returns400() throws Exception { + mockMvc.perform(post("/api/v1/notifications/email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"user@example.com\",\"body\":\"x\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fields.subject", notNullValue())); + } + + @Test + void send_missingBody_returns400() throws Exception { + mockMvc.perform(post("/api/v1/notifications/email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"user@example.com\",\"subject\":\"Hi\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fields.body", notNullValue())); } } diff --git a/email-service/src/test/java/com/redjanvier/emailservice/service/EmailServiceUnitTest.java b/email-service/src/test/java/com/redjanvier/emailservice/service/EmailServiceUnitTest.java new file mode 100644 index 0000000..90e4694 --- /dev/null +++ b/email-service/src/test/java/com/redjanvier/emailservice/service/EmailServiceUnitTest.java @@ -0,0 +1,60 @@ +package com.redjanvier.emailservice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import com.redjanvier.emailservice.entity.EmailNotification; +import com.redjanvier.emailservice.entity.NotificationStatus; +import com.redjanvier.emailservice.repository.EmailNotificationRepository; + +/** + * Pure unit tests for the send -> persist mapping (no Spring context). + */ +@ExtendWith(MockitoExtension.class) +class EmailServiceUnitTest { + + @Mock + private JavaMailSender mailSender; + + @Mock + private EmailNotificationRepository repository; + + private EmailService emailService; + + @BeforeEach + void setUp() { + emailService = new EmailService(mailSender, repository, "no-reply@test.local"); + when(repository.save(any(EmailNotification.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void send_successful_isPersistedAsSent() { + EmailNotification result = emailService.send("user@example.com", "Subject", "Body"); + + assertThat(result.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(result.getError()).isNull(); + verify(mailSender).send(any(SimpleMailMessage.class)); + } + + @Test + void send_whenSmtpThrows_isPersistedAsFailed() { + doThrow(new MailSendException("down")).when(mailSender).send(any(SimpleMailMessage.class)); + + EmailNotification result = emailService.send("user@example.com", "Subject", "Body"); + + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(result.getError()).isEqualTo("down"); + } +} diff --git a/email-service/src/test/resources/application.yml b/email-service/src/test/resources/application.yml index 49fe592..3159400 100644 --- a/email-service/src/test/resources/application.yml +++ b/email-service/src/test/resources/application.yml @@ -1,5 +1,5 @@ # Test config: in-memory H2 so context/integration tests need no Postgres. -# JavaMailSender is mocked in the test, so mail.* just needs to be present. +# JavaMailSender is mocked in tests, so mail.* just needs to be present. spring: datasource: url: jdbc:h2:mem:emailtestdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL diff --git a/sms-service/src/test/java/com/redjanvier/smsservice/SmsProviderFailurePathTest.java b/sms-service/src/test/java/com/redjanvier/smsservice/SmsProviderFailurePathTest.java new file mode 100644 index 0000000..0e58b4f --- /dev/null +++ b/sms-service/src/test/java/com/redjanvier/smsservice/SmsProviderFailurePathTest.java @@ -0,0 +1,49 @@ +package com.redjanvier.smsservice; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.redjanvier.smsservice.provider.SmsProvider; +import com.redjanvier.smsservice.provider.SmsResult; + +/** + * When the provider fails to deliver, the record must still be persisted with + * status FAILED and the endpoint must answer 502 Bad Gateway. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class SmsProviderFailurePathTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SmsProvider smsProvider; + + @Test + void providerFailure_persistsFailedAndReturns502() throws Exception { + when(smsProvider.send(anyString(), anyString())) + .thenReturn(SmsResult.failed("mock", "carrier unavailable")); + + mockMvc.perform(post("/api/v1/notifications/sms") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"+250788000000\",\"message\":\"will fail\"}")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.status", is("FAILED"))) + .andExpect(jsonPath("$.error", is("carrier unavailable"))); + } +} diff --git a/sms-service/src/test/java/com/redjanvier/smsservice/SmsServiceApplicationTests.java b/sms-service/src/test/java/com/redjanvier/smsservice/SmsServiceApplicationTests.java index d1b61fe..8ded4f5 100644 --- a/sms-service/src/test/java/com/redjanvier/smsservice/SmsServiceApplicationTests.java +++ b/sms-service/src/test/java/com/redjanvier/smsservice/SmsServiceApplicationTests.java @@ -1,6 +1,8 @@ package com.redjanvier.smsservice; +import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -12,9 +14,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +/** + * Web-layer tests for the happy paths, history/read endpoints and every + * validation error. Each test rolls back so the in-memory DB stays clean. + */ @SpringBootTest @AutoConfigureMockMvc +@Transactional class SmsServiceApplicationTests { @Autowired @@ -25,25 +33,88 @@ void contextLoads() { } @Test - void sendPersistsAndReturnsCreated() throws Exception { + void send_persistsAndReturnsCreated() throws Exception { mockMvc.perform(post("/api/v1/notifications/sms") .contentType(MediaType.APPLICATION_JSON) .content("{\"to\":\"+250788000000\",\"message\":\"hello\"}")) .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) .andExpect(jsonPath("$.status", is("SENT"))) .andExpect(jsonPath("$.provider", is("mock"))) - .andExpect(jsonPath("$.recipient", is("+250788000000"))); + .andExpect(jsonPath("$.recipient", is("+250788000000"))) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + void history_returnsSavedRecords() throws Exception { + mockMvc.perform(post("/api/v1/notifications/sms") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"+250788000001\",\"message\":\"first\"}")) + .andExpect(status().isCreated()); mockMvc.perform(get("/api/v1/notifications/sms")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].message", is("hello"))); + .andExpect(jsonPath("$.length()", greaterThan(0))) + .andExpect(jsonPath("$[0].message", is("first"))); + } + + @Test + void getById_returnsRecordWhenFound() throws Exception { + String body = mockMvc.perform(post("/api/v1/notifications/sms") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"+250788000002\",\"message\":\"findme\"}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + Integer id = com.jayway.jsonpath.JsonPath.read(body, "$.id"); + + mockMvc.perform(get("/api/v1/notifications/sms/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("findme"))); + } + + @Test + void getById_returns404WhenMissing() throws Exception { + mockMvc.perform(get("/api/v1/notifications/sms/{id}", 999999)) + .andExpect(status().isNotFound()); + } + + @Test + void send_missingRecipient_returns400() throws Exception { + mockMvc.perform(post("/api/v1/notifications/sms") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"no recipient\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", is("Validation failed"))) + .andExpect(jsonPath("$.fields.to", notNullValue())); + } + + @Test + void send_missingMessage_returns400() throws Exception { + mockMvc.perform(post("/api/v1/notifications/sms") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"+250788000000\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fields.message", notNullValue())); + } + + @Test + void send_blankFields_returns400() throws Exception { + mockMvc.perform(post("/api/v1/notifications/sms") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"to\":\"\",\"message\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fields.to", notNullValue())) + .andExpect(jsonPath("$.fields.message", notNullValue())); } @Test - void missingFieldsReturnBadRequest() throws Exception { + void send_messageTooLong_returns400() throws Exception { + String longMessage = "x".repeat(1601); mockMvc.perform(post("/api/v1/notifications/sms") .contentType(MediaType.APPLICATION_JSON) - .content("{\"to\":\"\"}")) - .andExpect(status().isBadRequest()); + .content("{\"to\":\"+250788000000\",\"message\":\"" + longMessage + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fields.message", notNullValue())); } } diff --git a/sms-service/src/test/java/com/redjanvier/smsservice/provider/MockSmsProviderTest.java b/sms-service/src/test/java/com/redjanvier/smsservice/provider/MockSmsProviderTest.java new file mode 100644 index 0000000..282735f --- /dev/null +++ b/sms-service/src/test/java/com/redjanvier/smsservice/provider/MockSmsProviderTest.java @@ -0,0 +1,24 @@ +package com.redjanvier.smsservice.provider; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class MockSmsProviderTest { + + private final MockSmsProvider provider = new MockSmsProvider(); + + @Test + void send_alwaysSucceedsWithMockProviderName() { + SmsResult result = provider.send("+250788000000", "hello"); + + assertThat(result.success()).isTrue(); + assertThat(result.provider()).isEqualTo("mock"); + assertThat(result.error()).isNull(); + } + + @Test + void name_isMock() { + assertThat(provider.name()).isEqualTo("mock"); + } +} diff --git a/sms-service/src/test/java/com/redjanvier/smsservice/service/SmsServiceUnitTest.java b/sms-service/src/test/java/com/redjanvier/smsservice/service/SmsServiceUnitTest.java new file mode 100644 index 0000000..b1bd1d3 --- /dev/null +++ b/sms-service/src/test/java/com/redjanvier/smsservice/service/SmsServiceUnitTest.java @@ -0,0 +1,59 @@ +package com.redjanvier.smsservice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.redjanvier.smsservice.entity.NotificationStatus; +import com.redjanvier.smsservice.entity.SmsNotification; +import com.redjanvier.smsservice.provider.SmsProvider; +import com.redjanvier.smsservice.provider.SmsResult; +import com.redjanvier.smsservice.repository.SmsNotificationRepository; + +/** + * Pure unit tests for the provider-result -> entity mapping (no Spring context). + */ +@ExtendWith(MockitoExtension.class) +class SmsServiceUnitTest { + + @Mock + private SmsProvider provider; + + @Mock + private SmsNotificationRepository repository; + + @InjectMocks + private SmsService smsService; + + @Test + void send_successfulProvider_isPersistedAsSent() { + when(provider.send("+250788000000", "hi")).thenReturn(SmsResult.ok("mock")); + when(repository.save(any(SmsNotification.class))).thenAnswer(inv -> inv.getArgument(0)); + + SmsNotification result = smsService.send("+250788000000", "hi"); + + assertThat(result.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(result.getProvider()).isEqualTo("mock"); + assertThat(result.getError()).isNull(); + verify(repository).save(any(SmsNotification.class)); + } + + @Test + void send_failingProvider_isPersistedAsFailedWithError() { + when(provider.send("+250788000000", "hi")).thenReturn(SmsResult.failed("twilio", "boom")); + when(repository.save(any(SmsNotification.class))).thenAnswer(inv -> inv.getArgument(0)); + + SmsNotification result = smsService.send("+250788000000", "hi"); + + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(result.getProvider()).isEqualTo("twilio"); + assertThat(result.getError()).isEqualTo("boom"); + } +}