Files
multica/server/internal/handler/autopilot_cron_preview_test.go
YYClaw 465546b83b feat(autopilots): redesign the autopilot schedule editor (#5457)
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.

Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.

Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
2026-07-17 16:44:31 +08:00

190 lines
5.5 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
func cronPreviewRequest(expr, tz string) *http.Request {
q := url.Values{}
q.Set("expr", expr)
if tz != "" {
q.Set("tz", tz)
}
return newRequest("GET", "/api/autopilots/cron-preview?"+q.Encode(), nil)
}
func decodeCronPreview(t *testing.T, w *httptest.ResponseRecorder) []string {
t.Helper()
var body struct {
NextRuns []string `json:"next_runs"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
return body.NextRuns
}
func TestCronPreview_ValidExpression(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
testHandler.CronPreview(w, cronPreviewRequest("0 9 * * *", "UTC"))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
runs := decodeCronPreview(t, w)
if len(runs) != 3 {
t.Fatalf("expected 3 next_runs, got %d: %v", len(runs), runs)
}
var prev time.Time
for i, s := range runs {
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("next_runs[%d] = %q is not RFC3339: %v", i, s, err)
}
if ts.UTC().Hour() != 9 || ts.Minute() != 0 {
t.Fatalf("next_runs[%d] = %q, want 09:00 UTC", i, s)
}
if i > 0 && !ts.After(prev) {
t.Fatalf("next_runs not strictly ascending: %v", runs)
}
prev = ts
}
}
func TestCronPreview_CompoundExpression(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
testHandler.CronPreview(w, cronPreviewRequest("0 9-21/2 * * 2-4", "Asia/Shanghai"))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("failed to load location: %v", err)
}
for i, s := range decodeCronPreview(t, w) {
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("next_runs[%d] not RFC3339: %v", i, err)
}
local := ts.In(loc)
if wd := local.Weekday(); wd < time.Tuesday || wd > time.Thursday {
t.Fatalf("next_runs[%d] = %s falls on %s, want Tue-Thu", i, s, wd)
}
if h := local.Hour(); h < 9 || h > 21 || (h-9)%2 != 0 {
t.Fatalf("next_runs[%d] = %s local hour %d, want 9-21 step 2", i, s, h)
}
}
}
func TestCronPreview_TimezoneApplied(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
testHandler.CronPreview(w, cronPreviewRequest("0 9 * * *", "Asia/Shanghai"))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
runs := decodeCronPreview(t, w)
if len(runs) == 0 {
t.Fatal("expected next_runs, got none")
}
ts, err := time.Parse(time.RFC3339, runs[0])
if err != nil {
t.Fatalf("not RFC3339: %v", err)
}
// 09:00 Asia/Shanghai (UTC+8, no DST) is 01:00 UTC.
if ts.UTC().Hour() != 1 {
t.Fatalf("expected 01:00 UTC for 09:00 Asia/Shanghai, got %s", runs[0])
}
}
func TestCronPreview_DefaultsToUTC(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
testHandler.CronPreview(w, cronPreviewRequest("30 18 * * *", ""))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
runs := decodeCronPreview(t, w)
if len(runs) == 0 {
t.Fatal("expected next_runs, got none")
}
ts, err := time.Parse(time.RFC3339, runs[0])
if err != nil {
t.Fatalf("not RFC3339: %v", err)
}
if ts.UTC().Hour() != 18 || ts.Minute() != 30 {
t.Fatalf("expected 18:30 UTC, got %s", runs[0])
}
}
// A syntactically valid expression that never fires answers 200 with an empty
// list, not 400: the editor tells "never runs" from "your cron is wrong" by the
// status, and a 400 here would also block the trigger from being saved at all.
func TestCronPreview_NeverFiringExpression(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
testHandler.CronPreview(w, cronPreviewRequest("0 0 30 2 *", "UTC"))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if runs := decodeCronPreview(t, w); len(runs) != 0 {
t.Fatalf("expected no next_runs, got %v", runs)
}
}
func TestCronPreview_BadRequests(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
cases := []struct {
name string
expr string
tz string
code string
}{
{"invalid expression", "not a cron", "UTC", cronPreviewInvalidCron},
{"six fields", "0 0 9 * * *", "UTC", cronPreviewInvalidCron},
{"descriptor", "@daily", "UTC", cronPreviewInvalidCron},
{"L token", "0 9 L * *", "UTC", cronPreviewInvalidCron},
{"missing expr", "", "UTC", cronPreviewInvalidCron},
// A timezone the server does not know is a distinct fault from a bad
// expression — the editor fixes them in different controls.
{"invalid timezone", "0 9 * * *", "Not/AZone", cronPreviewInvalidTimezone},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
testHandler.CronPreview(w, cronPreviewRequest(tc.expr, tc.tz))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Error string `json:"error"`
Code string `json:"code"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error == "" {
t.Fatalf("expected {\"error\": ...} body, got %s", w.Body.String())
}
if body.Code != tc.code {
t.Fatalf("expected code %q, got %q", tc.code, body.Code)
}
})
}
}