carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package moves
2
3import (
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).
16func 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.
48func 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.
71func 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.
84func 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.
114func 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.
124func 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
146func 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.
153func 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.
170func 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.
199func 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