banger/internal/daemon/opstate/registry_test.go
Thales Maciel 3c0af3a2de
opstate,daemon: list in-flight operations via daemon.operations.list
Prerequisite for `banger update`'s preflight, which refuses to swap
binaries while anything is in flight. Today's opstate.Registry
exposes Insert/Get/Prune but no iteration; without a snapshot
accessor the update flow can't tell whether a vm.create is
mid-prepare-work-disk.

  * opstate.Registry.List(): returns a freshly-allocated snapshot
    of every entry. Mutating the slice doesn't poison the
    registry. Pinned by tests covering the snapshot semantics
    and the empty case.
  * api.OperationSummary / OperationsListResult: a public-shape
    record per op. Today the Kind is always "vm.create" — the
    field exists so future async kinds (image.pull, kernel.pull)
    plug in without an API change.
  * Daemon.ListOperations + daemon.operations.list RPC:
    walks vmService.createOps and emits OperationSummary entries.
    Done ops are included in the snapshot; the update preflight
    filters by Done itself.
  * dispatch_test's documented-methods list updated.

No behaviour change for existing flows; this is a read-only
addition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:14:57 -03:00

114 lines
2.6 KiB
Go

package opstate
import (
"sync/atomic"
"testing"
"time"
)
type fakeOp struct {
id string
done atomic.Bool
updatedAt time.Time
canceled atomic.Bool
}
func (f *fakeOp) ID() string { return f.id }
func (f *fakeOp) IsDone() bool { return f.done.Load() }
func (f *fakeOp) UpdatedAt() time.Time { return f.updatedAt }
func (f *fakeOp) Cancel() { f.canceled.Store(true) }
func TestRegistryInsertAndGet(t *testing.T) {
var r Registry[*fakeOp]
op := &fakeOp{id: "op-1", updatedAt: time.Now()}
r.Insert(op)
got, ok := r.Get("op-1")
if !ok {
t.Fatal("Get after Insert missed")
}
if got.ID() != "op-1" {
t.Fatalf("Get().ID = %q", got.ID())
}
_, ok = r.Get("missing")
if ok {
t.Fatal("Get on missing key should miss")
}
}
func TestRegistryPruneDropsCompletedOldOps(t *testing.T) {
var r Registry[*fakeOp]
now := time.Now()
recent := &fakeOp{id: "recent", updatedAt: now}
recent.done.Store(true)
stale := &fakeOp{id: "stale", updatedAt: now.Add(-time.Hour)}
stale.done.Store(true)
pending := &fakeOp{id: "pending", updatedAt: now.Add(-time.Hour)}
// NOT done → stays even though old.
r.Insert(recent)
r.Insert(stale)
r.Insert(pending)
cutoff := now.Add(-time.Minute)
r.Prune(cutoff)
if _, ok := r.Get("stale"); ok {
t.Error("stale op should have been pruned")
}
if _, ok := r.Get("recent"); !ok {
t.Error("recent op should survive (newer than cutoff)")
}
if _, ok := r.Get("pending"); !ok {
t.Error("pending op should survive (not done)")
}
}
func TestRegistryListReturnsSnapshot(t *testing.T) {
var r Registry[*fakeOp]
now := time.Now()
a := &fakeOp{id: "a", updatedAt: now}
b := &fakeOp{id: "b", updatedAt: now}
c := &fakeOp{id: "c", updatedAt: now}
c.done.Store(true)
r.Insert(a)
r.Insert(b)
r.Insert(c)
got := r.List()
if len(got) != 3 {
t.Fatalf("List() returned %d entries, want 3", len(got))
}
ids := map[string]bool{}
for _, op := range got {
ids[op.ID()] = true
}
for _, want := range []string{"a", "b", "c"} {
if !ids[want] {
t.Errorf("List() missing %q; got %v", want, ids)
}
}
// Mutating the returned slice must not poison the registry.
got[0] = &fakeOp{id: "tampered"}
if _, ok := r.Get("tampered"); ok {
t.Error("List() returned the registry's internal map, not a copy")
}
}
func TestRegistryListEmpty(t *testing.T) {
var r Registry[*fakeOp]
if got := r.List(); len(got) != 0 {
t.Fatalf("List() on empty registry returned %d entries, want 0", len(got))
}
}
func TestRegistryPruneNoOpOnEmpty(t *testing.T) {
var r Registry[*fakeOp]
// Just shouldn't panic.
r.Prune(time.Now())
}