refactored, pre-buffer reuse
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Response;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerConcurrencyTest {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
private HttpClient httpClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
HttpServerConfiguration config = HttpServerConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.build();
|
||||
|
||||
server = new HttpServer(config);
|
||||
server.get("/ping", (req, res) -> "pong");
|
||||
server.post("/echo", (req, res) -> {
|
||||
byte[] body = req.getBody();
|
||||
return new Response(200, body, ContentType.TEXT_PLAIN);
|
||||
});
|
||||
|
||||
server.start().get(5, TimeUnit.SECONDS);
|
||||
|
||||
httpClient = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (server != null)
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private HttpResponse<String> get(int targetPort, String path) throws Exception {
|
||||
return httpClient.send(
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + targetPort + path))
|
||||
.GET()
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
return get(port, path);
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
return httpClient.send(
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + path))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
// --- concurrency ---
|
||||
|
||||
@Test
|
||||
void concurrent_getRequests_allReturn200() throws Exception {
|
||||
int count = 20;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(count);
|
||||
CountDownLatch ready = new CountDownLatch(count);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
AtomicInteger successes = new AtomicInteger();
|
||||
List<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||
|
||||
List<String> responses = new CopyOnWriteArrayList<>();
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
pool.submit(() -> {
|
||||
ready.countDown();
|
||||
try {
|
||||
start.await();
|
||||
HttpResponse<String> res = get("/ping");
|
||||
String summary = res.statusCode() + "|" + res.body();
|
||||
responses.add(summary);
|
||||
if (res.statusCode() == 200 && "pong".equals(res.body())) {
|
||||
successes.incrementAndGet();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.add(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ready.await();
|
||||
start.countDown();
|
||||
pool.shutdown();
|
||||
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
|
||||
|
||||
assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors);
|
||||
assertEquals(count, successes.get(), () -> "Responses: " + responses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrent_mixedRoutes_allReturn200() throws Exception {
|
||||
int perMethod = 10;
|
||||
int total = perMethod * 2;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(total);
|
||||
CountDownLatch ready = new CountDownLatch(total);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
AtomicInteger getSuccesses = new AtomicInteger();
|
||||
AtomicInteger postSuccesses = new AtomicInteger();
|
||||
List<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||
|
||||
for (int i = 0; i < perMethod; i++) {
|
||||
pool.submit(() -> {
|
||||
ready.countDown();
|
||||
try {
|
||||
start.await();
|
||||
HttpResponse<String> res = get("/ping");
|
||||
if (res.statusCode() == 200 && "pong".equals(res.body()))
|
||||
getSuccesses.incrementAndGet();
|
||||
} catch (Exception e) {
|
||||
errors.add(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (int i = 0; i < perMethod; i++) {
|
||||
pool.submit(() -> {
|
||||
ready.countDown();
|
||||
try {
|
||||
start.await();
|
||||
HttpResponse<String> res = post("/echo", "hello");
|
||||
if (res.statusCode() == 200 && "hello".equals(res.body()))
|
||||
postSuccesses.incrementAndGet();
|
||||
} catch (Exception e) {
|
||||
errors.add(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ready.await();
|
||||
start.countDown();
|
||||
pool.shutdown();
|
||||
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
|
||||
|
||||
assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors);
|
||||
assertEquals(perMethod, getSuccesses.get());
|
||||
assertEquals(perMethod, postSuccesses.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Races the router's lazy-compile step: a fresh server with 10 registered routes
|
||||
* is hit by threads simultaneously before any request has been processed,
|
||||
* causing multiple threads to compete on the first compilation.
|
||||
*/
|
||||
@Test
|
||||
void concurrent_lazyCompile_noRaceCondition() throws Exception {
|
||||
int freshPort;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
freshPort = s.getLocalPort();
|
||||
}
|
||||
|
||||
HttpServer freshServer = new HttpServer(HttpServerConfiguration.builder()
|
||||
.port(freshPort)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final int idx = i;
|
||||
freshServer.get("/route" + idx, (req, res) -> "handler" + idx);
|
||||
}
|
||||
|
||||
freshServer.start().get(5, TimeUnit.SECONDS);
|
||||
|
||||
int count = 20;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(count);
|
||||
CountDownLatch ready = new CountDownLatch(count);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
AtomicInteger successes = new AtomicInteger();
|
||||
List<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
final int routeIdx = i % 10;
|
||||
pool.submit(() -> {
|
||||
ready.countDown();
|
||||
try {
|
||||
start.await();
|
||||
HttpResponse<String> res = get(freshPort, "/route" + routeIdx);
|
||||
if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body())) {
|
||||
successes.incrementAndGet();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.add(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ready.await();
|
||||
start.countDown();
|
||||
pool.shutdown();
|
||||
|
||||
try {
|
||||
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
|
||||
assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors);
|
||||
assertEquals(count, successes.get());
|
||||
} finally {
|
||||
freshServer.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Response;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerTest {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Find a free ephemeral port
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
HttpServerConfiguration config = HttpServerConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.build();
|
||||
|
||||
server = new HttpServer(config);
|
||||
|
||||
// Setup some routes
|
||||
server.get("/api/ping", (req, res) -> "pong");
|
||||
|
||||
server.post("/api/echo", (req, res) -> {
|
||||
byte[] body = req.getBody();
|
||||
return new Response(201, body, ContentType.TEXT_PLAIN); // echo body & change status
|
||||
});
|
||||
|
||||
server.get("/api/crash", (req, res) -> {
|
||||
throw new RuntimeException("Simulated Crash");
|
||||
});
|
||||
|
||||
server.start().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (server != null) {
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- raw socket helper ---
|
||||
|
||||
private String sendRawRequest(String rawHttp) throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream()) {
|
||||
|
||||
out.write(rawHttp.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
baos.write(buffer, 0, read);
|
||||
}
|
||||
return baos.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
// --- E2E Tests ---
|
||||
|
||||
@Test
|
||||
void testGet_pingRoute_returns200AndStringBody() throws Exception {
|
||||
String req = "GET /api/ping HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n";
|
||||
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(res.contains("Content-Length: 4")); // "pong"
|
||||
assertTrue(res.endsWith("pong"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPost_echoRoute_returns201AndEchoesBody() throws Exception {
|
||||
String body = "Hello, Flash!";
|
||||
String req = "POST /api/echo HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Content-Length: " + body.length() + "\r\n" +
|
||||
"\r\n" +
|
||||
body;
|
||||
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 201 Created"));
|
||||
assertTrue(res.contains("Content-Length: " + body.length()));
|
||||
assertTrue(res.endsWith(body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotFound_returns404Html() throws Exception {
|
||||
String req = "GET /api/unknown HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n";
|
||||
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||
assertTrue(res.contains("404"));
|
||||
assertTrue(res.contains("No route matched this request"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testException_returns500Html() throws Exception {
|
||||
String req = "GET /api/crash HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n";
|
||||
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error"));
|
||||
assertTrue(res.contains("500"));
|
||||
assertTrue(res.contains("Simulated Crash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRoot_returns404_noExceptionTossed() throws Exception {
|
||||
String req = "GET / HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n";
|
||||
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||
assertTrue(res.contains("No route matched this request."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RequestParserTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static Request parse(String raw) throws IOException {
|
||||
byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
return RequestParser.parse(new ByteArrayInputStream(bytes));
|
||||
}
|
||||
|
||||
private static String req(String requestLine, String... headers) {
|
||||
StringBuilder sb = new StringBuilder(requestLine).append("\n");
|
||||
for (String h : headers) sb.append(h).append("\n");
|
||||
return sb.append("\n").toString();
|
||||
}
|
||||
|
||||
// --- request line ---
|
||||
|
||||
@Test
|
||||
void path_withoutQueryString() throws IOException {
|
||||
Request r = parse(req("GET /hello HTTP/1.1", "Host: localhost"));
|
||||
assertEquals("/hello", r.getRequestLine().getPath().toString());
|
||||
assertNull(r.getRequestLine().getQuery());
|
||||
}
|
||||
|
||||
@Test
|
||||
void path_splitsAtQuestionMark() throws IOException {
|
||||
Request r = parse(req("GET /hello?foo=bar&baz=qux HTTP/1.1", "Host: localhost"));
|
||||
assertEquals("/hello", r.getRequestLine().getPath().toString());
|
||||
assertEquals("foo=bar&baz=qux", r.getRequestLine().getQuery().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryParam_resolvedFromPath() throws IOException {
|
||||
Request r = parse(req("GET /search?q=flash&page=2 HTTP/1.1", "Host: localhost"));
|
||||
assertEquals("flash", r.getQueryParam("q"));
|
||||
assertEquals("2", r.getQueryParam("page"));
|
||||
}
|
||||
|
||||
// --- headers ---
|
||||
|
||||
@Test
|
||||
void headers_parsed() throws IOException {
|
||||
Request r = parse(req("GET / HTTP/1.1", "Host: example.com", "Accept: application/json"));
|
||||
assertEquals("example.com", r.getHeader("Host"));
|
||||
assertEquals("application/json", r.getHeader("Accept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headers_caseInsensitive() throws IOException {
|
||||
Request r = parse(req("GET / HTTP/1.1", "Content-Type: text/plain"));
|
||||
assertEquals("text/plain", r.getHeader("content-type"));
|
||||
assertEquals("text/plain", r.getHeader("CONTENT-TYPE"));
|
||||
}
|
||||
|
||||
// --- body ---
|
||||
|
||||
@Test
|
||||
void body_parsed() throws IOException {
|
||||
String body = "hello body";
|
||||
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
|
||||
Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void body_emptyWhenNoContentLength() throws IOException {
|
||||
Request r = parse(req("GET / HTTP/1.1", "Host: localhost"));
|
||||
assertEquals(0, r.getBody().length);
|
||||
}
|
||||
|
||||
// --- edge cases / robustness ---
|
||||
|
||||
@Test
|
||||
void emptyInputStream_returnsNull() throws IOException {
|
||||
assertNull(RequestParser.parse(new ByteArrayInputStream(new byte[0])));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingHeaderTerminator_throwsIOException() {
|
||||
// Valid request line but stream ends before \r\n\r\n
|
||||
byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(raw)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownHttpMethod_throwsIOException() {
|
||||
assertThrows(IOException.class, () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestLine_noProtocol_throwsIOException() {
|
||||
// No space after path — parser cannot find protocol boundary
|
||||
assertThrows(IOException.class, () -> parse(req("GET /noproto")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headersOverBufferSize_throwsIOException() {
|
||||
// 9 KB of data with no \r\n\r\n exhausts the 8 KB buffer
|
||||
byte[] giant = new byte[9000];
|
||||
Arrays.fill(giant, (byte) 'A');
|
||||
assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(giant)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_largerThanBody_readsPartial() throws IOException {
|
||||
// Content-Length claims 50 but stream ends after 5 bytes
|
||||
String body = "hello";
|
||||
String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body;
|
||||
Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(50, r.getBody().length);
|
||||
assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.relism.http;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ContentTypeTest {
|
||||
|
||||
// --- getBytes ---
|
||||
|
||||
@Test
|
||||
void getBytes_validType() {
|
||||
assertArrayEquals("text/plain".getBytes(StandardCharsets.UTF_8), ContentType.TEXT_PLAIN.getBytes());
|
||||
assertArrayEquals("application/json".getBytes(StandardCharsets.UTF_8), ContentType.JSON.getBytes());
|
||||
assertArrayEquals("image/png".getBytes(StandardCharsets.UTF_8), ContentType.IMAGE_PNG.getBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBytes_instancesAreNotNull() {
|
||||
for (ContentType ct : ContentType.values()) {
|
||||
assertNotNull(ct.getBytes());
|
||||
assertTrue(ct.getBytes().length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package dev.relism.http;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpMethodTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static HttpMethod parse(String raw) {
|
||||
byte[] b = raw.getBytes(StandardCharsets.UTF_8);
|
||||
return HttpMethod.fromBytes(b, 0, b.length);
|
||||
}
|
||||
|
||||
private static HttpMethod parse(String raw, int off, int len) {
|
||||
byte[] b = raw.getBytes(StandardCharsets.UTF_8);
|
||||
return HttpMethod.fromBytes(b, off, len);
|
||||
}
|
||||
|
||||
// --- fromBytes ---
|
||||
|
||||
@Test
|
||||
void fromBytes_validMethods() {
|
||||
assertEquals(HttpMethod.GET, parse("GET"));
|
||||
assertEquals(HttpMethod.POST, parse("POST"));
|
||||
assertEquals(HttpMethod.PUT, parse("PUT"));
|
||||
assertEquals(HttpMethod.DELETE, parse("DELETE"));
|
||||
assertEquals(HttpMethod.PATCH, parse("PATCH"));
|
||||
assertEquals(HttpMethod.OPTIONS, parse("OPTIONS"));
|
||||
assertEquals(HttpMethod.HEAD, parse("HEAD"));
|
||||
assertEquals(HttpMethod.TRACE, parse("TRACE"));
|
||||
assertEquals(HttpMethod.CONNECT, parse("CONNECT"));
|
||||
assertEquals(HttpMethod.PURGE, parse("PURGE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromBytes_withOffsetAndLength() {
|
||||
assertEquals(HttpMethod.POST, parse("XXXPOSTYYY", 3, 4));
|
||||
assertEquals(HttpMethod.GET, parse(" GET ", 1, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromBytes_invalidMethods_returnsNull() {
|
||||
assertNull(parse("INVALID"));
|
||||
assertNull(parse("GE")); // Too short
|
||||
assertNull(parse("GETT")); // Too long
|
||||
assertNull(parse("posT")); // Case sensitive
|
||||
assertNull(parse("")); // Empty
|
||||
}
|
||||
|
||||
// --- bytes ---
|
||||
|
||||
@Test
|
||||
void getBytes_matchesName() {
|
||||
assertArrayEquals("GET".getBytes(StandardCharsets.UTF_8), HttpMethod.GET.getBytes());
|
||||
assertArrayEquals("POST".getBytes(StandardCharsets.UTF_8), HttpMethod.POST.getBytes());
|
||||
}
|
||||
|
||||
// --- toString ---
|
||||
|
||||
@Test
|
||||
void toString_matchesName() {
|
||||
assertEquals("GET", HttpMethod.GET.toString());
|
||||
assertEquals("DELETE", HttpMethod.DELETE.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.relism.http;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpStatusTest {
|
||||
|
||||
// --- bytesForCode ---
|
||||
|
||||
@Test
|
||||
void bytesForCode_validCodes() {
|
||||
assertArrayEquals("200 OK".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(200));
|
||||
assertArrayEquals("404 Not Found".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(404));
|
||||
assertArrayEquals("500 Internal Server Error".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesForCode_unknownCode_returnsNull() {
|
||||
assertNull(HttpStatus.bytesForCode(999));
|
||||
assertNull(HttpStatus.bytesForCode(0));
|
||||
assertNull(HttpStatus.bytesForCode(2000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesForCode_allEnumsPresentInIndex() {
|
||||
// Let's just double check a handful of other representations to ensure full mapping
|
||||
assertArrayEquals("100 Continue".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(100));
|
||||
assertArrayEquals("301 Moved Permanently".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(301));
|
||||
assertArrayEquals("400 Bad Request".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(400));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HeaderMapTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static HeaderMap parse(String raw, String... headers) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String h : headers) sb.append(h).append("\r\n");
|
||||
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap map = new HeaderMap(buffer);
|
||||
|
||||
int current = 0;
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
int colon = sb.indexOf(":", current);
|
||||
int lineEnd = sb.indexOf("\r\n", current);
|
||||
int valueStart = colon + 1;
|
||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
||||
|
||||
map.add(current, colon - current, valueStart, lineEnd - valueStart);
|
||||
current = lineEnd + 2;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- getFirst ---
|
||||
|
||||
@Test
|
||||
void getFirst_existingHeader() {
|
||||
HeaderMap map = parse("", "Host: localhost", "Accept: text/plain");
|
||||
assertEquals("localhost", map.getFirst("Host"));
|
||||
assertEquals("text/plain", map.getFirst("Accept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFirst_caseInsensitive() {
|
||||
HeaderMap map = parse("", "ConteNT-tYPe: application/json");
|
||||
assertEquals("application/json", map.getFirst("content-type"));
|
||||
assertEquals("application/json", map.getFirst("CONTENT-TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFirst_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
assertNull(map.getFirst("Accept"));
|
||||
}
|
||||
|
||||
// --- getAll ---
|
||||
|
||||
@Test
|
||||
void getAll_multipleValuesByName() {
|
||||
HeaderMap map = parse("", "Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
assertEquals(List.of("a=1", "b=2"), map.getAll("Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAll_missingHeader_returnsEmptyList() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
assertTrue(map.getAll("Cookie").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAll_returnsAllHeaders() {
|
||||
HeaderMap map = parse("", "A: 1", "B: 2");
|
||||
assertEquals(List.of("1", "2"), map.getAll());
|
||||
}
|
||||
|
||||
// --- getView ---
|
||||
|
||||
@Test
|
||||
void getView_returnsZeroCopyView() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
ByteView view = map.getView("Host");
|
||||
assertNotNull(view);
|
||||
assertEquals(9, view.length());
|
||||
assertEquals('l', view.byteAt(0));
|
||||
assertEquals('t', view.byteAt(8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
assertNull(map.getView("Accept"));
|
||||
}
|
||||
|
||||
// --- limit ---
|
||||
|
||||
@Test
|
||||
void maxHeadersLimit() {
|
||||
byte[] buffer = new byte[100];
|
||||
HeaderMap map = new HeaderMap(buffer);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
map.add(0, 1, 1, 1);
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> map.add(0, 1, 1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PathParamsTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static PathParams of(String path, String... paramsPairs) {
|
||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||
ByteView view = new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
|
||||
String[] names = new String[paramsPairs.length / 2];
|
||||
int[] starts = new int[paramsPairs.length / 2];
|
||||
int[] lens = new int[paramsPairs.length / 2];
|
||||
|
||||
for (int i = 0; i + 1 < paramsPairs.length; i += 2) {
|
||||
names[i / 2] = paramsPairs[i];
|
||||
String val = paramsPairs[i + 1];
|
||||
starts[i / 2] = path.indexOf(val);
|
||||
lens[i / 2] = val.length();
|
||||
}
|
||||
|
||||
return new PathParams(view, names, starts, lens);
|
||||
}
|
||||
|
||||
// --- get ---
|
||||
|
||||
@Test
|
||||
void get_existingParam() {
|
||||
PathParams params = of("/users/123/posts/456", "userId", "123", "postId", "456");
|
||||
assertEquals("123", params.get("userId"));
|
||||
assertEquals("456", params.get("postId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_missingParam_returnsNull() {
|
||||
PathParams params = of("/users/123", "userId", "123");
|
||||
assertNull(params.get("unknown"));
|
||||
}
|
||||
|
||||
// --- view ---
|
||||
|
||||
@Test
|
||||
void view_existingParamZeroCopy() {
|
||||
PathParams params = of("/users/123", "userId", "123");
|
||||
ByteView view = params.view("userId");
|
||||
assertNotNull(view);
|
||||
assertEquals(3, view.length());
|
||||
assertEquals('1', view.byteAt(0));
|
||||
assertEquals('3', view.byteAt(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void view_missingParam_returnsNull() {
|
||||
PathParams params = of("/users/123", "userId", "123");
|
||||
assertNull(params.view("unknown"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class QueryParamsTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static QueryParams of(String raw) {
|
||||
byte[] bytes = raw.getBytes(StandardCharsets.UTF_8);
|
||||
ByteView view = new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
return new QueryParams(view);
|
||||
}
|
||||
|
||||
// --- get ---
|
||||
|
||||
@Test
|
||||
void get_singleParam() {
|
||||
assertEquals("hello", of("name=hello").get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_firstOfMultiple() {
|
||||
assertEquals("1", of("a=1&b=2&c=3").get("a"));
|
||||
assertEquals("2", of("a=1&b=2&c=3").get("b"));
|
||||
assertEquals("3", of("a=1&b=2&c=3").get("c"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_missingKey_returnsNull() {
|
||||
assertNull(of("a=1&b=2").get("z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_emptyValue() {
|
||||
assertEquals("", of("key=").get("key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_multiValue_returnsFirst() {
|
||||
assertEquals("a", of("tag=a&tag=b&tag=c").get("tag"));
|
||||
}
|
||||
|
||||
// --- getAll ---
|
||||
|
||||
@Test
|
||||
void getAll_multiValue() {
|
||||
assertEquals(List.of("a", "b", "c"), of("tag=a&tag=b&tag=c").getAll("tag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAll_missingKey_returnsEmpty() {
|
||||
assertTrue(of("a=1").getAll("z").isEmpty());
|
||||
}
|
||||
|
||||
// --- EMPTY ---
|
||||
|
||||
@Test
|
||||
void empty_returnsNull() {
|
||||
assertNull(QueryParams.EMPTY.get("anything"));
|
||||
assertTrue(QueryParams.EMPTY.getAll("anything").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RequestLineTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
if (s == null) return null;
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
// --- initialization ---
|
||||
|
||||
@Test
|
||||
void constructionAndGetters() {
|
||||
ByteView path = viewOf("/api");
|
||||
ByteView query = viewOf("q=1");
|
||||
ByteView proto = viewOf("HTTP/1.1");
|
||||
HeaderMap headers = new HeaderMap(new byte[0]);
|
||||
|
||||
RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers);
|
||||
|
||||
assertEquals(HttpMethod.GET, rl.getMethod());
|
||||
assertEquals(path, rl.getPath());
|
||||
assertEquals(query, rl.getQuery());
|
||||
assertEquals(proto, rl.getProtocol());
|
||||
assertEquals(headers, rl.getHeaders());
|
||||
assertNotNull(rl.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RequestTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
if (s == null) return null;
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
// --- creation ---
|
||||
|
||||
@Test
|
||||
void request_creationAndGetters() {
|
||||
HeaderMap headers = new HeaderMap(new byte[0]);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers);
|
||||
byte[] body = "body".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Request r = new Request(line, body);
|
||||
|
||||
assertEquals(line, r.getRequestLine());
|
||||
assertArrayEquals(body, r.getBody());
|
||||
assertNotNull(r.toString());
|
||||
}
|
||||
|
||||
// --- delegates ---
|
||||
|
||||
@Test
|
||||
void headers_delegatesToRequestLine() {
|
||||
byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap headers = new HeaderMap(buffer);
|
||||
headers.add(0, 4, 6, 9);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertEquals("localhost", r.getHeader("Host"));
|
||||
assertEquals(List.of("localhost"), r.getHeaders("Host"));
|
||||
assertEquals(List.of("localhost"), r.getHeaders());
|
||||
}
|
||||
|
||||
// --- pathParams ---
|
||||
|
||||
@Test
|
||||
void pathParam_lazyGet() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0]));
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.getPathParam("id"));
|
||||
|
||||
r.setPathParams(new PathParams(viewOf("/123"), new String[]{"id"}, new int[]{1}, new int[]{3}));
|
||||
assertEquals("123", r.getPathParam("id"));
|
||||
}
|
||||
|
||||
// --- queryParams ---
|
||||
|
||||
@Test
|
||||
void queryParam_lazyGet_fromQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap(new byte[0]));
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertEquals("1", r.getQueryParam("a"));
|
||||
assertEquals("2", r.getQueryParam("b")); // First value
|
||||
assertEquals(List.of("2", "3"), r.getQueryParams("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryParam_lazyGet_nullQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0]));
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.getQueryParam("a"));
|
||||
assertTrue(r.getQueryParams("a").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ResponseTest {
|
||||
|
||||
// --- creation ---
|
||||
|
||||
@Test
|
||||
void constructor_withByteArray() {
|
||||
byte[] body = "bytes".getBytes(StandardCharsets.UTF_8);
|
||||
Response r = new Response(200, body, ContentType.BINARY);
|
||||
|
||||
assertEquals(200, r.getStatusCode());
|
||||
assertArrayEquals(body, r.getBody());
|
||||
assertArrayEquals("application/octet-stream".getBytes(StandardCharsets.UTF_8), r.getContentType());
|
||||
assertNotNull(r.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withStringText() {
|
||||
Response r = new Response(404, "Not Found Text", ContentType.TEXT_PLAIN);
|
||||
|
||||
assertEquals(404, r.getStatusCode());
|
||||
assertArrayEquals("Not Found Text".getBytes(StandardCharsets.UTF_8), r.getBody());
|
||||
assertArrayEquals(ContentType.TEXT_PLAIN.getBytes(), r.getContentType());
|
||||
}
|
||||
|
||||
// --- setters ---
|
||||
|
||||
@Test
|
||||
void setContentType_byEnum() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setContentType(ContentType.JSON);
|
||||
assertArrayEquals(ContentType.JSON.getBytes(), r.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setContentType_byString() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setContentType("application/custom");
|
||||
assertArrayEquals("application/custom".getBytes(StandardCharsets.UTF_8), r.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withByteArray() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
byte[] newBody = "new".getBytes(StandardCharsets.UTF_8);
|
||||
r.setBody(newBody);
|
||||
assertArrayEquals(newBody, r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withObjectConvertedToString() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setBody(12345); // auto boxes to Integer, toString called
|
||||
assertArrayEquals("12345".getBytes(StandardCharsets.UTF_8), r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setStatusCode() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setStatusCode(201);
|
||||
assertEquals(201, r.getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SimpleHandlerTest {
|
||||
|
||||
// --- execution ---
|
||||
|
||||
@Test
|
||||
void handle_invokesFunctionalHandler() throws Exception {
|
||||
SimpleHandler.FunctionalHandler func = (req, res) -> "Hello";
|
||||
SimpleHandler handler = new SimpleHandler(func);
|
||||
|
||||
Object result = handler.handle(null, null);
|
||||
assertEquals("Hello", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package dev.relism.routing;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AbstractRouterTest {
|
||||
|
||||
// A dummy router for testing base functionality
|
||||
static class DummyRouter extends AbstractRouter {
|
||||
RequestHandler lastAddedHandler;
|
||||
HttpMethod lastAddedMethod;
|
||||
String lastAddedPath;
|
||||
|
||||
@Override
|
||||
public RequestHandler route(Request request) {
|
||||
return null; // Not testing routing logic here
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
|
||||
this.lastAddedMethod = method;
|
||||
this.lastAddedPath = path;
|
||||
this.lastAddedHandler = handler;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@Route(method = "POST", path = "/profile")
|
||||
static class ProfileHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static class UnannotatedHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- namespace ---
|
||||
|
||||
@Test
|
||||
void setNamespace_updatesStringAndBytes() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
assertEquals("/", router.getNamespace());
|
||||
|
||||
router.setNamespace("/api");
|
||||
assertEquals("/api", router.getNamespace());
|
||||
assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
@Test
|
||||
void getPostPutDelete_delegatesToAddRouteWithSanitizedPath() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
|
||||
|
||||
router.get("users/", func);
|
||||
assertEquals(HttpMethod.GET, router.lastAddedMethod);
|
||||
assertEquals("/users", router.lastAddedPath);
|
||||
assertTrue(router.lastAddedHandler instanceof SimpleHandler);
|
||||
|
||||
router.post("/items", func);
|
||||
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
||||
|
||||
router.put("update", func);
|
||||
assertEquals(HttpMethod.PUT, router.lastAddedMethod);
|
||||
|
||||
router.delete("//delete//", func);
|
||||
assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
|
||||
assertEquals("/delete", router.lastAddedPath);
|
||||
}
|
||||
|
||||
// --- register ---
|
||||
|
||||
@Test
|
||||
void register_annotatedHandler_addsRoute() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
ProfileHandler handler = new ProfileHandler();
|
||||
|
||||
router.register(handler);
|
||||
|
||||
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
||||
assertEquals("/profile", router.lastAddedPath);
|
||||
assertEquals(handler, router.lastAddedHandler);
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_unannotatedHandler_doesNothing() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
router.register(new UnannotatedHandler());
|
||||
assertNull(router.lastAddedMethod); // Nothing added
|
||||
}
|
||||
|
||||
// --- default handlers ---
|
||||
|
||||
@Test
|
||||
void defaultNotFoundHandler_returns404Html() throws Exception {
|
||||
DummyRouter router = new DummyRouter();
|
||||
Response res = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
|
||||
assertNotNull(router.getNotFoundHandler());
|
||||
|
||||
SimpleHandler.FunctionalHandler custom = (req, resp) -> "Custom 404";
|
||||
router.onNotFound(custom);
|
||||
|
||||
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultExceptionHandler_canBeOverridden() throws Exception {
|
||||
DummyRouter router = new DummyRouter();
|
||||
assertNotNull(router.getExceptionHandler());
|
||||
|
||||
AbstractRouter.ExceptionHandler custom = (ex, req, res) -> "Caught";
|
||||
router.onException(custom);
|
||||
|
||||
assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package dev.relism.routing;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.RequestLine;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GlobalRouterTest {
|
||||
|
||||
// --- mock ---
|
||||
|
||||
static class MockSubRouter extends AbstractRouter {
|
||||
RequestHandler matchedHandler;
|
||||
|
||||
MockSubRouter(RequestHandler handler) {
|
||||
this.matchedHandler = handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestHandler route(Request request) {
|
||||
return matchedHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
private Request mockRequest(String path) {
|
||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
||||
|
||||
RequestLine line = new RequestLine(
|
||||
HttpMethod.GET, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new HeaderMap(new byte[0])
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
// --- mount & route ---
|
||||
|
||||
@Test
|
||||
void route_delegatesToSubRouterBasedOnLongestPrefix() {
|
||||
GlobalRouter global = new GlobalRouter();
|
||||
|
||||
RequestHandler hApi = new SimpleHandler((req, res) -> "api");
|
||||
RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1");
|
||||
|
||||
global.mount("/api", new MockSubRouter(hApi));
|
||||
global.mount("/api/v1", new MockSubRouter(hApiV1)); // longer prefix
|
||||
|
||||
// Path matches /api/v1 -> Should pick hApiV1 because it's longer and sorted first
|
||||
RequestHandler resolved = global.route(mockRequest("/api/v1/users"));
|
||||
assertEquals(hApiV1, resolved);
|
||||
|
||||
// Path matches /api but not /api/v1
|
||||
RequestHandler resolved2 = global.route(mockRequest("/api/v2/users"));
|
||||
assertEquals(hApi, resolved2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void route_fallsBackToInternalRouter() throws Exception {
|
||||
GlobalRouter global = new GlobalRouter();
|
||||
RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal");
|
||||
|
||||
global.get("/hello", (req, res) -> "internal");
|
||||
// We know it routes to internal. Let's send a request.
|
||||
RequestHandler resolved = global.route(mockRequest("/hello"));
|
||||
assertNotNull(resolved);
|
||||
// It's the compiled FastPathRouter handler, let's verify it works
|
||||
assertEquals("internal", resolved.handle(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void route_noMatch_returnsNotFoundHandler() {
|
||||
GlobalRouter global = new GlobalRouter();
|
||||
// Nothing registered. Should return the global notFoundHandler.
|
||||
RequestHandler resolved = global.route(mockRequest("/unknown"));
|
||||
assertEquals(global.getNotFoundHandler(), resolved);
|
||||
}
|
||||
|
||||
// --- resolveExceptionHandler ---
|
||||
|
||||
@Test
|
||||
void resolveExceptionHandler_returnsScopedHandler() {
|
||||
GlobalRouter global = new GlobalRouter();
|
||||
MockSubRouter sub = new MockSubRouter(null);
|
||||
AbstractRouter.ExceptionHandler customSubHandler = (ex, req, res) -> "sub error";
|
||||
sub.onException(customSubHandler);
|
||||
|
||||
global.mount("/api", sub);
|
||||
|
||||
// Under sub-namespace
|
||||
assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail")));
|
||||
|
||||
// Outside sub-namespace (global)
|
||||
assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.relism.routing;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PathUtilsTest {
|
||||
|
||||
// --- sanitize ---
|
||||
|
||||
@Test
|
||||
void sanitize_nullOrBlank_returnsRoot() {
|
||||
assertEquals("/", PathUtils.sanitize(null));
|
||||
assertEquals("/", PathUtils.sanitize(""));
|
||||
assertEquals("/", PathUtils.sanitize(" "));
|
||||
assertEquals("/", PathUtils.sanitize("/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_trimsWhitespaceAndEnsuresLeadingSlash() {
|
||||
assertEquals("/users", PathUtils.sanitize(" users "));
|
||||
assertEquals("/api", PathUtils.sanitize("api"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_removesTrailingSlash() {
|
||||
assertEquals("/users", PathUtils.sanitize("/users/"));
|
||||
assertEquals("/api/v1", PathUtils.sanitize("/api/v1/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_collapsesMultipleSlashes() {
|
||||
assertEquals("/a/b/c", PathUtils.sanitize("//a///b//c/"));
|
||||
}
|
||||
|
||||
// --- join ---
|
||||
|
||||
@Test
|
||||
void join_withRootBase_returnsSanitizedPath() {
|
||||
assertEquals("/users", PathUtils.join("/", "/users/"));
|
||||
assertEquals("/users", PathUtils.join("/", "users"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void join_withRootPath_returnsSanitizedBase() {
|
||||
assertEquals("/api", PathUtils.join("/api/", "/"));
|
||||
assertEquals("/api", PathUtils.join("api", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void join_preventsDoubleNamespace() {
|
||||
// Path already starts with base
|
||||
assertEquals("/api/users", PathUtils.join("/api", "/api/users"));
|
||||
assertEquals("/api/users", PathUtils.join("/api/", "/api/users/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void join_concatenatesProperly() {
|
||||
assertEquals("/api/users", PathUtils.join("/api", "users"));
|
||||
assertEquals("/api/users", PathUtils.join("/api", "/users"));
|
||||
assertEquals("/api/users", PathUtils.join("api", "users"));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.relism.routing.routers.fastpathrouter;
|
||||
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.RequestLine;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FastPathRouterImplTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private Request mockRequest(HttpMethod method, String path) {
|
||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
||||
|
||||
RequestLine line = new RequestLine(
|
||||
method, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new HeaderMap(new byte[0])
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
// --- route ---
|
||||
|
||||
@Test
|
||||
void route_lazyCompilationAndMatch() {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.get("/a", (req, res) -> "A");
|
||||
router.post("/b", (req, res) -> "B");
|
||||
|
||||
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
|
||||
assertNotNull(res1);
|
||||
assertEquals("A", res1.handle(null, null));
|
||||
|
||||
RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b"));
|
||||
assertNotNull(res2);
|
||||
assertEquals("B", res2.handle(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void route_noMatch_returnsNull() {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
router.get("/a", (req, res) -> "A");
|
||||
|
||||
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
|
||||
// Wrong method
|
||||
assertNull(router.route(mockRequest(HttpMethod.POST, "/a")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void route_extractsPathParams() {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract");
|
||||
|
||||
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
|
||||
RequestHandler handler = router.route(request);
|
||||
|
||||
assertNotNull(handler);
|
||||
assertEquals("Extract", handler.handle(request, null));
|
||||
|
||||
// Verify path params were injected
|
||||
assertNotNull(request.getPathParams());
|
||||
assertEquals("123", request.getPathParam("id"));
|
||||
assertEquals("456", request.getPathParam("itemId"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.relism.routing.routers.fastpathrouter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FastPathViewsTest {
|
||||
|
||||
private static final byte[] SHARED_BUFFER = "GET /api/users?id=1 HTTP/1.1".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// --- RequestByteView ---
|
||||
|
||||
@Test
|
||||
void requestByteView_readsCorrectSlice() {
|
||||
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10);
|
||||
assertEquals(10, view.length());
|
||||
assertEquals('/', view.byteAt(0));
|
||||
assertEquals('s', view.byteAt(9));
|
||||
assertEquals("/api/users", view.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestByteView_outOfBounds_throwsException() {
|
||||
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10);
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(-1));
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10));
|
||||
}
|
||||
|
||||
// --- MethodPathByteView ---
|
||||
|
||||
@Test
|
||||
void methodPathByteView_combinesViewsCorrectly() {
|
||||
byte[] methodBytes = "POST".getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); // "/api/users"
|
||||
|
||||
FastPathViews.MethodPathByteView composite = new FastPathViews.MethodPathByteView();
|
||||
composite.reset(methodBytes, pathView);
|
||||
|
||||
assertEquals(14, composite.length());
|
||||
assertEquals('P', composite.byteAt(0));
|
||||
assertEquals('T', composite.byteAt(3));
|
||||
assertEquals('/', composite.byteAt(4));
|
||||
assertEquals('s', composite.byteAt(13));
|
||||
}
|
||||
|
||||
// --- SocketByteView & StringByteView ---
|
||||
|
||||
@Test
|
||||
void socketByteView_wrapsByteArray() {
|
||||
byte[] data = "Hello".getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.SocketByteView view = new FastPathViews.SocketByteView(data);
|
||||
assertEquals(5, view.length());
|
||||
assertEquals('H', view.byteAt(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringByteView_wrapsString() {
|
||||
FastPathViews.StringByteView view = new FastPathViews.StringByteView("Hello");
|
||||
assertEquals(5, view.length());
|
||||
assertEquals('o', view.byteAt(4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.relism.template;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ByteTemplateTest {
|
||||
|
||||
// --- render ---
|
||||
|
||||
@Test
|
||||
void render_singlePlaceholder() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
|
||||
byte[] result = tpl.render("name", "World");
|
||||
assertEquals("Hello World!", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_multiplePlaceholders() {
|
||||
ByteTemplate tpl = new ByteTemplate("{{greeting}} {{name}}, welcome to {{place}}");
|
||||
byte[] result = tpl.render(
|
||||
"greeting", "Hi",
|
||||
"name", "Alice",
|
||||
"place", "Wonderland"
|
||||
);
|
||||
assertEquals("Hi Alice, welcome to Wonderland", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_repeatedPlaceholder() {
|
||||
ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}");
|
||||
byte[] result = tpl.render("var", "test");
|
||||
assertEquals("test == test", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_unmatchedPlaceholder_leavesEmptySpace() {
|
||||
ByteTemplate tpl = new ByteTemplate("A{{foo}}B");
|
||||
byte[] result = tpl.render("bar", "baz"); // foo is missing
|
||||
assertEquals("AB", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_noPlaceholders_returnsIdenticalOutput() {
|
||||
ByteTemplate tpl = new ByteTemplate("Static Content Only");
|
||||
byte[] result = tpl.render("ignored", "value");
|
||||
assertEquals("Static Content Only", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_adjacentPlaceholders() {
|
||||
ByteTemplate tpl = new ByteTemplate("A{{v1}}{{v2}}B");
|
||||
byte[] result = tpl.render("v1", "1", "v2", "2");
|
||||
assertEquals("A12B", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.relism.template;
|
||||
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestLine;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ErrorPagesTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private Request mockRequest() {
|
||||
String path = "/api/test";
|
||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
||||
|
||||
String protocol = "HTTP/1.1";
|
||||
byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length);
|
||||
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap(new byte[0]));
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
// --- templates ---
|
||||
|
||||
@Test
|
||||
void renderNotFound_generatesHtml() {
|
||||
Request req = mockRequest();
|
||||
byte[] html = ErrorPages.renderNotFound(req);
|
||||
String result = new String(html, StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(result.contains("404"));
|
||||
assertTrue(result.contains("No route matched this request."));
|
||||
assertTrue(result.contains("/api/test")); // Injected path
|
||||
assertTrue(result.contains("GET")); // Injected method
|
||||
assertTrue(result.contains("footer-logo")); // Baked-in logo
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderException_generatesHtml() {
|
||||
Request req = mockRequest();
|
||||
Exception ex = new IllegalArgumentException("Invalid state in test application");
|
||||
byte[] html = ErrorPages.renderException(req, ex);
|
||||
String result = new String(html, StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(result.contains("500"));
|
||||
assertTrue(result.contains("/api/test"));
|
||||
assertTrue(result.contains("IllegalArgumentException"));
|
||||
assertTrue(result.contains("Invalid state in test application"));
|
||||
assertTrue(result.contains("ErrorPagesTest.java")); // Stacktrace inclusion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user