package schedule import ( "context" "encoding/json" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func TestValidNameAndPath(t *testing.T) { for _, ok := range []string{"a", "sync", "daily-digest", "x1", strings.Repeat("a", 32)} { if !ValidName(ok) { t.Errorf("ValidName(%q) = false", ok) } } for _, bad := range []string{"", "-a", "A", "a_b", "a b", strings.Repeat("a", 33), "sync/"} { if ValidName(bad) { t.Errorf("ValidName(%q) = true", bad) } } for _, ok := range []string{"/", "/jobs/sync", "/a/b-c_d.e?x=1"} { if err := ValidPath(ok); err != nil { t.Errorf("ValidPath(%q) = %v", ok, err) } } for _, bad := range []string{"", "jobs/sync", "/jobs/../etc", "https://x/y", "//host/p", "/" + strings.Repeat("a", 256)} { if err := ValidPath(bad); err == nil { t.Errorf("ValidPath(%q) = ok", bad) } } } func TestScheduleValidateNormalisesEvery(t *testing.T) { s := Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"} if err := s.Validate(); err != nil { t.Fatal(err) } if _, err := (Schedule{Name: "x", Every: "6h", Cron: "* * * * *", Path: "/p"}).Spec(); err == nil { t.Error("both every and cron accepted") } if err := (Schedule{Name: "x", Path: "/p"}).Validate(); err == nil { t.Error("neither every nor cron accepted") } if err := (Schedule{Name: "x", Every: "30s", Path: "/p"}).Validate(); err == nil { t.Error("30s accepted") } } func TestUpsertRemoveAndCAS(t *testing.T) { ctx := context.Background() st := release.NewDirStore(t.TempDir()) now := at("2026-08-23T22:00:00Z") d, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now) if err != nil { t.Fatal(err) } if len(d.Schedules) != 1 || d.Schema != 1 || d.UpdatedAt != "2026-08-23T22:00:00Z" { t.Fatalf("declaration = %+v", d) } // Upsert by name replaces; a second name sorts in. d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "12h", Path: "/jobs/sync"}, now) d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "alpha", Cron: "0 8 * * *", Path: "/jobs/a"}, now) if len(d.Schedules) != 2 || d.Schedules[0].Name != "alpha" || d.Schedules[1].Every != "12h0m0s" { t.Fatalf("declaration = %+v", d) } raw, _, _ := st.Get(ctx, DeclarationKey("bab", "app")) var onDisk Declaration if err := json.Unmarshal(raw, &onDisk); err != nil || len(onDisk.Schedules) != 2 { t.Fatalf("on disk: %s %v", raw, err) } // The cap. for i := 0; i < MaxSchedulesPerApp; i++ { UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "n" + string(rune('a'+i)), Every: "1h", Path: "/p"}, now) } if _, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "one-too-many", Every: "1h", Path: "/p"}, now); err == nil { t.Fatal("cap not enforced") } d, present, err := RemoveSchedule(ctx, st, "bab", "app", "alpha", now) if err != nil || !present { t.Fatalf("remove: %v present=%v", err, present) } if _, ok := d.Find("alpha"); ok { t.Fatal("alpha still declared") } if _, present, _ := RemoveSchedule(ctx, st, "bab", "app", "alpha", now); present { t.Fatal("second remove reports present") } } func TestReadDeclarationAbsentIsEmpty(t *testing.T) { d, etag, err := ReadDeclaration(context.Background(), release.NewDirStore(t.TempDir()), "bab", "app") if err != nil || etag != "" || len(d.Schedules) != 0 { t.Fatalf("%+v %q %v", d, etag, err) } } // ReadDeclaration takes an acct/app straight off a URL path in the console // door; it must refuse a malformed one itself rather than build a bucket // key from it and let the store report a confusing not-found. func TestReadDeclarationRejectsBadIDs(t *testing.T) { ctx := context.Background() st := release.NewDirStore(t.TempDir()) for _, tc := range []struct{ acct, app string }{ {"", "app"}, {"bab", ""}, {"BAB", "app"}, {"bab", "App"}, {"bab/../x", "app"}, } { if _, _, err := ReadDeclaration(ctx, st, tc.acct, tc.app); err == nil { t.Errorf("ReadDeclaration(%q, %q) = nil error, want a refusal", tc.acct, tc.app) } } } func TestCheckIDs(t *testing.T) { if err := checkIDs("bab", "app"); err != nil { t.Fatalf("checkIDs(valid) = %v", err) } for _, tc := range []struct{ acct, app string }{ {"", "app"}, {"bab", ""}, {"BAB", "app"}, {"bab", "App"}, {"b", "app"}, // too short for acctRe {"bab", "-app"}, // appRe wants a leading letter {"bab", "app/../etc"}, } { if err := checkIDs(tc.acct, tc.app); err == nil { t.Errorf("checkIDs(%q, %q) = nil, want a refusal", tc.acct, tc.app) } } } func TestTouchRunMovesTheEtag(t *testing.T) { ctx := context.Background() st := release.NewDirStore(t.TempDir()) if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil { t.Fatal(err) } _, e1, _ := st.Get(ctx, RunKey("bab", "app")) if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil { t.Fatal(err) } _, e2, _ := st.Get(ctx, RunKey("bab", "app")) if e1 == "" || e1 == e2 { t.Fatalf("etag did not move: %q → %q", e1, e2) } } // A cron expression can parse and still never match ("0 0 30 2 *"): its // Next is the zero time, and a box that stored that would have a row due // at -62135596800. Validate refuses it where the member can read why. func TestScheduleValidateRefusesAnExpressionThatNeverFires(t *testing.T) { err := Schedule{Name: "never", Cron: "0 0 30 2 *", Path: "/x"}.Validate() if err == nil || !strings.Contains(err.Error(), "never fires") { t.Fatalf("Validate(never-matching cron) = %v, want a 'never fires' refusal", err) } if err := (Schedule{Name: "leap", Cron: "0 0 29 2 *", Path: "/x"}).Validate(); err != nil { t.Fatalf("Feb 29 is a real (if rare) minute: %v", err) } } // ValidPath runs the parser BuildRequest builds the tick with, so a path it // accepts is a path the runner can send: a bad escape, a scheme or a // host smuggled in front of the path all fail at the terminal. func TestValidPathAgreesWithTheRequestParser(t *testing.T) { for _, bad := range []string{"/jobs/%zz", "/jobs/%", "http://x/y", "//x/y"} { if err := ValidPath(bad); err == nil { t.Errorf("ValidPath(%q) = ok, want a refusal", bad) } } for _, ok := range []string{"/jobs/sync", "/a%20b", "/x?y=1&z=2", "/x#frag"} { if err := ValidPath(ok); err != nil { t.Errorf("ValidPath(%q) = %v", ok, err) } } }