diff --git a/go/buildstep/guard_test.go b/go/buildstep/guard_test.go new file mode 100644 index 0000000..30514b1 --- /dev/null +++ b/go/buildstep/guard_test.go @@ -0,0 +1,79 @@ +package buildstep + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +type toolchainKeys map[string]delivery.Toolchain + +func (k toolchainKeys) put(key string, value any) (uint64, error) { + tc, err := delivery.DecodeToolchain(value.(json.RawMessage)) + if err != nil { + return 0, err + } + k[key] = tc + return uint64(len(k)), nil +} + +func guardFor(k toolchainKeys, installed string, probeErr error) Guard { + return Guard{Language: "go", Want: "1.27.0", Recipe: "r7", Node: "ace", Put: k.put, + Probe: func(context.Context) (string, error) { return installed, probeErr }} +} + +// 1473/AC1: the probe answers the recipe's version: nil, and .go holds that version and the recipe's. +func TestCheckPassesAndWritesTheToolchain(t *testing.T) { + k := toolchainKeys{} + if err := guardFor(k, "1.27.0", nil).Check(context.Background()); err != nil { + t.Fatal(err) + } + tc, ok := k["ace.go"] + if !ok || tc.Version != "1.27.0" || tc.Recipe != "r7" || tc.Language != "go" || tc.At.IsZero() { + t.Errorf("keys = %+v", k) + } +} + +// 1473/AC2: another version: a Refusal naming both, and the key holds the installed version. +func TestCheckRefusesAnotherVersionAndWritesTheInstalledOne(t *testing.T) { + k := toolchainKeys{} + err := guardFor(k, "1.27.1", nil).Check(context.Background()) + var ref *Refusal + if !errors.As(err, &ref) { + t.Fatalf("Check = %v, want a Refusal", err) + } + if ref.Text != "go 1.27.1 installed, the recipe builds with go 1.27.0" || ref.Want != "1.27.0" || ref.Have != "1.27.1" { + t.Errorf("refusal = %+v", ref) + } + if k["ace.go"].Version != "1.27.1" { + t.Errorf("the key holds %q, want the installed 1.27.1", k["ace.go"].Version) + } +} + +// 1473/AC3: a probe that fails: a Refusal naming the failure, and nothing put. +func TestCheckRefusesAFailedProbeAndPutsNothing(t *testing.T) { + k := toolchainKeys{} + err := guardFor(k, "", errors.New("go: not found")).Check(context.Background()) + var ref *Refusal + if !errors.As(err, &ref) || !strings.Contains(ref.Text, "go: not found") { + t.Fatalf("Check = %v, want a Refusal naming the probe's failure", err) + } + if len(k) != 0 { + t.Errorf("put %v", k) + } +} + +// A failed write is an error, not a refusal: the step fails instead of refusing. +func TestCheckAnswersAFailedWriteAsAnError(t *testing.T) { + g := guardFor(toolchainKeys{}, "1.27.0", nil) + g.Put = func(string, any) (uint64, error) { return 0, errors.New("no grant") } + err := g.Check(context.Background()) + var ref *Refusal + if err == nil || errors.As(err, &ref) { + t.Errorf("Check = %v", err) + } +} diff --git a/go/buildstep/step_test.go b/go/buildstep/step_test.go new file mode 100644 index 0000000..6e7f998 --- /dev/null +++ b/go/buildstep/step_test.go @@ -0,0 +1,154 @@ +package buildstep + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// keys is a steps state in memory: every value put, in order, per key, and a signal for each end. +type keys struct { + mu sync.Mutex + puts map[string][]delivery.Step + ended chan string +} + +func newKeys() *keys { return &keys{puts: map[string][]delivery.Step{}, ended: make(chan string, 16)} } + +func (k *keys) put(key string, value any) (uint64, error) { + step, err := delivery.DecodeStep(value.(json.RawMessage)) + if err != nil { + return 0, err + } + k.mu.Lock() + k.puts[key] = append(k.puts[key], step) + n := uint64(len(k.puts[key])) + k.mu.Unlock() + if step.State != delivery.StepRunning { + k.ended <- key + } + return n, nil +} + +func (k *keys) last(key string) delivery.Step { + k.mu.Lock() + defer k.mu.Unlock() + s := k.puts[key] + return s[len(s)-1] +} + +func (k *keys) waitEnd(t *testing.T) string { + t.Helper() + select { + case key := <-k.ended: + return key + case <-time.After(3 * time.Second): + t.Fatal("the step never ended on its key") + return "" + } +} + +// 1471/AC1: a run that blocks: Start answers an id within 100 ms and the key holds running. +func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) { + k := newKeys() + release := make(chan struct{}) + defer close(release) + began := time.Now() + id, err := Start(context.Background(), k.put, "ace", "fetch", func(ctx context.Context) (any, error) { + <-release + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if d := time.Since(began); d > 100*time.Millisecond { + t.Errorf("Start took %v", d) + } + s := k.last("ace." + id) + if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() { + t.Errorf("step = %+v", s) + } +} + +// 1471/AC2 and 1473/AC4: a result, an error and a Refusal end the step passed, failed and refused. +func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) { + for _, c := range []struct { + name string + run func(context.Context) (any, error) + state string + reason string + result string + }{ + {"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`}, + {"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""}, + {"refusal", func(context.Context) (any, error) { + return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"} + }, delivery.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""}, + } { + t.Run(c.name, func(t *testing.T) { + k := newKeys() + id, err := Start(context.Background(), k.put, "ace", "test", c.run) + if err != nil { + t.Fatal(err) + } + if got := k.waitEnd(t); got != "ace."+id { + t.Fatalf("ended key %q", got) + } + s := k.last("ace." + id) + if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() { + t.Errorf("step = %+v", s) + } + }) + } +} + +// 1471/AC3: a panic in run ends the step failed, naming it, and the test process goes on. +func TestAPanicEndsTheStepFailed(t *testing.T) { + k := newKeys() + id, _ := Start(context.Background(), k.put, "ace", "build", func(context.Context) (any, error) { panic("boom") }) + k.waitEnd(t) + s := k.last("ace." + id) + if s.State != delivery.StepFailed || s.Reason != "the step panicked: boom" { + t.Errorf("step = %+v", s) + } +} + +// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed +// while no step runs, so no goroutine reads it meanwhile. +func TestTwoIDsInOneMillisecondDiffer(t *testing.T) { + was := now + defer func() { now = was }() + at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + now = func() time.Time { return at } + seen := map[string]bool{} + for i := 0; i < 50; i++ { + id := NewID() + if seen[id] { + t.Fatalf("id %q handed out twice", id) + } + seen[id] = true + if _, err := delivery.StepKey("ace", id); err != nil { + t.Errorf("id %q: %v", id, err) + } + } +} + +// A first write that fails starts nothing. +func TestStartAnswersTheErrorWhenTheRunningWriteFails(t *testing.T) { + ran := make(chan struct{}, 1) + _, err := Start(context.Background(), func(string, any) (uint64, error) { return 0, errors.New("no grant") }, "ace", "fetch", + func(context.Context) (any, error) { ran <- struct{}{}; return nil, nil }) + if err == nil { + t.Fatal("Start answered no error") + } + select { + case <-ran: + t.Error("run was called") + case <-time.After(50 * time.Millisecond): + } +}