package tools import ( "context" "path/filepath" "strings" "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" ) func newTestCronTool(t *testing.T) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) msgBus := bus.NewMessageBus() cfg := config.DefaultConfig() tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg) if err != nil { t.Fatalf("NewCronTool() error: %v", err) } return tool } // TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "command_confirm": true, "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected command scheduling to be blocked from remote channel") } if !strings.Contains(result.ForLLM, "restricted to internal channels") { t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM) } } // TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required func TestCronTool_CommandRequiresConfirm(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected error when command_confirm is missing") } if !strings.Contains(result.ForLLM, "command_confirm=true") { t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM) } } // TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "command_confirm": true, "at_seconds": float64(60), }) if result.IsError { t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) } } // TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing func TestCronTool_AddJobRequiresSessionContext(t *testing.T) { tool := newTestCronTool(t) result := tool.Execute(context.Background(), map[string]any{ "action": "add", "message": "reminder", "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected error when session context is missing") } if !strings.Contains(result.ForLLM, "no session context") { t.Errorf("expected 'no session context' message, got: %s", result.ForLLM) } } // TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "time to stretch", "at_seconds": float64(600), }) if result.IsError { t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) } } func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "send me a poem", "at_seconds": float64(600), }) if result.IsError { t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM) } jobs := tool.cronService.ListJobs(false) if len(jobs) != 1 { t.Fatalf("expected 1 job, got %d", len(jobs)) } if jobs[0].Payload.Deliver { t.Fatal("expected deliver=false by default for non-command jobs") } }