import (
"context"
- "log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
+ "testing/synctest"
"time"
"github.com/stretchr/testify/require"
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
- reopened, err := openSchedules(path, schedules.clock, discardLog())
+ reopened, err := openSchedules(path, "OneChat", schedules.clock, discardLog())
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())
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))
}
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, discardLog())
+ _, err := openSchedules(path, "OneChat", time.Now, discardLog())
require.ErrorContains(t, err, "schedule 1: the runs of")
}
}
logs := &SyncBuffer{}
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(logs, "", 0))
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, daemonTestLogger(logs))
require.NoError(t, err)
server := newModelFake(t)
require.NoError(t, err)
clock.Set(clock.Now().Add(5 * time.Minute))
+
+ schedules.changed <- struct{}{}
+
test.events.WaitFor(t, "idle", 1)
require.Equal(t, 1, server.Calls())
require.Equal(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply, texts[len(texts)-1])
require.Equal(t, scheduleSource, test.events.LastEvent("work").Source)
require.Equal(t, "No schedules.", schedules.List())
- require.Equal(t, "schedule 1 runs\n", logs.String())
+ require.Equal(t, "<6>schedule ran id=1\n", logs.String())
}
func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) {
}
logs := &SyncBuffer{}
- schedules, err := openSchedules(path, clock.Now, log.New(logs, "", 0))
+ schedules, err := openSchedules(path, "OneChat", clock.Now, daemonTestLogger(logs))
require.NoError(t, err)
server := newModelFake(t)
"[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
}, texts[len(texts)-2:])
require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
- require.Equal(t, "schedule 1 runs 11h30m0s late, because the daemon was stopped at its time\nschedule 2 runs 10h30m0s late, because the daemon was stopped at its time\n", logs.String())
+ require.Equal(t, "<6>schedule ran id=1 late=11h30m0s\n<6>schedule ran id=2 late=10h30m0s\n", logs.String())
}
func TestDaemonRunsADueSchedule(t *testing.T) {
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, discardLog())
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog())
require.NoError(t, err)
- schedules.check = time.Millisecond
whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
socket, _ := daemonStartTestWithSchedules(t, newModelFake(t), nil, whatsapp, schedules, clock.Now)
client := terminalStartTestClient(t, socket)
require.NoError(t, err)
clock.Set(clock.Now().Add(5 * time.Minute))
+
+ schedules.changed <- struct{}{}
+
client.WaitFor(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply+"\n[working]\na line\n[done]")
}
+func TestSchedulesCheckTheClockAfterItsWaitLimit(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+ loop, events := newTestIdleTurnLoop(t, whatsapp)
+ clock := &ScheduleTestClock{now: time.Now()}
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog())
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Check the clock.", new(clock.Now().Add(5*time.Minute).Format(scheduleAtLayout)), nil)
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ done <- schedules.Run(ctx, loop)
+ }()
+
+ synctest.Wait()
+ require.Zero(t, loop.memory.Len())
+ clock.Set(clock.Now().Add(5 * time.Minute))
+ time.Sleep(scheduleCheck)
+ synctest.Wait()
+ require.Equal(t, 1, loop.memory.Len())
+ require.Contains(t, events.Last("work"), "Check the clock.")
+ cancel()
+ require.NoError(t, <-done)
+ })
+}
+
func TestToolboxRunsTheScheduleTools(t *testing.T) {
schedules, _ := scheduleTestOpen(t)
box := &Toolbox{
require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
}
+func TestSchedulesKeepTheirOwnerAcrossRestart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ clock := newTestClock()
+ schedules, err := openSchedules(path, "GreenOtter", clock.Now, discardLog())
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Check the plants.", new(clock.Now().Add(time.Minute).Format(scheduleAtLayout)), nil)
+ require.NoError(t, err)
+
+ reopened, err := openSchedules(path, "GreenOtter", clock.Now, discardLog())
+ require.NoError(t, err)
+ require.Equal(t, "GreenOtter", reopened.sorted()[0].Owner)
+
+ _, err = openSchedules(path, "OneChat", clock.Now, discardLog())
+ require.ErrorContains(t, err, "belongs to")
+}
+
+func TestSchedulesAssignTheOwnerToSavedRootSchedules(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"Check the news.","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
+
+ schedules, err := openSchedules(path, "OneChat", newTestClock().Now, discardLog())
+ require.NoError(t, err)
+ require.Equal(t, "OneChat", schedules.sorted()[0].Owner)
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ require.Contains(t, string(data), `"owner":"OneChat"`)
+}
+
// ScheduleTestClock is a clock that a test sets while Run reads it.
type ScheduleTestClock struct {
mutex sync.Mutex
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(path, clock.Now, discardLog())
+ schedules, err := openSchedules(path, "OneChat", clock.Now, discardLog())
require.NoError(t, err)
return schedules, path
}
// scheduleTestRun runs schedules in a new turn loop that calls server, until
-// the test ends. Run looks at the times every millisecond.
+// the test ends.
func scheduleTestRun(t *testing.T, schedules *Schedules, server *ModelFake) *TurnTestLoop {
t.Helper()
- schedules.check = time.Millisecond
test := newTestTurnLoop(t, server)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error)