package methods import ( "context" "encoding/json" "testing" "time" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // ---- stub CronStore ---- type stubCronStore struct { jobs map[string]*store.CronJob addErr error addNilJob bool // simulate insert-ok-but-readback-failed: AddJob returns (nil, nil) removeErr error updateErr error enableErr error addedJob *store.CronJob updateCnt int enableCnt int runCnt int } func newStubCronStore() *stubCronStore { return &stubCronStore{jobs: make(map[string]*store.CronJob)} } func (s *stubCronStore) addJob(id, name, userID string) { s.jobs[id] = &store.CronJob{ID: id, Name: name, UserID: userID, Enabled: true} } func (s *stubCronStore) AddJob(_ context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { if s.addErr != nil { return nil, s.addErr } if s.addNilJob { return nil, nil } job := &store.CronJob{ ID: "new-job-id", Name: name, UserID: userID, Enabled: true, Schedule: schedule, } s.addedJob = job s.jobs[job.ID] = job return job, nil } func (s *stubCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) { j, ok := s.jobs[jobID] return j, ok } func (s *stubCronStore) ListJobs(_ context.Context, _ bool, _, userID string) []store.CronJob { var result []store.CronJob for _, j := range s.jobs { if userID == "" || j.UserID == userID { result = append(result, *j) } } return result } func (s *stubCronStore) RemoveJob(_ context.Context, jobID string) error { if s.removeErr != nil { return s.removeErr } delete(s.jobs, jobID) return nil } func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { s.updateCnt++ if s.updateErr != nil { return nil, s.updateErr } j, ok := s.jobs[jobID] if !ok { return nil, store.ErrCronJobNotFound } return j, nil } func (s *stubCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error { s.enableCnt++ if s.enableErr != nil { return s.enableErr } if j, ok := s.jobs[jobID]; ok { j.Enabled = enabled } return nil } func (s *stubCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]store.CronRunLogEntry, int) { return nil, 0 } func (s *stubCronStore) Status() map[string]any { return map[string]any{"running": true} } // Lifecycle stubs (not called in unit tests) func (s *stubCronStore) Start() error { return nil } func (s *stubCronStore) Stop() {} func (s *stubCronStore) SetOnJob(_ func(*store.CronJob) (*store.CronJobResult, error)) {} func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {} func (s *stubCronStore) RunJob(_ context.Context, _ string, _ bool) (bool, string, error) { s.runCnt++ return true, "", nil } func (s *stubCronStore) SetDefaultTimezone(_ string) {} func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil } // ---- helpers ---- func buildCronMethods(t *testing.T, svc *stubCronStore) *CronMethods { t.Helper() cfg := &config.Config{} return NewCronMethods(svc, &stubEventPub{}, cfg) } func cronReqFrame(t *testing.T, method string, params map[string]any) *protocol.RequestFrame { t.Helper() raw, err := json.Marshal(params) if err != nil { t.Fatalf("marshal: %v", err) } return &protocol.RequestFrame{ Type: protocol.FrameTypeRequest, ID: "cron-req-1", Method: method, Params: raw, } } // ---- Tests: handleList ---- func TestCronList_EmptyStore_ReturnsEmptyJobs(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronList, map[string]any{}) m.handleList(context.Background(), client, req) // No panic = success } func TestCronList_WithJobs_ReturnsAll(t *testing.T) { svc := newStubCronStore() svc.addJob("job-1", "my-job", "user-x") svc.addJob("job-2", "another-job", "user-y") m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronList, map[string]any{"includeDisabled": false}) m.handleList(context.Background(), client, req) // No panic = success } // ---- Tests: handleCreate ---- func TestCronCreate_MissingName_ReturnsInvalidRequest(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ "message": "hello", "schedule": map[string]any{"kind": "every", "everyMs": 60000}, }) m.handleCreate(context.Background(), client, req) // No panic = name-required error path hit } func TestCronCreate_MissingMessage_ReturnsInvalidRequest(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ "name": "my-job", "schedule": map[string]any{"kind": "every", "everyMs": 60000}, // message intentionally omitted }) m.handleCreate(context.Background(), client, req) // No panic = message-required error path hit } func TestCronCreate_InvalidSlug_ReturnsInvalidRequest(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() // Name contains uppercase — invalid slug req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ "name": "My Invalid Name!", "message": "do something", "schedule": map[string]any{"kind": "every", "everyMs": 60000}, }) m.handleCreate(context.Background(), client, req) // No panic = invalid-slug error path hit } func TestCronCreate_ValidParams_CreatesJob(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ "name": "my-daily-job", "message": "do the thing", "schedule": map[string]any{"kind": "every", "everyMs": 3600000}, }) m.handleCreate(context.Background(), client, req) // No panic = successful create path hit if svc.addedJob == nil { t.Error("expected AddJob to be called") } if svc.addedJob != nil && svc.addedJob.Name != "my-daily-job" { t.Errorf("job name = %q, want %q", svc.addedJob.Name, "my-daily-job") } } // Regression (B24:2794): when the store's AddJob returns (nil, nil) — the // insert succeeded but the tenant-scoped readback failed — handleCreate must // NOT dereference the nil job (previously panicked at job.ID). It must return // an error response and never call UpdateJob with a nil job. func TestCronCreate_NilJobFromStore_NoPanic_ReturnsError(t *testing.T) { svc := newStubCronStore() svc.addNilJob = true m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ "name": "nil-job", "message": "do the thing", "schedule": map[string]any{"kind": "every", "everyMs": 60000}, }) m.handleCreate(context.Background(), client, req) // No panic = nil-guard hit before job.ID dereference. if svc.updateCnt != 0 { t.Fatalf("UpdateJob called %d times after nil job, want 0", svc.updateCnt) } } // ---- Tests: handleDelete ---- func TestCronDelete_MissingJobID_ReturnsInvalidRequest(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{}) m.handleDelete(context.Background(), client, req) // No panic = jobId-required error path hit } func TestCronDelete_UserOwnsJob_DeletesJob(t *testing.T) { svc := newStubCronStore() // nullClient().UserID() returns "" — match the job's userID to that so ownership check passes. svc.addJob("del-job", "to-delete", "") m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{"jobId": "del-job"}) m.handleDelete(context.Background(), client, req) if _, exists := svc.jobs["del-job"]; exists { t.Error("expected job to be deleted from store") } } func TestCronDelete_NonExistentJob_NonAdminPath_ReturnsUnauthorized(t *testing.T) { svc := newStubCronStore() // No job with that ID exists — non-admin client will get unauthorized m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{"jobId": "ghost-job"}) m.handleDelete(context.Background(), client, req) // No panic = unauthorized path hit } // ---- Tests: handleToggle ---- func TestCronToggle_MissingJobID_ReturnsInvalidRequest(t *testing.T) { svc := newStubCronStore() m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"enabled": true}) m.handleToggle(context.Background(), client, req) // No panic = jobId-required error path hit } func TestCronToggle_BlocksCredentialBoundEnableByDifferentUser(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ ID: "job-1", UserID: "", Payload: store.CronPayload{ CredentialUserID: "tenant-user-a", }, } m := buildCronMethods(t, svc) client := nullClient() ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": true}) m.handleToggle(ctx, client, req) if svc.enableCnt != 0 { t.Fatalf("EnableJob called %d times, want 0", svc.enableCnt) } } func TestCronToggle_AllowsCredentialBoundDisableByDifferentUser(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ ID: "job-1", UserID: "", Payload: store.CronPayload{ CredentialUserID: "tenant-user-a", }, } m := buildCronMethods(t, svc) client := nullClient() ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": false}) m.handleToggle(ctx, client, req) if svc.enableCnt != 1 { t.Fatalf("EnableJob called %d times, want 1", svc.enableCnt) } } func TestCronUpdate_BlocksCredentialBoundJobByDifferentUser(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ ID: "job-1", UserID: "", Payload: store.CronPayload{ CredentialUserID: "tenant-user-a", }, } m := buildCronMethods(t, svc) client := nullClient() ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{ "jobId": "job-1", "patch": map[string]any{"message": "run gh issue list"}, }) m.handleUpdate(ctx, client, req) if svc.updateCnt != 0 { t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt) } } func TestCronRun_BlocksCredentialBoundJobByDifferentUser(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ ID: "job-1", UserID: "", Payload: store.CronPayload{ CredentialUserID: "tenant-user-a", }, } m := buildCronMethods(t, svc) client := nullClient() ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") req := cronReqFrame(t, protocol.MethodCronRun, map[string]any{"jobId": "job-1", "mode": "force"}) m.handleRun(ctx, client, req) if svc.runCnt != 0 { t.Fatalf("RunJob called %d times, want 0", svc.runCnt) } } // ---- Tests: handleUpdate command gate ---- // A normal job must not be mutable into a command job when the gateway has // command cron disabled. func TestCronUpdate_BlocksCommandWhenDisabled(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""} m := buildCronMethods(t, svc) // cfg.Cron.CommandEnabled defaults to false client := nullClient() req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{ "jobId": "job-1", "patch": map[string]any{"command": map[string]any{"argv": []any{"echo", "hi"}}}, }) m.handleUpdate(context.Background(), client, req) if svc.updateCnt != 0 { t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt) } } // Update must validate the command spec; an empty argv must be rejected even // when command cron is enabled. func TestCronUpdate_RejectsInvalidCommandSpec(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""} cfg := &config.Config{} cfg.Cron.CommandEnabled = true m := NewCronMethods(svc, &stubEventPub{}, cfg) client := nullClient() req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{ "jobId": "job-1", "patch": map[string]any{"command": map[string]any{"argv": []any{}}}, // empty argv → invalid }) m.handleUpdate(context.Background(), client, req) if svc.updateCnt != 0 { t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt) } } // A valid command payload on update is accepted when command cron is enabled. func TestCronUpdate_EnabledValidCommand_Updates(t *testing.T) { svc := newStubCronStore() svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""} cfg := &config.Config{} cfg.Cron.CommandEnabled = true m := NewCronMethods(svc, &stubEventPub{}, cfg) client := nullClient() req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{ "jobId": "job-1", "patch": map[string]any{"command": map[string]any{"argv": []any{"df", "-h"}}}, }) m.handleUpdate(context.Background(), client, req) if svc.updateCnt != 1 { t.Fatalf("UpdateJob called %d times, want 1", svc.updateCnt) } }