Files
2026-06-02 23:14:41 +08:00

652 lines
17 KiB
Go

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)
}
}