Skip to content

Commit eec40d0

Browse files
authored
Merge pull request #2 from RedJanvier/feat/tests-and-ci
chore: add tests and ci
2 parents 791d45b + da2e62f commit eec40d0

16 files changed

Lines changed: 757 additions & 17 deletions

File tree

.gitattributes

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Normalize line endings so the Maven wrapper runs on Linux CI runners.
2+
* text=auto
3+
4+
# Shell scripts must be LF regardless of the OS they were committed from.
5+
mvnw text eol=lf
6+
*.sh text eol=lf
7+
8+
# Windows scripts stay CRLF.
9+
*.cmd text eol=crlf
10+
*.bat text eol=crlf

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
build-and-test:
9+
name: Build & Test (${{ matrix.module }})
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
module: [api-gateway, sms-service, email-service]
15+
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
20+
- name: Set up JDK 17
21+
uses: actions/setup-java@v4
22+
with:
23+
distribution: temurin
24+
java-version: '17'
25+
cache: maven
26+
27+
- name: Make Maven wrapper executable
28+
working-directory: ${{ matrix.module }}
29+
run: chmod +x ./mvnw
30+
31+
# api-gateway's rate-limit test starts a throwaway Redis via Testcontainers.
32+
# ubuntu-latest ships with a running Docker daemon, so no extra setup is needed.
33+
- name: Build and test
34+
working-directory: ${{ matrix.module }}
35+
run: ./mvnw -B verify
36+
37+
- name: Upload test reports
38+
if: always()
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: surefire-reports-${{ matrix.module }}
42+
path: ${{ matrix.module }}/target/surefire-reports/
43+
if-no-files-found: ignore

.idea/workspace.xml

Lines changed: 70 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ The solution provided doesn't tackle all the points but it is a base for the rem
140140
- **Twilio** *(optional)* : Real SMS delivery provider
141141
- **Spring Boot** : All three microservices (Java 17)
142142
- **Docker / Docker Compose** : Containerization & local orchestration
143+
- **GitHub Actions** : CI — builds and tests every service on each push
143144
- **Kafka** *(future)* : Message broker for asynchronous delivery
144145

145146
## Security
@@ -203,6 +204,20 @@ docker compose up --build
203204

204205
The default configuration needs **no external credentials**: SMS uses a mock provider (messages appear in the `sms-service` logs) and Email is delivered to Mailpit, viewable at **http://localhost:8025**.
205206

