carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The CLI half of `carlos schedule` (Task 9): the wire shape each of the
4// four doors requires, the renderer `carlos schedule ls` prints through
5// (asserted against a fixed clock so it needs no console at all), and the
6// one client-side refusal (`-every 30s`) that must never reach the network.
7
8import (
9 "bytes"
10 "context"
11 "encoding/json"
12 "net/http"
13 "net/http/httptest"
14 "os/exec"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/carlosframework/platform/internal/schedule"
20)
21
22// TestAPISessionScheduleListWireShape pins handleAPIScheduleList's shape: a
23// bearer GET at the app's /schedules path, the account segment carried
24// verbatim (the console resolves names server-side, never this client), and
25// the canonical account/app the console echoes back read straight through.
26func TestAPISessionScheduleListWireShape(t *testing.T) {
27 var gotPath, gotMethod, gotAuth string
28 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
30 json.NewEncoder(w).Encode(apiScheduleList{
31 Account: "bab", App: "keymail",
32 Schedules: []schedule.Schedule{{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}},
33 Instances: []schedule.Status{},
34 UpdatedAt: "2026-08-23T22:14:09Z",
35 })
36 }))
37 defer ts.Close()
38
39 res, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail")
40 if err != nil {
41 t.Fatalf("scheduleList: %v", err)
42 }
43 if gotMethod != http.MethodGet {
44 t.Fatalf("method = %s, want GET", gotMethod)
45 }
46 if gotPath != "/api/cli/apps/keymail-team/keymail/schedules" {
47 t.Fatalf("path = %s", gotPath)
48 }
49 if gotAuth != "Bearer cli-token" {
50 t.Fatalf("auth = %q", gotAuth)
51 }
52 if res.Account != "bab" || res.App != "keymail" {
53 t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
54 }
55 if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" {
56 t.Fatalf("schedules = %+v", res.Schedules)
57 }
58}
59
60// TestAPISessionScheduleSetWireShape pins handleAPIScheduleSet's shape: a
61// bearer POST at /schedules whose body is the schedule.Schedule JSON
62// verbatim — the console re-validates it, this client does not reshape it.
63func TestAPISessionScheduleSetWireShape(t *testing.T) {
64 var gotPath, gotMethod, gotAuth string
65 var gotBody schedule.Schedule
66 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
68 json.NewDecoder(r.Body).Decode(&gotBody)
69 json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{gotBody}, Instances: []schedule.Status{}})
70 }))
71 defer ts.Close()
72
73 sc := schedule.Schedule{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}
74 res, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", sc)
75 if err != nil {
76 t.Fatalf("scheduleSet: %v", err)
77 }
78 if gotMethod != http.MethodPost {
79 t.Fatalf("method = %s, want POST", gotMethod)
80 }
81 if gotPath != "/api/cli/apps/bab/keymail/schedules" {
82 t.Fatalf("path = %s", gotPath)
83 }
84 if gotAuth != "Bearer cli-token" {
85 t.Fatalf("auth = %q", gotAuth)
86 }
87 if gotBody != sc {
88 t.Fatalf("body = %+v, want %+v sent verbatim", gotBody, sc)
89 }
90 if res.Account != "bab" || res.App != "keymail" {
91 t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
92 }
93}
94
95// TestAPISessionScheduleRemoveWireShape pins handleAPIScheduleRemove's
96// shape: a bearer POST at /schedules/{name}/delete.
97func TestAPISessionScheduleRemoveWireShape(t *testing.T) {
98 var gotPath, gotMethod, gotAuth string
99 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
101 json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{}, Instances: []schedule.Status{}})
102 }))
103 defer ts.Close()
104
105 res, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync")
106 if err != nil {
107 t.Fatalf("scheduleRemove: %v", err)
108 }
109 if gotMethod != http.MethodPost {
110 t.Fatalf("method = %s, want POST", gotMethod)
111 }
112 if gotPath != "/api/cli/apps/bab/keymail/schedules/sync/delete" {
113 t.Fatalf("path = %s", gotPath)
114 }
115 if gotAuth != "Bearer cli-token" {
116 t.Fatalf("auth = %q", gotAuth)
117 }
118 if res.Account != "bab" || res.App != "keymail" {
119 t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
120 }
121}
122
123// TestAPISessionScheduleRunWireShape pins handleAPIScheduleRun's shape: a
124// bearer POST at /schedules/{name}/run, with the account segment carried
125// verbatim (restart's own rule — the console resolves it, not this
126// client). The typed segment ("keymail-team") deliberately differs from
127// what the fake server answers ("bab"), so a passing canonical-name
128// assertion proves the response was actually decoded and returned, not
129// just echoed from the request.
130func TestAPISessionScheduleRunWireShape(t *testing.T) {
131 var gotPath, gotMethod, gotAuth string
132 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
134 json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail", "name": "sync"})
135 }))
136 defer ts.Close()
137
138 res, err := newTestSession(ts.URL).scheduleRun(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "sync")
139 if err != nil {
140 t.Fatalf("scheduleRun: %v", err)
141 }
142 if gotMethod != http.MethodPost {
143 t.Fatalf("method = %s, want POST", gotMethod)
144 }
145 if gotPath != "/api/cli/apps/keymail-team/keymail/schedules/sync/run" {
146 t.Fatalf("path = %s", gotPath)
147 }
148 if gotAuth != "Bearer cli-token" {
149 t.Fatalf("auth = %q", gotAuth)
150 }
151 // The CANONICAL names come back from the console, not from what was
152 // typed — restart's own assertion (TestAPISessionRestartWireShape).
153 if !res.Requested || res.Account != "bab" || res.App != "keymail" || res.Name != "sync" {
154 t.Fatalf("result = %+v, want the console's canonical bab/keymail/sync", res)
155 }
156}
157
158// TestAPISessionScheduleDoorNotFound404 covers ls/set (no {name} segment):
159// the byte-identical 404 apiAccountApp gives an unknown account, someone
160// else's account, an app that isn't there, OR an old console with no
161// schedule door at all — nothing in the response tells those apart, so the
162// message must name every one.
163func TestAPISessionScheduleDoorNotFound404(t *testing.T) {
164 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165 http.NotFound(w, r)
166 }))
167 defer ts.Close()
168
169 _, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "bab"}, "keymail")
170 if err == nil {
171 t.Fatal("a 404 must error")
172 }
173 if !strings.Contains(err.Error(), "-account") {
174 t.Fatalf("error %q drops the membership explanation", err)
175 }
176 if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "carlos schedule") {
177 t.Fatalf("error %q does not name the old-console cause", err)
178 }
179}
180
181// TestAPISessionScheduleNameNotFound404 covers rm/run's {name} segment: the
182// SAME byte-identical 404 (declaredSchedule) also means "-name was never
183// declared" — a third cause the ls/set message cannot know to mention.
184func TestAPISessionScheduleNameNotFound404(t *testing.T) {
185 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186 http.NotFound(w, r)
187 }))
188 defer ts.Close()
189
190 _, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "ghost")
191 if err == nil {
192 t.Fatal("a 404 must error")
193 }
194 if !strings.Contains(err.Error(), "--name is not declared") {
195 t.Fatalf("error %q drops the undeclared-name explanation", err)
196 }
197 if !strings.Contains(err.Error(), "-account") || !strings.Contains(err.Error(), "predates") {
198 t.Fatalf("error %q drops the other two causes", err)
199 }
200}
201
202// TestAPISessionScheduleServerErrorSurfacesVerbatim: a door that answers
203// with a real diagnosis (the shared write budget, an over-cap declaration)
204// must reach the terminal in the console's own words.
205func TestAPISessionScheduleServerErrorSurfacesVerbatim(t *testing.T) {
206 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207 w.WriteHeader(http.StatusTooManyRequests)
208 json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"})
209 }))
210 defer ts.Close()
211
212 _, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", schedule.Schedule{Name: "sync", Every: "1h", Path: "/jobs/sync"})
213 if err == nil || !strings.Contains(err.Error(), "wait a minute") {
214 t.Fatalf("schedule set 429 = %v, want the console's own text", err)
215 }
216}
217
218// TestRenderScheduleList pins the exact layout the brief specifies: the
219// declared-schedules table, a blank line, then one block per instance —
220// against a FIXED clock, so relative times ("next in 4h52m") are
221// deterministic instead of racing wall-clock time.
222func TestRenderScheduleList(t *testing.T) {
223 now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC) // matches the brief's "reported 12s ago"
224 res := apiScheduleList{
225 Account: "bab", App: "app",
226 UpdatedAt: "2026-08-23T22:14:09Z",
227 Schedules: []schedule.Schedule{
228 {Name: "reminders", Cron: "0 8 * * *", Path: "/jobs/reminders"},
229 {Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"},
230 },
231 Instances: []schedule.Status{
232 {
233 Host: "a.bab.oncarlos.com", Box: "carlos-ie-1",
234 ReportedAt: now.Add(-12 * time.Second).Format(time.RFC3339),
235 Timers: []schedule.TimerStatus{
236 {
237 Name: "sync",
238 NextAt: now.Add(4*time.Hour + 52*time.Minute).Unix(),
239 LastAt: now.Add(-(1*time.Hour + 8*time.Minute)).Unix(),
240 LastStatus: "204",
241 LastMs: 3200,
242 },
243 {
244 Name: "reminders",
245 NextAt: now.Add(9*time.Hour + 53*time.Minute).Unix(),
246 },
247 {
248 Name: "reminders",
249 Kind: schedule.KindOnce,
250 NextAt: now.Add(30 * time.Second).Unix(),
251 },
252 {
253 Name: "leapless",
254 LastStatus: schedule.StatusNeverFires,
255 },
256 },
257 },
258 {
259 Host: "u.bab.oncarlos.com",
260 Unsupported: schedule.UnsupportedUnit,
261 },
262 },
263 }
264
265 var buf strings.Builder
266 renderScheduleList(&buf, res, now)
267
268 want := "schedules for bab/app (updated 2026-08-23T22:14:09Z)\n" +
269 " NAME WHEN PATH\n" +
270 " reminders cron 0 8 * * * /jobs/reminders\n" +
271 " sync every 6h /jobs/sync\n" +
272 "\n" +
273 "instances\n" +
274 " a.bab.oncarlos.com (box carlos-ie-1, reported 12s ago)\n" +
275 " sync next in 4h52m last 1h08m ago → 204 in 3.2s\n" +
276 " reminders next in 9h53m last —\n" +
277 " reminders (once) next in 30s last —\n" +
278 " leapless next — last — (never fires)\n" +
279 " u.bab.oncarlos.com unit-backed instance — scheduled work delivers to exec-backed instances in this release\n"
280 if got := buf.String(); got != want {
281 t.Fatalf("renderScheduleList =\n%s\nwant\n%s", got, want)
282 }
283}
284
285// TestRenderScheduleListEmpty pins the two empty-state sentences: no
286// schedules declared, and no instance has reported.
287func TestRenderScheduleListEmpty(t *testing.T) {
288 now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC)
289 res := apiScheduleList{Account: "bab", App: "app"}
290
291 var buf strings.Builder
292 renderScheduleList(&buf, res, now)
293
294 want := "no schedules declared for bab/app — carlos schedule set --app app --name <n> --every 6h --path /jobs/<n>\n" +
295 "\n" +
296 "instances\n" +
297 " no instance has reported yet — boxes report within seconds of a schedule landing\n"
298 if got := buf.String(); got != want {
299 t.Fatalf("renderScheduleList (empty) =\n%s\nwant\n%s", got, want)
300 }
301}
302
303// TestRenderScheduleListNilSlices proves the renderer never panics on a nil
304// Schedules/Instances/Timers — the shape a pre-scheduleAnswer console (or a
305// hand-built apiScheduleList{} in a test) can still hand it, even though
306// scheduleAnswer itself always fills `[]`.
307func TestRenderScheduleListNilSlices(t *testing.T) {
308 var buf strings.Builder
309 renderScheduleList(&buf, apiScheduleList{Account: "bab", App: "app"}, time.Now())
310 if buf.Len() == 0 {
311 t.Fatal("renderScheduleList wrote nothing")
312 }
313}
314
315func TestScheduleLsFlagsAHoldsAwakeRow(t *testing.T) {
316 var b bytes.Buffer
317 now := time.Unix(1788126000, 0).UTC()
318 inst := schedule.Status{
319 Schema: 1, Host: "jam.bac.oncarlos.com", Box: "annie-welding",
320 ReportedAt: now.Format(time.RFC3339),
321 Timers: []schedule.TimerStatus{{
322 Name: "sweep", Kind: schedule.KindSchedule, Spec: "every 5m",
323 Path: "/jobs/sweep", NextAt: now.Add(2 * time.Minute).Unix(),
324 LastAt: now.Add(-3 * time.Minute).Unix(), LastStatus: "204", LastMs: 1,
325 HoldsAwake: true,
326 }},
327 }
328 renderScheduleInstance(&b, inst, now)
329
330 out := b.String()
331 if !strings.Contains(out, "sweep") {
332 t.Fatalf("row missing from output:\n%s", out)
333 }
334 if !strings.Contains(out, "never hibernates") {
335 t.Errorf("no holds-awake warning in output:\n%s", out)
336 }
337}
338
339func TestScheduleLsSaysNothingForAnOrdinaryRow(t *testing.T) {
340 var b bytes.Buffer
341 now := time.Unix(1788126000, 0).UTC()
342 inst := schedule.Status{
343 Schema: 1, Host: "seapointish.bag.oncarlos.com", Box: "annie-welding",
344 ReportedAt: now.Format(time.RFC3339),
345 Timers: []schedule.TimerStatus{{
346 Name: "sync", Kind: schedule.KindSchedule, Spec: "every 6h",
347 Path: "/jobs/sync", NextAt: now.Add(time.Hour).Unix(),
348 }},
349 }
350 renderScheduleInstance(&b, inst, now)
351
352 if strings.Contains(b.String(), "never hibernates") {
353 t.Errorf("warned about a 6-hourly schedule:\n%s", b.String())
354 }
355}
356
357// TestScheduleVerbRefusals drives the real binary for the ways `carlos
358// schedule` stops before it ever dials anything: direct bucket mode, and
359// -every 30s (schedule.ValidEvery's "whole minutes only" rule) — run with
360// NO console configured at all, so a passing test proves the validation
361// error is what stopped it, not a session failure that happened to look
362// similar. Both run through carlosTestEnv with a scratch cmd.Dir — the
363// repo rule — so a logged-in laptop's real credential and console can
364// never be what this test exercises. One buildCarlos call covers both,
365// restart_test.go's TestRestartVerbRefusals shape, because the build is
366// the expensive part and everything after it is a fast subprocess run.
367func TestScheduleVerbRefusals(t *testing.T) {
368 bin := buildCarlos(t)
369 scratch := t.TempDir()
370 run := func(env []string, args ...string) (string, error) {
371 t.Helper()
372 cmd := exec.Command(bin, args...)
373 cmd.Env = carlosTestEnv(t, env...)
374 cmd.Dir = scratch
375 out, err := cmd.CombinedOutput()
376 return string(out), err
377 }
378
379 // Direct bucket mode: refused in schedule's OWN words, naming the
380 // login that fixes it — never another verb's advice.
381 out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "schedule", "ls", "-app", "keymail")
382 if err == nil {
383 t.Fatalf("schedule ls in direct bucket mode exited 0:\n%s", out)
384 }
385 if !strings.Contains(out, "carlos schedule works through a console") ||
386 !strings.Contains(out, "carlos auth login") {
387 t.Fatalf("direct-mode refusal = %q, want schedule's own message and the login fix", out)
388 }
389
390 // -every 30s fails schedule.ValidEvery ("shortest interval is 1m" — 30s
391 // is under MinEvery, checked before the whole-minutes rule) entirely
392 // client-side, in schedule.go's runScheduleSet, BEFORE
393 // requireScheduleAPISession ever runs — so this must fail on the
394 // validation message even with no console configured at all, never on
395 // "no deployment target configured" or a login prompt.
396 out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "30s", "-path", "/jobs/sync")
397 if err == nil {
398 t.Fatalf("schedule set --every 30s exited 0:\n%s", out)
399 }
400 if !strings.Contains(out, "shortest interval is 1m") {
401 t.Fatalf("schedule set --every 30s = %q, want the ValidEvery refusal, unreached network", out)
402 }
403 if strings.Contains(out, "no deployment target") || strings.Contains(out, "auth login") {
404 t.Fatalf("schedule set --every 30s = %q, reached session resolution before validating", out)
405 }
406
407 // -every 90s is >= the 1m floor but not a whole minute — the OTHER
408 // ValidEvery clause ("whole minutes only"), also unreached network.
409 out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "90s", "-path", "/jobs/sync")
410 if err == nil {
411 t.Fatalf("schedule set --every 90s exited 0:\n%s", out)
412 }
413 if !strings.Contains(out, "whole minutes only") {
414 t.Fatalf("schedule set --every 90s = %q, want the whole-minutes refusal", out)
415 }
416
417 // Giving both -every and -cron is the same client-side gate
418 // (Schedule.Spec's "give -every or -cron, not both"), also unreached
419 // network.
420 out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "1h", "-cron", "0 8 * * *", "-path", "/jobs/sync")
421 if err == nil {
422 t.Fatalf("schedule set with both --every and --cron exited 0:\n%s", out)
423 }
424 if !strings.Contains(out, "not both") {
425 t.Fatalf("schedule set both = %q, want the give-one-or-the-other refusal", out)
426 }
427
428 // The usage line lands under "Your apps", after restart, and reads
429 // EXACTLY the spec's mandated string (spec §9) — carlos help is the
430 // one place a member finds a verb exists at all, and a paraphrase
431 // there is a spec violation even if every word in it is true.
432 out, err = run(nil, "help")
433 if err != nil {
434 t.Fatalf("carlos help exited nonzero:\n%s", out)
435 }
436 const wantLine = " schedule recurring or one-shot work per app: ls | set | rm | run"
437 if !strings.Contains(out, wantLine) {
438 t.Fatalf("carlos help does not carry the exact spec usage line %q:\n%s", wantLine, out)
439 }
440 restartAt := strings.Index(out, "\n restart ")
441 scheduleAt := strings.Index(out, "\n"+wantLine)
442 if restartAt == -1 || scheduleAt == -1 || scheduleAt < restartAt {
443 t.Fatalf("carlos help does not list schedule after restart:\n%s", out)
444 }
445}
446