package main import ( "context" "encoding/json" "strings" "testing" "time" "github.com/novox/mesh-controller/internal/inventory" ) // A plan as the store would answer it at each read: the reads are counted, and each answers the next. type planReads struct { plans []inventory.Plan n int } func (r *planReads) read(context.Context) (inventory.Plan, error) { p := r.plans[min(r.n, len(r.plans)-1)] r.n++ return p, nil } func followedPlan(rev int64, state string, tier int) inventory.Plan { return inventory.Plan{ID: "plan-1", Repository: "novox/x", Commit: "abc", State: state, Tier: tier, Tiers: [][]string{{"a"}, {"b"}}, Revision: rev} } // **`plans` waits for a plan, and says what ended the wait** (novox/hq issue 229): an agent following a // rollout is told when the plan moved, reached its next tier or ended, or that the bound passed — in one // call, without a loop of its own. func TestAWaitForAPlanEndsWhenWhatItWaitsForHappens(t *testing.T) { cases := []struct { name string until string reads []inventory.Plan came string times int }{ {"moved: any save", "moved", []inventory.Plan{ followedPlan(1, inventory.PlanBuilding, 0), followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanBuilding, 0)}, "moved", 3}, {"tier: a save inside the tier is not enough", "tier", []inventory.Plan{ followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanBuilding, 0), followedPlan(3, inventory.PlanRolling, 1)}, "tier", 3}, {"tier: a plan that ended has no next tier to wait for", "tier", []inventory.Plan{ followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanFailed, 0)}, "done", 2}, {"done: a new tier is not the end", "done", []inventory.Plan{ followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanRolling, 1), followedPlan(3, inventory.PlanDone, 1)}, "done", 3}, {"done: a plan already ended answers at once", "done", []inventory.Plan{ followedPlan(4, inventory.PlanSuperseded, 1)}, "done", 1}, {"the bound passes", "moved", []inventory.Plan{followedPlan(1, inventory.PlanBuilding, 0)}, "bound", -1}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { r := &planReads{plans: c.reads} p, came, err := waitOnPlan(context.Background(), r.read, c.until, 50*time.Millisecond, time.Millisecond) if err != nil { t.Fatal(err) } if came != c.came { t.Fatalf("the wait ended as %q, want %q", came, c.came) } if c.times > 0 && r.n != c.times { t.Errorf("read the plan %d times, want %d: it answered before or after what it waited for", r.n, c.times) } if want := c.reads[min(r.n, len(c.reads))-1]; p.Revision != want.Revision { t.Errorf("answered revision %d, not the plan as it was last read (%d)", p.Revision, want.Revision) } }) } } // What a wait is asked for is checked before anything is read: what it waits until, and a bound no longer than // a call is answered within. func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) { for _, bad := range []struct{ until, bound string }{{"landed", ""}, {"", "1h"}, {"", "-3s"}, {"", "soon"}} { if _, _, err := planWaitAsked(bad.until, bad.bound); err == nil { t.Errorf("until %q, bound %q was taken", bad.until, bad.bound) } } until, bound, err := planWaitAsked("", "") if err != nil || until != "moved" || bound != maxPlanWait { t.Fatalf("the defaults are %q %s %v, want moved and %s", until, bound, err, maxPlanWait) } if _, bound, _ := planWaitAsked("tier", "12"); bound != 12*time.Second { t.Errorf("a bare number is seconds; read %s", bound) } } // **The answer is data, with its prose as a field beside it** (novox/hq issue 229): the verb's `answer` carries // the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string. func TestAWaitForAPlanAnswersAsData(t *testing.T) { p := followedPlan(7, inventory.PlanRolling, 1) st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0] doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2") stdout, _ := json.Marshal(doc) a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true) data, ok := a.Answer.(map[string]any) if !ok { t.Fatalf("the answer is not data: %v", a.Answer) } if data["came"] != "tier" || data["revision"] != float64(7) { t.Errorf("the data does not say what ended the wait: %v", data) } if plan, _ := data["plan"].(map[string]any); plan["state"] != "rolling" || plan["tier"] != float64(1) { t.Errorf("the data does not carry the plan: %v", data["plan"]) } if _, text := data["text"]; text { t.Errorf("the prose is in the data too: %v", data) } if strings.Contains(a.Output, "{") || !strings.Contains(a.Output, "plan-1") || !strings.Contains(a.Output, "tier") { t.Errorf("the output is not the line a person reads: %q", a.Output) } } // The verb composes the wait, and asks for a window as long as its bound. func TestThePlansVerbComposesAWait(t *testing.T) { argv, err := argvFor("plans", map[string]any{"wait": "plan-1", "until": "done", "bound": "15s"}) if err != nil { t.Fatal(err) } if got := strings.Join(argv, " "); got != "plans wait plan-1 --until done --bound 15s" { t.Fatalf("composed %q", got) } if _, err := argvFor("plans", map[string]any{"until": "done"}); err == nil { t.Error("until without wait was taken") } if _, err := argvFor("plans", map[string]any{"wait": "plan-1", "bound": "1h"}); err == nil { t.Error("a bound past what a call can wait was taken") } } // Live, with the store: `plans wait` run as the verb runs it answers when the plan reaches its next tier, as // data, and says the bound passed when nothing moved (novox/hq issue 229). func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) { open := aMesh(t) ctx := t.Context() // Read every 50ms rather than every second, so the wait answers as soon as the plan moves and the bound // below passes in a fraction of a second (novox/hq issue 591); the move still waits 1.5s, long enough // for the wait to have read the plan before it. was := planWaitEvery planWaitEvery = 50 * time.Millisecond t.Cleanup(func() { planWaitEvery = was }) now := time.Now().UTC() walk := inventory.Plan{ID: "plan-229", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c229c229", Created: now, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}}, Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}} if err := open.inventory.SavePlan(ctx, &walk); err != nil { t.Fatal(err) } moved := make(chan error, 1) go func() { time.Sleep(1500 * time.Millisecond) p, err := open.inventory.PlanByID(ctx, walk.ID) if err == nil { p.Tier, p.State = 1, inventory.PlanRolling err = open.inventory.SavePlan(ctx, &p) } moved <- err }() began := time.Now() var got planWaited out := printed(t, func() error { return plansCommand(ctx, []string{"wait", walk.ID, "--until", "tier", "--bound", "15s"}) }) if err := <-moved; err != nil { t.Fatal(err) } if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("plans wait did not answer data: %v: %q", err, out) } if got.Came != "tier" || got.Plan.Tier != 1 || got.Plan.State != inventory.PlanRolling { t.Fatalf("the wait answered %+v", got) } if waited := time.Since(began); waited > 3*time.Second { t.Errorf("it answered after %s, not when the plan moved", waited) } out = printed(t, func() error { return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "300ms"}) }) if err := json.Unmarshal([]byte(out), &got); err != nil || got.Came != "bound" || got.Until != "moved" { t.Fatalf("a plan that did not move answered %+v (%v)", got, err) } } // The verb's schema says the longest bound in words, and the words are the value (review 75 on #266): the // catalogue cannot read maxPlanWait, so this holds them together. func TestThePlansSchemaSaysTheLongestBoundThereIs(t *testing.T) { v, ok := controllerVerb("plans") if !ok { t.Fatal("no plans verb") } props, _ := v.Input["properties"].(map[string]any) bound, _ := props["bound"].(map[string]any) said, _ := bound["description"].(string) if !strings.Contains(said, maxPlanWait.String()+", the default, is the longest") { t.Fatalf("the schema says %q; the longest bound is %s", said, maxPlanWait) } }