package reacquisition import ( "errors" "testing" "time" ) func TestBackoffSchedule(t *testing.T) { s := Defaults // 6h base, 168h (one week) cap, 3 attempts cases := []struct { name string attempts int32 want time.Duration }{ // Never tried is always due — the grace window, not the backoff, is // what holds the first attempt back. {"never attempted", 0, 0}, {"negative is treated as never", -1, 0}, {"after one attempt", 1, 6 * time.Hour}, {"after two", 2, 12 * time.Hour}, {"after three", 3, 24 * time.Hour}, {"after four", 4, 48 * time.Hour}, {"after five", 5, 96 * time.Hour}, // 6h * 2^5 = 192h, past the one-week cap. {"clamped at the cap", 6, 168 * time.Hour}, {"still clamped far out", 20, 168 * time.Hour}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := s.Backoff(c.attempts); got != c.want { t.Errorf("Backoff(%d) = %v, want %v", c.attempts, got, c.want) } }) } } // The doubling must not be able to overflow int32 before the clamp is // consulted — a large base with a high attempt count is the case that would // wrap negative and make a spent album look due immediately. func TestBackoffDoesNotOverflow(t *testing.T) { s := Settings{BackoffBaseHours: 168, BackoffMaxHours: 720} for attempts := int32(1); attempts <= 40; attempts++ { got := s.Backoff(attempts) if got <= 0 { t.Fatalf("Backoff(%d) = %v, want a positive duration", attempts, got) } if got > 720*time.Hour { t.Fatalf("Backoff(%d) = %v, want <= the 720h cap", attempts, got) } } } func TestBackoffRespectsCustomSettings(t *testing.T) { s := Settings{BackoffBaseHours: 1, BackoffMaxHours: 4} for attempts, want := range map[int32]time.Duration{ 1: 1 * time.Hour, 2: 2 * time.Hour, 3: 4 * time.Hour, 4: 4 * time.Hour, // clamped } { if got := s.Backoff(attempts); got != want { t.Errorf("Backoff(%d) = %v, want %v", attempts, got, want) } } } func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) { // Each case mirrors a CHECK in migration 0056. Validating in Go as well // means the API answers 400 with a readable message instead of surfacing // a constraint violation. cases := []struct { name string in Settings }{ {"zero grace would fire on every transient unmount", mutate(func(s *Settings) { s.GraceHours = 0 })}, {"grace beyond a month", mutate(func(s *Settings) { s.GraceHours = 721 })}, {"zero backoff base", mutate(func(s *Settings) { s.BackoffBaseHours = 0 })}, {"backoff base beyond a week", mutate(func(s *Settings) { s.BackoffBaseHours = 169 })}, {"zero backoff cap", mutate(func(s *Settings) { s.BackoffMaxHours = 0 })}, {"cap below base is incoherent", mutate(func(s *Settings) { s.BackoffBaseHours = 48 s.BackoffMaxHours = 24 })}, {"zero attempts means never try", mutate(func(s *Settings) { s.MaxAttempts = 0 })}, {"attempts beyond ten", mutate(func(s *Settings) { s.MaxAttempts = 11 })}, {"zero per pass means never sweep", mutate(func(s *Settings) { s.MaxPerPass = 0 })}, {"per pass beyond the cap", mutate(func(s *Settings) { s.MaxPerPass = 201 })}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if err := validate(c.in); !errors.Is(err, ErrOutOfRange) { t.Errorf("validate() = %v, want ErrOutOfRange", err) } }) } } func TestValidateAcceptsDefaults(t *testing.T) { if err := validate(Defaults); err != nil { t.Fatalf("the shipped defaults must be valid, got %v", err) } } // Equal base and cap is legal — it is how an operator asks for a flat retry // interval rather than an escalating one. func TestValidateAcceptsFlatBackoff(t *testing.T) { s := mutate(func(s *Settings) { s.BackoffBaseHours = 12 s.BackoffMaxHours = 12 }) if err := validate(s); err != nil { t.Fatalf("flat backoff should be allowed, got %v", err) } if got := s.Backoff(5); got != 12*time.Hour { t.Errorf("flat backoff gave %v, want 12h at every attempt", got) } } func mutate(f func(*Settings)) Settings { s := Defaults f(&s) return s }