Feature/ext validation/request validation #15
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user