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 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/release"
11)
12
13func TestValidNameAndPath(t *testing.T) {
14 for _, ok := range []string{"a", "sync", "daily-digest", "x1", strings.Repeat("a", 32)} {
15 if !ValidName(ok) {
16 t.Errorf("ValidName(%q) = false", ok)
17 }
18 }
19 for _, bad := range []string{"", "-a", "A", "a_b", "a b", strings.Repeat("a", 33), "sync/"} {
20 if ValidName(bad) {
21 t.Errorf("ValidName(%q) = true", bad)
22 }
23 }
24 for _, ok := range []string{"/", "/jobs/sync", "/a/b-c_d.e?x=1"} {
25 if err := ValidPath(ok); err != nil {
26 t.Errorf("ValidPath(%q) = %v", ok, err)
27 }
28 }
29 for _, bad := range []string{"", "jobs/sync", "/jobs/../etc", "https://x/y", "//host/p", "/" + strings.Repeat("a", 256)} {
30 if err := ValidPath(bad); err == nil {
31 t.Errorf("ValidPath(%q) = ok", bad)
32 }
33 }
34}
35
36func TestScheduleValidateNormalisesEvery(t *testing.T) {
37 s := Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}
38 if err := s.Validate(); err != nil {
39 t.Fatal(err)
40 }
41 if _, err := (Schedule{Name: "x", Every: "6h", Cron: "* * * * *", Path: "/p"}).Spec(); err == nil {
42 t.Error("both every and cron accepted")
43 }
44 if err := (Schedule{Name: "x", Path: "/p"}).Validate(); err == nil {
45 t.Error("neither every nor cron accepted")
46 }
47 if err := (Schedule{Name: "x", Every: "30s", Path: "/p"}).Validate(); err == nil {
48 t.Error("30s accepted")
49 }
50}
51
52func TestUpsertRemoveAndCAS(t *testing.T) {
53 ctx := context.Background()
54 st := release.NewDirStore(t.TempDir())
55 now := at("2026-08-23T22:00:00Z")
56 d, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if len(d.Schedules) != 1 || d.Schema != 1 || d.UpdatedAt != "2026-08-23T22:00:00Z" {
61 t.Fatalf("declaration = %+v", d)
62 }
63 // Upsert by name replaces; a second name sorts in.
64 d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "12h", Path: "/jobs/sync"}, now)
65 d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "alpha", Cron: "0 8 * * *", Path: "/jobs/a"}, now)
66 if len(d.Schedules) != 2 || d.Schedules[0].Name != "alpha" || d.Schedules[1].Every != "12h0m0s" {
67 t.Fatalf("declaration = %+v", d)
68 }
69 raw, _, _ := st.Get(ctx, DeclarationKey("bab", "app"))
70 var onDisk Declaration
71 if err := json.Unmarshal(raw, &onDisk); err != nil || len(onDisk.Schedules) != 2 {
72 t.Fatalf("on disk: %s %v", raw, err)
73 }
74 // The cap.
75 for i := 0; i < MaxSchedulesPerApp; i++ {
76 UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "n" + string(rune('a'+i)), Every: "1h", Path: "/p"}, now)
77 }
78 if _, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "one-too-many", Every: "1h", Path: "/p"}, now); err == nil {
79 t.Fatal("cap not enforced")
80 }
81 d, present, err := RemoveSchedule(ctx, st, "bab", "app", "alpha", now)
82 if err != nil || !present {
83 t.Fatalf("remove: %v present=%v", err, present)
84 }
85 if _, ok := d.Find("alpha"); ok {
86 t.Fatal("alpha still declared")
87 }
88 if _, present, _ := RemoveSchedule(ctx, st, "bab", "app", "alpha", now); present {
89 t.Fatal("second remove reports present")
90 }
91}
92
93func TestReadDeclarationAbsentIsEmpty(t *testing.T) {
94 d, etag, err := ReadDeclaration(context.Background(), release.NewDirStore(t.TempDir()), "bab", "app")
95 if err != nil || etag != "" || len(d.Schedules) != 0 {
96 t.Fatalf("%+v %q %v", d, etag, err)
97 }
98}
99
100// ReadDeclaration takes an acct/app straight off a URL path in the console
101// door; it must refuse a malformed one itself rather than build a bucket
102// key from it and let the store report a confusing not-found.
103func TestReadDeclarationRejectsBadIDs(t *testing.T) {
104 ctx := context.Background()
105 st := release.NewDirStore(t.TempDir())
106 for _, tc := range []struct{ acct, app string }{
107 {"", "app"},
108 {"bab", ""},
109 {"BAB", "app"},
110 {"bab", "App"},
111 {"bab/../x", "app"},
112 } {
113 if _, _, err := ReadDeclaration(ctx, st, tc.acct, tc.app); err == nil {
114 t.Errorf("ReadDeclaration(%q, %q) = nil error, want a refusal", tc.acct, tc.app)
115 }
116 }
117}
118
119func TestCheckIDs(t *testing.T) {
120 if err := checkIDs("bab", "app"); err != nil {
121 t.Fatalf("checkIDs(valid) = %v", err)
122 }
123 for _, tc := range []struct{ acct, app string }{
124 {"", "app"},
125 {"bab", ""},
126 {"BAB", "app"},
127 {"bab", "App"},
128 {"b", "app"}, // too short for acctRe
129 {"bab", "-app"}, // appRe wants a leading letter
130 {"bab", "app/../etc"},
131 } {
132 if err := checkIDs(tc.acct, tc.app); err == nil {
133 t.Errorf("checkIDs(%q, %q) = nil, want a refusal", tc.acct, tc.app)
134 }
135 }
136}
137
138func TestTouchRunMovesTheEtag(t *testing.T) {
139 ctx := context.Background()
140 st := release.NewDirStore(t.TempDir())
141 if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil {
142 t.Fatal(err)
143 }
144 _, e1, _ := st.Get(ctx, RunKey("bab", "app"))
145 if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil {
146 t.Fatal(err)
147 }
148 _, e2, _ := st.Get(ctx, RunKey("bab", "app"))
149 if e1 == "" || e1 == e2 {
150 t.Fatalf("etag did not move: %q → %q", e1, e2)
151 }
152}
153
154// A cron expression can parse and still never match ("0 0 30 2 *"): its
155// Next is the zero time, and a box that stored that would have a row due
156// at -62135596800. Validate refuses it where the member can read why.
157func TestScheduleValidateRefusesAnExpressionThatNeverFires(t *testing.T) {
158 err := Schedule{Name: "never", Cron: "0 0 30 2 *", Path: "/x"}.Validate()
159 if err == nil || !strings.Contains(err.Error(), "never fires") {
160 t.Fatalf("Validate(never-matching cron) = %v, want a 'never fires' refusal", err)
161 }
162 if err := (Schedule{Name: "leap", Cron: "0 0 29 2 *", Path: "/x"}).Validate(); err != nil {
163 t.Fatalf("Feb 29 is a real (if rare) minute: %v", err)
164 }
165}
166
167// ValidPath runs the parser BuildRequest builds the tick with, so a path it
168// accepts is a path the runner can send: a bad escape, a scheme or a
169// host smuggled in front of the path all fail at the terminal.
170func TestValidPathAgreesWithTheRequestParser(t *testing.T) {
171 for _, bad := range []string{"/jobs/%zz", "/jobs/%", "http://x/y", "//x/y"} {
172 if err := ValidPath(bad); err == nil {
173 t.Errorf("ValidPath(%q) = ok, want a refusal", bad)
174 }
175 }
176 for _, ok := range []string{"/jobs/sync", "/a%20b", "/x?y=1&z=2", "/x#frag"} {
177 if err := ValidPath(ok); err != nil {
178 t.Errorf("ValidPath(%q) = %v", ok, err)
179 }
180 }
181}
182