diff --git a/AGENTS.md b/AGENTS.md index 985077f..6ee7f45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Format: `(): ` | `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: diff --git a/README.md b/README.md index bce2420..99661cc 100644 --- a/README.md +++ b/README.md @@ -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 + + dev.relism + flash-testing + ${flash.version} + test + +``` + +```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 ``` diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 65ed15a..70452eb 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -31,6 +31,12 @@ + + dev.relism + flash-testing + ${project.version} + test + dev.relism flash-ext-jackson diff --git a/flash-testing/pom.xml b/flash-testing/pom.xml new file mode 100644 index 0000000..a8c2fe6 --- /dev/null +++ b/flash-testing/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + dev.relism + flash-parent + 2.1.0-SNAPSHOT + + + flash-testing + jar + + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter-api + + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java new file mode 100644 index 0000000..b55b01b --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java @@ -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}: + * + *
{@code
+ * app.request()
+ *    .header("Authorization", "Bearer " + token)
+ *    .json("{\"name\":\"bob\"}")
+ *    .post("/api/users")
+ *    .expectStatus(201);
+ * }
+ * + * 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 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; + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java new file mode 100644 index 0000000..454173b --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java @@ -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. + * + *
{@code
+ * app.get("/api/users")
+ *    .expectStatus(200)
+ *    .expectHeader("content-type", "application/json")
+ *    .expectBodyContains("alice");
+ * }
+ * + * 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 response; + + FlashResponse(String method, String path, HttpResponse 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(); + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java new file mode 100644 index 0000000..666d821 --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java @@ -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. + * + *
{@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");
+ *     }
+ * }
+ * }
+ * + *

More than one server

+ * 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: + * + *
{@code
+ * @RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
+ * @RegisterExtension static FlashTest api  = FlashTest.of(new BlogApp(auth.baseUri()));
+ * }
+ * + *

Scope

+ * 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. + * + *

Replacing services

+ * {@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, Object> overrides = new LinkedHashMap<>(); + private Consumer 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. + * + *

{@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. + * + *

The harness stamps host, port and the shutdown drain window after 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 profile) { + requireNotStarted("profile(...)"); + this.profile = Objects.requireNonNull(profile, "profile"); + return this; + } + + /** + * Replaces the service bound to {@code type} with {@code instance} for this server. + * + *

Wins over anything the application or its extensions declare, including services + * provided by an installed {@code FlashExtension}. + * + *

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 FlashTest mock(Class 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) 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"); + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java new file mode 100644 index 0000000..3888e9e --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java @@ -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. + * + *
{@code
+ * try (FlashWebSocket socket = app.ws("/live")) {
+ *     socket.sendText("hello");
+ *     assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
+ * }
+ * }
+ * + * 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 received = new LinkedBlockingQueue<>(); + private final CompletableFuture 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. + * + *

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); + } + } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java new file mode 100644 index 0000000..780c28b --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java @@ -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"); + } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java new file mode 100644 index 0000000..f0a6e6e --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java @@ -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(); } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java new file mode 100644 index 0000000..0a0287e --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java @@ -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()); + } + } +} diff --git a/pom.xml b/pom.xml index cdbc07d..aaa7c7e 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ flash + flash-testing flash-extensions @@ -36,6 +37,7 @@ 3.2.8 2.18.0 1.37 + 5.11.0 3.6.0 @@ -60,6 +62,11 @@ flash ${project.version} + + dev.relism + flash-testing + ${project.version} + dev.relism flash-ext-jackson @@ -139,9 +146,14 @@ org.junit.jupiter junit-jupiter - 5.11.0 + ${junit.version} test + + org.junit.jupiter + junit-jupiter-api + ${junit.version} +