package cron_test import ( "context" "errors" "log/slog" "os" "path/filepath" "sync/atomic" "testing" "time" "github.com/enmanuel/agents/internal/config" coretypes "github.com/enmanuel/agents/pkg/llm" shellcron "github.com/enmanuel/agents/shell/cron" ) // ── fakes ────────────────────────────────────────────────────────────────── type fakeSender struct { calls atomic.Int32 lastMD string lastRM string } func (f *fakeSender) SendMarkdown(_ context.Context, room, md string) error { f.calls.Add(1) f.lastRM = room f.lastMD = md return nil } type errSender struct{} func (e *errSender) SendMarkdown(_ context.Context, _, _ string) error { return errors.New("matrix unavailable") } func fakeLLM(reply string) coretypes.CompleteFunc { return func(_ context.Context, _ coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { return coretypes.CompletionResponse{Content: reply}, nil } } func newTestLogger(t *testing.T) *slog.Logger { t.Helper() return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) } // ── helpers ──────────────────────────────────────────────────────────────── // waitCalls blocks until the sender has received at least n calls or the deadline passes. func waitCalls(t *testing.T, f *fakeSender, n int32) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if f.calls.Load() >= n { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("expected %d call(s) to SendMarkdown, got %d", n, f.calls.Load()) } // ── cron-based tests (require timer) ────────────────────────────────────── func TestScheduler_SendMessage_Inline(t *testing.T) { sender := &fakeSender{} cfg := []config.ScheduleCfg{ { Name: "test-inline", Cron: "@every 100ms", OutputRoom: "!room:server.com", Action: config.ScheduledAction{ Kind: "send_message", Message: "hola mundo", }, }, } s := shellcron.New(cfg, sender, nil, "", newTestLogger(t)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) s.Start(ctx) }() waitCalls(t, sender, 1) cancel() <-done if sender.lastRM != "!room:server.com" { t.Errorf("unexpected room: %s", sender.lastRM) } if sender.lastMD != "hola mundo" { t.Errorf("unexpected message: %s", sender.lastMD) } } func TestScheduler_SendMessage_Template(t *testing.T) { // Write a temporary template file dir := t.TempDir() tmpl := filepath.Join(dir, "greeting.md") if err := os.WriteFile(tmpl, []byte("buenos días"), 0o600); err != nil { t.Fatal(err) } sender := &fakeSender{} cfg := []config.ScheduleCfg{ { Name: "test-template", Cron: "@every 100ms", OutputRoom: "!room2:server.com", Action: config.ScheduledAction{ Kind: "send_message", Template: tmpl, }, }, } s := shellcron.New(cfg, sender, nil, "", newTestLogger(t)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) s.Start(ctx) }() waitCalls(t, sender, 1) cancel() <-done if sender.lastMD != "buenos días" { t.Errorf("unexpected message: %q", sender.lastMD) } } func TestScheduler_LLMPrompt(t *testing.T) { sender := &fakeSender{} cfg := []config.ScheduleCfg{ { Name: "test-llm", Cron: "@every 100ms", OutputRoom: "!room3:server.com", Action: config.ScheduledAction{ Kind: "llm_prompt", Prompt: "resume el día", }, }, } llm := fakeLLM("resumen generado por LLM") s := shellcron.New(cfg, sender, llm, "gpt-4o", newTestLogger(t)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) s.Start(ctx) }() waitCalls(t, sender, 1) cancel() <-done if sender.lastMD != "resumen generado por LLM" { t.Errorf("unexpected LLM reply: %q", sender.lastMD) } } func TestScheduler_MatrixSendError(t *testing.T) { // If matrix.SendMarkdown returns an error, the scheduler should log it and not panic. cfg := []config.ScheduleCfg{ { Name: "err-send", Cron: "@every 100ms", OutputRoom: "!room:server.com", Action: config.ScheduledAction{ Kind: "send_message", Message: "trigger error", }, }, } s := shellcron.New(cfg, &errSender{}, nil, "", newTestLogger(t)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) s.Start(ctx) }() // Let it fire at least once without panicking time.Sleep(250 * time.Millisecond) cancel() <-done } // ── Fire() tests (deterministic, no timer) ───────────────────────────────── func TestFire_SendMessage_Inline(t *testing.T) { sender := &fakeSender{} s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) sc := config.ScheduleCfg{ Name: "fire-inline", Cron: "0 9 * * *", OutputRoom: "!fireroom:server.com", Action: config.ScheduledAction{ Kind: "send_message", Message: "buenos días via Fire", }, } s.Fire(context.Background(), sc) if sender.calls.Load() != 1 { t.Fatalf("expected 1 call, got %d", sender.calls.Load()) } if sender.lastRM != "!fireroom:server.com" { t.Errorf("unexpected room: %s", sender.lastRM) } if sender.lastMD != "buenos días via Fire" { t.Errorf("unexpected message: %s", sender.lastMD) } } func TestFire_LLMPrompt(t *testing.T) { sender := &fakeSender{} llm := fakeLLM("respuesta del LLM via Fire") s := shellcron.New(nil, sender, llm, "gpt-4o", newTestLogger(t)) sc := config.ScheduleCfg{ Name: "fire-llm", Cron: "0 18 * * *", OutputRoom: "!llmroom:server.com", Action: config.ScheduledAction{ Kind: "llm_prompt", Prompt: "resume el día", }, } s.Fire(context.Background(), sc) if sender.calls.Load() != 1 { t.Fatalf("expected 1 call, got %d", sender.calls.Load()) } if sender.lastMD != "respuesta del LLM via Fire" { t.Errorf("unexpected LLM reply: %q", sender.lastMD) } } func TestFire_NoOutputRoom_Skips(t *testing.T) { sender := &fakeSender{} s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) sc := config.ScheduleCfg{ Name: "fire-no-room", Cron: "0 9 * * *", OutputRoom: "", // intentionally empty Action: config.ScheduledAction{ Kind: "send_message", Message: "should not send", }, } s.Fire(context.Background(), sc) if sender.calls.Load() != 0 { t.Errorf("expected 0 calls when output_room is empty, got %d", sender.calls.Load()) } } func TestFire_LLMPrompt_NoLLM_Skips(t *testing.T) { // When no LLM is configured, Fire with llm_prompt should not send anything. sender := &fakeSender{} s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) sc := config.ScheduleCfg{ Name: "fire-no-llm", Cron: "0 9 * * *", OutputRoom: "!room:server.com", Action: config.ScheduledAction{ Kind: "llm_prompt", Prompt: "hello", }, } s.Fire(context.Background(), sc) if sender.calls.Load() != 0 { t.Errorf("expected 0 calls without LLM, got %d", sender.calls.Load()) } } func TestScheduler_SkipsInvalidSchedule(t *testing.T) { // Schedules without output_room or without action kind must be skipped during Start. // We use Fire directly to test the skip logic without timer overhead. sender := &fakeSender{} s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) ctx := context.Background() // No output_room → skip s.Fire(ctx, config.ScheduleCfg{ Name: "no-room", Cron: "@every 100ms", // missing OutputRoom Action: config.ScheduledAction{Kind: "send_message", Message: "hi"}, }) // No kind → Fire calls buildHandler which returns nil → skip s.Fire(ctx, config.ScheduleCfg{ Name: "no-kind", Cron: "@every 100ms", OutputRoom: "!room:server.com", // missing Action.Kind }) if sender.calls.Load() != 0 { t.Errorf("expected 0 calls for invalid schedules, got %d", sender.calls.Load()) } }