diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2196876 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b220f4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: ['main'] + pull_request: + types: [opened, synchronize] + +jobs: + test: + name: Test + timeout-minutes: 15 + runs-on: ubuntu-latest + + env: + CI: true + + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: actions/setup-go@v5 + with: + go-version: '^1.23.4' + - run: go version + + - name: Install gofumpt + run: go install mvdan.cc/gofumpt@latest + + - name: Add gofumpt to PATH + run: echo "$GOPATH/bin" >> $GITHUB_PATH + + - name: Run gofumpt + run: diff <(echo -n) <(gofumpt -d .) + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.62.2 + args: --verbose --timeout=3m + + - name: Test + run: make test diff --git a/.gitignore b/.gitignore index 6f72f89..6ecd0fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# # Binaries for programs and plugins *.exe *.exe~ @@ -14,12 +11,5 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file +# .env files .env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1555ea4 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +GOCMD=GO111MODULE=on go + +linters-install: + @golangci-lint --version >/dev/null 2>&1 || { \ + echo "installing linting tools..."; \ + curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.62.2; \ + } + +lint: linters-install + golangci-lint run + +test: + $(GOCMD) test -v -cover -race ./... + +.PHONY: test lint linters-install diff --git a/README.md b/README.md index 1e3dd56..d2a2490 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ # qasphere-csv -Library to write QA Sphere CSV files with test cases + +The `qasphere-csv` Go library simplifies the creation of CSV files for importing test cases into the [QA Sphere](https://qasphere.com/) Test Management System. + +## Features + +- Programmatically create large projects instead of manual entries. +- Facilitate migration from older test management systems by converting exported data into QA Sphere's CSV format. +- Includes in-built validations to ensure CSV files meet QA Sphere's requirements for smooth import. + +## How to Use + +### Starting from Scratch + +Clone the repository and explore the [basic example](examples/basic/main.go). Modify the code to add your test cases and run: + +```bash +go run examples/basic/main.go +``` + +Use the `WriteCSVToFile()` method to write directly to a file. + +### Integrating into an Existing Project + +To include `qasphere-csv` in your Go project, run: + +```bash +go get github.com/hypersequent/qasphere-csv +``` + +Import the library in your Go project: + +```go +import qascsv "github.com/hypersequent/qasphere-csv" +``` + +Refer to the [basic example](examples/basic/main.go) for API usage. + +## Importing Test Cases on QA Sphere + +1. Create a new Project, if not already done. +2. Open the project from the **Dashboard** and navigate to the **Test Cases** tab. +3. Select the **Import** option from the dropdown in the top right. + +For more details, please check the [documentation](https://docs.qasphere.com/). + +## Contributing + +We welcome contributions! If you have a feature request, encounter a problem, or have questions, please [create a new issue](https://github.com/Hypersequent/qasphere-csv/issues/new/choose). You can also contribute by opening a pull request. + +Before submitting a pull request, please ensure: +1. Appropriate unit tests are added and existing tests pass - `make test` +2. Lint checks pass - `make lint` + +## License + +This library is available under the MIT License. For more details, please see the [LICENSE](license) file. diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..8448993 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "log" + + qascsv "github.com/hypersequent/qasphere-csv" +) + +func main() { + // Create a new instance of QASphereCSV + qasCSV := qascsv.NewQASphereCSV() + + // 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"}, + Priority: "low", + Tags: []string{"About Us", "Checklist", "REQ-4", "UI"}, + Preconditions: "The \"About Us\" page is opened", + Steps: []qascsv.Step{{ + Action: "Test the display across various screen sizes (desktop, tablet, mobile) to ensure that blocks and buttons adjust appropriately to different viewport widths", + }}, + }); err != nil { + log.Fatal("failed to add single test case", err) + } + + // 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"}, + Priority: "medium", + Tags: []string{"Cart", "checkout", "REQ-6", "Functional"}, + Preconditions: "1. Order is placed\n2. Successful message is shown", + Steps: []qascsv.Step{{ + Action: "Go back to the \"Main\" page", + Expected: "The \"Cart\" icon is empty", + }, { + Action: "Click the \"Cart\" icon", + Expected: "The empty state is shown in the \"Cart\" modal", + }}, + }, { + Title: "Changing to corresponding cursor after hovering the element", + Folder: []string{"Bistro Delivery", "Cart", "Checkout"}, + Priority: "low", + Tags: []string{"Checklist", "REQ-6", "UI", "checkout"}, + Preconditions: "The \"Checkout\" page is opened", + Steps: []qascsv.Step{{ + Action: "Test the display across various screen sizes (desktop, tablet, mobile) to ensure that blocks and buttons adjust appropriately to different viewport widths", + }}, + }}); err != nil { + log.Fatal("failed to add multiple test cases", err) + } + + // Generate CSV string + csvStr, err := qasCSV.GenerateCSV() + if err != nil { + log.Fatal("failed to generate CSV", err) + } + fmt.Println(csvStr) + + // We can also directly write the CSV to a file + // if err := qascsv.WriteCSVToFile("example.csv"); err != nil { + // log.Fatal("failed to write CSV to file", err) + // } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61ad0a7 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/hypersequent/qasphere-csv + +go 1.23.4 + +require ( + github.com/go-playground/validator/v10 v10.23.0 + github.com/hashicorp/go-multierror v1.1.1 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7873bea --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/qacsv_test.go b/qacsv_test.go new file mode 100644 index 0000000..8809cac --- /dev/null +++ b/qacsv_test.go @@ -0,0 +1,273 @@ +package qascsv + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +var successTestCases = []TestCase{ + { + Title: "tc-with-all-fields", + LegacyID: "legacy-id", + Folder: []string{"root", "child"}, + Priority: "high", + Tags: []string{"tag1", "tag2"}, + Preconditions: "preconditions", + Steps: []Step{ + { + Action: "action-1", + Expected: "expected-1", + }, + { + Action: "action-2", + Expected: "expected-2", + }, + }, + Requirement: &Requirement{Title: "req1", URL: "http://req1"}, + Files: []File{ + { + Name: "file-1.csv", + MimeType: "text/csv", + Size: 10, + URL: "http://file1", + }, { + Name: "file-1.csv", + ID: "file-id", + MimeType: "text/csv", + Size: 10, + }, + }, + Links: []Link{ + { + Title: "link-1", + URL: "http://link1", + }, { + Title: "link-2", + URL: "http://link2", + }, + }, + Draft: false, + }, + { + Title: "tc-with-minimal-fields", + Folder: []string{"root"}, + Priority: "high", + }, + { + Title: "tc-with-special-chars.,<>/@$%\"\"''*&()[]{}+-`!~;", + LegacyID: "legacy-id", + Folder: []string{"root", "child"}, + Priority: "high", + Tags: []string{"tag1.,<>/@$%\"\"''*&()[]{}+-`!~;"}, + Preconditions: "preconditions.,<>/@$%\"\"''*&()[]{}+-`!~;", + Steps: []Step{ + { + Action: "action.,<>/@$%\"\"''*&()[]{}+-`!~;", + Expected: "expected.,<>/@$%\"\"''*&()[]{}+-`!~;", + }, + }, + Requirement: &Requirement{Title: "req.,<>/@$%\"\"''*&()[]{}+-`!~;"}, + Files: []File{ + { + Name: "file-1.csv", + MimeType: "text/csv", + Size: 10, + URL: "http://file1", + }, + }, + Links: []Link{ + { + Title: "link-1.,<>/@$%\"\"''*&()[]{}+-`!~;", + URL: "http://link1", + }, + }, + Draft: false, + }, + { + Title: "tc-with-partial-fields", + Folder: []string{"root"}, + Priority: "low", + Tags: []string{}, + Preconditions: "", + Steps: []Step{ + { + Action: "action-1", + }, + { + Expected: "expected-2", + }, + }, + Requirement: &Requirement{URL: "http://req1"}, + Files: []File{ + { + Name: "file-1.csv", + MimeType: "text/csv", + Size: 10, + URL: "http://file1", + }, { + Name: "file-1.csv", + ID: "file-id", + MimeType: "text/csv", + Size: 10, + }, + }, + Links: []Link{}, + Draft: true, + }, +} + +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]!~;",, +` + +var failureTestCases = []TestCase{ + { + Title: "", + 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-", + Folder: []string{"root"}, + Priority: "high", + }, { + Title: "no folder", + Folder: []string{}, + Priority: "high", + }, { + Title: "folder with empty title", + Folder: []string{"root/child"}, + Priority: "high", + }, { + Title: "folder title with slash", + Folder: []string{"root/child"}, + Priority: "high", + }, { + Title: "wrong priority", + Folder: []string{"root"}, + Priority: "very high", + }, { + Title: "empty tag", + Folder: []string{"root"}, + Priority: "high", + Tags: []string{""}, + }, { + 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"}, + }, { + Title: "requirement without title and url", + Folder: []string{"root"}, + Priority: "high", + Requirement: &Requirement{}, + }, { + Title: "requirement with invalid url", + Folder: []string{"root"}, + Priority: "high", + Requirement: &Requirement{URL: "ftp://req1"}, + }, { + Title: "link without title and url", + Folder: []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 title", + Folder: []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: "file without name", + Folder: []string{"root"}, + Priority: "high", + Files: []File{ + { + MimeType: "text/csv", + Size: 10, + URL: "http://file1", + }, + }, + }, { + Title: "file without id and url", + Folder: []string{"root"}, + Priority: "high", + Files: []File{ + { + Name: "file-1.csv", + MimeType: "text/csv", + Size: 10, + }, + }, + }, { + Title: "file with invalid url", + Folder: []string{"root"}, + Priority: "high", + Files: []File{ + { + Name: "file-1.csv", + MimeType: "text/csv", + Size: 10, + URL: "ftp://file1", + }, + }, + }, +} + +func TestGenerateCSVSuccess(t *testing.T) { + qasCSV := NewQASphereCSV() + for _, tc := range successTestCases { + err := qasCSV.AddTestCase(tc) + require.NoError(t, err) + } + + actualCSV, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + require.Equal(t, strings.ReplaceAll(successTestCasesCSV, "[BACKTICK]", "`"), actualCSV) +} + +func TestWriteCSVMultipleTCasesSuccess(t *testing.T) { + tempFileName := "temp.csv" + qasCSV := NewQASphereCSV() + + err := qasCSV.AddTestCases(successTestCases) + require.NoError(t, err) + require.NoError(t, qasCSV.WriteCSVToFile(tempFileName)) + + f, err := os.Open(tempFileName) + require.NoError(t, err) + defer func() { + f.Close() + os.Remove(tempFileName) + }() + + b, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, strings.ReplaceAll(successTestCasesCSV, "[BACKTICK]", "`"), string(b)) +} + +func TestFailureTestCases(t *testing.T) { + for _, tc := range failureTestCases { + 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 new file mode 100644 index 0000000..c7d3061 --- /dev/null +++ b/qascsv.go @@ -0,0 +1,251 @@ +// Package qascsv provides APIs to generate CSV files that can be used to import +// test cases in a QA Sphere project. +package qascsv + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "slices" + "strconv" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" +) + +var staticColumns = []string{ + "Folder", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements", + "Links", "Files", "Preconditions", +} + +// Priority represents the priority of a test case in QA Sphere. +type Priority string + +// The priorities available in QA Sphere. +const ( + PriorityLow Priority = "low" + PriorityMedium Priority = "medium" + PriorityHigh Priority = "high" +) + +// Requirement represent important requirements and reference document +// associated with a test case. At least one of title/url is required. +type Requirement struct { + Title string `validate:"required_without=URL,max=255"` + URL string `validate:"required_without=Title,omitempty,http_url,max=255"` +} + +// Link represents a URL. +type Link struct { + Title string `validate:"required,max=255"` + URL string `validate:"required,http_url,max=255"` +} + +// 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"` +} + +// Step represents a single action to perform in a test case. +type Step struct { + // The action to perform. Markdown is supported. (optional) + Action string + // The expected result of the action. Markdown is supported. (optional) + Expected string +} + +// TestCase represents a test case in QA Sphere. +type TestCase struct { + // The title of the test case. (required) + Title string `validate:"required,max=255"` + // 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=/"` + // 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, + // filter or organise related test cases and also helps in creating + // test runs. (optional) + Tags []string `validate:"dive,required,max=255"` + // The preconditions (or description) for the test case. Markdown is + // supported. (optional) + Preconditions string + // The sequence of (ordered) actions to be performed while executing + // the test case. (optional) + Steps []Step + // Primary requirement or reference document associated with the + // test case. (optional) + Requirement *Requirement + // Any other files relevant to the test case. (optional) + Files []File `validate:"dive"` + // Any other links relevant to the test case. (optional) + Links []Link `validate:"dive"` + // Whether the test case is still work in progress and not in its + // final state. The test case should later be updated as and then + // published. (optional) + Draft bool +} + +// 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 + + numTCases int + maxSteps int +} + +func NewQASphereCSV() *QASphereCSV { + return &QASphereCSV{ + folderTCaseMap: make(map[string][]TestCase), + validate: validator.New(), + } +} + +func (q *QASphereCSV) AddTestCase(tc TestCase) error { + if err := q.validateTestCase(tc); err != nil { + return errors.Wrap(err, "test case validation") + } + + q.addTCase(tc) + return nil +} + +func (q *QASphereCSV) AddTestCases(tcs []TestCase) error { + var err error + for i, tc := range tcs { + if retErr := q.validateTestCase(tc); retErr != nil { + err = multierror.Append(err, errors.Wrapf(retErr, "test case %d", i)) + } + } + if err != nil { + return errors.Wrap(err, "validation") + } + + for _, tc := range tcs { + q.addTCase(tc) + } + + return nil +} + +func (q *QASphereCSV) GenerateCSV() (string, error) { + w := &strings.Builder{} + if err := q.writeCSV(w); err != nil { + return "", errors.Wrap(err, "generate csv") + } + return w.String(), nil +} + +func (q *QASphereCSV) WriteCSVToFile(file string) error { + f, err := os.Create(file) + if err != nil { + return errors.Wrap(err, "create csv") + } + defer f.Close() + + if err := q.writeCSV(f); err != nil { + return errors.Wrap(err, "write csv") + } + + return nil +} + +func (q *QASphereCSV) validateTestCase(tc TestCase) error { + return q.validate.Struct(tc) +} + +func (q *QASphereCSV) addTCase(tc TestCase) { + folderPath := strings.Join(tc.Folder, "/") + q.folderTCaseMap[folderPath] = append(q.folderTCaseMap[folderPath], tc) + + q.numTCases++ + if (len(tc.Steps)) > q.maxSteps { + q.maxSteps = len(tc.Steps) + } +} + +func (q *QASphereCSV) getFolders() []string { + var folders []string + for folder := range q.folderTCaseMap { + folders = append(folders, folder) + } + slices.Sort(folders) + return folders +} + +func (q *QASphereCSV) getCSVRows() ([][]string, error) { + rows := make([][]string, 0, q.numTCases+1) + numCols := len(staticColumns) + 2*q.maxSteps + + 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)) + } + + 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 links []string + for _, link := range tc.Links { + links = append(links, fmt.Sprintf("[%s](%s)", link.Title, link.URL)) + } + + var files string + if len(tc.Files) > 0 { + filesb, err := json.Marshal(tc.Files) + if err != nil { + return nil, errors.Wrap(err, "json marshal files") + } + files = string(filesb) + } + + 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) + + numSteps := len(tc.Steps) + for i := 0; i < q.maxSteps; i++ { + if i < numSteps { + row = append(row, tc.Steps[i].Action, tc.Steps[i].Expected) + } else { + row = append(row, "", "") + } + } + + rows = append(rows, row) + } + } + + return rows, nil +} + +func (q *QASphereCSV) writeCSV(w io.Writer) error { + rows, err := q.getCSVRows() + if err != nil { + return errors.Wrap(err, "get csv rows") + } + return csv.NewWriter(w).WriteAll(rows) +}