| index 75ca2163d..ca8195b51 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -3,6 +3,7 @@ package status_test |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "strings" |
| "testing" |
| "time" |
| @@ -12,6 +13,24 @@ import ( |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| |
| +// flakyPutStore wraps a real release.Store and makes its first N Put calls |
| +// return release.ErrCASConflict before delegating — a stand-in for another |
| +// writer racing in between WriteBox's Get-etag and Put. Every other method |
| +// (Get, List) is the embedded Store's real behavior. |
| +type flakyPutStore struct { |
| + release.Store |
| + failCount int // Put calls left to fail before delegating |
| + putCalls int |
| +} |
| + |
| +func (f *flakyPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + f.putCalls++ |
| + if f.putCalls <= f.failCount { |
| + return "", release.ErrCASConflict |
| + } |
| + return f.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| func TestBandBoundaries(t *testing.T) { |
| f := func(v float64) *float64 { return &v } |
| cases := []struct { |
| @@ -58,6 +77,35 @@ func TestSnapshotJSONCarriesNoHostnames(t *testing.T) { |
| } |
| } |
| |
| +// TestRouteCountsJSONKeysAreSnakeCase locks RouteCounts' wire shape to the |
| +// design spec's fleet.json example (docs/superpowers/specs/2026-08-01-fleet- |
| +// status-page-design.md §2: {"static":2,"resident":3,"hibernating":4, |
| +// "awake":1,"asleep":3}) — lowercase snake_case, not Go's default |
| +// capitalized field names. |
| +func TestRouteCountsJSONKeysAreSnakeCase(t *testing.T) { |
| + routes := []registry.Route{ |
| + {Host: "a", Kind: "static"}, |
| + {Host: "b", Kind: "instance"}, |
| + {Host: "c", Kind: "instance", Hibernate: true, Socket: "/c.sock"}, |
| + } |
| + listening := func(string) *bool { v := true; return &v } |
| + b := status.CollectBox(routes, status.Probes{MachineID: "m", Listening: listening}, time.Now()) |
| + j, err := json.Marshal(b.Routes) |
| + if err != nil { |
| + t.Fatalf("marshal RouteCounts: %v", err) |
| + } |
| + for _, key := range []string{`"static"`, `"resident"`, `"hibernating"`, `"awake"`, `"asleep"`} { |
| + if !strings.Contains(string(j), key) { |
| + t.Errorf("RouteCounts JSON %s missing key %s", j, key) |
| + } |
| + } |
| + for _, key := range []string{`"Static"`, `"Resident"`, `"Hibernating"`, `"Awake"`, `"Asleep"`} { |
| + if strings.Contains(string(j), key) { |
| + t.Errorf("RouteCounts JSON %s carries capitalized Go field name %s", j, key) |
| + } |
| + } |
| +} |
| + |
| // TestWriteBoxPutsUnderLabeledKey isn't in the plan's Step-1 listing, but |
| // WriteBox is part of the exported contract Task 3 relies on — worth locking |
| // down that it actually lands the box at the key CollectBox's label implies. |
| @@ -117,3 +165,47 @@ func TestWriteBoxOverwritesOnSecondTick(t *testing.T) { |
| t.Errorf("stored box has GeneratedAt %v, want the second write's %v (second write must win)", round.GeneratedAt, second.GeneratedAt) |
| } |
| } |
| + |
| +// TestWriteBoxRetriesOnceOnCASConflict exercises the retry branch directly: |
| +// the first Put loses the CAS race (something else wrote between WriteBox's |
| +// Get and its Put), so WriteBox must re-read the etag and retry exactly |
| +// once — and that retry must succeed. |
| +func TestWriteBoxRetriesOnceOnCASConflict(t *testing.T) { |
| + dir := t.TempDir() |
| + real := release.NewDirStore(dir) |
| + flaky := &flakyPutStore{Store: real, failCount: 1} |
| + b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now()) |
| + |
| + if err := status.WriteBox(context.Background(), flaky, b); err != nil { |
| + t.Fatalf("WriteBox with one transient conflict: %v", err) |
| + } |
| + if flaky.putCalls != 2 { |
| + t.Errorf("Put called %d times, want exactly 2 (fail once, retry once)", flaky.putCalls) |
| + } |
| + |
| + key := "status/boxes/" + b.Label + ".json" |
| + if _, _, err := real.Get(context.Background(), key); err != nil { |
| + t.Fatalf("Get %s after retry: %v", key, err) |
| + } |
| +} |
| + |
| +// TestWriteBoxReturnsErrorOnDoubleCASConflict proves WriteBox does NOT retry |
| +// a second time: if the retried Put also loses the race, that error must |
| +// reach the caller rather than being swallowed or retried forever. |
| +func TestWriteBoxReturnsErrorOnDoubleCASConflict(t *testing.T) { |
| + dir := t.TempDir() |
| + real := release.NewDirStore(dir) |
| + flaky := &flakyPutStore{Store: real, failCount: 2} |
| + b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now()) |
| + |
| + err := status.WriteBox(context.Background(), flaky, b) |
| + if err == nil { |
| + t.Fatal("WriteBox: want an error after two consecutive CAS conflicts, got nil") |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Errorf("WriteBox error = %v, want it to wrap release.ErrCASConflict", err) |
| + } |
| + if flaky.putCalls != 2 { |
| + t.Errorf("Put called %d times, want exactly 2 (no third attempt)", flaky.putCalls) |
| + } |
| +} |