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): } }