diff --git a/src/main/java/com/volta/Main.java b/src/main/java/com/volta/Main.java index f5a4827..a55d96d 100644 --- a/src/main/java/com/volta/Main.java +++ b/src/main/java/com/volta/Main.java @@ -1,26 +1,14 @@ package com.volta; -import java.net.http.HttpResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.volta.engine.LoadEngine; public class Main { - private static final Logger log = LoggerFactory.getLogger(Main.class); private static final String TARGET_URL = "https://jsonplaceholder.typicode.com/posts/1"; + private static final int TARGET_RPS = 5; + private static final int DURATION_SECONDS = 10; public static void main(String[] args) { - try (HttpSender sender = new HttpSender()) { - while (true) { - try { - Thread.sleep(1000); - HttpResponse response = sender.send(TARGET_URL); - log.info("Status: {}, Body: {}", response.statusCode(), response.body()); - } catch (Exception e) { - log.error("Request failed", e); - } - } - } catch (Exception e) { - log.error("Sender closed or failed to initialize", e); - } + LoadEngine engine = new LoadEngine(TARGET_URL, TARGET_RPS, DURATION_SECONDS); + engine.start(); } } diff --git a/src/main/java/com/volta/engine/LoadEngine.java b/src/main/java/com/volta/engine/LoadEngine.java new file mode 100644 index 0000000..12d4194 --- /dev/null +++ b/src/main/java/com/volta/engine/LoadEngine.java @@ -0,0 +1,63 @@ +package com.volta.engine; + +import com.volta.http.HttpSender; +import java.net.http.HttpResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LoadEngine { + private static final Logger log = LoggerFactory.getLogger(LoadEngine.class); + + private final String url; + private final int targetRps; + private final int durationSeconds; + private volatile boolean running = false; + + public LoadEngine(String URL, int targetRPS, int durationSeconds) { + if (URL == null || URL.isBlank()) { + throw new IllegalArgumentException("URL must not be empty"); + } + if (targetRPS <= 0) { + throw new IllegalArgumentException("RPS must be positive"); + } + if (durationSeconds <= 0) { + throw new IllegalArgumentException("Duration must be positive"); + } + + this.url = URL; + this.targetRps = targetRPS; + this.durationSeconds = durationSeconds; + } + + public void start() { + running = true; + long intervalNanos = 1_000_000_000L / targetRps; + long endTime = System.nanoTime() + (long) durationSeconds * 1_000_000_000L; + long sendNextTime = System.nanoTime(); + + try (HttpSender sender = new HttpSender()) { + while (running && System.nanoTime() < endTime) { + + while (System.nanoTime() < sendNextTime) { + // busy-wait + } + + try { + HttpResponse response = sender.send(url); + log.info("Status: {}, Body: {}", response.statusCode(), response.body()); + } catch (Exception e) { + log.error("Request failed", e); + } + sendNextTime += intervalNanos; + } + } catch (Exception e) { + log.error("Sender closed or failed to initialize", e); + } + + log.info("Test finished"); + } + + public void stop() { + running = false; + } +} diff --git a/src/main/java/com/volta/http/HttpSender.java b/src/main/java/com/volta/http/HttpSender.java new file mode 100644 index 0000000..330de6e --- /dev/null +++ b/src/main/java/com/volta/http/HttpSender.java @@ -0,0 +1,22 @@ +package com.volta.http; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +public class HttpSender implements AutoCloseable { + private final HttpClient client = HttpClient.newHttpClient(); + + public HttpResponse send(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); + + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + @Override + public void close() throws Exception { + client.close(); + } +} diff --git a/src/test/java/com/volta/engine/LoadEngineTest.java b/src/test/java/com/volta/engine/LoadEngineTest.java new file mode 100644 index 0000000..30b1b61 --- /dev/null +++ b/src/test/java/com/volta/engine/LoadEngineTest.java @@ -0,0 +1,104 @@ +package com.volta.engine; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.*; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.volta.http.HttpSender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LoadEngineTest { + + private WireMockServer wireMock; + private String baseUrl; + private HttpSender sender; + + @BeforeEach + void setUp() { + wireMock = new WireMockServer(wireMockConfig().dynamicPort()); + wireMock.start(); + wireMock.stubFor(get("/test").willReturn(ok("OK"))); + baseUrl = "http://localhost:" + wireMock.port(); + + try (HttpSender sender = new HttpSender()) { + sender.send(baseUrl + "/test"); + } catch (Exception ignored) { + } + } + + @AfterEach + void tearDown() throws Exception { + if (sender != null) { + sender.close(); + } + wireMock.stop(); + } + + @Test + void shouldSendRequestsAtTargetRps() { + int targetRps = 10; + int durationSeconds = 5; + int expectedRequests = targetRps * durationSeconds; + + LoadEngine engine = new LoadEngine(baseUrl + "/test", targetRps, durationSeconds); + engine.start(); + + int actualRequests = + wireMock.countRequestsMatching(getRequestedFor(urlEqualTo("/test")).build()).getCount(); + + int minExpected = (int) (expectedRequests * 0.8); + int maxExpected = (int) (expectedRequests * 1.2); + + assertTrue( + actualRequests >= minExpected && actualRequests <= maxExpected, + String.format( + "Expected %d requests (±20%%), but got %d", expectedRequests, actualRequests)); + } + + @Test + void shouldStopAutomaticallyAfterDuration() { + int durationSeconds = 2; + LoadEngine engine = new LoadEngine(baseUrl + "/test", 10, durationSeconds); + + long startTime = System.currentTimeMillis(); + engine.start(); + long elapsed = System.currentTimeMillis() - startTime; + + assertTrue( + elapsed >= 2000 && elapsed < 3000, + String.format("Expected ~2000ms, but took %dms", elapsed)); + } + + @Test + void shouldStopManually() throws InterruptedException { + int durationSeconds = 60; + LoadEngine engine = new LoadEngine(baseUrl + "/test", 10, durationSeconds); + + Thread engineThread = new Thread(engine::start); + engineThread.start(); + + Thread.sleep(1000); + engine.stop(); + engineThread.join(2000); + + assertFalse(engineThread.isAlive(), "Engine should have stopped"); + } + + @Test + void shouldHandleServerErrors() { + wireMock.stubFor(get("/error").willReturn(serverError())); + LoadEngine engine = new LoadEngine(baseUrl + "/error", 10, 2); + + assertDoesNotThrow(() -> engine.start()); + } + + @Test + void shouldHandleInvalidUrl() { + LoadEngine engine = new LoadEngine("http://invalid-host-that-does-not-exist:9999/test", 5, 2); + + assertDoesNotThrow(() -> engine.start()); + } +} diff --git a/src/test/java/com/volta/http/HttpSenderTest.java b/src/test/java/com/volta/http/HttpSenderTest.java new file mode 100644 index 0000000..1405cd2 --- /dev/null +++ b/src/test/java/com/volta/http/HttpSenderTest.java @@ -0,0 +1,35 @@ +package com.volta.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; + +@WireMockTest(httpPort = 8089) +class HttpSenderTest { + + private final HttpSender sender = new HttpSender(); + + @Test + void sendReturns200ForValidUrl() throws Exception { + stubFor(get("/test").willReturn(ok("Hello from mock!"))); + + HttpResponse response = sender.send("http://localhost:8089/test"); + assertEquals(200, response.statusCode()); + } + + @Test + void responseBodyIsNotEmpty() throws Exception { + stubFor(get("/test").willReturn(ok("Hello from mock!"))); + + HttpResponse response = sender.send("http://localhost:8089/test"); + assertFalse(response.body().isEmpty()); + } + + @Test + void sendThrowsOnInvalidUrl() { + assertThrows(Exception.class, () -> sender.send("http://localhost:1")); + } +}