carlos / platform Public

console: tests for the job's decisions; the pass test steps the row now that the job does

TestSelfServeDecisionsSpeakEachFact covers every fact sentence of the
target-ready and source-precheck decisions, the fact set's data paths and
the ready rule. TestPassStartsAndRetriesOnReadFailure's third case
pinned Task 4's stub step (every row left as found); it now pins each
event delivered once and the row stepped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 31f3c610adeb1a3580712ad27268af2c4ef76ae7 parent 88cf466
2 files changed, +127 −9
  • internal/console/selfserve_checks_test.go +112 −0
  • internal/console/selfserve_pass_test.go +15 −9
diff --git a/internal/console/selfserve_checks_test.go b/internal/console/selfserve_checks_test.go
new file mode 100644
index 000000000..43fe0e56d
--- /dev/null
+++ b/internal/console/selfserve_checks_test.go
@@ -0,0 +1,112 @@
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// TestSelfServeDecisionsSpeakEachFact: the job's decisions over a target's
+// ready report and a source's precheck, fact by fact: each failing fact
+// its own sentence, a missing or unknown one "could not determine <fact>
+// on <label>", free disk under twice the replica, and an empty stray
+// directory passing (the start removes it).
+func TestSelfServeDecisionsSpeakEachFact(t *testing.T) {
+ const host, label = "shop.example.com", "uk-box"
+ ok := moves.Fact{Known: true, OK: true}
+ ready := func() *moves.TargetReadyReport {
+ r := &moves.TargetReadyReport{RouteAbsent: ok, TimersAbsent: ok, LiveLink: ok, ProgramsFile: ok, ParkDir: ok, DiskFree: ok,
+ DiskFreeBytes: 10 << 30, MovesPrefix: moves.PrefixFacts{Known: true}, InstancesPrefix: moves.PrefixFacts{Known: true}, Lock: ok,
+ DataPaths: map[string]moves.Fact{}}
+ for _, p := range targetDataPaths(host, nil) {
+ r.DataPaths[p] = ok
+ }
+ return r
+ }
+ if got := targetReadyDecision(host, label, ready(), 1<<20, true); got != "" {
+ t.Fatalf("a clean target: %q", got)
+ }
+ for _, c := range []struct {
+ name, want string
+ edit func(r *moves.TargetReadyReport)
+ replica int64
+ }{
+ {"route unknown", "could not determine the route on uk-box", func(r *moves.TargetReadyReport) { r.RouteAbsent = moves.Fact{} }, 0},
+ {"route held", "the target box already has a route for shop.example.com", func(r *moves.TargetReadyReport) { r.RouteAbsent.OK = false }, 0},
+ {"timers", "the target box has timers for shop.example.com", func(r *moves.TargetReadyReport) { r.TimersAbsent.OK = false }, 0},
+ {"stale live link", "the target box has a stale live link for shop.example.com naming 2026-10-01.1", func(r *moves.TargetReadyReport) {
+ r.LiveLink = moves.Fact{Known: true, Detail: "/opt/carlos/versions/2026-10-01.1/bab/shop"}
+ }, 0},
+ {"programs file", "the target box's programs file names ./cmd/shop", func(r *moves.TargetReadyReport) {
+ r.ProgramsFile = moves.Fact{Known: true, Detail: "./cmd/shop"}
+ }, 0},
+ {"a data path held", "the target box already holds data for shop.example.com: /data/shop.example.com.db", func(r *moves.TargetReadyReport) {
+ r.DataPaths["/data/shop.example.com.db"] = moves.Fact{Known: true}
+ }, 0},
+ {"a data path left out", "could not determine /data/shop.example.com.env on uk-box", func(r *moves.TargetReadyReport) {
+ delete(r.DataPaths, "/data/shop.example.com.env")
+ }, 0},
+ {"park directory", "the target box's park directory is not clean", func(r *moves.TargetReadyReport) { r.ParkDir.OK = false }, 0},
+ {"disk under twice the replica", "the target box has too little free disk for shop.example.com", func(r *moves.TargetReadyReport) {
+ r.DiskFreeBytes = 3 << 20
+ }, 2 << 20},
+ {"staging prefix unknown", "could not determine the staging prefix on uk-box", func(r *moves.TargetReadyReport) { r.MovesPrefix.Known = false }, 0},
+ {"instances prefix held", "the target bucket already holds objects for shop.example.com", func(r *moves.TargetReadyReport) {
+ r.InstancesPrefix.Objects = map[string]moves.PrefixObject{"db.ltx": {}}
+ }, 0},
+ {"a lock", "the target bucket holds a lock for shop.example.com (moves/shop.example.com/lock.json)", func(r *moves.TargetReadyReport) {
+ r.Lock = moves.Fact{Known: true, Detail: "moves/shop.example.com/lock.json"}
+ }, 0},
+ } {
+ r := ready()
+ c.edit(r)
+ if got := targetReadyDecision(host, label, r, c.replica, true); got != c.want {
+ t.Errorf("%s: %q, want %q", c.name, got, c.want)
+ }
+ }
+ // After staging the staging prefix holds the staged copy: the book
+ // decides it, not the ready decision.
+ r := ready()
+ r.MovesPrefix.Objects = map[string]moves.PrefixObject{"db.ltx": {}}
+ if got := targetReadyDecision(host, label, r, 0, false); got != "" {
+ t.Errorf("after staging: %q", got)
+ }
+
+ pre := func() *moves.SourcePrecheck {
+ return &moves.SourcePrecheck{ArchiveEligible: ok, Strays: moves.StraysFact{Known: true}}
+ }
+ for _, c := range []struct {
+ name, want string
+ edit func(p *moves.SourcePrecheck)
+ }{
+ {"clean", "", func(*moves.SourcePrecheck) {}},
+ {"archive unknown", "could not determine the archive on ie-box", func(p *moves.SourcePrecheck) { p.ArchiveEligible = moves.Fact{} }},
+ {"archive refused", "shop.example.com's files cannot be archived: an unsupported file type", func(p *moves.SourcePrecheck) {
+ p.ArchiveEligible = moves.Fact{Known: true, Detail: "an unsupported file type"}
+ }},
+ {"strays unknown", "could not determine the stray entries on ie-box", func(p *moves.SourcePrecheck) { p.Strays.Known = false }},
+ {"an empty directory", "", func(p *moves.SourcePrecheck) {
+ p.Strays.Entries = []moves.StrayEntry{{Path: "/data/x", Kind: moves.StrayDir, Known: true}}
+ }},
+ {"a stray file", "shop.example.com has a stray entry beside its files directory: /data/x.txt", func(p *moves.SourcePrecheck) {
+ p.Strays.Entries = []moves.StrayEntry{{Path: "/data/x.txt", Kind: moves.StrayFile, Known: true}}
+ }},
+ {"an unread directory", "could not determine /data/y on ie-box", func(p *moves.SourcePrecheck) {
+ p.Strays.Entries = []moves.StrayEntry{{Path: "/data/y", Kind: moves.StrayDir}}
+ }},
+ } {
+ p := pre()
+ c.edit(p)
+ if got := sourcePrecheckDecision(host, "ie-box", p); got != c.want {
+ t.Errorf("%s: %q, want %q", c.name, got, c.want)
+ }
+ }
+ if got := targetDataPaths(host, map[string]moves.Fact{"/srv/d/" + host + ".db": ok})[0]; got != provision.InstanceDir("/srv/d", host) {
+ t.Errorf("the data directory is not read from the report: %q", got)
+ }
+ if stagingReady(moves.OfflineReport{Rounds: 1}) || !stagingReady(moves.OfflineReport{Rounds: 2, RoundCopiedBytes: 1 << 20}) ||
+ stagingReady(moves.OfflineReport{Rounds: 3, RoundCopiedBytes: 8 << 20, RoundDeferredBytes: 8 << 20}) {
+ t.Error("the ready rule")
+ }
+}
diff --git a/internal/console/selfserve_pass_test.go b/internal/console/selfserve_pass_test.go
index 3f6649939..057a406a7 100644
--- a/internal/console/selfserve_pass_test.go
+++ b/internal/console/selfserve_pass_test.go
@@ -321,10 +321,13 @@ func TestPassStartsAndRetriesOnReadFailure(t *testing.T) {
}
})
- t.Run("the pass drains events and leaves rows it cannot advance alone", func(t *testing.T) {
+ t.Run("the pass drains each event once and steps the rows", func(t *testing.T) {
+ // Task 4 pinned the stub step here (every row left as found); the
+ // job (Task 7) steps the row: its instance has no record, so it is
+ // finished "the instance was recreated", and every event (the
+ // admission's and the outcome's) is delivered exactly once.
fx, _ := ssPassFixture(t)
ssAdmit(t, fx.st, ssMove(ssID, ssHost, ssT0, "ie-1", "uk-1"))
- before := ssLoad(t, fx.st, ssID)
fx.srv.selfServeScanAtStart(ctx)
prev := selfServeTickInterval
selfServeTickInterval = 10 * time.Millisecond
@@ -333,20 +336,23 @@ func TestPassStartsAndRetriesOnReadFailure(t *testing.T) {
done := make(chan struct{})
go func() { fx.srv.StartSelfServeMovesPass(pctx); close(done) }()
deadline := time.Now().Add(5 * time.Second)
- for auditCount(t, fx.st) == 0 && time.Now().Before(deadline) {
+ for auditCount(t, fx.st) < 2 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
time.Sleep(50 * time.Millisecond) // a few more ticks
cancel()
<-done
- if n := auditCount(t, fx.st); n != 1 {
- t.Fatalf("%d audit rows, want the admission event once", n)
+ evs := ssEvents(t, fx.st, ssID)
+ if n := auditCount(t, fx.st); n != len(evs) || n != 2 {
+ t.Fatalf("%d audit rows for %d events, want each of the admission's and the outcome's once", n, len(evs))
}
- if after := ssLoad(t, fx.st, ssID); !reflect.DeepEqual(after, before) {
- t.Fatalf("the pass changed a row it cannot advance:\nbefore %+v\n after %+v", before, after)
+ for _, e := range evs {
+ if !e.Emitted {
+ t.Fatalf("event %d undelivered", e.Seq)
+ }
}
- if !fx.srv.selfServeMode() {
- t.Fatal("recovery mode ended with the row open")
+ if after := ssLoad(t, fx.st, ssID); after.Outcome != "aborted" || after.OutcomeReason != msgSelfServeRecreated {
+ t.Fatalf("the row after the pass: %s %q", after.Outcome, after.OutcomeReason)
}
})