207+
### 3. Run the tests
208+
209+
Each service is an independent Maven module with its own test suite. Run one with its wrapper:
210+
211+
```bash
212+
cd sms-service && ./mvnw test
213+
```
214+
215+
No Docker is required to run the tests: persistence tests use an in-memory H2 database, and the API-gateway rate-limit test starts an in-process Redis (embedded-redis). All suites also run automatically in CI on every push (see [Continuous Integration](#continuous-integration)).
216+
217+
## Continuous Integration
218+
219+
Every push and pull request triggers the GitHub Actions workflow at [`.github/workflows/ci.yml`](.github/workflows/ci.yml). It runs a matrix job — one per service (`api-gateway`, `sms-service`, `email-service`) — that sets up JDK 17, restores the Maven cache, and runs `./mvnw verify`. The build fails if any test fails, so regressions are caught before merge.
220+
206221
## Documentation
207222
> Base path for all endpoints (through the gateway) is `http://localhost:8081/api/v1`
208223

api-gateway/pom.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@
4444
<artifactId>reactor-test</artifactId>
4545
<scope>test</scope>
4646
</dependency>
47+
<!-- Real (embedded, in-process) Redis for rate-limiter integration tests.
48+
No Docker required, so the test runs locally and in CI alike. -->
49+
<dependency>
50+
<groupId>com.github.codemonstur</groupId>
51+
<artifactId>embedded-redis</artifactId>
52+
<version>1.4.3</version>
53+
<scope>test</scope>
54+
</dependency>
55+
<!-- Stub downstream service for routing/rate-limit tests -->
56+
<dependency>
57+
<groupId>com.squareup.okhttp3</groupId>
58+
<artifactId>mockwebserver</artifactId>
59+
<scope>test</scope>
60+
</dependency>
4761
</dependencies>
4862
<dependencyManagement>
4963
<dependencies>

api-gateway/src/main/java/com/redjanvier/apigateway/config/RateLimiterConfig.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,32 @@
77

88
import reactor.core.publisher.Mono;
99

10+
/**
11+
* Key resolvers for the gateway's {@code RequestRateLimiter} filter.
12+
*
13+
* <p>The rate limiter buckets requests by the key returned here. Two strategies
14+
* are provided so both README requirements are covered:
15+
*
16+
* <ul>
17+
* <li>{@link #systemKeyResolver()} — one shared bucket for the whole system.
18+
* This enforces the "limit amount of requests per time window across the
19+
* whole system" requirement (the {@code 10 req / 3s} system-wide limit).</li>
20+
* <li>{@link #clientKeyResolver()} — one bucket per client, keyed by the
21+
* {@code X-Client-Id} header (falling back to the caller's IP). This
22+
* enforces the "limit too many requests from a client" requirement.</li>
23+
* </ul>
24+
*
25+
* <p>The active strategy is chosen in {@code application.yml} via
26+
* {@code key-resolver: "#{@systemKeyResolver}"} (or {@code #{@clientKeyResolver}}).
27+
* {@code systemKeyResolver} is {@link Primary} so it is used if a route omits an
28+
* explicit key resolver.
29+
*/
1030
@Configuration
1131
public class RateLimiterConfig {
1232

13-
private static final String SYSTEM_BUCKET = "system";
14-
private static final String CLIENT_HEADER = "X-Client-Id";
33+
static final String SYSTEM_BUCKET = "system";
34+
static final String CLIENT_HEADER = "X-Client-Id";
35+
static final String UNKNOWN_CLIENT = "unknown";
1536

1637
@Bean
1738
@Primary
@@ -26,7 +47,7 @@ public KeyResolver clientKeyResolver() {
2647
if (clientId == null || clientId.isBlank()) {
2748
clientId = exchange.getRequest().getRemoteAddress() != null
2849
? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
29-
: "unknown";
50+
: UNKNOWN_CLIENT;
3051
}
3152
return Mono.just(clientId);
3253
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.redjanvier.apigateway;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.io.IOException;
6+
import java.net.ServerSocket;
7+
8+
import org.junit.jupiter.api.AfterAll;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.context.SpringBootTest;
12+
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
13+
import org.springframework.test.context.DynamicPropertyRegistry;
14+
import org.springframework.test.context.DynamicPropertySource;
15+
import org.springframework.test.web.reactive.server.WebTestClient;
16+
17+
import okhttp3.mockwebserver.Dispatcher;
18+
import okhttp3.mockwebserver.MockResponse;
19+
import okhttp3.mockwebserver.MockWebServer;
20+
import okhttp3.mockwebserver.RecordedRequest;
21+
import redis.embedded.RedisServer;
22+
23+
/**
24+
* End-to-end test of the gateway's two headline responsibilities:
25+
* <ol>
26+
* <li>routing a request to the correct downstream service, and</li>
27+
* <li>enforcing the Redis-backed rate limit (HTTP 429 once the bucket drains).</li>
28+
* </ol>
29+
*
30+
* <p>Uses a real, in-process Redis (embedded-redis, no Docker) and a stubbed
31+
* downstream (MockWebServer), so it exercises the actual {@code RequestRateLimiter}
32+
* Lua script and runs anywhere — locally and in CI.
33+
*/
34+
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
35+
class RateLimitingIntegrationTest {
36+
37+
static RedisServer redisServer;
38+
static MockWebServer downstream;
39+
40+
@DynamicPropertySource
41+
static void properties(DynamicPropertyRegistry registry) throws IOException {
42+
int redisPort = findFreePort();
43+
redisServer = new RedisServer(redisPort);
44+
redisServer.start();
45+
46+
downstream = new MockWebServer();
47+
downstream.setDispatcher(new Dispatcher() {
48+
@Override
49+
public MockResponse dispatch(RecordedRequest request) {
50+
return new MockResponse().setResponseCode(200).setBody("ok");
51+
}
52+
});
53+
downstream.start();
54+
55+
String base = "http://" + downstream.getHostName() + ":" + downstream.getPort();
56+
registry.add("spring.data.redis.host", () -> "localhost");
57+
registry.add("spring.data.redis.port", () -> redisPort);
58+
registry.add("SMS_SERVICE_URI", () -> base);
59+
registry.add("EMAIL_SERVICE_URI", () -> base);
60+
}
61+
62+
@AfterAll
63+
static void tearDown() throws IOException {
64+
if (downstream != null) {
65+
downstream.shutdown();
66+
}
67+
if (redisServer != null) {
68+
redisServer.stop();
69+
}
70+
}
71+
72+
private static int findFreePort() throws IOException {
73+
try (ServerSocket socket = new ServerSocket(0)) {
74+
return socket.getLocalPort();
75+
}
76+
}
77+
78+
@Autowired
79+
private WebTestClient client;
80+
81+
@Test
82+
void routesToDownstreamThenRateLimitsTheBurst() {
83+
// 1) Routing: the first request is proxied to the stub downstream.
84+
client.post().uri("/api/v1/notifications/sms")
85+
.exchange()
86+
.expectStatus().isOk()
87+
.expectBody(String.class).isEqualTo("ok");
88+
89+
// 2) Rate limiting: a rapid burst must be partly rejected with 429.
90+
int ok = 1;
91+
int limited = 0;
92+
for (int i = 0; i < 40; i++) {
93+
int status = client.post().uri("/api/v1/notifications/sms")
94+
.exchange()
95+
.returnResult(String.class)
96+
.getStatus()
97+
.value();
98+
if (status == 429) {
99+
limited++;
100+
} else if (status == 200) {
101+
ok++;
102+
}
103+
}
104+
105+
assertThat(limited)
106+
.as("the rate limiter must reject part of a 41-request burst")
107+
.isGreaterThan(0);
108+
assertThat(ok)
109+
.as("some requests should still pass through")
110+
.isGreaterThanOrEqualTo(1)
111+
.isLessThan(41);
112+
}
113+
}

0 commit comments

Comments
 (0)