package release import ( "context" "encoding/json" "errors" "testing" ) func TestChangeScope(t *testing.T) { cases := map[string]string{ "apps/bab/shop/instances/a.example.com.json": "apps/bab/shop", "apps/bab/shop/config-gen.json": "apps/bab/shop", "apps/bab/shop/channels/canary/x": "apps/bab/shop", "apps/bab/shop/sidecars.json": "apps/bab/shop", "apps/bab/shop/schedules.json": "apps/bab/shop", "accounts/bab/account.json": "accounts/bab", "accounts/bab/telemetry.json": "accounts/bab", "control/certs/example.com": "control/certs", "control/edges/box-a.json": "control/edges", } for k, want := range cases { got, ok := ChangeScope(k) if !ok || got != want { t.Errorf("ChangeScope(%q) = %q,%v want %q", k, got, ok, want) } } for _, k := range []string{ "apps/bab/shop/logs/box/2026-10-21-10/x", "apps/bab/shop/errors/x", "apps/bab/shop/analytics/x", "apps/bab/shop/runs/r1/request.json", "apps/bab/shop/metrics/x", "apps/bab/shop/schedule-status/shop.example.com.json", "apps/bab/shop", "apps/bab/", "apps/", ChangeIndexKey, "control/serial", "control/edge-mesh-key", "control/edge-mesh-key.next", "releases/x", "status/boxes/a.json", "fleets/f/apps/bab/shop/config.json", } { if s, ok := ChangeScope(k); ok { t.Errorf("ChangeScope(%q) = %q, want none", k, s) } } } // bumpFuncs adapts a Store to the raw get/put pair bumpChangeIndex takes. func bumpFuncs(s Store) (func(context.Context, string) ([]byte, string, error), func(context.Context, string, []byte, string) (string, error)) { return s.Get, s.Put } func TestBumpChangeIndexCreatesThenIncrements(t *testing.T) { t.Parallel() ctx := context.Background() s := NewDirStore(t.TempDir()) get, put := bumpFuncs(s) if err := bumpChangeIndex(ctx, get, put, "control/certs"); err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if err := bumpChangeIndex(ctx, get, put, "apps/bab/shop"); err != nil { t.Fatal(err) } } idx, etag, err := ReadChangeIndex(ctx, s) if err != nil { t.Fatal(err) } if etag == "" || idx.Schema != 1 { t.Fatalf("etag %q schema %d", etag, idx.Schema) } if idx.Entries["apps/bab/shop"] != 2 || idx.Entries["control/certs"] != 1 || len(idx.Entries) != 2 { t.Fatalf("entries = %v", idx.Entries) } } func TestBumpChangeIndexAppliesAllScopesInOneWrite(t *testing.T) { t.Parallel() ctx := context.Background() s := NewDirStore(t.TempDir()) puts := 0 get := s.Get put := func(ctx context.Context, k string, b []byte, m string) (string, error) { puts++ return s.Put(ctx, k, b, m) } if err := bumpChangeIndex(ctx, get, put, "apps/bab/shop", "accounts/bab", "apps/bab/shop"); err != nil { t.Fatal(err) } idx, _, _ := ReadChangeIndex(ctx, s) if puts != 1 || idx.Entries["accounts/bab"] != 1 || idx.Entries["apps/bab/shop"] != 1 { t.Fatalf("puts %d entries %v", puts, idx.Entries) } } func TestBumpChangeIndexRetriesOnConflict(t *testing.T) { t.Parallel() ctx := context.Background() s := NewDirStore(t.TempDir()) puts := 0 put := func(ctx context.Context, k string, b []byte, m string) (string, error) { puts++ if puts <= 2 { return "", ErrCASConflict } return s.Put(ctx, k, b, m) } if err := bumpChangeIndex(ctx, s.Get, put, "apps/bab/shop"); err != nil { t.Fatal(err) } idx, _, _ := ReadChangeIndex(ctx, s) if puts != 3 || idx.Entries["apps/bab/shop"] != 1 { t.Fatalf("puts %d entries %v", puts, idx.Entries) } } func TestBumpChangeIndexGivesUpAfterFourAttempts(t *testing.T) { t.Parallel() s := NewDirStore(t.TempDir()) puts := 0 put := func(context.Context, string, []byte, string) (string, error) { puts++ return "", ErrCASConflict } err := bumpChangeIndex(context.Background(), s.Get, put, "apps/bab/shop") if err == nil || !errors.Is(err, ErrCASConflict) { t.Fatalf("err = %v", err) } if puts != 4 { t.Fatalf("puts = %d, want 4", puts) } } func TestBumpChangeIndexRefusesUnknownSchema(t *testing.T) { t.Parallel() ctx := context.Background() s := NewDirStore(t.TempDir()) if _, err := s.Put(ctx, ChangeIndexKey, []byte(`{"schema":2,"entries":{}}`), ""); err != nil { t.Fatal(err) } puts := 0 put := func(ctx context.Context, k string, b []byte, m string) (string, error) { puts++ return s.Put(ctx, k, b, m) } err := bumpChangeIndex(ctx, s.Get, put, "apps/bab/shop") if !errors.Is(err, ErrChangeIndexSchema) || puts != 0 { t.Fatalf("err = %v, puts = %d", err, puts) } } func TestReadChangeIndexAbsentIsEmpty(t *testing.T) { t.Parallel() idx, etag, err := ReadChangeIndex(context.Background(), NewDirStore(t.TempDir())) if err != nil { t.Fatal(err) } if idx.Schema != 1 || idx.Entries == nil || len(idx.Entries) != 0 || etag != "" { t.Fatalf("idx %+v etag %q", idx, etag) } } func TestReadChangeIndexMalformedAndSchema(t *testing.T) { t.Parallel() ctx := context.Background() s := NewDirStore(t.TempDir()) if _, err := s.Put(ctx, ChangeIndexKey, []byte(`not json`), ""); err != nil { t.Fatal(err) } if _, _, err := ReadChangeIndex(ctx, s); err == nil || errors.Is(err, ErrChangeIndexSchema) { t.Fatalf("malformed err = %v", err) } body, _ := json.Marshal(ChangeIndex{Schema: 3}) if _, err := s.Put(ctx, ChangeIndexKey, body, mustEtag(t, s)); err != nil { t.Fatal(err) } if _, _, err := ReadChangeIndex(ctx, s); !errors.Is(err, ErrChangeIndexSchema) { t.Fatalf("schema err = %v", err) } } func mustEtag(t *testing.T, s Store) string { t.Helper() _, e, err := s.Get(context.Background(), ChangeIndexKey) if err != nil { t.Fatal(err) } return e }