package moves import ( "crypto/sha256" "encoding/hex" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) // ยง9.4: the digest covers active rows and their bookkeeping and excludes // disposable history, so the runner's daily prune of completed // once-timers never changes what a clean recovery compares (joint N14). func TestTimersDigestIgnoresCompletedOnceTimers(t *testing.T) { sched := registry.Timer{Host: "h", Name: "nightly", Kind: "schedule", Spec: "0 2 * * *", Path: "/n", NextAt: 100, DueAt: 100, Attempts: 1, Registration: "r1"} pending := registry.Timer{Host: "h", Name: "send-later", Kind: "once", Path: "/s", NextAt: 200, DueAt: 200, Registration: "r2"} done := registry.Timer{Host: "h", Name: "sent", Kind: "once", Path: "/s", NextAt: 0, LastAt: 50, LastStatus: "200", Registration: "r3"} d1 := TimersDigest([]registry.Timer{sched, pending, done}) d2 := TimersDigest([]registry.Timer{pending, sched}) if d1 != d2 { t.Fatal("history or order changed the digest") } moved := pending moved.Attempts = 1 if TimersDigest([]registry.Timer{sched, moved}) == d1 { t.Fatal("bookkeeping on an active row is part of the digest") } if n := len(ActiveTimers([]registry.Timer{sched, pending, done})); n != 2 { t.Fatalf("active = %d", n) } snap := TimersSnapshot{V: 1, Host: "h", Rows: []registry.Timer{sched, pending, done}} b, err := snap.Encode() if err != nil { t.Fatal(err) } back, err := DecodeTimers(b) if err != nil || len(back.Rows) != 3 || back.Rows[0].Registration != "r1" { t.Fatalf("round trip lost the registration: %+v %v", back, err) } } // Which rows are active: every schedule row (even one with no next // occurrence and a last status), a once-timer that has never fired, and a // once-timer that fired but is retrying or rescheduled. Only a fired // once-timer with nothing next is history. func TestActiveTimersSelection(t *testing.T) { rows := []registry.Timer{ {Name: "sched-idle", Kind: "schedule", NextAt: 0, LastStatus: "200"}, {Name: "sched-next", Kind: "schedule", NextAt: 10, LastStatus: "200"}, {Name: "once-new", Kind: "once", NextAt: 0, LastStatus: ""}, {Name: "once-retry", Kind: "once", NextAt: 20, LastStatus: "503"}, {Name: "once-done", Kind: "once", NextAt: 0, LastStatus: "200"}, {Name: "once-failed", Kind: "once", NextAt: 0, LastStatus: "500"}, } var got []string for _, r := range ActiveTimers(rows) { got = append(got, r.Name) } want := "sched-idle,sched-next,once-new,once-retry" if strings.Join(got, ",") != want { t.Fatalf("active = %v, want %s", got, want) } if n := len(ActiveTimers(nil)); n != 0 { t.Fatalf("active of nothing = %d", n) } } // The digest must not reorder or drop rows in the caller's slice. func TestTimersDigestLeavesInputAlone(t *testing.T) { a := registry.Timer{Host: "h", Name: "b", Kind: "once", NextAt: 1} b := registry.Timer{Host: "h", Name: "a", Kind: "once", NextAt: 2} done := registry.Timer{Host: "h", Name: "c", Kind: "once", LastStatus: "200"} in := []registry.Timer{a, b, done} TimersDigest(in) if in[0].Name != "b" || in[1].Name != "a" || in[2].Name != "c" || len(in) != 3 { t.Fatalf("input changed: %+v", in) } } // Every column of an active row moves the digest, including the // registration, which registry.Timer hides from JSON. func TestTimersDigestCoversEveryColumn(t *testing.T) { base := registry.Timer{Host: "h", Name: "n", Kind: "schedule", Spec: "* * * * *", Path: "/p", NextAt: 5, LastAt: 4, LastStatus: "200", LastMs: 7, Attempts: 1, CreatedAt: 3, DueAt: 5, Registration: "r"} want := TimersDigest([]registry.Timer{base}) muts := map[string]func(*registry.Timer){ "Host": func(x *registry.Timer) { x.Host = "h2" }, "Spec": func(x *registry.Timer) { x.Spec = "0 * * * *" }, "Path": func(x *registry.Timer) { x.Path = "/q" }, "NextAt": func(x *registry.Timer) { x.NextAt++ }, "LastAt": func(x *registry.Timer) { x.LastAt++ }, "LastStatus": func(x *registry.Timer) { x.LastStatus = "500" }, "LastMs": func(x *registry.Timer) { x.LastMs++ }, "Attempts": func(x *registry.Timer) { x.Attempts++ }, "CreatedAt": func(x *registry.Timer) { x.CreatedAt++ }, "DueAt": func(x *registry.Timer) { x.DueAt++ }, "Registration": func(x *registry.Timer) { x.Registration = "r2" }, } for name, mut := range muts { x := base mut(&x) if TimersDigest([]registry.Timer{x}) == want { t.Errorf("%s is not part of the digest", name) } } if !strings.HasPrefix(want, "sha256:") || len(want) != len("sha256:")+64 { t.Fatalf("digest form = %q", want) } } // Rows with the same name and different kinds sort by kind, so the digest // does not depend on input order for them either. func TestTimersDigestOrdersByNameThenKind(t *testing.T) { a := registry.Timer{Host: "h", Name: "x", Kind: "once", NextAt: 1} b := registry.Timer{Host: "h", Name: "x", Kind: "schedule", Spec: "* * * * *", NextAt: 2} if TimersDigest([]registry.Timer{a, b}) != TimersDigest([]registry.Timer{b, a}) { t.Fatal("order of same-named rows changed the digest") } } // The snapshot carries every column through the wire, the registration // included, and the version, host and time. func TestTimersSnapshotRoundTrip(t *testing.T) { at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) row := registry.Timer{Host: "h", Name: "n", Kind: "schedule", Spec: "0 2 * * *", Path: "/n", NextAt: 1, LastAt: 2, LastStatus: "200", LastMs: 3, Attempts: 4, CreatedAt: 5, DueAt: 6, Registration: "reg"} b, err := TimersSnapshot{V: 1, Host: "h", Rows: []registry.Timer{row}, TakenAt: at}.Encode() if err != nil { t.Fatal(err) } if !strings.Contains(string(b), `"reg"`) { t.Fatalf("registration not on the wire: %s", b) } back, err := DecodeTimers(b) if err != nil { t.Fatal(err) } if back.V != 1 || back.Host != "h" || !back.TakenAt.Equal(at) || len(back.Rows) != 1 || back.Rows[0] != row { t.Fatalf("round trip = %+v", back) } if TimersDigest(back.Rows) != TimersDigest([]registry.Timer{row}) { t.Fatal("digest changed across the wire") } } func TestDecodeTimersRejectsGarbage(t *testing.T) { if _, err := DecodeTimers([]byte("not json")); err == nil { t.Fatal("garbage decoded") } } // An empty snapshot round-trips and its digest is stable. func TestTimersSnapshotEmpty(t *testing.T) { b, err := TimersSnapshot{V: 1, Host: "h"}.Encode() if err != nil { t.Fatal(err) } back, err := DecodeTimers(b) if err != nil || len(back.Rows) != 0 { t.Fatalf("empty = %+v %v", back, err) } if TimersDigest(nil) != TimersDigest(back.Rows) { 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) } }