carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package schedule
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/registry"
14)
15
16func controlFixture(t *testing.T) (*registry.Registry, *http.ServeMux) {
17 t.Helper()
18 reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db"))
19 if err != nil {
20 t.Fatal(err)
21 }
22 t.Cleanup(func() { reg.Close() })
23 mux := http.NewServeMux()
24 Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, func() time.Time { return at("2026-08-23T13:07:00Z") })
25 return reg, mux
26}
27
28func do(mux *http.ServeMux, method, path, auth, body string) *httptest.ResponseRecorder {
29 req := httptest.NewRequest(method, path, strings.NewReader(body))
30 if auth != "" {
31 req.Header.Set("Authorization", auth)
32 }
33 rec := httptest.NewRecorder()
34 mux.ServeHTTP(rec, req)
35 return rec
36}
37
38func TestControlRequiresTheHostsToken(t *testing.T) {
39 _, mux := controlFixture(t)
40 for _, auth := range []string{"", "Bearer wrong", "Basic tok"} {
41 if rec := do(mux, "PUT", "/schedule/r", auth, `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); rec.Code != http.StatusUnauthorized {
42 t.Errorf("auth %q → %d, want 401", auth, rec.Code)
43 }
44 if rec := do(mux, "GET", "/schedule", auth, ""); rec.Code != http.StatusUnauthorized {
45 t.Errorf("GET auth %q → %d", auth, rec.Code)
46 }
47 }
48}
49
50func TestControlPutGetDelete(t *testing.T) {
51 reg, mux := controlFixture(t)
52 rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/jobs/remind"}`)
53 if rec.Code != http.StatusOK {
54 t.Fatalf("put = %d %s", rec.Code, rec.Body)
55 }
56 var ts TimerStatus
57 json.Unmarshal(rec.Body.Bytes(), &ts)
58 if ts.Kind != KindOnce || ts.NextAt != at("2026-09-01T08:00:00Z").Unix() || ts.Path != "/jobs/remind" || ts.Spec != "at "+itoa(ts.NextAt) {
59 t.Fatalf("put body = %+v", ts)
60 }
61 tm, _ := reg.GetTimer("a.bab.oncarlos.com", "remind")
62 if tm == nil || tm.Kind != KindOnce {
63 t.Fatalf("row = %+v", tm)
64 }
65 // Upsert by name moves the time.
66 do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-02T08:00:00Z","path":"/jobs/remind"}`)
67 tm, _ = reg.GetTimer("a.bab.oncarlos.com", "remind")
68 if tm.NextAt != at("2026-09-02T08:00:00Z").Unix() {
69 t.Fatalf("upsert did not move: %+v", tm)
70 }
71 rec = do(mux, "GET", "/schedule", "Bearer tok", "")
72 var list struct{ Timers []TimerStatus }
73 json.Unmarshal(rec.Body.Bytes(), &list)
74 if len(list.Timers) != 1 || list.Timers[0].Name != "remind" {
75 t.Fatalf("list = %+v", list)
76 }
77 if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent {
78 t.Fatalf("delete = %d", rec.Code)
79 }
80 if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent {
81 t.Fatalf("second delete = %d, want 204 (idempotent)", rec.Code)
82 }
83}
84
85func TestControlRefusals(t *testing.T) {
86 reg, mux := controlFixture(t)
87 reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: 1})
88 cases := []struct {
89 name, body string
90 want int
91 }{
92 {"sync", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`, http.StatusConflict},
93 {"Bad", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`, http.StatusBadRequest},
94 {"ok", `{"at":"2026-09-01T08:00:00Z","path":"p"}`, http.StatusBadRequest},
95 {"ok", `{"at":"soon","path":"/p"}`, http.StatusBadRequest},
96 {"ok", `{"at":"2028-09-01T08:00:00Z","path":"/p"}`, http.StatusBadRequest}, // > 400 days ahead
97 {"ok", `not json`, http.StatusBadRequest},
98 }
99 for _, c := range cases {
100 if rec := do(mux, "PUT", "/schedule/"+c.name, "Bearer tok", c.body); rec.Code != c.want {
101 t.Errorf("PUT %s %s → %d, want %d (%s)", c.name, c.body, rec.Code, c.want, rec.Body)
102 }
103 }
104 // A past `at` is accepted and fires on the next tick.
105 if rec := do(mux, "PUT", "/schedule/past", "Bearer tok", `{"at":"2020-01-01T00:00:00Z","path":"/p"}`); rec.Code != http.StatusOK {
106 t.Fatalf("past at → %d", rec.Code)
107 }
108 if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "past"); tm.NextAt != at("2020-01-01T00:00:00Z").Unix() {
109 t.Fatalf("past row = %+v", tm)
110 }
111}
112
113// json.Decoder.Decode stops at the first value and silently drops whatever
114// follows it, so a naive Decode(&body) would accept a body like
115// `{"at":...}{"at":...}` or `{"at":...}garbage` and act on the first
116// object alone. The PUT handler must refuse it instead.
117func TestControlPutRejectsTrailingJSON(t *testing.T) {
118 _, mux := controlFixture(t)
119 for _, body := range []string{
120 `{"at":"2026-09-01T08:00:00Z","path":"/p"}{"at":"2026-09-02T08:00:00Z","path":"/p"}`,
121 `{"at":"2026-09-01T08:00:00Z","path":"/p"} garbage`,
122 `{"at":"2026-09-01T08:00:00Z","path":"/p"},`,
123 } {
124 if rec := do(mux, "PUT", "/schedule/trailing", "Bearer tok", body); rec.Code != http.StatusBadRequest {
125 t.Errorf("PUT with trailing data %q → %d, want 400 (%s)", body, rec.Code, rec.Body)
126 }
127 }
128 // A clean single object still works.
129 if rec := do(mux, "PUT", "/schedule/clean", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusOK {
130 t.Fatalf("clean body → %d (%s)", rec.Code, rec.Body)
131 }
132 // dec.More() must not trip on WHITESPACE after the object — a curl
133 // heredoc (`curl -d @- <<EOF`) appends exactly this, a trailing
134 // newline, and sometimes leading/trailing spaces too. Only a second
135 // TOKEN counts as trailing data.
136 for i, body := range []string{
137 "{\"at\":\"2026-09-01T08:00:00Z\",\"path\":\"/p\"}\n",
138 "{\"at\":\"2026-09-01T08:00:00Z\",\"path\":\"/p\"} \n \t",
139 " {\"at\":\"2026-09-01T08:00:00Z\",\"path\":\"/p\"} ",
140 } {
141 name := "clean-ws-" + itoa(int64(i))
142 if rec := do(mux, "PUT", "/schedule/"+name, "Bearer tok", body); rec.Code != http.StatusOK {
143 t.Errorf("PUT with trailing/leading whitespace %q → %d, want 200 (%s)", body, rec.Code, rec.Body)
144 }
145 }
146}
147
148// A member's clock is not UTC; `at` must accept an offset and the row must
149// store the UTC instant it names, not the offset's clock-face numbers.
150func TestControlPutNormalisesTZOffsetToUTC(t *testing.T) {
151 reg, mux := controlFixture(t)
152 // 09:00 +01:00 is 08:00Z.
153 rec := do(mux, "PUT", "/schedule/tz", "Bearer tok", `{"at":"2026-09-01T09:00:00+01:00","path":"/p"}`)
154 if rec.Code != http.StatusOK {
155 t.Fatalf("put = %d %s", rec.Code, rec.Body)
156 }
157 want := at("2026-09-01T08:00:00Z").Unix()
158 var ts TimerStatus
159 json.Unmarshal(rec.Body.Bytes(), &ts)
160 if ts.NextAt != want || ts.Spec != "at "+itoa(want) {
161 t.Fatalf("response = %+v, want next_at=%d", ts, want)
162 }
163 tm, err := reg.GetTimer("a.bab.oncarlos.com", "tz")
164 if err != nil || tm.NextAt != want || tm.DueAt != want {
165 t.Fatalf("row = %+v, err=%v, want next_at/due_at=%d", tm, err, want)
166 }
167}
168
169func TestControlDeleteRefusesDeclaredSchedule(t *testing.T) {
170 reg, mux := controlFixture(t)
171 reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: 1})
172 if rec := do(mux, "DELETE", "/schedule/sync", "Bearer tok", ""); rec.Code != http.StatusConflict {
173 t.Fatalf("delete declared schedule = %d, want 409 (%s)", rec.Code, rec.Body)
174 }
175 tm, err := reg.GetTimer("a.bab.oncarlos.com", "sync")
176 if err != nil || tm == nil || tm.Kind != KindSchedule {
177 t.Fatalf("row did not survive the refused delete: %v, err=%v", tm, err)
178 }
179}
180
181func TestControlCapsOncePerHost(t *testing.T) {
182 reg, mux := controlFixture(t)
183 for i := 0; i < MaxOncePerHost; i++ {
184 reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "n" + itoa(int64(i)), Kind: KindOnce, Spec: "at 1", Path: "/p", NextAt: 1})
185 }
186 if rec := do(mux, "PUT", "/schedule/over", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusTooManyRequests {
187 t.Fatalf("over cap → %d", rec.Code)
188 }
189 // Replacing an existing name is not a new row and must still work.
190 if rec := do(mux, "PUT", "/schedule/n1", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusOK {
191 t.Fatalf("replace at cap → %d", rec.Code)
192 }
193}
194
195func itoa(n int64) string { return strconv.FormatInt(n, 10) }
196
197func TestControlScheduleCapabilities(t *testing.T) {
198 _, mux := controlFixture(t)
199 for _, auth := range []string{"", "Bearer wrong", "Basic tok"} {
200 rec := do(mux, "GET", "/schedule-capabilities", auth, "")
201 if rec.Code != http.StatusUnauthorized || strings.Contains(rec.Body.String(), "registration_fence") {
202 t.Fatalf("unauthorized capability response: %d %s", rec.Code, rec.Body.String())
203 }
204 }
205 rec := do(mux, "GET", "/schedule-capabilities", "Bearer tok", "")
206 if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" {
207 t.Fatalf("capability response: %d %s", rec.Code, rec.Body.String())
208 }
209 var got map[string]bool
210 if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
211 t.Fatal(err)
212 }
213 if len(got) != 1 || !got["registration_fence"] {
214 t.Fatalf("capabilities: %v", got)
215 }
216 old := http.NewServeMux()
217 old.HandleFunc("GET /schedule", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
218 rec = do(old, "GET", "/schedule-capabilities", "Bearer tok", "")
219 if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "registration_fence") {
220 t.Fatalf("old capability response: %d %s", rec.Code, rec.Body.String())
221 }
222}
223
224func TestControlNonpositiveOccurrenceArmsTimer(t *testing.T) {
225 for _, requested := range []int64{0, -1, -62135596800} {
226 for _, current := range []int64{1787490420, 0, -1} {
227 t.Run(strconv.FormatInt(requested, 10)+"/"+strconv.FormatInt(current, 10), func(t *testing.T) {
228 reg, _ := controlFixture(t)
229 mux := http.NewServeMux()
230 Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, func() time.Time { return time.Unix(current, 0) })
231 body := `{"at":"` + time.Unix(requested, 0).UTC().Format(time.RFC3339) + `","path":"/jobs/remind"}`
232 rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", body)
233 if rec.Code != http.StatusOK {
234 t.Fatalf("PUT: %d %s", rec.Code, rec.Body.String())
235 }
236 row, err := reg.GetTimer("a.bab.oncarlos.com", "remind")
237 if err != nil {
238 t.Fatal(err)
239 }
240 if row.NextAt != max(int64(1), current) || row.DueAt != requested || row.Spec != "at "+strconv.FormatInt(requested, 10) {
241 t.Fatalf("normalized timer: %+v", row)
242 }
243 first := row.Registration
244 rec = do(mux, "PUT", "/schedule/remind", "Bearer tok", body)
245 if rec.Code != http.StatusOK {
246 t.Fatalf("repeat PUT: %d %s", rec.Code, rec.Body.String())
247 }
248 row, err = reg.GetTimer("a.bab.oncarlos.com", "remind")
249 if err != nil {
250 t.Fatal(err)
251 }
252 if row.Registration == first {
253 t.Fatal("repeat PUT did not rearm")
254 }
255 })
256 }
257 }
258}
259