package backup import ( "bytes" "context" "errors" "io" "strings" "testing" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func at(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return t } func TestDueRunsAtTheHourCatchesUpAndBacksOffAfterAFailure(t *testing.T) { cases := []struct { name string now string t runTimes want string }{ {"never run: the slot that passed is due", "2026-11-02T10:00:00Z", runTimes{}, "2026-11-02T03:00:00Z"}, {"ran at the hour: tomorrow's slot", "2026-11-02T10:00:00Z", runTimes{lastSuccess: at("2026-11-02T03:00:05Z"), lastAttempt: at("2026-11-02T03:00:05Z")}, "2026-11-03T03:00:00Z"}, {"before today's hour, yesterday's ran: today's slot", "2026-11-02T02:00:00Z", runTimes{lastSuccess: at("2026-11-01T03:00:05Z"), lastAttempt: at("2026-11-01T03:00:05Z")}, "2026-11-02T03:00:00Z"}, {"server was down at the hour: catches up", "2026-11-02T10:00:00Z", runTimes{lastSuccess: at("2026-11-01T03:00:05Z"), lastAttempt: at("2026-11-01T03:00:05Z")}, "2026-11-02T03:00:00Z"}, {"first failure: an hour later", "2026-11-02T03:30:00Z", runTimes{lastAttempt: at("2026-11-02T03:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-02T04:00:00Z"}, {"failing for two hours: two hours later", "2026-11-02T05:30:00Z", runTimes{lastAttempt: at("2026-11-02T05:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-02T07:00:00Z"}, {"a retry past the next slot waits for the slot", "2026-11-02T19:30:00Z", runTimes{lastAttempt: at("2026-11-02T19:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-03T03:00:00Z"}, } for _, c := range cases { if got := due(at(c.now), 3, c.t); !got.Equal(at(c.want)) { t.Errorf("%s: due = %s, want %s", c.name, got.Format(time.RFC3339), c.want) } } } func TestTheHourIsUTC(t *testing.T) { // 22:00 at UTC-5 is 03:00 UTC the next day, which is that day's slot. now := time.Date(2026, 11, 1, 22, 0, 0, 0, time.FixedZone("x", -5*3600)) if got := slotBefore(now, 3); !got.Equal(at("2026-11-02T03:00:00Z")) { t.Errorf("slot = %s", got) } } func TestVerdict(t *testing.T) { now := at("2026-11-02T10:00:00Z") on := dbq.BackupSetting{Enabled: true} good := runTimes{lastSuccess: at("2026-11-02T03:00:00Z"), lastAttempt: at("2026-11-02T03:00:00Z")} cases := []struct { name string st dbq.BackupSetting probs []string t runTimes want string }{ {"switched off, even with problems and failures", dbq.BackupSetting{}, []string{"x"}, runTimes{failingSince: now}, StateOff}, {"on but not set up", on, []string{"x"}, good, StateIncomplete}, {"the latest run failed", on, nil, runTimes{lastSuccess: good.lastSuccess, failingSince: now}, StateFailing}, {"nothing has run yet", on, nil, runTimes{}, StateWaiting}, {"the latest backup is old", on, nil, runTimes{lastSuccess: now.Add(-27 * time.Hour)}, StateOverdue}, {"a day and a bit is still fine", on, nil, runTimes{lastSuccess: now.Add(-25 * time.Hour)}, StateOK}, {"fine", on, nil, good, StateOK}, } for _, c := range cases { if got := verdict(c.st, c.probs, c.t, now); got != c.want { t.Errorf("%s: %s, want %s", c.name, got, c.want) } } } func TestProblemsNameWhatIsMissing(t *testing.T) { if p := problems(dbq.BackupSetting{Destination: DestinationS3}); len(p) != 4 { t.Errorf("an empty bucket: %v, want endpoint, name, access key and secret key", p) } if p := problems(dbq.BackupSetting{Destination: DestinationS3, Endpoint: "https://s3.example.com", Bucket: "b", AccessKey: "a", SecretKey: "v1.x"}); len(p) != 0 { t.Errorf("a whole bucket: %v", p) } if p := problems(dbq.BackupSetting{Destination: DestinationFolder}); len(p) != 1 { t.Errorf("an empty folder: %v", p) } if p := problems(dbq.BackupSetting{Destination: DestinationFolder, Folder: "/backups"}); len(p) != 0 { t.Errorf("a folder: %v", p) } } func TestMergeRefusesWhatCannotBeKept(t *testing.T) { ok := Update{Destination: DestinationS3, RunHour: 3} for field, u := range map[string]func(Update) Update{ "destination": func(u Update) Update { u.Destination = "ftp"; return u }, "run_hour": func(u Update) Update { u.RunHour = 24; return u }, "upload_limit_kib": func(u Update) Update { u.UploadLimitKiB = -1; return u }, "folder": func(u Update) Update { u.Folder = "backups"; return u }, "endpoint": func(u Update) Update { u.Endpoint = "s3.example.com"; return u }, } { _, err := merge(dbq.BackupSetting{}, u(ok)) var fe *FieldError if !errors.As(err, &fe) || fe.Field != field { t.Errorf("%s: err = %v, want a FieldError on it", field, err) } } } func TestMergeTidiesAndKeepsTheStoredSecret(t *testing.T) { st, err := merge(dbq.BackupSetting{SecretKey: "v1.stored"}, Update{ Enabled: true, Destination: DestinationS3, Endpoint: " https://s3.example.com/ ", Bucket: " minstrel ", AccessKey: " ak ", RunHour: 4, UploadLimitKiB: 512, Folder: "/srv/backups/", }) if err != nil { t.Fatal(err) } if st.Endpoint != "https://s3.example.com" || st.Bucket != "minstrel" || st.AccessKey != "ak" || st.SecretKey != "v1.stored" || st.RunHour != 4 || st.UploadLimitKib != 512 || st.Folder != "/srv/backups" || !st.Enabled { t.Errorf("merged = %+v", st) } } func TestThrottleKeepsToItsRate(t *testing.T) { data := bytes.Repeat([]byte("x"), 20*1024) start := time.Now() got, err := io.ReadAll(Throttle(context.Background(), bytes.NewReader(data), 100*1024)) if err != nil || !bytes.Equal(got, data) { t.Fatalf("read %d bytes, err %v", len(got), err) } // 20 KiB at 100 KiB a second takes at least a fifth of a second. if took := time.Since(start); took < 180*time.Millisecond { t.Errorf("took %s, want at least 200ms", took) } } func TestThrottleWithNoLimitIsTheReaderItself(t *testing.T) { r := strings.NewReader("x") if Throttle(context.Background(), r, 0) != io.Reader(r) { t.Error("a zero limit wrapped the reader") } } func TestThrottleStopsWhenItsContextEnds(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := io.ReadAll(Throttle(ctx, bytes.NewReader(make([]byte, 4096)), 1024)) if !errors.Is(err, context.Canceled) { t.Errorf("err = %v, want context.Canceled", err) } } func TestDescribeSaysWhyARunStopped(t *testing.T) { if got := describe(context.DeadlineExceeded); !strings.Contains(got, "longer than 30 minutes") { t.Errorf("deadline: %q", got) } if got := describe(context.Canceled); !strings.Contains(got, "server stopped") { t.Errorf("canceled: %q", got) } if got := describe(errors.New("backup: the folder /x isn't there")); got != "the folder /x isn't there" { t.Errorf("other: %q", got) } }