Files
Felis/internal/api/schedules_test.go

713 lines
28 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
)
var _ ServerSchedules = (*PGRepo)(nil)
// fakeSchedules is an in-memory ServerSchedules with the compare-and-set
// conditions of the PGRepo queries (internal/pgint/schedules_test.go runs the
// same cases against Postgres). It hands out copies, so a test sees only what
// was written through the interface.
type fakeSchedules struct {
rows map[int64]*Schedule
owners map[string]string // server -> its owner now, for DueSchedules
nextID int64
claims int
}
func newFakeSchedules() *fakeSchedules {
return &fakeSchedules{rows: map[int64]*Schedule{}, owners: map[string]string{}}
}
func cloneSchedule(s *Schedule) *Schedule {
c := *s
for _, p := range []**time.Time{&c.NextRunAt, &c.WarnedFor, &c.RunStepAt, &c.LastRunAt} {
if *p != nil {
*p = ptrTime(**p)
}
}
return &c
}
func sameTime(p *time.Time, t time.Time) bool { return p != nil && p.Equal(t) }
// put stores s as it is and returns its id.
func (f *fakeSchedules) put(s Schedule) int64 {
f.nextID++
s.ID = f.nextID
f.rows[s.ID] = cloneSchedule(&s)
return s.ID
}
// row reads a schedule back for a test's assertions.
func (f *fakeSchedules) row(t *testing.T, id int64) *Schedule {
t.Helper()
r, ok := f.rows[id]
if !ok {
t.Fatalf("schedule %d is gone", id)
}
return cloneSchedule(r)
}
func (f *fakeSchedules) ListSchedules(_ context.Context, server string) ([]Schedule, error) {
var out []Schedule
for _, r := range f.rows {
if r.Server == server {
out = append(out, *cloneSchedule(r))
}
}
slices.SortFunc(out, func(a, b Schedule) int { return int(a.ID - b.ID) })
return out, nil
}
func (f *fakeSchedules) GetSchedule(_ context.Context, server string, id int64) (*Schedule, error) {
r, ok := f.rows[id]
if !ok || r.Server != server {
return nil, ErrNotFound
}
return cloneSchedule(r), nil
}
func (f *fakeSchedules) CreateSchedule(_ context.Context, s *Schedule, limit int) error {
n := 0
for _, r := range f.rows {
if r.Server == s.Server {
n++
}
}
if n >= limit {
return ErrScheduleLimit
}
s.CreatedAt = time.Unix(1_700_000_000, 0)
s.ID = f.put(*s)
return nil
}
func (f *fakeSchedules) idle(server string, id int64) (*Schedule, error) {
r, ok := f.rows[id]
switch {
case !ok || r.Server != server:
return nil, ErrNotFound
case r.RunState != "":
return nil, ErrScheduleRunning
}
return r, nil
}
func (f *fakeSchedules) UpdateSchedule(_ context.Context, s *Schedule) error {
r, err := f.idle(s.Server, s.ID)
if err != nil {
return err
}
r.OwnerID, r.Label, r.Action, r.Command = s.OwnerID, s.Label, s.Action, s.Command
r.EveryMinutes, r.MinuteOfDay, r.Weekdays, r.Timezone = s.EveryMinutes, s.MinuteOfDay, s.Weekdays, s.Timezone
r.WarnMinutes, r.Enabled, r.NextRunAt, r.WarnedFor = s.WarnMinutes, s.Enabled, s.NextRunAt, nil
if r.NextRunAt != nil {
r.NextRunAt = ptrTime(*r.NextRunAt)
}
return nil
}
func (f *fakeSchedules) DeleteSchedule(_ context.Context, server string, id int64) error {
if _, err := f.idle(server, id); err != nil {
return err
}
delete(f.rows, id)
return nil
}
func (f *fakeSchedules) DueSchedules(_ context.Context, horizon time.Time) ([]DueSchedule, error) {
var out []DueSchedule
for _, r := range f.rows {
if r.RunState != "" || (r.Enabled && r.NextRunAt != nil && !r.NextRunAt.After(horizon)) {
out = append(out, DueSchedule{Schedule: *cloneSchedule(r), ServerOwner: f.owners[r.Server]})
}
}
slices.SortFunc(out, func(a, b DueSchedule) int {
if (a.RunState == "") != (b.RunState == "") {
if a.RunState != "" {
return -1
}
return 1
}
if a.NextRunAt != nil && b.NextRunAt != nil && !a.NextRunAt.Equal(*b.NextRunAt) {
return a.NextRunAt.Compare(*b.NextRunAt)
}
return int(a.ID - b.ID)
})
return out, nil
}
func (f *fakeSchedules) ClaimScheduleRun(_ context.Context, id int64, due, next *time.Time, now time.Time) (bool, error) {
r, ok := f.rows[id]
if !ok || r.RunState != "" {
return false, nil
}
if due != nil {
if !r.Enabled || !sameTime(r.NextRunAt, *due) {
return false, nil
}
r.NextRunAt, r.WarnedFor = ptrTime(*next), nil
}
f.claims++
r.RunState, r.RunResume, r.RunStepAt = runClaimed, false, ptrTime(now)
r.LastRunAt, r.LastResult, r.LastDetail = ptrTime(now), "", ""
return true, nil
}
func (f *fakeSchedules) AdvanceScheduleRun(_ context.Context, id int64, from, to string, resume bool, result, detail string, now time.Time) (bool, error) {
r, ok := f.rows[id]
if !ok || r.RunState != from {
return false, nil
}
r.RunState, r.RunResume, r.RunStepAt = to, resume, ptrTime(now)
if result != "" {
r.LastResult, r.LastDetail = result, detail
}
return true, nil
}
func (f *fakeSchedules) FinishScheduleRun(_ context.Context, id int64, from, result, detail string) (bool, error) {
r, ok := f.rows[id]
if !ok || from == "" || r.RunState != from {
return false, nil
}
r.RunState, r.RunResume, r.RunStepAt = "", false, nil
r.LastResult, r.LastDetail = result, detail
return true, nil
}
func (f *fakeSchedules) MissScheduleRun(_ context.Context, id int64, due, next time.Time, detail string) (bool, error) {
r, ok := f.rows[id]
if !ok || r.RunState != "" || !r.Enabled || !sameTime(r.NextRunAt, due) {
return false, nil
}
r.NextRunAt, r.WarnedFor = ptrTime(next), nil
r.LastRunAt, r.LastResult, r.LastDetail = ptrTime(due), ScheduleMissed, detail
return true, nil
}
func (f *fakeSchedules) WarnScheduleRun(_ context.Context, id int64, due time.Time) (bool, error) {
r, ok := f.rows[id]
if !ok || r.RunState != "" || !r.Enabled || !sameTime(r.NextRunAt, due) || sameTime(r.WarnedFor, due) {
return false, nil
}
r.WarnedFor = ptrTime(due)
return true, nil
}
func (f *fakeSchedules) DisableSchedule(_ context.Context, id int64, detail string) (bool, error) {
r, ok := f.rows[id]
if !ok || r.RunState != "" || !r.Enabled {
return false, nil
}
r.Enabled, r.NextRunAt, r.WarnedFor = false, nil, nil
r.LastResult, r.LastDetail = ScheduleSkipped, detail
return true, nil
}
func mustZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Fatal(err)
}
return loc
}
// TestScheduleNextRun pins the run times: daily and repeating rules, the
// weekday mask read in the schedule's zone, and the two daylight-saving edges.
func TestScheduleNextRun(t *testing.T) {
sh := mustZone(t, "Asia/Shanghai")
ny := mustZone(t, "America/New_York")
const everyDay, weekdaysOnly, monday, saturday = 0x7f, 0x3e, 1 << 1, 1 << 6
cases := []struct {
name string
s Schedule
after time.Time
want time.Time
}{
{"daily, later today",
Schedule{MinuteOfDay: 4*60 + 30, Weekdays: everyDay, Timezone: "Asia/Shanghai"},
time.Date(2026, 9, 28, 1, 0, 0, 0, sh), time.Date(2026, 9, 28, 4, 30, 0, 0, sh)},
{"daily, today's run is past",
Schedule{MinuteOfDay: 4 * 60, Weekdays: everyDay, Timezone: "Asia/Shanghai"},
time.Date(2026, 9, 28, 4, 0, 0, 0, sh), time.Date(2026, 9, 29, 4, 0, 0, 0, sh)},
{"weekday mask in the schedule's zone (Sunday 23:00 UTC is Monday in Shanghai)",
Schedule{MinuteOfDay: 9 * 60, Weekdays: monday, Timezone: "Asia/Shanghai"},
time.Date(2026, 9, 27, 23, 0, 0, 0, time.UTC), time.Date(2026, 9, 28, 9, 0, 0, 0, sh)},
{"one weekday, next week, across the month end",
Schedule{MinuteOfDay: 9 * 60, Weekdays: monday, Timezone: "Asia/Shanghai"},
time.Date(2026, 9, 28, 10, 0, 0, 0, sh), time.Date(2026, 10, 5, 9, 0, 0, 0, sh)},
{"interval: the next multiple since midnight",
Schedule{EveryMinutes: 180, Weekdays: everyDay, Timezone: "Asia/Shanghai"},
time.Date(2026, 9, 28, 7, 10, 0, 0, sh), time.Date(2026, 9, 28, 9, 0, 0, 0, sh)},
{"interval skips the days off (Friday night -> Monday 00:00)",
Schedule{EveryMinutes: 720, Weekdays: weekdaysOnly, Timezone: "Asia/Shanghai"},
time.Date(2026, 10, 2, 12, 0, 0, 0, sh), time.Date(2026, 10, 5, 0, 0, 0, 0, sh)},
{"interval, Saturday only, from the week before",
Schedule{EveryMinutes: 15, Weekdays: saturday, Timezone: "UTC"},
time.Date(2026, 9, 26, 23, 50, 0, 0, time.UTC), time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)},
{"a time daylight saving skips runs an hour early",
Schedule{MinuteOfDay: 2*60 + 30, Weekdays: everyDay, Timezone: "America/New_York"},
time.Date(2026, 3, 8, 0, 0, 0, 0, ny), time.Date(2026, 3, 8, 6, 30, 0, 0, time.UTC)},
{"a repeated time runs once: after the first 01:30 comes tomorrow's",
Schedule{MinuteOfDay: 90, Weekdays: everyDay, Timezone: "America/New_York"},
time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC), time.Date(2026, 11, 2, 6, 30, 0, 0, time.UTC)},
{"a zone no longer known runs in UTC",
Schedule{MinuteOfDay: 60, Weekdays: everyDay, Timezone: "Gone/Zone"},
time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 28, 1, 0, 0, 0, time.UTC)},
{"the first 01:30 on the day the clock goes back",
Schedule{MinuteOfDay: 90, Weekdays: everyDay, Timezone: "America/New_York"},
time.Date(2026, 11, 1, 0, 0, 0, 0, ny), time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.s.nextRun(tc.after); !got.Equal(tc.want) {
t.Fatalf("nextRun(%s) = %s, want %s", tc.after, got, tc.want)
}
})
}
}
// TestScheduleInputValidation walks the settings apply refuses, each with the
// one field that makes it wrong, and what a valid body stores.
func TestScheduleInputValidation(t *testing.T) {
valid := func() scheduleInput {
return scheduleInput{Action: ScheduleRestart, MinuteOfDay: 240, Weekdays: 0x7f, Timezone: "Asia/Shanghai", WarnMinutes: 5}
}
bad := []struct {
name string
edit func(*scheduleInput)
code string
msg string
}{
{"label too long", func(in *scheduleInput) { in.Label = strings.Repeat("猫", 65) }, "bad_schedule", "label too long"},
{"label with a newline", func(in *scheduleInput) { in.Label = "a\nb" }, "bad_schedule", "single line"},
{"label with DEL", func(in *scheduleInput) { in.Label = "a\x7fb" }, "bad_schedule", "single line"},
{"unknown action", func(in *scheduleInput) { in.Action = "reboot" }, "bad_schedule", "action must be"},
{"command on a restart", func(in *scheduleInput) { in.Command = "say hi" }, "bad_schedule", "only a command schedule"},
{"command action without a command", func(in *scheduleInput) { in.Action, in.WarnMinutes = ScheduleCommand, 0 }, "bad_request", "command is required"},
{"two commands in one", func(in *scheduleInput) { in.Action, in.WarnMinutes, in.Command = ScheduleCommand, 0, "say a\nop me" }, "bad_request", "single line"},
{"minute_of_day negative", func(in *scheduleInput) { in.MinuteOfDay = -1 }, "bad_schedule", "minute_of_day must be"},
{"minute_of_day 1440", func(in *scheduleInput) { in.MinuteOfDay = 1440 }, "bad_schedule", "minute_of_day must be"},
{"interval off the list", func(in *scheduleInput) { in.EveryMinutes, in.MinuteOfDay = 45, 0 }, "bad_schedule", "every_minutes must be"},
{"restart every 30 minutes", func(in *scheduleInput) { in.EveryMinutes, in.MinuteOfDay = 30, 0 }, "bad_schedule", "at most every 60 minutes"},
{"interval with a minute_of_day", func(in *scheduleInput) { in.EveryMinutes = 60 }, "bad_schedule", "no minute_of_day"},
{"no weekday", func(in *scheduleInput) { in.Weekdays = 0 }, "bad_schedule", "weekdays"},
{"weekday bit 7", func(in *scheduleInput) { in.Weekdays = 0x80 }, "bad_schedule", "weekdays"},
{"empty timezone", func(in *scheduleInput) { in.Timezone = " " }, "bad_schedule", "IANA zone"},
{"Local", func(in *scheduleInput) { in.Timezone = "Local" }, "bad_schedule", "IANA zone"},
{"unknown timezone", func(in *scheduleInput) { in.Timezone = "Mars/Olympus" }, "bad_schedule", "unknown timezone"},
{"warning off the list", func(in *scheduleInput) { in.WarnMinutes = 2 }, "bad_schedule", "warn_minutes must be"},
{"warning on a command", func(in *scheduleInput) { in.Action, in.Command = ScheduleCommand, "say hi" }, "bad_schedule", "only a restart, stop or backup warns"},
{"warning on a start", func(in *scheduleInput) { in.Action = ScheduleStart }, "bad_schedule", "only a restart, stop or backup warns"},
}
for _, tc := range bad {
t.Run(tc.name, func(t *testing.T) {
in := valid()
tc.edit(&in)
err := in.apply(&Schedule{})
var he *apiError
if !errors.As(err, &he) || he.status != http.StatusBadRequest || he.code != tc.code || !strings.Contains(he.msg, tc.msg) {
t.Fatalf("apply = %v, want 400 %s containing %q", err, tc.code, tc.msg)
}
})
}
t.Run("valid bodies store trimmed values", func(t *testing.T) {
in := scheduleInput{Label: " 每晚 ", Action: ScheduleCommand, Command: " /say 晚安 ", EveryMinutes: 15,
Weekdays: 0x41, Timezone: " Europe/Berlin "}
var s Schedule
if err := in.apply(&s); err != nil {
t.Fatal(err)
}
want := Schedule{Label: "每晚", Action: ScheduleCommand, Command: "say 晚安", EveryMinutes: 15,
Weekdays: 0x41, Timezone: "Europe/Berlin", Enabled: true}
if s != want {
t.Fatalf("stored %+v, want %+v", s, want)
}
off := false
in = scheduleInput{Label: strings.Repeat("猫", 64), Action: ScheduleBackup, MinuteOfDay: 1439, Weekdays: 1,
Timezone: "UTC", WarnMinutes: 30, Enabled: &off}
if err := in.apply(&s); err != nil {
t.Fatal(err)
}
if s.Enabled || s.Command != "" || s.WarnMinutes != 30 || s.MinuteOfDay != 1439 {
t.Fatalf("stored %+v", s)
}
})
}
// schedRig is a server with a schedule store: survival, owned by owner1,
// running, at 2026-09-28 03:00 UTC.
type schedRig struct {
a *API
repo *fakeRepo
cl *fakeCluster
st *fakeSchedules
con *fakeConsole
b *fakeScheduledBackuper
jobs *fakeJobStatus
clock time.Time
}
var schedT0 = time.Date(2026, 9, 28, 3, 0, 0, 0, time.UTC)
func newSchedRig(t *testing.T) *schedRig {
t.Helper()
r := &schedRig{repo: newFakeRepo(), cl: newFakeCluster(), st: newFakeSchedules(), con: &fakeConsole{},
b: &fakeScheduledBackuper{}, jobs: &fakeJobStatus{}, clock: schedT0}
r.repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
r.cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: string(v1alpha1.PhaseRunning), Ready: true,
DesiredState: string(v1alpha1.DesiredRunning)}
r.st.owners["survival"] = "owner1"
r.a = newTestAPI(r.repo, r.cl)
r.a.Now = func() time.Time { return r.clock }
r.a.Schedules, r.a.Console, r.a.Backuper, r.a.JobStatus = r.st, r.con, r.b, r.jobs
return r
}
func (r *schedRig) stopped() {
info := r.cl.byName["survival"]
info.Phase, info.Ready, info.DesiredState = string(v1alpha1.PhaseStopped), false, string(v1alpha1.DesiredStopped)
}
// schedule stores a schedule of survival owned by owner1, due at due.
func (r *schedRig) schedule(action string, due time.Time, edit ...func(*Schedule)) int64 {
s := Schedule{Server: "survival", OwnerID: "owner1", Action: action, MinuteOfDay: due.Hour()*60 + due.Minute(),
Weekdays: 0x7f, Timezone: "UTC", Enabled: true, NextRunAt: ptrTime(due), CreatedBy: "[email protected]"}
if action == ScheduleCommand {
s.Command = "say hi"
}
for _, e := range edit {
e(&s)
}
return r.st.put(s)
}
func (r *schedRig) tick(t *testing.T) {
t.Helper()
if err := r.a.RunSchedules(context.Background()); err != nil {
t.Fatalf("RunSchedules: %v", err)
}
}
var (
schedOwner = &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
schedStranger = &Principal{UserID: "other", Email: "[email protected]", Role: "user"}
schedAdmin = &Principal{UserID: "adm", Email: "[email protected]", Role: "admin", ViaAdminAccess: true}
)
func (r *schedRig) do(p *Principal, method, target, body string) *http.Response {
r.a.External = staticExternal{p: p}
var h map[string]string
if body != "" {
h = jsonHeader
}
return do(r.a.ExternalHandler(), method, target, body, h).Result()
}
func decodeSchedule(t *testing.T, res *http.Response) Schedule {
t.Helper()
var s Schedule
if err := json.NewDecoder(res.Body).Decode(&s); err != nil {
t.Fatal(err)
}
return s
}
func schedErrCode(t *testing.T, res *http.Response) string {
t.Helper()
var raw map[string]map[string]string
if err := json.NewDecoder(res.Body).Decode(&raw); err != nil {
t.Fatal(err)
}
return raw["error"]["code"]
}
const schedBase = "/api/v1/servers/survival/schedules"
// TestScheduleRoutesGate: every schedule route answers 400 for a bad name, 404
// for an unknown server, 403 for a stranger and 503 without a store, before it
// reads anything else.
func TestScheduleRoutesGate(t *testing.T) {
body := `{"action":"restart","minute_of_day":240,"weekdays":127,"timezone":"UTC"}`
routes := []struct{ method, path, body string }{
{"GET", "", ""}, {"POST", "", body}, {"PUT", "/1", body}, {"DELETE", "/1", ""}, {"POST", "/1/run", ""},
}
for _, rt := range routes {
t.Run(rt.method+rt.path, func(t *testing.T) {
r := newSchedRig(t)
r.schedule(ScheduleRestart, schedT0.Add(time.Hour))
check := func(p *Principal, target string, status int, code string) {
t.Helper()
res := r.do(p, rt.method, target, rt.body)
if res.StatusCode != status || schedErrCode(t, res) != code {
t.Fatalf("%s %s = %d, want %d %s", rt.method, target, res.StatusCode, status, code)
}
}
check(schedOwner, "/api/v1/servers/Bad_Name/schedules"+rt.path, 400, "bad_name")
check(schedOwner, "/api/v1/servers/nope/schedules"+rt.path, 404, "not_found")
check(schedStranger, schedBase+rt.path, 403, "forbidden")
r.a.Schedules = nil
check(schedOwner, schedBase+rt.path, 503, "schedules_unavailable")
if len(r.repo.audits) != 0 || r.con.calls != 0 {
t.Fatalf("a refused request left audits %+v / %d console calls", r.repo.audits, r.con.calls)
}
})
}
t.Run("bad id", func(t *testing.T) {
r := newSchedRig(t)
for _, id := range []string{"0", "-1", "x"} {
res := r.do(schedOwner, "DELETE", schedBase+"/"+id, "")
if res.StatusCode != 400 || schedErrCode(t, res) != "bad_id" {
t.Fatalf("DELETE %s = %d, want 400 bad_id", id, res.StatusCode)
}
}
})
}
func TestScheduleCRUD(t *testing.T) {
t.Run("list: empty is [] with the limit", func(t *testing.T) {
r := newSchedRig(t)
res := r.do(schedOwner, "GET", schedBase, "")
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 || string(raw) != `{"limit":20,"schedules":[],"server":"survival"}`+"\n" {
t.Fatalf("GET = %d %s", res.StatusCode, raw)
}
})
t.Run("owner creates: next run, owner binding, audit", func(t *testing.T) {
r := newSchedRig(t) // 2026-09-28 03:00 UTC = 11:00 in Shanghai
res := r.do(schedOwner, "POST", schedBase,
`{"label":"夜间重启","action":"restart","minute_of_day":240,"weekdays":127,"timezone":"Asia/Shanghai","warn_minutes":5}`)
if res.StatusCode != 201 {
t.Fatalf("POST = %d", res.StatusCode)
}
got := decodeSchedule(t, res)
want := time.Date(2026, 9, 28, 20, 0, 0, 0, time.UTC) // 04:00 on the 29th in Shanghai
if got.ID != 1 || got.Label != "夜间重启" || !sameTime(got.NextRunAt, want) || !got.Enabled ||
got.CreatedBy != "[email protected]" || got.LastRunAt != nil || got.RunState != "" {
t.Fatalf("created %+v", got)
}
if row := r.st.row(t, 1); row.OwnerID != "owner1" {
t.Fatalf("stored owner %q, want owner1", row.OwnerID)
}
if len(r.repo.audits) != 1 || r.repo.audits[0].Action != "schedule.create" ||
r.repo.audits[0].Actor != "[email protected]" || r.repo.audits[0].ActorUserID != "owner1" || r.repo.audits[0].ServerName != "survival" ||
string(r.repo.audits[0].Payload) != `{"action":"restart","schedule":1}` {
t.Fatalf("audits %+v", r.repo.audits)
}
})
t.Run("admin creates on an owned server: it belongs to the owner", func(t *testing.T) {
r := newSchedRig(t)
res := r.do(schedAdmin, "POST", schedBase, `{"action":"stop","minute_of_day":0,"weekdays":1,"timezone":"UTC","enabled":false}`)
if res.StatusCode != 201 {
t.Fatalf("POST = %d", res.StatusCode)
}
got := decodeSchedule(t, res)
if got.NextRunAt != nil || got.Enabled || got.CreatedBy != "[email protected]" {
t.Fatalf("created %+v", got)
}
if row := r.st.row(t, got.ID); row.OwnerID != "owner1" {
t.Fatalf("stored owner %q, want owner1", row.OwnerID)
}
})
t.Run("bad settings and unknown fields are 400 and store nothing", func(t *testing.T) {
r := newSchedRig(t)
for body, code := range map[string]string{
`{"action":"restart","minute_of_day":240,"weekdays":127,"timezone":"Nowhere/City"}`: "bad_schedule",
`{"action":"command","weekdays":127,"timezone":"UTC"}`: "bad_request",
`{"action":"restart","weekdays":127,"timezone":"UTC","owner_id":"me"}`: "bad_request",
} {
res := r.do(schedOwner, "POST", schedBase, body)
if res.StatusCode != 400 || schedErrCode(t, res) != code {
t.Fatalf("POST %s = %d, want 400 %s", body, res.StatusCode, code)
}
}
if len(r.st.rows) != 0 || len(r.repo.audits) != 0 {
t.Fatalf("stored %d rows, %d audits", len(r.st.rows), len(r.repo.audits))
}
})
t.Run("the 21st is 409 schedule_limit", func(t *testing.T) {
r := newSchedRig(t)
for range maxSchedulesPerServer {
r.schedule(ScheduleStop, schedT0.Add(time.Hour))
}
res := r.do(schedOwner, "POST", schedBase, `{"action":"stop","weekdays":127,"timezone":"UTC"}`)
if res.StatusCode != 409 || schedErrCode(t, res) != "schedule_limit" || len(r.st.rows) != maxSchedulesPerServer {
t.Fatalf("POST = %d, rows %d", res.StatusCode, len(r.st.rows))
}
})
t.Run("update: new settings, current owner, fresh next run and warning marker", func(t *testing.T) {
r := newSchedRig(t)
id := r.schedule(ScheduleRestart, schedT0.Add(time.Hour), func(s *Schedule) {
s.OwnerID, s.WarnedFor = "previous", ptrTime(schedT0.Add(time.Hour))
})
res := r.do(schedOwner, "PUT", fmt.Sprintf("%s/%d", schedBase, id),
`{"action":"command","command":"/save-all","every_minutes":30,"weekdays":127,"timezone":"UTC"}`)
if res.StatusCode != 200 {
t.Fatalf("PUT = %d", res.StatusCode)
}
got := decodeSchedule(t, res)
row := r.st.row(t, id)
if got.Action != ScheduleCommand || got.Command != "save-all" || !sameTime(got.NextRunAt, schedT0.Add(30*time.Minute)) ||
row.OwnerID != "owner1" || row.WarnedFor != nil || row.Command != "save-all" {
t.Fatalf("PUT answered %+v, stored %+v", got, row)
}
if len(r.repo.audits) != 1 || r.repo.audits[0].Action != "schedule.update" {
t.Fatalf("audits %+v", r.repo.audits)
}
})
t.Run("update and delete refuse a running or unknown schedule", func(t *testing.T) {
r := newSchedRig(t)
id := r.schedule(ScheduleRestart, schedT0.Add(time.Hour), func(s *Schedule) { s.RunState = runStopping })
body := `{"action":"stop","weekdays":127,"timezone":"UTC"}`
for _, c := range []struct{ method, path, body, code string }{
{"PUT", fmt.Sprintf("/%d", id), body, "schedule_running"},
{"DELETE", fmt.Sprintf("/%d", id), "", "schedule_running"},
{"PUT", "/99", body, "not_found"},
{"DELETE", "/99", "", "not_found"},
} {
res := r.do(schedOwner, c.method, schedBase+c.path, c.body)
want := 409
if c.code == "not_found" {
want = 404
}
if res.StatusCode != want || schedErrCode(t, res) != c.code {
t.Fatalf("%s %s = %d, want %d %s", c.method, c.path, res.StatusCode, want, c.code)
}
}
if row := r.st.row(t, id); row.Action != ScheduleRestart || len(r.repo.audits) != 0 {
t.Fatalf("refused writes changed %+v / audited %+v", row, r.repo.audits)
}
})
t.Run("a schedule of another server is 404", func(t *testing.T) {
r := newSchedRig(t)
r.repo.byName["creative"] = &ServerRecord{Name: "creative", OwnerID: "owner1"}
id := r.st.put(Schedule{Server: "creative", Action: ScheduleStop, Weekdays: 1, Timezone: "UTC"})
res := r.do(schedOwner, "DELETE", fmt.Sprintf("%s/%d", schedBase, id), "")
if res.StatusCode != 404 || len(r.st.rows) != 1 {
t.Fatalf("DELETE = %d, rows %d", res.StatusCode, len(r.st.rows))
}
})
t.Run("delete", func(t *testing.T) {
r := newSchedRig(t)
id := r.schedule(ScheduleStop, schedT0.Add(time.Hour))
res := r.do(schedOwner, "DELETE", fmt.Sprintf("%s/%d", schedBase, id), "")
if res.StatusCode != 204 || len(r.st.rows) != 0 {
t.Fatalf("DELETE = %d, rows %d", res.StatusCode, len(r.st.rows))
}
if len(r.repo.audits) != 1 || r.repo.audits[0].Action != "schedule.delete" ||
string(r.repo.audits[0].Payload) != fmt.Sprintf(`{"action":"stop","schedule":%d}`, id) {
t.Fatalf("audits %+v", r.repo.audits)
}
})
t.Run("list shows the schedules oldest first", func(t *testing.T) {
r := newSchedRig(t)
a := r.schedule(ScheduleStop, schedT0.Add(2*time.Hour))
b := r.schedule(ScheduleStart, schedT0.Add(time.Hour))
res := r.do(schedOwner, "GET", schedBase, "")
var body struct {
Schedules []Schedule `json:"schedules"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Schedules) != 2 || body.Schedules[0].ID != a || body.Schedules[1].ID != b {
t.Fatalf("listed %+v", body.Schedules)
}
})
}
// TestScheduleRunNow: a run on request starts at once, without the warning,
// whether or not the schedule is enabled, and leaves its next run alone.
func TestScheduleRunNow(t *testing.T) {
t.Run("a command runs and the answer carries its outcome", func(t *testing.T) {
r := newSchedRig(t)
r.con.reply = "§aSaved the game"
next := schedT0.Add(5 * time.Hour)
id := r.schedule(ScheduleCommand, next, func(s *Schedule) { s.Command = "save-all" })
res := r.do(schedOwner, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
if res.StatusCode != 202 {
t.Fatalf("run = %d", res.StatusCode)
}
got := decodeSchedule(t, res)
if got.LastResult != ScheduleOK || got.LastDetail != "Saved the game" || got.RunState != "" ||
!sameTime(got.LastRunAt, schedT0) || !sameTime(got.NextRunAt, next) {
t.Fatalf("after the run %+v", got)
}
if r.con.gotCommand != "save-all" || r.con.calls != 1 {
t.Fatalf("console ran %q (%d calls)", r.con.gotCommand, r.con.calls)
}
var actions []string
for _, e := range r.repo.audits {
actions = append(actions, e.Action+"/"+e.Actor)
}
if strings.Join(actions, ",") != "schedule.run_now/[email protected],schedule.run/scheduler" {
t.Fatalf("audits %v", actions)
}
})
t.Run("a disabled restart starts and goes on in the background", func(t *testing.T) {
r := newSchedRig(t)
id := r.schedule(ScheduleRestart, schedT0, func(s *Schedule) { s.Enabled, s.NextRunAt, s.WarnMinutes = false, nil, 5 })
res := r.do(schedOwner, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
got := decodeSchedule(t, res)
if res.StatusCode != 202 || got.RunState != runStopping || got.NextRunAt != nil {
t.Fatalf("run = %d %+v", res.StatusCode, got)
}
if r.cl.desired["survival"] != v1alpha1.DesiredStopped || r.con.calls != 0 {
t.Fatalf("desired %q, %d console calls (no warning on request)", r.cl.desired["survival"], r.con.calls)
}
})
t.Run("a running schedule is 409 schedule_running", func(t *testing.T) {
r := newSchedRig(t)
id := r.schedule(ScheduleRestart, schedT0, func(s *Schedule) { s.RunState = runStarting })
res := r.do(schedOwner, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
if res.StatusCode != 409 || schedErrCode(t, res) != "schedule_running" || len(r.repo.audits) != 0 {
t.Fatalf("run = %d, audits %+v", res.StatusCode, r.repo.audits)
}
})
t.Run("a schedule of the previous owner is 409 schedule_stale", func(t *testing.T) {
r := newSchedRig(t)
id := r.schedule(ScheduleCommand, schedT0, func(s *Schedule) { s.OwnerID = "previous" })
res := r.do(schedAdmin, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
if res.StatusCode != 409 || schedErrCode(t, res) != "schedule_stale" || r.con.calls != 0 || r.st.claims != 0 {
t.Fatalf("run = %d, console %d, claims %d", res.StatusCode, r.con.calls, r.st.claims)
}
})
t.Run("unknown schedule is 404", func(t *testing.T) {
r := newSchedRig(t)
res := r.do(schedOwner, "POST", schedBase+"/7/run", "")
if res.StatusCode != 404 {
t.Fatalf("run = %d", res.StatusCode)
}
})
}