The Go server now runs sync -> infer -> flush-cache by itself at the times in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00, "off" disables), read in SYNC_TZ (default Europe/Prague). Nothing runs at startup; an invalid value stops the server with an error. - internal/scheduler: time-of-day parsing, Next, and the run loop - banksync.Runner: one lock shared by /sync-bank and the scheduler so runs never overlap, plus the status of the last run - footer shows the last sync since start and the next scheduled run - binary embeds time/tzdata because the Alpine image has no zone data Deliberately not implemented in the legacy Python app, which is being phased out; recorded in CLAUDE.md under "Deliberately Go-only features". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
3.3 KiB
Go
116 lines
3.3 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestParseTimes(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want []TimeOfDay
|
|
wantErr bool
|
|
}{
|
|
{"04:00", []TimeOfDay{{4, 0}}, false},
|
|
{" 18:30 , 06:00,06:00 ", []TimeOfDay{{6, 0}, {18, 30}}, false},
|
|
{"off", nil, false},
|
|
{"OFF", nil, false},
|
|
{"", nil, true},
|
|
{"25:00", nil, true},
|
|
{"04:60", nil, true},
|
|
{"daily", nil, true},
|
|
{"04:00,", nil, true},
|
|
}
|
|
for _, tc := range cases {
|
|
got, err := ParseTimes(tc.in)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Errorf("ParseTimes(%q): err = %v, wantErr %v", tc.in, err, tc.wantErr)
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("ParseTimes(%q) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNext(t *testing.T) {
|
|
prague, err := time.LoadLocation("Europe/Prague")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
at := func(s string) time.Time {
|
|
v, err := time.ParseInLocation("2006-01-02 15:04:05", s, prague)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
four := []TimeOfDay{{4, 0}}
|
|
cases := []struct {
|
|
name string
|
|
now time.Time
|
|
times []TimeOfDay
|
|
want time.Time
|
|
}{
|
|
{"before today's time", at("2026-10-08 03:59:59"), four, at("2026-10-08 04:00:00")},
|
|
{"exactly at the time moves to tomorrow", at("2026-10-08 04:00:00"), four, at("2026-10-09 04:00:00")},
|
|
{"after today's time", at("2026-10-08 12:00:00"), four, at("2026-10-09 04:00:00")},
|
|
{"picks the next of several", at("2026-10-08 12:00:00"), []TimeOfDay{{6, 0}, {18, 0}}, at("2026-10-08 18:00:00")},
|
|
{"year rollover", at("2026-12-31 23:00:00"), four, at("2027-01-01 04:00:00")},
|
|
{"now given in UTC", at("2026-10-08 03:00:00").UTC(), four, at("2026-10-08 04:00:00")},
|
|
// Spring forward (2026-03-29 02:00 → 03:00): still 04:00 local, 23h after the previous run.
|
|
{"spring forward", at("2026-03-28 04:00:00"), four, at("2026-03-29 04:00:00")},
|
|
// Fall back (2026-10-25 03:00 → 02:00): still 04:00 local, 25h after the previous run.
|
|
{"fall back", at("2026-10-24 04:00:00"), four, at("2026-10-25 04:00:00")},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := Next(tc.now, tc.times, prague)
|
|
if !got.Equal(tc.want) {
|
|
t.Errorf("Next = %s, want %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if got := Next(at("2026-10-08 12:00:00"), nil, prague); !got.IsZero() {
|
|
t.Errorf("Next with empty schedule = %s, want zero", got)
|
|
}
|
|
if d := at("2026-03-29 04:00:00").Sub(at("2026-03-28 04:00:00")); d != 23*time.Hour {
|
|
t.Errorf("spring-forward gap = %s, want 23h", d)
|
|
}
|
|
}
|
|
|
|
func TestRunStopsOnCancelAndEmptySchedule(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
called := false
|
|
job := func(context.Context) { called = true }
|
|
|
|
done := make(chan struct{})
|
|
go func() { Run(context.Background(), nil, time.UTC, logger, job); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run with empty schedule did not return")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done = make(chan struct{})
|
|
far := time.Now().UTC().Add(12 * time.Hour)
|
|
go func() {
|
|
Run(ctx, []TimeOfDay{{far.Hour(), far.Minute()}}, time.UTC, logger, job)
|
|
close(done)
|
|
}()
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run did not return after cancel")
|
|
}
|
|
if called {
|
|
t.Error("job must not run before its scheduled time")
|
|
}
|
|
}
|