| index 424f0c9e7..97f1a382f 100644 |
| --- a/internal/usage/edge_test.go |
| +++ b/internal/usage/edge_test.go |
| @@ -69,6 +69,82 @@ func TestUpsertEdgeDayMergesAcrossFlushes(t *testing.T) { |
| } |
| } |
| |
| +// The hop counters are an ADDITIVE shape change (the paid-for wire rule): |
| +// every day object already in a production bucket predates them, so one |
| +// written without them must still parse, fold and round-trip — and a |
| +// single-region box, which never sees a hop, must keep writing objects that |
| +// are byte-identical to what it wrote before the fields existed. |
| +func TestEdgeDayHopFieldsAdditive(t *testing.T) { |
| + // A day object as a pre-ruling binary wrote it: no hop keys at all. |
| + const old = `{"v":1,"box":"box1","date":"2026-08-24","hours":{"09":{"a.example.com":` + |
| + `{"account":"bab","app":"hello","requests":10,"bytes_out":1000}}}}` |
| + var day EdgeDay |
| + if err := json.Unmarshal([]byte(old), &day); err != nil { |
| + t.Fatalf("a day object written before the hop fields existed must still parse: %v", err) |
| + } |
| + row := day.Hours["09"]["a.example.com"] |
| + if row.Requests != 10 || row.BytesOut != 1000 || row.HopRequests != 0 || row.HopBytesOut != 0 { |
| + t.Fatalf("row = %+v, want the old counters and zero hop counters", row) |
| + } |
| + // And it folds a hop-bearing delta on top without losing the old counts. |
| + day.AddDeltas("09", map[string]EdgeRow{ |
| + "a.example.com": {Account: "bab", App: "hello", Requests: 3, BytesOut: 300, HopRequests: 2, HopBytesOut: 250}, |
| + }) |
| + row = day.Hours["09"]["a.example.com"] |
| + if row.Requests != 13 || row.BytesOut != 1300 || row.HopRequests != 2 || row.HopBytesOut != 250 { |
| + t.Fatalf("folded row = %+v", row) |
| + } |
| + |
| + // Round-trip: what a hop-annotating box writes, a hop-annotating box reads. |
| + raw, err := json.Marshal(day) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var back EdgeDay |
| + if err := json.Unmarshal(raw, &back); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := back.Hours["09"]["a.example.com"]; got != row { |
| + t.Fatalf("round-tripped row = %+v, want %+v", got, row) |
| + } |
| + |
| + // omitempty, the single-region promise: a row with no hop traffic writes |
| + // no hop keys, so a one-box deployment's objects do not change shape. |
| + plain, err := json.Marshal(EdgeRow{Account: "bab", App: "hello", Requests: 1, BytesOut: 10}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := `{"account":"bab","app":"hello","requests":1,"bytes_out":10}`; string(plain) != want { |
| + t.Fatalf("hop-free row marshals as %s, want %s — omitempty keeps single-region objects unchanged", plain, want) |
| + } |
| +} |
| + |
| +// The fold accumulates the hop counters across flushes exactly as it does the |
| +// totals — they are counters in the same hour bucket, not a snapshot. |
| +func TestEdgeDayHourFoldSumsHopFields(t *testing.T) { |
| + var day EdgeDay |
| + delta := map[string]EdgeRow{ |
| + "a.example.com": {Account: "bab", App: "hello", Requests: 4, BytesOut: 400, HopRequests: 1, HopBytesOut: 100}, |
| + } |
| + day.AddDeltas("09", delta) |
| + day.AddDeltas("09", delta) |
| + day.AddDeltas("10", delta) |
| + |
| + if got := day.Hours["09"]["a.example.com"]; got.Requests != 8 || got.BytesOut != 800 || |
| + got.HopRequests != 2 || got.HopBytesOut != 200 { |
| + t.Fatalf("hour 09 row = %+v, want two flushes summed", got) |
| + } |
| + if got := day.Hours["10"]["a.example.com"]; got.HopRequests != 1 || got.HopBytesOut != 100 { |
| + t.Fatalf("hour 10 row = %+v, want the third flush in its own bucket", got) |
| + } |
| + // The subset invariant, stated where a future reader will meet it: hop |
| + // bytes live INSIDE bytes_out, so a consumer summing BytesOut has already |
| + // counted them. |
| + if got := day.Hours["09"]["a.example.com"]; got.HopBytesOut > got.BytesOut || got.HopRequests > got.Requests { |
| + t.Fatalf("row = %+v — the hop counters are a subset of the totals, never an addition", got) |
| + } |
| +} |
| + |
| func TestUpsertEdgeDayEmptyDeltasIsNoop(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| if err := UpsertEdgeDay(context.Background(), s, "box1", time.Now().UTC(), nil); err != nil { |