Feature/ext validation/request validation #15

Merged
Relism merged 10 commits from feature/ext-validation/request-validation into master 2026-09-09 14:21:17 +00:00
12 changed files with 971 additions and 2 deletions
Showing only changes of commit 424ca31b7a - Show all commits
+4 -1
View File
@@ -36,7 +36,7 @@ Format: `<type>(<scope>): <short description>`
| `chore` | Build, deps, tooling — no production code | | `chore` | Build, deps, tooling — no production code |
| `ci` | Changes to GitHub Actions workflows | | `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. `ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
@@ -85,6 +85,9 @@ chore(release): 2.1.0
- Root POM: `flash-parent` — defines all dependency versions and plugin config. - Root POM: `flash-parent` — defines all dependency versions and plugin config.
- `flash` module: the core framework JAR. - `flash` module: the core framework JAR.
- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not
under `flash-extensions/` — it is not something you `install()`, and it carries
`junit-jupiter-api` at compile scope.
- `flash-extensions` POM: aggregator for all extension modules. - `flash-extensions` POM: aggregator for all extension modules.
- Extensions live under `flash-extensions/flash-ext-*/`. - Extensions live under `flash-extensions/flash-ext-*/`.
- When adding a new extension: - When adding a new extension:
+102
View File
@@ -8,6 +8,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| Module | Description | | Module | Description |
|---|---| |---|---|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model | | `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | | `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
@@ -389,6 +390,107 @@ The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension. future `flash-ext-grpc` extension.
## Testing
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
a client pointed at it. Add it with test scope:
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${flash.version}</version>
<scope>test</scope>
</dependency>
```
```java
class UserRoutesTest {
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
@Test
void listsUsers() {
app.get("/api/users")
.expectStatus(200)
.expectHeader("content-type", "application/json")
.expectBodyContains("alice");
}
}
```
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
independently of which port they run on:
```java
public final class BlogApp implements FlashApplication {
@Override public void configure(FlashApp app) {
app.install(new JacksonExtension());
app.mount("/api", scope -> scope.scan("dev.blog.api"));
}
}
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
```
It is a functional interface, so a lambda works too:
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
### Requests
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
failure. `get` and `delete` skip the builder when there is nothing to add.
```java
app.get("/api/users").expectStatus(200);
app.request()
.header("Authorization", "Bearer " + token)
.json("{\"name\":\"bob\"}")
.post("/api/users")
.expectStatus(201);
try (FlashWebSocket socket = app.ws("/live")) {
socket.sendText("hello");
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
}
```
### Replacing services
`mock` installs replacements after everything your app and its extensions declare, so a fake always
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
a Mockito mock are equally welcome.
### More than one server
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
— so declaration order does the wiring:
```java
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
```
### Scope
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
### Configuration
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
you a client for.
```java
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
```
## Architecture ## Architecture
``` ```
+6
View File
@@ -31,6 +31,12 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId> <artifactId>flash-ext-jackson</artifactId>
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-parent</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-testing</artifactId>
<packaging>jar</packaging>
<!--
Deliberately NOT under flash-extensions/: that aggregator holds things you install()
onto an app. This module is a JUnit 5 extension, and it depends on junit-jupiter-api at
COMPILE scope — nothing installable should drag JUnit onto an application's classpath.
Consumers add this with <scope>test</scope>.
-->
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,94 @@
package dev.relism.flash.testing;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Objects;
/**
* A request being built against a {@link FlashTest} server. The HTTP verb is terminal — it
* sends the request and hands back a {@link FlashResponse}:
*
* <pre>{@code
* app.request()
* .header("Authorization", "Bearer " + token)
* .json("{\"name\":\"bob\"}")
* .post("/api/users")
* .expectStatus(201);
* }</pre>
*
* For a bodyless {@code GET} or {@code DELETE}, {@link FlashTest#get} and
* {@link FlashTest#delete} skip the builder entirely.
*/
public final class FlashRequest {
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
private final FlashTest server;
private final HttpRequest.Builder request = HttpRequest.newBuilder().timeout(REQUEST_TIMEOUT);
private byte[] body;
FlashRequest(FlashTest server) {
this.server = server;
}
/** Adds a header. Repeatable — a name may be sent more than once. */
public FlashRequest header(String name, String value) {
request.header(name, value);
return this;
}
/** Sets a UTF-8 request body. */
public FlashRequest body(String text) {
this.body = Objects.requireNonNull(text, "text").getBytes(StandardCharsets.UTF_8);
return this;
}
/** Sets a raw request body. */
public FlashRequest body(byte[] bytes) {
this.body = Objects.requireNonNull(bytes, "bytes").clone();
return this;
}
/** Sets a UTF-8 body and {@code content-type: application/json}. */
public FlashRequest json(String json) {
return body(json).header("content-type", "application/json");
}
public FlashResponse get(String path) { return send("GET", path); }
public FlashResponse post(String path) { return send("POST", path); }
public FlashResponse put(String path) { return send("PUT", path); }
public FlashResponse patch(String path) { return send("PATCH", path); }
public FlashResponse delete(String path) { return send("DELETE", path); }
public FlashResponse head(String path) { return send("HEAD", path); }
public FlashResponse options(String path) { return send("OPTIONS", path); }
/** Sends any method, including the ones Flash adds beyond RFC 9110 ({@code PURGE}, {@code QUERY}). */
public FlashResponse send(String method, String path) {
URI target = server.baseUri().resolve(normalise(path));
HttpRequest built = request.uri(target)
.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body))
.build();
try {
HttpResponse<String> response =
server.client().send(built, HttpResponse.BodyHandlers.ofString());
return new FlashResponse(method, target.getPath(), response);
} catch (IOException failure) {
throw new AssertionError(method + ' ' + target + " failed", failure);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError(method + ' ' + target + " was interrupted", interrupted);
}
}
/** Shared with {@link FlashTest#ws} — the ws:// URI needs the same leading slash. */
static String normalise(String path) {
Objects.requireNonNull(path, "path");
return path.startsWith("/") ? path : '/' + path;
}
}
@@ -0,0 +1,80 @@
package dev.relism.flash.testing;
import java.net.http.HttpHeaders;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* A response from a {@link FlashTest} server, with chainable assertions.
*
* <pre>{@code
* app.get("/api/users")
* .expectStatus(200)
* .expectHeader("content-type", "application/json")
* .expectBodyContains("alice");
* }</pre>
*
* Every failure message carries the request line, the status and the body, so a red test says
* what actually came back rather than only what did not match. Use {@link #status()},
* {@link #body()} and {@link #headers()} for anything the assertions do not cover.
*/
public final class FlashResponse {
private final String method;
private final String path;
private final HttpResponse<String> response;
FlashResponse(String method, String path, HttpResponse<String> response) {
this.method = method;
this.path = path;
this.response = response;
}
// ── Raw access ───────────────────────────────────────────────────────────
/** Response status code. */
public int status() { return response.statusCode(); }
/** Response body decoded as a string. */
public String body() { return response.body(); }
/** All response headers. */
public HttpHeaders headers() { return response.headers(); }
/** First value of {@code name} (case-insensitive), or {@code null} if absent. */
public String header(String name) { return response.headers().firstValue(name).orElse(null); }
// ── Assertions ───────────────────────────────────────────────────────────
/** Asserts the status code. */
public FlashResponse expectStatus(int expected) {
assertEquals(expected, status(), this::describe);
return this;
}
/** Asserts the first value of a header (name case-insensitive). */
public FlashResponse expectHeader(String name, String expected) {
assertEquals(expected, header(name), () -> "header '" + name + '\'' + describe());
return this;
}
/** Asserts the body matches exactly. */
public FlashResponse expectBody(String expected) {
assertEquals(expected, body(), this::describe);
return this;
}
/** Asserts the body contains {@code fragment}. */
public FlashResponse expectBodyContains(String fragment) {
assertTrue(body().contains(fragment), () -> "expected body to contain '" + fragment + '\'' + describe());
return this;
}
private String describe() {
return "\n request: " + method + ' ' + path
+ "\n status: " + status()
+ "\n body: " + body();
}
}
@@ -0,0 +1,240 @@
package dev.relism.flash.testing;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashApplication;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
/**
* Boots a real {@link FlashApp} on an OS-assigned port for the duration of a test class or a
* single test, and gives you a client pointed at it.
*
* <pre>{@code
* class UserRoutesTest {
*
* @RegisterExtension
* static FlashTest app = FlashTest.of(new BlogApp())
* .mock(UserService.class, new InMemoryUserService());
*
* @Test
* void listsUsers() {
* app.get("/api/users")
* .expectStatus(200)
* .expectBodyContains("alice");
* }
* }
* }</pre>
*
* <h3>More than one server</h3>
* Because this is an ordinary object in a field rather than an annotation, a test class can
* hold as many as it needs, and one can be wired from another with plain Java. Startup is lazy
* — reading {@code baseUri()} boots that server on the spot — so declaration order does the
* wiring, with no reliance on JUnit's extension ordering:
*
* <pre>{@code
* @RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
* @RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
* }</pre>
*
* <h3>Scope</h3>
* A {@code static} field boots once for the class; a non-static field boots a fresh app for
* every test. That is stock JUnit field semantics — the isolation switch is the keyword.
*
* <h3>Replacing services</h3>
* {@link #mock} installs its replacements as the last extension, after everything the
* application and its extensions declare, so a fake always wins. Any object will do — a
* hand-written fake or a Mockito mock you created; this module depends on no mocking library.
*
* @see FlashRequest
* @see FlashResponse
*/
public final class FlashTest implements BeforeAllCallback, AfterAllCallback,
BeforeEachCallback, AfterEachCallback {
private static final String LOOPBACK = "127.0.0.1";
/**
* ponytail: the harness pins the drain window so teardown is fast — the default 15s would
* be paid on every class. Add a profile escape hatch if a test ever needs to exercise
* draining itself.
*/
private static final int DRAIN_MS = 250;
private final FlashApplication application;
private final Map<Class<?>, Object> overrides = new LinkedHashMap<>();
private Consumer<FlashConfiguration.FlashConfigurationBuilder> profile = builder -> { };
private FlashApp app;
private HttpClient client;
private URI baseUri;
private boolean classScoped;
private FlashTest(FlashApplication application) {
this.application = Objects.requireNonNull(application, "application");
}
/**
* Creates a harness for {@code application}. Nothing is bound or started until first use.
*
* <p>{@link FlashApplication} is a functional interface, so a lambda works as well as a
* named class: {@code FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))}.
*/
public static FlashTest of(FlashApplication application) {
return new FlashTest(application);
}
/**
* Customises the {@link FlashConfiguration} this server runs with — timeouts, HTTP/2
* switches, buffer sizes.
*
* <p>The harness stamps host, port and the shutdown drain window <em>after</em> this runs,
* so a profile cannot break it. Setting {@code listener(...)} or {@code tls(...)} is
* rejected: the harness owns the single plaintext loopback listener it hands you a client
* for.
*/
public FlashTest profile(Consumer<FlashConfiguration.FlashConfigurationBuilder> profile) {
requireNotStarted("profile(...)");
this.profile = Objects.requireNonNull(profile, "profile");
return this;
}
/**
* Replaces the service bound to {@code type} with {@code instance} for this server.
*
* <p>Wins over anything the application or its extensions declare, including services
* provided by an installed {@code FlashExtension}.
*
* <p>ponytail: overrides are applied to the app's root context. A service declared inside
* a {@code mount(...)} scope's child context shadows the root and is therefore not
* reachable — add child-context targeting if that ever comes up.
*/
public <T> FlashTest mock(Class<T> type, T instance) {
requireNotStarted("mock(...)");
overrides.put(Objects.requireNonNull(type, "type"), Objects.requireNonNull(instance, "instance"));
return this;
}
// ── Accessors (each boots the server if it is not running) ────────────────
/** Base URI of the running server, e.g. {@code http://127.0.0.1:41307}. */
public URI baseUri() { ensureStarted(); return baseUri; }
/** OS-assigned port of the running server. */
public int port() { ensureStarted(); return app.port(); }
/** The running app — escape hatch for assertions the harness does not cover. */
public FlashApp app() { ensureStarted(); return app; }
/** The client the harness issues requests with. */
public HttpClient client() { ensureStarted(); return client; }
// ── Requests ─────────────────────────────────────────────────────────────
/** Starts a request with headers or a body; the HTTP verb sends it. */
public FlashRequest request() { ensureStarted(); return new FlashRequest(this); }
/** Sends {@code GET path} with no headers or body. */
public FlashResponse get(String path) { return request().get(path); }
/** Sends {@code DELETE path} with no headers or body. */
public FlashResponse delete(String path) { return request().delete(path); }
/**
* Opens a WebSocket to {@code path} on this server. Close it when done — a
* try-with-resources block is the usual shape.
*/
public FlashWebSocket ws(String path) {
ensureStarted();
return new FlashWebSocket(client,
URI.create("ws://" + LOOPBACK + ':' + app.port() + FlashRequest.normalise(path)));
}
// ── JUnit lifecycle ──────────────────────────────────────────────────────
// A static field receives class- AND method-level callbacks, so afterEach would otherwise
// tear the server down after the first test. classScoped records which tier owns it.
@Override public void beforeAll(ExtensionContext context) { classScoped = true; ensureStarted(); }
@Override public void afterAll(ExtensionContext context) { stop(); }
@Override public void beforeEach(ExtensionContext context) { if (!classScoped) ensureStarted(); }
@Override public void afterEach(ExtensionContext context) { if (!classScoped) stop(); }
// ── Internals ────────────────────────────────────────────────────────────
private void ensureStarted() {
if (app == null) start();
}
private void start() {
FlashConfiguration.FlashConfigurationBuilder builder = FlashConfiguration.builder();
profile.accept(builder);
FlashConfiguration config = builder
.port(0)
.host(LOOPBACK)
.shutdownDrainTimeoutMs(DRAIN_MS)
.build();
rejectListenerOverrides(config);
FlashApp starting = FlashApp.create(config).apply(application);
if (!overrides.isEmpty())
starting.install((registrar, ctx) -> applyOverrides(ctx));
starting.start();
app = starting;
baseUri = URI.create("http://" + LOOPBACK + ':' + starting.port());
client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
}
/**
* {@code listeners} is a lombok {@code @Singular} field, so it can only be inspected after
* the build — hence build-then-check rather than a guard on the builder.
*/
private static void rejectListenerOverrides(FlashConfiguration config) {
if (!config.getListeners().isEmpty())
throw new IllegalStateException(
"FlashTest owns the listener — remove listener(...) from the profile");
if (config.getTls() != null)
throw new IllegalStateException(
"FlashTest serves plaintext on loopback — remove tls(...) from the profile");
}
@SuppressWarnings("unchecked")
private void applyOverrides(FlashContext ctx) {
overrides.forEach((type, instance) -> ctx.override((Class<Object>) type, instance));
}
private void stop() {
if (app == null) return;
// Order matters: the client holds keep-alive sockets open, and ServerLifecycle.stop()
// spins until the last one closes or the drain window expires. shutdownNow rather than
// close(), which blocks until every operation completes — a test that leaked an open
// WebSocket would hang teardown forever.
client.shutdownNow();
try {
client.awaitTermination(Duration.ofSeconds(2));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
app.stop().join();
app = null;
client = null;
baseUri = null;
}
private void requireNotStarted(String what) {
if (app != null)
throw new IllegalStateException(what + " must be configured before the server starts");
}
}
@@ -0,0 +1,139 @@
package dev.relism.flash.testing;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* A WebSocket connected to a {@link FlashTest} server, for asserting on what a Flash endpoint
* pushes back.
*
* <pre>{@code
* try (FlashWebSocket socket = app.ws("/live")) {
* socket.sendText("hello");
* assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
* }
* }</pre>
*
* Backed by {@link java.net.http.WebSocket}, so the RFC 6455 handshake, masking and
* fragmentation are the JDK's, not hand-rolled. Incoming text is queued as it arrives, so a
* message that lands before {@link #awaitText} is called is not lost.
*/
public final class FlashWebSocket implements AutoCloseable {
private final BlockingQueue<String> received = new LinkedBlockingQueue<>();
private final CompletableFuture<Integer> closed = new CompletableFuture<>();
private final WebSocket socket;
FlashWebSocket(HttpClient client, URI uri) {
this.socket = client.newWebSocketBuilder()
.buildAsync(uri, new QueueingListener())
.join();
}
/** Sends a whole text message. */
public FlashWebSocket sendText(String message) {
socket.sendText(Objects.requireNonNull(message, "message"), true).join();
return this;
}
/**
* Waits for the next text message.
*
* @throws AssertionError if none arrives within {@code timeout}
*/
public String awaitText(Duration timeout) {
String message = poll(timeout);
if (message == null)
throw new AssertionError("No WebSocket text message within " + timeout);
return message;
}
/**
* Waits for the server to close the connection and returns its close status code.
*
* @throws AssertionError if the server does not close within {@code timeout}
*/
public int awaitClose(Duration timeout) {
try {
return closed.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException notClosed) {
throw new AssertionError("WebSocket was not closed within " + timeout, notClosed);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted awaiting WebSocket close", interrupted);
} catch (ExecutionException failure) {
throw new AssertionError("WebSocket failed before closing", failure.getCause());
}
}
/**
* Sends a normal close and gives the server a moment to answer it.
*
* <p>Never throws: this is cleanup, usually in a try-with-resources, and a connection the
* server already dropped must not mask the failure the test was actually reporting. Use
* {@link #awaitClose} when the close itself is what you are asserting on.
*/
@Override
public void close() {
try {
if (!socket.isOutputClosed()) socket.sendClose(WebSocket.NORMAL_CLOSURE, "").join();
} catch (RuntimeException alreadyGone) {
// nothing to close
}
closed.completeOnTimeout(WebSocket.NORMAL_CLOSURE, 1, TimeUnit.SECONDS)
.exceptionally(failure -> WebSocket.NORMAL_CLOSURE)
.join();
}
private String poll(Duration timeout) {
try {
return received.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted awaiting a WebSocket message", interrupted);
}
}
/** Reassembles fragmented text and queues whole messages. */
private final class QueueingListener implements WebSocket.Listener {
private final StringBuilder partial = new StringBuilder();
@Override
public void onOpen(WebSocket webSocket) {
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
partial.append(data);
if (last) {
received.add(partial.toString());
partial.setLength(0);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
closed.complete(statusCode);
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
closed.completeExceptionally(error);
}
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.testing;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* A non-static field gets JUnit's method-level callbacks, so the app is rebuilt for every
* test. Counting boots proves the isolation rather than inferring it from a fresh port, which
* the OS is free to reuse.
*/
class FlashTestPerMethodScopeTest {
private static final AtomicInteger boots = new AtomicInteger();
@RegisterExtension
FlashTest app = FlashTest.of(configured -> {
boots.incrementAndGet();
configured.get("/ping", (req, res) -> "pong");
});
@AfterAll
static void bootedOncePerTest() {
assertEquals(2, boots.get(), "an instance FlashTest field should boot per test");
}
@Test
void firstTestGetsItsOwnApp() {
app.get("/ping").expectStatus(200).expectBody("pong");
}
@Test
void secondTestGetsAnotherApp() {
app.get("/ping").expectStatus(200).expectBody("pong");
}
}
@@ -0,0 +1,141 @@
package dev.relism.flash.testing;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashApplication;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The scenario the harness exists for: two servers in one class, the second configured from
* the first's address, with a service faked out on the second.
*/
class FlashTestSelfTest {
private static final AtomicInteger downstreamBoots = new AtomicInteger();
@RegisterExtension
static FlashTest upstream = FlashTest.of(app -> app
.get("/health", (req, res) -> "UP")
.get("/echo", (req, res) -> "upstream:" + req.query("v")));
// upstream.baseUri() boots it right here, during this field's initialiser — declaration
// order does the wiring, with no @Order and no reliance on JUnit's extension ordering.
@RegisterExtension
static FlashTest downstream = FlashTest.of(new Downstream(upstream.baseUri()))
.mock(Greeter.class, () -> "faked");
@AfterAll
static void classScopedServerBootsExactlyOnce() {
assertEquals(1, downstreamBoots.get(), "a static FlashTest field should boot once per class");
}
// ── two servers, wired together ──────────────────────────────────────────
@Test
void eachServerGetsItsOwnPort() {
assertNotEquals(upstream.port(), downstream.port());
assertTrue(upstream.port() > 0);
}
@Test
void downstreamReachesUpstreamThroughItsInjectedBaseUri() {
downstream.get("/call-upstream")
.expectStatus(200)
.expectBody("UP");
}
// ── mocking ──────────────────────────────────────────────────────────────
@Test
void mockWinsOverAServiceProvidedByAnInstalledExtension() {
downstream.get("/greeting")
.expectStatus(200)
.expectBody("faked");
}
@Test
void mockAfterStartIsRejected() {
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> downstream.mock(Greeter.class, () -> "too late"));
assertTrue(error.getMessage().contains("before the server starts"));
}
// ── request / response surface ───────────────────────────────────────────
@Test
void sendsHeadersQueriesAndBodies() {
upstream.get("/echo?v=7").expectBody("upstream:7");
downstream.request()
.header("X-Trace", "abc")
.json("{\"name\":\"bob\"}")
.post("/submit")
.expectStatus(201)
.expectHeader("X-Trace", "abc")
.expectBodyContains("bob");
}
@Test
void failedAssertionsReportTheActualResponse() {
AssertionError error = assertThrows(AssertionError.class,
() -> upstream.get("/health").expectStatus(404));
String message = error.getMessage();
assertTrue(message.contains("GET /health"), message);
assertTrue(message.contains("200"), message);
assertTrue(message.contains("UP"), message);
}
@Test
void unmatchedRoutesStillComeBackAsResponses() {
upstream.get("/nope").expectStatus(404);
}
// ── fixtures ─────────────────────────────────────────────────────────────
/** Counts its own boots so the class-scoped lifecycle can be asserted. */
private record Downstream(URI upstream) implements FlashApplication {
@Override
public void configure(FlashApp app) {
downstreamBoots.incrementAndGet();
// Provided by an extension, so the .mock(...) above has something real to beat.
app.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"));
app.get("/greeting", (req, res) -> app.ctx().require(Greeter.class).greet());
app.get("/call-upstream", (req, res) -> {
try (HttpClient http = HttpClient.newHttpClient()) {
return http.send(HttpRequest.newBuilder(upstream.resolve("/health")).build(),
HttpResponse.BodyHandlers.ofString()).body();
} catch (IOException failure) {
throw new IllegalStateException(failure);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
});
app.post("/submit", (req, res) -> res.status(201)
.header("X-Trace", req.header("X-Trace"))
.body(req.body().bytes()));
}
}
@FunctionalInterface
interface Greeter { String greet(); }
}
@@ -0,0 +1,74 @@
package dev.relism.flash.testing;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class FlashWebSocketTest {
private static final Duration TIMEOUT = Duration.ofSeconds(2);
@RegisterExtension
static FlashTest app = FlashTest.of(configured -> {
configured.ws("/echo", new WebSocketHandler() {
@Override public void onOpen(WebSocketSession session) { }
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {
if (frame.opcode() != WebSocketFrame.OP_TEXT) return;
try {
byte[] echo = ("echo:" + new String(frame.copyPayload(), frame.payloadOffset(),
frame.payloadLength(), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8);
session.sendText(echo, 0, echo.length);
} catch (Exception failure) {
throw new IllegalStateException(failure);
}
}
});
configured.ws("/silent", new WebSocketHandler() {
@Override public void onOpen(WebSocketSession session) { }
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) { }
});
});
@Test
void roundTripsTextThroughARealHandshake() {
try (FlashWebSocket socket = app.ws("/echo")) {
socket.sendText("hello");
assertEquals("echo:hello", socket.awaitText(TIMEOUT));
}
}
@Test
void queuesEveryMessageInOrder() {
try (FlashWebSocket socket = app.ws("/echo")) {
socket.sendText("one").sendText("two");
assertEquals("echo:one", socket.awaitText(TIMEOUT));
assertEquals("echo:two", socket.awaitText(TIMEOUT));
}
}
/** The likeliest user mistake: teardown must cancel it rather than block on it. */
@Test
void anUnclosedSocketDoesNotHangTeardown() {
FlashWebSocket leaked = app.ws("/echo");
leaked.sendText("no try-with-resources here");
assertEquals("echo:no try-with-resources here", leaked.awaitText(TIMEOUT));
}
@Test
void awaitTextFailsLoudlyWhenNothingArrives() {
try (FlashWebSocket socket = app.ws("/silent")) {
socket.sendText("ignored");
AssertionError error = assertThrows(AssertionError.class,
() -> socket.awaitText(Duration.ofMillis(300)));
assertEquals("No WebSocket text message within PT0.3S", error.getMessage());
}
}
}
+13 -1
View File
@@ -11,6 +11,7 @@
<modules> <modules>
<module>flash</module> <module>flash</module>
<module>flash-testing</module>
<module>flash-extensions</module> <module>flash-extensions</module>
</modules> </modules>
@@ -36,6 +37,7 @@
<maven.gpg.plugin.version>3.2.8</maven.gpg.plugin.version> <maven.gpg.plugin.version>3.2.8</maven.gpg.plugin.version>
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.version> <maven.versions.plugin.version>2.18.0</maven.versions.plugin.version>
<jmh.version>1.37</jmh.version> <jmh.version>1.37</jmh.version>
<junit.version>5.11.0</junit.version>
<build.helper.plugin.version>3.6.0</build.helper.plugin.version> <build.helper.plugin.version>3.6.0</build.helper.plugin.version>
</properties> </properties>
@@ -60,6 +62,11 @@
<artifactId>flash</artifactId> <artifactId>flash</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId> <artifactId>flash-ext-jackson</artifactId>
@@ -139,9 +146,14 @@
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
<version>5.11.0</version> <version>${junit.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>