package core import ( "context" "encoding/json" "path/filepath" "strings" "testing" "time" ) func TestCronStore_MuteToggle(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } job := &CronJob{ ID: "test1", Project: "proj", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "hello", Enabled: true, CreatedAt: time.Now(), } if err := store.Add(job); err != nil { t.Fatal(err) } if store.Get("test1").Mute { t.Error("new job should not be muted") } if !store.SetMute("test1", true) { t.Error("SetMute should return true for existing job") } if !store.Get("test1").Mute { t.Error("job should be muted after SetMute(true)") } newState, ok := store.ToggleMute("test1") if !ok { t.Error("ToggleMute should return ok=true for existing job") } if newState { t.Error("ToggleMute should have toggled mute to false") } newState, ok = store.ToggleMute("test1") if !ok || !newState { t.Error("ToggleMute should toggle back to true") } if store.SetMute("nonexistent", true) { t.Error("SetMute should return false for nonexistent job") } _, ok = store.ToggleMute("nonexistent") if ok { t.Error("ToggleMute should return ok=false for nonexistent job") } } func TestCronStore_MutePersistence(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } job := &CronJob{ ID: "persist1", Project: "proj", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "hello", Enabled: true, CreatedAt: time.Now(), } if err := store.Add(job); err != nil { t.Fatal(err) } if !store.SetMute("persist1", true) { t.Fatal("SetMute should return true") } store2, err := NewCronStore(dir) if err != nil { t.Fatal(err) } j := store2.Get("persist1") if j == nil { t.Fatal("job not found after reload") } if !j.Mute { t.Error("mute flag should persist after reload") } } func TestMutePlatform_DiscardMessages(t *testing.T) { inner := &stubPlatformEngine{n: "test"} mp := &mutePlatform{inner} if err := mp.Reply(context.Background(), "ctx", "hello"); err != nil { t.Errorf("Reply should return nil, got %v", err) } if err := mp.Send(context.Background(), "key", "world"); err != nil { t.Errorf("Send should return nil, got %v", err) } if len(inner.getSent()) != 0 { t.Errorf("mutePlatform should discard messages, got %v", inner.getSent()) } if mp.Name() != "test" { t.Errorf("mutePlatform should delegate Name(), got %q", mp.Name()) } } func TestCronJob_MuteField(t *testing.T) { job := &CronJob{ID: "m1", Mute: false} if job.Mute { t.Error("default should be not muted") } job.Mute = true if !job.Mute { t.Error("should be muted after setting") } } func TestCronExprToHuman_BasicCases(t *testing.T) { tests := []struct { expr string lang Language want string }{ {"0 6 * * *", LangEnglish, "Daily at 06:00"}, {"0 6 * * *", LangChinese, "每天 06:00"}, {"30 14 * * 1", LangEnglish, "Every Monday at 14:30"}, // Step expressions {"*/5 * * * *", LangEnglish, "Every 5 min"}, {"*/5 * * * *", LangChinese, "每5分钟"}, {"*/30 * * * *", LangChinese, "每30分钟"}, {"*/15 * * * *", LangJapanese, "15分ごと"}, {"0 */2 * * *", LangEnglish, "Every 2 h (:00)"}, {"0 */2 * * *", LangChinese, "每2小时 (:00)"}, {"30 */6 * * *", LangEnglish, "Every 6 h (:30)"}, // Regular cases still work {"0 0 1 * *", LangEnglish, "Monthly, day 1, 00:00"}, {"0 0 1 * *", LangChinese, "每月1日 00:00"}, } for _, tt := range tests { got := CronExprToHuman(tt.expr, tt.lang) if got != tt.want { t.Errorf("CronExprToHuman(%q, %v) = %q, want %q", tt.expr, tt.lang, got, tt.want) } } } func TestRenderCronCard_WithButtons(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } if err := store.Add(&CronJob{ ID: "j1", Project: "test", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "daily task", Enabled: true, CreatedAt: time.Now(), }); err != nil { t.Fatal(err) } if err := store.Add(&CronJob{ ID: "j2", Project: "test", SessionKey: "test:ch1", CronExpr: "0 12 * * *", Prompt: "noon task", Enabled: false, Mute: true, CreatedAt: time.Now(), }); err != nil { t.Fatal(err) } p := &stubPlatformEngine{n: "test"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) scheduler := NewCronScheduler(store) e.cronScheduler = scheduler card := e.renderCronCard("test:ch1", "") if card == nil { t.Fatal("card should not be nil") } hasButtons := card.HasButtons() if !hasButtons { t.Error("card should have interactive buttons") } allBtns := card.CollectButtons() var allValues []string for _, row := range allBtns { for _, btn := range row { allValues = append(allValues, btn.Data) } } found := map[string]bool{ "disable j1": false, "enable j2": false, "mute j1": false, "unmute j2": false, "delete j1": false, "delete j2": false, } for _, v := range allValues { for key := range found { if strings.Contains(v, key) { found[key] = true } } } for key, ok := range found { if !ok { t.Errorf("expected button containing %q not found in card buttons: %v", key, allValues) } } text := card.RenderText() if !strings.Contains(text, "[mute]") { t.Error("muted job should show [mute] tag in card text") } } func TestRenderCronCard_HasHint(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } if err := store.Add(&CronJob{ ID: "h1", Project: "test", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "task", Enabled: true, CreatedAt: time.Now(), }); err != nil { t.Fatal(err) } p := &stubPlatformEngine{n: "test"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) scheduler := NewCronScheduler(store) e.cronScheduler = scheduler card := e.renderCronCard("test:ch1", "") text := card.RenderText() if !strings.Contains(text, "/cron add") || !strings.Contains(text, "/cron mute") { t.Errorf("card should contain command hints, got:\n%s", text) } } func TestExecuteCardAction_CronActions(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } if err := store.Add(&CronJob{ ID: "act1", Project: "test", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "task", Enabled: true, CreatedAt: time.Now(), }); err != nil { t.Fatal(err) } p := &stubPlatformEngine{n: "test"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) scheduler := NewCronScheduler(store) e.cronScheduler = scheduler scheduler.RegisterEngine("test", e) if err := scheduler.Start(); err != nil { t.Fatal(err) } defer scheduler.Stop() e.executeCardAction("/cron", "disable act1", "test:ch1") j := store.Get("act1") if j.Enabled { t.Error("job should be disabled after card action") } e.executeCardAction("/cron", "enable act1", "test:ch1") j = store.Get("act1") if !j.Enabled { t.Error("job should be re-enabled after card action") } e.executeCardAction("/cron", "mute act1", "test:ch1") j = store.Get("act1") if !j.Mute { t.Error("job should be muted after card action") } e.executeCardAction("/cron", "unmute act1", "test:ch1") j = store.Get("act1") if j.Mute { t.Error("job should be unmuted after card action") } e.executeCardAction("/cron", "delete act1", "test:ch1") if store.Get("act1") != nil { t.Error("job should be deleted after card action") } } func TestCmdCronMute_TextCommand(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } if err := store.Add(&CronJob{ ID: "txt1", Project: "test", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "task", Enabled: true, CreatedAt: time.Now(), }); err != nil { t.Fatal(err) } p := &stubPlatformEngine{n: "test"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) scheduler := NewCronScheduler(store) e.cronScheduler = scheduler msg := &Message{SessionKey: "test:ch1", UserID: "u1", ReplyCtx: "ctx"} e.cmdCronMute(p, msg, []string{"txt1"}, true) if !store.Get("txt1").Mute { t.Error("job should be muted via text command") } sent := p.getSent() if len(sent) == 0 || !strings.Contains(sent[len(sent)-1], "🔇") { t.Errorf("should reply with muted confirmation, got: %v", sent) } e.cmdCronMute(p, msg, []string{"txt1"}, false) if store.Get("txt1").Mute { t.Error("job should be unmuted via text command") } sent = p.getSent() if len(sent) == 0 || !strings.Contains(sent[len(sent)-1], "🔔") { t.Errorf("should reply with unmuted confirmation, got: %v", sent) } e.cmdCronMute(p, msg, []string{"nonexistent"}, true) sent = p.getSent() if len(sent) == 0 || !strings.Contains(sent[len(sent)-1], "not found") { t.Errorf("should reply with not found for bad id, got: %v", sent) } } func TestCronStore_JobsPath(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } expected := filepath.Join(dir, "crons", "jobs.json") if store.path != expected { t.Errorf("store path = %q, want %q", store.path, expected) } } func TestCronJob_ExecutionTimeout(t *testing.T) { j := &CronJob{} if got := j.ExecutionTimeout(); got != defaultCronJobTimeout { t.Errorf("nil TimeoutMins: got %v, want %v", got, defaultCronJobTimeout) } zero := 0 j.TimeoutMins = &zero if got := j.ExecutionTimeout(); got != 0 { t.Errorf("TimeoutMins=0: got %v, want 0", got) } five := 5 j.TimeoutMins = &five if got := j.ExecutionTimeout(); got != 5*time.Minute { t.Errorf("TimeoutMins=5: got %v", got) } } func TestCronScheduler_AddJob_InvalidSessionMode(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } cs := NewCronScheduler(store) err = cs.AddJob(&CronJob{ ID: "x1", Project: "p", SessionKey: "test:1:1", CronExpr: "0 6 * * *", Prompt: "hi", SessionMode: "bogus", }) if err == nil { t.Fatal("expected error for invalid session_mode") } } func TestCronJob_UsesNewSessionPerRun(t *testing.T) { for _, mode := range []string{"new_per_run", "new-per-run", "NEW_PER_RUN"} { j := &CronJob{SessionMode: mode} if !j.UsesNewSessionPerRun() { t.Errorf("UsesNewSessionPerRun(%q) = false", mode) } } j := &CronJob{SessionMode: "reuse"} if j.UsesNewSessionPerRun() { t.Error("reuse should not use new session per run") } } func TestCronJob_JSONLegacyUnmarshal(t *testing.T) { raw := `{"id":"1","project":"p","session_key":"t:1:1","cron_expr":"0 6 * * *","prompt":"x","enabled":true}` var j CronJob if err := json.Unmarshal([]byte(raw), &j); err != nil { t.Fatal(err) } if j.SessionMode != "" { t.Errorf("legacy JSON: SessionMode = %q, want empty", j.SessionMode) } if j.TimeoutMins != nil { t.Errorf("legacy JSON: TimeoutMins = %v, want nil", j.TimeoutMins) } } func TestCronScheduler_AddJob_NegativeTimeoutMins(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } cs := NewCronScheduler(store) n := -1 err = cs.AddJob(&CronJob{ ID: "t1", Project: "p", SessionKey: "test:1:1", CronExpr: "0 6 * * *", Prompt: "hi", TimeoutMins: &n, }) if err == nil { t.Fatal("expected error for negative timeout_mins") } } // TestCronScheduler_AddJob_EmptySessionKey verifies that AddJob refuses to // persist a job without a session_key. Without this guard, ExecuteCronJob // later fails at fire-time with `platform "" not found for session ""`, // leaving an unrunnable job lingering in the store. func TestCronScheduler_AddJob_EmptySessionKey(t *testing.T) { for _, tt := range []struct { name string sessionKey string }{ {"empty", ""}, {"whitespace", " "}, } { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } cs := NewCronScheduler(store) err = cs.AddJob(&CronJob{ ID: "j1", Project: "p", SessionKey: tt.sessionKey, CronExpr: "0 6 * * *", Prompt: "hi", }) if err == nil { t.Fatalf("expected error for empty session_key, got nil") } if !strings.Contains(err.Error(), "session_key") { t.Errorf("error %q should mention session_key", err.Error()) } if jobs := store.List(); len(jobs) != 0 { t.Errorf("store should be empty after rejected AddJob, got %d job(s)", len(jobs)) } }) } } func TestCronScheduler_AddJob_NormalizesSessionMode(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } cs := NewCronScheduler(store) job := &CronJob{ ID: "n1", Project: "p", SessionKey: "test:1:1", CronExpr: "0 6 * * *", Prompt: "hi", SessionMode: "new-per-run", } if err := cs.AddJob(job); err != nil { t.Fatal(err) } if job.SessionMode != "new_per_run" { t.Errorf("SessionMode = %q, want new_per_run", job.SessionMode) } } func TestCronScheduler_UsesNewSession_GlobalDefault(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } cs := NewCronScheduler(store) // Test 1: global default is "new_per_run", job has no session_mode set cs.SetDefaultSessionMode("new_per_run") job := &CronJob{SessionMode: ""} if !cs.UsesNewSession(job) { t.Error("global new_per_run + job empty: expected UsesNewSession=true") } // Test 2: per-job "reuse" overrides global "new_per_run" job.SessionMode = "reuse" if cs.UsesNewSession(job) { t.Error("global new_per_run + job reuse: expected UsesNewSession=false") } // Test 3: per-job "new_per_run" overrides global default (reuse) cs.SetDefaultSessionMode("") job.SessionMode = "new_per_run" if !cs.UsesNewSession(job) { t.Error("global reuse + job new_per_run: expected UsesNewSession=true") } // Test 4: both global and job are default (reuse) job.SessionMode = "" if cs.UsesNewSession(job) { t.Error("global reuse + job empty: expected UsesNewSession=false") } } func TestCronStore_MarkRun(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } job := &CronJob{ ID: "markrun-test", Project: "proj", SessionKey: "test:ch1", CronExpr: "0 6 * * *", Prompt: "hello", Enabled: true, } if err := store.Add(job); err != nil { t.Fatal(err) } // MarkRun should update LastRun before := time.Now() store.MarkRun("markrun-test", nil) after := time.Now() updated := store.Get("markrun-test") if updated.LastRun.IsZero() { t.Error("LastRun should be set after MarkRun") } if updated.LastRun.Before(before) || updated.LastRun.After(after) { t.Error("LastRun should be between before and after MarkRun call") } } func TestCronStore_ListByProject(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } // Add jobs for different projects jobs := []*CronJob{ {ID: "j1", Project: "proj1", SessionKey: "s1", CronExpr: "0 6 * * *", Prompt: "p1"}, {ID: "j2", Project: "proj1", SessionKey: "s2", CronExpr: "0 7 * * *", Prompt: "p2"}, {ID: "j3", Project: "proj2", SessionKey: "s3", CronExpr: "0 8 * * *", Prompt: "p3"}, } for _, j := range jobs { j.Enabled = true if err := store.Add(j); err != nil { t.Fatal(err) } } list := store.ListByProject("proj1") if len(list) != 2 { t.Errorf("ListByProject(proj1) = %d jobs, want 2", len(list)) } list2 := store.ListByProject("proj2") if len(list2) != 1 { t.Errorf("ListByProject(proj2) = %d jobs, want 1", len(list2)) } list3 := store.ListByProject("nonexistent") if len(list3) != 0 { t.Errorf("ListByProject(nonexistent) = %d jobs, want 0", len(list3)) } } // TestCronScheduler_UpdateJob_EnabledNonBoolPreservesSchedule verifies that // passing a non-bool value for the "enabled" field is rejected up-front and // does NOT silently leave the scheduled cron entry removed. Before this fix, // UpdateJob removed the entry before delegating to store.Update, so a type // mismatch left the job marked Enabled but with no scheduled tick — the only // recovery was a daemon restart. func TestCronScheduler_UpdateJob_EnabledNonBoolPreservesSchedule(t *testing.T) { dir := t.TempDir() store, err := NewCronStore(dir) if err != nil { t.Fatal(err) } cs := NewCronScheduler(store) job := &CronJob{ ID: "e1", Project: "p", SessionKey: "test:1:1", CronExpr: "0 6 * * *", Prompt: "hi", Enabled: true, } if err := cs.AddJob(job); err != nil { t.Fatal(err) } cs.mu.RLock() _, scheduledBefore := cs.entries[job.ID] cs.mu.RUnlock() if !scheduledBefore { t.Fatal("precondition: enabled job should be scheduled after AddJob") } // String "true" instead of bool true — what a misbehaving HTTP/management // API client could send. if err := cs.UpdateJob(job.ID, "enabled", "true"); err == nil { t.Fatal("UpdateJob with non-bool enabled value should return an error") } cs.mu.RLock() _, scheduledAfter := cs.entries[job.ID] cs.mu.RUnlock() if !scheduledAfter { t.Fatal("schedule was removed even though update failed; job will never fire again") } stored := store.Get(job.ID) if stored == nil || !stored.Enabled { t.Fatalf("stored job state should be unchanged on validation error, got %+v", stored) } }