Files
Maven/internal/store/reminders_reschedule_test.go
claude 13cb1903a9 recurring reminders keep their wall-clock hour and survive downtime (V-616)
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.
2026-08-06 04:36:36 +04:00

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