package handlers import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "testing" "github.com/freeflowuniverse/heroagent/pkg/herojobs" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) // MockRedisClient is a mock implementation of the RedisClientInterface type MockRedisClient struct { mock.Mock } // StoreJob mocks the StoreJob method func (m *MockRedisClient) StoreJob(job *herojobs.Job) error { args := m.Called(job) return args.Error(0) } // EnqueueJob mocks the EnqueueJob method func (m *MockRedisClient) EnqueueJob(job *herojobs.Job) error { args := m.Called(job) return args.Error(0) } // GetJob mocks the GetJob method func (m *MockRedisClient) GetJob(jobID interface{}) (*herojobs.Job, error) { // jobID is interface{} args := m.Called(jobID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*herojobs.Job), args.Error(1) } // ListJobs mocks the ListJobs method func (m *MockRedisClient) ListJobs(circleID, topic string) ([]uint32, error) { // Returns []uint32 args := m.Called(circleID, topic) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]uint32), args.Error(1) } // QueueSize mocks the QueueSize method func (m *MockRedisClient) QueueSize(circleID, topic string) (int64, error) { args := m.Called(circleID, topic) // Ensure Get(0) is not nil before type assertion if it can be nil in some error cases if args.Get(0) == nil && args.Error(1) != nil { // If error is set, result might be nil return 0, args.Error(1) } return args.Get(0).(int64), args.Error(1) } // QueueEmpty mocks the QueueEmpty method func (m *MockRedisClient) QueueEmpty(circleID, topic string) error { args := m.Called(circleID, topic) return args.Error(0) } // setupTest initializes a test environment with a mock client func setupTest() (*JobHandler, *MockRedisClient, *fiber.App) { mockClient := new(MockRedisClient) handler := &JobHandler{ client: mockClient, // Assign the mock that implements RedisClientInterface } app := fiber.New() // Register routes (ensure these match the actual routes in job_handlers.go) apiJobs := app.Group("/api/jobs") // Assuming routes are under /api/jobs apiJobs.Post("/submit", handler.submitJob) apiJobs.Get("/get/:id", handler.getJob) // :id as per job_handlers.go apiJobs.Delete("/delete/:id", handler.deleteJob) // :id as per job_handlers.go apiJobs.Get("/list", handler.listJobs) apiJobs.Get("/queue/size", handler.queueSize) apiJobs.Post("/queue/empty", handler.queueEmpty) apiJobs.Get("/queue/get", handler.queueGet) apiJobs.Post("/create", handler.createJob) // If admin routes are also tested, they need to be registered here too // adminJobs := app.Group("/admin/jobs") // jobRoutes(adminJobs) // if using the same handler instance return handler, mockClient, app } // createTestRequest creates a test request with the given method, path, and body func createTestRequest(method, path string, body io.Reader) (*http.Request, error) { req := httptest.NewRequest(method, path, body) req.Header.Set("Content-Type", "application/json") return req, nil } // TestQueueEmpty tests the queueEmpty handler func TestQueueEmpty(t *testing.T) { // Test cases tests := []struct { name string circleID string topic string emptyError error expectedStatus int expectedBody string }{ { name: "Success", circleID: "test-circle", topic: "test-topic", emptyError: nil, expectedStatus: fiber.StatusOK, expectedBody: `{"status":"success","message":"Queue for circle test-circle and topic test-topic emptied successfully"}`, }, // Removed "Connection Error" test case as Connect is no longer directly called per op { name: "Empty Error", circleID: "test-circle", topic: "test-topic", emptyError: errors.New("empty error"), expectedStatus: fiber.StatusInternalServerError, expectedBody: `{"error":"Failed to empty queue: empty error"}`, }, { name: "Empty Circle ID", circleID: "", topic: "test-topic", emptyError: nil, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Circle ID is required"}`, }, { name: "Empty Topic", circleID: "test-circle", topic: "", emptyError: nil, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Topic is required"}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Create a new mock client for each test and setup app _, mockClient, app := setupTest() // Use setupTest to get handler with mock // Setup mock expectations if tc.circleID != "" && tc.topic != "" { // Only expect call if params are valid mockClient.On("QueueEmpty", tc.circleID, tc.topic).Return(tc.emptyError) } // Create request body reqBody := map[string]string{ "circleid": tc.circleID, "topic": tc.topic, } reqBodyBytes, err := json.Marshal(reqBody) assert.NoError(t, err) // Create test request req, err := createTestRequest(http.MethodPost, "/api/jobs/queue/empty", bytes.NewReader(reqBodyBytes)) assert.NoError(t, err) req.Header.Set("Content-Type", "application/json") // Perform the request resp, err := app.Test(req) assert.NoError(t, err) // Check status code assert.Equal(t, tc.expectedStatus, resp.StatusCode) // Check response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.JSONEq(t, tc.expectedBody, string(body)) // Verify that all expectations were met mockClient.AssertExpectations(t) }) } } // TestQueueGet tests the queueGet handler func TestQueueGet(t *testing.T) { // Create a test job testJob := herojobs.NewJob() testJob.JobID = 10 // This will be a number in JSON testJob.CircleID = "test-circle" testJob.Topic = "test-topic" testJob.Params = "some script" testJob.ParamsType = herojobs.ParamsTypeHeroScript testJob.Status = herojobs.JobStatusNew // Test cases tests := []struct { name string circleID string topic string listJobsError error listJobsResp []uint32 getJobError error getJobResp *herojobs.Job expectedStatus int expectedBody string // This will need to be updated to match the actual job structure }{ { name: "Success", circleID: "test-circle", topic: "test-topic", listJobsError: nil, listJobsResp: []uint32{10}, getJobError: nil, getJobResp: testJob, expectedStatus: fiber.StatusOK, expectedBody: `{"jobid":10,"circleid":"test-circle","topic":"test-topic","params":"some script","paramstype":"HeroScript","status":"new","sessionkey":"","result":"","error":"","timeout":60,"log":false,"timescheduled":0,"timestart":0,"timeend":0}`, }, // Removed "Connection Error" { name: "ListJobs Error", circleID: "test-circle", topic: "test-topic", listJobsError: errors.New("list error"), listJobsResp: nil, getJobError: nil, // Not reached getJobResp: nil, // Not reached expectedStatus: fiber.StatusInternalServerError, expectedBody: `{"error":"Failed to list jobs in queue: list error"}`, }, { name: "GetJob Error after ListJobs success", circleID: "test-circle", topic: "test-topic", listJobsError: nil, listJobsResp: []uint32{10}, getJobError: errors.New("get error"), getJobResp: nil, expectedStatus: fiber.StatusInternalServerError, // Or based on how GetJob error is handled (e.g. fallback to OurDB) // The error message might be more complex if OurDB load is also attempted and fails expectedBody: `{"error":"Failed to get job 10 from queue (Redis err: get error / OurDB err: record not found)"}`, // Adjusted expected error }, { name: "Queue Empty (ListJobs returns empty)", circleID: "test-circle", topic: "test-topic", listJobsError: nil, listJobsResp: []uint32{}, // Empty list getJobError: nil, getJobResp: nil, expectedStatus: fiber.StatusNotFound, expectedBody: `{"error":"Queue is empty or no jobs found"}`, }, { name: "Empty Circle ID", circleID: "", topic: "test-topic", listJobsError: nil, listJobsResp: nil, getJobError: nil, getJobResp: nil, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Circle ID is required"}`, }, { name: "Empty Topic", circleID: "test-circle", topic: "", listJobsError: nil, listJobsResp: nil, getJobError: nil, getJobResp: nil, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Topic is required"}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Create a new mock client for each test and setup app _, mockClient, app := setupTest() // Setup mock expectations if tc.circleID != "" && tc.topic != "" { mockClient.On("ListJobs", tc.circleID, tc.topic).Return(tc.listJobsResp, tc.listJobsError) if tc.listJobsError == nil && len(tc.listJobsResp) > 0 { // Expect GetJob to be called with the first ID from listJobsResp // The handler passes uint32 to client.GetJob, which matches interface{} mockClient.On("GetJob", tc.listJobsResp[0]).Return(tc.getJobResp, tc.getJobError).Maybe() // If GetJob from Redis fails, a Load from OurDB is attempted. // We are not mocking job.Load() here as it's on the job object. // The error message in the test case reflects this potential dual failure. } } // Create test request path := fmt.Sprintf("/api/jobs/queue/get?circleid=%s&topic=%s", tc.circleID, tc.topic) req, err := createTestRequest(http.MethodGet, path, nil) assert.NoError(t, err) // Perform the request resp, err := app.Test(req) assert.NoError(t, err) // Check status code assert.Equal(t, tc.expectedStatus, resp.StatusCode) // Check response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.JSONEq(t, tc.expectedBody, string(body)) // Verify that all expectations were met mockClient.AssertExpectations(t) }) } } // TestCreateJob tests the createJob handler func TestCreateJob(t *testing.T) { // Test cases createdJob := herojobs.NewJob() createdJob.JobID = 10 // Assuming Save will populate this; for mock, we set it createdJob.CircleID = "test-circle" createdJob.Topic = "test-topic" createdJob.SessionKey = "test-key" createdJob.Params = "test-params" createdJob.ParamsType = herojobs.ParamsTypeHeroScript // Match "HeroScript" string createdJob.Status = herojobs.JobStatusNew // Default status after NewJob and Save tests := []struct { name string reqBody map[string]interface{} // Use map for flexibility storeError error enqueueError error expectedStatus int expectedBody string // Will be the createdJob marshaled }{ { name: "Success", reqBody: map[string]interface{}{ "circleid": "test-circle", "topic": "test-topic", "sessionkey": "test-key", "params": "test-params", "paramstype": "HeroScript", "timeout": 30, "log": true, }, storeError: nil, enqueueError: nil, expectedStatus: fiber.StatusOK, // Expected body should match the 'createdJob' structure after Save, Store, Enqueue // JobID is assigned by Save(), which we are not mocking here. // The handler returns the job object. // For the test, we assume Save() works and populates JobID if it were a real DB. // The mock will return the job passed to it. expectedBody: `{"jobid":0,"circleid":"test-circle","topic":"test-topic","params":"test-params","paramstype":"HeroScript","status":"new","sessionkey":"test-key","result":"","error":"","timeout":30,"log":true,"timescheduled":0,"timestart":0,"timeend":0}`, }, // Removed "Connection Error" { name: "StoreJob Error", reqBody: map[string]interface{}{ "circleid": "test-circle", "topic": "test-topic", "params": "p", "paramstype": "HeroScript", }, storeError: errors.New("store error"), enqueueError: nil, expectedStatus: fiber.StatusInternalServerError, expectedBody: `{"error":"Failed to store new job in Redis: store error"}`, }, { name: "EnqueueJob Error", reqBody: map[string]interface{}{ "circleid": "test-circle", "topic": "test-topic", "params": "p", "paramstype": "HeroScript", }, storeError: nil, enqueueError: errors.New("enqueue error"), expectedStatus: fiber.StatusInternalServerError, expectedBody: `{"error":"Failed to enqueue new job in Redis: enqueue error"}`, }, { name: "Empty Circle ID", reqBody: map[string]interface{}{ "circleid": "", "topic": "test-topic", "params": "p", "paramstype": "HeroScript", }, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Circle ID is required"}`, }, { name: "Empty Topic", reqBody: map[string]interface{}{ "circleid": "c", "topic": "", "params": "p", "paramstype": "HeroScript", }, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Topic is required"}`, }, { name: "Empty Params", reqBody: map[string]interface{}{ "circleid": "c", "topic": "t", "params": "", "paramstype": "HeroScript", }, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Params are required"}`, }, { name: "Empty ParamsType", reqBody: map[string]interface{}{ "circleid": "c", "topic": "t", "params": "p", "paramstype": "", }, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"ParamsType is required"}`, }, { name: "Invalid ParamsType", reqBody: map[string]interface{}{ "circleid": "c", "topic": "t", "params": "p", "paramstype": "InvalidType", }, expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Invalid ParamsType: InvalidType"}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, mockClient, app := setupTest() // Setup mock expectations // job.Save() is called before client interactions. We assume it succeeds for these tests. // The mock will be called with a job object. We use mock.AnythingOfType for the job // because the JobID might be populated by Save() in a real scenario, making exact match hard. if tc.reqBody["circleid"] != "" && tc.reqBody["topic"] != "" && tc.reqBody["params"] != "" && tc.reqBody["paramstype"] != "" && herojobs.ParamsType(tc.reqBody["paramstype"].(string)) != "" { // Basic validation check // We expect StoreJob to be called with a *herojobs.Job. // The actual JobID is set by job.Save() which is not mocked here. // So we use mock.AnythingOfType to match the argument. mockClient.On("StoreJob", mock.AnythingOfType("*herojobs.Job")).Return(tc.storeError).Once().Maybe() if tc.storeError == nil { mockClient.On("EnqueueJob", mock.AnythingOfType("*herojobs.Job")).Return(tc.enqueueError).Once().Maybe() } } reqBodyBytes, err := json.Marshal(tc.reqBody) assert.NoError(t, err) req, err := createTestRequest(http.MethodPost, "/api/jobs/create", bytes.NewReader(reqBodyBytes)) // Use /api/jobs/create assert.NoError(t, err) // Content-Type is set by createTestRequest // Perform the request resp, err := app.Test(req) assert.NoError(t, err) // Check status code assert.Equal(t, tc.expectedStatus, resp.StatusCode) // Check response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.JSONEq(t, tc.expectedBody, string(body)) // Verify that all expectations were met mockClient.AssertExpectations(t) }) } } // TestSubmitJob tests the submitJob handler func TestSubmitJob(t *testing.T) { // Test cases submittedJob := herojobs.NewJob() submittedJob.JobID = 10 // Assume Save populates this submittedJob.CircleID = "test-circle" submittedJob.Topic = "test-topic" submittedJob.Params = "submitted params" submittedJob.ParamsType = herojobs.ParamsTypeHeroScript submittedJob.Status = herojobs.JobStatusNew tests := []struct { name string jobToSubmit *herojobs.Job // This is the job in the request body storeError error enqueueError error expectedStatus int expectedBody string // Will be the jobToSubmit marshaled (after potential Save) }{ { name: "Success", jobToSubmit: submittedJob, storeError: nil, enqueueError: nil, expectedStatus: fiber.StatusOK, // The handler returns the job object from the request after Save(), Store(), Enqueue() // For the mock, the JobID from jobToSubmit will be used. expectedBody: `{"jobid":10,"circleid":"test-circle","topic":"test-topic","params":"submitted params","paramstype":"HeroScript","status":"new","sessionkey":"","result":"","error":"","timeout":60,"log":false,"timescheduled":0,"timestart":0,"timeend":0}`, }, // Removed "Connection Error" { name: "StoreJob Error", jobToSubmit: submittedJob, storeError: errors.New("store error"), enqueueError: nil, expectedStatus: fiber.StatusInternalServerError, expectedBody: `{"error":"Failed to store job in Redis: store error"}`, }, { name: "EnqueueJob Error", jobToSubmit: submittedJob, storeError: nil, enqueueError: errors.New("enqueue error"), expectedStatus: fiber.StatusInternalServerError, expectedBody: `{"error":"Failed to enqueue job: enqueue error"}`, }, { name: "Empty Job in request (parsing error)", jobToSubmit: nil, // Simulates empty or malformed request body expectedStatus: fiber.StatusBadRequest, expectedBody: `{"error":"Failed to parse job data: unexpected end of JSON input"}`, // Or similar based on actual parsing }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, mockClient, app := setupTest() // Setup mock expectations // job.Save() is called before client interactions. if tc.jobToSubmit != nil { // If job is parsable from request // We expect StoreJob to be called with the job from the request. // The JobID might be modified by Save() in a real scenario. mockClient.On("StoreJob", tc.jobToSubmit).Return(tc.storeError).Once().Maybe() if tc.storeError == nil { mockClient.On("EnqueueJob", tc.jobToSubmit).Return(tc.enqueueError).Once().Maybe() } } var reqBodyBytes []byte var err error if tc.jobToSubmit != nil { reqBodyBytes, err = json.Marshal(tc.jobToSubmit) assert.NoError(t, err) } req, err := createTestRequest(http.MethodPost, "/api/jobs/submit", bytes.NewReader(reqBodyBytes)) // Use /api/jobs/submit assert.NoError(t, err) // Content-Type is set by createTestRequest // Perform the request resp, err := app.Test(req) assert.NoError(t, err) // Check status code assert.Equal(t, tc.expectedStatus, resp.StatusCode) // Check response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.JSONEq(t, tc.expectedBody, string(body)) // Verify that all expectations were met mockClient.AssertExpectations(t) }) } }