diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml new file mode 100644 index 0000000..6b03349 --- /dev/null +++ b/.github/workflows/run-test.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Download dependencies + run: go mod download + + - name: Run tests with coverage + run: go test ./... -cover diff --git a/Makefile b/Makefile index 4e038f7..c09bb8c 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ build: go build test: - go test ./... + go test ./... -v ex: mkdir -p bin/ diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..b614334 --- /dev/null +++ b/client_test.go @@ -0,0 +1,479 @@ +package bsubio + +import ( + "bytes" + "context" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test constants +const ( + testHTTPTimeout = 10 * time.Second + testContextTimeout = 100 * time.Millisecond +) + +// TestNewBsubClient tests client initialization +func TestNewBsubClient(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + errContains string + }{ + { + name: "valid config with defaults", + config: Config{ + APIKey: "test-api-key", + }, + wantErr: false, + }, + { + name: "valid config with custom base URL", + config: Config{ + APIKey: "test-api-key", + BaseURL: "https://custom.bsub.io", + }, + wantErr: false, + }, + { + name: "valid config with custom HTTP client", + config: Config{ + APIKey: "test-api-key", + HTTPClient: &http.Client{Timeout: testHTTPTimeout}, + }, + wantErr: false, + }, + { + name: "missing API key", + config: Config{ + BaseURL: "https://app.bsub.io", + }, + wantErr: true, + errContains: "API key not found", + }, + { + name: "empty API key", + config: Config{ + APIKey: "", + }, + wantErr: true, + errContains: "API key not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewBsubClient(tt.config) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + assert.Nil(t, client) + } else { + require.NoError(t, err) + require.NotNil(t, client) + require.NotNil(t, client.ClientWithResponses) + } + }) + } +} + +// TestNewBsubClient_AuthInterceptor verifies that the auth interceptor adds Bearer token +func TestNewBsubClient_AuthInterceptor(t *testing.T) { + mockServer := NewMockServer() + defer mockServer.Close() + + apiKey := "test-api-key-123" + client, err := NewBsubClient(Config{ + APIKey: apiKey, + BaseURL: mockServer.URL, + }) + require.NoError(t, err) + + // Make a request + ctx := context.Background() + reqBody := CreateJobJSONRequestBody{Type: "test/linecount"} + resp, err := client.CreateJobWithResponse(ctx, reqBody) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, 201, resp.StatusCode()) +} + +// TestCreateAndSubmitJob tests the job creation and submission flow with passthrough +func TestCreateAndSubmitJob(t *testing.T) { + t.Run("successful job creation and submission with passthrough", func(t *testing.T) { + client, mockServer, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + data := bytes.NewReader([]byte("test data content")) + job, err := client.CreateAndSubmitJob(ctx, "test/linecount", data) + + require.NoError(t, err) + require.NotNil(t, job) + assert.NotNil(t, job.Id) + // Note: CreateAndSubmitJob returns job from create step, so status is still "created" + assert.Equal(t, JobStatusCreated, *job.Status) + + // Verify job was submitted and is now finished in mock server + if mockServer != nil { + storedJob := mockServer.GetJob(*job.Id) + require.NotNil(t, storedJob) + assert.Equal(t, "test/linecount", *storedJob.Type) + // The mock server updates the status to finished for passthrough jobs + assert.Equal(t, JobStatusFinished, *storedJob.Status) + } + }) + + t.Run("successful job with line_counter", func(t *testing.T) { + client, mockServer, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + data := bytes.NewReader([]byte("line1\nline2\nline3\nline4\nline5")) + job, err := client.CreateAndSubmitJob(ctx, "test/linecount", data) + + require.NoError(t, err) + require.NotNil(t, job) + // CreateAndSubmitJob returns job from create step + assert.Equal(t, JobStatusCreated, *job.Status) + + // Verify in mock server that job was actually submitted + if mockServer != nil { + storedJob := mockServer.GetJob(*job.Id) + require.NotNil(t, storedJob) + assert.Equal(t, JobStatusFinished, *storedJob.Status) + } + }) +} + +// TestWaitForJob tests the polling mechanism +func TestWaitForJob(t *testing.T) { + mode := GetTestMode() + if mode == TestModeProduction { + t.Skip("Skipping WaitForJob in production mode - requires long-running job") + } + + t.Run("job finishes immediately with passthrough", func(t *testing.T) { + client, mockServer, cleanup := SetupTestClient(t) + defer cleanup() + + // Create and submit a passthrough job first + ctx := context.Background() + data := bytes.NewReader([]byte("test data")) + job, err := client.CreateAndSubmitJob(ctx, "test/linecount", data) + require.NoError(t, err) + require.NotNil(t, job) + + // Wait for job (should be already finished for passthrough) + finalJob, err := client.WaitForJob(ctx, *job.Id) + require.NoError(t, err) + require.NotNil(t, finalJob) + assert.Equal(t, JobStatusFinished, *finalJob.Status) + + if mockServer != nil { + storedJob := mockServer.GetJob(*job.Id) + require.NotNil(t, storedJob) + } + }) + + t.Run("context cancellation", func(t *testing.T) { + client, mockServer, cleanup := SetupTestClient(t) + defer cleanup() + + if mockServer == nil { + t.Skip("Context cancellation test only supported in mock mode") + } + + // Create a job but don't submit it (so it stays in created state) + ctx := context.Background() + reqBody := CreateJobJSONRequestBody{Type: "test/linecount"} + resp, err := client.CreateJobWithResponse(ctx, reqBody) + require.NoError(t, err) + require.NotNil(t, resp.JSON201) + + jobID := *resp.JSON201.Data.Id + + // Manually set the job to processing state (simulating long-running job) + job := mockServer.GetJob(jobID) + status := JobStatusProcessing + job.Status = &status + + // Create context with short timeout + ctxWithTimeout, cancel := context.WithTimeout(context.Background(), testContextTimeout) + defer cancel() + + finalJob, err := client.WaitForJob(ctxWithTimeout, jobID) + + require.Error(t, err) + assert.Nil(t, finalJob) + assert.Contains(t, err.Error(), "context") + }) +} + +// TestGetJobResult tests result retrieval +func TestGetJobResult(t *testing.T) { + t.Run("successful result retrieval with passthrough", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + data := bytes.NewReader([]byte("test input data")) + job, err := client.CreateAndSubmitJob(ctx, "test/linecount", data) + require.NoError(t, err) + require.NotNil(t, job) + + // For passthrough, job should be finished immediately + result, err := client.GetJobResult(ctx, *job.Id) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, *job.Id, *result.Job.Id) + assert.NotEmpty(t, result.Output) + }) + + t.Run("successful result with line_counter", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + data := bytes.NewReader([]byte("line1\nline2\nline3\nline4\nline5")) + job, err := client.CreateAndSubmitJob(ctx, "test/linecount", data) + require.NoError(t, err) + + result, err := client.GetJobResult(ctx, *job.Id) + require.NoError(t, err) + require.NotNil(t, result) + assert.NotEmpty(t, result.Output) + }) +} + +// TestProcess tests end-to-end processing with reader +func TestProcess(t *testing.T) { + t.Run("successful processing with passthrough", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + inputData := []byte("Test input data for passthrough") + data := bytes.NewReader(inputData) + result, err := client.Process(ctx, "test/linecount", data) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JobStatusFinished, *result.Job.Status) + assert.NotEmpty(t, result.Output) + }) + + t.Run("successful processing with line_counter", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + inputData := []byte("line1\nline2\nline3") + data := bytes.NewReader(inputData) + result, err := client.Process(ctx, "test/linecount", data) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JobStatusFinished, *result.Job.Status) + assert.NotEmpty(t, result.Output) + }) +} + +// TestCreateAndSubmitJobFromFile tests file-based job submission +func TestCreateAndSubmitJobFromFile(t *testing.T) { + t.Run("successful file processing with passthrough", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + // Create temporary test file + tmpDir := t.TempDir() + testFilePath := filepath.Join(tmpDir, "test.txt") + testContent := []byte("File content for passthrough test") + err := os.WriteFile(testFilePath, testContent, 0644) + require.NoError(t, err) + + ctx := context.Background() + job, err := client.CreateAndSubmitJobFromFile(ctx, "test/linecount", testFilePath) + + require.NoError(t, err) + require.NotNil(t, job) + assert.NotNil(t, job.Id) + }) + + t.Run("file not found", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + job, err := client.CreateAndSubmitJobFromFile(ctx, "test/linecount", "/nonexistent/file.txt") + + require.Error(t, err) + assert.Nil(t, job) + assert.Contains(t, err.Error(), "failed to open file") + }) +} + +// TestProcessFile tests end-to-end file processing +func TestProcessFile(t *testing.T) { + t.Run("successful file processing end-to-end with passthrough", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + // Create temporary test file + tmpDir := t.TempDir() + testFilePath := filepath.Join(tmpDir, "test.txt") + testContent := []byte("File content for end-to-end test") + err := os.WriteFile(testFilePath, testContent, 0644) + require.NoError(t, err) + + ctx := context.Background() + result, err := client.ProcessFile(ctx, "test/linecount", testFilePath) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JobStatusFinished, *result.Job.Status) + assert.NotEmpty(t, result.Output) + }) + + t.Run("successful file processing with line_counter", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + tmpDir := t.TempDir() + testFilePath := filepath.Join(tmpDir, "lines.txt") + testContent := []byte("line1\nline2\nline3\nline4\nline5\nline6\nline7") + err := os.WriteFile(testFilePath, testContent, 0644) + require.NoError(t, err) + + ctx := context.Background() + result, err := client.ProcessFile(ctx, "test/linecount", testFilePath) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JobStatusFinished, *result.Job.Status) + assert.NotEmpty(t, result.Output) + }) +} + +// TestJobStatus tests the job status enum +func TestJobStatus(t *testing.T) { + statuses := []JobStatus{ + JobStatusCreated, + JobStatusLoaded, + JobStatusPending, + JobStatusClaimed, + JobStatusPreparing, + JobStatusProcessing, + JobStatusFinished, + JobStatusFailed, + } + + for _, status := range statuses { + assert.NotEmpty(t, status, "Status should not be empty") + } +} + +// TestJobIsTerminal tests terminal state detection +func TestJobIsTerminal(t *testing.T) { + tests := []struct { + status JobStatus + isTerminal bool + }{ + {JobStatusCreated, false}, + {JobStatusLoaded, false}, + {JobStatusPending, false}, + {JobStatusClaimed, false}, + {JobStatusPreparing, false}, + {JobStatusProcessing, false}, + {JobStatusFinished, true}, + {JobStatusFailed, true}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + isTerminal := tt.status == JobStatusFinished || tt.status == JobStatusFailed + assert.Equal(t, tt.isTerminal, isTerminal) + }) + } +} + +// BenchmarkCreateAndSubmitJob benchmarks the job creation flow +func BenchmarkCreateAndSubmitJob(b *testing.B) { + mockServer := NewMockServer() + defer mockServer.Close() + + client, err := NewBsubClient(Config{ + APIKey: "test-key", + BaseURL: mockServer.URL, + }) + if err != nil { + b.Fatal(err) + } + + ctx := context.Background() + data := []byte("test data content") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := bytes.NewReader(data) + _, err := client.CreateAndSubmitJob(ctx, "test/linecount", reader) + if err != nil { + b.Fatal(err) + } + } +} + +// TestIntegration_RealJobTypes tests with actual job types that exist in production +// Run with BSUB_TEST_MODE=production to test against real server +func TestIntegration_RealJobTypes(t *testing.T) { + mode := GetTestMode() + if mode != TestModeProduction { + t.Log("Running in mock mode. Set BSUB_TEST_MODE=production to test against real server") + } + + t.Run("test/linecount job - single line", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + input := []byte("Hello from test/linecount!") + result, err := client.Process(ctx, "test/linecount", bytes.NewReader(input)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JobStatusFinished, *result.Job.Status) + assert.NotEmpty(t, result.Output) + + t.Logf("Job ID: %s", result.Job.Id.String()) + t.Logf("Output: %s", string(result.Output)) + }) + + t.Run("test/linecount job - multiple lines", func(t *testing.T) { + client, _, cleanup := SetupTestClient(t) + defer cleanup() + + ctx := context.Background() + input := []byte("line 1\nline 2\nline 3\nline 4\nline 5") + result, err := client.Process(ctx, "test/linecount", bytes.NewReader(input)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JobStatusFinished, *result.Job.Status) + assert.NotEmpty(t, result.Output) + + t.Logf("Job ID: %s", result.Job.Id.String()) + t.Logf("Line count output: %s", string(result.Output)) + }) +} diff --git a/examples/batch/main.go b/examples/batch/main.go index 832bc3d..24e616c 100644 --- a/examples/batch/main.go +++ b/examples/batch/main.go @@ -113,7 +113,7 @@ func processFile(ctx context.Context, client *bsubio.BsubClient, jobType, filePa return result } - result.jobID = *jobResult.Job.Id + result.jobID = jobResult.Job.Id.String() result.output = jobResult.Output result.outputSize = len(jobResult.Output) diff --git a/examples/comprehensive/main.go b/examples/comprehensive/main.go index 3677ad5..dbe7fc0 100644 --- a/examples/comprehensive/main.go +++ b/examples/comprehensive/main.go @@ -68,7 +68,7 @@ func main() { // Example 3: Process a file (if provided as argument) if len(os.Args) > 1 { filePath := os.Args[1] - jobType := "passthrough" // Simple passthrough for demo + jobType := "test/linecount" // Simple test job for demo if len(os.Args) > 2 { jobType = os.Args[2] } diff --git a/go.mod b/go.mod index 0509d20..58dfe8e 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,18 @@ go 1.25.0 require github.com/oapi-codegen/runtime v1.1.2 // Will be updated by go mod tidy +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.10.0 +) + require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/getkin/kin-openapi v0.132.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -18,6 +23,7 @@ require ( github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect diff --git a/testhelpers_test.go b/testhelpers_test.go new file mode 100644 index 0000000..25eee3a --- /dev/null +++ b/testhelpers_test.go @@ -0,0 +1,102 @@ +package bsubio + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestMode determines whether tests run against mock or production server +type TestMode string + +const ( + TestModeMock TestMode = "mock" + TestModeProduction TestMode = "production" +) + +// BsubConfig represents the structure of ~/.config/bsubio/config.json +type BsubConfig struct { + APIKey string `json:"api_key"` + BaseURL string `json:"base_url"` +} + +// GetTestMode returns the test mode from environment variable +// Set BSUB_TEST_MODE=production to test against production server +// Default is mock mode +func GetTestMode() TestMode { + mode := os.Getenv("BSUB_TEST_MODE") + if mode == "production" { + return TestModeProduction + } + return TestModeMock +} + +// LoadBsubConfig loads configuration from ~/.config/bsubio/config.json +func LoadBsubConfig() (*BsubConfig, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configPath := filepath.Join(homeDir, ".config", "bsubio", "config.json") + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config BsubConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// SetupTestClient creates a test client based on the test mode +// In mock mode: creates a mock server and returns client pointing to it +// In production mode: loads config from ~/.config/bsub/config.json and creates real client +func SetupTestClient(t *testing.T) (*BsubClient, *MockServer, func()) { + mode := GetTestMode() + + switch mode { + case TestModeProduction: + // Load production config + config, err := LoadBsubConfig() + if err != nil { + t.Skipf("Skipping production test: failed to load config: %v", err) + return nil, nil, func() {} + } + + if config.APIKey == "" { + t.Skip("Skipping production test: no API key in config") + return nil, nil, func() {} + } + + clientConfig := Config{ + APIKey: config.APIKey, + } + if config.BaseURL != "" { + clientConfig.BaseURL = config.BaseURL + } + + client, err := NewBsubClient(clientConfig) + if err != nil { + t.Fatalf("Failed to create production client: %v", err) + } + + return client, nil, func() {} + + default: // TestModeMock + mockServer := NewMockServer() + client, err := NewBsubClient(Config{ + APIKey: "test-api-key", + BaseURL: mockServer.URL, + }) + if err != nil { + t.Fatalf("Failed to create mock client: %v", err) + } + + return client, mockServer, func() { mockServer.Close() } + } +} diff --git a/testserver_test.go b/testserver_test.go new file mode 100644 index 0000000..204b6cf --- /dev/null +++ b/testserver_test.go @@ -0,0 +1,336 @@ +package bsubio + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// MockServer provides a mock bsub.io server for testing +type MockServer struct { + *httptest.Server + jobs map[uuid.UUID]*Job + uploadedData map[uuid.UUID][]byte // Store uploaded data for calculating results + mu sync.RWMutex + delays map[string]time.Duration // Optional delays for specific operations +} + +// NewMockServer creates a new mock bsub.io server +func NewMockServer() *MockServer { + ms := &MockServer{ + jobs: make(map[uuid.UUID]*Job), + uploadedData: make(map[uuid.UUID][]byte), + delays: make(map[string]time.Duration), + } + + ms.Server = httptest.NewServer(http.HandlerFunc(ms.handler)) + return ms +} + +// GetJob returns a job by ID (for testing inspection) +func (ms *MockServer) GetJob(jobID uuid.UUID) *Job { + ms.mu.RLock() + defer ms.mu.RUnlock() + return ms.jobs[jobID] +} + +func (ms *MockServer) handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Check for delays + ms.mu.RLock() + for op, delay := range ms.delays { + if strings.Contains(r.URL.Path, op) { + time.Sleep(delay) + break + } + } + ms.mu.RUnlock() + + switch { + case r.Method == "POST" && r.URL.Path == "/v1/jobs": + ms.handleCreateJob(w, r) + + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/upload/"): + ms.handleUpload(w, r) + + case r.Method == "POST" && strings.Contains(r.URL.Path, "/submit"): + ms.handleSubmit(w, r) + + case r.Method == "GET" && strings.Contains(r.URL.Path, "/v1/jobs/") && strings.Contains(r.URL.Path, "/output"): + ms.handleGetOutput(w, r) + + case r.Method == "GET" && strings.Contains(r.URL.Path, "/v1/jobs/") && strings.Contains(r.URL.Path, "/logs"): + ms.handleGetLogs(w, r) + + case r.Method == "GET" && strings.Contains(r.URL.Path, "/v1/jobs/"): + ms.handleGetJob(w, r) + + default: + http.Error(w, "Not found", http.StatusNotFound) + } +} + +func (ms *MockServer) handleCreateJob(w http.ResponseWriter, r *http.Request) { + var req CreateJobJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + jobID := uuid.New() + status := JobStatusCreated + uploadToken := uuid.New().String() + now := time.Now() + userID := "test-user-id" + dataSize := int64(0) + + job := &Job{ + Id: &jobID, + Type: &req.Type, + Status: &status, + CreatedAt: &now, + UpdatedAt: &now, + UserId: &userID, + UploadToken: &uploadToken, + DataSize: &dataSize, + } + + ms.mu.Lock() + ms.jobs[jobID] = job + ms.mu.Unlock() + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": job, + "success": true, + }) +} + +func (ms *MockServer) handleUpload(w http.ResponseWriter, r *http.Request) { + // Extract job ID from path: /v1/upload/{jobId} + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 4 { + http.Error(w, "Invalid upload path", http.StatusBadRequest) + return + } + + jobID, err := uuid.Parse(parts[3]) + if err != nil { + http.Error(w, "Invalid job ID", http.StatusBadRequest) + return + } + + // Extract token from query parameters + uploadToken := r.URL.Query().Get("token") + if uploadToken == "" { + http.Error(w, "Missing upload token", http.StatusBadRequest) + return + } + + // Read the uploaded data + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read upload", http.StatusBadRequest) + return + } + + // Verify job exists and token matches + ms.mu.Lock() + defer ms.mu.Unlock() + + job, exists := ms.jobs[jobID] + if !exists { + http.Error(w, "Job not found", http.StatusNotFound) + return + } + + if job.UploadToken == nil || *job.UploadToken != uploadToken { + http.Error(w, "Invalid upload token", http.StatusUnauthorized) + return + } + + // Update job status and store data + status := JobStatusLoaded + job.Status = &status + dataSize := int64(len(data)) + job.DataSize = &dataSize + ms.uploadedData[jobID] = data + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data_size": len(data), + "message": "Upload successful", + }) +} + +func (ms *MockServer) handleSubmit(w http.ResponseWriter, r *http.Request) { + // Extract job ID from path: /v1/jobs/{jobId}/submit + parts := strings.Split(r.URL.Path, "/") + var jobID uuid.UUID + for i, part := range parts { + if part == "jobs" && i+1 < len(parts) { + parsed, err := uuid.Parse(parts[i+1]) + if err == nil { + jobID = parsed + } + break + } + } + + ms.mu.Lock() + job, exists := ms.jobs[jobID] + if !exists { + ms.mu.Unlock() + http.Error(w, "Job not found", http.StatusNotFound) + return + } + + // Simulate job processing - for test job types, mark as finished immediately + // For other types, mark as pending and will need to be polled + status := JobStatusFinished + if job.Type != nil { + switch *job.Type { + case "test/linecount": + status = JobStatusFinished + default: + status = JobStatusPending + } + } + job.Status = &status + now := time.Now() + job.UpdatedAt = &now + ms.mu.Unlock() + + // Return simple success response (matching real API) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Job submitted successfully", + }) +} + +func (ms *MockServer) handleGetJob(w http.ResponseWriter, r *http.Request) { + // Extract job ID from path: /v1/jobs/{jobId} + parts := strings.Split(r.URL.Path, "/") + var jobID uuid.UUID + for i, part := range parts { + if part == "jobs" && i+1 < len(parts) { + // Remove any query parameters or additional path segments + idPart := strings.Split(parts[i+1], "?")[0] + parsed, err := uuid.Parse(idPart) + if err == nil { + jobID = parsed + } + break + } + } + + ms.mu.RLock() + job, exists := ms.jobs[jobID] + ms.mu.RUnlock() + + if !exists { + http.Error(w, "Job not found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": job, + "success": true, + }) +} + +func (ms *MockServer) handleGetOutput(w http.ResponseWriter, r *http.Request) { + // For mock server, return output based on job type and actual uploaded data + parts := strings.Split(r.URL.Path, "/") + var jobID uuid.UUID + for i, part := range parts { + if part == "jobs" && i+1 < len(parts) { + parsed, err := uuid.Parse(parts[i+1]) + if err == nil { + jobID = parsed + } + break + } + } + + ms.mu.RLock() + job, exists := ms.jobs[jobID] + uploadedData := ms.uploadedData[jobID] + ms.mu.RUnlock() + + if !exists || job.Status == nil || *job.Status != JobStatusFinished { + http.Error(w, "Output not available", http.StatusNotFound) + return + } + + // Generate output based on job type + var output string + if job.Type != nil { + switch *job.Type { + case "test/linecount": + // Calculate actual line count from uploaded data + if len(uploadedData) == 0 { + output = "0" + } else { + // Count newlines and add 1 (last line might not have newline) + lineCount := bytes.Count(uploadedData, []byte("\n")) + // If data doesn't end with newline, we have one more line + if uploadedData[len(uploadedData)-1] != '\n' { + lineCount++ + } + output = strconv.Itoa(lineCount) + } + default: + output = "mock output" + } + } else { + output = "mock output" + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(output)) +} + +func (ms *MockServer) handleGetLogs(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + var jobID uuid.UUID + for i, part := range parts { + if part == "jobs" && i+1 < len(parts) { + parsed, err := uuid.Parse(parts[i+1]) + if err == nil { + jobID = parsed + } + break + } + } + + ms.mu.RLock() + job, exists := ms.jobs[jobID] + ms.mu.RUnlock() + + if !exists { + http.Error(w, "Logs not available", http.StatusNotFound) + return + } + + logs := "Mock job processing logs" + if job.Type != nil { + logs = "Processing " + *job.Type + " job\nCompleted successfully" + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logs)) +}