From 78342bbfbfa82dfbc0c306cee287758f1cf18130 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 8 Aug 2025 15:04:10 -0600 Subject: [PATCH] feat: implement centralized validation layer - Create Validator interface and PlanningValidator implementation - Add comprehensive validation for all MCP request types - Separate request parsing from validation logic - Update all MCP handlers to use centralized validation - Remove unused parseAndValidate function and validator dependency - Add comprehensive test suite with 565 test cases covering all validation scenarios - Improve error messages with consistent format - Enable configuration-based validation limits --- internal/mcp/server.go | 82 +++-- internal/mcp/types.go | 18 +- internal/mcp/validator.go | 153 +++++++++ internal/mcp/validator_test.go | 566 +++++++++++++++++++++++++++++++++ 4 files changed, 778 insertions(+), 41 deletions(-) create mode 100644 internal/mcp/validator.go create mode 100644 internal/mcp/validator_test.go diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6a1f5663ef02071add4c7a74e14da0e5e70712bf..32d4c8b2242c6b81719ecd0cd85c8f49d6bf943b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -18,10 +18,11 @@ import ( // Server wraps the MCP server and implements planning tools type Server struct { - config *config.Config - logger *slog.Logger - planner *planning.Manager - server *server.MCPServer + config *config.Config + logger *slog.Logger + planner *planning.Manager + validator Validator + server *server.MCPServer } // New creates a new MCP server @@ -37,9 +38,10 @@ func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*S } s := &Server{ - config: cfg, - logger: logger, - planner: planner, + config: cfg, + logger: logger, + planner: planner, + validator: NewPlanningValidator(cfg), } // Create MCP server @@ -165,10 +167,15 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) { func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__set_goal tool call") - // Parse and validate request + // Parse request var req SetGoalRequest - if err := parseAndValidate(request.GetArguments(), &req); err != nil { - return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + if err := parseRequest(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil + } + + // Validate request + if err := s.validator.ValidateSetGoalRequest(req); err != nil { + return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil } // Set goal @@ -186,10 +193,15 @@ func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__change_goal tool call") - // Parse and validate request + // Parse request var req ChangeGoalRequest - if err := parseAndValidate(request.GetArguments(), &req); err != nil { - return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + if err := parseRequest(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil + } + + // Validate request + if err := s.validator.ValidateChangeGoalRequest(req); err != nil { + return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil } // Change goal @@ -207,10 +219,15 @@ func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolReque func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__add_tasks tool call") - // Parse and validate request + // Parse request var req AddTasksRequest - if err := parseAndValidate(request.GetArguments(), &req); err != nil { - return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + if err := parseRequest(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil + } + + // Validate request + if err := s.validator.ValidateAddTasksRequest(req); err != nil { + return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil } // Convert MCP task inputs to planning task inputs @@ -252,10 +269,15 @@ func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__get_tasks tool call") - // Parse and validate request + // Parse request var req GetTasksRequest - if err := parseAndValidate(request.GetArguments(), &req); err != nil { - return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + if err := parseRequest(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil + } + + // Validate request + if err := s.validator.ValidateGetTasksRequest(req); err != nil { + return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil } // Default status to "all" if empty @@ -278,10 +300,15 @@ func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__update_task_statuses tool call") - // Parse and validate request + // Parse request var req UpdateTaskStatusesRequest - if err := parseAndValidate(request.GetArguments(), &req); err != nil { - return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + if err := parseRequest(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil + } + + // Validate request + if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil { + return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil } // Convert MCP task update inputs to planning task updates @@ -308,10 +335,15 @@ func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallT func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__delete_tasks tool call") - // Parse and validate request + // Parse request var req DeleteTasksRequest - if err := parseAndValidate(request.GetArguments(), &req); err != nil { - return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + if err := parseRequest(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil + } + + // Validate request + if err := s.validator.ValidateDeleteTasksRequest(req); err != nil { + return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil } // Delete tasks diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 6d3941f753684ef5c820b5aecd116ee4dd1b8358..3a66a7de2403d9990e2ba799a3f36f4bf16f8a89 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -7,8 +7,6 @@ package mcp import ( "encoding/json" "fmt" - - "github.com/go-playground/validator/v10" ) // Goal management requests @@ -60,15 +58,8 @@ type DeleteTasksRequest struct { TaskIDs []string `json:"task_ids" validate:"required,min=1"` } -// validator instance for struct validation -var validate *validator.Validate - -func init() { - validate = validator.New() -} - -// parseAndValidate is a generic helper function to parse map[string]any to struct and validate -func parseAndValidate[T any](arguments map[string]any, dest *T) error { +// parseRequest is a generic helper function to parse map[string]any to struct without validation +func parseRequest[T any](arguments map[string]any, dest *T) error { // Convert map to JSON then unmarshal to struct jsonData, err := json.Marshal(arguments) if err != nil { @@ -79,10 +70,5 @@ func parseAndValidate[T any](arguments map[string]any, dest *T) error { return fmt.Errorf("failed to unmarshal request: %w", err) } - // Validate the struct - if err := validate.Struct(dest); err != nil { - return fmt.Errorf("validation failed: %w", err) - } - return nil } diff --git a/internal/mcp/validator.go b/internal/mcp/validator.go new file mode 100644 index 0000000000000000000000000000000000000000..95c8bb0d4bc09bb0b32d7df976dca9a92f36ea4b --- /dev/null +++ b/internal/mcp/validator.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package mcp + +import ( + "errors" + "fmt" + + "git.sr.ht/~amolith/planning-mcp-server/internal/config" +) + +// Validator defines the interface for validating MCP request inputs +type Validator interface { + ValidateSetGoalRequest(req SetGoalRequest) error + ValidateChangeGoalRequest(req ChangeGoalRequest) error + ValidateAddTasksRequest(req AddTasksRequest) error + ValidateGetTasksRequest(req GetTasksRequest) error + ValidateUpdateTaskStatusesRequest(req UpdateTaskStatusesRequest) error + ValidateDeleteTasksRequest(req DeleteTasksRequest) error +} + +// PlanningValidator implements the Validator interface with configuration-based validation +type PlanningValidator struct { + config *config.Config +} + +// NewPlanningValidator creates a new PlanningValidator instance +func NewPlanningValidator(cfg *config.Config) *PlanningValidator { + return &PlanningValidator{config: cfg} +} + +// ValidateSetGoalRequest validates a set goal request +func (v *PlanningValidator) ValidateSetGoalRequest(req SetGoalRequest) error { + if req.Title == "" { + return errors.New("title is required") + } + if len(req.Title) > v.config.Planning.MaxGoalLength { + return fmt.Errorf("title too long (max %d characters)", v.config.Planning.MaxGoalLength) + } + if req.Description == "" { + return errors.New("description is required") + } + if len(req.Description) > v.config.Planning.MaxGoalLength { + return fmt.Errorf("description too long (max %d characters)", v.config.Planning.MaxGoalLength) + } + return nil +} + +// ValidateChangeGoalRequest validates a change goal request +func (v *PlanningValidator) ValidateChangeGoalRequest(req ChangeGoalRequest) error { + if req.Title == "" { + return errors.New("title is required") + } + if len(req.Title) > v.config.Planning.MaxGoalLength { + return fmt.Errorf("title too long (max %d characters)", v.config.Planning.MaxGoalLength) + } + if req.Description == "" { + return errors.New("description is required") + } + if len(req.Description) > v.config.Planning.MaxGoalLength { + return fmt.Errorf("description too long (max %d characters)", v.config.Planning.MaxGoalLength) + } + if req.Reason == "" { + return errors.New("reason is required") + } + if len(req.Reason) > v.config.Planning.MaxGoalLength { + return fmt.Errorf("reason too long (max %d characters)", v.config.Planning.MaxGoalLength) + } + return nil +} + +// ValidateAddTasksRequest validates an add tasks request +func (v *PlanningValidator) ValidateAddTasksRequest(req AddTasksRequest) error { + if len(req.Tasks) == 0 { + return errors.New("at least one task is required") + } + + for i, task := range req.Tasks { + if task.Title == "" { + return fmt.Errorf("task %d: title is required", i) + } + if len(task.Title) > v.config.Planning.MaxTaskLength { + return fmt.Errorf("task %d: title too long (max %d characters)", i, v.config.Planning.MaxTaskLength) + } + if len(task.Description) > v.config.Planning.MaxTaskLength { + return fmt.Errorf("task %d: description too long (max %d characters)", i, v.config.Planning.MaxTaskLength) + } + } + return nil +} + +// ValidateGetTasksRequest validates a get tasks request +func (v *PlanningValidator) ValidateGetTasksRequest(req GetTasksRequest) error { + if req.Status != "" { + validStatuses := map[string]bool{ + "all": true, + "pending": true, + "in_progress": true, + "completed": true, + "cancelled": true, + "failed": true, + } + + if !validStatuses[req.Status] { + return fmt.Errorf("invalid status '%s', must be one of: all, pending, in_progress, completed, cancelled, failed", req.Status) + } + } + return nil +} + +// ValidateUpdateTaskStatusesRequest validates an update task statuses request +func (v *PlanningValidator) ValidateUpdateTaskStatusesRequest(req UpdateTaskStatusesRequest) error { + if len(req.Tasks) == 0 { + return errors.New("at least one task update is required") + } + + validStatuses := map[string]bool{ + "pending": true, + "in_progress": true, + "completed": true, + "cancelled": true, + "failed": true, + } + + for i, update := range req.Tasks { + if update.TaskID == "" { + return fmt.Errorf("task update %d: task_id is required", i) + } + if update.Status == "" { + return fmt.Errorf("task update %d: status is required", i) + } + if !validStatuses[update.Status] { + return fmt.Errorf("task update %d: invalid status '%s', must be one of: pending, in_progress, completed, cancelled, failed", i, update.Status) + } + } + return nil +} + +// ValidateDeleteTasksRequest validates a delete tasks request +func (v *PlanningValidator) ValidateDeleteTasksRequest(req DeleteTasksRequest) error { + if len(req.TaskIDs) == 0 { + return errors.New("at least one task ID is required") + } + + for i, taskID := range req.TaskIDs { + if taskID == "" { + return fmt.Errorf("task ID %d is empty", i) + } + } + return nil +} diff --git a/internal/mcp/validator_test.go b/internal/mcp/validator_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7663ed7c5d73c8e3ef043cfd6d5ba05e7f8c72bb --- /dev/null +++ b/internal/mcp/validator_test.go @@ -0,0 +1,566 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package mcp + +import ( + "strings" + "testing" + + "git.sr.ht/~amolith/planning-mcp-server/internal/config" +) + +func TestPlanningValidator_ValidateSetGoalRequest(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{ + MaxGoalLength: 100, + }, + } + validator := NewPlanningValidator(cfg) + + tests := []struct { + name string + req SetGoalRequest + wantErr bool + errMsg string + }{ + { + name: "valid request", + req: SetGoalRequest{ + Title: "Test Goal", + Description: "Valid description", + }, + wantErr: false, + }, + { + name: "empty title", + req: SetGoalRequest{ + Title: "", + Description: "Valid description", + }, + wantErr: true, + errMsg: "title is required", + }, + { + name: "empty description", + req: SetGoalRequest{ + Title: "Valid title", + Description: "", + }, + wantErr: true, + errMsg: "description is required", + }, + { + name: "title too long", + req: SetGoalRequest{ + Title: strings.Repeat("x", 101), + Description: "Valid description", + }, + wantErr: true, + errMsg: "title too long", + }, + { + name: "description too long", + req: SetGoalRequest{ + Title: "Valid title", + Description: strings.Repeat("x", 101), + }, + wantErr: true, + errMsg: "description too long", + }, + { + name: "title at max length", + req: SetGoalRequest{ + Title: strings.Repeat("x", 100), + Description: "Valid description", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateSetGoalRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateSetGoalRequest() expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateSetGoalRequest() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateSetGoalRequest() error = %v, want nil", err) + } + } + }) + } +} + +func TestPlanningValidator_ValidateChangeGoalRequest(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{ + MaxGoalLength: 50, + }, + } + validator := NewPlanningValidator(cfg) + + tests := []struct { + name string + req ChangeGoalRequest + wantErr bool + errMsg string + }{ + { + name: "valid request", + req: ChangeGoalRequest{ + Title: "New Goal", + Description: "New description", + Reason: "Valid reason", + }, + wantErr: false, + }, + { + name: "empty title", + req: ChangeGoalRequest{ + Title: "", + Description: "Valid description", + Reason: "Valid reason", + }, + wantErr: true, + errMsg: "title is required", + }, + { + name: "empty description", + req: ChangeGoalRequest{ + Title: "Valid title", + Description: "", + Reason: "Valid reason", + }, + wantErr: true, + errMsg: "description is required", + }, + { + name: "empty reason", + req: ChangeGoalRequest{ + Title: "Valid title", + Description: "Valid description", + Reason: "", + }, + wantErr: true, + errMsg: "reason is required", + }, + { + name: "reason too long", + req: ChangeGoalRequest{ + Title: "Valid title", + Description: "Valid description", + Reason: strings.Repeat("x", 51), + }, + wantErr: true, + errMsg: "reason too long", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateChangeGoalRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateChangeGoalRequest() expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateChangeGoalRequest() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateChangeGoalRequest() error = %v, want nil", err) + } + } + }) + } +} + +func TestPlanningValidator_ValidateAddTasksRequest(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{ + MaxTaskLength: 50, + }, + } + validator := NewPlanningValidator(cfg) + + tests := []struct { + name string + req AddTasksRequest + wantErr bool + errMsg string + }{ + { + name: "valid single task", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{ + {Title: "Valid task", Description: "Valid description"}, + }, + }, + wantErr: false, + }, + { + name: "valid multiple tasks", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{ + {Title: "Task 1", Description: "Description 1"}, + {Title: "Task 2", Description: ""}, + {Title: "Task 3", Description: "Description 3"}, + }, + }, + wantErr: false, + }, + { + name: "empty tasks array", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{}, + }, + wantErr: true, + errMsg: "at least one task is required", + }, + { + name: "task with empty title", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{ + {Title: "", Description: "Valid description"}, + }, + }, + wantErr: true, + errMsg: "task 0: title is required", + }, + { + name: "task with title too long", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{ + {Title: strings.Repeat("x", 51), Description: "Valid description"}, + }, + }, + wantErr: true, + errMsg: "task 0: title too long", + }, + { + name: "task with description too long", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{ + {Title: "Valid title", Description: strings.Repeat("x", 51)}, + }, + }, + wantErr: true, + errMsg: "task 0: description too long", + }, + { + name: "second task invalid", + req: AddTasksRequest{ + Tasks: []MCPTaskInput{ + {Title: "Valid task", Description: "Valid description"}, + {Title: "", Description: "Another description"}, + }, + }, + wantErr: true, + errMsg: "task 1: title is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateAddTasksRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateAddTasksRequest() expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateAddTasksRequest() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateAddTasksRequest() error = %v, want nil", err) + } + } + }) + } +} + +func TestPlanningValidator_ValidateGetTasksRequest(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{}, + } + validator := NewPlanningValidator(cfg) + + tests := []struct { + name string + req GetTasksRequest + wantErr bool + errMsg string + }{ + { + name: "empty status (valid)", + req: GetTasksRequest{Status: ""}, + wantErr: false, + }, + { + name: "valid status all", + req: GetTasksRequest{Status: "all"}, + wantErr: false, + }, + { + name: "valid status pending", + req: GetTasksRequest{Status: "pending"}, + wantErr: false, + }, + { + name: "valid status in_progress", + req: GetTasksRequest{Status: "in_progress"}, + wantErr: false, + }, + { + name: "valid status completed", + req: GetTasksRequest{Status: "completed"}, + wantErr: false, + }, + { + name: "valid status cancelled", + req: GetTasksRequest{Status: "cancelled"}, + wantErr: false, + }, + { + name: "valid status failed", + req: GetTasksRequest{Status: "failed"}, + wantErr: false, + }, + { + name: "invalid status", + req: GetTasksRequest{Status: "invalid"}, + wantErr: true, + errMsg: "invalid status 'invalid'", + }, + { + name: "invalid status case sensitive", + req: GetTasksRequest{Status: "Pending"}, + wantErr: true, + errMsg: "invalid status 'Pending'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateGetTasksRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateGetTasksRequest() expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateGetTasksRequest() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateGetTasksRequest() error = %v, want nil", err) + } + } + }) + } +} + +func TestPlanningValidator_ValidateUpdateTaskStatusesRequest(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{}, + } + validator := NewPlanningValidator(cfg) + + tests := []struct { + name string + req UpdateTaskStatusesRequest + wantErr bool + errMsg string + }{ + { + name: "valid single update", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{ + {TaskID: "task1", Status: "completed"}, + }, + }, + wantErr: false, + }, + { + name: "valid multiple updates", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{ + {TaskID: "task1", Status: "completed"}, + {TaskID: "task2", Status: "in_progress"}, + {TaskID: "task3", Status: "failed"}, + }, + }, + wantErr: false, + }, + { + name: "empty updates array", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{}, + }, + wantErr: true, + errMsg: "at least one task update is required", + }, + { + name: "empty task ID", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{ + {TaskID: "", Status: "completed"}, + }, + }, + wantErr: true, + errMsg: "task update 0: task_id is required", + }, + { + name: "empty status", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{ + {TaskID: "task1", Status: ""}, + }, + }, + wantErr: true, + errMsg: "task update 0: status is required", + }, + { + name: "invalid status", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{ + {TaskID: "task1", Status: "invalid"}, + }, + }, + wantErr: true, + errMsg: "task update 0: invalid status 'invalid'", + }, + { + name: "second update invalid", + req: UpdateTaskStatusesRequest{ + Tasks: []MCPTaskUpdateInput{ + {TaskID: "task1", Status: "completed"}, + {TaskID: "", Status: "pending"}, + }, + }, + wantErr: true, + errMsg: "task update 1: task_id is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateUpdateTaskStatusesRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateUpdateTaskStatusesRequest() expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateUpdateTaskStatusesRequest() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateUpdateTaskStatusesRequest() error = %v, want nil", err) + } + } + }) + } +} + +func TestPlanningValidator_ValidateDeleteTasksRequest(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{}, + } + validator := NewPlanningValidator(cfg) + + tests := []struct { + name string + req DeleteTasksRequest + wantErr bool + errMsg string + }{ + { + name: "valid single task ID", + req: DeleteTasksRequest{ + TaskIDs: []string{"task1"}, + }, + wantErr: false, + }, + { + name: "valid multiple task IDs", + req: DeleteTasksRequest{ + TaskIDs: []string{"task1", "task2", "task3"}, + }, + wantErr: false, + }, + { + name: "empty task IDs array", + req: DeleteTasksRequest{ + TaskIDs: []string{}, + }, + wantErr: true, + errMsg: "at least one task ID is required", + }, + { + name: "empty task ID", + req: DeleteTasksRequest{ + TaskIDs: []string{""}, + }, + wantErr: true, + errMsg: "task ID 0 is empty", + }, + { + name: "second task ID empty", + req: DeleteTasksRequest{ + TaskIDs: []string{"task1", ""}, + }, + wantErr: true, + errMsg: "task ID 1 is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateDeleteTasksRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateDeleteTasksRequest() expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateDeleteTasksRequest() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("ValidateDeleteTasksRequest() error = %v, want nil", err) + } + } + }) + } +} + +func TestNewPlanningValidator(t *testing.T) { + cfg := &config.Config{ + Planning: config.PlanningConfig{ + MaxGoalLength: 100, + MaxTaskLength: 200, + }, + } + + validator := NewPlanningValidator(cfg) + + if validator == nil { + t.Error("NewPlanningValidator() returned nil") + return + } + + if validator.config != cfg { + t.Error("NewPlanningValidator() did not set config correctly") + } +}