diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadConfigDirSourceAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadConfigDirSourceAssemblyTest.java new file mode 100644 index 0000000..07652cf --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadConfigDirSourceAssemblyTest.java @@ -0,0 +1,213 @@ +package dev.ltms.fleet; + +import dev.ltms.fleet.config.ConfigRef; +import dev.ltms.fleet.config.FleetConfig; +import dev.ltms.fleet.guard.SubscriptionGuard; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.HerdrClient; +import dev.ltms.fleet.mcp.FleetMcp; +import dev.ltms.fleet.msg.ReplyInbox; +import io.javalin.Javalin; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.LongSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * fleetd #612 Shape A, unit r5 — {@code FleetdAssembly.java:488} wires {@link + * FleetMcp.LeadConfigDirSource} with {@code Fleetd.leadConfigDirSource(() -> config.get().profiles(), + * leaders)}. {@link FleetdLeadConfigDirSourceWiringTest} already pins that the FACTORY itself + * delegates to the real {@link Fleetd#leadConfigDirLookup} — but, by its own javadoc, it "does not + * and structurally cannot cover" whether the real call site in {@code FleetdAssembly} still calls + * that factory at all. Measured there: swapping that one-line call for a bare {@code + * FleetMcp.LeadConfigDirSource.none()} compiles with 0 errors and leaves the full suite green. + * + *

This is the literal fleetd #602/#606 defect, one call site away from its own fix: {@code main} + * (now {@code FleetdAssembly}) used to build {@code LeadConfigDirSource.none()} inline, the whole + * suite passed, and the live daemon reported {@code "state":"unknown"} for every lead's context, + * forever, with no test noticing. The fix extracted the factory; this test is the one that proves + * {@code FleetdAssembly}'s own call site still reaches it. + * + *

This test drives the REAL {@link FleetMcp} the real {@link FleetdAssembly#assembleAndStart} + * builds, reached through {@link FleetdRuntime#mcp()}, and reads the {@code leadConfigDirs} field it + * was constructed with via reflection — {@code FleetMcp} exposes no public accessor for it (unlike + * {@code quarantineSource()}/{@code leadSeatSource()}), so there is no non-reflective route to the + * live instance. The assertion resolves a REAL lead name against a REAL configured {@code + * configDir:}: {@link FleetMcp.LeadConfigDirSource#none()} (the historical defect, and the + * mis-wire this test's mutation cycles reintroduce) always returns {@code null} regardless of the + * input, so a non-null, config-matching answer is a property {@code none()} can never produce by + * accident. + */ +class FleetdLeadConfigDirSourceAssemblyTest { + + private static final String LEAD_NAME = "opus"; + private static final String LEAD_TAB = "lead: opus"; + private static final String LEAD_PROFILE = "sonnet"; + + private static final class RecordingResourcePorts implements ResourcePorts { + + final FakeHerdr herdr = new FakeHerdr(); + final SentinelReplyInbox replyInbox = new SentinelReplyInbox(); + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + return herdr; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> replyInbox; + } + + @Override + public Fleetd.LeadMailboxOpener leadMailboxOpener() { + return (uri, selfCoordId, prefetch) -> { + throw new UnsupportedOperationException( + "leadMailboxOpener must not be called — no coordinator: block is configured"); + }; + } + + @Override + public LongSupplier nanoClock() { + return System::nanoTime; + } + + @Override + public LongSupplier wallClockNanos() { + return System::nanoTime; + } + + @Override + public ScheduledExecutorService newScheduler(String purpose) { + return Executors.newSingleThreadScheduledExecutor(); + } + + @Override + public void addShutdownHook(Runnable hook) { + } + + @Override + public void startHttp(Javalin app, String host, int port) { + } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } + } + + private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { + @Override + public void own(String target) { + } + + @Override + public void release(String target) { + } + + @Override + public void publish(String target, String msgId, String content) { + } + + @Override + public List peek(String target) { + return List.of(); + } + + @Override + public boolean ack(String target, String msgId) { + return false; + } + + @Override + public void close() { + } + } + + private static FleetConfig writeConfig(Path dir, String configDir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8765 + idleSleepGuard: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + fleet: + leaders: + %s: + tab: "%s" + profile: %s + profiles: + %s: + subscription: true + argv: ["ccs", "sonnet"] + configDir: "%s" + """.formatted(LEAD_NAME, LEAD_TAB, LEAD_PROFILE, LEAD_PROFILE, configDir)); + return FleetConfig.load(f); + } + + @SuppressWarnings("unchecked") + private static FleetMcp.LeadConfigDirSource leadConfigDirSourceOf(FleetMcp mcp) throws Exception { + Field field = FleetMcp.class.getDeclaredField("leadConfigDirs"); + field.setAccessible(true); + return (FleetMcp.LeadConfigDirSource) field.get(mcp); + } + + @Test + @DisplayName("[BEHAVIOURAL] the real assembled LeadConfigDirSource resolves a lead's REAL " + + "configured configDir, not the none() stand-in's hardcoded null") + void assembledLeadConfigDirSourceResolvesTheRealConfiguredConfigDir(@TempDir Path dir) throws Exception { + String configuredConfigDir = "/mnt/fake-lead-configdir"; + FleetConfig cfg = writeConfig(dir, configuredConfigDir); + ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg); + SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); + RecordingResourcePorts ports = new RecordingResourcePorts(); + // Label FakeHerdr's own default pane's tab (term_a / w2:p7 / w2:t7, already carrying a live + // agent) to match fleet.leaders.opus.tab exactly, so LeadLauncher.ensureLeads() sees the + // lead as already live and does not try to auto-launch a second one. + ports.herdr.withTab("w2", "w2:t7", LEAD_TAB); + + FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); + try { + FleetMcp.LeadConfigDirSource source = leadConfigDirSourceOf(runtime.mcp()); + + assertEquals(configuredConfigDir, source.configDirFor().apply(LEAD_NAME), + "fleet.leaders." + LEAD_NAME + ".profile (" + LEAD_PROFILE + ") configures " + + "configDir: " + configuredConfigDir + " — the real assembled source must " + + "resolve it. FleetMcp.LeadConfigDirSource.none() (the inert stand-in " + + "this test's mutation cycles swap the call site for, and the historical " + + "fleetd #602/#606 defect) always reports null here, whatever the input"); + + // A lead name the config does not recognise still resolves to null, not a crash — the + // same source, applied to an input that must stay at the inert answer even on the real, + // non-inert instance. + assertNull(source.configDirFor().apply("no-such-lead")); + } finally { + // Surefire runs the whole suite in one JVM fork (fleetd/pom.xml sets no forkCount / + // reuseForks), so the scheduler/loops this assembly starts must be torn down here, on the + // failure path too — hence try/finally rather than a bare statement at the end. + runtime.close(); + } + } +}