From 78d90855c5b0240e742b505b21b6c0ceb64228d3 Mon Sep 17 00:00:00 2001 From: CodingPhoenixx Date: Thu, 28 May 2026 13:40:24 +0200 Subject: [PATCH] Add test coverage for core server components: annotation scanning, routing, rate limiting, CORS, and JSON handling --- .gitea/workflows/test.yml | 44 ++++++ build.gradle | 12 ++ .../coph/nextusweb/server/HttpServerTest.java | 31 ++++ .../annotation/AnnotationScannerTest.java | 113 ++++++++++++++ .../server/annotation/AnnotationsTest.java | 81 ++++++++++ .../server/cores/CorsConfigTest.java | 61 ++++++++ .../server/cores/CorsHandlerTest.java | 95 ++++++++++++ .../nextusweb/server/json/JsonMapperTest.java | 36 +++++ .../ratelimit/FixedWindowLimiterTest.java | 43 ++++++ .../server/ratelimit/KeyResolverTest.java | 53 +++++++ .../ratelimit/LeakyBucketLimiterTest.java | 43 ++++++ .../server/ratelimit/RateLimitConfigTest.java | 78 ++++++++++ .../server/ratelimit/RateLimitGateTest.java | 77 ++++++++++ .../ratelimit/RateLimiterResultTest.java | 26 ++++ .../ratelimit/SlidingWindowLimiterTest.java | 38 +++++ .../ratelimit/TokenBucketLimiterTest.java | 50 +++++++ .../nextusweb/server/router/RequestTest.java | 104 +++++++++++++ .../nextusweb/server/router/ResponseTest.java | 54 +++++++ .../nextusweb/server/router/RouterTest.java | 83 +++++++++++ .../exception/BadRequestExceptionTest.java | 19 +++ .../server/websocket/WebSocketConfigTest.java | 79 ++++++++++ .../WebSocketFrameHandlerFactoryTest.java | 20 +++ .../server/websocket/WebSocketGroupTest.java | 134 +++++++++++++++++ .../websocket/WebSocketHandlerTest.java | 23 +++ .../server/websocket/WebSocketRouterTest.java | 45 ++++++ .../websocket/WebSocketSessionTest.java | 138 ++++++++++++++++++ 26 files changed, 1580 insertions(+) create mode 100644 .gitea/workflows/test.yml create mode 100644 src/test/java/dev/coph/nextusweb/server/HttpServerTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/annotation/AnnotationScannerTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/annotation/AnnotationsTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/cores/CorsConfigTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/cores/CorsHandlerTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/json/JsonMapperTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiterTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/KeyResolverTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiterTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfigTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitGateTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimiterResultTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiterTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiterTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/router/RequestTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/router/ResponseTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/router/RouterTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/router/exception/BadRequestExceptionTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/websocket/WebSocketConfigTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandlerFactoryTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/websocket/WebSocketGroupTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/websocket/WebSocketHandlerTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/websocket/WebSocketRouterTest.java create mode 100644 src/test/java/dev/coph/nextusweb/server/websocket/WebSocketSessionTest.java diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..c09cb64 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,44 @@ +name: Run Tests on Push and Pull Request + +on: + push: + branches: + - master + pull_request: + +jobs: + run-tests: + runs-on: java26 + + steps: + - name: Checkout Code + run: | + SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///') + rm -rf "$GITHUB_WORKSPACE"/* + git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE" + cd "$GITHUB_WORKSPACE" + git checkout ${{ github.sha }} + + - name: Make gradlew executable + run: | + cd "$GITHUB_WORKSPACE" + chmod +x ./gradlew + + - name: Run JUnit tests + id: run_tests + run: | + cd "$GITHUB_WORKSPACE" + ./gradlew test --no-daemon --stacktrace + + - name: Upload test reports + if: always() + run: | + cd "$GITHUB_WORKSPACE" + if [ -d "build/reports/tests/test" ]; then + echo "Test reports available in build/reports/tests/test" + ls -la build/reports/tests/test || true + fi + if [ -d "build/test-results/test" ]; then + echo "Test result XMLs:" + ls -la build/test-results/test || true + fi diff --git a/build.gradle b/build.gradle index a6b5a30..6d6702b 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,10 @@ repositories { dependencies { implementation 'io.netty:netty-all:4.2.14.Final' implementation 'tools.jackson.core:jackson-databind:3.1.3' + + testImplementation platform('org.junit:junit-bom:5.11.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } java { @@ -23,6 +27,14 @@ java { targetCompatibility = JavaVersion.VERSION_26 } +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + showStandardStreams = false + } +} + publishing { publications { diff --git a/src/test/java/dev/coph/nextusweb/server/HttpServerTest.java b/src/test/java/dev/coph/nextusweb/server/HttpServerTest.java new file mode 100644 index 0000000..c4adb75 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/HttpServerTest.java @@ -0,0 +1,31 @@ +package dev.coph.nextusweb.server; + +import dev.coph.nextusweb.server.cores.CorsConfig; +import dev.coph.nextusweb.server.cores.CorsHandler; +import dev.coph.nextusweb.server.ratelimit.RateLimitConfig; +import dev.coph.nextusweb.server.ratelimit.RateLimitGate; +import dev.coph.nextusweb.server.router.Router; +import dev.coph.nextusweb.server.websocket.WebSocketConfig; +import dev.coph.nextusweb.server.websocket.WebSocketRouter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpServerTest { + + @Test + void builderReturnsConfiguredServer() { + Router router = new Router(); + HttpServer server = HttpServer.builder(0, router); + assertNotNull(server); + assertSame(server, server.withCorsHandler(new CorsHandler(CorsConfig.permissive()))); + RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder().build()); + try { + assertSame(server, server.withRateLimitGate(gate)); + assertSame(server, server.withWebSockets(new WebSocketRouter())); + assertSame(server, server.withWebSockets(new WebSocketRouter(), WebSocketConfig.defaults())); + } finally { + gate.shutdown(); + } + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/annotation/AnnotationScannerTest.java b/src/test/java/dev/coph/nextusweb/server/annotation/AnnotationScannerTest.java new file mode 100644 index 0000000..c26b699 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/annotation/AnnotationScannerTest.java @@ -0,0 +1,113 @@ +package dev.coph.nextusweb.server.annotation; + +import dev.coph.nextusweb.server.router.Request; +import dev.coph.nextusweb.server.router.Response; +import dev.coph.nextusweb.server.router.Router; +import io.netty.handler.codec.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class AnnotationScannerTest { + + @Controller("/api") + static class GoodController { + AtomicBoolean called = new AtomicBoolean(false); + + @GET("/hello") + public void hello(Request req, Response res) { + called.set(true); + res.text("hi"); + } + + @POST("post") + public void post(Request req, Response res) {} + + @Route(method = "PUT", path = "/put") + public void put(Request req, Response res) {} + + @CUSTOM(method = "OPTIONS", value = "/opt") + public void opt(Request req, Response res) {} + + public void notAnnotated(Request req, Response res) {} + } + + static class NoControllerAnnotation { + @GET("/x") + public void x(Request req, Response res) {} + } + + @Controller("nopref") + static class PrefixNoSlash { + @GET("/a") + public void a(Request req, Response res) {} + } + + static class BadSignature { + @GET("/bad") + public String bad(String s) { return s; } + } + + static class WrongReturnType { + @GET("/bad") + public String bad(Request req, Response res) { return "no"; } + } + + @Test + void registersAllAnnotatedRoutesWithPrefix() { + Router router = new Router(); + GoodController ctrl = new GoodController(); + AnnotationScanner.register(router, ctrl); + + assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/api/hello")); + assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.POST, "/api/post")); + assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.PUT, "/api/put")); + assertInstanceOf(Router.Resolution.Match.class, + router.resolve(HttpMethod.valueOf("OPTIONS"), "/api/opt")); + } + + @Test + void registrationWithoutControllerAnnotationUsesEmptyPrefix() { + Router router = new Router(); + AnnotationScanner.register(router, new NoControllerAnnotation()); + assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/x")); + } + + @Test + void prefixWithoutLeadingSlashIsNormalized() { + Router router = new Router(); + AnnotationScanner.register(router, new PrefixNoSlash()); + assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/nopref/a")); + } + + @Test + void invokingRegisteredHandlerCallsTheControllerMethod() throws Exception { + Router router = new Router(); + GoodController ctrl = new GoodController(); + AnnotationScanner.register(router, ctrl); + + Router.Resolution res = router.resolve(HttpMethod.GET, "/api/hello"); + Router.Resolution.Match m = assertInstanceOf(Router.Resolution.Match.class, res); + + Response resp = new Response(); + m.handler().handle(null, resp); + assertTrue(ctrl.called.get()); + assertEquals(200, resp.status()); + } + + @Test + void badSignatureThrows() { + Router router = new Router(); + assertThrows(IllegalArgumentException.class, + () -> AnnotationScanner.register(router, new BadSignature())); + } + + @Test + void wrongReturnTypeThrows() { + Router router = new Router(); + assertThrows(IllegalArgumentException.class, + () -> AnnotationScanner.register(router, new WrongReturnType())); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/annotation/AnnotationsTest.java b/src/test/java/dev/coph/nextusweb/server/annotation/AnnotationsTest.java new file mode 100644 index 0000000..436ab02 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/annotation/AnnotationsTest.java @@ -0,0 +1,81 @@ +package dev.coph.nextusweb.server.annotation; + +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +class AnnotationsTest { + + @Controller + static class CtrlDefault {} + + @Controller("/api") + static class CtrlWithValue {} + + static class Routes { + @GET("/g") public void g() {} + @POST("/p") public void p() {} + @PUT("/u") public void u() {} + @PATCH("/pa") public void pa() {} + @DELETE("/d") public void d() {} + @CUSTOM(method = "OPTIONS", value = "/o") public void o() {} + @Route(method = "TRACE", path = "/t") public void t() {} + } + + @Test + void controllerDefaultValueIsEmpty() { + Controller c = CtrlDefault.class.getAnnotation(Controller.class); + assertNotNull(c); + assertEquals("", c.value()); + } + + @Test + void controllerValueIsCarried() { + Controller c = CtrlWithValue.class.getAnnotation(Controller.class); + assertNotNull(c); + assertEquals("/api", c.value()); + } + + @Test + void controllerHasRuntimeRetentionAndTypeTarget() { + Retention r = Controller.class.getAnnotation(Retention.class); + Target t = Controller.class.getAnnotation(Target.class); + assertEquals(RetentionPolicy.RUNTIME, r.value()); + assertArrayEquals(new ElementType[]{ElementType.TYPE}, t.value()); + } + + @Test + void routeMethodAnnotationsCarryValues() throws Exception { + assertEquals("/g", Routes.class.getDeclaredMethod("g").getAnnotation(GET.class).value()); + assertEquals("/p", Routes.class.getDeclaredMethod("p").getAnnotation(POST.class).value()); + assertEquals("/u", Routes.class.getDeclaredMethod("u").getAnnotation(PUT.class).value()); + assertEquals("/pa", Routes.class.getDeclaredMethod("pa").getAnnotation(PATCH.class).value()); + assertEquals("/d", Routes.class.getDeclaredMethod("d").getAnnotation(DELETE.class).value()); + + CUSTOM custom = Routes.class.getDeclaredMethod("o").getAnnotation(CUSTOM.class); + assertEquals("OPTIONS", custom.method()); + assertEquals("/o", custom.value()); + + Route route = Routes.class.getDeclaredMethod("t").getAnnotation(Route.class); + assertEquals("TRACE", route.method()); + assertEquals("/t", route.path()); + } + + @Test + void allMethodAnnotationsTargetMethods() { + Class[] anns = {GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, CUSTOM.class, Route.class}; + for (Class a : anns) { + Target t = a.getAnnotation(Target.class); + Retention r = a.getAnnotation(Retention.class); + assertNotNull(t, a.getSimpleName() + " missing @Target"); + assertNotNull(r, a.getSimpleName() + " missing @Retention"); + assertEquals(RetentionPolicy.RUNTIME, r.value()); + assertArrayEquals(new ElementType[]{ElementType.METHOD}, t.value()); + } + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/cores/CorsConfigTest.java b/src/test/java/dev/coph/nextusweb/server/cores/CorsConfigTest.java new file mode 100644 index 0000000..4ab1cbf --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/cores/CorsConfigTest.java @@ -0,0 +1,61 @@ +package dev.coph.nextusweb.server.cores; + +import io.netty.handler.codec.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CorsConfigTest { + + @Test + void permissiveBuildsWithExpectedDefaults() { + CorsConfig c = CorsConfig.permissive(); + assertTrue(c.allowAnyOrigin()); + assertFalse(c.allowCredentials()); + assertEquals(3600, c.maxAgeSeconds()); + assertTrue(c.allowedMethods().contains(HttpMethod.GET)); + assertTrue(c.allowedMethods().contains(HttpMethod.OPTIONS)); + assertTrue(c.allowedHeaders().contains("Authorization")); + } + + @Test + void isOriginAllowedHandlesNullAndWildcard() { + assertFalse(CorsConfig.permissive().isOriginAllowed(null)); + assertTrue(CorsConfig.permissive().isOriginAllowed("https://anything")); + } + + @Test + void exactOriginMatchOnly() { + CorsConfig c = CorsConfig.builder() + .allowedOrigins("https://a.com") + .build(); + assertTrue(c.isOriginAllowed("https://a.com")); + assertFalse(c.isOriginAllowed("https://b.com")); + assertFalse(c.isOriginAllowed(null)); + } + + @Test + void wildcardWithCredentialsIsRejected() { + assertThrows(IllegalStateException.class, () -> CorsConfig.builder() + .anyOrigin() + .allowCredentials(true) + .build()); + } + + @Test + void exposedAndAllowedHeadersAreCopied() { + CorsConfig c = CorsConfig.builder() + .allowedHeaders("A", "B") + .exposedHeaders("X") + .allowedMethods(HttpMethod.GET) + .allowCredentials(true) + .maxAgeSeconds(60) + .allowedOrigins("http://a") + .build(); + assertEquals(2, c.allowedHeaders().size()); + assertTrue(c.exposedHeaders().contains("X")); + assertTrue(c.allowCredentials()); + assertEquals(60, c.maxAgeSeconds()); + assertFalse(c.allowAnyOrigin()); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/cores/CorsHandlerTest.java b/src/test/java/dev/coph/nextusweb/server/cores/CorsHandlerTest.java new file mode 100644 index 0000000..be268c4 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/cores/CorsHandlerTest.java @@ -0,0 +1,95 @@ +package dev.coph.nextusweb.server.cores; + +import dev.coph.nextusweb.server.router.Response; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CorsHandlerTest { + + private CorsHandler permissiveHandler() { + return new CorsHandler(CorsConfig.permissive()); + } + + @Test + void applyHeadersDoesNothingForNullOrigin() { + Response res = new Response(); + permissiveHandler().applyHeaders(null, res); + assertNull(res.headers().get("Access-Control-Allow-Origin")); + } + + @Test + void applyHeadersDoesNothingForDisallowedOrigin() { + CorsHandler h = new CorsHandler(CorsConfig.builder() + .allowedOrigins("https://allow") + .allowedMethods(HttpMethod.GET).build()); + Response res = new Response(); + h.applyHeaders("https://other", res); + assertNull(res.headers().get("Access-Control-Allow-Origin")); + } + + @Test + void applyHeadersWritesWildcardWhenAnyOriginAndNoCreds() { + Response res = new Response(); + permissiveHandler().applyHeaders("https://x.com", res); + assertEquals("*", res.headers().get("Access-Control-Allow-Origin")); + } + + @Test + void applyHeadersWritesOriginAndVaryWhenSpecific() { + CorsHandler h = new CorsHandler(CorsConfig.builder() + .allowedOrigins("https://a") + .allowedMethods(HttpMethod.GET) + .allowCredentials(true) + .exposedHeaders("X-Custom").build()); + Response res = new Response(); + h.applyHeaders("https://a", res); + assertEquals("https://a", res.headers().get("Access-Control-Allow-Origin")); + assertEquals("Origin", res.headers().get("Vary")); + assertEquals("true", res.headers().get("Access-Control-Allow-Credentials")); + assertEquals("X-Custom", res.headers().get("Access-Control-Expose-Headers")); + } + + @Test + void isPreflightTrueOnlyForOptionsWithRequestMethod() { + HttpHeaders hs = new DefaultHttpHeaders(); + hs.set("Access-Control-Request-Method", "GET"); + assertTrue(permissiveHandler().isPreflight(HttpMethod.OPTIONS, hs)); + assertFalse(permissiveHandler().isPreflight(HttpMethod.GET, hs)); + HttpHeaders empty = new DefaultHttpHeaders(); + assertFalse(permissiveHandler().isPreflight(HttpMethod.OPTIONS, empty)); + } + + @Test + void handlePreflightReturns403ForDisallowedOrigin() { + CorsHandler h = new CorsHandler(CorsConfig.builder() + .allowedOrigins("https://allow") + .allowedMethods(HttpMethod.GET).build()); + Response res = h.handlePreflight("https://other", new DefaultHttpHeaders()); + assertEquals(403, res.status()); + } + + @Test + void handlePreflightWritesAllowAndMaxAge() { + Response res = permissiveHandler().handlePreflight("https://x.com", new DefaultHttpHeaders()); + assertEquals(204, res.status()); + assertNotNull(res.headers().get("Access-Control-Allow-Methods")); + assertEquals("3600", res.headers().get("Access-Control-Max-Age")); + } + + @Test + void handlePreflightEchoesRequestedHeadersIfNoneConfigured() { + CorsConfig cfg = CorsConfig.builder() + .anyOrigin() + .allowedMethods(HttpMethod.GET) + .build(); + CorsHandler h = new CorsHandler(cfg); + HttpHeaders req = new DefaultHttpHeaders(); + req.set("Access-Control-Request-Headers", "x-foo"); + Response res = h.handlePreflight("https://x.com", req); + assertEquals("x-foo", res.headers().get("Access-Control-Allow-Headers")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/json/JsonMapperTest.java b/src/test/java/dev/coph/nextusweb/server/json/JsonMapperTest.java new file mode 100644 index 0000000..c2905e9 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/json/JsonMapperTest.java @@ -0,0 +1,36 @@ +package dev.coph.nextusweb.server.json; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonMapperTest { + + @Test + void mapperIsAvailable() { + assertNotNull(JsonMapper.MAPPER); + } + + @Test + void mapperRoundTripsSimpleValues() { + var node = JsonMapper.MAPPER.valueToTree(java.util.Map.of("a", 1)); + assertTrue(node.isObject()); + assertEquals(1, node.get("a").asInt()); + } + + @Test + void mapperReadsTree() { + JsonNode n = JsonMapper.MAPPER.readTree("{\"k\":\"v\"}"); + assertTrue(n.has("k")); + assertNotNull(n.get("k")); + } + + @Test + void mapperSerializesToBytes() { + byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(java.util.Map.of("a", "b")); + String s = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + assertTrue(s.contains("\"a\"")); + assertTrue(s.contains("\"b\"")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiterTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiterTest.java new file mode 100644 index 0000000..61e95db --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiterTest.java @@ -0,0 +1,43 @@ +package dev.coph.nextusweb.server.ratelimit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FixedWindowLimiterTest { + + @Test + void allowsUpToLimitThenDenies() { + FixedWindowLimiter lim = new FixedWindowLimiter(3, 1000); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertTrue(lim.tryAcquire("k", 0).allowed()); + RateLimiter.Result r = lim.tryAcquire("k", 0); + assertFalse(r.allowed()); + assertEquals(3, r.limit()); + assertTrue(r.retryAfterMillis() > 0); + } + + @Test + void newWindowResetsCount() { + FixedWindowLimiter lim = new FixedWindowLimiter(1, 100); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertFalse(lim.tryAcquire("k", 0).allowed()); + long windowNs = 100L * 1_000_000L; + assertTrue(lim.tryAcquire("k", windowNs).allowed()); + } + + @Test + void differentKeysAreIndependent() { + FixedWindowLimiter lim = new FixedWindowLimiter(1, 1000); + assertTrue(lim.tryAcquire("a", 0).allowed()); + assertTrue(lim.tryAcquire("b", 0).allowed()); + } + + @Test + void cleanupDoesNotThrow() { + FixedWindowLimiter lim = new FixedWindowLimiter(1, 1000); + lim.tryAcquire("k", System.nanoTime()); + assertDoesNotThrow(() -> lim.cleanup(0)); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/KeyResolverTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/KeyResolverTest.java new file mode 100644 index 0000000..df02435 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/KeyResolverTest.java @@ -0,0 +1,53 @@ +package dev.coph.nextusweb.server.ratelimit; + +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpVersion; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class KeyResolverTest { + + private HttpRequest req(String header, String value) { + HttpRequest r = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/"); + if (header != null) r.headers().set(header, value); + return r; + } + + @Test + void clientIpUsesRemoteWhenNoForwardedHeader() { + assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(req(null, null), "10.0.0.1")); + } + + @Test + void clientIpUsesForwardedHeaderFirstValue() { + HttpRequest r = req("X-Forwarded-For", "1.1.1.1, 2.2.2.2"); + assertEquals("1.1.1.1", KeyResolver.clientIp().resolve(r, "10.0.0.1")); + } + + @Test + void clientIpHandlesSingleForwardedValue() { + HttpRequest r = req("X-Forwarded-For", "3.3.3.3"); + assertEquals("3.3.3.3", KeyResolver.clientIp().resolve(r, "10.0.0.1")); + } + + @Test + void userOrIpReturnsBearerToken() { + HttpRequest r = req("Authorization", "Bearer abc123"); + assertEquals("u:abc123", KeyResolver.userOrIp().resolve(r, "10.0.0.1")); + } + + @Test + void userOrIpFallsBackToClientIp() { + HttpRequest r = req(null, null); + assertEquals("ip:10.0.0.1", KeyResolver.userOrIp().resolve(r, "10.0.0.1")); + } + + @Test + void userOrIpIgnoresNonBearerAuth() { + HttpRequest r = req("Authorization", "Basic xyz"); + assertEquals("ip:10.0.0.1", KeyResolver.userOrIp().resolve(r, "10.0.0.1")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiterTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiterTest.java new file mode 100644 index 0000000..b3e7802 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiterTest.java @@ -0,0 +1,43 @@ +package dev.coph.nextusweb.server.ratelimit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LeakyBucketLimiterTest { + + @Test + void fillsUpToCapacityThenDenies() { + LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 2); + long now = 0; + assertTrue(lim.tryAcquire("k", now).allowed()); + assertTrue(lim.tryAcquire("k", now).allowed()); + RateLimiter.Result r = lim.tryAcquire("k", now); + assertFalse(r.allowed()); + assertEquals(2, r.limit()); + assertTrue(r.retryAfterMillis() > 0); + } + + @Test + void leakReducesLevelAndAllowsAgain() { + LeakyBucketLimiter lim = new LeakyBucketLimiter(10, 1); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertFalse(lim.tryAcquire("k", 0).allowed()); + long oneSec = 1_000_000_000L; + assertTrue(lim.tryAcquire("k", oneSec).allowed()); + } + + @Test + void differentKeysAreIndependent() { + LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 1); + assertTrue(lim.tryAcquire("a", 0).allowed()); + assertTrue(lim.tryAcquire("b", 0).allowed()); + } + + @Test + void cleanupDoesNotThrow() { + LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 1); + lim.tryAcquire("k", System.nanoTime()); + assertDoesNotThrow(() -> lim.cleanup(0)); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfigTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfigTest.java new file mode 100644 index 0000000..f232767 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfigTest.java @@ -0,0 +1,78 @@ +package dev.coph.nextusweb.server.ratelimit; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimitConfigTest { + + private RateLimiter alwaysAllow() { + return (k, now) -> RateLimiter.Result.allow(1, 1); + } + + private KeyResolver keyer() { + return (req, remote) -> "x"; + } + + @Test + void emptyConfigReturnsEmptyList() { + RateLimitConfig cfg = RateLimitConfig.builder().build(); + assertTrue(cfg.rulesFor("/anything").isEmpty()); + } + + @Test + void globalOnlyReturnsOneRule() { + RateLimitConfig cfg = RateLimitConfig.builder() + .global(alwaysAllow(), keyer()) + .build(); + List rules = cfg.rulesFor("/x"); + assertEquals(1, rules.size()); + assertEquals("global", rules.getFirst().name()); + } + + @Test + void exactPathTrumpsPrefixRule() { + RateLimitConfig cfg = RateLimitConfig.builder() + .forPath("/a/b", alwaysAllow(), keyer()) + .forPrefix("/a/", alwaysAllow(), keyer()) + .build(); + List rules = cfg.rulesFor("/a/b"); + assertEquals(1, rules.size()); + assertEquals("/a/b", rules.getFirst().name()); + } + + @Test + void prefixRuleMatchesWhenNoExact() { + RateLimitConfig cfg = RateLimitConfig.builder() + .forPrefix("/api/", alwaysAllow(), keyer()) + .build(); + List rules = cfg.rulesFor("/api/users"); + assertEquals(1, rules.size()); + assertEquals("/api/*", rules.getFirst().name()); + } + + @Test + void longerPrefixWinsOverShorter() { + RateLimitConfig cfg = RateLimitConfig.builder() + .forPrefix("/api/", alwaysAllow(), keyer()) + .forPrefix("/api/v2/", alwaysAllow(), keyer()) + .build(); + List rules = cfg.rulesFor("/api/v2/users"); + assertEquals(1, rules.size()); + assertEquals("/api/v2/*", rules.getFirst().name()); + } + + @Test + void globalIsAlwaysIncludedAlongsideMatchedRule() { + RateLimitConfig cfg = RateLimitConfig.builder() + .global(alwaysAllow(), keyer()) + .forPath("/x", alwaysAllow(), keyer()) + .build(); + List rules = cfg.rulesFor("/x"); + assertEquals(2, rules.size()); + assertEquals("global", rules.get(0).name()); + assertEquals("/x", rules.get(1).name()); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitGateTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitGateTest.java new file mode 100644 index 0000000..28d665e --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimitGateTest.java @@ -0,0 +1,77 @@ +package dev.coph.nextusweb.server.ratelimit; + +import dev.coph.nextusweb.server.router.Response; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpVersion; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimitGateTest { + + private HttpRequest req() { + return new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/"); + } + + @Test + void checkReturnsNullWhenNoRulesMatch() { + RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder().build()); + assertNull(gate.check(req(), "/anything", "1.1.1.1")); + gate.shutdown(); + } + + @Test + void checkAllowsWhenWithinLimit() { + RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder() + .global(new FixedWindowLimiter(2, 1000), KeyResolver.clientIp()) + .build()); + RateLimiter.Result r = gate.check(req(), "/x", "1.1.1.1"); + assertNotNull(r); + assertTrue(r.allowed()); + gate.shutdown(); + } + + @Test + void checkDeniesWhenAnyRuleDenies() { + RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder() + .global(new FixedWindowLimiter(1, 1000), KeyResolver.clientIp()) + .build()); + gate.check(req(), "/x", "1.1.1.1"); + RateLimiter.Result r = gate.check(req(), "/x", "1.1.1.1"); + assertNotNull(r); + assertFalse(r.allowed()); + gate.shutdown(); + } + + @Test + void applyHeadersIsNoOpForNull() { + Response res = new Response(); + RateLimitGate.applyHeaders(null, res); + assertNull(res.headers().get("X-RateLimit-Limit")); + } + + @Test + void applyHeadersWritesLimitAndRemaining() { + Response res = new Response(); + RateLimitGate.applyHeaders(RateLimiter.Result.allow(5, 10), res); + assertEquals("10", res.headers().get("X-RateLimit-Limit")); + assertEquals("5", res.headers().get("X-RateLimit-Remaining")); + assertNull(res.headers().get("Retry-After")); + } + + @Test + void applyHeadersWritesRetryAfterWhenDenied() { + Response res = new Response(); + RateLimitGate.applyHeaders(RateLimiter.Result.deny(10, 2500), res); + assertEquals("3", res.headers().get("Retry-After")); + } + + @Test + void applyHeadersClampsNegativeRemainingToZero() { + Response res = new Response(); + RateLimitGate.applyHeaders(new RateLimiter.Result(true, -5, 10, 0), res); + assertEquals("0", res.headers().get("X-RateLimit-Remaining")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimiterResultTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimiterResultTest.java new file mode 100644 index 0000000..03aaabc --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/RateLimiterResultTest.java @@ -0,0 +1,26 @@ +package dev.coph.nextusweb.server.ratelimit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimiterResultTest { + + @Test + void allowFactoryProducesAllowed() { + RateLimiter.Result r = RateLimiter.Result.allow(5, 10); + assertTrue(r.allowed()); + assertEquals(5, r.remaining()); + assertEquals(10, r.limit()); + assertEquals(0, r.retryAfterMillis()); + } + + @Test + void denyFactoryProducesDenied() { + RateLimiter.Result r = RateLimiter.Result.deny(10, 250); + assertFalse(r.allowed()); + assertEquals(0, r.remaining()); + assertEquals(10, r.limit()); + assertEquals(250, r.retryAfterMillis()); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiterTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiterTest.java new file mode 100644 index 0000000..99719c3 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiterTest.java @@ -0,0 +1,38 @@ +package dev.coph.nextusweb.server.ratelimit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SlidingWindowLimiterTest { + + @Test + void allowsUpToLimitThenDenies() { + SlidingWindowLimiter lim = new SlidingWindowLimiter(2, 1000); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertFalse(lim.tryAcquire("k", 0).allowed()); + } + + @Test + void afterFullWindowAllowsAgain() { + SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 100); + assertTrue(lim.tryAcquire("k", 0).allowed()); + long twoWindows = 200L * 1_000_000L; + assertTrue(lim.tryAcquire("k", twoWindows).allowed()); + } + + @Test + void differentKeysAreIndependent() { + SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 1000); + assertTrue(lim.tryAcquire("a", 0).allowed()); + assertTrue(lim.tryAcquire("b", 0).allowed()); + } + + @Test + void cleanupDoesNotThrow() { + SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 1000); + lim.tryAcquire("k", System.nanoTime()); + assertDoesNotThrow(() -> lim.cleanup(0)); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiterTest.java b/src/test/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiterTest.java new file mode 100644 index 0000000..f3eace4 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiterTest.java @@ -0,0 +1,50 @@ +package dev.coph.nextusweb.server.ratelimit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TokenBucketLimiterTest { + + @Test + void burstUpToCapacityIsAllowed() { + TokenBucketLimiter lim = new TokenBucketLimiter(1, 3); + long now = 0; + assertTrue(lim.tryAcquire("k", now).allowed()); + assertTrue(lim.tryAcquire("k", now).allowed()); + assertTrue(lim.tryAcquire("k", now).allowed()); + } + + @Test + void emptyBucketIsDeniedAndRetryAfterIsPositive() { + TokenBucketLimiter lim = new TokenBucketLimiter(1, 1); + assertTrue(lim.tryAcquire("k", 0).allowed()); + RateLimiter.Result r = lim.tryAcquire("k", 0); + assertFalse(r.allowed()); + assertTrue(r.retryAfterMillis() > 0); + assertEquals(1, r.limit()); + } + + @Test + void refillAllowsAcquireAfterTime() { + TokenBucketLimiter lim = new TokenBucketLimiter(10, 1); + assertTrue(lim.tryAcquire("k", 0).allowed()); + assertFalse(lim.tryAcquire("k", 0).allowed()); + long oneSecLater = 1_000_000_000L; + assertTrue(lim.tryAcquire("k", oneSecLater).allowed()); + } + + @Test + void differentKeysAreIndependent() { + TokenBucketLimiter lim = new TokenBucketLimiter(1, 1); + assertTrue(lim.tryAcquire("a", 0).allowed()); + assertTrue(lim.tryAcquire("b", 0).allowed()); + } + + @Test + void cleanupDoesNotThrow() { + TokenBucketLimiter lim = new TokenBucketLimiter(1, 1); + lim.tryAcquire("k", System.nanoTime()); + assertDoesNotThrow(() -> lim.cleanup(0)); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/router/RequestTest.java b/src/test/java/dev/coph/nextusweb/server/router/RequestTest.java new file mode 100644 index 0000000..7883e6a --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/router/RequestTest.java @@ -0,0 +1,104 @@ +package dev.coph.nextusweb.server.router; + +import dev.coph.nextusweb.server.router.exception.BadRequestException; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.util.CharsetUtil; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestTest { + + private FullHttpRequest build(HttpMethod method, String uri, String body) { + var content = body == null + ? Unpooled.EMPTY_BUFFER + : Unpooled.copiedBuffer(body, CharsetUtil.UTF_8); + return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri, content); + } + + record Payload(String name, int age) {} + + @Test + void pathParamReturnsFromMap() { + Request req = new Request(build(HttpMethod.GET, "/u/1", null), Map.of("id", "1")); + assertEquals("1", req.pathParam("id")); + assertNull(req.pathParam("missing")); + } + + @Test + void queryParamReturnsFirstValue() { + Request req = new Request(build(HttpMethod.GET, "/?a=1&a=2&b=foo", null), Map.of()); + assertEquals("1", req.queryParam("a")); + assertEquals("foo", req.queryParam("b")); + assertNull(req.queryParam("nope")); + } + + @Test + void queryParamsReturnsAllValues() { + Request req = new Request(build(HttpMethod.GET, "/?a=1&a=2", null), Map.of()); + assertEquals(java.util.List.of("1", "2"), req.queryParams("a")); + assertTrue(req.queryParams("missing").isEmpty()); + } + + @Test + void headerReturnsValue() { + FullHttpRequest raw = build(HttpMethod.GET, "/", null); + raw.headers().set("X-Foo", "bar"); + Request req = new Request(raw, Map.of()); + assertEquals("bar", req.header("X-Foo")); + } + + @Test + void bodyReturnsContentAsString() { + Request req = new Request(build(HttpMethod.POST, "/", "hello"), Map.of()); + assertEquals("hello", req.body()); + } + + @Test + void methodAndPathExpose() { + Request req = new Request(build(HttpMethod.POST, "/a/b?q=1", null), Map.of()); + assertEquals(HttpMethod.POST, req.method()); + assertEquals("/a/b", req.path()); + } + + @Test + void jsonReturnsNullNodeForEmptyBody() { + Request req = new Request(build(HttpMethod.POST, "/", null), Map.of()); + var node = req.json(); + assertNotNull(node); + assertTrue(node.isNull()); + } + + @Test + void jsonParsesObject() { + Request req = new Request(build(HttpMethod.POST, "/", "{\"a\":1}"), Map.of()); + var node = req.json(); + assertTrue(node.has("a")); + } + + @Test + void jsonThrowsBadRequestOnInvalidJson() { + Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of()); + assertThrows(BadRequestException.class, req::json); + } + + @Test + void jsonAsDeserializes() { + Request req = new Request(build(HttpMethod.POST, "/", "{\"name\":\"x\",\"age\":42}"), Map.of()); + Payload p = req.jsonAs(Payload.class); + assertEquals("x", p.name()); + assertEquals(42, p.age()); + } + + @Test + void jsonAsThrowsBadRequestOnInvalid() { + Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of()); + assertThrows(BadRequestException.class, () -> req.jsonAs(Payload.class)); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/router/ResponseTest.java b/src/test/java/dev/coph/nextusweb/server/router/ResponseTest.java new file mode 100644 index 0000000..e469f71 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/router/ResponseTest.java @@ -0,0 +1,54 @@ +package dev.coph.nextusweb.server.router; + +import io.netty.handler.codec.http.HttpHeaderNames; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseTest { + + @Test + void defaultStatusIs200AndEmptyBody() { + Response res = new Response(); + assertEquals(200, res.status()); + assertArrayEquals(new byte[0], res.body()); + } + + @Test + void statusIsFluent() { + Response res = new Response().status(404); + assertEquals(404, res.status()); + } + + @Test + void headerSetsValue() { + Response res = new Response().header("X-Foo", "bar"); + assertEquals("bar", res.headers().get("X-Foo")); + } + + @Test + void textSetsBodyAndContentType() { + Response res = new Response().text("hello"); + assertEquals("hello", new String(res.body(), StandardCharsets.UTF_8)); + assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("text/plain")); + } + + @Test + void jsonStringSetsBodyAndContentType() { + Response res = new Response().json("{\"a\":1}"); + assertEquals("{\"a\":1}", new String(res.body(), StandardCharsets.UTF_8)); + assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("application/json")); + } + + @Test + void jsonObjectSerializesValue() { + Response res = new Response().json(Map.of("k", "v")); + String s = new String(res.body(), StandardCharsets.UTF_8); + assertTrue(s.contains("\"k\"")); + assertTrue(s.contains("\"v\"")); + assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("application/json")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/router/RouterTest.java b/src/test/java/dev/coph/nextusweb/server/router/RouterTest.java new file mode 100644 index 0000000..3c26071 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/router/RouterTest.java @@ -0,0 +1,83 @@ +package dev.coph.nextusweb.server.router; + +import io.netty.handler.codec.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class RouterTest { + + private final Router.Handler noop = (req, res) -> {}; + + @Test + void getRegistersAndResolvesExactPath() { + Router r = new Router().get("/hello", noop); + assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/hello")); + } + + @Test + void postPutDeleteRegister() { + Router r = new Router() + .post("/p", noop) + .put("/u", noop) + .delete("/d", noop); + assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.POST, "/p")); + assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.PUT, "/u")); + assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.DELETE, "/d")); + } + + @Test + void notFoundForUnknownPath() { + Router r = new Router().get("/a", noop); + assertInstanceOf(Router.Resolution.NotFound.class, r.resolve(HttpMethod.GET, "/x")); + } + + @Test + void methodNotAllowedWhenPathMatchesDifferentMethod() { + Router r = new Router().get("/a", noop); + Router.Resolution res = r.resolve(HttpMethod.POST, "/a"); + Router.Resolution.MethodNotAllowed mna = assertInstanceOf(Router.Resolution.MethodNotAllowed.class, res); + assertTrue(mna.allowedMethods().contains(HttpMethod.GET)); + } + + @Test + void pathParamsAreExtracted() { + Router r = new Router().get("/u/{id}", noop); + Router.Resolution res = r.resolve(HttpMethod.GET, "/u/42"); + Router.Resolution.Match m = assertInstanceOf(Router.Resolution.Match.class, res); + assertEquals("42", m.pathParams().get("id")); + } + + @Test + void wildcardMatches() { + Router r = new Router().get("/files/*", noop); + assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/files/anything")); + } + + @Test + void useAddsMiddlewareReturned() { + AtomicInteger count = new AtomicInteger(); + Router r = new Router().use((req, res) -> count.incrementAndGet()); + assertEquals(1, r.middlewares().size()); + r.middlewares().getFirst().accept(null, null); + assertEquals(1, count.get()); + } + + @Test + void registerWorksWithCustomMethod() { + Router r = new Router().register(HttpMethod.valueOf("OPTIONS"), "/x", noop); + assertInstanceOf(Router.Resolution.Match.class, + r.resolve(HttpMethod.valueOf("OPTIONS"), "/x")); + } + + @Test + void handlerInvocationWorks() throws Exception { + AtomicInteger called = new AtomicInteger(); + Router r = new Router().get("/x", (req, res) -> called.incrementAndGet()); + var match = (Router.Resolution.Match) r.resolve(HttpMethod.GET, "/x"); + match.handler().handle(null, null); + assertEquals(1, called.get()); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/router/exception/BadRequestExceptionTest.java b/src/test/java/dev/coph/nextusweb/server/router/exception/BadRequestExceptionTest.java new file mode 100644 index 0000000..0236a9d --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/router/exception/BadRequestExceptionTest.java @@ -0,0 +1,19 @@ +package dev.coph.nextusweb.server.router.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class BadRequestExceptionTest { + + @Test + void messageIsCarried() { + BadRequestException e = new BadRequestException("oops"); + assertEquals("oops", e.getMessage()); + } + + @Test + void isRuntimeException() { + assertInstanceOf(RuntimeException.class, new BadRequestException("x")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketConfigTest.java b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketConfigTest.java new file mode 100644 index 0000000..b104306 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketConfigTest.java @@ -0,0 +1,79 @@ +package dev.coph.nextusweb.server.websocket; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class WebSocketConfigTest { + + @Test + void defaultsHasExpectedValues() { + WebSocketConfig c = WebSocketConfig.defaults(); + assertEquals(65_536, c.maxFramePayloadLength()); + assertEquals(1_048_576, c.maxAggregatedMessageSize()); + assertEquals(Duration.ofSeconds(60), c.idleTimeout()); + assertFalse(c.allowAnyOrigin()); + assertTrue(c.allowedOrigins().isEmpty()); + assertNull(c.subprotocolsCsv()); + assertTrue(c.compression()); + assertFalse(c.checkStartsWith()); + } + + @Test + void isOriginAllowedRespectsList() { + WebSocketConfig c = WebSocketConfig.builder() + .allowedOrigins("https://a", "https://b") + .build(); + assertTrue(c.isOriginAllowed("https://a")); + assertTrue(c.isOriginAllowed("https://b")); + assertFalse(c.isOriginAllowed("https://c")); + assertFalse(c.isOriginAllowed(null)); + } + + @Test + void anyOriginAllowsEverythingExceptNullCheck() { + WebSocketConfig c = WebSocketConfig.builder().anyOrigin().build(); + assertTrue(c.allowAnyOrigin()); + assertTrue(c.isOriginAllowed("https://anything")); + assertTrue(c.isOriginAllowed(null)); + } + + @Test + void invalidFramePayloadLengthRejected() { + assertThrows(IllegalArgumentException.class, + () -> WebSocketConfig.builder().maxFramePayloadLength(0)); + } + + @Test + void invalidAggregatedMessageSizeRejected() { + assertThrows(IllegalArgumentException.class, + () -> WebSocketConfig.builder().maxAggregatedMessageSize(0)); + } + + @Test + void noIdleTimeoutSetsNull() { + WebSocketConfig c = WebSocketConfig.builder().noIdleTimeout().build(); + assertNull(c.idleTimeout()); + } + + @Test + void subprotocolsCsvJoins() { + WebSocketConfig c = WebSocketConfig.builder().subprotocols("a", "b").build(); + String csv = c.subprotocolsCsv(); + assertNotNull(csv); + assertTrue(csv.contains("a")); + assertTrue(csv.contains("b")); + } + + @Test + void compressionAndCheckStartsWithFlags() { + WebSocketConfig c = WebSocketConfig.builder() + .compression(false) + .checkStartsWith(true) + .build(); + assertFalse(c.compression()); + assertTrue(c.checkStartsWith()); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandlerFactoryTest.java b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandlerFactoryTest.java new file mode 100644 index 0000000..a4fea40 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandlerFactoryTest.java @@ -0,0 +1,20 @@ +package dev.coph.nextusweb.server.websocket; + +import io.netty.channel.ChannelHandler; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WebSocketFrameHandlerFactoryTest { + + @Test + void createReturnsChannelHandler() { + ChannelHandler h = WebSocketFrameHandlerFactory.create( + new WebSocketHandler() {}, + "/ws", + Map.of("a", "b")); + assertNotNull(h); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketGroupTest.java b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketGroupTest.java new file mode 100644 index 0000000..035093e --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketGroupTest.java @@ -0,0 +1,134 @@ +package dev.coph.nextusweb.server.websocket; + +import io.netty.channel.DefaultChannelId; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WebSocketGroupTest { + + private EmbeddedChannel uniqueChannel() { + return new EmbeddedChannel(DefaultChannelId.newInstance()); + } + + private WebSocketSession session(EmbeddedChannel ch) { + return new WebSocketSession(ch, "/ws", Map.of()); + } + + @Test + void defaultConstructorHasAnonymousName() { + assertEquals("anonymous", new WebSocketGroup().name()); + } + + @Test + void namedConstructorRetainsName() { + assertEquals("chat", new WebSocketGroup("chat").name()); + } + + @Test + void addAndRemoveAdjustSize() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel ch = uniqueChannel(); + WebSocketSession s = session(ch); + g.add(s); + assertEquals(1, g.size()); + g.remove(s); + assertEquals(0, g.size()); + } + + @Test + void broadcastSendsTextToAll() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel a = uniqueChannel(); + EmbeddedChannel b = uniqueChannel(); + g.add(session(a)).add(session(b)); + + g.broadcast("hi"); + a.runPendingTasks(); + b.runPendingTasks(); + Object fa = a.readOutbound(); + Object fb = b.readOutbound(); + TextWebSocketFrame ta = assertInstanceOf(TextWebSocketFrame.class, fa); + TextWebSocketFrame tb = assertInstanceOf(TextWebSocketFrame.class, fb); + assertEquals("hi", ta.text()); + assertEquals("hi", tb.text()); + ta.release(); + tb.release(); + } + + @Test + void broadcastJsonSendsTextFrames() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel a = uniqueChannel(); + g.add(session(a)); + + g.broadcastJson(Map.of("k", "v")); + a.runPendingTasks(); + Object out = a.readOutbound(); + TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out); + assertTrue(frame.text().contains("\"k\"")); + frame.release(); + } + + @Test + void broadcastBinarySendsBinaryFrames() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel a = uniqueChannel(); + g.add(session(a)); + + g.broadcastBinary(new byte[]{1, 2, 3}); + a.runPendingTasks(); + Object out = a.readOutbound(); + BinaryWebSocketFrame frame = assertInstanceOf(BinaryWebSocketFrame.class, out); + assertEquals(3, frame.content().readableBytes()); + frame.release(); + } + + @Test + void broadcastExceptSkipsExcludedSession() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel a = uniqueChannel(); + EmbeddedChannel b = uniqueChannel(); + WebSocketSession sa = session(a); + WebSocketSession sb = session(b); + g.add(sa).add(sb); + + g.broadcastExcept(sa, "hello"); + a.runPendingTasks(); + b.runPendingTasks(); + assertNull(a.readOutbound()); + Object out = b.readOutbound(); + TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out); + assertEquals("hello", frame.text()); + frame.release(); + } + + @Test + void closeAllClosesUnderlyingChannels() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel a = uniqueChannel(); + g.add(session(a)); + g.closeAll(); + a.runPendingTasks(); + assertFalse(a.isActive()); + } + + @Test + void fluentMethodsReturnGroup() { + WebSocketGroup g = new WebSocketGroup("g"); + EmbeddedChannel a = uniqueChannel(); + WebSocketSession s = session(a); + assertSame(g, g.add(s)); + assertSame(g, g.broadcast("x")); + assertSame(g, g.broadcastBinary(new byte[]{1})); + assertSame(g, g.broadcastJson(Map.of("a", 1))); + assertSame(g, g.broadcastExcept(null, "y")); + assertSame(g, g.remove(s)); + assertSame(g, g.closeAll()); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketHandlerTest.java b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketHandlerTest.java new file mode 100644 index 0000000..18c353f --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketHandlerTest.java @@ -0,0 +1,23 @@ +package dev.coph.nextusweb.server.websocket; + +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WebSocketHandlerTest { + + @Test + void defaultMethodsDoNotThrow() { + WebSocketHandler handler = new WebSocketHandler() {}; + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of()); + assertDoesNotThrow(() -> handler.onOpen(session)); + assertDoesNotThrow(() -> handler.onMessage(session, "msg")); + assertDoesNotThrow(() -> handler.onBinary(session, new byte[]{1})); + assertDoesNotThrow(() -> handler.onClose(session, 1000, "ok")); + assertDoesNotThrow(() -> handler.onError(session, new RuntimeException("e"))); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketRouterTest.java b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketRouterTest.java new file mode 100644 index 0000000..4e67b67 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketRouterTest.java @@ -0,0 +1,45 @@ +package dev.coph.nextusweb.server.websocket; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class WebSocketRouterTest { + + private final WebSocketHandler handler = new WebSocketHandler() {}; + + @Test + void resolvesExactPath() { + WebSocketRouter r = new WebSocketRouter().on("/ws", handler); + WebSocketRouter.Resolution res = r.resolve("/ws"); + assertNotNull(res); + assertSame(handler, res.handler()); + assertTrue(res.pathParams().isEmpty()); + } + + @Test + void returnsNullForUnknown() { + WebSocketRouter r = new WebSocketRouter().on("/ws", handler); + assertNull(r.resolve("/missing")); + } + + @Test + void extractsPathParameters() { + WebSocketRouter r = new WebSocketRouter().on("/rooms/{id}", handler); + WebSocketRouter.Resolution res = r.resolve("/rooms/abc"); + assertNotNull(res); + assertEquals("abc", res.pathParams().get("id")); + } + + @Test + void onIsFluent() { + WebSocketRouter r = new WebSocketRouter(); + assertSame(r, r.on("/x", handler)); + } + + @Test + void interiorNodeWithoutHandlerReturnsNull() { + WebSocketRouter r = new WebSocketRouter().on("/a/b", handler); + assertNull(r.resolve("/a")); + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketSessionTest.java b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketSessionTest.java new file mode 100644 index 0000000..a851143 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/websocket/WebSocketSessionTest.java @@ -0,0 +1,138 @@ +package dev.coph.nextusweb.server.websocket; + +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.util.CharsetUtil; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WebSocketSessionTest { + + private WebSocketSession session(EmbeddedChannel ch) { + return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42")); + } + + @Test + void idIsAssignedAndNonNull() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + assertNotNull(s.id()); + assertFalse(s.id().isEmpty()); + } + + @Test + void pathAndPathParamExpose() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + assertEquals("/ws/{id}", s.path()); + assertEquals("42", s.pathParam("id")); + assertNull(s.pathParam("missing")); + } + + @Test + void isOpenWhileChannelActive() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + assertTrue(s.isOpen()); + ch.close(); + assertFalse(s.isOpen()); + } + + @Test + void channelGetterReturnsChannel() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + assertSame(ch, s.channel()); + } + + @Test + void attributesSetAndRetrieve() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + s.attribute("k", "v"); + assertEquals("v", s.attribute("k")); + s.attribute("k", null); + assertNull(s.attribute("k")); + } + + @Test + void sendWritesTextFrame() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + s.send("hi"); + Object out = ch.readOutbound(); + TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out); + assertEquals("hi", frame.text()); + frame.release(); + } + + @Test + void sendJsonProducesTextFrame() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + s.sendJson(Map.of("a", "b")); + Object out = ch.readOutbound(); + TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out); + String payload = frame.content().toString(CharsetUtil.UTF_8); + assertTrue(payload.contains("\"a\"")); + frame.release(); + } + + @Test + void sendBinaryProducesBinaryFrame() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + s.sendBinary(new byte[]{1, 2, 3}); + Object out = ch.readOutbound(); + BinaryWebSocketFrame frame = assertInstanceOf(BinaryWebSocketFrame.class, out); + assertEquals(3, frame.content().readableBytes()); + frame.release(); + } + + @Test + void pingProducesPingFrame() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + s.ping(); + Object out = ch.readOutbound(); + assertInstanceOf(PingWebSocketFrame.class, out); + ((PingWebSocketFrame) out).release(); + } + + @Test + void closeProducesCloseFrameAndClosesChannel() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + s.close(1001, "going-away"); + Object out = ch.readOutbound(); + CloseWebSocketFrame frame = assertInstanceOf(CloseWebSocketFrame.class, out); + assertEquals(1001, frame.statusCode()); + assertEquals("going-away", frame.reasonText()); + frame.release(); + } + + @Test + void sendOnInactiveChannelDoesNotThrow() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + ch.close(); + assertDoesNotThrow(() -> s.send("ignored")); + assertDoesNotThrow(() -> s.sendBinary(new byte[]{1})); + assertDoesNotThrow(() -> s.ping()); + assertDoesNotThrow(() -> s.sendJson(Map.of("a", 1))); + assertDoesNotThrow(() -> s.close()); + } + + @Test + void remoteAddressReturnsNullForUnconnectedEmbeddedChannel() { + EmbeddedChannel ch = new EmbeddedChannel(); + WebSocketSession s = session(ch); + assertDoesNotThrow(s::remoteAddress); + } +}