]> git.giacomolli.uk - onechat/blob - schedule_test.go
5ef174bbf9090bbccf1c9f47514d64a6b61e98bb
[onechat] / schedule_test.go
1 package main
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11 )
12
13 func TestSchedulesSet(t *testing.T) {
14 testCases := []struct {
15 name string
16 text string
17 at *string
18 cron *string
19 answer string
20 listed string
21 }{
22 {
23 name: "a one-time schedule at a local time",
24 text: "Remind the user to drink water.",
25 at: new("2026-11-09 09:00"),
26 answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
27 listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
28 },
29 {
30 name: "a repeating schedule at the next match",
31 text: "Tell the user\nthe time.",
32 cron: new("*/5 * * * *"),
33 answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
34 listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
35 },
36 }
37
38 for _, tc := range testCases {
39 t.Run(tc.name, func(t *testing.T) {
40 schedules, _ := scheduleTestOpen(t)
41
42 answer, err := schedules.Set(tc.text, tc.at, tc.cron)
43 require.NoError(t, err)
44 require.Equal(t, tc.answer, answer)
45 require.Equal(t, tc.listed, schedules.List())
46 })
47 }
48 }
49
50 func TestSchedulesSetRefuses(t *testing.T) {
51 testCases := []struct {
52 name string
53 text string
54 at *string
55 cron *string
56 message string
57 }{
58 {
59 name: "an empty text",
60 text: " \n",
61 at: new("2026-11-09 09:00"),
62 message: "the text must have 1 to 4096 bytes",
63 },
64 {
65 name: "a text that is too long",
66 text: strings.Repeat("x", scheduleTextBytes+1),
67 at: new("2026-11-09 09:00"),
68 message: "the text must have 1 to 4096 bytes",
69 },
70 {
71 name: "neither at nor cron",
72 text: "Tell the user the time.",
73 message: "give either at or cron, and null for the other",
74 },
75 {
76 name: "both at and cron",
77 text: "Tell the user the time.",
78 at: new("2026-11-09 09:00"),
79 cron: new("0 9 * * *"),
80 message: "give either at or cron, and null for the other",
81 },
82 {
83 name: "a time in another layout",
84 text: "Tell the user the time.",
85 at: new("2026-11-09T09:00:00-03:00"),
86 message: "at must be a local time such as 2026-11-08 20:00",
87 },
88 {
89 name: "a time that is past",
90 text: "Tell the user the time.",
91 at: new("2026-11-08 20:00"),
92 message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
93 },
94 {
95 name: "runs less than 5 minutes apart",
96 text: "Tell the user the time.",
97 cron: new("* * * * *"),
98 message: "at least 5 minutes apart",
99 },
100 }
101
102 for _, tc := range testCases {
103 t.Run(tc.name, func(t *testing.T) {
104 schedules, path := scheduleTestOpen(t)
105
106 _, err := schedules.Set(tc.text, tc.at, tc.cron)
107 require.ErrorContains(t, err, tc.message)
108 require.NoFileExists(t, path)
109 })
110 }
111 }
112
113 func TestSchedulesSurviveARestart(t *testing.T) {
114 schedules, path := scheduleTestOpen(t)
115
116 _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
117 require.NoError(t, err)
118
119 _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
120 require.NoError(t, err)
121
122 _, err = schedules.Remove(1)
123 require.NoError(t, err)
124
125 info, err := os.Stat(path)
126 require.NoError(t, err)
127 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
128
129 reopened, err := openSchedules(path, schedules.clock)
130 require.NoError(t, err)
131 require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
132
133 answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
134 require.NoError(t, err)
135 require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
136 }
137
138 func TestSchedulesRemove(t *testing.T) {
139 schedules, _ := scheduleTestOpen(t)
140
141 _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
142 require.NoError(t, err)
143
144 answer, err := schedules.Remove(1)
145 require.NoError(t, err)
146 require.Equal(t, "Schedule 1 removed.", answer)
147 require.Equal(t, "No schedules.", schedules.List())
148
149 _, err = schedules.Remove(1)
150 require.EqualError(t, err, "no schedule 1")
151 }
152
153 func TestSchedulesDone(t *testing.T) {
154 schedules, _ := scheduleTestOpen(t)
155 start := schedules.clock()
156
157 _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
158 require.NoError(t, err)
159
160 _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
161 require.NoError(t, err)
162
163 wait, found := schedules.Wait(start)
164 require.True(t, found)
165 require.Equal(t, time.Hour, wait)
166
167 // The daemon was stopped for three hours, so each schedule missed its
168 // times and runs once.
169 now := start.Add(3*time.Hour + 30*time.Minute)
170 due := schedules.Due(now)
171 require.Len(t, due, 2)
172 for _, schedule := range due {
173 require.NoError(t, schedules.Done(schedule, now))
174 }
175
176 require.Empty(t, schedules.Due(now))
177 require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
178
179 _, err = schedules.Remove(2)
180 require.NoError(t, err)
181 require.NoError(t, schedules.Done(due[1], now))
182 require.Equal(t, "No schedules.", schedules.List())
183
184 _, found = schedules.Wait(now)
185 require.False(t, found)
186 }
187
188 func TestScheduleMessage(t *testing.T) {
189 zone := time.FixedZone("", -3*60*60)
190 due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
191 testCases := []struct {
192 name string
193 schedule Schedule
194 now time.Time
195 message string
196 }{
197 {
198 name: "a one-time run on time",
199 schedule: Schedule{
200 ID: 3,
201 Text: "Remind the user to drink water.",
202 Due: due,
203 },
204 now: due.Add(time.Second),
205 message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
206 },
207 {
208 name: "a late repeating run",
209 schedule: Schedule{
210 ID: 4,
211 Text: "Tell the user the news.",
212 Cron: "0 9 * * 1-5",
213 Due: due,
214 },
215 now: due.Add(2 * time.Hour),
216 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,
217 },
218 }
219
220 for _, tc := range testCases {
221 t.Run(tc.name, func(t *testing.T) {
222 require.Equal(t, tc.message, tc.schedule.Message(tc.now))
223 })
224 }
225 }
226
227 func TestOpenSchedulesRefusesABadCron(t *testing.T) {
228 path := filepath.Join(t.TempDir(), scheduleFileName)
229 require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
230
231 _, err := openSchedules(path, time.Now)
232 require.ErrorContains(t, err, "schedule 1: the runs of")
233 }
234
235 // scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
236 // 2026-11-08 20:00 -03:00, and returns them and the path of the file.
237 func scheduleTestOpen(t *testing.T) (*Schedules, string) {
238 path := filepath.Join(t.TempDir(), scheduleFileName)
239 clock := &TestClock{
240 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
241 }
242
243 schedules, err := openSchedules(path, clock.Now)
244 require.NoError(t, err)
245
246 return schedules, path
247 }