13cb1903a9
RescheduleReminder walked the cron on the UTC instant scanReminder returns, so a daily 09:00 Moscow reminder rescheduled to 09:00 UTC — noon the same day, and noon every day after. And any occurrence earlier than now marked the reminder fired, so a daemon down overnight ended the recurrence for good. The walk now runs in the owner's location and skips past occurrences instead of killing the reminder. Skipping and not replaying keeps the no-backlog rule routine.DueAccepted already follows.
105 lines
3.3 KiB
Go
105 lines
3.3 KiB
Go
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()
|
|
}
|