diff --git a/qacsv_test.go b/qacsv_test.go index 8809cac..3764c34 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -27,9 +27,10 @@ var successTestCases = []TestCase{ Expected: "expected-2", }, }, - Requirement: &Requirement{Title: "req1", URL: "http://req1"}, + Requirements: []Requirement{{Title: "req1", URL: "http://req1"}}, Files: []File{ { + ID: "file-id", Name: "file-1.csv", MimeType: "text/csv", Size: 10, @@ -37,6 +38,7 @@ var successTestCases = []TestCase{ }, { Name: "file-1.csv", ID: "file-id", + URL: "http://file1", MimeType: "text/csv", Size: 10, }, @@ -70,9 +72,10 @@ var successTestCases = []TestCase{ Expected: "expected.,<>/@$%\"\"''*&()[]{}+-`!~;", }, }, - Requirement: &Requirement{Title: "req.,<>/@$%\"\"''*&()[]{}+-`!~;"}, + Requirements: []Requirement{{Title: "req.,<>/@$%\"\"''*&()[]{}+-`!~;"}}, Files: []File{ { + ID: "file-id", Name: "file-1.csv", MimeType: "text/csv", Size: 10, @@ -101,9 +104,10 @@ var successTestCases = []TestCase{ Expected: "expected-2", }, }, - Requirement: &Requirement{URL: "http://req1"}, + Requirements: []Requirement{{URL: "http://req1"}}, Files: []File{ { + ID: "file-id", Name: "file-1.csv", MimeType: "text/csv", Size: 10, @@ -111,6 +115,7 @@ var successTestCases = []TestCase{ }, { Name: "file-1.csv", ID: "file-id", + URL: "http://file1", MimeType: "text/csv", Size: 10, }, @@ -120,11 +125,11 @@ var successTestCases = []TestCase{ }, } -const successTestCasesCSV = `Folder,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Step 1,Expected 1,Step 2,Expected 2 -root,tc-with-minimal-fields,,false,high,,,,,,,,, -root,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""file_name"":""file-1.csv"",""url"":""http://file1"",""mime_type"":""text/csv"",""size"":10},{""file_name"":""file-1.csv"",""id"":""file-id"",""mime_type"":""text/csv"",""size"":10}]",,action-1,,,expected-2 -root/child,tc-with-all-fields,legacy-id,false,high,"tag1,tag2",[req1](http://req1),"[link-1](http://link1),[link-2](http://link2)","[{""file_name"":""file-1.csv"",""url"":""http://file1"",""mime_type"":""text/csv"",""size"":10},{""file_name"":""file-1.csv"",""id"":""file-id"",""mime_type"":""text/csv"",""size"":10}]",preconditions,action-1,expected-1,action-2,expected-2 -root/child,"tc-with-special-chars.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",legacy-id,false,high,"tag1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[req.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;]()","[link-1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;](http://link1)","[{""file_name"":""file-1.csv"",""url"":""http://file1"",""mime_type"":""text/csv"",""size"":10}]","preconditions.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","action.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","expected.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",, +const successTestCasesCSV = `Folder,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,Step 2,Expected 2 +root,standalone,tc-with-minimal-fields,,false,high,,,,,,,,,,, +root,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",,,,action-1,,,expected-2 +root/child,standalone,tc-with-all-fields,legacy-id,false,high,"tag1,tag2",[req1](http://req1),"[link-1](http://link1),[link-2](http://link2)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",preconditions,,,action-1,expected-1,action-2,expected-2 +root/child,standalone,"tc-with-special-chars.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",legacy-id,false,high,"tag1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[req.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;]()","[link-1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;](http://link1)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]","preconditions.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",,,"action.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","expected.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",, ` var failureTestCases = []TestCase{ @@ -133,7 +138,7 @@ var failureTestCases = []TestCase{ Folder: []string{"root"}, Priority: "high", }, { - Title: "very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-very-long-title-", + Title: strings.Repeat("a", 512), // Exceeds 511 char limit Folder: []string{"root"}, Priority: "high", }, { @@ -161,17 +166,17 @@ var failureTestCases = []TestCase{ Title: "long tag", Folder: []string{"root"}, Priority: "high", - Tags: []string{"very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very-long-tag-very"}, + Tags: []string{strings.Repeat("a", 256)}, // Exceeds 255 char limit }, { - Title: "requirement without title and url", - Folder: []string{"root"}, - Priority: "high", - Requirement: &Requirement{}, + Title: "requirement without title and url", + Folder: []string{"root"}, + Priority: "high", + Requirements: []Requirement{{}}, }, { - Title: "requirement with invalid url", - Folder: []string{"root"}, - Priority: "high", - Requirement: &Requirement{URL: "ftp://req1"}, + Title: "requirement with invalid url", + Folder: []string{"root"}, + Priority: "high", + Requirements: []Requirement{{URL: "ftp://req1"}}, }, { Title: "link without title and url", Folder: []string{"root"}, @@ -229,8 +234,159 @@ var failureTestCases = []TestCase{ }, } +// Custom field test data +var customFields = []CustomField{ + { + SystemName: "test_env", + Type: CustomFieldTypeDropdown, + }, + { + SystemName: "automation", + Type: CustomFieldTypeDropdown, + }, + { + SystemName: "notes", + Type: CustomFieldTypeText, + }, +} + +var customFieldSuccessTestCases = []TestCase{ + { + Title: "tc-with-single-custom-field", + Folder: []string{"custom-fields"}, + Priority: "medium", + CustomFields: map[string]CustomFieldValue{ + "test_env": { + Value: "staging", + IsDefault: false, + }, + }, + }, + { + Title: "tc-with-multiple-custom-fields", + Folder: []string{"custom-fields"}, + Priority: "high", + Tags: []string{"regression", "smoke"}, + CustomFields: map[string]CustomFieldValue{ + "test_env": { + Value: "production", + IsDefault: false, + }, + "automation": { + Value: "Automated", + IsDefault: false, + }, + "notes": { + Value: "This is a test note with special chars: !@#$%^&*()", + IsDefault: false, + }, + }, + Steps: []Step{ + { + Action: "Execute test", + Expected: "Test passes", + }, + }, + }, + { + Title: "tc-with-empty-custom-field-value", + Folder: []string{"custom-fields"}, + Priority: "low", + CustomFields: map[string]CustomFieldValue{ + "notes": { + Value: "", + IsDefault: false, + }, + }, + }, + { + Title: "tc-with-default-custom-field", + Folder: []string{"custom-fields"}, + Priority: "medium", + CustomFields: map[string]CustomFieldValue{ + "automation": { + Value: "", + IsDefault: false, + }, + }, + }, + { + Title: "tc-with-all-fields-and-custom-fields", + LegacyID: "CF-001", + Folder: []string{"custom-fields", "comprehensive"}, + Priority: "high", + Tags: []string{"custom", "comprehensive"}, + Preconditions: "Custom field test setup", + Steps: []Step{ + { + Action: "Step 1", + Expected: "Result 1", + }, + }, + Requirements: []Requirement{{Title: "CF Requirements", URL: "http://cf-req"}}, + Files: []File{ + { + ID: "cf-file-id", + Name: "cf-test.txt", + MimeType: "text/plain", + Size: 100, + URL: "http://cf-file", + }, + }, + Links: []Link{ + { + Title: "CF Link", + URL: "http://cf-link", + }, + }, + Draft: false, + CustomFields: map[string]CustomFieldValue{ + "test_env": { + Value: "development", + IsDefault: false, + }, + "automation": { + Value: "In Progress", + IsDefault: false, + }, + }, + }, +} + +const customFieldSuccessTestCasesCSV = `Folder,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,custom_field_dropdown_test_env,custom_field_dropdown_automation,custom_field_text_notes +custom-fields,standalone,tc-with-single-custom-field,,false,medium,,,,,,,,,,"{""value"":""staging"",""isDefault"":false}",, +custom-fields,standalone,tc-with-multiple-custom-fields,,false,high,"regression,smoke",,,,,,,Execute test,Test passes,"{""value"":""production"",""isDefault"":false}","{""value"":""Automated"",""isDefault"":false}","{""value"":""This is a test note with special chars: !@#$%^\u0026*()"",""isDefault"":false}" +custom-fields,standalone,tc-with-empty-custom-field-value,,false,low,,,,,,,,,,,,"{""value"":"""",""isDefault"":false}" +custom-fields,standalone,tc-with-default-custom-field,,false,medium,,,,,,,,,,,"{""value"":"""",""isDefault"":false}", +custom-fields/comprehensive,standalone,tc-with-all-fields-and-custom-fields,CF-001,false,high,"custom,comprehensive",[CF Requirements](http://cf-req),[CF Link](http://cf-link),"[{""fileName"":""cf-test.txt"",""id"":""cf-file-id"",""url"":""http://cf-file"",""mimeType"":""text/plain"",""size"":100}]",Custom field test setup,,,Step 1,Result 1,"{""value"":""development"",""isDefault"":false}","{""value"":""In Progress"",""isDefault"":false}", +` + +var customFieldFailureTestCases = []TestCase{ + { + Title: "tc-with-undefined-custom-field", + Folder: []string{"custom-fields-errors"}, + Priority: "high", + CustomFields: map[string]CustomFieldValue{ + "undefined_field": { + Value: "some value", + }, + }, + }, + { + Title: "tc-with-very-long-custom-field-value", + Folder: []string{"custom-fields-errors"}, + Priority: "medium", + CustomFields: map[string]CustomFieldValue{ + "notes": { + Value: strings.Repeat("a", 256), // Exceeds 255 char limit + }, + }, + }, +} + func TestGenerateCSVSuccess(t *testing.T) { qasCSV := NewQASphereCSV() + for _, tc := range successTestCases { err := qasCSV.AddTestCase(tc) require.NoError(t, err) @@ -271,3 +427,32 @@ func TestFailureTestCases(t *testing.T) { }) } } + +func TestCustomFieldSuccessTestCases(t *testing.T) { + qasCSV := NewQASphereCSV() + if err := qasCSV.AddCustomFields(customFields); err != nil { + t.Fatalf("Failed to add custom fields: %v", err) + } + + for _, tc := range customFieldSuccessTestCases { + err := qasCSV.AddTestCase(tc) + require.NoError(t, err) + } + actualCSV, err := qasCSV.GenerateCSV() + require.NoError(t, err) + require.Equal(t, customFieldSuccessTestCasesCSV, actualCSV) +} + +func TestCustomFieldFailureTestCases(t *testing.T) { + qasCSV := NewQASphereCSV() + if err := qasCSV.AddCustomFields(customFields); err != nil { + t.Fatalf("Failed to add custom fields: %v", err) + } + + for _, tc := range customFieldFailureTestCases { + t.Run(tc.Title, func(t *testing.T) { + err := qasCSV.AddTestCase(tc) + require.NotNil(t, err) + }) + } +} diff --git a/qascsv.go b/qascsv.go index c7d3061..553fb8c 100644 --- a/qascsv.go +++ b/qascsv.go @@ -17,9 +17,11 @@ import ( "github.com/pkg/errors" ) +// staticColumns will always be present in the CSV file +// but there can be additional columns for steps and custom fields. var staticColumns = []string{ - "Folder", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements", - "Links", "Files", "Preconditions", + "Folder", "Type", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements", + "Links", "Files", "Preconditions", "Parameter Values", "Template Suffix Params", } // Priority represents the priority of a test case in QA Sphere. @@ -32,6 +34,13 @@ const ( PriorityHigh Priority = "high" ) +type TestCaseType string + +const ( + TestCaseTypeStandalone TestCaseType = "standalone" + TestCaseTypeTemplate TestCaseType = "template" +) + // Requirement represent important requirements and reference document // associated with a test case. At least one of title/url is required. type Requirement struct { @@ -45,17 +54,17 @@ type Link struct { URL string `validate:"required,http_url,max=255"` } -// File represents an external file. +// File represents an attachment or file associated with a test case. +// These files need to be uploaded to the QA Sphere project via the API +// See API documentation for more details. +// +// https://docs.qasphere.com/api/upload_file type File struct { - // The name of the file. (required) - Name string `validate:"required" json:"file_name"` - // If the file is already uploaded on QA Sphere, then its ID. (optional) - ID string `validate:"required_without=URL" json:"id,omitempty"` - // The URL of the file. If the file is not uploaded on QA Sphere, - // the URL is required. (optional) - URL string `validate:"required_without=ID,omitempty,http_url" json:"url,omitempty"` - MimeType string `json:"mime_type"` - Size int64 `json:"size"` + Name string `validate:"required" json:"fileName"` + ID string `validate:"required" json:"id"` + URL string `validate:"required" json:"url"` + MimeType string `validate:"required" json:"mimeType"` + Size int64 `validate:"required" json:"size"` } // Step represents a single action to perform in a test case. @@ -66,16 +75,45 @@ type Step struct { Expected string } +// ParameterValue represents parameter values that you provide for template test cases. +// Template test cases are test cases where the body of the test case can contain some placeholders +// of the form ${parameter_name}. Then the users need to provide the values for these parameters +// in the form of a map. These are used to generate a filled test case which replaced the placeholders. +type ParameterValue struct { + Priority *Priority `json:"priority,omitempty" validate:"oneof=low medium high"` + Values map[string]string `json:"values" validate:"required,dive,keys,max=255,endkeys"` +} + +type CustomFieldType string + +const ( + CustomFieldTypeText CustomFieldType = "text" + CustomFieldTypeDropdown CustomFieldType = "dropdown" +) + +type CustomField struct { + SystemName string `validate:"required,max=64"` + Type CustomFieldType `validate:"required,oneof=text dropdown"` +} + +type CustomFieldValue struct { + Value string `json:"value" validate:"max=255"` + IsDefault bool `json:"isDefault" validate:"omitempty"` +} + // TestCase represents a test case in QA Sphere. type TestCase struct { // The title of the test case. (required) - Title string `validate:"required,max=255"` + Title string `validate:"required,max=511"` + // The type of the test case. (optional) + // If not specified, it defaults to "standalone". + Type TestCaseType `validate:"omitempty,oneof=standalone template"` // In case of migrating from another test management system, the // test case ID in the existing test management system. This is only // for reference. (optional) LegacyID string `validate:"max=255"` // The complete folder path to the test case. (required) - Folder []string `validate:"min=1,dive,required,max=127,excludesall=/"` + Folder []string `validate:"min=1,dive,required,max=255,excludesall=/"` // The priority of the test case. (required) Priority Priority `validate:"required,oneof=low medium high"` // The tags to assign to the test cases. This can be used to group, @@ -90,7 +128,7 @@ type TestCase struct { Steps []Step // Primary requirement or reference document associated with the // test case. (optional) - Requirement *Requirement + Requirements []Requirement `validate:"dive"` // Any other files relevant to the test case. (optional) Files []File `validate:"dive"` // Any other links relevant to the test case. (optional) @@ -99,6 +137,19 @@ type TestCase struct { // final state. The test case should later be updated as and then // published. (optional) Draft bool + // The parameter values to be used for the test case. (optional) + // This is used for template test cases where the body of the test case + // can contain some placeholders of the form ${parameter_name}. + // For each ParameterValue provided in this array, we generate a distinct filled test case + // See ParameterValue for more details. + ParameterValues []ParameterValue `validate:"dive"` + // The filled template suffix params to be used for template test cases. + // For easy identification, we add a suffix to the filled test case title + // For example, for a template with title "Template_title" and you provide SuffixParams as param1, param2 + // The generated filled test case will have title "Template_title (param1=val1, param2=val2)". + FilledTCaseTitleSuffixParams []string `validate:"dive,max=255"` + // The custom fields to be used for the test case. (optional) + CustomFields map[string]CustomFieldValue `validate:"dive,keys,max=64,endkeys,required"` } // QASphereCSV provides APIs to generate CSV that can be used to import @@ -106,6 +157,7 @@ type TestCase struct { type QASphereCSV struct { folderTCaseMap map[string][]TestCase validate *validator.Validate + customFields []CustomField numTCases int maxSteps int @@ -118,7 +170,40 @@ func NewQASphereCSV() *QASphereCSV { } } +// AddCustomField adds a custom field to the QASphereCSV. +// The custom fields need to pre-declared by using AddCustomField or AddCustomFields +func (q *QASphereCSV) AddCustomField(cf CustomField) error { + if err := q.validate.Struct(cf); err != nil { + return errors.Wrap(err, "custom field validation") + } + + // Check for duplicate custom field SystemName + for _, existingCF := range q.customFields { + if existingCF.SystemName == cf.SystemName { + return errors.Errorf("custom field with SystemName %q already exists", cf.SystemName) + } + } + + q.customFields = append(q.customFields, cf) + return nil +} + +// AddCustomFields adds multiple custom fields to the QASphereCSV. +func (q *QASphereCSV) AddCustomFields(cfs []CustomField) error { + var err error + for _, cf := range cfs { + if retErr := q.AddCustomField(cf); retErr != nil { + err = multierror.Append(err, retErr) + } + } + return err +} + func (q *QASphereCSV) AddTestCase(tc TestCase) error { + if tc.Type == TestCaseType("") { + tc.Type = TestCaseTypeStandalone + } + if err := q.validateTestCase(tc); err != nil { return errors.Wrap(err, "test case validation") } @@ -130,6 +215,11 @@ func (q *QASphereCSV) AddTestCase(tc TestCase) error { func (q *QASphereCSV) AddTestCases(tcs []TestCase) error { var err error for i, tc := range tcs { + if tc.Type == TestCaseType("") { + tc.Type = TestCaseTypeStandalone + tcs[i].Type = TestCaseTypeStandalone + } + if retErr := q.validateTestCase(tc); retErr != nil { err = multierror.Append(err, errors.Wrapf(retErr, "test case %d", i)) } @@ -168,6 +258,21 @@ func (q *QASphereCSV) WriteCSVToFile(file string) error { } func (q *QASphereCSV) validateTestCase(tc TestCase) error { + if tc.CustomFields != nil { + for systemName := range tc.CustomFields { + var found bool + for _, cf := range q.customFields { + if cf.SystemName == systemName { + found = true + break + } + } + if !found { + return errors.Errorf("custom field %s is not defined in QASphereCSV.customFields", systemName) + } + } + } + return q.validate.Struct(tc) } @@ -192,19 +297,30 @@ func (q *QASphereCSV) getFolders() []string { func (q *QASphereCSV) getCSVRows() ([][]string, error) { rows := make([][]string, 0, q.numTCases+1) - numCols := len(staticColumns) + 2*q.maxSteps + numCols := len(staticColumns) + 2*q.maxSteps + len(q.customFields) rows = append(rows, append(make([]string, 0, numCols), staticColumns...)) for i := 0; i < q.maxSteps; i++ { rows[0] = append(rows[0], fmt.Sprintf("Step %d", i+1), fmt.Sprintf("Expected %d", i+1)) } + customFieldsMap := make(map[string]int) + for i, cf := range q.customFields { + customFieldHeader := fmt.Sprintf("custom_field_%s_%s", cf.Type, cf.SystemName) + rows[0] = append(rows[0], customFieldHeader) + customFieldsMap[cf.SystemName] = i + } + folders := q.getFolders() for _, f := range folders { for _, tc := range q.folderTCaseMap[f] { - var requirement string - if tc.Requirement != nil { - requirement = fmt.Sprintf("[%s](%s)", tc.Requirement.Title, tc.Requirement.URL) + var requirements []string + for _, req := range tc.Requirements { + if req.Title == "" && req.URL == "" { + continue + } + + requirements = append(requirements, fmt.Sprintf("[%s](%s)", req.Title, req.URL)) } var links []string @@ -221,10 +337,20 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { files = string(filesb) } + var parameterValues string + if len(tc.ParameterValues) > 0 { + parameterValuesb, err := json.Marshal(tc.ParameterValues) + if err != nil { + return nil, errors.Wrap(err, "json marshal parameter values") + } + parameterValues = string(parameterValuesb) + } + row := make([]string, 0, numCols) - row = append(row, f, tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft), - string(tc.Priority), strings.Join(tc.Tags, ","), requirement, - strings.Join(links, ","), files, tc.Preconditions) + row = append(row, f, string(tc.Type), tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft), + string(tc.Priority), strings.Join(tc.Tags, ","), strings.Join(requirements, ","), + strings.Join(links, ","), files, tc.Preconditions, parameterValues, + strings.Join(tc.FilledTCaseTitleSuffixParams, ",")) numSteps := len(tc.Steps) for i := 0; i < q.maxSteps; i++ { @@ -235,6 +361,17 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { } } + customFieldCols := make([]string, len(customFieldsMap)) + for systemName, cfValue := range tc.CustomFields { + cfValueJSON, err := json.Marshal(cfValue) + if err != nil { + return nil, errors.Wrap(err, "json marshal custom field value") + } + + customFieldCols[customFieldsMap[systemName]] = string(cfValueJSON) + } + row = append(row, customFieldCols...) + rows = append(rows, row) } }