| 1 | package moves |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | ) |
| 12 | |
| 13 | // §9.4: the digest covers active rows and their bookkeeping and excludes |
| 14 | // disposable history, so the runner's daily prune of completed |
| 15 | // once-timers never changes what a clean recovery compares (joint N14). |
| 16 | func TestTimersDigestIgnoresCompletedOnceTimers(t *testing.T) { |
| 17 | sched := registry.Timer{Host: "h", Name: "nightly", Kind: "schedule", Spec: "0 2 * * *", Path: "/n", NextAt: 100, DueAt: 100, Attempts: 1, Registration: "r1"} |
| 18 | pending := registry.Timer{Host: "h", Name: "send-later", Kind: "once", Path: "/s", NextAt: 200, DueAt: 200, Registration: "r2"} |
| 19 | done := registry.Timer{Host: "h", Name: "sent", Kind: "once", Path: "/s", NextAt: 0, LastAt: 50, LastStatus: "200", Registration: "r3"} |
| 20 | d1 := TimersDigest([]registry.Timer{sched, pending, done}) |
| 21 | d2 := TimersDigest([]registry.Timer{pending, sched}) |
| 22 | if d1 != d2 { |
| 23 | t.Fatal("history or order changed the digest") |
| 24 | } |
| 25 | moved := pending |
| 26 | moved.Attempts = 1 |
| 27 | if TimersDigest([]registry.Timer{sched, moved}) == d1 { |
| 28 | t.Fatal("bookkeeping on an active row is part of the digest") |
| 29 | } |
| 30 | if n := len(ActiveTimers([]registry.Timer{sched, pending, done})); n != 2 { |
| 31 | t.Fatalf("active = %d", n) |
| 32 | } |
| 33 | snap := TimersSnapshot{V: 1, Host: "h", Rows: []registry.Timer{sched, pending, done}} |
| 34 | b, err := snap.Encode() |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | back, err := DecodeTimers(b) |
| 39 | if err != nil || len(back.Rows) != 3 || back.Rows[0].Registration != "r1" { |
| 40 | t.Fatalf("round trip lost the registration: %+v %v", back, err) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // Which rows are active: every schedule row (even one with no next |
| 45 | // occurrence and a last status), a once-timer that has never fired, and a |
| 46 | // once-timer that fired but is retrying or rescheduled. Only a fired |
| 47 | // once-timer with nothing next is history. |
| 48 | func TestActiveTimersSelection(t *testing.T) { |
| 49 | rows := []registry.Timer{ |
| 50 | {Name: "sched-idle", Kind: "schedule", NextAt: 0, LastStatus: "200"}, |
| 51 | {Name: "sched-next", Kind: "schedule", NextAt: 10, LastStatus: "200"}, |
| 52 | {Name: "once-new", Kind: "once", NextAt: 0, LastStatus: ""}, |
| 53 | {Name: "once-retry", Kind: "once", NextAt: 20, LastStatus: "503"}, |
| 54 | {Name: "once-done", Kind: "once", NextAt: 0, LastStatus: "200"}, |
| 55 | {Name: "once-failed", Kind: "once", NextAt: 0, LastStatus: "500"}, |
| 56 | } |
| 57 | var got []string |
| 58 | for _, r := range ActiveTimers(rows) { |
| 59 | got = append(got, r.Name) |
| 60 | } |
| 61 | want := "sched-idle,sched-next,once-new,once-retry" |
| 62 | if strings.Join(got, ",") != want { |
| 63 | t.Fatalf("active = %v, want %s", got, want) |
| 64 | } |
| 65 | if n := len(ActiveTimers(nil)); n != 0 { |
| 66 | t.Fatalf("active of nothing = %d", n) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // The digest must not reorder or drop rows in the caller's slice. |
| 71 | func TestTimersDigestLeavesInputAlone(t *testing.T) { |
| 72 | a := registry.Timer{Host: "h", Name: "b", Kind: "once", NextAt: 1} |
| 73 | b := registry.Timer{Host: "h", Name: "a", Kind: "once", NextAt: 2} |
| 74 | done := registry.Timer{Host: "h", Name: "c", Kind: "once", LastStatus: "200"} |
| 75 | in := []registry.Timer{a, b, done} |
| 76 | TimersDigest(in) |
| 77 | if in[0].Name != "b" || in[1].Name != "a" || in[2].Name != "c" || len(in) != 3 { |
| 78 | t.Fatalf("input changed: %+v", in) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // Every column of an active row moves the digest, including the |
| 83 | // registration, which registry.Timer hides from JSON. |
| 84 | func TestTimersDigestCoversEveryColumn(t *testing.T) { |
| 85 | 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"} |
| 86 | want := TimersDigest([]registry.Timer{base}) |
| 87 | muts := map[string]func(*registry.Timer){ |
| 88 | "Host": func(x *registry.Timer) { x.Host = "h2" }, |
| 89 | "Spec": func(x *registry.Timer) { x.Spec = "0 * * * *" }, |
| 90 | "Path": func(x *registry.Timer) { x.Path = "/q" }, |
| 91 | "NextAt": func(x *registry.Timer) { x.NextAt++ }, |
| 92 | "LastAt": func(x *registry.Timer) { x.LastAt++ }, |
| 93 | "LastStatus": func(x *registry.Timer) { x.LastStatus = "500" }, |
| 94 | "LastMs": func(x *registry.Timer) { x.LastMs++ }, |
| 95 | "Attempts": func(x *registry.Timer) { x.Attempts++ }, |
| 96 | "CreatedAt": func(x *registry.Timer) { x.CreatedAt++ }, |
| 97 | "DueAt": func(x *registry.Timer) { x.DueAt++ }, |
| 98 | "Registration": func(x *registry.Timer) { x.Registration = "r2" }, |
| 99 | } |
| 100 | for name, mut := range muts { |
| 101 | x := base |
| 102 | mut(&x) |
| 103 | if TimersDigest([]registry.Timer{x}) == want { |
| 104 | t.Errorf("%s is not part of the digest", name) |
| 105 | } |
| 106 | } |
| 107 | if !strings.HasPrefix(want, "sha256:") || len(want) != len("sha256:")+64 { |
| 108 | t.Fatalf("digest form = %q", want) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Rows with the same name and different kinds sort by kind, so the digest |
| 113 | // does not depend on input order for them either. |
| 114 | func TestTimersDigestOrdersByNameThenKind(t *testing.T) { |
| 115 | a := registry.Timer{Host: "h", Name: "x", Kind: "once", NextAt: 1} |
| 116 | b := registry.Timer{Host: "h", Name: "x", Kind: "schedule", Spec: "* * * * *", NextAt: 2} |
| 117 | if TimersDigest([]registry.Timer{a, b}) != TimersDigest([]registry.Timer{b, a}) { |
| 118 | t.Fatal("order of same-named rows changed the digest") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // The snapshot carries every column through the wire, the registration |
| 123 | // included, and the version, host and time. |
| 124 | func TestTimersSnapshotRoundTrip(t *testing.T) { |
| 125 | at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 126 | 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"} |
| 127 | b, err := TimersSnapshot{V: 1, Host: "h", Rows: []registry.Timer{row}, TakenAt: at}.Encode() |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if !strings.Contains(string(b), `"reg"`) { |
| 132 | t.Fatalf("registration not on the wire: %s", b) |
| 133 | } |
| 134 | back, err := DecodeTimers(b) |
| 135 | if err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | if back.V != 1 || back.Host != "h" || !back.TakenAt.Equal(at) || len(back.Rows) != 1 || back.Rows[0] != row { |
| 139 | t.Fatalf("round trip = %+v", back) |
| 140 | } |
| 141 | if TimersDigest(back.Rows) != TimersDigest([]registry.Timer{row}) { |
| 142 | t.Fatal("digest changed across the wire") |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestDecodeTimersRejectsGarbage(t *testing.T) { |
| 147 | if _, err := DecodeTimers([]byte("not json")); err == nil { |
| 148 | t.Fatal("garbage decoded") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // An empty snapshot round-trips and its digest is stable. |
| 153 | func TestTimersSnapshotEmpty(t *testing.T) { |
| 154 | b, err := TimersSnapshot{V: 1, Host: "h"}.Encode() |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | back, err := DecodeTimers(b) |
| 159 | if err != nil || len(back.Rows) != 0 { |
| 160 | t.Fatalf("empty = %+v %v", back, err) |
| 161 | } |
| 162 | if TimersDigest(nil) != TimersDigest(back.Rows) { |
| 163 | t.Fatal("empty digests differ") |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // The decoder reads only schema v1. Anything else could carry state this |
| 168 | // reader would silently drop, and a re-encode would then label the partial |
| 169 | // reading v1, so it is refused before any row is returned. |
| 170 | func TestDecodeTimersRejectsUnsupportedVersions(t *testing.T) { |
| 171 | row := `"rows":[{"Host":"h","Name":"n","Kind":"once","NextAt":9,"Registration":"r"}]` |
| 172 | for name, in := range map[string]string{ |
| 173 | "missing v": `{"host":"h",` + row + `}`, |
| 174 | "v 0": `{"v":0,"host":"h",` + row + `}`, |
| 175 | "v 2": `{"v":2,"host":"h",` + row + `}`, |
| 176 | "v -1": `{"v":-1,"host":"h",` + row + `}`, |
| 177 | "null": `null`, |
| 178 | "empty obj": `{}`, |
| 179 | } { |
| 180 | got, err := DecodeTimers([]byte(in)) |
| 181 | if err == nil { |
| 182 | t.Errorf("%s: decoded without error: %+v", name, got) |
| 183 | continue |
| 184 | } |
| 185 | if len(got.Rows) != 0 { |
| 186 | t.Errorf("%s: rows returned with the error: %+v", name, got.Rows) |
| 187 | } |
| 188 | } |
| 189 | if _, err := DecodeTimers([]byte(`{"v":1,"host":"h","rows":[]}`)); err != nil { |
| 190 | t.Fatalf("v 1 refused: %v", err) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // Golden digests: the bytes a clean recovery compares must not change |
| 195 | // between releases, or two boxes on different builds would disagree about |
| 196 | // identical timers. The expected strings are fixed; a failure here means |
| 197 | // the canonical form changed (field order, names, separators, sort, or |
| 198 | // the active-row rule), which needs a version bump, not a new golden. |
| 199 | func TestTimersDigestGolden(t *testing.T) { |
| 200 | rows := []registry.Timer{ |
| 201 | {Host: "h", Name: "send-later", Kind: "once", Path: "/s", NextAt: 200, DueAt: 200, Registration: "r2"}, |
| 202 | {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"}, |
| 203 | {Host: "h", Name: "sent", Kind: "once", Path: "/s", LastAt: 50, LastStatus: "200", Registration: "r3"}, |
| 204 | } |
| 205 | const want = "sha256:8ac11b38bc2b5b6a658410fc56c8422690fdf53f76489ea95f6c7f9039a42654" |
| 206 | if got := TimersDigest(rows); got != want { |
| 207 | t.Fatalf("digest = %s, want %s", got, want) |
| 208 | } |
| 209 | // The same bytes spelled out, so the golden is not just a snapshot of |
| 210 | // the implementation: two JSON lines, sorted by name, no history. |
| 211 | 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" + |
| 212 | `{"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" |
| 213 | sum := sha256.Sum256([]byte(canon)) |
| 214 | if got := "sha256:" + hex.EncodeToString(sum[:]); got != want { |
| 215 | t.Fatalf("spelled-out canonical form hashes to %s, want %s", got, want) |
| 216 | } |
| 217 | const wantEmpty = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" |
| 218 | if got := TimersDigest(nil); got != wantEmpty { |
| 219 | t.Fatalf("empty digest = %s", got) |
| 220 | } |
| 221 | if got := TimersDigest([]registry.Timer{rows[2]}); got != wantEmpty { |
| 222 | t.Fatalf("history-only digest = %s", got) |
| 223 | } |
| 224 | } |
| 225 | |