diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b220f4b..ec9eac5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,5 +41,8 @@ jobs: version: v1.62.2 args: --verbose --timeout=3m + - name: Build examples + run: make build-examples + - name: Test run: make test diff --git a/.gitignore b/.gitignore index 6ecd0fc..e7bea99 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # .env files .env + +# Generated CSV files +*.csv diff --git a/Makefile b/Makefile index 1555ea4..b9e1ee1 100644 --- a/Makefile +++ b/Makefile @@ -12,4 +12,7 @@ lint: linters-install test: $(GOCMD) test -v -cover -race ./... -.PHONY: test lint linters-install +build-examples: + $(GOCMD) build ./examples/... + +.PHONY: test lint linters-install build-examples diff --git a/examples/basic/main.go b/examples/basic/main.go index 8448993..0f876c6 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -14,7 +14,7 @@ func main() { // Add a single test case if err := qasCSV.AddTestCase(qascsv.TestCase{ Title: "Changing to corresponding cursor after hovering the element", - Folder: []string{"Bistro Delivery", "About Us"}, + FolderPath: []string{"Bistro Delivery", "About Us"}, Priority: "low", Tags: []string{"About Us", "Checklist", "REQ-4", "UI"}, Preconditions: "The \"About Us\" page is opened", @@ -28,7 +28,7 @@ func main() { // Add multiple test cases if err := qasCSV.AddTestCases([]qascsv.TestCase{{ Title: "Cart should be cleared after making the checkout", - Folder: []string{"Bistro Delivery", "Cart", "Checkout"}, + FolderPath: []string{"Bistro Delivery", "Cart", "Checkout"}, Priority: "medium", Tags: []string{"Cart", "checkout", "REQ-6", "Functional"}, Preconditions: "1. Order is placed\n2. Successful message is shown", @@ -41,7 +41,7 @@ func main() { }}, }, { Title: "Changing to corresponding cursor after hovering the element", - Folder: []string{"Bistro Delivery", "Cart", "Checkout"}, + FolderPath: []string{"Bistro Delivery", "Cart", "Checkout"}, Priority: "low", Tags: []string{"Checklist", "REQ-6", "UI", "checkout"}, Preconditions: "The \"Checkout\" page is opened", diff --git a/examples/functional/main.go b/examples/functional/main.go new file mode 100644 index 0000000..b19d413 --- /dev/null +++ b/examples/functional/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "log" + + qascsv "github.com/hypersequent/qasphere-csv" +) + +func main() { + if err := generateFolderComments(); err != nil { + log.Fatal(err) + } + if err := generateEmptyFolders(); err != nil { + log.Fatal(err) + } + if err := generateEscaping(); err != nil { + log.Fatal(err) + } +} + +func generateFolderComments() error { + q := qascsv.NewQASphereCSV() + + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Commented Folder"}, + Comment: "This folder has a comment but also contains test cases", + }); err != nil { + return err + } + if err := q.AddTestCase(qascsv.TestCase{ + Title: "Test inside commented folder", + FolderPath: []string{"Commented Folder"}, + Priority: qascsv.PriorityHigh, + }); err != nil { + return err + } + + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Another Folder"}, + Comment: "Standalone comment on a folder with no test cases", + }); err != nil { + return err + } + + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Parent", "Child With Comment"}, + Comment: "Nested folder comment", + }); err != nil { + return err + } + if err := q.AddTestCase(qascsv.TestCase{ + Title: "Test in parent folder", + FolderPath: []string{"Parent"}, + Priority: qascsv.PriorityMedium, + }); err != nil { + return err + } + + if err := q.WriteCSVToFile("folder_comments.csv"); err != nil { + return err + } + log.Println("wrote folder_comments.csv") + return nil +} + +func generateEmptyFolders() error { + q := qascsv.NewQASphereCSV() + + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Empty Root Folder"}, + }); err != nil { + return err + } + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Parent", "Empty Child"}, + }); err != nil { + return err + } + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Parent", "Another Empty Child"}, + }); err != nil { + return err + } + if err := q.AddTestCase(qascsv.TestCase{ + Title: "Test in parent alongside empty children", + FolderPath: []string{"Parent"}, + Priority: qascsv.PriorityLow, + }); err != nil { + return err + } + + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Deep", "Nested", "Empty"}, + }); err != nil { + return err + } + + if err := q.WriteCSVToFile("empty_folders.csv"); err != nil { + return err + } + log.Println("wrote empty_folders.csv") + return nil +} + +func generateEscaping() error { + q := qascsv.NewQASphereCSV() + + if err := q.AddTestCase(qascsv.TestCase{ + Title: "Test in folder with slash", + FolderPath: []string{"Features/Bugs", "Login"}, + Priority: qascsv.PriorityHigh, + }); err != nil { + return err + } + if err := q.AddTestCase(qascsv.TestCase{ + Title: "Test in folder with multiple slashes", + FolderPath: []string{"A/B/C", "D/E"}, + Priority: qascsv.PriorityMedium, + }); err != nil { + return err + } + + if err := q.AddFolder(qascsv.Folder{ + FolderPath: []string{"Empty/Slash/Folder"}, + Comment: "This empty folder name contains slashes", + }); err != nil { + return err + } + + if err := q.AddTestCase(qascsv.TestCase{ + Title: "Test in normal folder for comparison", + FolderPath: []string{"Normal Folder", "Subfolder"}, + Priority: qascsv.PriorityLow, + }); err != nil { + return err + } + + if err := q.WriteCSVToFile("escaping.csv"); err != nil { + return err + } + log.Println("wrote escaping.csv") + return nil +} diff --git a/qacsv_test.go b/qacsv_test.go index 3764c34..5b80714 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -13,7 +13,7 @@ var successTestCases = []TestCase{ { Title: "tc-with-all-fields", LegacyID: "legacy-id", - Folder: []string{"root", "child"}, + FolderPath: []string{"root", "child"}, Priority: "high", Tags: []string{"tag1", "tag2"}, Preconditions: "preconditions", @@ -55,14 +55,14 @@ var successTestCases = []TestCase{ Draft: false, }, { - Title: "tc-with-minimal-fields", - Folder: []string{"root"}, - Priority: "high", + Title: "tc-with-minimal-fields", + FolderPath: []string{"root"}, + Priority: "high", }, { Title: "tc-with-special-chars.,<>/@$%\"\"''*&()[]{}+-`!~;", LegacyID: "legacy-id", - Folder: []string{"root", "child"}, + FolderPath: []string{"root", "child"}, Priority: "high", Tags: []string{"tag1.,<>/@$%\"\"''*&()[]{}+-`!~;"}, Preconditions: "preconditions.,<>/@$%\"\"''*&()[]{}+-`!~;", @@ -92,7 +92,7 @@ var successTestCases = []TestCase{ }, { Title: "tc-with-partial-fields", - Folder: []string{"root"}, + FolderPath: []string{"root"}, Priority: "low", Tags: []string{}, Preconditions: "", @@ -125,82 +125,82 @@ var successTestCases = []TestCase{ }, } -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]!~;",, +const successTestCasesCSV = `Folder,Folder Comment,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/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]!~;",, +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 ` var failureTestCases = []TestCase{ { - Title: "", - Folder: []string{"root"}, - Priority: "high", + Title: "", + FolderPath: []string{"root"}, + Priority: "high", }, { - Title: strings.Repeat("a", 512), // Exceeds 511 char limit - Folder: []string{"root"}, - Priority: "high", + Title: strings.Repeat("a", 512), // Exceeds 511 char limit + FolderPath: []string{"root"}, + Priority: "high", }, { - Title: "no folder", - Folder: []string{}, - Priority: "high", + Title: "no folder", + FolderPath: []string{}, + Priority: "high", }, { - Title: "folder with empty title", - Folder: []string{"root/child"}, - Priority: "high", + Title: "folder with empty segment", + FolderPath: []string{"root", ""}, + Priority: "high", }, { - Title: "folder title with slash", - Folder: []string{"root/child"}, - Priority: "high", + Title: "folder segment ending with backslash", + FolderPath: []string{"root\\"}, + Priority: "high", }, { - Title: "wrong priority", - Folder: []string{"root"}, - Priority: "very high", + Title: "wrong priority", + FolderPath: []string{"root"}, + Priority: "very high", }, { - Title: "empty tag", - Folder: []string{"root"}, - Priority: "high", - Tags: []string{""}, + Title: "empty tag", + FolderPath: []string{"root"}, + Priority: "high", + Tags: []string{""}, }, { - Title: "long tag", - Folder: []string{"root"}, - Priority: "high", - Tags: []string{strings.Repeat("a", 256)}, // Exceeds 255 char limit + Title: "long tag", + FolderPath: []string{"root"}, + Priority: "high", + Tags: []string{strings.Repeat("a", 256)}, // Exceeds 255 char limit }, { Title: "requirement without title and url", - Folder: []string{"root"}, + FolderPath: []string{"root"}, Priority: "high", Requirements: []Requirement{{}}, }, { Title: "requirement with invalid url", - Folder: []string{"root"}, + FolderPath: []string{"root"}, Priority: "high", Requirements: []Requirement{{URL: "ftp://req1"}}, }, { - Title: "link without title and url", - Folder: []string{"root"}, - Priority: "high", - Links: []Link{{}}, + Title: "link without title and url", + FolderPath: []string{"root"}, + Priority: "high", + Links: []Link{{}}, }, { - Title: "link with no url", - Folder: []string{"root"}, - Priority: "high", - Links: []Link{{Title: "link-1"}}, + Title: "link with no url", + FolderPath: []string{"root"}, + Priority: "high", + Links: []Link{{Title: "link-1"}}, }, { - Title: "link with no title", - Folder: []string{"root"}, - Priority: "high", - Links: []Link{{URL: "http://link1"}}, + Title: "link with no title", + FolderPath: []string{"root"}, + Priority: "high", + Links: []Link{{URL: "http://link1"}}, }, { - Title: "link with invalid url", - Folder: []string{"root"}, - Priority: "high", - Links: []Link{{Title: "link-1", URL: "ftp://link1"}}, + Title: "link with invalid url", + FolderPath: []string{"root"}, + Priority: "high", + Links: []Link{{Title: "link-1", URL: "ftp://link1"}}, }, { - Title: "file without name", - Folder: []string{"root"}, - Priority: "high", + Title: "file without name", + FolderPath: []string{"root"}, + Priority: "high", Files: []File{ { MimeType: "text/csv", @@ -209,9 +209,9 @@ var failureTestCases = []TestCase{ }, }, }, { - Title: "file without id and url", - Folder: []string{"root"}, - Priority: "high", + Title: "file without id and url", + FolderPath: []string{"root"}, + Priority: "high", Files: []File{ { Name: "file-1.csv", @@ -220,9 +220,9 @@ var failureTestCases = []TestCase{ }, }, }, { - Title: "file with invalid url", - Folder: []string{"root"}, - Priority: "high", + Title: "file with invalid url", + FolderPath: []string{"root"}, + Priority: "high", Files: []File{ { Name: "file-1.csv", @@ -252,9 +252,9 @@ var customFields = []CustomField{ var customFieldSuccessTestCases = []TestCase{ { - Title: "tc-with-single-custom-field", - Folder: []string{"custom-fields"}, - Priority: "medium", + Title: "tc-with-single-custom-field", + FolderPath: []string{"custom-fields"}, + Priority: "medium", CustomFields: map[string]CustomFieldValue{ "test_env": { Value: "staging", @@ -263,10 +263,10 @@ var customFieldSuccessTestCases = []TestCase{ }, }, { - Title: "tc-with-multiple-custom-fields", - Folder: []string{"custom-fields"}, - Priority: "high", - Tags: []string{"regression", "smoke"}, + Title: "tc-with-multiple-custom-fields", + FolderPath: []string{"custom-fields"}, + Priority: "high", + Tags: []string{"regression", "smoke"}, CustomFields: map[string]CustomFieldValue{ "test_env": { Value: "production", @@ -289,9 +289,9 @@ var customFieldSuccessTestCases = []TestCase{ }, }, { - Title: "tc-with-empty-custom-field-value", - Folder: []string{"custom-fields"}, - Priority: "low", + Title: "tc-with-empty-custom-field-value", + FolderPath: []string{"custom-fields"}, + Priority: "low", CustomFields: map[string]CustomFieldValue{ "notes": { Value: "", @@ -300,9 +300,9 @@ var customFieldSuccessTestCases = []TestCase{ }, }, { - Title: "tc-with-default-custom-field", - Folder: []string{"custom-fields"}, - Priority: "medium", + Title: "tc-with-default-custom-field", + FolderPath: []string{"custom-fields"}, + Priority: "medium", CustomFields: map[string]CustomFieldValue{ "automation": { Value: "", @@ -313,7 +313,7 @@ var customFieldSuccessTestCases = []TestCase{ { Title: "tc-with-all-fields-and-custom-fields", LegacyID: "CF-001", - Folder: []string{"custom-fields", "comprehensive"}, + FolderPath: []string{"custom-fields", "comprehensive"}, Priority: "high", Tags: []string{"custom", "comprehensive"}, Preconditions: "Custom field test setup", @@ -353,19 +353,19 @@ var customFieldSuccessTestCases = []TestCase{ }, } -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}", +const customFieldSuccessTestCasesCSV = `Folder,Folder Comment,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: !@#$%^&*()"",""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", + Title: "tc-with-undefined-custom-field", + FolderPath: []string{"custom-fields-errors"}, + Priority: "high", CustomFields: map[string]CustomFieldValue{ "undefined_field": { Value: "some value", @@ -373,9 +373,9 @@ var customFieldFailureTestCases = []TestCase{ }, }, { - Title: "tc-with-very-long-custom-field-value", - Folder: []string{"custom-fields-errors"}, - Priority: "medium", + Title: "tc-with-very-long-custom-field-value", + FolderPath: []string{"custom-fields-errors"}, + Priority: "medium", CustomFields: map[string]CustomFieldValue{ "notes": { Value: strings.Repeat("a", 256), // Exceeds 255 char limit @@ -456,3 +456,154 @@ func TestCustomFieldFailureTestCases(t *testing.T) { }) } } + +func TestFolderSlashEscaping(t *testing.T) { + qasCSV := NewQASphereCSV() + + err := qasCSV.AddTestCase(TestCase{ + Title: "tc-in-slash-folder", + FolderPath: []string{"root/parent", "child/leaf"}, + Priority: "high", + }) + require.NoError(t, err) + + csv, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params +root\/parent/child\/leaf,,standalone,tc-in-slash-folder,,false,high,,,,,,, +` + require.Equal(t, expected, csv) +} + +func TestFolderSegmentEndingWithBackslash(t *testing.T) { + qasCSV := NewQASphereCSV() + + err := qasCSV.AddTestCase(TestCase{ + Title: "tc-bad-backslash", + FolderPath: []string{"root\\"}, + Priority: "high", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "must not end with '\\'") +} + +func TestAddFolderEmpty(t *testing.T) { + qasCSV := NewQASphereCSV() + + err := qasCSV.AddFolder(Folder{FolderPath: []string{"empty-folder"}}) + require.NoError(t, err) + + csv, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params +empty-folder,,,,,,,,,,,,, +` + require.Equal(t, expected, csv) +} + +func TestAddFolderWithComment(t *testing.T) { + qasCSV := NewQASphereCSV() + + err := qasCSV.AddFolder(Folder{ + FolderPath: []string{"commented-folder"}, + Comment: "This is a folder comment", + }) + require.NoError(t, err) + + csv, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params +commented-folder,This is a folder comment,,,,,,,,,,,, +` + require.Equal(t, expected, csv) +} + +func TestAddFolderWithCommentAndTestCases(t *testing.T) { + qasCSV := NewQASphereCSV() + + err := qasCSV.AddFolder(Folder{ + FolderPath: []string{"my-folder"}, + Comment: "Folder description", + }) + require.NoError(t, err) + + err = qasCSV.AddTestCase(TestCase{ + Title: "tc-in-commented-folder", + FolderPath: []string{"my-folder"}, + Priority: "high", + }) + require.NoError(t, err) + + csv, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params +my-folder,Folder description,,,,,,,,,,,, +my-folder,,standalone,tc-in-commented-folder,,false,high,,,,,,, +` + require.Equal(t, expected, csv) +} + +func TestAddFolderValidation(t *testing.T) { + t.Run("empty folder path", func(t *testing.T) { + qasCSV := NewQASphereCSV() + err := qasCSV.AddFolder(Folder{Comment: "comment"}) + require.Error(t, err) + }) + + t.Run("empty folder segment", func(t *testing.T) { + qasCSV := NewQASphereCSV() + err := qasCSV.AddFolder(Folder{FolderPath: []string{"root", ""}, Comment: "comment"}) + require.Error(t, err) + }) + + t.Run("folder segment ending with backslash", func(t *testing.T) { + qasCSV := NewQASphereCSV() + err := qasCSV.AddFolder(Folder{FolderPath: []string{"root\\"}, Comment: "comment"}) + require.Error(t, err) + require.Contains(t, err.Error(), "must not end with '\\'") + }) + + t.Run("duplicate folder", func(t *testing.T) { + qasCSV := NewQASphereCSV() + err := qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "comment"}) + require.NoError(t, err) + err = qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "another comment"}) + require.Error(t, err) + require.Contains(t, err.Error(), "already exists") + }) + + t.Run("folder already has test cases", func(t *testing.T) { + qasCSV := NewQASphereCSV() + err := qasCSV.AddTestCase(TestCase{ + Title: "tc", + FolderPath: []string{"root"}, + Priority: "high", + }) + require.NoError(t, err) + err = qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "comment"}) + require.Error(t, err) + require.Contains(t, err.Error(), "already exists") + }) +} + +func TestAddFolderWithSlashInName(t *testing.T) { + qasCSV := NewQASphereCSV() + + err := qasCSV.AddFolder(Folder{ + FolderPath: []string{"folder/with/slashes", "child"}, + Comment: "slash comment", + }) + require.NoError(t, err) + + csv, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params +folder\/with\/slashes/child,slash comment,,,,,,,,,,,, +` + require.Equal(t, expected, csv) +} diff --git a/qascsv.go b/qascsv.go index 553fb8c..4dd68fc 100644 --- a/qascsv.go +++ b/qascsv.go @@ -3,12 +3,12 @@ package qascsv import ( + "bytes" "encoding/csv" "encoding/json" "fmt" "io" "os" - "slices" "strconv" "strings" @@ -20,7 +20,7 @@ import ( // 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", + "Folder", "Folder Comment", "Type", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements", "Links", "Files", "Preconditions", "Parameter Values", "Template Suffix Params", } @@ -101,6 +101,14 @@ type CustomFieldValue struct { IsDefault bool `json:"isDefault" validate:"omitempty"` } +// Folder represents a folder to be created in QA Sphere. +type Folder struct { + // The folder path segments. (required) + FolderPath []string `validate:"min=1,dive,required,max=255"` + // An optional comment for the folder. + Comment string +} + // TestCase represents a test case in QA Sphere. type TestCase struct { // The title of the test case. (required) @@ -113,7 +121,7 @@ type TestCase struct { // 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=255,excludesall=/"` + FolderPath []string `validate:"min=1,dive,required,max=255"` // 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, @@ -155,9 +163,11 @@ type TestCase struct { // QASphereCSV provides APIs to generate CSV that can be used to import // test cases in a project on QA Sphere. type QASphereCSV struct { - folderTCaseMap map[string][]TestCase - validate *validator.Validate - customFields []CustomField + folderTCaseMap map[string][]TestCase + folderCommentMap map[string]string + folderOrder []string + validate *validator.Validate + customFields []CustomField numTCases int maxSteps int @@ -165,8 +175,9 @@ type QASphereCSV struct { func NewQASphereCSV() *QASphereCSV { return &QASphereCSV{ - folderTCaseMap: make(map[string][]TestCase), - validate: validator.New(), + folderTCaseMap: make(map[string][]TestCase), + folderCommentMap: make(map[string]string), + validate: validator.New(), } } @@ -235,6 +246,27 @@ func (q *QASphereCSV) AddTestCases(tcs []TestCase) error { return nil } +func (q *QASphereCSV) AddFolder(f Folder) error { + if err := q.validate.Struct(f); err != nil { + return errors.Wrap(err, "folder validation") + } + if err := validateFolderSegments(f.FolderPath); err != nil { + return err + } + + folderPath := escapeFolderPath(f.FolderPath) + if _, exists := q.folderTCaseMap[folderPath]; exists { + return errors.Errorf("folder %q already exists", folderPath) + } + + q.folderOrder = append(q.folderOrder, folderPath) + q.folderTCaseMap[folderPath] = nil + if f.Comment != "" { + q.folderCommentMap[folderPath] = f.Comment + } + return nil +} + func (q *QASphereCSV) GenerateCSV() (string, error) { w := &strings.Builder{} if err := q.writeCSV(w); err != nil { @@ -257,7 +289,28 @@ func (q *QASphereCSV) WriteCSVToFile(file string) error { return nil } +func validateFolderSegments(segments []string) error { + for _, seg := range segments { + if strings.HasSuffix(seg, `\`) { + return errors.Errorf("folder segment %q must not end with '\\'", seg) + } + } + return nil +} + +func escapeFolderPath(segments []string) string { + escaped := make([]string, len(segments)) + for i, seg := range segments { + escaped[i] = strings.ReplaceAll(seg, "/", `\/`) + } + return strings.Join(escaped, "/") +} + func (q *QASphereCSV) validateTestCase(tc TestCase) error { + if err := validateFolderSegments(tc.FolderPath); err != nil { + return err + } + if tc.CustomFields != nil { for systemName := range tc.CustomFields { var found bool @@ -277,7 +330,10 @@ func (q *QASphereCSV) validateTestCase(tc TestCase) error { } func (q *QASphereCSV) addTCase(tc TestCase) { - folderPath := strings.Join(tc.Folder, "/") + folderPath := escapeFolderPath(tc.FolderPath) + if _, exists := q.folderTCaseMap[folderPath]; !exists { + q.folderOrder = append(q.folderOrder, folderPath) + } q.folderTCaseMap[folderPath] = append(q.folderTCaseMap[folderPath], tc) q.numTCases++ @@ -287,12 +343,17 @@ func (q *QASphereCSV) addTCase(tc TestCase) { } func (q *QASphereCSV) getFolders() []string { - var folders []string - for folder := range q.folderTCaseMap { - folders = append(folders, folder) + return q.folderOrder +} + +func jsonMarshal(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err } - slices.Sort(folders) - return folders + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil } func (q *QASphereCSV) getCSVRows() ([][]string, error) { @@ -313,7 +374,18 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { folders := q.getFolders() for _, f := range folders { - for _, tc := range q.folderTCaseMap[f] { + tcs := q.folderTCaseMap[f] + comment := q.folderCommentMap[f] + + // Write a folder-only row if the folder is empty or has a comment + if len(tcs) == 0 || comment != "" { + row := make([]string, numCols) + row[0] = f + row[1] = comment + rows = append(rows, row) + } + + for _, tc := range tcs { var requirements []string for _, req := range tc.Requirements { if req.Title == "" && req.URL == "" { @@ -330,7 +402,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { var files string if len(tc.Files) > 0 { - filesb, err := json.Marshal(tc.Files) + filesb, err := jsonMarshal(tc.Files) if err != nil { return nil, errors.Wrap(err, "json marshal files") } @@ -339,7 +411,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { var parameterValues string if len(tc.ParameterValues) > 0 { - parameterValuesb, err := json.Marshal(tc.ParameterValues) + parameterValuesb, err := jsonMarshal(tc.ParameterValues) if err != nil { return nil, errors.Wrap(err, "json marshal parameter values") } @@ -347,7 +419,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), + 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, ",")) @@ -363,7 +435,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { customFieldCols := make([]string, len(customFieldsMap)) for systemName, cfValue := range tc.CustomFields { - cfValueJSON, err := json.Marshal(cfValue) + cfValueJSON, err := jsonMarshal(cfValue) if err != nil { return nil, errors.Wrap(err, "json marshal custom field value") }