From b7035288958923ac152f7efd674d8342d6fd7041 Mon Sep 17 00:00:00 2001 From: Satvik Choudhary Date: Sat, 7 Jun 2025 18:34:47 +0530 Subject: [PATCH 1/5] update qascsv - increase folder length, tcase title length - add type (standalone, template) - add custom fields support --- qacsv_test.go | 199 ++++++++++++++++++++++++++++++++++++++++++++++++-- qascsv.go | 138 ++++++++++++++++++++++++++++++---- 2 files changed, 314 insertions(+), 23 deletions(-) diff --git a/qacsv_test.go b/qacsv_test.go index 8809cac..e1cfe6d 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -30,6 +30,7 @@ var successTestCases = []TestCase{ Requirement: &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, }, @@ -73,6 +75,7 @@ var successTestCases = []TestCase{ Requirement: &Requirement{Title: "req.,<>/@$%\"\"''*&()[]{}+-`!~;"}, Files: []File{ { + ID: "file-id", Name: "file-1.csv", MimeType: "text/csv", Size: 10, @@ -104,6 +107,7 @@ var successTestCases = []TestCase{ Requirement: &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,7 +166,7 @@ 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"}, @@ -229,6 +234,156 @@ 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", + }, + }, + Requirement: &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 { @@ -271,3 +426,33 @@ 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) { + qasCSV := NewQASphereCSV() + err := qasCSV.AddTestCase(tc) + require.NotNil(t, err) + }) + } +} diff --git a/qascsv.go b/qascsv.go index c7d3061..2589e74 100644 --- a/qascsv.go +++ b/qascsv.go @@ -18,8 +18,8 @@ import ( ) 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 +32,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 { @@ -47,15 +54,11 @@ type Link struct { // File represents an external 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 +69,41 @@ type Step struct { Expected string } +// ParameterValue represents a parameter value to be used for the test case +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) + 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, @@ -99,6 +127,12 @@ 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) + ParameterValues []ParameterValue `validate:"dive"` + // The filled template suffix params to be used for the test case. (optional) + 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 +140,7 @@ type TestCase struct { type QASphereCSV struct { folderTCaseMap map[string][]TestCase validate *validator.Validate + customFields []CustomField numTCases int maxSteps int @@ -118,7 +153,30 @@ func NewQASphereCSV() *QASphereCSV { } } +func (q *QASphereCSV) AddCustomField(cf CustomField) error { + if err := q.validate.Struct(cf); err != nil { + return errors.Wrap(err, "custom field validation") + } + + q.customFields = append(q.customFields, cf) + return nil +} + +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 +188,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 +231,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,13 +270,20 @@ 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] { @@ -221,10 +306,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), + row = append(row, f, string(tc.Type), tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft), string(tc.Priority), strings.Join(tc.Tags, ","), requirement, - strings.Join(links, ","), files, tc.Preconditions) + strings.Join(links, ","), files, tc.Preconditions, parameterValues, + strings.Join(tc.FilledTCaseTitleSuffixParams, ",")) numSteps := len(tc.Steps) for i := 0; i < q.maxSteps; i++ { @@ -235,6 +330,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) } } From 460ebe848fae02a9795b8eb954b9e370826f9633 Mon Sep 17 00:00:00 2001 From: Satvik Choudhary Date: Mon, 9 Jun 2025 15:34:40 +0530 Subject: [PATCH 2/5] fix - add custom field duplicate check - allow multiple requirements (backend already supports) --- qacsv_test.go | 24 ++++++++++++------------ qascsv.go | 21 ++++++++++++++++----- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/qacsv_test.go b/qacsv_test.go index e1cfe6d..4becd02 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -27,7 +27,7 @@ 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", @@ -72,7 +72,7 @@ var successTestCases = []TestCase{ Expected: "expected.,<>/@$%\"\"''*&()[]{}+-`!~;", }, }, - Requirement: &Requirement{Title: "req.,<>/@$%\"\"''*&()[]{}+-`!~;"}, + Requirements: []Requirement{{Title: "req.,<>/@$%\"\"''*&()[]{}+-`!~;"}}, Files: []File{ { ID: "file-id", @@ -104,7 +104,7 @@ var successTestCases = []TestCase{ Expected: "expected-2", }, }, - Requirement: &Requirement{URL: "http://req1"}, + Requirements: []Requirement{{URL: "http://req1"}}, Files: []File{ { ID: "file-id", @@ -168,15 +168,15 @@ var failureTestCases = []TestCase{ Priority: "high", 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"}, @@ -323,7 +323,7 @@ var customFieldSuccessTestCases = []TestCase{ Expected: "Result 1", }, }, - Requirement: &Requirement{Title: "CF Requirements", URL: "http://cf-req"}, + Requirements: []Requirement{{Title: "CF Requirements", URL: "http://cf-req"}}, Files: []File{ { ID: "cf-file-id", diff --git a/qascsv.go b/qascsv.go index 2589e74..c9aeac9 100644 --- a/qascsv.go +++ b/qascsv.go @@ -118,7 +118,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) @@ -158,6 +158,13 @@ func (q *QASphereCSV) AddCustomField(cf CustomField) error { 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 } @@ -287,9 +294,13 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { 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 @@ -317,7 +328,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { row := make([]string, 0, numCols) row = append(row, f, string(tc.Type), tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft), - string(tc.Priority), strings.Join(tc.Tags, ","), requirement, + string(tc.Priority), strings.Join(tc.Tags, ","), strings.Join(requirements, ","), strings.Join(links, ","), files, tc.Preconditions, parameterValues, strings.Join(tc.FilledTCaseTitleSuffixParams, ",")) From dde6370a5a3e83ac226a500bdbbd4db94fdf8f35 Mon Sep 17 00:00:00 2001 From: Satvik Choudhary Date: Mon, 9 Jun 2025 15:38:54 +0530 Subject: [PATCH 3/5] fix potential false negative test case --- qacsv_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qacsv_test.go b/qacsv_test.go index 4becd02..3764c34 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -386,6 +386,7 @@ var customFieldFailureTestCases = []TestCase{ func TestGenerateCSVSuccess(t *testing.T) { qasCSV := NewQASphereCSV() + for _, tc := range successTestCases { err := qasCSV.AddTestCase(tc) require.NoError(t, err) @@ -450,7 +451,6 @@ func TestCustomFieldFailureTestCases(t *testing.T) { for _, tc := range customFieldFailureTestCases { t.Run(tc.Title, func(t *testing.T) { - qasCSV := NewQASphereCSV() err := qasCSV.AddTestCase(tc) require.NotNil(t, err) }) From cadc895090bc96d7ec5881c5d58e80d732f22ca2 Mon Sep 17 00:00:00 2001 From: Satvik Choudhary Date: Mon, 9 Jun 2025 19:46:54 +0530 Subject: [PATCH 4/5] add more comments --- qascsv.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/qascsv.go b/qascsv.go index c9aeac9..0d5411b 100644 --- a/qascsv.go +++ b/qascsv.go @@ -17,6 +17,8 @@ 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", "Type", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements", "Links", "Files", "Preconditions", "Parameter Values", "Template Suffix Params", @@ -52,7 +54,11 @@ 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 { Name string `validate:"required" json:"fileName"` ID string `validate:"required" json:"id"` @@ -69,7 +75,10 @@ type Step struct { Expected string } -// ParameterValue represents a parameter value to be used for the test case +// 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"` @@ -97,6 +106,7 @@ type TestCase struct { // The title of the test case. (required) 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 @@ -128,8 +138,15 @@ type TestCase struct { // 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 the test case. (optional) + // 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"` @@ -153,6 +170,8 @@ 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") @@ -169,6 +188,7 @@ func (q *QASphereCSV) AddCustomField(cf CustomField) error { return nil } +// AddCustomFields adds multiple custom fields to the QASphereCSV. func (q *QASphereCSV) AddCustomFields(cfs []CustomField) error { var err error for _, cf := range cfs { @@ -178,7 +198,6 @@ func (q *QASphereCSV) AddCustomFields(cfs []CustomField) error { } return err } - func (q *QASphereCSV) AddTestCase(tc TestCase) error { if tc.Type == TestCaseType("") { tc.Type = TestCaseTypeStandalone From ef920a097ddde17487922dfca8428962031e058d Mon Sep 17 00:00:00 2001 From: Satvik Choudhary Date: Mon, 9 Jun 2025 19:50:24 +0530 Subject: [PATCH 5/5] fix gofumpt --- qascsv.go | 1 + 1 file changed, 1 insertion(+) diff --git a/qascsv.go b/qascsv.go index 0d5411b..553fb8c 100644 --- a/qascsv.go +++ b/qascsv.go @@ -198,6 +198,7 @@ func (q *QASphereCSV) AddCustomFields(cfs []CustomField) error { } return err } + func (q *QASphereCSV) AddTestCase(tc TestCase) error { if tc.Type == TestCaseType("") { tc.Type = TestCaseTypeStandalone