+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSchedulesSet(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ at *string
+ cron *string
+ answer string
+ listed string
+ }{
+ {
+ name: "a one-time schedule at a local time",
+ text: "Remind the user to drink water.",
+ at: new("2026-11-09 09:00"),
+ answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
+ listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
+ },
+ {
+ name: "a repeating schedule at the next match",
+ text: "Tell the user\nthe time.",
+ cron: new("*/5 * * * *"),
+ answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
+ listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+
+ answer, err := schedules.Set(tc.text, tc.at, tc.cron)
+ require.NoError(t, err)
+ require.Equal(t, tc.answer, answer)
+ require.Equal(t, tc.listed, schedules.List())
+ })
+ }
+}
+
+func TestSchedulesSetRefuses(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ at *string
+ cron *string
+ message string
+ }{
+ {
+ name: "an empty text",
+ text: " \n",
+ at: new("2026-11-09 09:00"),
+ message: "the text must have 1 to 4096 bytes",
+ },
+ {
+ name: "a text that is too long",
+ text: strings.Repeat("x", scheduleTextBytes+1),
+ at: new("2026-11-09 09:00"),
+ message: "the text must have 1 to 4096 bytes",
+ },
+ {
+ name: "neither at nor cron",
+ text: "Tell the user the time.",
+ message: "give either at or cron, and null for the other",
+ },
+ {
+ name: "both at and cron",
+ text: "Tell the user the time.",
+ at: new("2026-11-09 09:00"),
+ cron: new("0 9 * * *"),
+ message: "give either at or cron, and null for the other",
+ },
+ {
+ name: "a time in another layout",
+ text: "Tell the user the time.",
+ at: new("2026-11-09T09:00:00-03:00"),
+ message: "at must be a local time such as 2026-11-08 20:00",
+ },
+ {
+ name: "a time that is past",
+ text: "Tell the user the time.",
+ at: new("2026-11-08 20:00"),
+ message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
+ },
+ {
+ name: "runs less than 5 minutes apart",
+ text: "Tell the user the time.",
+ cron: new("* * * * *"),
+ message: "at least 5 minutes apart",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ schedules, path := scheduleTestOpen(t)
+
+ _, err := schedules.Set(tc.text, tc.at, tc.cron)
+ require.ErrorContains(t, err, tc.message)
+ require.NoFileExists(t, path)
+ })
+ }
+}
+
+func TestSchedulesSurviveARestart(t *testing.T) {
+ schedules, path := scheduleTestOpen(t)
+
+ _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
+ require.NoError(t, err)
+
+ _, err = schedules.Remove(1)
+ require.NoError(t, err)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+ reopened, err := openSchedules(path, schedules.clock)
+ require.NoError(t, err)
+ require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
+
+ answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
+ require.NoError(t, err)
+ require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
+}
+
+func TestSchedulesRemove(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+
+ _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
+ require.NoError(t, err)
+
+ answer, err := schedules.Remove(1)
+ require.NoError(t, err)
+ require.Equal(t, "Schedule 1 removed.", answer)
+ require.Equal(t, "No schedules.", schedules.List())
+
+ _, err = schedules.Remove(1)
+ require.EqualError(t, err, "no schedule 1")
+}
+
+func TestSchedulesDone(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+ start := schedules.clock()
+
+ _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
+ require.NoError(t, err)
+
+ wait, found := schedules.Wait(start)
+ require.True(t, found)
+ require.Equal(t, time.Hour, wait)
+
+ // The daemon was stopped for three hours, so each schedule missed its
+ // times and runs once.
+ now := start.Add(3*time.Hour + 30*time.Minute)
+ due := schedules.Due(now)
+ require.Len(t, due, 2)
+ for _, schedule := range due {
+ require.NoError(t, schedules.Done(schedule, now))
+ }
+
+ require.Empty(t, schedules.Due(now))
+ require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
+
+ _, err = schedules.Remove(2)
+ require.NoError(t, err)
+ require.NoError(t, schedules.Done(due[1], now))
+ require.Equal(t, "No schedules.", schedules.List())
+
+ _, found = schedules.Wait(now)
+ require.False(t, found)
+}
+
+func TestScheduleMessage(t *testing.T) {
+ zone := time.FixedZone("", -3*60*60)
+ due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
+ testCases := []struct {
+ name string
+ schedule Schedule
+ now time.Time
+ message string
+ }{
+ {
+ name: "a one-time run on time",
+ schedule: Schedule{
+ ID: 3,
+ Text: "Remind the user to drink water.",
+ Due: due,
+ },
+ now: due.Add(time.Second),
+ message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
+ },
+ {
+ name: "a late repeating run",
+ schedule: Schedule{
+ ID: 4,
+ Text: "Tell the user the news.",
+ Cron: "0 9 * * 1-5",
+ Due: due,
+ },
+ now: due.Add(2 * time.Hour),
+ message: "[Schedule 4] Monday 2026-11-09 09:00 -03:00, repeats 0 9 * * 1-5\nTell the user the news.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.message, tc.schedule.Message(tc.now))
+ })
+ }
+}
+
+func TestOpenSchedulesRefusesABadCron(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
+
+ _, err := openSchedules(path, time.Now)
+ require.ErrorContains(t, err, "schedule 1: the runs of")
+}
+
+// scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
+// 2026-11-08 20:00 -03:00, and returns them and the path of the file.
+func scheduleTestOpen(t *testing.T) (*Schedules, string) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ clock := &TestClock{
+ now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+ }
+
+ schedules, err := openSchedules(path, clock.Now)
+ require.NoError(t, err)
+
+ return schedules, path
+}