package evolve import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" ) func newStore(t *testing.T) *FileParameterStore { t.Helper() s, err := NewFileParameterStore(t.TempDir()) if err != nil { t.Fatalf("NewFileParameterStore: %v", err) } return s } func TestFileParameterStore_GetSet(t *testing.T) { s := newStore(t) ctx := context.Background() if _, _, err := s.Get(ctx, "k"); !errors.Is(err, ErrParameterNotFound) { t.Errorf("Get missing: want ErrParameterNotFound, got %v", err) } v, err := s.Set(ctx, "k", 42, "init") if err != nil { t.Fatal(err) } if v != 1 { t.Errorf("first version: want 1, got %d", v) } val, ver, err := s.Get(ctx, "k") if err != nil { t.Fatal(err) } if ver != 1 { t.Errorf("ver: want 1, got %d", ver) } // JSON roundtrip turns numeric literals into float64. if got, _ := val.(float64); got != 42 { t.Errorf("val: want 42, got %v", val) } v2, err := s.Set(ctx, "k", 100, "update") if err != nil { t.Fatal(err) } if v2 != 2 { t.Errorf("second version: want 2, got %d", v2) } } func TestFileParameterStore_ReasonRequired(t *testing.T) { s := newStore(t) ctx := context.Background() if _, err := s.Set(ctx, "k", 1, ""); !errors.Is(err, ErrReasonRequired) { t.Errorf("Set empty reason: %v", err) } if _, err := s.Set(ctx, "k", 1, "init"); err != nil { t.Fatal(err) } if _, err := s.Rollback(ctx, "k", 1, ""); !errors.Is(err, ErrReasonRequired) { t.Errorf("Rollback empty reason: %v", err) } if err := s.Lock(ctx, "k", ""); !errors.Is(err, ErrReasonRequired) { t.Errorf("Lock empty reason: %v", err) } if err := s.Unlock(ctx, "k", ""); !errors.Is(err, ErrReasonRequired) { t.Errorf("Unlock empty reason: %v", err) } } func TestFileParameterStore_KeyEscaping(t *testing.T) { s := newStore(t) ctx := context.Background() key := "evolve.carrier_risk_penalty/Y-express" if _, err := s.Set(ctx, key, 0.5, "init"); err != nil { t.Fatal(err) } if _, _, err := s.Get(ctx, key); err != nil { t.Fatal(err) } keys, err := s.List(ctx, "") if err != nil { t.Fatal(err) } if len(keys) != 1 || keys[0] != key { t.Errorf("List: %v", keys) } } func TestFileParameterStore_History(t *testing.T) { s := newStore(t) ctx := context.Background() if _, err := s.History(ctx, "missing", 0); !errors.Is(err, ErrParameterNotFound) { t.Errorf("History missing: %v", err) } for i := 1; i <= 5; i++ { if _, err := s.Set(ctx, "k", i, fmt.Sprintf("v%d", i)); err != nil { t.Fatal(err) } } h, err := s.History(ctx, "k", 0) if err != nil { t.Fatal(err) } if len(h) != 5 { t.Errorf("History len: %d", len(h)) } for i, c := range h { if c.Version != i+1 { t.Errorf("entry[%d].Version=%d", i, c.Version) } if c.Author != "user" { t.Errorf("entry[%d].Author=%q", i, c.Author) } } h3, err := s.History(ctx, "k", 3) if err != nil { t.Fatal(err) } if len(h3) != 3 { t.Errorf("limit 3: %d", len(h3)) } if h3[0].Version != 3 { t.Errorf("limit 3 first version: %d", h3[0].Version) } } func TestFileParameterStore_Rollback(t *testing.T) { s := newStore(t) ctx := context.Background() for i := 1; i <= 3; i++ { if _, err := s.Set(ctx, "k", i, fmt.Sprintf("v%d", i)); err != nil { t.Fatal(err) } } newV, err := s.Rollback(ctx, "k", 1, "bad release") if err != nil { t.Fatal(err) } if newV != 4 { t.Errorf("rollback creates new version: want 4, got %d", newV) } val, ver, _ := s.Get(ctx, "k") if ver != 4 { t.Errorf("ver: %d", ver) } if got, _ := val.(float64); got != 1 { t.Errorf("val after rollback: %v", val) } h, _ := s.History(ctx, "k", 0) if len(h) != 4 { t.Errorf("history len: %d", len(h)) } if !strings.Contains(h[3].Reason, "rollback to v1") { t.Errorf("rollback reason: %q", h[3].Reason) } if h[3].Author != "rollback" { t.Errorf("rollback author: %q", h[3].Author) } if _, err := s.Rollback(ctx, "k", 99, "x"); !errors.Is(err, ErrVersionNotFound) { t.Errorf("rollback missing version: %v", err) } } func TestFileParameterStore_Lock(t *testing.T) { s := newStore(t) ctx := context.Background() if _, err := s.Set(ctx, "k", 1, "init"); err != nil { t.Fatal(err) } if err := s.Lock(ctx, "k", "freeze for audit"); err != nil { t.Fatal(err) } if _, err := s.Set(ctx, "k", 2, "x"); !errors.Is(err, ErrParameterLocked) { t.Errorf("Set on locked: %v", err) } // Rollback is allowed on locked. newV, err := s.Rollback(ctx, "k", 1, "rollback while locked") if err != nil { t.Errorf("Rollback on locked: %v", err) } if newV != 2 { t.Errorf("rollback version: %d", newV) } if err := s.Unlock(ctx, "k", "audit done"); err != nil { t.Fatal(err) } if _, err := s.Set(ctx, "k", 99, "after unlock"); err != nil { t.Errorf("Set after unlock: %v", err) } // Idempotent unlock. if err := s.Unlock(ctx, "k", "again"); err != nil { t.Errorf("idempotent unlock: %v", err) } } func TestFileParameterStore_LockNonexistent(t *testing.T) { s := newStore(t) if err := s.Lock(context.Background(), "ghost", "reason"); !errors.Is(err, ErrParameterNotFound) { t.Errorf("Lock nonexistent: %v", err) } } func TestFileParameterStore_List(t *testing.T) { s := newStore(t) ctx := context.Background() for _, k := range []string{"evolve.a", "evolve.b", "other.c"} { if _, err := s.Set(ctx, k, 1, "init"); err != nil { t.Fatal(err) } } all, err := s.List(ctx, "") if err != nil { t.Fatal(err) } if len(all) != 3 { t.Errorf("all: %v", all) } pre, err := s.List(ctx, "evolve.") if err != nil { t.Fatal(err) } if len(pre) != 2 { t.Errorf("prefix: %v", pre) } } func TestFileParameterStore_Watch(t *testing.T) { s := newStore(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() ch, err := s.Watch(ctx, "evolve.") if err != nil { t.Fatal(err) } chAll, _ := s.Watch(ctx, "") if _, err := s.Set(ctx, "evolve.x", 1, "init"); err != nil { t.Fatal(err) } if _, err := s.Set(ctx, "other.y", 1, "init"); err != nil { t.Fatal(err) } select { case evt := <-ch: if evt.Key != "evolve.x" || evt.IsLock { t.Errorf("evt: %+v", evt) } case <-time.After(time.Second): t.Error("no event for evolve.x") } got := 0 for got < 2 { select { case <-chAll: got++ case <-time.After(time.Second): t.Errorf("chAll only got %d events", got) return } } cancel() deadline := time.After(time.Second) for { select { case _, ok := <-ch: if !ok { return } case <-deadline: t.Error("channel did not close after cancel") return } } } func TestFileParameterStore_WatchLockEvent(t *testing.T) { s := newStore(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if _, err := s.Set(ctx, "k", 1, "init"); err != nil { t.Fatal(err) } ch, _ := s.Watch(ctx, "") if err := s.Lock(ctx, "k", "freeze"); err != nil { t.Fatal(err) } select { case evt := <-ch: if !evt.IsLock || evt.Change.Author != "lock" { t.Errorf("expect lock event: %+v", evt) } case <-time.After(time.Second): t.Error("no lock event") } if err := s.Unlock(ctx, "k", "thaw"); err != nil { t.Fatal(err) } select { case evt := <-ch: if !evt.IsLock || evt.Change.Author != "unlock" { t.Errorf("expect unlock event: %+v", evt) } case <-time.After(time.Second): t.Error("no unlock event") } } func TestFileParameterStore_AtomicConcurrent(t *testing.T) { s := newStore(t) ctx := context.Background() if _, err := s.Set(ctx, "k", 0, "init"); err != nil { t.Fatal(err) } var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(i int) { defer wg.Done() _, _ = s.Set(ctx, "k", i, fmt.Sprintf("write %d", i)) }(i) } wg.Wait() _, ver, _ := s.Get(ctx, "k") if ver != 11 { t.Errorf("expected version 11 after 1+10 writes, got %d", ver) } h, _ := s.History(ctx, "k", 0) if len(h) != 11 { t.Errorf("expected 11 history entries, got %d", len(h)) } for i, c := range h { if c.Version != i+1 { t.Errorf("entry[%d].Version=%d", i, c.Version) } } } func TestFileParameterStore_Persistence(t *testing.T) { dir := t.TempDir() s1, err := NewFileParameterStore(dir) if err != nil { t.Fatal(err) } if _, err := s1.Set(context.Background(), "k", "hello", "init"); err != nil { t.Fatal(err) } s2, err := NewFileParameterStore(dir) if err != nil { t.Fatal(err) } val, ver, err := s2.Get(context.Background(), "k") if err != nil { t.Fatal(err) } if got, _ := val.(string); got != "hello" || ver != 1 { t.Errorf("persistence: val=%v ver=%d", val, ver) } } func TestFileParameterStore_FilesOnDisk(t *testing.T) { dir := t.TempDir() s, _ := NewFileParameterStore(dir) ctx := context.Background() if _, err := s.Set(ctx, "evolve.x", 1, "init"); err != nil { t.Fatal(err) } if _, err := s.Set(ctx, "evolve.x", 2, "v2"); err != nil { t.Fatal(err) } if err := s.Lock(ctx, "evolve.x", "freeze"); err != nil { t.Fatal(err) } keyDir := filepath.Join(dir, "evolve.x") for _, p := range []string{ filepath.Join(keyDir, "current.json"), filepath.Join(keyDir, "history", "v1.json"), filepath.Join(keyDir, "history", "v2.json"), filepath.Join(keyDir, "lock.json"), } { if _, err := os.Stat(p); err != nil { t.Errorf("missing file %s: %v", p, err) } } } func TestFileParameterStore_CorruptCurrent(t *testing.T) { dir := t.TempDir() s, _ := NewFileParameterStore(dir) ctx := context.Background() if _, err := s.Set(ctx, "k", 1, "init"); err != nil { t.Fatal(err) } cur := filepath.Join(dir, "k", "current.json") if err := os.WriteFile(cur, []byte("{not json"), 0o644); err != nil { t.Fatal(err) } if _, _, err := s.Get(ctx, "k"); err == nil || strings.Contains(err.Error(), "not found") { t.Errorf("expected decode error, got %v", err) } if _, err := s.Set(ctx, "k", 2, "after corrupt"); err == nil { t.Error("Set should propagate decode error from readCurrent") } } func TestFileParameterStore_CorruptHistory(t *testing.T) { dir := t.TempDir() s, _ := NewFileParameterStore(dir) ctx := context.Background() if _, err := s.Set(ctx, "k", 1, "init"); err != nil { t.Fatal(err) } h1 := filepath.Join(dir, "k", "history", "v1.json") if err := os.WriteFile(h1, []byte("garbage"), 0o644); err != nil { t.Fatal(err) } if _, err := s.History(ctx, "k", 0); err == nil { t.Error("History should fail on corrupt entry") } if _, err := s.Rollback(ctx, "k", 1, "x"); err == nil { t.Error("Rollback should fail on corrupt target") } } func TestFileParameterStore_ListSkipsStaleDirs(t *testing.T) { dir := t.TempDir() s, _ := NewFileParameterStore(dir) ctx := context.Background() if _, err := s.Set(ctx, "real", 1, "init"); err != nil { t.Fatal(err) } // Stale dir without current.json (e.g. half-deleted entry). if err := os.MkdirAll(filepath.Join(dir, "stale"), 0o755); err != nil { t.Fatal(err) } keys, err := s.List(ctx, "") if err != nil { t.Fatal(err) } if len(keys) != 1 || keys[0] != "real" { t.Errorf("expected ['real'], got %v", keys) } } func TestFileParameterStore_HistoryEmptyWhenDirMissing(t *testing.T) { dir := t.TempDir() s, _ := NewFileParameterStore(dir) ctx := context.Background() if _, err := s.Set(ctx, "k", 1, "init"); err != nil { t.Fatal(err) } // Delete history dir but keep current.json -> History returns empty slice. if err := os.RemoveAll(filepath.Join(dir, "k", "history")); err != nil { t.Fatal(err) } h, err := s.History(ctx, "k", 0) if err != nil { t.Fatal(err) } if len(h) != 0 { t.Errorf("expected empty history, got %d entries", len(h)) } } // Compile-time assertion that *FileParameterStore satisfies ParameterStore. var _ ParameterStore = (*FileParameterStore)(nil)