github.com/saucelabs/saucectl@v0.175.1/internal/http/rdcservice_test.go (about)

     1  package http
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"os"
    11  	"path/filepath"
    12  	"reflect"
    13  	"sort"
    14  	"testing"
    15  	"time"
    16  
    17  	"github.com/hashicorp/go-retryablehttp"
    18  	"github.com/saucelabs/saucectl/internal/config"
    19  	"github.com/saucelabs/saucectl/internal/devices"
    20  	"github.com/saucelabs/saucectl/internal/job"
    21  	"github.com/stretchr/testify/assert"
    22  )
    23  
    24  func TestRDCService_ReadJob(t *testing.T) {
    25  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    26  		var err error
    27  		switch r.URL.Path {
    28  		case "/v1/rdc/jobs/test1":
    29  			w.WriteHeader(http.StatusOK)
    30  			_, err = w.Write([]byte(`{"id": "test1", "error": null, "status": "passed", "consolidated_status": "passed"}`))
    31  		case "/v1/rdc/jobs/test2":
    32  			w.WriteHeader(http.StatusOK)
    33  			_, err = w.Write([]byte(`{"id": "test2", "error": "no-device-found", "status": "failed", "consolidated_status": "failed"}`))
    34  		case "/v1/rdc/jobs/test3":
    35  			w.WriteHeader(http.StatusOK)
    36  			_, err = w.Write([]byte(`{"id": "test3", "error": null, "status": "in progress", "consolidated_status": "in progress"}`))
    37  		case "/v1/rdc/jobs/test4":
    38  			w.WriteHeader(http.StatusNotFound)
    39  		default:
    40  			w.WriteHeader(http.StatusInternalServerError)
    41  		}
    42  		if err != nil {
    43  			t.Errorf("failed to respond: %v", err)
    44  		}
    45  	}))
    46  	defer ts.Close()
    47  	timeout := 3 * time.Second
    48  	client := NewRDCService(ts.URL, "test-user", "test-key", timeout, config.ArtifactDownload{})
    49  
    50  	testCases := []struct {
    51  		name    string
    52  		jobID   string
    53  		want    job.Job
    54  		wantErr error
    55  	}{
    56  		{
    57  			name:    "passed job",
    58  			jobID:   "test1",
    59  			want:    job.Job{ID: "test1", Error: "", Status: "passed", Passed: true, IsRDC: true},
    60  			wantErr: nil,
    61  		},
    62  		{
    63  			name:    "failed job",
    64  			jobID:   "test2",
    65  			want:    job.Job{ID: "test2", Error: "no-device-found", Status: "failed", Passed: false, IsRDC: true},
    66  			wantErr: nil,
    67  		},
    68  		{
    69  			name:    "in progress job",
    70  			jobID:   "test3",
    71  			want:    job.Job{ID: "test3", Error: "", Status: "in progress", Passed: false, IsRDC: true},
    72  			wantErr: nil,
    73  		},
    74  		{
    75  			name:    "non-existent job",
    76  			jobID:   "test4",
    77  			want:    job.Job{ID: "test4", Error: "", Status: "", Passed: false},
    78  			wantErr: ErrJobNotFound,
    79  		},
    80  	}
    81  
    82  	for _, tt := range testCases {
    83  		j, err := client.ReadJob(context.Background(), tt.jobID, true)
    84  		assert.Equal(t, err, tt.wantErr)
    85  		if err == nil {
    86  			assert.Equal(t, tt.want, j)
    87  		}
    88  	}
    89  }
    90  
    91  func TestRDCService_PollJob(t *testing.T) {
    92  	var retryCount int
    93  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    94  		var err error
    95  		switch r.URL.Path {
    96  		case "/v1/rdc/jobs/1":
    97  			_ = json.NewEncoder(w).Encode(rdcJob{
    98  				ID:     "1",
    99  				Status: job.StateComplete,
   100  			})
   101  		case "/v1/rdc/jobs/2":
   102  			_ = json.NewEncoder(w).Encode(rdcJob{
   103  				ID:     "2",
   104  				Passed: false,
   105  				Status: job.StateError,
   106  				Error:  "User Abandoned Test -- User terminated",
   107  			})
   108  		case "/v1/rdc/jobs/3":
   109  			w.WriteHeader(http.StatusNotFound)
   110  		case "/v1/rdc/jobs/4":
   111  			w.WriteHeader(http.StatusUnauthorized)
   112  		case "/v1/rdc/jobs/5":
   113  			if retryCount < 2 {
   114  				w.WriteHeader(http.StatusInternalServerError)
   115  				retryCount++
   116  				return
   117  			}
   118  
   119  			_ = json.NewEncoder(w).Encode(rdcJob{
   120  				ID:     "5",
   121  				Status: job.StatePassed,
   122  				Passed: true,
   123  				Error:  "",
   124  			})
   125  		default:
   126  			w.WriteHeader(http.StatusInternalServerError)
   127  		}
   128  
   129  		if err != nil {
   130  			t.Errorf("failed to respond: %v", err)
   131  		}
   132  	}))
   133  	defer ts.Close()
   134  	timeout := 3 * time.Second
   135  
   136  	testCases := []struct {
   137  		name         string
   138  		client       RDCService
   139  		jobID        string
   140  		expectedResp job.Job
   141  		expectedErr  error
   142  	}{
   143  		{
   144  			name:   "get job details with ID 1 and status 'complete'",
   145  			client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}),
   146  			jobID:  "1",
   147  			expectedResp: job.Job{
   148  				ID:     "1",
   149  				Passed: false,
   150  				Status: "complete",
   151  				Error:  "",
   152  				IsRDC:  true,
   153  			},
   154  			expectedErr: nil,
   155  		},
   156  		{
   157  			name:   "get job details with ID 2 and status 'error'",
   158  			client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}),
   159  			jobID:  "2",
   160  			expectedResp: job.Job{
   161  				ID:     "2",
   162  				Passed: false,
   163  				Status: "error",
   164  				Error:  "User Abandoned Test -- User terminated",
   165  				IsRDC:  true,
   166  			},
   167  			expectedErr: nil,
   168  		},
   169  		{
   170  			name:         "job not found error from external API",
   171  			client:       NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}),
   172  			jobID:        "3",
   173  			expectedResp: job.Job{},
   174  			expectedErr:  ErrJobNotFound,
   175  		},
   176  		{
   177  			name:         "http status is not 200, but 401 from external API",
   178  			client:       NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}),
   179  			jobID:        "4",
   180  			expectedResp: job.Job{},
   181  			expectedErr:  errors.New("unexpected statusCode: 401"),
   182  		},
   183  		{
   184  			name:         "unexpected status code from external API",
   185  			client:       NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}),
   186  			jobID:        "333",
   187  			expectedResp: job.Job{},
   188  			expectedErr:  errors.New("internal server error"),
   189  		},
   190  		{
   191  			name:   "get job details with ID 5. retry 2 times and succeed",
   192  			client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}),
   193  			jobID:  "5",
   194  			expectedResp: job.Job{
   195  				ID:     "5",
   196  				Passed: true,
   197  				Status: job.StatePassed,
   198  				Error:  "",
   199  				IsRDC:  true,
   200  			},
   201  			expectedErr: nil,
   202  		},
   203  	}
   204  
   205  	for _, tc := range testCases {
   206  		t.Run(tc.name, func(t *testing.T) {
   207  			tc.client.Client.RetryWaitMax = 1 * time.Millisecond
   208  			got, err := tc.client.PollJob(context.Background(), tc.jobID, 10*time.Millisecond, 0, true)
   209  			assert.Equal(t, tc.expectedResp, got)
   210  			if err != nil {
   211  				assert.Equal(t, tc.expectedErr, err)
   212  			}
   213  		})
   214  	}
   215  }
   216  
   217  func TestRDCService_GetJobAssetFileNames(t *testing.T) {
   218  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   219  		var err error
   220  		switch r.URL.Path {
   221  		case "/v1/rdc/jobs/1":
   222  			w.WriteHeader(http.StatusOK)
   223  			_, err = w.Write([]byte(`{"automation_backend":"xcuitest","framework_log_url":"https://dummy/xcuitestLogs","device_log_url":"https://dummy/deviceLogs","video_url":"https://dummy/video.mp4"}`))
   224  		case "/v1/rdc/jobs/2":
   225  			w.WriteHeader(http.StatusOK)
   226  			_, err = w.Write([]byte(`{"automation_backend":"xcuitest","framework_log_url":"https://dummy/xcuitestLogs","screenshots":[{"id":"sc1"}],"video_url":"https://dummy/video.mp4"}`))
   227  		case "/v1/rdc/jobs/3":
   228  			w.WriteHeader(http.StatusOK)
   229  			// The discrepancy between automation_backend and framework_log_url is wanted, as this is how the backend is currently responding.
   230  			_, err = w.Write([]byte(`{"automation_backend":"espresso","framework_log_url":"https://dummy/xcuitestLogs","video_url":"https://dummy/video.mp4"}`))
   231  		case "/v1/rdc/jobs/4":
   232  			w.WriteHeader(http.StatusOK)
   233  			// The discrepancy between automation_backend and framework_log_url is wanted, as this is how the backend is currently responding.
   234  			_, err = w.Write([]byte(`{"automation_backend":"espresso","framework_log_url":"https://dummy/xcuitestLogs","device_log_url":"https://dummy/deviceLogs","screenshots":[{"id":"sc1"}],"video_url":"https://dummy/video.mp4"}`))
   235  		default:
   236  			w.WriteHeader(http.StatusNotFound)
   237  		}
   238  
   239  		if err != nil {
   240  			t.Errorf("failed to respond: %v", err)
   241  		}
   242  	}))
   243  	defer ts.Close()
   244  	client := NewRDCService(ts.URL, "test-user", "test-password", 1*time.Second, config.ArtifactDownload{})
   245  
   246  	testCases := []struct {
   247  		name     string
   248  		jobID    string
   249  		expected []string
   250  		wantErr  error
   251  	}{
   252  		{
   253  			name:     "XCUITest w/o screenshots",
   254  			jobID:    "1",
   255  			expected: []string{"device.log", "junit.xml", "video.mp4", "xcuitest.log"},
   256  			wantErr:  nil,
   257  		},
   258  		{
   259  			name:     "XCUITest w/ screenshots w/o deviceLogs",
   260  			jobID:    "2",
   261  			expected: []string{"junit.xml", "screenshots.zip", "video.mp4", "xcuitest.log"},
   262  			wantErr:  nil,
   263  		},
   264  		{
   265  			name:     "espresso w/o screenshots",
   266  			jobID:    "3",
   267  			expected: []string{"junit.xml", "video.mp4"},
   268  			wantErr:  nil,
   269  		},
   270  		{
   271  			name:     "espresso w/ screenshots w/o deviceLogs",
   272  			jobID:    "4",
   273  			expected: []string{"device.log", "junit.xml", "screenshots.zip", "video.mp4"},
   274  			wantErr:  nil,
   275  		},
   276  	}
   277  	for _, tt := range testCases {
   278  		t.Run(tt.name, func(t *testing.T) {
   279  			files, err := client.GetJobAssetFileNames(context.Background(), tt.jobID, true)
   280  			if err != nil {
   281  				if !reflect.DeepEqual(err, tt.wantErr) {
   282  					t.Errorf("GetJobAssetFileNames(): got: %v, want: %v", err, tt.wantErr)
   283  				}
   284  				return
   285  			}
   286  			if tt.wantErr != nil {
   287  				t.Errorf("GetJobAssetFileNames(): got: %v, want: %v", err, tt.wantErr)
   288  			}
   289  			sort.Strings(files)
   290  			sort.Strings(tt.expected)
   291  			if !reflect.DeepEqual(files, tt.expected) {
   292  				t.Errorf("GetJobAssetFileNames(): got: %v, want: %v", files, tt.expected)
   293  			}
   294  		})
   295  	}
   296  }
   297  
   298  func TestRDCService_GetJobAssetFileContent(t *testing.T) {
   299  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   300  		var err error
   301  		switch r.URL.Path {
   302  		case "/v1/rdc/jobs/jobID/deviceLogs":
   303  			w.WriteHeader(http.StatusOK)
   304  			_, err = w.Write([]byte("INFO 15:10:16 1 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\nINFO 15:10:16 2 GmsCoreXrpcWrapper : Returning a channel provider with trafficStatsTag=12803\nINFO 15:10:16 3 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\n"))
   305  		case "/v1/rdc/jobs/jobID/junit.xml":
   306  			w.WriteHeader(http.StatusOK)
   307  			_, err = w.Write([]byte("<xml>junit.xml</xml>"))
   308  		default:
   309  			w.WriteHeader(http.StatusNotFound)
   310  		}
   311  
   312  		if err != nil {
   313  			t.Errorf("failed to respond: %v", err)
   314  		}
   315  	}))
   316  	defer ts.Close()
   317  	client := NewRDCService(ts.URL, "test-user", "test-password", 1*time.Second, config.ArtifactDownload{})
   318  
   319  	testCases := []struct {
   320  		name     string
   321  		jobID    string
   322  		fileName string
   323  		want     []byte
   324  		wantErr  error
   325  	}{
   326  		{
   327  			name:     "Download deviceLogs asset",
   328  			jobID:    "jobID",
   329  			fileName: "deviceLogs",
   330  			want:     []byte("INFO 15:10:16 1 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\nINFO 15:10:16 2 GmsCoreXrpcWrapper : Returning a channel provider with trafficStatsTag=12803\nINFO 15:10:16 3 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\n"),
   331  			wantErr:  nil,
   332  		},
   333  		{
   334  			name:     "Download junit.xml asset",
   335  			jobID:    "jobID",
   336  			fileName: "junit.xml",
   337  			want:     []byte("<xml>junit.xml</xml>"),
   338  			wantErr:  nil,
   339  		},
   340  		{
   341  			name:     "Download invalid filename",
   342  			jobID:    "jobID",
   343  			fileName: "buggy-file.txt",
   344  			wantErr:  errors.New("asset not found"),
   345  		},
   346  	}
   347  	for _, tt := range testCases {
   348  		data, err := client.GetJobAssetFileContent(context.Background(), tt.jobID, tt.fileName, true)
   349  		assert.Equal(t, err, tt.wantErr)
   350  		if err == nil {
   351  			assert.Equal(t, tt.want, data)
   352  		}
   353  	}
   354  }
   355  
   356  func TestRDCService_DownloadArtifact(t *testing.T) {
   357  	fileContent := "<xml>junit.xml</xml>"
   358  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   359  		var err error
   360  		switch r.URL.Path {
   361  		case "/v1/rdc/jobs/test-123":
   362  			_, err = w.Write([]byte(`{"automation_backend":"espresso"}`))
   363  		case "/v1/rdc/jobs/test-123/junit.xml":
   364  			_, err = w.Write([]byte(fileContent))
   365  		default:
   366  			w.WriteHeader(http.StatusNotFound)
   367  		}
   368  
   369  		if err != nil {
   370  			t.Errorf("failed to respond: %v", err)
   371  		}
   372  	}))
   373  	defer ts.Close()
   374  
   375  	tempDir, err := os.MkdirTemp("", "saucectl-download-artifact")
   376  	if err != nil {
   377  		t.Errorf("Failed to create temp dir: %v", err)
   378  	}
   379  	defer func() {
   380  		_ = os.RemoveAll(tempDir)
   381  	}()
   382  
   383  	rc := NewRDCService(ts.URL, "dummy-user", "dummy-key", 10*time.Second, config.ArtifactDownload{
   384  		Directory: tempDir,
   385  		Match:     []string{"junit.xml"},
   386  	})
   387  	rc.DownloadArtifact("test-123", "suite name", true)
   388  
   389  	fileName := filepath.Join(tempDir, "suite_name", "junit.xml")
   390  	d, err := os.ReadFile(fileName)
   391  	if err != nil {
   392  		t.Errorf("file '%s' not found: %v", fileName, err)
   393  	}
   394  
   395  	if string(d) != fileContent {
   396  		t.Errorf("file content mismatch: got '%v', expects: '%v'", d, fileContent)
   397  	}
   398  }
   399  
   400  func TestRDCService_GetDevices(t *testing.T) {
   401  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   402  		var err error
   403  		completeQuery := fmt.Sprintf("%s?%s", r.URL.Path, r.URL.RawQuery)
   404  		switch completeQuery {
   405  		case "/v1/rdc/devices/filtered?os=ANDROID":
   406  			_, err = w.Write([]byte(`{"entities":[{"name": "OnePlus 5T"},{"name": "OnePlus 6"},{"name": "OnePlus 6T"}]}`))
   407  		case "/v1/rdc/devices/filtered?os=IOS":
   408  			_, err = w.Write([]byte(`{"entities":[{"name": "iPhone XR"},{"name": "iPhone XS"},{"name": "iPhone X"}]}`))
   409  		default:
   410  			w.WriteHeader(http.StatusNotFound)
   411  		}
   412  
   413  		if err != nil {
   414  			t.Errorf("failed to respond: %v", err)
   415  		}
   416  	}))
   417  	defer ts.Close()
   418  	client := retryablehttp.NewClient()
   419  	client.HTTPClient = &http.Client{Timeout: 1 * time.Second}
   420  
   421  	cl := RDCService{
   422  		Client:    client,
   423  		URL:       ts.URL,
   424  		Username:  "dummy-user",
   425  		AccessKey: "dummy-key",
   426  	}
   427  	type args struct {
   428  		ctx context.Context
   429  		OS  string
   430  	}
   431  	tests := []struct {
   432  		name    string
   433  		args    args
   434  		want    []devices.Device
   435  		wantErr bool
   436  	}{
   437  		{
   438  			name: "Android devices",
   439  			args: args{
   440  				ctx: context.Background(),
   441  				OS:  "ANDROID",
   442  			},
   443  			want: []devices.Device{
   444  				{Name: "OnePlus 5T"},
   445  				{Name: "OnePlus 6"},
   446  				{Name: "OnePlus 6T"},
   447  			},
   448  			wantErr: false,
   449  		},
   450  		{
   451  			name: "iOS devices",
   452  			args: args{
   453  				ctx: context.Background(),
   454  				OS:  "IOS",
   455  			},
   456  			want: []devices.Device{
   457  				{Name: "iPhone XR"},
   458  				{Name: "iPhone XS"},
   459  				{Name: "iPhone X"},
   460  			},
   461  			wantErr: false,
   462  		},
   463  	}
   464  	for _, tt := range tests {
   465  		t.Run(tt.name, func(t *testing.T) {
   466  			got, err := cl.GetDevices(tt.args.ctx, tt.args.OS)
   467  			if (err != nil) != tt.wantErr {
   468  				t.Errorf("GetDevices() error = %v, wantErr %v", err, tt.wantErr)
   469  				return
   470  			}
   471  			if !reflect.DeepEqual(got, tt.want) {
   472  				t.Errorf("GetDevices() got = %v, want %v", got, tt.want)
   473  			}
   474  		})
   475  	}
   476  }
   477  
   478  func TestRDCService_StartJob(t *testing.T) {
   479  	type args struct {
   480  		ctx               context.Context
   481  		jobStarterPayload job.StartOptions
   482  	}
   483  	type fields struct {
   484  		HTTPClient *http.Client
   485  		URL        string
   486  	}
   487  	tests := []struct {
   488  		name       string
   489  		fields     fields
   490  		args       args
   491  		want       string
   492  		wantErr    error
   493  		serverFunc func(w http.ResponseWriter, r *http.Request) // what shall the mock server respond with
   494  	}{
   495  		{
   496  			name: "Happy path",
   497  			args: args{
   498  				ctx: context.TODO(),
   499  				jobStarterPayload: job.StartOptions{
   500  					User:        "fake-user",
   501  					AccessKey:   "fake-access-key",
   502  					BrowserName: "fake-browser-name",
   503  					Name:        "fake-test-name",
   504  					Framework:   "fake-framework",
   505  					Build:       "fake-buildname",
   506  					Tags:        nil,
   507  				},
   508  			},
   509  			want:    "fake-job-id",
   510  			wantErr: nil,
   511  			serverFunc: func(w http.ResponseWriter, r *http.Request) {
   512  				w.WriteHeader(201)
   513  				_, _ = w.Write([]byte(`{ "test_report": { "id": "fake-job-id" }}`))
   514  			},
   515  		},
   516  		{
   517  			name: "Non 2xx status code",
   518  			args: args{
   519  				ctx:               context.TODO(),
   520  				jobStarterPayload: job.StartOptions{},
   521  			},
   522  			want:    "",
   523  			wantErr: fmt.Errorf("job start failed; unexpected response code:'300', msg:''"),
   524  			serverFunc: func(w http.ResponseWriter, r *http.Request) {
   525  				w.WriteHeader(300)
   526  			},
   527  		},
   528  		{
   529  			name: "Unknown error",
   530  			args: args{
   531  				ctx:               context.TODO(),
   532  				jobStarterPayload: job.StartOptions{},
   533  			},
   534  			want:    "",
   535  			wantErr: fmt.Errorf("job start failed; unexpected response code:'500', msg:'Internal server error'"),
   536  			serverFunc: func(w http.ResponseWriter, r *http.Request) {
   537  				w.WriteHeader(500)
   538  				_, err := w.Write([]byte("Internal server error"))
   539  				if err != nil {
   540  					t.Errorf("failed to write response: %v", err)
   541  				}
   542  			},
   543  		},
   544  	}
   545  	for _, tt := range tests {
   546  		t.Run(tt.name, func(t *testing.T) {
   547  			server := httptest.NewServer(http.HandlerFunc(tt.serverFunc))
   548  			defer server.Close()
   549  
   550  			c := &RDCService{
   551  				Client: &retryablehttp.Client{HTTPClient: server.Client()},
   552  				URL:    server.URL,
   553  			}
   554  
   555  			got, _, err := c.StartJob(tt.args.ctx, tt.args.jobStarterPayload)
   556  			if (err != nil) && !reflect.DeepEqual(err, tt.wantErr) {
   557  				t.Errorf("StartJob() error = %v, wantErr %v", err, tt.wantErr)
   558  				return
   559  			}
   560  			if !reflect.DeepEqual(got, tt.want) {
   561  				t.Errorf("StartJob() got = %v, want %v", got, tt.want)
   562  			}
   563  		})
   564  	}
   565  }