package store import ( "context" "errors" "testing" "time" "github.com/oklog/ulid/v2" ) func ptrStr(s string) *string { return &s } func seedFleetUser(t *testing.T, s *Store) string { t.Helper() id := ulid.Make().String() if err := s.CreateUser(context.Background(), User{ ID: id, Username: "u-" + id[:6], PasswordHash: "x", Role: RoleAdmin, }); err != nil { t.Fatalf("create user: %v", err) } return id } func seedFleetHost(t *testing.T, s *Store, name string) string { t.Helper() id := ulid.Make().String() if err := s.CreateHost(context.Background(), Host{ ID: id, Name: name, OS: "linux", Arch: "amd64", EnrolledAt: time.Now().UTC(), }, "tokenhash-"+id[:6], ""); err != nil { t.Fatalf("create host: %v", err) } return id } func TestCreateFleetUpdate_RefusesIfRunning(t *testing.T) { t.Parallel() s := openTestStore(t) uid := seedFleetUser(t, s) h1 := seedFleetHost(t, s, "h1") fu1 := FleetUpdate{ID: ulid.Make().String(), StartedByUserID: uid, TargetVersion: "v1"} if err := s.CreateFleetUpdate(context.Background(), fu1, []string{h1}); err != nil { t.Fatalf("create #1: %v", err) } fu2 := FleetUpdate{ID: ulid.Make().String(), StartedByUserID: uid, TargetVersion: "v2"} err := s.CreateFleetUpdate(context.Background(), fu2, []string{h1}) if !errors.Is(err, ErrFleetUpdateRunning) { t.Fatalf("want ErrFleetUpdateRunning, got %v", err) } } func TestCreateFleetUpdate_HydrateRoundTrip(t *testing.T) { t.Parallel() s := openTestStore(t) uid := seedFleetUser(t, s) h1 := seedFleetHost(t, s, "h1") h2 := seedFleetHost(t, s, "h2") fu := FleetUpdate{ID: ulid.Make().String(), StartedByUserID: uid, TargetVersion: "v1.2.3"} if err := s.CreateFleetUpdate(context.Background(), fu, []string{h1, h2}); err != nil { t.Fatal(err) } got, hosts, err := s.GetFleetUpdate(context.Background(), fu.ID) if err != nil { t.Fatal(err) } if got.Status != "running" || got.TargetVersion != "v1.2.3" { t.Fatalf("parent: %+v", got) } if len(hosts) != 2 || hosts[0].Position != 0 || hosts[1].Position != 1 { t.Fatalf("hosts: %+v", hosts) } if hosts[0].Status != "pending" || hosts[1].Status != "pending" { t.Fatalf("hosts status: %+v", hosts) } } func TestSetFleetUpdateHostStatus_ProgressesAndStoresJobID(t *testing.T) { t.Parallel() s := openTestStore(t) uid := seedFleetUser(t, s) h := seedFleetHost(t, s, "h1") fu := FleetUpdate{ID: ulid.Make().String(), StartedByUserID: uid, TargetVersion: "v1"} _ = s.CreateFleetUpdate(context.Background(), fu, []string{h}) jobID := ulid.Make().String() if err := s.CreateJob(context.Background(), Job{ ID: jobID, HostID: h, Kind: "update", ActorKind: "user", ActorID: ptrStr(uid), CreatedAt: time.Now().UTC(), }); err != nil { t.Fatal(err) } if err := s.SetFleetUpdateHostStatus(context.Background(), fu.ID, h, "running", "", ""); err != nil { t.Fatal(err) } if err := s.SetFleetUpdateHostStatus(context.Background(), fu.ID, h, "succeeded", "", jobID); err != nil { t.Fatal(err) } _, hs, _ := s.GetFleetUpdate(context.Background(), fu.ID) if hs[0].Status != "succeeded" || hs[0].JobID != jobID { t.Fatalf("after succeed: %+v", hs[0]) } pending, _ := s.ListPendingFleetUpdateHosts(context.Background(), fu.ID) if len(pending) != 0 { t.Fatalf("pending should be empty: %+v", pending) } } func TestHaltAndCompleteFleetUpdate(t *testing.T) { t.Parallel() s := openTestStore(t) uid := seedFleetUser(t, s) h := seedFleetHost(t, s, "h1") fu1 := FleetUpdate{ID: ulid.Make().String(), StartedByUserID: uid, TargetVersion: "v1"} _ = s.CreateFleetUpdate(context.Background(), fu1, []string{h}) if err := s.HaltFleetUpdate(context.Background(), fu1.ID, "boom", time.Now().UTC()); err != nil { t.Fatal(err) } got, _, _ := s.GetFleetUpdate(context.Background(), fu1.ID) if got.Status != "halted" || got.HaltedReason != "boom" { t.Fatalf("after halt: %+v", got) } if got.CompletedAt == nil { t.Fatal("halted must stamp completed_at") } if active, _ := s.ActiveFleetUpdate(context.Background()); active != nil { t.Fatalf("halted should clear active: %+v", active) } // Now a fresh run can start. fu2 := FleetUpdate{ID: ulid.Make().String(), StartedByUserID: uid, TargetVersion: "v2"} if err := s.CreateFleetUpdate(context.Background(), fu2, []string{h}); err != nil { t.Fatalf("create after halt: %v", err) } if err := s.CompleteFleetUpdate(context.Background(), fu2.ID, time.Now().UTC()); err != nil { t.Fatal(err) } got, _, _ = s.GetFleetUpdate(context.Background(), fu2.ID) if got.Status != "completed" { t.Fatalf("after complete: %+v", got) } } func TestRunningUpdateJobForHost(t *testing.T) { t.Parallel() s := openTestStore(t) h := seedFleetHost(t, s, "h1") got, err := s.RunningUpdateJobForHost(context.Background(), h) if err != nil || got != "" { t.Fatalf("empty case: got=%q err=%v", got, err) } jobID := ulid.Make().String() if err := s.CreateJob(context.Background(), Job{ ID: jobID, HostID: h, Kind: "update", ActorKind: "user", ActorID: ptrStr("u-1"), CreatedAt: time.Now().UTC(), }); err != nil { t.Fatal(err) } got, err = s.RunningUpdateJobForHost(context.Background(), h) if err != nil || got != jobID { t.Fatalf("queued case: got=%q err=%v", got, err) } // Mark succeeded → no longer "in flight". if err := s.MarkJobFinished(context.Background(), jobID, "succeeded", 0, nil, "", time.Now().UTC()); err != nil { t.Fatal(err) } got, err = s.RunningUpdateJobForHost(context.Background(), h) if err != nil || got != "" { t.Fatalf("after succeed: got=%q err=%v", got, err) } }