Add test coverage for core server components: annotation scanning, routing, rate limiting, CORS, and JSON handling
Auto Publish on Version Change / check-and-publish (push) Successful in 14s
Run Tests on Push and Pull Request / run-tests (push) Successful in 19s

This commit is contained in:
CodingPhoenixx
2026-05-28 13:40:24 +02:00
parent 2531f87c31
commit 78d90855c5
26 changed files with 1580 additions and 0 deletions
+44
View File
@@ -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
+12
View File
@@ -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 {
@@ -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();
}
}
}
@@ -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()));
}
}
@@ -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());
}
}
}
@@ -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());
}
}
@@ -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"));
}
}
@@ -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\""));
}
}
@@ -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));
}
}
@@ -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"));
}
}
@@ -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));
}
}
@@ -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<RateLimitConfig.Rule> 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<RateLimitConfig.Rule> 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<RateLimitConfig.Rule> 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<RateLimitConfig.Rule> 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<RateLimitConfig.Rule> rules = cfg.rulesFor("/x");
assertEquals(2, rules.size());
assertEquals("global", rules.get(0).name());
assertEquals("/x", rules.get(1).name());
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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")));
}
}
@@ -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"));
}
}
@@ -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.<String>attribute("k"));
s.attribute("k", null);
assertNull(s.<String>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);
}
}