package store import ( "context" "testing" "time" ) // A recurring reminder keeps its wall-clock hour in the owner's zone. The cron // walk used to run on the UTC instant scanReminder returns, so "0 9 * * *" // created for 09:00 Moscow rescheduled to 09:00 UTC — noon, and noon every day // after that (V-616). func TestRescheduleKeepsWallClockHour(t *testing.T) { s := newTestStore(t) ctx := context.Background() msk := time.FixedZone("MSK", 3*60*60) fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk) id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *") if err != nil { t.Fatal(err) } if err := s.rescheduleReminderIn(ctx, id, fire, msk); err != nil { t.Fatalf("reschedule: %v", err) } want := time.Date(2026, 7, 2, 9, 0, 0, 0, msk) got := nextFire(t, s, id) if !got.Equal(want) { t.Fatalf("next fire: want %s, got %s", want, got.In(msk)) } } // A daily reminder survives a daemon that was down for days. The past // occurrences are skipped, not replayed, and the reminder stays pending. func TestRescheduleRollsForwardAfterDowntime(t *testing.T) { s := newTestStore(t) ctx := context.Background() msk := time.FixedZone("MSK", 3*60*60) fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk) id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *") if err != nil { t.Fatal(err) } // The box comes back three days later, mid-afternoon. now := time.Date(2026, 7, 4, 15, 0, 0, 0, msk) if err := s.rescheduleReminderIn(ctx, id, now, msk); err != nil { t.Fatalf("reschedule: %v", err) } rs, err := s.ListReminders(ctx, 10) if err != nil || len(rs) != 1 { t.Fatalf("list: %d reminders, %v", len(rs), err) } if rs[0].Status != ReminderPending { t.Fatalf("status: want %s, got %s — downtime killed the recurrence", ReminderPending, rs[0].Status) } want := time.Date(2026, 7, 5, 9, 0, 0, 0, msk) if got := nextFire(t, s, id); !got.Equal(want) { t.Fatalf("next fire: want %s, got %s", want, got.In(msk)) } // And exactly one fire is owed, not a backlog of four. due, err := s.DueReminders(ctx, want.Add(time.Second)) if err != nil || len(due) != 1 { t.Fatalf("due after roll-forward: want 1, got %d (%v)", len(due), err) } } // A daily 03:00 reminder does not drift across the spring-forward changeover: // the hour is wall clock, so the interval is 23 hours that day, not 24. func TestRescheduleAcrossDSTKeepsHour(t *testing.T) { s := newTestStore(t) ctx := context.Background() berlin, err := time.LoadLocation("Europe/Berlin") if err != nil { t.Skipf("tzdata unavailable: %v", err) } fire := time.Date(2027, 3, 27, 3, 0, 0, 0, berlin) // CET, day before the change id, err := s.CreateReminder(ctx, fire, `{"text":"бэкап"}`, "0 3 * * *") if err != nil { t.Fatal(err) } if err := s.rescheduleReminderIn(ctx, id, fire, berlin); err != nil { t.Fatalf("reschedule: %v", err) } got := nextFire(t, s, id).In(berlin) if got.Hour() != 3 || got.Day() != 28 { t.Fatalf("next fire: want 2027-03-28 03:00 local, got %s", got) } if d := got.Sub(fire); d != 23*time.Hour { t.Fatalf("gap across spring forward: want 23h, got %s", d) } } func nextFire(t *testing.T, s *Store, id int64) time.Time { t.Helper() var ms int64 if err := s.db.QueryRow("SELECT next_fire_ts FROM reminders WHERE id = ?", id).Scan(&ms); err != nil { t.Fatalf("read next_fire_ts: %v", err) } return time.UnixMilli(ms).UTC() }