Add test coverage for core server components: annotation scanning, routing, rate limiting, CORS, and JSON handling
This commit is contained in:
@@ -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
|
||||||
@@ -13,6 +13,10 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'io.netty:netty-all:4.2.14.Final'
|
implementation 'io.netty:netty-all:4.2.14.Final'
|
||||||
implementation 'tools.jackson.core:jackson-databind:3.1.3'
|
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 {
|
java {
|
||||||
@@ -23,6 +27,14 @@ java {
|
|||||||
targetCompatibility = JavaVersion.VERSION_26
|
targetCompatibility = JavaVersion.VERSION_26
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events "passed", "skipped", "failed"
|
||||||
|
showStandardStreams = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
publications {
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user