diff --git a/cmd/cmd.go b/cmd/cmd.go index 16df6b9a..c1c3cea3 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -48,6 +48,7 @@ func NewCommad() *cobra.Command { command.AddCommand(NewContextCommand(&clientOpts)) command.AddCommand(NewLoginCommand(&clientOpts)) command.AddCommand(NewLogoutCommand(&clientOpts)) + command.AddCommand(NewTestsCommand(&clientOpts)) defaultLocalConfigPath, err := config.DefaultLocalConfigPath() errors.CheckError(err) diff --git a/cmd/tests.go b/cmd/tests.go new file mode 100644 index 00000000..3a312203 --- /dev/null +++ b/cmd/tests.go @@ -0,0 +1,130 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/spf13/cobra" +) + +func NewTestsCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + testsCmd := &cobra.Command{ + Use: "tests", + Short: "Manage Microcks test results", + Long: `Manage Microcks test results`, + Run: func(cmd *cobra.Command, args []string) { + cmd.HelpFunc()(cmd, args) + }, + } + testsCmd.AddCommand(newTestsListCommand(globalClientOpts)) + return testsCmd +} + +func newTestsListCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var ( + page int + size int + ) + listCmd := &cobra.Command{ + Use: "list ", + Short: "List test results for a service", + Long: `List test results for a service`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + fmt.Println("tests list command requires arg") + os.Exit(1) + } + serviceRef := args[0] + + config.InsecureTLS = globalClientOpts.InsecureTLS + config.CaCertPaths = globalClientOpts.CaCertPaths + config.Verbose = globalClientOpts.Verbose + + var mc connectors.MicrocksClient + + if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { + mc = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + + keycloakURL, err := mc.GetKeycloakURL() + if err != nil { + fmt.Printf("Got error when invoking Microcks client retrieving config: %s", err) + os.Exit(1) + } + + var oauthToken string = "unauthenticated-token" + if keycloakURL != "null" { + kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + oauthToken, err = kc.ConnectAndGetToken() + if err != nil { + fmt.Printf("Got error when invoking Keycloak client: %s", err) + os.Exit(1) + } + } + mc.SetOAuthToken(oauthToken) + } else { + localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) + if err != nil { + fmt.Println(err) + return + } + if localConfig == nil { + fmt.Println("Please login to perform operation...") + return + } + if globalClientOpts.Context == "" { + globalClientOpts.Context = localConfig.CurrentContext + } + mc, err = connectors.NewClient(*globalClientOpts) + if err != nil { + fmt.Printf("error %v", err) + return + } + _, err = localConfig.ResolveContext(globalClientOpts.Context) + errors.CheckError(err) + } + + results, err := mc.GetTestResults(serviceRef, page, size) + if err != nil { + fmt.Printf("Got error when invoking Microcks client listing test results: %s", err) + os.Exit(1) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tDATE\tRUNNER\tENDPOINT\tSUCCESS") + for _, r := range results { + date := time.UnixMilli(r.TestDate).Format("2006-01-02 15:04:05") + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%v\n", + r.ID, + date, + r.RunnerType, + r.TestedEndpoint, + r.Success, + ) + } + w.Flush() + }, + } + listCmd.Flags().IntVar(&page, "page", 0, "Page of results to retrieve") + listCmd.Flags().IntVar(&size, "size", 20, "Number of results per page") + return listCmd +} diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index f76884b8..fde5e891 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -50,6 +50,7 @@ type MicrocksClient interface { SetOAuthToken(oauthToken string) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) GetTestResult(testResultID string) (*TestResultSummary, error) + GetTestResults(serviceRef string, page, size int) ([]TestResultSummary, error) UploadArtifact(specificationFilePath string, mainArtifact bool) (string, error) DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) } @@ -65,6 +66,19 @@ type TestResultSummary struct { ElapsedTime int32 `json:"elapsedTime"` Success bool `json:"success"` InProgress bool `json:"inProgress"` + RunnerType string `json:"runnerType"` +} + +// testResultsPage wraps the paginated response from GET /api/tests +type testResultsPage struct { + Content []TestResultSummary `json:"content"` +} + +// serviceSummary holds the fields needed to resolve a service ObjectId by name and version +type serviceSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` } // HeaderDTO represents an operation header passed for Test @@ -417,6 +431,97 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, return &result, nil } +func (c *microcksClient) getServiceID(serviceRef string) (string, error) { + parts := strings.SplitN(serviceRef, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("serviceRef must be in : format") + } + name, version := parts[0], parts[1] + + rel := &url.URL{Path: "services"} + u := c.APIURL.ResolveReference(rel) + q := u.Query() + q.Set("page", "0") + q.Set("size", "200") + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var services []serviceSummary + if err := json.Unmarshal(body, &services); err != nil { + return "", fmt.Errorf("failed to parse services response: %w", err) + } + + for _, s := range services { + if s.Name == name && s.Version == version { + return s.ID, nil + } + } + return "", fmt.Errorf("service %q not found", serviceRef) +} + +func (c *microcksClient) GetTestResults(serviceRef string, page, size int) ([]TestResultSummary, error) { + serviceID, err := c.getServiceID(serviceRef) + if err != nil { + return nil, err + } + + rel := &url.URL{Path: "tests/service/" + serviceID} + u := c.APIURL.ResolveReference(rel) + q := u.Query() + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("size", fmt.Sprintf("%d", size)) + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + + // Dump request if verbose required. + config.DumpRequestIfRequired("Microcks for listing test results", req, false) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Dump response if verbose required. + config.DumpResponseIfRequired("Microcks for listing test results", resp, true) + + body, err := io.ReadAll(resp.Body) + if err != nil { + panic(err.Error()) + } + + var results []TestResultSummary + if err := json.Unmarshal(body, &results); err != nil { + return nil, fmt.Errorf("failed to parse test results response: %w", err) + } + + return results, nil +} + func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifact bool) (string, error) { // Ensure file exists on fs. file, err := os.Open(specificationFilePath) diff --git a/pkg/connectors/microcks_client_tests_test.go b/pkg/connectors/microcks_client_tests_test.go new file mode 100644 index 00000000..f6ed5e36 --- /dev/null +++ b/pkg/connectors/microcks_client_tests_test.go @@ -0,0 +1,108 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package connectors + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func makeTestsServer(t *testing.T, serviceID string, results []TestResultSummary, checkPage, checkSize string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/services": + _ = json.NewEncoder(w).Encode([]serviceSummary{{ID: serviceID, Name: "WeatherForecast API", Version: "1.1.0"}}) + case "/api/tests/service/" + serviceID: + if checkPage != "" { + if got := r.URL.Query().Get("page"); got != checkPage { + t.Fatalf("unexpected page: %s", got) + } + } + if checkSize != "" { + if got := r.URL.Query().Get("size"); got != checkSize { + t.Fatalf("unexpected size: %s", got) + } + } + _ = json.NewEncoder(w).Encode(results) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) +} + +func TestGetTestResultsReturnsResults(t *testing.T) { + results := []TestResultSummary{ + {ID: "abc123", ServiceID: "svc001", TestedEndpoint: "http://localhost:8080", RunnerType: "OPEN_API_SCHEMA", Success: true}, + {ID: "def456", ServiceID: "svc001", TestedEndpoint: "http://localhost:8080", RunnerType: "OPEN_API_SCHEMA", Success: false}, + } + server := makeTestsServer(t, "svc001", results, "0", "20") + defer server.Close() + + client := NewMicrocksClient(server.URL) + got, err := client.GetTestResults("WeatherForecast API:1.1.0", 0, 20) + if err != nil { + t.Fatalf("GetTestResults returned error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 results, got %d", len(got)) + } + if got[0].ID != "abc123" { + t.Fatalf("unexpected first result ID: %s", got[0].ID) + } + if got[1].Success { + t.Fatalf("expected second result to be failed") + } +} + +func TestGetTestResultsReturnsEmptySlice(t *testing.T) { + server := makeTestsServer(t, "svc001", []TestResultSummary{}, "", "") + defer server.Close() + + client := NewMicrocksClient(server.URL) + got, err := client.GetTestResults("WeatherForecast API:1.1.0", 0, 20) + if err != nil { + t.Fatalf("GetTestResults returned error: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty results, got %d", len(got)) + } +} + +func TestGetTestResultsPaginationParams(t *testing.T) { + server := makeTestsServer(t, "svc001", []TestResultSummary{}, "2", "5") + defer server.Close() + + client := NewMicrocksClient(server.URL) + _, err := client.GetTestResults("WeatherForecast API:1.1.0", 2, 5) + if err != nil { + t.Fatalf("GetTestResults returned error: %v", err) + } +} + +func TestGetTestResultsInvalidServiceRefReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer server.Close() + + client := NewMicrocksClient(server.URL) + _, err := client.GetTestResults("NoColonHere", 0, 20) + if err == nil { + t.Fatal("expected error for invalid serviceRef format, got nil") + } +}