package schedule import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) func controlFixture(t *testing.T) (*registry.Registry, *http.ServeMux) { t.Helper() reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) mux := http.NewServeMux() Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, func() time.Time { return at("2026-08-23T13:07:00Z") }) return reg, mux } func do(mux *http.ServeMux, method, path, auth, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) if auth != "" { req.Header.Set("Authorization", auth) } rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec } func TestControlRequiresTheHostsToken(t *testing.T) { _, mux := controlFixture(t) for _, auth := range []string{"", "Bearer wrong", "Basic tok"} { if rec := do(mux, "PUT", "/schedule/r", auth, `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); rec.Code != http.StatusUnauthorized { t.Errorf("auth %q → %d, want 401", auth, rec.Code) } if rec := do(mux, "GET", "/schedule", auth, ""); rec.Code != http.StatusUnauthorized { t.Errorf("GET auth %q → %d", auth, rec.Code) } } } func TestControlPutGetDelete(t *testing.T) { reg, mux := controlFixture(t) rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/jobs/remind"}`) if rec.Code != http.StatusOK { t.Fatalf("put = %d %s", rec.Code, rec.Body) } var ts TimerStatus json.Unmarshal(rec.Body.Bytes(), &ts) if ts.Kind != KindOnce || ts.NextAt != at("2026-09-01T08:00:00Z").Unix() || ts.Path != "/jobs/remind" || ts.Spec != "at "+itoa(ts.NextAt) { t.Fatalf("put body = %+v", ts) } tm, _ := reg.GetTimer("a.bab.oncarlos.com", "remind") if tm == nil || tm.Kind != KindOnce { t.Fatalf("row = %+v", tm) } // Upsert by name moves the time. do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-02T08:00:00Z","path":"/jobs/remind"}`) tm, _ = reg.GetTimer("a.bab.oncarlos.com", "remind") if tm.NextAt != at("2026-09-02T08:00:00Z").Unix() { t.Fatalf("upsert did not move: %+v", tm) } rec = do(mux, "GET", "/schedule", "Bearer tok", "") var list struct{ Timers []TimerStatus } json.Unmarshal(rec.Body.Bytes(), &list) if len(list.Timers) != 1 || list.Timers[0].Name != "remind" { t.Fatalf("list = %+v", list) } if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent { t.Fatalf("delete = %d", rec.Code) } if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent { t.Fatalf("second delete = %d, want 204 (idempotent)", rec.Code) } } func TestControlRefusals(t *testing.T) { reg, mux := controlFixture(t) reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: 1}) cases := []struct { name, body string want int }{ {"sync", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`, http.StatusConflict}, {"Bad", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`, http.StatusBadRequest}, {"ok", `{"at":"2026-09-01T08:00:00Z","path":"p"}`, http.StatusBadRequest}, {"ok", `{"at":"soon","path":"/p"}`, http.StatusBadRequest}, {"ok", `{"at":"2028-09-01T08:00:00Z","path":"/p"}`, http.StatusBadRequest}, // > 400 days ahead {"ok", `not json`, http.StatusBadRequest}, } for _, c := range cases { if rec := do(mux, "PUT", "/schedule/"+c.name, "Bearer tok", c.body); rec.Code != c.want { t.Errorf("PUT %s %s → %d, want %d (%s)", c.name, c.body, rec.Code, c.want, rec.Body) } } // A past `at` is accepted and fires on the next tick. if rec := do(mux, "PUT", "/schedule/past", "Bearer tok", `{"at":"2020-01-01T00:00:00Z","path":"/p"}`); rec.Code != http.StatusOK { t.Fatalf("past at → %d", rec.Code) } if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "past"); tm.NextAt != at("2020-01-01T00:00:00Z").Unix() { t.Fatalf("past row = %+v", tm) } } // json.Decoder.Decode stops at the first value and silently drops whatever // follows it, so a naive Decode(&body) would accept a body like // `{"at":...}{"at":...}` or `{"at":...}garbage` and act on the first // object alone. The PUT handler must refuse it instead. func TestControlPutRejectsTrailingJSON(t *testing.T) { _, mux := controlFixture(t) for _, body := range []string{ `{"at":"2026-09-01T08:00:00Z","path":"/p"}{"at":"2026-09-02T08:00:00Z","path":"/p"}`, `{"at":"2026-09-01T08:00:00Z","path":"/p"} garbage`, `{"at":"2026-09-01T08:00:00Z","path":"/p"},`, } { if rec := do(mux, "PUT", "/schedule/trailing", "Bearer tok", body); rec.Code != http.StatusBadRequest { t.Errorf("PUT with trailing data %q → %d, want 400 (%s)", body, rec.Code, rec.Body) } } // A clean single object still works. if rec := do(mux, "PUT", "/schedule/clean", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusOK { t.Fatalf("clean body → %d (%s)", rec.Code, rec.Body) } // dec.More() must not trip on WHITESPACE after the object — a curl // heredoc (`curl -d @- <