Files
tiny-forge/internal/workload/plugin/trigger/schedule/schedule_test.go
T
alexei.dolgolyov 39e1e36510
Build / build (push) Successful in 10m42s
feat(triggers): add schedule trigger kind + internal scheduler
Fourth trigger kind alongside registry/git/manual. Recurring time-interval
fires driven by a new internal/scheduler tick loop (default 30s, clamped
to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as
inbound HTTP webhooks, so per-binding concurrency, outcome accounting,
and config-merge semantics are identical.

Schema: triggers.last_fired_at TEXT column (additive ALTER for existing
DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking
Match cannot wedge a tight loop; failed deploys wait one full interval
before retry — correct trade-off for a periodic refresh trigger.

Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the
schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly,
custom interval input matched to Go time.ParseDuration syntax, optional
pinned reference). /triggers/[id] surfaces "last fired" on schedule rows.
EN+RU i18n in parity.

Review fixes from go-reviewer / security-reviewer / typescript-reviewer:
- Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double-
  cancel panic on shutdown re-entry).
- shouldFire rejects sub-MinInterval as defense-in-depth against
  hand-inserted rows that bypassed Validate.
- fire() asserts trigger Kind=="schedule" before dispatching.
- Aligned isValidInterval regex across all three frontend sites; reject
  the unsupported "d" unit (Go time.ParseDuration doesn't accept it).
- formatLastFired falls back to lastFiredNever on malformed timestamps
  rather than leaking raw bytes into the UI.
- main.go scheduler closure logs per-fire deployed/errored counts.
2026-05-16 11:24:05 +03:00

198 lines
5.5 KiB
Go

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)
}
}