package main // The CLI half of `carlos schedule` (Task 9): the wire shape each of the // four doors requires, the renderer `carlos schedule ls` prints through // (asserted against a fixed clock so it needs no console at all), and the // one client-side refusal (`-every 30s`) that must never reach the network. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os/exec" "strings" "testing" "time" "github.com/carlosframework/platform/internal/schedule" ) // TestAPISessionScheduleListWireShape pins handleAPIScheduleList's shape: a // bearer GET at the app's /schedules path, the account segment carried // verbatim (the console resolves names server-side, never this client), and // the canonical account/app the console echoes back read straight through. func TestAPISessionScheduleListWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") json.NewEncoder(w).Encode(apiScheduleList{ Account: "bab", App: "keymail", Schedules: []schedule.Schedule{{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}}, Instances: []schedule.Status{}, UpdatedAt: "2026-08-23T22:14:09Z", }) })) defer ts.Close() res, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail") if err != nil { t.Fatalf("scheduleList: %v", err) } if gotMethod != http.MethodGet { t.Fatalf("method = %s, want GET", gotMethod) } if gotPath != "/api/cli/apps/keymail-team/keymail/schedules" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } if res.Account != "bab" || res.App != "keymail" { t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) } if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" { t.Fatalf("schedules = %+v", res.Schedules) } } // TestAPISessionScheduleSetWireShape pins handleAPIScheduleSet's shape: a // bearer POST at /schedules whose body is the schedule.Schedule JSON // verbatim — the console re-validates it, this client does not reshape it. func TestAPISessionScheduleSetWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth string var gotBody schedule.Schedule ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{gotBody}, Instances: []schedule.Status{}}) })) defer ts.Close() sc := schedule.Schedule{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"} res, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", sc) if err != nil { t.Fatalf("scheduleSet: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/cli/apps/bab/keymail/schedules" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } if gotBody != sc { t.Fatalf("body = %+v, want %+v sent verbatim", gotBody, sc) } if res.Account != "bab" || res.App != "keymail" { t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) } } // TestAPISessionScheduleRemoveWireShape pins handleAPIScheduleRemove's // shape: a bearer POST at /schedules/{name}/delete. func TestAPISessionScheduleRemoveWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{}, Instances: []schedule.Status{}}) })) defer ts.Close() res, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync") if err != nil { t.Fatalf("scheduleRemove: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/cli/apps/bab/keymail/schedules/sync/delete" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } if res.Account != "bab" || res.App != "keymail" { t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) } } // TestAPISessionScheduleRunWireShape pins handleAPIScheduleRun's shape: a // bearer POST at /schedules/{name}/run, with the account segment carried // verbatim (restart's own rule — the console resolves it, not this // client). The typed segment ("keymail-team") deliberately differs from // what the fake server answers ("bab"), so a passing canonical-name // assertion proves the response was actually decoded and returned, not // just echoed from the request. func TestAPISessionScheduleRunWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail", "name": "sync"}) })) defer ts.Close() res, err := newTestSession(ts.URL).scheduleRun(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "sync") if err != nil { t.Fatalf("scheduleRun: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/cli/apps/keymail-team/keymail/schedules/sync/run" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } // The CANONICAL names come back from the console, not from what was // typed — restart's own assertion (TestAPISessionRestartWireShape). if !res.Requested || res.Account != "bab" || res.App != "keymail" || res.Name != "sync" { t.Fatalf("result = %+v, want the console's canonical bab/keymail/sync", res) } } // TestAPISessionScheduleDoorNotFound404 covers ls/set (no {name} segment): // the byte-identical 404 apiAccountApp gives an unknown account, someone // else's account, an app that isn't there, OR an old console with no // schedule door at all — nothing in the response tells those apart, so the // message must name every one. func TestAPISessionScheduleDoorNotFound404(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() _, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "bab"}, "keymail") if err == nil { t.Fatal("a 404 must error") } if !strings.Contains(err.Error(), "-account") { t.Fatalf("error %q drops the membership explanation", err) } if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "carlos schedule") { t.Fatalf("error %q does not name the old-console cause", err) } } // TestAPISessionScheduleNameNotFound404 covers rm/run's {name} segment: the // SAME byte-identical 404 (declaredSchedule) also means "-name was never // declared" — a third cause the ls/set message cannot know to mention. func TestAPISessionScheduleNameNotFound404(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() _, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "ghost") if err == nil { t.Fatal("a 404 must error") } if !strings.Contains(err.Error(), "--name is not declared") { t.Fatalf("error %q drops the undeclared-name explanation", err) } if !strings.Contains(err.Error(), "-account") || !strings.Contains(err.Error(), "predates") { t.Fatalf("error %q drops the other two causes", err) } } // TestAPISessionScheduleServerErrorSurfacesVerbatim: a door that answers // with a real diagnosis (the shared write budget, an over-cap declaration) // must reach the terminal in the console's own words. func TestAPISessionScheduleServerErrorSurfacesVerbatim(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"}) })) defer ts.Close() _, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", schedule.Schedule{Name: "sync", Every: "1h", Path: "/jobs/sync"}) if err == nil || !strings.Contains(err.Error(), "wait a minute") { t.Fatalf("schedule set 429 = %v, want the console's own text", err) } } // TestRenderScheduleList pins the exact layout the brief specifies: the // declared-schedules table, a blank line, then one block per instance — // against a FIXED clock, so relative times ("next in 4h52m") are // deterministic instead of racing wall-clock time. func TestRenderScheduleList(t *testing.T) { now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC) // matches the brief's "reported 12s ago" res := apiScheduleList{ Account: "bab", App: "app", UpdatedAt: "2026-08-23T22:14:09Z", Schedules: []schedule.Schedule{ {Name: "reminders", Cron: "0 8 * * *", Path: "/jobs/reminders"}, {Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}, }, Instances: []schedule.Status{ { Host: "a.bab.oncarlos.com", Box: "carlos-ie-1", ReportedAt: now.Add(-12 * time.Second).Format(time.RFC3339), Timers: []schedule.TimerStatus{ { Name: "sync", NextAt: now.Add(4*time.Hour + 52*time.Minute).Unix(), LastAt: now.Add(-(1*time.Hour + 8*time.Minute)).Unix(), LastStatus: "204", LastMs: 3200, }, { Name: "reminders", NextAt: now.Add(9*time.Hour + 53*time.Minute).Unix(), }, { Name: "reminders", Kind: schedule.KindOnce, NextAt: now.Add(30 * time.Second).Unix(), }, { Name: "leapless", LastStatus: schedule.StatusNeverFires, }, }, }, { Host: "u.bab.oncarlos.com", Unsupported: schedule.UnsupportedUnit, }, }, } var buf strings.Builder renderScheduleList(&buf, res, now) want := "schedules for bab/app (updated 2026-08-23T22:14:09Z)\n" + " NAME WHEN PATH\n" + " reminders cron 0 8 * * * /jobs/reminders\n" + " sync every 6h /jobs/sync\n" + "\n" + "instances\n" + " a.bab.oncarlos.com (box carlos-ie-1, reported 12s ago)\n" + " sync next in 4h52m last 1h08m ago → 204 in 3.2s\n" + " reminders next in 9h53m last —\n" + " reminders (once) next in 30s last —\n" + " leapless next — last — (never fires)\n" + " u.bab.oncarlos.com unit-backed instance — scheduled work delivers to exec-backed instances in this release\n" if got := buf.String(); got != want { t.Fatalf("renderScheduleList =\n%s\nwant\n%s", got, want) } } // TestRenderScheduleListEmpty pins the two empty-state sentences: no // schedules declared, and no instance has reported. func TestRenderScheduleListEmpty(t *testing.T) { now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC) res := apiScheduleList{Account: "bab", App: "app"} var buf strings.Builder renderScheduleList(&buf, res, now) want := "no schedules declared for bab/app — carlos schedule set --app app --name --every 6h --path /jobs/\n" + "\n" + "instances\n" + " no instance has reported yet — boxes report within seconds of a schedule landing\n" if got := buf.String(); got != want { t.Fatalf("renderScheduleList (empty) =\n%s\nwant\n%s", got, want) } } // TestRenderScheduleListNilSlices proves the renderer never panics on a nil // Schedules/Instances/Timers — the shape a pre-scheduleAnswer console (or a // hand-built apiScheduleList{} in a test) can still hand it, even though // scheduleAnswer itself always fills `[]`. func TestRenderScheduleListNilSlices(t *testing.T) { var buf strings.Builder renderScheduleList(&buf, apiScheduleList{Account: "bab", App: "app"}, time.Now()) if buf.Len() == 0 { t.Fatal("renderScheduleList wrote nothing") } } func TestScheduleLsFlagsAHoldsAwakeRow(t *testing.T) { var b bytes.Buffer now := time.Unix(1788126000, 0).UTC() inst := schedule.Status{ Schema: 1, Host: "jam.bac.oncarlos.com", Box: "annie-welding", ReportedAt: now.Format(time.RFC3339), Timers: []schedule.TimerStatus{{ Name: "sweep", Kind: schedule.KindSchedule, Spec: "every 5m", Path: "/jobs/sweep", NextAt: now.Add(2 * time.Minute).Unix(), LastAt: now.Add(-3 * time.Minute).Unix(), LastStatus: "204", LastMs: 1, HoldsAwake: true, }}, } renderScheduleInstance(&b, inst, now) out := b.String() if !strings.Contains(out, "sweep") { t.Fatalf("row missing from output:\n%s", out) } if !strings.Contains(out, "never hibernates") { t.Errorf("no holds-awake warning in output:\n%s", out) } } func TestScheduleLsSaysNothingForAnOrdinaryRow(t *testing.T) { var b bytes.Buffer now := time.Unix(1788126000, 0).UTC() inst := schedule.Status{ Schema: 1, Host: "seapointish.bag.oncarlos.com", Box: "annie-welding", ReportedAt: now.Format(time.RFC3339), Timers: []schedule.TimerStatus{{ Name: "sync", Kind: schedule.KindSchedule, Spec: "every 6h", Path: "/jobs/sync", NextAt: now.Add(time.Hour).Unix(), }}, } renderScheduleInstance(&b, inst, now) if strings.Contains(b.String(), "never hibernates") { t.Errorf("warned about a 6-hourly schedule:\n%s", b.String()) } } // TestScheduleVerbRefusals drives the real binary for the ways `carlos // schedule` stops before it ever dials anything: direct bucket mode, and // -every 30s (schedule.ValidEvery's "whole minutes only" rule) — run with // NO console configured at all, so a passing test proves the validation // error is what stopped it, not a session failure that happened to look // similar. Both run through carlosTestEnv with a scratch cmd.Dir — the // repo rule — so a logged-in laptop's real credential and console can // never be what this test exercises. One buildCarlos call covers both, // restart_test.go's TestRestartVerbRefusals shape, because the build is // the expensive part and everything after it is a fast subprocess run. func TestScheduleVerbRefusals(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() run := func(env []string, args ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, env...) cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } // Direct bucket mode: refused in schedule's OWN words, naming the // login that fixes it — never another verb's advice. out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "schedule", "ls", "-app", "keymail") if err == nil { t.Fatalf("schedule ls in direct bucket mode exited 0:\n%s", out) } if !strings.Contains(out, "carlos schedule works through a console") || !strings.Contains(out, "carlos auth login") { t.Fatalf("direct-mode refusal = %q, want schedule's own message and the login fix", out) } // -every 30s fails schedule.ValidEvery ("shortest interval is 1m" — 30s // is under MinEvery, checked before the whole-minutes rule) entirely // client-side, in schedule.go's runScheduleSet, BEFORE // requireScheduleAPISession ever runs — so this must fail on the // validation message even with no console configured at all, never on // "no deployment target configured" or a login prompt. out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "30s", "-path", "/jobs/sync") if err == nil { t.Fatalf("schedule set --every 30s exited 0:\n%s", out) } if !strings.Contains(out, "shortest interval is 1m") { t.Fatalf("schedule set --every 30s = %q, want the ValidEvery refusal, unreached network", out) } if strings.Contains(out, "no deployment target") || strings.Contains(out, "auth login") { t.Fatalf("schedule set --every 30s = %q, reached session resolution before validating", out) } // -every 90s is >= the 1m floor but not a whole minute — the OTHER // ValidEvery clause ("whole minutes only"), also unreached network. out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "90s", "-path", "/jobs/sync") if err == nil { t.Fatalf("schedule set --every 90s exited 0:\n%s", out) } if !strings.Contains(out, "whole minutes only") { t.Fatalf("schedule set --every 90s = %q, want the whole-minutes refusal", out) } // Giving both -every and -cron is the same client-side gate // (Schedule.Spec's "give -every or -cron, not both"), also unreached // network. out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "1h", "-cron", "0 8 * * *", "-path", "/jobs/sync") if err == nil { t.Fatalf("schedule set with both --every and --cron exited 0:\n%s", out) } if !strings.Contains(out, "not both") { t.Fatalf("schedule set both = %q, want the give-one-or-the-other refusal", out) } // The usage line lands under "Your apps", after restart, and reads // EXACTLY the spec's mandated string (spec §9) — carlos help is the // one place a member finds a verb exists at all, and a paraphrase // there is a spec violation even if every word in it is true. out, err = run(nil, "help") if err != nil { t.Fatalf("carlos help exited nonzero:\n%s", out) } const wantLine = " schedule recurring or one-shot work per app: ls | set | rm | run" if !strings.Contains(out, wantLine) { t.Fatalf("carlos help does not carry the exact spec usage line %q:\n%s", wantLine, out) } restartAt := strings.Index(out, "\n restart ") scheduleAt := strings.Index(out, "\n"+wantLine) if restartAt == -1 || scheduleAt == -1 || scheduleAt < restartAt { t.Fatalf("carlos help does not list schedule after restart:\n%s", out) } }