fleetd #252: guard test for the REST route inventory
FleetApp's route list has never been checked against anything and has already drifted once (GET /member-credentials shipped hours before the #252 ticket and was missing from its list). Add RestRouteInventoryTest, modelled on McpContractDocTest, which scrapes FleetApp.java's app.<verb>("path") calls with a regex and compares them against an explicit expected inventory, failing loudly with the added/removed routes when they diverge.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package dev.ltms.fleet.rest;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* fleetd #252: the REST surface has no supported operator entry point of its own — it is the
|
||||
* documented fallback for when the MCP mount drops (see the operator wiki's REST page), and
|
||||
* nothing has ever checked a hand-written route list against it. Proof it drifts: the ticket
|
||||
* itself was filed with 14 routes, and {@link FleetApp} registers 15 — {@code GET
|
||||
* /member-credentials} (fleetd #111) shipped hours before the ticket and was already missing from
|
||||
* its list.
|
||||
*
|
||||
* <p>This test is modelled on {@code McpContractDocTest} (fleetd #114 / CB-609), which solved the
|
||||
* same shape of problem for the MCP tool catalogue: read the source text with a regex instead of
|
||||
* trusting a maintained copy. Here the "doc" is an explicit inventory written directly in this
|
||||
* test rather than a separate Markdown file, because the operator wiki page lives in a submodule
|
||||
* a worker cannot read reliably (see {@code CLAUDE.md} → Project addendum). Keeping the expected
|
||||
* list in the test means it still fails loudly the moment {@link FleetApp} changes, which is the
|
||||
* property that actually matters; a human keeps the wiki page in sync using the failure message
|
||||
* below as the diff.
|
||||
*
|
||||
* <p><b>It checks source text, not behaviour.</b> It reads {@link FleetApp}'s source for {@code
|
||||
* app.<verb>("<path>")} registrations and does not boot a server. It cannot catch a route that is
|
||||
* registered through some other mechanism entirely (a filter, a redirect) — only ones shaped like
|
||||
* the {@code app.get/post/delete/put/patch(...)} calls every route here actually uses.
|
||||
*/
|
||||
class RestRouteInventoryTest {
|
||||
|
||||
/** Tests run with the module directory as cwd. */
|
||||
private static final Path REST_SOURCE = Path.of("src/main/java/dev/ltms/fleet/rest/FleetApp.java");
|
||||
|
||||
/**
|
||||
* The REST surface as verified against {@link FleetApp} on 2026-09-03 (fleetd #252). Update
|
||||
* this list AND the operator wiki's REST-surface entry together whenever a route is added,
|
||||
* removed, or renamed — never one without the other.
|
||||
*
|
||||
* <p>{@code /mcp} is deliberately excluded: it is a raw Jetty {@code ServletHolder} mount
|
||||
* (see {@code FleetApp.build()}, around the {@code modifyServletContextHandler} call), not an
|
||||
* {@code app.<verb>(...)} route, so it is a different registration mechanism and this test's
|
||||
* regex does not — and should not — see it. If {@code /mcp} ever moves to a Javalin route,
|
||||
* add it here explicitly rather than relying on the regex to pick it up by accident.
|
||||
*/
|
||||
private static final Set<String> EXPECTED_ROUTES = Set.of(
|
||||
"GET /healthz",
|
||||
"GET /metrics",
|
||||
"GET /sessions",
|
||||
"GET /agents",
|
||||
"GET /members",
|
||||
"GET /profiles",
|
||||
"GET /member-credentials",
|
||||
"POST /members",
|
||||
"DELETE /members/{paneId}",
|
||||
"POST /sessions/{id}/message",
|
||||
"POST /sessions/{id}/reply",
|
||||
"GET /sessions/{id}/replies",
|
||||
"POST /sessions/{id}/ask",
|
||||
"GET /sessions/{id}/status",
|
||||
"GET /tasks/{ticket}"
|
||||
);
|
||||
|
||||
/**
|
||||
* Every {@code app.<verb>("<path>")} call in {@link FleetApp}'s source, as {@code "VERB path"}.
|
||||
* This matches inside an {@code if (...) { ... }} block just as well as a top-level statement —
|
||||
* the regex only looks for the call shape, not its surrounding control flow — which is what
|
||||
* catches {@code GET /metrics} (registered conditionally on {@code metrics != null}).
|
||||
*/
|
||||
private static Set<String> routesTheServerRegisters() throws Exception {
|
||||
String source = Files.readString(REST_SOURCE);
|
||||
Matcher m = Pattern.compile("app\\.(get|post|delete|put|patch)\\(\\s*\"([^\"]+)\"").matcher(source);
|
||||
Set<String> found = new LinkedHashSet<>();
|
||||
while (m.find()) {
|
||||
found.add(m.group(1).toUpperCase(Locale.ROOT) + " " + m.group(2));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("[SOURCE TEXT] FleetApp registers exactly the documented REST route inventory")
|
||||
void theRegisteredRoutesMatchTheExpectedInventory() throws Exception {
|
||||
Set<String> actual = routesTheServerRegisters();
|
||||
|
||||
Set<String> added = new LinkedHashSet<>(actual);
|
||||
added.removeAll(EXPECTED_ROUTES);
|
||||
Set<String> removed = new LinkedHashSet<>(EXPECTED_ROUTES);
|
||||
removed.removeAll(actual);
|
||||
|
||||
assertTrue(added.isEmpty() && removed.isEmpty(),
|
||||
"FleetApp's registered REST routes no longer match this test's expected inventory. "
|
||||
+ "Added (in FleetApp, not in this test): " + added + ". "
|
||||
+ "Removed (in this test, not in FleetApp): " + removed + ". "
|
||||
+ "Update EXPECTED_ROUTES in RestRouteInventoryTest AND the operator wiki's "
|
||||
+ "REST-surface entry together — this is the fleetd #252 defect: the route "
|
||||
+ "list drifted for a month with nothing checking it. Do NOT weaken this test.");
|
||||
}
|
||||
|
||||
/**
|
||||
* The denominator guard (same shape as {@code McpContractDocTest}'s vacuity check). Pins that
|
||||
* the regex really is still finding registrations, so a scrape that silently stops matching
|
||||
* can't make the check above pass by finding nothing on both sides.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("[SOURCE TEXT] the route scrape is not vacuous — it found the expected count")
|
||||
void theScrapeActuallyFoundRoutes() throws Exception {
|
||||
Set<String> actual = routesTheServerRegisters();
|
||||
assertTrue(actual.size() >= EXPECTED_ROUTES.size(),
|
||||
"scraped only " + actual.size() + " route registration(s) from FleetApp (" + actual
|
||||
+ "), but this test expects at least " + EXPECTED_ROUTES.size()
|
||||
+ "; the app.<verb>(\"...\") scrape has stopped matching and the check above "
|
||||
+ "is now vacuous");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user