| 1 | package schedule |
| 2 | |
| 3 | import ( |
| 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 | |
| 16 | func 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 | |
| 28 | func 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 | |
| 38 | func 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 | |
| 50 | func 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 | |
| 85 | func 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. |
| 117 | func 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. |
| 150 | func 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 | |
| 169 | func 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 | |
| 181 | func 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 | |
| 195 | func itoa(n int64) string { return strconv.FormatInt(n, 10) } |
| 196 | |
| 197 | func 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 | |
| 224 | func 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 | |