Files

355 lines
10 KiB
Go

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