Files
kacerrandClaude Opus 5.5 2c2db60fe7 feat(go): scheduled bank sync (Go only)
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>
2026-10-08 12:17:15 +02:00

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