feat(core): expose bound ports, service override, FlashApplication and close hooks
Four small seams, each useful on its own, that together make a Flash app testable without hand-rolled scaffolding. - ServerHandle/ServerLifecycle/FlashApp gain port()/ports(). Listeners already bind in the FlashApp constructor, so port(0) resolved to a real port that nothing could read back; every integration test worked around this by opening a ServerSocket(0), closing it and reusing the number, which races anything else on the machine. - FlashContext.override() replaces a binding instead of rejecting it. The duplicate-is-an-error rule stays everywhere else; this is the single deliberate exception, for swapping a service out in tests. A replacement is logged at INFO so misuse in production is visible. - FlashApplication + FlashApp.apply() name an application independently of the port it runs on, so the same one can be booted twice. It takes FlashApp rather than FlashRegistrar because ws() and mount() live there. Being a functional interface, a lambda and a named class are the same thing. - FlashContext.onClose() runs cleanup at stop(), children first and then in reverse registration order. stop() previously closed sockets and the executor and never touched the service graph, so a pooled DataSource was only ever released by JVM exit — invisible with one app per process, a leak per test class once a suite boots and stops many. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
74169e40f4
commit
4a85a27648
@@ -0,0 +1,172 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The four seams {@code flash-testing} is built on: reading back an ephemeral port,
|
||||
* replacing a binding, applying an application, and closing services at stop.
|
||||
*/
|
||||
class FlashAppLifecycleTest {
|
||||
|
||||
private static FlashConfiguration ephemeral() {
|
||||
return FlashConfiguration.builder().port(0).host("127.0.0.1")
|
||||
.shutdownDrainTimeoutMs(250).build();
|
||||
}
|
||||
|
||||
// ── port() ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void exposesTheOsAssignedPortBeforeStart() {
|
||||
FlashApp app = FlashApp.create(ephemeral());
|
||||
try {
|
||||
assertTrue(app.port() > 0, "port 0 should resolve to a real bound port");
|
||||
assertEquals(List.of(app.port()), app.ports());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheSamePortAcrossStart() {
|
||||
FlashApp app = FlashApp.create(ephemeral()).get("/ping", (req, res) -> "pong");
|
||||
try {
|
||||
int beforeStart = app.port();
|
||||
app.start();
|
||||
assertEquals(beforeStart, app.port());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
// ── override() ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void overrideReplacesAnExistingBinding() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Greeter.class, () -> "real");
|
||||
ctx.override(Greeter.class, () -> "fake");
|
||||
|
||||
ctx.resolveAll();
|
||||
|
||||
assertEquals("fake", ctx.require(Greeter.class).greet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideAlsoDeclaresWhenNothingWasBound() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.override(Greeter.class, () -> "fake");
|
||||
|
||||
ctx.resolveAll();
|
||||
|
||||
assertEquals("fake", ctx.require(Greeter.class).greet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideIsRejectedOnceDeclarationsAreClosed() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.resolveAll();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> ctx.override(Greeter.class, () -> "fake"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastInstalledExtensionWinsOverAnEarlierProvider() {
|
||||
FlashApp app = FlashApp.create(ephemeral())
|
||||
.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"))
|
||||
.install((registrar, ctx) -> ctx.override(Greeter.class, () -> "fake"));
|
||||
try {
|
||||
app.start();
|
||||
assertEquals("fake", app.ctx().require(Greeter.class).greet());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
// ── apply() ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void applyRunsImmediatelyWithAnOpenContextAndABoundPort() {
|
||||
List<Integer> portSeenInsideConfigure = new ArrayList<>();
|
||||
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
|
||||
portSeenInsideConfigure.add(configured.port());
|
||||
configured.ctx().provide(Greeter.class, () -> "from-application");
|
||||
configured.get("/hello", (req, res) -> "hi");
|
||||
});
|
||||
try {
|
||||
assertEquals(List.of(app.port()), portSeenInsideConfigure);
|
||||
app.start();
|
||||
assertEquals("from-application", app.ctx().require(Greeter.class).greet());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
// ── onClose() ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void stopRunsCloseCallbacksInReverseOrder() {
|
||||
List<String> closed = new ArrayList<>();
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
|
||||
configured.ctx().onClose(() -> closed.add("first"));
|
||||
configured.ctx().onClose(() -> closed.add("second"));
|
||||
});
|
||||
|
||||
app.start();
|
||||
app.stop().join();
|
||||
|
||||
assertEquals(List.of("second", "first"), closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFactoryCanRegisterItsOwnTeardownWhileResolving() {
|
||||
AtomicInteger closes = new AtomicInteger();
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured ->
|
||||
configured.ctx().supply(Greeter.class, services -> {
|
||||
services.onClose(closes::incrementAndGet);
|
||||
return () -> "resolved";
|
||||
}));
|
||||
|
||||
app.start();
|
||||
assertEquals(0, closes.get(), "must not close while the app is live");
|
||||
|
||||
app.stop().join();
|
||||
assertEquals(1, closes.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aThrowingCloseCallbackDoesNotStopTheRest() {
|
||||
List<String> closed = new ArrayList<>();
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
|
||||
configured.ctx().onClose(() -> closed.add("ran"));
|
||||
configured.ctx().onClose(() -> { throw new IllegalStateException("boom"); });
|
||||
});
|
||||
|
||||
app.start();
|
||||
assertDoesNotThrow(() -> app.stop().join());
|
||||
|
||||
assertEquals(List.of("ran"), closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeCallbacksRunAtMostOnce() {
|
||||
AtomicInteger closes = new AtomicInteger();
|
||||
FlashApp app = FlashApp.create(ephemeral())
|
||||
.apply(configured -> configured.ctx().onClose(closes::incrementAndGet));
|
||||
|
||||
app.start();
|
||||
app.stop().join();
|
||||
app.stop().join();
|
||||
|
||||
assertEquals(1, closes.get());
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Greeter { String greet(); }
|
||||
}
|
||||
Reference in New Issue
Block a user