package schedule import ( "context" "encoding/json" "strings" "testing" "time" "github.com/alexei/tinyforge/internal/workload/plugin" ) func TestValidate(t *testing.T) { tr := &trigger{} cases := []struct { name string cfg json.RawMessage wantErr string // substring; empty = expect success }{ {"empty body rejected", nil, "config is required"}, {"empty object rejected", json.RawMessage(`{}`), "interval is required"}, {"missing interval rejected", json.RawMessage(`{"reference":"v1"}`), "interval is required"}, {"invalid json rejected", json.RawMessage(`not json`), "invalid json"}, {"unparseable interval rejected", json.RawMessage(`{"interval":"banana"}`), "invalid interval"}, {"zero interval rejected", json.RawMessage(`{"interval":"0s"}`), "must be positive"}, {"sub-minute rejected", json.RawMessage(`{"interval":"30s"}`), "below minimum"}, {"exact minimum accepted", json.RawMessage(`{"interval":"1m"}`), ""}, {"hour accepted", json.RawMessage(`{"interval":"1h"}`), ""}, {"day accepted", json.RawMessage(`{"interval":"24h"}`), ""}, {"unknown keys tolerated", json.RawMessage(`{"interval":"1h","future_field":42}`), ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := tr.Validate(tc.cfg) if tc.wantErr == "" { if err != nil { t.Fatalf("expected nil err, got %v", err) } return } if err == nil { t.Fatalf("expected error containing %q, got nil", tc.wantErr) } if !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("error %q missing substring %q", err.Error(), tc.wantErr) } }) } } func TestParseInterval(t *testing.T) { cases := []struct { in string want time.Duration err bool }{ {"1h", time.Hour, false}, {"24h", 24 * time.Hour, false}, {" 5m ", 5 * time.Minute, false}, {"168h", 7 * 24 * time.Hour, false}, {"", 0, true}, {"banana", 0, true}, {"-1h", 0, true}, {"0s", 0, true}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { got, err := ParseInterval(tc.in) if tc.err { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected err: %v", err) } if got != tc.want { t.Fatalf("got %s, want %s", got, tc.want) } }) } } func TestIntervalOfRaw(t *testing.T) { d, err := IntervalOfRaw(`{"interval":"30m"}`) if err != nil { t.Fatalf("err: %v", err) } if d != 30*time.Minute { t.Fatalf("got %s, want 30m", d) } if _, err := IntervalOfRaw(""); err == nil { t.Fatalf("expected error on empty raw") } } func TestMatch_WrongKindIgnored(t *testing.T) { tr := &trigger{} wl := plugin.Workload{ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`)} intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, plugin.InboundEvent{Kind: "manual"}) if err != nil { t.Fatalf("unexpected err: %v", err) } if intent != nil { t.Fatalf("expected nil intent for wrong kind, got %+v", intent) } } func TestMatch_MissingSchedulePayload(t *testing.T) { tr := &trigger{} wl := plugin.Workload{ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`)} intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, plugin.InboundEvent{Kind: "schedule"}) if err != nil { t.Fatalf("unexpected err: %v", err) } if intent != nil { t.Fatalf("expected nil intent when payload missing, got %+v", intent) } } func TestMatch_SchedulePopulatesIntent(t *testing.T) { tr := &trigger{} wl := plugin.Workload{ ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"6h","reference":"stable"}`), } fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, plugin.InboundEvent{ Kind: "schedule", Schedule: &plugin.ScheduleEvent{FiredAt: fixed}, }) if err != nil { t.Fatalf("unexpected err: %v", err) } if intent == nil { t.Fatalf("expected intent") } if intent.Reason != "schedule" { t.Errorf("Reason = %q, want schedule", intent.Reason) } if intent.Reference != "stable" { t.Errorf("Reference = %q, want stable", intent.Reference) } if intent.TriggeredBy != "scheduler" { t.Errorf("TriggeredBy = %q, want scheduler", intent.TriggeredBy) } if !intent.TriggeredAt.Equal(fixed) { t.Errorf("TriggeredAt = %s, want %s", intent.TriggeredAt, fixed) } if intent.Metadata["interval"] != "6h" { t.Errorf("interval metadata = %q, want 6h", intent.Metadata["interval"]) } } func TestMatch_ZeroFiredAtFallsBackToNow(t *testing.T) { tr := &trigger{} wl := plugin.Workload{ ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`), } before := time.Now().UTC() intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, plugin.InboundEvent{ Kind: "schedule", Schedule: &plugin.ScheduleEvent{}, }) if err != nil { t.Fatalf("unexpected err: %v", err) } after := time.Now().UTC() if intent.TriggeredAt.Before(before) || intent.TriggeredAt.After(after.Add(time.Second)) { t.Errorf("TriggeredAt %s not in [%s, %s]", intent.TriggeredAt, before, after) } } func TestKindAndSchemaSample(t *testing.T) { tr := &trigger{} if tr.Kind() != "schedule" { t.Fatalf("Kind = %q, want schedule", tr.Kind()) } type withSample interface{ SchemaSample() any } s, ok := any(tr).(withSample) if !ok { t.Fatalf("trigger does not expose SchemaSample()") } sample, ok := s.SchemaSample().(Config) if !ok { t.Fatalf("SchemaSample is not Config: %T", s.SchemaSample()) } if _, err := ParseInterval(sample.Interval); err != nil { t.Errorf("SchemaSample.Interval is not parseable: %v", err) } }