| index 2a83cf59d..44cb8beaa 100644 |
| --- a/internal/moves/timers_test.go |
| +++ b/internal/moves/timers_test.go |
| @@ -1,6 +1,8 @@ |
| package moves |
| |
| import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| "strings" |
| "testing" |
| "time" |
| @@ -161,3 +163,62 @@ func TestTimersSnapshotEmpty(t *testing.T) { |
| t.Fatal("empty digests differ") |
| } |
| } |
| + |
| +// The decoder reads only schema v1. Anything else could carry state this |
| +// reader would silently drop, and a re-encode would then label the partial |
| +// reading v1, so it is refused before any row is returned. |
| +func TestDecodeTimersRejectsUnsupportedVersions(t *testing.T) { |
| + row := `"rows":[{"Host":"h","Name":"n","Kind":"once","NextAt":9,"Registration":"r"}]` |
| + for name, in := range map[string]string{ |
| + "missing v": `{"host":"h",` + row + `}`, |
| + "v 0": `{"v":0,"host":"h",` + row + `}`, |
| + "v 2": `{"v":2,"host":"h",` + row + `}`, |
| + "v -1": `{"v":-1,"host":"h",` + row + `}`, |
| + "null": `null`, |
| + "empty obj": `{}`, |
| + } { |
| + got, err := DecodeTimers([]byte(in)) |
| + if err == nil { |
| + t.Errorf("%s: decoded without error: %+v", name, got) |
| + continue |
| + } |
| + if len(got.Rows) != 0 { |
| + t.Errorf("%s: rows returned with the error: %+v", name, got.Rows) |
| + } |
| + } |
| + if _, err := DecodeTimers([]byte(`{"v":1,"host":"h","rows":[]}`)); err != nil { |
| + t.Fatalf("v 1 refused: %v", err) |
| + } |
| +} |
| + |
| +// Golden digests: the bytes a clean recovery compares must not change |
| +// between releases, or two boxes on different builds would disagree about |
| +// identical timers. The expected strings are fixed; a failure here means |
| +// the canonical form changed (field order, names, separators, sort, or |
| +// the active-row rule), which needs a version bump, not a new golden. |
| +func TestTimersDigestGolden(t *testing.T) { |
| + rows := []registry.Timer{ |
| + {Host: "h", Name: "send-later", Kind: "once", Path: "/s", NextAt: 200, DueAt: 200, Registration: "r2"}, |
| + {Host: "h", Name: "nightly", Kind: "schedule", Spec: "0 2 * * *", Path: "/n", NextAt: 100, LastAt: 40, LastStatus: "200", LastMs: 12, Attempts: 1, CreatedAt: 10, DueAt: 100, Registration: "r1"}, |
| + {Host: "h", Name: "sent", Kind: "once", Path: "/s", LastAt: 50, LastStatus: "200", Registration: "r3"}, |
| + } |
| + const want = "sha256:8ac11b38bc2b5b6a658410fc56c8422690fdf53f76489ea95f6c7f9039a42654" |
| + if got := TimersDigest(rows); got != want { |
| + t.Fatalf("digest = %s, want %s", got, want) |
| + } |
| + // The same bytes spelled out, so the golden is not just a snapshot of |
| + // the implementation: two JSON lines, sorted by name, no history. |
| + canon := `{"Host":"h","Name":"nightly","Kind":"schedule","Spec":"0 2 * * *","Path":"/n","NextAt":100,"LastAt":40,"LastStatus":"200","LastMs":12,"Attempts":1,"CreatedAt":10,"DueAt":100,"Registration":"r1"}` + "\n" + |
| + `{"Host":"h","Name":"send-later","Kind":"once","Spec":"","Path":"/s","NextAt":200,"LastAt":0,"LastStatus":"","LastMs":0,"Attempts":0,"CreatedAt":0,"DueAt":200,"Registration":"r2"}` + "\n" |
| + sum := sha256.Sum256([]byte(canon)) |
| + if got := "sha256:" + hex.EncodeToString(sum[:]); got != want { |
| + t.Fatalf("spelled-out canonical form hashes to %s, want %s", got, want) |
| + } |
| + const wantEmpty = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" |
| + if got := TimersDigest(nil); got != wantEmpty { |
| + t.Fatalf("empty digest = %s", got) |
| + } |
| + if got := TimersDigest([]registry.Timer{rows[2]}); got != wantEmpty { |
| + t.Fatalf("history-only digest = %s", got) |
| + } |
| +} |