Feature/ext validation/request validation #15
@@ -36,7 +36,7 @@ Format: `<type>(<scope>): <short description>`
|
||||
| `chore` | Build, deps, tooling — no production code |
|
||||
| `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-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.
|
||||
- `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.
|
||||
- Extensions live under `flash-extensions/flash-ext-*/`.
|
||||
- When adding a new extension:
|
||||
|
||||
@@ -8,6 +8,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| `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-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||
| `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
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<modules>
|
||||
<module>flash</module>
|
||||
<module>flash-testing</module>
|
||||
<module>flash-extensions</module>
|
||||
</modules>
|
||||
|
||||
@@ -36,6 +37,7 @@
|
||||
<maven.gpg.plugin.version>3.2.8</maven.gpg.plugin.version>
|
||||
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.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>
|
||||
</properties>
|
||||
|
||||
@@ -60,6 +62,11 @@
|
||||
<artifactId>flash</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
@@ -139,9 +146,14 @@
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user