package api_test // SPDX-License-Identifier: GPL-3.0-or-later import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "projects.blender.org/studio/flamenco/pkg/api" "projects.blender.org/studio/flamenco/tests/helpers" ) // APITestSuite provides comprehensive API endpoint testing type APITestSuite struct { suite.Suite server *httptest.Server client *http.Client testHelper *helpers.TestHelper } // SetupSuite initializes the test environment func (suite *APITestSuite) SetupSuite() { suite.testHelper = helpers.NewTestHelper(suite.T()) // Start test server with Flamenco Manager suite.server = suite.testHelper.StartTestServer() suite.client = &http.Client{ Timeout: 30 * time.Second, } } // TearDownSuite cleans up the test environment func (suite *APITestSuite) TearDownSuite() { if suite.server != nil { suite.server.Close() } if suite.testHelper != nil { suite.testHelper.Cleanup() } } // TestMetaEndpoints tests version and configuration endpoints func (suite *APITestSuite) TestMetaEndpoints() { suite.Run("GetVersion", func() { resp, err := suite.makeRequest("GET", "/api/v3/version", nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var version api.FlamencoVersion err = json.NewDecoder(resp.Body).Decode(&version) require.NoError(suite.T(), err) assert.NotEmpty(suite.T(), version.Version) assert.Equal(suite.T(), "flamenco", version.Name) }) suite.Run("GetConfiguration", func() { resp, err := suite.makeRequest("GET", "/api/v3/configuration", nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var config api.ManagerConfiguration err = json.NewDecoder(resp.Body).Decode(&config) require.NoError(suite.T(), err) assert.NotNil(suite.T(), config.Variables) }) } // TestJobManagement tests job CRUD operations func (suite *APITestSuite) TestJobManagement() { suite.Run("SubmitJob", func() { job := suite.createTestJob() jobData, err := json.Marshal(job) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/jobs", bytes.NewReader(jobData)) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var submittedJob api.Job err = json.NewDecoder(resp.Body).Decode(&submittedJob) require.NoError(suite.T(), err) assert.Equal(suite.T(), job.Name, submittedJob.Name) assert.Equal(suite.T(), job.Type, submittedJob.Type) assert.NotEmpty(suite.T(), submittedJob.Id) }) suite.Run("QueryJobs", func() { resp, err := suite.makeRequest("GET", "/api/v3/jobs", nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var jobs api.JobsQuery err = json.NewDecoder(resp.Body).Decode(&jobs) require.NoError(suite.T(), err) assert.NotNil(suite.T(), jobs.Jobs) }) suite.Run("GetJob", func() { // Submit a job first job := suite.createTestJob() submittedJob := suite.submitJob(job) resp, err := suite.makeRequest("GET", fmt.Sprintf("/api/v3/jobs/%s", submittedJob.Id), nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var retrievedJob api.Job err = json.NewDecoder(resp.Body).Decode(&retrievedJob) require.NoError(suite.T(), err) assert.Equal(suite.T(), submittedJob.Id, retrievedJob.Id) assert.Equal(suite.T(), job.Name, retrievedJob.Name) }) suite.Run("DeleteJob", func() { // Submit a job first job := suite.createTestJob() submittedJob := suite.submitJob(job) resp, err := suite.makeRequest("DELETE", fmt.Sprintf("/api/v3/jobs/%s", submittedJob.Id), nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusNoContent, resp.StatusCode) // Verify job is deleted resp, err = suite.makeRequest("GET", fmt.Sprintf("/api/v3/jobs/%s", submittedJob.Id), nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusNotFound, resp.StatusCode) }) } // TestWorkerManagement tests worker registration and management func (suite *APITestSuite) TestWorkerManagement() { suite.Run("RegisterWorker", func() { worker := suite.createTestWorker() workerData, err := json.Marshal(worker) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/worker/register-worker", bytes.NewReader(workerData)) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var registeredWorker api.RegisteredWorker err = json.NewDecoder(resp.Body).Decode(®isteredWorker) require.NoError(suite.T(), err) assert.NotEmpty(suite.T(), registeredWorker.Uuid) assert.Equal(suite.T(), worker.Name, registeredWorker.Name) }) suite.Run("QueryWorkers", func() { resp, err := suite.makeRequest("GET", "/api/v3/workers", nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var workers api.WorkerList err = json.NewDecoder(resp.Body).Decode(&workers) require.NoError(suite.T(), err) assert.NotNil(suite.T(), workers.Workers) }) suite.Run("WorkerSignOn", func() { worker := suite.createTestWorker() registeredWorker := suite.registerWorker(worker) signOnInfo := api.WorkerSignOn{ Name: worker.Name, SoftwareVersion: "3.0.0", SupportedTaskTypes: []string{"blender", "ffmpeg"}, } signOnData, err := json.Marshal(signOnInfo) require.NoError(suite.T(), err) url := fmt.Sprintf("/api/v3/worker/%s/sign-on", registeredWorker.Uuid) resp, err := suite.makeRequest("POST", url, bytes.NewReader(signOnData)) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var signOnResponse api.WorkerStateChange err = json.NewDecoder(resp.Body).Decode(&signOnResponse) require.NoError(suite.T(), err) assert.Equal(suite.T(), api.WorkerStatusAwake, *signOnResponse.StatusRequested) }) } // TestTaskManagement tests task assignment and updates func (suite *APITestSuite) TestTaskManagement() { suite.Run("ScheduleTask", func() { // Setup: Create job and register worker job := suite.createTestJob() submittedJob := suite.submitJob(job) worker := suite.createTestWorker() registeredWorker := suite.registerWorker(worker) suite.signOnWorker(registeredWorker.Uuid, worker.Name) // Request task scheduling url := fmt.Sprintf("/api/v3/worker/%s/task", registeredWorker.Uuid) resp, err := suite.makeRequest("POST", url, nil) require.NoError(suite.T(), err) if resp.StatusCode == http.StatusOK { var assignedTask api.AssignedTask err = json.NewDecoder(resp.Body).Decode(&assignedTask) require.NoError(suite.T(), err) assert.NotEmpty(suite.T(), assignedTask.Uuid) assert.Equal(suite.T(), submittedJob.Id, assignedTask.JobId) } else { // No tasks available is also valid assert.Equal(suite.T(), http.StatusNoContent, resp.StatusCode) } }) } // TestErrorHandling tests various error scenarios func (suite *APITestSuite) TestErrorHandling() { suite.Run("NotFoundEndpoint", func() { resp, err := suite.makeRequest("GET", "/api/v3/nonexistent", nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusNotFound, resp.StatusCode) }) suite.Run("InvalidJobSubmission", func() { invalidJob := map[string]interface{}{ "name": "", // Empty name should be invalid "type": "nonexistent-type", } jobData, err := json.Marshal(invalidJob) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/jobs", bytes.NewReader(jobData)) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) }) suite.Run("InvalidWorkerRegistration", func() { invalidWorker := map[string]interface{}{ "name": "", // Empty name should be invalid } workerData, err := json.Marshal(invalidWorker) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/worker/register-worker", bytes.NewReader(workerData)) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) }) } // TestConcurrentRequests tests API behavior under concurrent load func (suite *APITestSuite) TestConcurrentRequests() { suite.Run("ConcurrentJobSubmission", func() { const numJobs = 10 results := make(chan error, numJobs) for i := 0; i < numJobs; i++ { go func(jobIndex int) { job := suite.createTestJob() job.Name = fmt.Sprintf("Concurrent Job %d", jobIndex) jobData, err := json.Marshal(job) if err != nil { results <- err return } resp, err := suite.makeRequest("POST", "/api/v3/jobs", bytes.NewReader(jobData)) if err != nil { results <- err return } resp.Body.Close() if resp.StatusCode != http.StatusOK { results <- fmt.Errorf("expected 200, got %d", resp.StatusCode) return } results <- nil }(i) } // Collect results for i := 0; i < numJobs; i++ { err := <-results assert.NoError(suite.T(), err) } }) } // Helper methods func (suite *APITestSuite) makeRequest(method, path string, body io.Reader) (*http.Response, error) { url := suite.server.URL + path req, err := http.NewRequestWithContext(context.Background(), method, url, body) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return suite.client.Do(req) } func (suite *APITestSuite) createTestJob() api.SubmittedJob { return api.SubmittedJob{ Name: "Test Render Job", Type: "simple-blender-render", Priority: 50, SubmitterPlatform: "linux", Settings: map[string]interface{}{ "filepath": "/shared-storage/projects/test.blend", "chunk_size": 10, "format": "PNG", "image_file_extension": ".png", "frames": "1-10", }, } } func (suite *APITestSuite) createTestWorker() api.WorkerRegistration { return api.WorkerRegistration{ Name: fmt.Sprintf("test-worker-%d", time.Now().UnixNano()), Address: "192.168.1.100", Platform: "linux", SoftwareVersion: "3.0.0", SupportedTaskTypes: []string{"blender", "ffmpeg"}, } } func (suite *APITestSuite) submitJob(job api.SubmittedJob) api.Job { jobData, err := json.Marshal(job) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/jobs", bytes.NewReader(jobData)) require.NoError(suite.T(), err) require.Equal(suite.T(), http.StatusOK, resp.StatusCode) var submittedJob api.Job err = json.NewDecoder(resp.Body).Decode(&submittedJob) require.NoError(suite.T(), err) resp.Body.Close() return submittedJob } func (suite *APITestSuite) registerWorker(worker api.WorkerRegistration) api.RegisteredWorker { workerData, err := json.Marshal(worker) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/worker/register-worker", bytes.NewReader(workerData)) require.NoError(suite.T(), err) require.Equal(suite.T(), http.StatusOK, resp.StatusCode) var registeredWorker api.RegisteredWorker err = json.NewDecoder(resp.Body).Decode(®isteredWorker) require.NoError(suite.T(), err) resp.Body.Close() return registeredWorker } func (suite *APITestSuite) signOnWorker(workerUUID, workerName string) { signOnInfo := api.WorkerSignOn{ Name: workerName, SoftwareVersion: "3.0.0", SupportedTaskTypes: []string{"blender", "ffmpeg"}, } signOnData, err := json.Marshal(signOnInfo) require.NoError(suite.T(), err) url := fmt.Sprintf("/api/v3/worker/%s/sign-on", workerUUID) resp, err := suite.makeRequest("POST", url, bytes.NewReader(signOnData)) require.NoError(suite.T(), err) require.Equal(suite.T(), http.StatusOK, resp.StatusCode) resp.Body.Close() } // TestAPIValidation tests OpenAPI schema validation func (suite *APITestSuite) TestAPIValidation() { suite.Run("ValidateResponseSchemas", func() { // Test version endpoint schema resp, err := suite.makeRequest("GET", "/api/v3/version", nil) require.NoError(suite.T(), err) assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) var version api.FlamencoVersion err = json.NewDecoder(resp.Body).Decode(&version) require.NoError(suite.T(), err) // Validate required fields assert.NotEmpty(suite.T(), version.Version) assert.NotEmpty(suite.T(), version.Name) assert.Contains(suite.T(), strings.ToLower(version.Name), "flamenco") resp.Body.Close() }) suite.Run("ValidateRequestSchemas", func() { // Test job submission with all required fields job := api.SubmittedJob{ Name: "Schema Test Job", Type: "simple-blender-render", Priority: 50, SubmitterPlatform: "linux", Settings: map[string]interface{}{ "filepath": "/test.blend", "frames": "1-10", }, } jobData, err := json.Marshal(job) require.NoError(suite.T(), err) resp, err := suite.makeRequest("POST", "/api/v3/jobs", bytes.NewReader(jobData)) require.NoError(suite.T(), err) // Should succeed with valid data if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) suite.T().Logf("Unexpected response: %s", string(body)) } resp.Body.Close() }) } // TestSuite runs all API tests func TestAPISuite(t *testing.T) { suite.Run(t, new(APITestSuite)) }