diff --git a/db/config.go b/db/config.go index e331ea3d5..80453763a 100644 --- a/db/config.go +++ b/db/config.go @@ -97,6 +97,7 @@ func InitDB() { db.AutoMigrate(&TextSnippet{}) db.AutoMigrate(&BountyTiming{}) db.AutoMigrate(&FileAsset{}) + db.AutoMigrate(&TicketPlan{}) DB.MigrateTablesWithOrgUuid() DB.MigrateOrganizationToWorkspace() diff --git a/db/interface.go b/db/interface.go index 9439f33d8..b066bbaa5 100644 --- a/db/interface.go +++ b/db/interface.go @@ -284,4 +284,10 @@ type Database interface { DeleteTicketGroup(TicketGroupUUID uuid.UUID) error PauseBountyTiming(bountyID uint) error ResumeBountyTiming(bountyID uint) error + CreateOrEditTicketPlan(plan *TicketPlan) (*TicketPlan, error) + GetTicketPlan(uuid string) (*TicketPlan, error) + DeleteTicketPlan(uuid string) error + GetTicketPlansByFeature(featureUUID string) ([]TicketPlan, error) + GetTicketPlansByPhase(phaseUUID string) ([]TicketPlan, error) + GetTicketPlansByWorkspace(workspaceUUID string) ([]TicketPlan, error) } diff --git a/db/structs.go b/db/structs.go index 8814b971b..fc616a2ae 100644 --- a/db/structs.go +++ b/db/structs.go @@ -1264,6 +1264,31 @@ type ListFileAssetsParams struct { PageSize int `form:"pageSize,default=50"` } +type PlanStatus string + +const ( + DraftPlan PlanStatus = "DRAFT" + ApprovedPlan PlanStatus = "APPROVED" +) + +type TicketPlan struct { + UUID uuid.UUID `gorm:"primaryKey;type:uuid"` + WorkspaceUuid string `gorm:"type:varchar(255);index:workspace_index" json:"workspace_uuid"` + FeatureUUID string `gorm:"type:varchar(255);index:composite_index;default:null" json:"feature_uuid"` + Features WorkspaceFeatures `gorm:"foreignKey:FeatureUUID;references:Uuid;constraint:OnDelete:SET NULL"` + PhaseUUID string `gorm:"type:varchar(255);index:phase_index;default:null" json:"phase_uuid"` + FeaturePhase FeaturePhase `gorm:"foreignKey:PhaseUUID;references:Uuid;constraint:OnDelete:SET NULL"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + TicketGroups pq.StringArray `gorm:"type:uuid[];not null;default:'{}'" json:"ticket_groups"` + Status PlanStatus `gorm:"type:varchar(50);default:'DRAFT'" json:"status"` + Version int `gorm:"type:integer;default:0" json:"version"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by"` + UpdatedBy string `gorm:"type:varchar(255)" json:"updated_by"` + CreatedAt time.Time `gorm:"type:timestamp;default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `gorm:"type:timestamp;default:current_timestamp" json:"updated_at"` +} + func (Person) TableName() string { return "people" } diff --git a/db/test_config.go b/db/test_config.go index 068fcce6a..5e1042292 100644 --- a/db/test_config.go +++ b/db/test_config.go @@ -83,6 +83,7 @@ func InitTestDB() { db.AutoMigrate(&TextSnippet{}) db.AutoMigrate(&ConnectionCodesList{}) db.AutoMigrate(&TicketMessage{}) + db.AutoMigrate(&TicketPlan{}) people := TestDB.GetAllPeople() for _, p := range people { diff --git a/db/ticket_plan.go b/db/ticket_plan.go new file mode 100644 index 000000000..2abd8008f --- /dev/null +++ b/db/ticket_plan.go @@ -0,0 +1,100 @@ +package db + +import ( + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +func (db database) CreateOrEditTicketPlan(plan *TicketPlan) (*TicketPlan, error) { + if plan.UUID == uuid.Nil { + return nil, errors.New("ticket plan UUID is required") + } + + if plan.WorkspaceUuid == "" { + return nil, errors.New("workspace UUID is required") + } + + if plan.Name == "" { + return nil, errors.New("name is required") + } + + var existingPlan TicketPlan + result := db.db.Where("uuid = ?", plan.UUID).First(&existingPlan) + + now := time.Now() + if result.Error != nil { + + plan.CreatedAt = now + plan.UpdatedAt = now + plan.Version = 1 + if err := db.db.Create(plan).Error; err != nil { + return nil, fmt.Errorf("failed to create ticket plan: %w", err) + } + } else { + plan.UpdatedAt = now + plan.Version = existingPlan.Version + 1 + if err := db.db.Model(&existingPlan).Updates(plan).Error; err != nil { + return nil, fmt.Errorf("failed to update ticket plan: %w", err) + } + } + + var updatedPlan TicketPlan + if err := db.db.Where("uuid = ?", plan.UUID).First(&updatedPlan).Error; err != nil { + return nil, fmt.Errorf("failed to fetch updated ticket plan: %w", err) + } + + return &updatedPlan, nil +} + +func (db database) GetTicketPlan(uuid string) (*TicketPlan, error) { + var plan TicketPlan + result := db.db.Where("uuid = ?", uuid).First(&plan) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("ticket plan not found") + } + return nil, result.Error + } + + return &plan, nil +} + +func (db database) DeleteTicketPlan(uuid string) error { + result := db.db.Where("uuid = ?", uuid).Delete(&TicketPlan{}) + if result.Error != nil { + return fmt.Errorf("failed to delete ticket plan: %w", result.Error) + } + if result.RowsAffected == 0 { + return errors.New("ticket plan not found") + } + return nil +} + +func (db database) GetTicketPlansByFeature(featureUUID string) ([]TicketPlan, error) { + var plans []TicketPlan + if err := db.db.Where("feature_uuid = ?", featureUUID).Find(&plans).Error; err != nil { + return nil, fmt.Errorf("failed to fetch ticket plans by feature: %w", err) + } + return plans, nil +} + +func (db database) GetTicketPlansByPhase(phaseUUID string) ([]TicketPlan, error) { + var plans []TicketPlan + if err := db.db.Where("phase_uuid = ?", phaseUUID).Find(&plans).Error; err != nil { + return nil, fmt.Errorf("failed to fetch ticket plans by phase: %w", err) + } + return plans, nil +} + +func (db database) GetTicketPlansByWorkspace(workspaceUUID string) ([]TicketPlan, error) { + var plans []TicketPlan + if err := db.db.Where("workspace_uuid = ?", workspaceUUID).Find(&plans).Error; err != nil { + return nil, fmt.Errorf("failed to fetch ticket plans by workspace: %w", err) + } + return plans, nil +} \ No newline at end of file diff --git a/handlers/ticket_plan_handler.go b/handlers/ticket_plan_handler.go new file mode 100644 index 000000000..8214c68c3 --- /dev/null +++ b/handlers/ticket_plan_handler.go @@ -0,0 +1,287 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "github.com/stakwork/sphinx-tribes/auth" + "github.com/stakwork/sphinx-tribes/db" + "github.com/stakwork/sphinx-tribes/logger" + "github.com/stakwork/sphinx-tribes/websocket" +) + +type CreateTicketPlanRequest struct { + FeatureID string `json:"feature_id"` + PhaseID string `json:"phase_id"` + Name string `json:"name"` + Description string `json:"description"` + TicketGroupIDs []string `json:"ticket_group_ids"` + SourceWebsocket string `json:"source_websocket,omitempty"` +} + +type TicketPlanResponse struct { + Success bool `json:"success"` + PlanID string `json:"plan_id,omitempty"` + Message string `json:"message"` + Errors []string `json:"errors,omitempty"` +} + +type TicketArrayItem struct { + TicketName string `json:"ticket_name"` + TicketDescription string `json:"ticket_description"` +} + +func (th *ticketHandler) CreateTicketPlan(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + var planRequest CreateTicketPlanRequest + if err := json.NewDecoder(r.Body).Decode(&planRequest); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Invalid request body", + Errors: []string{err.Error()}, + }) + return + } + + if planRequest.FeatureID == "" || planRequest.PhaseID == "" || planRequest.Name == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Missing required fields", + Errors: []string{"feature_id, phase_id, and name are required"}, + }) + return + } + + feature := th.db.GetFeatureByUuid(planRequest.FeatureID) + if feature.Uuid == "" { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Feature not found", + }) + return + } + + phase, err := th.db.GetPhaseByUuid(planRequest.PhaseID) + if err != nil || phase.Uuid == "" { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Phase not found", + }) + return + } + + newPlan := &db.TicketPlan{ + UUID: uuid.New(), + WorkspaceUuid: feature.WorkspaceUuid, + FeatureUUID: planRequest.FeatureID, + PhaseUUID: planRequest.PhaseID, + Name: planRequest.Name, + Description: planRequest.Description, + TicketGroups: planRequest.TicketGroupIDs, + Status: db.DraftPlan, + Version: 1, + CreatedBy: pubKeyFromAuth, + UpdatedBy: pubKeyFromAuth, + } + + + createdPlan, err := th.db.CreateOrEditTicketPlan(newPlan) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Failed to create ticket plan", + Errors: []string{err.Error()}, + }) + return + } + + if planRequest.SourceWebsocket != "" { + websocketErr := websocket.WebsocketPool.SendTicketMessage(websocket.TicketMessage{ + BroadcastType: "direct", + SourceSessionID: planRequest.SourceWebsocket, + Action: "TICKET_PLAN_CREATED", + Message: fmt.Sprintf("Created ticket plan %s", createdPlan.UUID.String()), + }) + + if websocketErr != nil { + logger.Log.Error("Failed to send websocket message", "error", websocketErr) + } + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: true, + PlanID: createdPlan.UUID.String(), + Message: "Ticket plan created successfully", + }) +} + +func (th *ticketHandler) GetTicketPlan(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + uuid := chi.URLParam(r, "uuid") + if uuid == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "UUID is required"}) + return + } + + plan, err := th.db.GetTicketPlan(uuid) + if err != nil { + status := http.StatusInternalServerError + if err.Error() == "ticket plan not found" { + status = http.StatusNotFound + } + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(plan) +} + +func (th *ticketHandler) DeleteTicketPlan(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + uuid := chi.URLParam(r, "uuid") + if uuid == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "UUID is required"}) + return + } + + err := th.db.DeleteTicketPlan(uuid) + if err != nil { + status := http.StatusInternalServerError + if err.Error() == "ticket plan not found" { + status = http.StatusNotFound + } + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Ticket plan deleted successfully"}) +} + +func (th *ticketHandler) GetTicketPlansByFeature(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + featureUUID := chi.URLParam(r, "feature_uuid") + if featureUUID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Feature UUID is required"}) + return + } + + plans, err := th.db.GetTicketPlansByFeature(featureUUID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(plans) +} + +func (th *ticketHandler) GetTicketPlansByPhase(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + phaseUUID := chi.URLParam(r, "phase_uuid") + if phaseUUID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Phase UUID is required"}) + return + } + + plans, err := th.db.GetTicketPlansByPhase(phaseUUID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(plans) +} + +func (th *ticketHandler) GetTicketPlansByWorkspace(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + workspaceUUID := chi.URLParam(r, "workspace_uuid") + if workspaceUUID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Workspace UUID is required"}) + return + } + + plans, err := th.db.GetTicketPlansByWorkspace(workspaceUUID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(plans) +} \ No newline at end of file diff --git a/handlers/ticket_plan_handler_test.go b/handlers/ticket_plan_handler_test.go new file mode 100644 index 000000000..c43034b0b --- /dev/null +++ b/handlers/ticket_plan_handler_test.go @@ -0,0 +1,1058 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "github.com/stakwork/sphinx-tribes/auth" + "github.com/stakwork/sphinx-tribes/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateTicketPlan(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + tHandler := NewTicketHandler(&http.Client{}, db.TestDB) + + person := db.Person{ + Uuid: uuid.New().String(), + OwnerAlias: "test-alias", + UniqueName: "test-unique-name", + OwnerPubKey: "test-pubkey", + PriceToMeet: 0, + Description: "test-description", + } + _, err := db.TestDB.CreateOrEditPerson(person) + require.NoError(t, err) + + workspace := db.Workspace{ + Uuid: uuid.New().String(), + Name: "test-workspace-" + uuid.New().String(), + OwnerPubKey: person.OwnerPubKey, + Github: "https://github.com/test", + Website: "https://www.testwebsite.com", + Description: "test-description", + } + _, err = db.TestDB.CreateOrEditWorkspace(workspace) + require.NoError(t, err) + + feature := db.WorkspaceFeatures{ + Uuid: uuid.New().String(), + WorkspaceUuid: workspace.Uuid, + Name: "test-feature", + Url: "https://github.com/test-feature", + Priority: 0, + CreatedBy: person.OwnerPubKey, + } + _, err = db.TestDB.CreateOrEditFeature(feature) + require.NoError(t, err) + + phase := db.FeaturePhase{ + Uuid: uuid.New().String(), + FeatureUuid: feature.Uuid, + Name: "test-phase", + Priority: 0, + } + _, err = db.TestDB.CreateOrEditFeaturePhase(phase) + require.NoError(t, err) + + tests := []struct { + name string + requestBody CreateTicketPlanRequest + auth string + expectedStatus int + validateFunc func(t *testing.T, response *httptest.ResponseRecorder) + }{ + { + name: "successful ticket plan creation", + requestBody: CreateTicketPlanRequest{ + FeatureID: feature.Uuid, + PhaseID: phase.Uuid, + Name: "Test Ticket Plan", + Description: "A test ticket plan description", + }, + auth: person.OwnerPubKey, + expectedStatus: http.StatusCreated, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp TicketPlanResponse + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.True(t, resp.Success) + assert.NotEmpty(t, resp.PlanID) + assert.Equal(t, "Ticket plan created successfully", resp.Message) + + createdPlan, err := db.TestDB.GetTicketPlan(resp.PlanID) + require.NoError(t, err) + assert.Equal(t, feature.Uuid, createdPlan.FeatureUUID) + assert.Equal(t, phase.Uuid, createdPlan.PhaseUUID) + assert.Equal(t, "Test Ticket Plan", createdPlan.Name) + }, + }, + { + name: "unauthorized no auth token", + requestBody: CreateTicketPlanRequest{ + FeatureID: feature.Uuid, + PhaseID: phase.Uuid, + Name: "Test Ticket Plan", + }, + auth: "", + expectedStatus: http.StatusUnauthorized, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Unauthorized", resp["error"]) + }, + }, + { + name: "bad request - missing feature ID", + requestBody: CreateTicketPlanRequest{ + PhaseID: phase.Uuid, + Name: "Test Ticket Plan", + }, + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp TicketPlanResponse + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Message, "Missing required fields") + }, + }, + { + name: "bad request - missing phase ID", + requestBody: CreateTicketPlanRequest{ + FeatureID: feature.Uuid, + Name: "Test Ticket Plan", + }, + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp TicketPlanResponse + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Message, "Missing required fields") + }, + }, + { + name: "bad request - missing name", + requestBody: CreateTicketPlanRequest{ + FeatureID: feature.Uuid, + PhaseID: phase.Uuid, + }, + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp TicketPlanResponse + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Message, "Missing required fields") + }, + }, + { + name: "not found - invalid feature", + requestBody: CreateTicketPlanRequest{ + FeatureID: uuid.New().String(), + PhaseID: phase.Uuid, + Name: "Test Ticket Plan", + }, + auth: person.OwnerPubKey, + expectedStatus: http.StatusNotFound, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp TicketPlanResponse + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Equal(t, "Feature not found", resp.Message) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + jsonBody, err := json.Marshal(tt.requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/bounties/ticket/plan", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + if tt.auth != "" { + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, tt.auth)) + } + + rr := httptest.NewRecorder() + + tHandler.CreateTicketPlan(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, rr) + } + }) + } +} + +func TestGetTicketPlan(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + tHandler := NewTicketHandler(&http.Client{}, db.TestDB) + + person := db.Person{ + Uuid: uuid.New().String(), + OwnerAlias: "test-alias", + UniqueName: "test-unique-name", + OwnerPubKey: "test-pubkey", + PriceToMeet: 0, + Description: "test-description", + } + _, err := db.TestDB.CreateOrEditPerson(person) + require.NoError(t, err) + + workspace := db.Workspace{ + Uuid: uuid.New().String(), + Name: "test-workspace-" + uuid.New().String(), + OwnerPubKey: person.OwnerPubKey, + Github: "https://github.com/test", + Website: "https://www.testwebsite.com", + Description: "test-description", + } + _, err = db.TestDB.CreateOrEditWorkspace(workspace) + require.NoError(t, err) + + feature := db.WorkspaceFeatures{ + Uuid: uuid.New().String(), + WorkspaceUuid: workspace.Uuid, + Name: "test-feature", + Url: "https://github.com/test-feature", + Priority: 0, + CreatedBy: person.OwnerPubKey, + } + _, err = db.TestDB.CreateOrEditFeature(feature) + require.NoError(t, err) + + phase := db.FeaturePhase{ + Uuid: uuid.New().String(), + FeatureUuid: feature.Uuid, + Name: "test-phase", + Priority: 0, + } + _, err = db.TestDB.CreateOrEditFeaturePhase(phase) + require.NoError(t, err) + + ticketPlan := &db.TicketPlan{ + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan", + Description: "Test Description", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + } + createdPlan, err := db.TestDB.CreateOrEditTicketPlan(ticketPlan) + require.NoError(t, err) + + tests := []struct { + name string + planUUID string + auth string + expectedStatus int + validateFunc func(t *testing.T, response *httptest.ResponseRecorder) + }{ + { + name: "successful ticket plan retrieval", + planUUID: createdPlan.UUID.String(), + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var retrievedPlan db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &retrievedPlan) + require.NoError(t, err) + + assert.Equal(t, createdPlan.UUID, retrievedPlan.UUID) + assert.Equal(t, "Test Ticket Plan", retrievedPlan.Name) + assert.Equal(t, feature.Uuid, retrievedPlan.FeatureUUID) + assert.Equal(t, phase.Uuid, retrievedPlan.PhaseUUID) + }, + }, + { + name: "unauthorized - no auth token", + planUUID: createdPlan.UUID.String(), + auth: "", + expectedStatus: http.StatusUnauthorized, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Unauthorized", resp["error"]) + }, + }, + { + name: "bad request - empty UUID", + planUUID: "", + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "UUID is required", resp["error"]) + }, + }, + { + name: "not found - non-existent UUID", + planUUID: uuid.New().String(), + auth: person.OwnerPubKey, + expectedStatus: http.StatusNotFound, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Contains(t, resp["error"], "ticket plan not found") + }, + }, + { + name: "invalid UUID format", + planUUID: "invalid-uuid", + auth: person.OwnerPubKey, + expectedStatus: http.StatusInternalServerError, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + req := httptest.NewRequest(http.MethodGet, "/bounties/ticket/plan/"+tt.planUUID, nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", tt.planUUID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + if tt.auth != "" { + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, tt.auth)) + } + + rr := httptest.NewRecorder() + + tHandler.GetTicketPlan(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, rr) + } + }) + } +} + +func TestDeleteTicketPlan(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + tHandler := NewTicketHandler(&http.Client{}, db.TestDB) + + person := db.Person{ + Uuid: uuid.New().String(), + OwnerAlias: "test-alias", + UniqueName: "test-unique-name", + OwnerPubKey: "test-pubkey", + PriceToMeet: 0, + Description: "test-description", + } + _, err := db.TestDB.CreateOrEditPerson(person) + require.NoError(t, err) + + workspace := db.Workspace{ + Uuid: uuid.New().String(), + Name: "test-workspace-" + uuid.New().String(), + OwnerPubKey: person.OwnerPubKey, + Github: "https://github.com/test", + Website: "https://www.testwebsite.com", + Description: "test-description", + } + _, err = db.TestDB.CreateOrEditWorkspace(workspace) + require.NoError(t, err) + + feature := db.WorkspaceFeatures{ + Uuid: uuid.New().String(), + WorkspaceUuid: workspace.Uuid, + Name: "test-feature", + Url: "https://github.com/test-feature", + Priority: 0, + CreatedBy: person.OwnerPubKey, + } + _, err = db.TestDB.CreateOrEditFeature(feature) + require.NoError(t, err) + + phase := db.FeaturePhase{ + Uuid: uuid.New().String(), + FeatureUuid: feature.Uuid, + Name: "test-phase", + Priority: 0, + } + _, err = db.TestDB.CreateOrEditFeaturePhase(phase) + require.NoError(t, err) + + tests := []struct { + name string + createPlan bool + planUUID string + auth string + expectedStatus int + validateFunc func(t *testing.T, response *httptest.ResponseRecorder, planUUID string) + }{ + { + name: "successful ticket plan deletion", + createPlan: true, + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder, planUUID string) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "Ticket plan deleted successfully", resp["message"]) + + _, err = db.TestDB.GetTicketPlan(planUUID) + assert.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "not found") + }, + }, + { + name: "unauthorized - no auth token", + createPlan: true, + auth: "", + expectedStatus: http.StatusUnauthorized, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder, planUUID string) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Unauthorized", resp["error"]) + }, + }, + { + name: "bad request - empty UUID", + createPlan: false, + planUUID: "", + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder, planUUID string) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "UUID is required", resp["error"]) + }, + }, + { + name: "not found - non-existent UUID", + createPlan: false, + planUUID: uuid.New().String(), + auth: person.OwnerPubKey, + expectedStatus: http.StatusNotFound, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder, planUUID string) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Contains(t, resp["error"], "ticket plan not found") + }, + }, + { + name: "invalid UUID format", + createPlan: false, + planUUID: "invalid-uuid", + auth: person.OwnerPubKey, + expectedStatus: http.StatusInternalServerError, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder, planUUID string) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var planUUID string + if tt.createPlan { + ticketPlan := &db.TicketPlan{ + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan", + Description: "Test Description", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + } + createdPlan, err := db.TestDB.CreateOrEditTicketPlan(ticketPlan) + require.NoError(t, err) + planUUID = createdPlan.UUID.String() + } else { + planUUID = tt.planUUID + } + + req := httptest.NewRequest(http.MethodDelete, "/bounties/ticket/plan/"+planUUID, nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", planUUID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + if tt.auth != "" { + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, tt.auth)) + } + + rr := httptest.NewRecorder() + + tHandler.DeleteTicketPlan(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, rr, planUUID) + } + }) + } +} + +func TestGetTicketPlansByFeature(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + tHandler := NewTicketHandler(&http.Client{}, db.TestDB) + + person := db.Person{ + Uuid: uuid.New().String(), + OwnerAlias: "test-alias", + UniqueName: "test-unique-name", + OwnerPubKey: "test-pubkey", + PriceToMeet: 0, + Description: "test-description", + } + _, err := db.TestDB.CreateOrEditPerson(person) + require.NoError(t, err) + + workspace := db.Workspace{ + Uuid: uuid.New().String(), + Name: "test-workspace-" + uuid.New().String(), + OwnerPubKey: person.OwnerPubKey, + Github: "https://github.com/test", + Website: "https://www.testwebsite.com", + Description: "test-description", + } + _, err = db.TestDB.CreateOrEditWorkspace(workspace) + require.NoError(t, err) + + feature := db.WorkspaceFeatures{ + Uuid: uuid.New().String(), + WorkspaceUuid: workspace.Uuid, + Name: "test-feature", + Url: "https://github.com/test-feature", + Priority: 0, + CreatedBy: person.OwnerPubKey, + } + _, err = db.TestDB.CreateOrEditFeature(feature) + require.NoError(t, err) + + phase := db.FeaturePhase{ + Uuid: uuid.New().String(), + FeatureUuid: feature.Uuid, + Name: "test-phase", + Priority: 0, + } + _, err = db.TestDB.CreateOrEditFeaturePhase(phase) + require.NoError(t, err) + + ticketPlans := []*db.TicketPlan{ + { + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan 1", + Description: "Test Description 1", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + }, + { + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan 2", + Description: "Test Description 2", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + }, + } + + var createdPlans []string + for _, plan := range ticketPlans { + createdPlan, err := db.TestDB.CreateOrEditTicketPlan(plan) + require.NoError(t, err) + createdPlans = append(createdPlans, createdPlan.UUID.String()) + } + + tests := []struct { + name string + featureUUID string + auth string + expectedStatus int + validateFunc func(t *testing.T, response *httptest.ResponseRecorder) + }{ + { + name: "successful retrieval of ticket plans by feature", + featureUUID: feature.Uuid, + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var plans []db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &plans) + require.NoError(t, err) + + assert.Equal(t, 2, len(plans), "Should return 2 ticket plans") + + planNames := make(map[string]bool) + for _, plan := range plans { + assert.Equal(t, feature.Uuid, plan.FeatureUUID, "Feature UUID should match") + planNames[plan.Name] = true + } + assert.Contains(t, planNames, "Test Ticket Plan 1") + assert.Contains(t, planNames, "Test Ticket Plan 2") + }, + }, + { + name: "unauthorized - no auth token", + featureUUID: feature.Uuid, + auth: "", + expectedStatus: http.StatusUnauthorized, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Unauthorized", resp["error"]) + }, + }, + { + name: "bad request - empty feature UUID", + featureUUID: "", + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Feature UUID is required", resp["error"]) + }, + }, + { + name: "no plans for feature", + featureUUID: uuid.New().String(), + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var plans []db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &plans) + require.NoError(t, err) + assert.Equal(t, 0, len(plans), "Should return empty list for non-existent feature") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + req := httptest.NewRequest(http.MethodGet, "/bounties/ticket/plan/feature/"+tt.featureUUID, nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("feature_uuid", tt.featureUUID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + if tt.auth != "" { + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, tt.auth)) + } + + rr := httptest.NewRecorder() + + tHandler.GetTicketPlansByFeature(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, rr) + } + }) + } +} + +func TestGetTicketPlansByPhase(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + tHandler := NewTicketHandler(&http.Client{}, db.TestDB) + + person := db.Person{ + Uuid: uuid.New().String(), + OwnerAlias: "test-alias", + UniqueName: "test-unique-name", + OwnerPubKey: "test-pubkey", + PriceToMeet: 0, + Description: "test-description", + } + _, err := db.TestDB.CreateOrEditPerson(person) + require.NoError(t, err) + + workspace := db.Workspace{ + Uuid: uuid.New().String(), + Name: "test-workspace-" + uuid.New().String(), + OwnerPubKey: person.OwnerPubKey, + Github: "https://github.com/test", + Website: "https://www.testwebsite.com", + Description: "test-description", + } + _, err = db.TestDB.CreateOrEditWorkspace(workspace) + require.NoError(t, err) + + feature := db.WorkspaceFeatures{ + Uuid: uuid.New().String(), + WorkspaceUuid: workspace.Uuid, + Name: "test-feature", + Url: "https://github.com/test-feature", + Priority: 0, + CreatedBy: person.OwnerPubKey, + } + _, err = db.TestDB.CreateOrEditFeature(feature) + require.NoError(t, err) + + phase := db.FeaturePhase{ + Uuid: uuid.New().String(), + FeatureUuid: feature.Uuid, + Name: "test-phase", + Priority: 0, + } + _, err = db.TestDB.CreateOrEditFeaturePhase(phase) + require.NoError(t, err) + + ticketPlans := []*db.TicketPlan{ + { + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan 1", + Description: "Test Description 1", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + }, + { + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan 2", + Description: "Test Description 2", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + }, + } + + var createdPlans []string + for _, plan := range ticketPlans { + createdPlan, err := db.TestDB.CreateOrEditTicketPlan(plan) + require.NoError(t, err) + createdPlans = append(createdPlans, createdPlan.UUID.String()) + } + + tests := []struct { + name string + phaseUUID string + auth string + expectedStatus int + validateFunc func(t *testing.T, response *httptest.ResponseRecorder) + }{ + { + name: "successful retrieval of ticket plans by phase", + phaseUUID: phase.Uuid, + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var plans []db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &plans) + require.NoError(t, err) + + assert.Equal(t, 2, len(plans), "Should return 2 ticket plans") + + planNames := make(map[string]bool) + for _, plan := range plans { + assert.Equal(t, phase.Uuid, plan.PhaseUUID, "Phase UUID should match") + planNames[plan.Name] = true + } + assert.Contains(t, planNames, "Test Ticket Plan 1") + assert.Contains(t, planNames, "Test Ticket Plan 2") + }, + }, + { + name: "unauthorized - no auth token", + phaseUUID: phase.Uuid, + auth: "", + expectedStatus: http.StatusUnauthorized, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Unauthorized", resp["error"]) + }, + }, + { + name: "bad request - empty phase UUID", + phaseUUID: "", + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Phase UUID is required", resp["error"]) + }, + }, + { + name: "no plans for phase", + phaseUUID: uuid.New().String(), + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var plans []db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &plans) + require.NoError(t, err) + assert.Equal(t, 0, len(plans), "Should return empty list for non-existent phase") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/bounties/ticket/plan/phase/"+tt.phaseUUID, nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("phase_uuid", tt.phaseUUID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + if tt.auth != "" { + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, tt.auth)) + } + + rr := httptest.NewRecorder() + + tHandler.GetTicketPlansByPhase(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, rr) + } + }) + } +} + +func TestGetTicketPlansByWorkspace(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + tHandler := NewTicketHandler(&http.Client{}, db.TestDB) + + person := db.Person{ + Uuid: uuid.New().String(), + OwnerAlias: "test-alias", + UniqueName: "test-unique-name", + OwnerPubKey: "test-pubkey", + PriceToMeet: 0, + Description: "test-description", + } + _, err := db.TestDB.CreateOrEditPerson(person) + require.NoError(t, err) + + workspace := db.Workspace{ + Uuid: uuid.New().String(), + Name: "test-workspace-" + uuid.New().String(), + OwnerPubKey: person.OwnerPubKey, + Github: "https://github.com/test", + Website: "https://www.testwebsite.com", + Description: "test-description", + } + _, err = db.TestDB.CreateOrEditWorkspace(workspace) + require.NoError(t, err) + + feature := db.WorkspaceFeatures{ + Uuid: uuid.New().String(), + WorkspaceUuid: workspace.Uuid, + Name: "test-feature", + Url: "https://github.com/test-feature", + Priority: 0, + CreatedBy: person.OwnerPubKey, + } + _, err = db.TestDB.CreateOrEditFeature(feature) + require.NoError(t, err) + + phase := db.FeaturePhase{ + Uuid: uuid.New().String(), + FeatureUuid: feature.Uuid, + Name: "test-phase", + Priority: 0, + } + _, err = db.TestDB.CreateOrEditFeaturePhase(phase) + require.NoError(t, err) + + ticketPlans := []*db.TicketPlan{ + { + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan 1", + Description: "Test Description 1", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + }, + { + UUID: uuid.New(), + WorkspaceUuid: workspace.Uuid, + FeatureUUID: feature.Uuid, + PhaseUUID: phase.Uuid, + Name: "Test Ticket Plan 2", + Description: "Test Description 2", + Status: db.DraftPlan, + CreatedBy: person.OwnerPubKey, + }, + } + + var createdPlans []string + for _, plan := range ticketPlans { + createdPlan, err := db.TestDB.CreateOrEditTicketPlan(plan) + require.NoError(t, err) + createdPlans = append(createdPlans, createdPlan.UUID.String()) + } + + tests := []struct { + name string + workspaceUUID string + auth string + expectedStatus int + validateFunc func(t *testing.T, response *httptest.ResponseRecorder) + }{ + { + name: "successful retrieval of ticket plans by workspace", + workspaceUUID: workspace.Uuid, + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var plans []db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &plans) + require.NoError(t, err) + + assert.Equal(t, 2, len(plans), "Should return 2 ticket plans") + + planNames := make(map[string]bool) + for _, plan := range plans { + assert.Equal(t, workspace.Uuid, plan.WorkspaceUuid, "Workspace UUID should match") + planNames[plan.Name] = true + } + assert.Contains(t, planNames, "Test Ticket Plan 1") + assert.Contains(t, planNames, "Test Ticket Plan 2") + }, + }, + { + name: "unauthorized - no auth token", + workspaceUUID: workspace.Uuid, + auth: "", + expectedStatus: http.StatusUnauthorized, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Unauthorized", resp["error"]) + }, + }, + { + name: "bad request - empty workspace UUID", + workspaceUUID: "", + auth: person.OwnerPubKey, + expectedStatus: http.StatusBadRequest, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var resp map[string]string + err := json.Unmarshal(response.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "error") + assert.Equal(t, "Workspace UUID is required", resp["error"]) + }, + }, + { + name: "no plans for workspace", + workspaceUUID: uuid.New().String(), + auth: person.OwnerPubKey, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, response *httptest.ResponseRecorder) { + var plans []db.TicketPlan + err := json.Unmarshal(response.Body.Bytes(), &plans) + require.NoError(t, err) + assert.Equal(t, 0, len(plans), "Should return empty list for non-existent workspace") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/bounties/ticket/plan/workspace/"+tt.workspaceUUID, nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspace_uuid", tt.workspaceUUID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + if tt.auth != "" { + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, tt.auth)) + } + + rr := httptest.NewRecorder() + + tHandler.GetTicketPlansByWorkspace(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, rr) + } + }) + } +} diff --git a/mocks/Database.go b/mocks/Database.go index 9567f6bf6..73063124a 100644 --- a/mocks/Database.go +++ b/mocks/Database.go @@ -13799,4 +13799,330 @@ func (_c *Database_GetConnectionCodesList_Call) Return(_a0 []db.ConnectionCodesL func (_c *Database_GetConnectionCodesList_Call) RunAndReturn(run func(int, int) ([]db.ConnectionCodesList, int64, error)) *Database_GetConnectionCodesList_Call { _c.Call.Return(run) return _c -} \ No newline at end of file +} + + +// CreateOrEditTicketPlan provides a mock function with given fields: plan +func (_m *Database) CreateOrEditTicketPlan(plan *db.TicketPlan) (*db.TicketPlan, error) { + ret := _m.Called(plan) + + if len(ret) == 0 { + panic("no return value specified for CreateOrEditTicketPlan") + } + + var r0 *db.TicketPlan + var r1 error + if rf, ok := ret.Get(0).(func(*db.TicketPlan) (*db.TicketPlan, error)); ok { + return rf(plan) + } + if rf, ok := ret.Get(0).(func(*db.TicketPlan) *db.TicketPlan); ok { + r0 = rf(plan) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*db.TicketPlan) + } + } + + if rf, ok := ret.Get(1).(func(*db.TicketPlan) error); ok { + r1 = rf(plan) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type Database_CreateOrEditTicketPlan_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) CreateOrEditTicketPlan(plan interface{}) *Database_CreateOrEditTicketPlan_Call { + return &Database_CreateOrEditTicketPlan_Call{Call: _e.mock.On("CreateOrEditTicketPlan", plan)} +} + +func (_c *Database_CreateOrEditTicketPlan_Call) Run(run func(plan *db.TicketPlan)) *Database_CreateOrEditTicketPlan_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*db.TicketPlan)) + }) + return _c +} + +func (_c *Database_CreateOrEditTicketPlan_Call) Return(_a0 *db.TicketPlan, _a1 error) *Database_CreateOrEditTicketPlan_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_CreateOrEditTicketPlan_Call) RunAndReturn(run func(*db.TicketPlan) (*db.TicketPlan, error)) *Database_CreateOrEditTicketPlan_Call { + _c.Call.Return(run) + return _c +} + + + +// GetTicketPlan provides a mock function with given fields: _a0 +func (_m *Database) GetTicketPlan(_a0 string) (*db.TicketPlan, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for GetTicketPlan") + } + + var r0 *db.TicketPlan + var r1 error + if rf, ok := ret.Get(0).(func(string) (*db.TicketPlan, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) *db.TicketPlan); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*db.TicketPlan) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type Database_GetTicketPlan_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) GetTicketPlan(_a0 interface{}) *Database_GetTicketPlan_Call { + return &Database_GetTicketPlan_Call{Call: _e.mock.On("GetTicketPlan", _a0)} +} + +func (_c *Database_GetTicketPlan_Call) Run(run func(_a0 string)) *Database_GetTicketPlan_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetTicketPlan_Call) Return(_a0 *db.TicketPlan, _a1 error) *Database_GetTicketPlan_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetTicketPlan_Call) RunAndReturn(run func(string) (*db.TicketPlan, error)) *Database_GetTicketPlan_Call { + _c.Call.Return(run) + return _c +} + + + +// DeleteTicketPlan provides a mock function with given fields: _a0 +func (_m *Database) DeleteTicketPlan(_a0 string) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for DeleteTicketPlan") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type Database_DeleteTicketPlan_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) DeleteTicketPlan(_a0 interface{}) *Database_DeleteTicketPlan_Call { + return &Database_DeleteTicketPlan_Call{Call: _e.mock.On("DeleteTicketPlan", _a0)} +} + +func (_c *Database_DeleteTicketPlan_Call) Run(run func(_a0 string)) *Database_DeleteTicketPlan_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_DeleteTicketPlan_Call) Return(_a0 error) *Database_DeleteTicketPlan_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Database_DeleteTicketPlan_Call) RunAndReturn(run func(string) error) *Database_DeleteTicketPlan_Call { + _c.Call.Return(run) + return _c +} + + +// GetTicketPlansByFeature provides a mock function with given fields: featureUUID +func (_m *Database) GetTicketPlansByFeature(featureUUID string) ([]db.TicketPlan, error) { + ret := _m.Called(featureUUID) + + if len(ret) == 0 { + panic("no return value specified for GetTicketPlansByFeature") + } + + var r0 []db.TicketPlan + var r1 error + if rf, ok := ret.Get(0).(func(string) ([]db.TicketPlan, error)); ok { + return rf(featureUUID) + } + if rf, ok := ret.Get(0).(func(string) []db.TicketPlan); ok { + r0 = rf(featureUUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]db.TicketPlan) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(featureUUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type Database_GetTicketPlansByFeature_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) GetTicketPlansByFeature(featureUUID interface{}) *Database_GetTicketPlansByFeature_Call { + return &Database_GetTicketPlansByFeature_Call{Call: _e.mock.On("GetTicketPlansByFeature", featureUUID)} +} + +func (_c *Database_GetTicketPlansByFeature_Call) Run(run func(featureUUID string)) *Database_GetTicketPlansByFeature_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetTicketPlansByFeature_Call) Return(_a0 []db.TicketPlan, _a1 error) *Database_GetTicketPlansByFeature_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetTicketPlansByFeature_Call) RunAndReturn(run func(string) ([]db.TicketPlan, error)) *Database_GetTicketPlansByFeature_Call { + _c.Call.Return(run) + return _c +} + + +// GetTicketPlansByPhase provides a mock function with given fields: phaseUUID +func (_m *Database) GetTicketPlansByPhase(phaseUUID string) ([]db.TicketPlan, error) { + ret := _m.Called(phaseUUID) + + if len(ret) == 0 { + panic("no return value specified for GetTicketPlansByPhase") + } + + var r0 []db.TicketPlan + var r1 error + if rf, ok := ret.Get(0).(func(string) ([]db.TicketPlan, error)); ok { + return rf(phaseUUID) + } + if rf, ok := ret.Get(0).(func(string) []db.TicketPlan); ok { + r0 = rf(phaseUUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]db.TicketPlan) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(phaseUUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type Database_GetTicketPlansByPhase_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) GetTicketPlansByPhase(phaseUUID interface{}) *Database_GetTicketPlansByPhase_Call { + return &Database_GetTicketPlansByPhase_Call{Call: _e.mock.On("GetTicketPlansByPhase", phaseUUID)} +} + +func (_c *Database_GetTicketPlansByPhase_Call) Run(run func(phaseUUID string)) *Database_GetTicketPlansByPhase_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetTicketPlansByPhase_Call) Return(_a0 []db.TicketPlan, _a1 error) *Database_GetTicketPlansByPhase_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetTicketPlansByPhase_Call) RunAndReturn(run func(string) ([]db.TicketPlan, error)) *Database_GetTicketPlansByPhase_Call { + _c.Call.Return(run) + return _c +} + + +// GetTicketPlansByWorkspace provides a mock function with given fields: workspaceUUID +func (_m *Database) GetTicketPlansByWorkspace(workspaceUUID string) ([]db.TicketPlan, error) { + ret := _m.Called(workspaceUUID) + + if len(ret) == 0 { + panic("no return value specified for GetTicketPlansByWorkspace") + } + + var r0 []db.TicketPlan + var r1 error + if rf, ok := ret.Get(0).(func(string) ([]db.TicketPlan, error)); ok { + return rf(workspaceUUID) + } + if rf, ok := ret.Get(0).(func(string) []db.TicketPlan); ok { + r0 = rf(workspaceUUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]db.TicketPlan) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(workspaceUUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type Database_GetTicketPlansByWorkspace_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) GetTicketPlansByWorkspace(workspaceUUID interface{}) *Database_GetTicketPlansByWorkspace_Call { + return &Database_GetTicketPlansByWorkspace_Call{Call: _e.mock.On("GetTicketPlansByWorkspace", workspaceUUID)} +} + +func (_c *Database_GetTicketPlansByWorkspace_Call) Run(run func(workspaceUUID string)) *Database_GetTicketPlansByWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetTicketPlansByWorkspace_Call) Return(_a0 []db.TicketPlan, _a1 error) *Database_GetTicketPlansByWorkspace_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetTicketPlansByWorkspace_Call) RunAndReturn(run func(string) ([]db.TicketPlan, error)) *Database_GetTicketPlansByWorkspace_Call { + _c.Call.Return(run) + return _c +} diff --git a/routes/ticket_routes.go b/routes/ticket_routes.go index 6198094e7..a2e43388e 100644 --- a/routes/ticket_routes.go +++ b/routes/ticket_routes.go @@ -33,6 +33,13 @@ func TicketRoutes() chi.Router { r.Get("/workspace/{workspace_uuid}/draft/{uuid}", ticketHandler.GetWorkspaceDraftTicket) r.Post("/workspace/{workspace_uuid}/draft/{uuid}", ticketHandler.UpdateWorkspaceDraftTicket) r.Delete("/workspace/{workspace_uuid}/draft/{uuid}", ticketHandler.DeleteWorkspaceDraftTicket) + + r.Post("/plan", ticketHandler.CreateTicketPlan) + r.Get("/plan/{uuid}", ticketHandler.GetTicketPlan) + r.Delete("/plan/{uuid}", ticketHandler.DeleteTicketPlan) + r.Get("/plan/feature/{feature_uuid}", ticketHandler.GetTicketPlansByFeature) + r.Get("/plan/phase/{phase_uuid}", ticketHandler.GetTicketPlansByPhase) + r.Get("/plan/workspace/{workspace_uuid}", ticketHandler.GetTicketPlansByWorkspace) }) return r