github.com/liamawhite/cli-with-i18n@v6.32.1-0.20171122084555-dede0a5c3448+incompatible/api/cloudcontroller/ccv2/job_test.go (about)

     1  package ccv2_test
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"io/ioutil"
    10  	"mime/multipart"
    11  	"net/http"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/liamawhite/cli-with-i18n/api/cloudcontroller"
    16  	"github.com/liamawhite/cli-with-i18n/api/cloudcontroller/ccerror"
    17  	. "github.com/liamawhite/cli-with-i18n/api/cloudcontroller/ccv2"
    18  	"github.com/liamawhite/cli-with-i18n/api/cloudcontroller/ccv2/ccv2fakes"
    19  	"github.com/liamawhite/cli-with-i18n/api/cloudcontroller/wrapper"
    20  	. "github.com/onsi/ginkgo"
    21  	. "github.com/onsi/ginkgo/extensions/table"
    22  	. "github.com/onsi/gomega"
    23  	. "github.com/onsi/gomega/ghttp"
    24  )
    25  
    26  var _ = Describe("Job", func() {
    27  	var client *Client
    28  
    29  	Describe("Job", func() {
    30  		DescribeTable("Finished",
    31  			func(status JobStatus, expected bool) {
    32  				job := Job{Status: status}
    33  				Expect(job.Finished()).To(Equal(expected))
    34  			},
    35  
    36  			Entry("when failed, it returns false", JobStatusFailed, false),
    37  			Entry("when finished, it returns true", JobStatusFinished, true),
    38  			Entry("when queued, it returns false", JobStatusQueued, false),
    39  			Entry("when running, it returns false", JobStatusRunning, false),
    40  		)
    41  
    42  		DescribeTable("Failed",
    43  			func(status JobStatus, expected bool) {
    44  				job := Job{Status: status}
    45  				Expect(job.Failed()).To(Equal(expected))
    46  			},
    47  
    48  			Entry("when failed, it returns true", JobStatusFailed, true),
    49  			Entry("when finished, it returns false", JobStatusFinished, false),
    50  			Entry("when queued, it returns false", JobStatusQueued, false),
    51  			Entry("when running, it returns false", JobStatusRunning, false),
    52  		)
    53  	})
    54  
    55  	Describe("PollJob", func() {
    56  		BeforeEach(func() {
    57  			client = NewTestClient(Config{JobPollingTimeout: time.Minute})
    58  		})
    59  
    60  		Context("when the job starts queued and then finishes successfully", func() {
    61  			BeforeEach(func() {
    62  				server.AppendHandlers(
    63  					CombineHandlers(
    64  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
    65  						RespondWith(http.StatusAccepted, `{
    66  							"metadata": {
    67  								"guid": "some-job-guid",
    68  								"created_at": "2016-06-08T16:41:27Z",
    69  								"url": "/v2/jobs/some-job-guid"
    70  							},
    71  							"entity": {
    72  								"guid": "some-job-guid",
    73  								"status": "queued"
    74  							}
    75  						}`, http.Header{"X-Cf-Warnings": {"warning-1"}}),
    76  					))
    77  
    78  				server.AppendHandlers(
    79  					CombineHandlers(
    80  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
    81  						RespondWith(http.StatusAccepted, `{
    82  							"metadata": {
    83  								"guid": "some-job-guid",
    84  								"created_at": "2016-06-08T16:41:28Z",
    85  								"url": "/v2/jobs/some-job-guid"
    86  							},
    87  							"entity": {
    88  								"guid": "some-job-guid",
    89  								"status": "running"
    90  							}
    91  						}`, http.Header{"X-Cf-Warnings": {"warning-2, warning-3"}}),
    92  					))
    93  
    94  				server.AppendHandlers(
    95  					CombineHandlers(
    96  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
    97  						RespondWith(http.StatusAccepted, `{
    98  							"metadata": {
    99  								"guid": "some-job-guid",
   100  								"created_at": "2016-06-08T16:41:29Z",
   101  								"url": "/v2/jobs/some-job-guid"
   102  							},
   103  							"entity": {
   104  								"guid": "some-job-guid",
   105  								"status": "finished"
   106  							}
   107  						}`, http.Header{"X-Cf-Warnings": {"warning-4"}}),
   108  					))
   109  			})
   110  
   111  			It("should poll until completion", func() {
   112  				warnings, err := client.PollJob(Job{GUID: "some-job-guid"})
   113  				Expect(err).ToNot(HaveOccurred())
   114  				Expect(warnings).To(ConsistOf("warning-1", "warning-2", "warning-3", "warning-4"))
   115  			})
   116  		})
   117  
   118  		Context("when the job starts queued and then fails", func() {
   119  			var jobFailureMessage string
   120  			BeforeEach(func() {
   121  				jobFailureMessage = "I am a banana!!!"
   122  
   123  				server.AppendHandlers(
   124  					CombineHandlers(
   125  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   126  						RespondWith(http.StatusAccepted, `{
   127  							"metadata": {
   128  								"guid": "some-job-guid",
   129  								"created_at": "2016-06-08T16:41:27Z",
   130  								"url": "/v2/jobs/some-job-guid"
   131  							},
   132  							"entity": {
   133  								"guid": "some-job-guid",
   134  								"status": "queued"
   135  							}
   136  						}`, http.Header{"X-Cf-Warnings": {"warning-1"}}),
   137  					))
   138  
   139  				server.AppendHandlers(
   140  					CombineHandlers(
   141  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   142  						RespondWith(http.StatusAccepted, `{
   143  							"metadata": {
   144  								"guid": "some-job-guid",
   145  								"created_at": "2016-06-08T16:41:28Z",
   146  								"url": "/v2/jobs/some-job-guid"
   147  							},
   148  							"entity": {
   149  								"guid": "some-job-guid",
   150  								"status": "running"
   151  							}
   152  						}`, http.Header{"X-Cf-Warnings": {"warning-2, warning-3"}}),
   153  					))
   154  
   155  				server.AppendHandlers(
   156  					CombineHandlers(
   157  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   158  						RespondWith(http.StatusOK, fmt.Sprintf(`
   159  							{
   160  								"metadata": {
   161  									"guid": "some-job-guid",
   162  									"created_at": "2016-06-08T16:41:29Z",
   163  									"url": "/v2/jobs/some-job-guid"
   164  								},
   165  								"entity": {
   166  									"error": "Use of entity>error is deprecated in favor of entity>error_details.",
   167  									"error_details": {
   168  										"code": 160001,
   169  										"description": "%s",
   170  										"error_code": "CF-AppBitsUploadInvalid"
   171  									},
   172  									"guid": "job-guid",
   173  									"status": "failed"
   174  								}
   175  							}
   176  							`, jobFailureMessage), http.Header{"X-Cf-Warnings": {"warning-4"}}),
   177  					))
   178  			})
   179  
   180  			It("returns a JobFailedError", func() {
   181  				warnings, err := client.PollJob(Job{GUID: "some-job-guid"})
   182  				Expect(err).To(MatchError(ccerror.JobFailedError{
   183  					JobGUID: "some-job-guid",
   184  					Message: jobFailureMessage,
   185  				}))
   186  				Expect(warnings).To(ConsistOf("warning-1", "warning-2", "warning-3", "warning-4"))
   187  			})
   188  		})
   189  
   190  		Context("when retrieving the job errors", func() {
   191  			BeforeEach(func() {
   192  				server.AppendHandlers(
   193  					CombineHandlers(
   194  						VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   195  						RespondWith(http.StatusAccepted, `{
   196  							INVALID YAML
   197  						}`, http.Header{"X-Cf-Warnings": {"warning-1, warning-2"}}),
   198  					))
   199  			})
   200  
   201  			It("returns the CC error", func() {
   202  				warnings, err := client.PollJob(Job{GUID: "some-job-guid"})
   203  				Expect(warnings).To(ConsistOf("warning-1", "warning-2"))
   204  				Expect(err.Error()).To(MatchRegexp("invalid character"))
   205  			})
   206  		})
   207  
   208  		Describe("JobPollingTimeout", func() {
   209  			Context("when the job runs longer than the OverallPollingTimeout", func() {
   210  				var jobPollingTimeout time.Duration
   211  
   212  				BeforeEach(func() {
   213  					jobPollingTimeout = 100 * time.Millisecond
   214  					client = NewTestClient(Config{
   215  						JobPollingTimeout:  jobPollingTimeout,
   216  						JobPollingInterval: 60 * time.Millisecond,
   217  					})
   218  
   219  					server.AppendHandlers(
   220  						CombineHandlers(
   221  							VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   222  							RespondWith(http.StatusAccepted, `{
   223  							"metadata": {
   224  								"guid": "some-job-guid",
   225  								"created_at": "2016-06-08T16:41:27Z",
   226  								"url": "/v2/jobs/some-job-guid"
   227  							},
   228  							"entity": {
   229  								"guid": "some-job-guid",
   230  								"status": "queued"
   231  							}
   232  						}`, http.Header{"X-Cf-Warnings": {"warning-1"}}),
   233  						))
   234  
   235  					server.AppendHandlers(
   236  						CombineHandlers(
   237  							VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   238  							RespondWith(http.StatusAccepted, `{
   239  							"metadata": {
   240  								"guid": "some-job-guid",
   241  								"created_at": "2016-06-08T16:41:28Z",
   242  								"url": "/v2/jobs/some-job-guid"
   243  							},
   244  							"entity": {
   245  								"guid": "some-job-guid",
   246  								"status": "running"
   247  							}
   248  						}`, http.Header{"X-Cf-Warnings": {"warning-2, warning-3"}}),
   249  						))
   250  
   251  					server.AppendHandlers(
   252  						CombineHandlers(
   253  							VerifyRequest(http.MethodGet, "/v2/jobs/some-job-guid"),
   254  							RespondWith(http.StatusAccepted, `{
   255  							"metadata": {
   256  								"guid": "some-job-guid",
   257  								"created_at": "2016-06-08T16:41:29Z",
   258  								"url": "/v2/jobs/some-job-guid"
   259  							},
   260  							"entity": {
   261  								"guid": "some-job-guid",
   262  								"status": "finished"
   263  							}
   264  						}`, http.Header{"X-Cf-Warnings": {"warning-4"}}),
   265  						))
   266  				})
   267  
   268  				It("raises a JobTimeoutError", func() {
   269  					_, err := client.PollJob(Job{GUID: "some-job-guid"})
   270  
   271  					Expect(err).To(MatchError(ccerror.JobTimeoutError{
   272  						Timeout: jobPollingTimeout,
   273  						JobGUID: "some-job-guid",
   274  					}))
   275  				})
   276  
   277  				// Fuzzy test to ensure that the overall function time isn't [far]
   278  				// greater than the OverallPollingTimeout. Since this is partially
   279  				// dependent on the speed of the system, the expectation is that the
   280  				// function *should* never exceed three times the timeout.
   281  				It("does not run [too much] longer than the timeout", func() {
   282  					startTime := time.Now()
   283  					_, err := client.PollJob(Job{GUID: "some-job-guid"})
   284  					endTime := time.Now()
   285  					Expect(err).To(HaveOccurred())
   286  
   287  					// If the jobPollingTimeout is less than the PollingInterval,
   288  					// then the margin may be too small, we should not allow the
   289  					// jobPollingTimeout to be set to less than the PollingInterval
   290  					Expect(endTime).To(BeTemporally("~", startTime, 3*jobPollingTimeout))
   291  				})
   292  			})
   293  		})
   294  	})
   295  
   296  	Describe("GetJob", func() {
   297  		BeforeEach(func() {
   298  			client = NewTestClient()
   299  		})
   300  
   301  		Context("when no errors are encountered", func() {
   302  			BeforeEach(func() {
   303  				jsonResponse := `{
   304  					"metadata": {
   305  						"guid": "job-guid",
   306  						"created_at": "2016-06-08T16:41:27Z",
   307  						"url": "/v2/jobs/job-guid"
   308  					},
   309  					"entity": {
   310  						"guid": "job-guid",
   311  						"status": "queued"
   312  					}
   313  				}`
   314  
   315  				server.AppendHandlers(
   316  					CombineHandlers(
   317  						VerifyRequest(http.MethodGet, "/v2/jobs/job-guid"),
   318  						RespondWith(http.StatusOK, jsonResponse, http.Header{"X-Cf-Warnings": {"warning-1, warning-2"}}),
   319  					))
   320  			})
   321  
   322  			It("returns job with all warnings", func() {
   323  				job, warnings, err := client.GetJob("job-guid")
   324  
   325  				Expect(err).NotTo(HaveOccurred())
   326  				Expect(warnings).To(ConsistOf(Warnings{"warning-1", "warning-2"}))
   327  				Expect(job.GUID).To(Equal("job-guid"))
   328  				Expect(job.Status).To(Equal(JobStatusQueued))
   329  			})
   330  		})
   331  
   332  		Context("when the job fails", func() {
   333  			BeforeEach(func() {
   334  				jsonResponse := `
   335  					{
   336  						"metadata": {
   337  							"guid": "some-job-guid",
   338  							"created_at": "2016-06-08T16:41:29Z",
   339  							"url": "/v2/jobs/some-job-guid"
   340  						},
   341  						"entity": {
   342  							"error": "Use of entity>error is deprecated in favor of entity>error_details.",
   343  							"error_details": {
   344  								"code": 160001,
   345  								"description": "some-error",
   346  								"error_code": "CF-AppBitsUploadInvalid"
   347  							},
   348  							"guid": "job-guid",
   349  							"status": "failed"
   350  						}
   351  					}
   352  					`
   353  				server.AppendHandlers(
   354  					CombineHandlers(
   355  						VerifyRequest(http.MethodGet, "/v2/jobs/job-guid"),
   356  						RespondWith(http.StatusOK, jsonResponse, http.Header{"X-Cf-Warnings": {"warning-1, warning-2"}}),
   357  					))
   358  			})
   359  
   360  			It("returns job with all warnings", func() {
   361  				job, warnings, err := client.GetJob("job-guid")
   362  
   363  				Expect(err).NotTo(HaveOccurred())
   364  				Expect(warnings).To(ConsistOf(Warnings{"warning-1", "warning-2"}))
   365  				Expect(job.GUID).To(Equal("job-guid"))
   366  				Expect(job.Status).To(Equal(JobStatusFailed))
   367  				Expect(job.Error).To(Equal("Use of entity>error is deprecated in favor of entity>error_details."))
   368  				Expect(job.ErrorDetails.Description).To(Equal("some-error"))
   369  			})
   370  		})
   371  	})
   372  
   373  	Describe("UploadApplicationPackage", func() {
   374  		BeforeEach(func() {
   375  			client = NewTestClient()
   376  		})
   377  
   378  		Context("when the upload is successful", func() {
   379  			var (
   380  				resources  []Resource
   381  				reader     io.Reader
   382  				readerBody []byte
   383  			)
   384  
   385  			BeforeEach(func() {
   386  				resources = []Resource{
   387  					{Filename: "foo"},
   388  					{Filename: "bar"},
   389  				}
   390  
   391  				readerBody = []byte("hello world")
   392  				reader = bytes.NewReader(readerBody)
   393  
   394  				verifyHeaderAndBody := func(_ http.ResponseWriter, req *http.Request) {
   395  					contentType := req.Header.Get("Content-Type")
   396  					Expect(contentType).To(MatchRegexp("multipart/form-data; boundary=[\\w\\d]+"))
   397  
   398  					defer req.Body.Close()
   399  					reader := multipart.NewReader(req.Body, contentType[30:])
   400  
   401  					// Verify that matched resources are sent properly
   402  					resourcesPart, err := reader.NextPart()
   403  					Expect(err).NotTo(HaveOccurred())
   404  
   405  					Expect(resourcesPart.FormName()).To(Equal("resources"))
   406  
   407  					defer resourcesPart.Close()
   408  					expectedJSON, err := json.Marshal(resources)
   409  					Expect(err).NotTo(HaveOccurred())
   410  					Expect(ioutil.ReadAll(resourcesPart)).To(MatchJSON(expectedJSON))
   411  
   412  					// Verify that the application bits are sent properly
   413  					resourcesPart, err = reader.NextPart()
   414  					Expect(err).NotTo(HaveOccurred())
   415  
   416  					Expect(resourcesPart.FormName()).To(Equal("application"))
   417  					Expect(resourcesPart.FileName()).To(Equal("application.zip"))
   418  
   419  					defer resourcesPart.Close()
   420  					Expect(ioutil.ReadAll(resourcesPart)).To(Equal(readerBody))
   421  				}
   422  
   423  				response := `{
   424  					"metadata": {
   425  						"guid": "job-guid",
   426  						"url": "/v2/jobs/job-guid"
   427  					},
   428  					"entity": {
   429  						"guid": "job-guid",
   430  						"status": "queued"
   431  					}
   432  				}`
   433  
   434  				server.AppendHandlers(
   435  					CombineHandlers(
   436  						VerifyRequest(http.MethodPut, "/v2/apps/some-app-guid/bits", "async=true"),
   437  						verifyHeaderAndBody,
   438  						RespondWith(http.StatusOK, response, http.Header{"X-Cf-Warnings": {"this is a warning"}}),
   439  					),
   440  				)
   441  			})
   442  
   443  			It("returns the created job and warnings", func() {
   444  				job, warnings, err := client.UploadApplicationPackage("some-app-guid", resources, reader, int64(len(readerBody)))
   445  				Expect(err).NotTo(HaveOccurred())
   446  				Expect(warnings).To(ConsistOf("this is a warning"))
   447  				Expect(job).To(Equal(Job{
   448  					GUID:   "job-guid",
   449  					Status: JobStatusQueued,
   450  				}))
   451  			})
   452  		})
   453  
   454  		Context("when the CC returns an error", func() {
   455  			BeforeEach(func() {
   456  				response := `{
   457  					"code": 30003,
   458  					"description": "Banana",
   459  					"error_code": "CF-Banana"
   460  				}`
   461  
   462  				server.AppendHandlers(
   463  					CombineHandlers(
   464  						VerifyRequest(http.MethodPut, "/v2/apps/some-app-guid/bits", "async=true"),
   465  						RespondWith(http.StatusNotFound, response, http.Header{"X-Cf-Warnings": {"this is a warning"}}),
   466  					),
   467  				)
   468  			})
   469  
   470  			It("returns the error", func() {
   471  				_, warnings, err := client.UploadApplicationPackage("some-app-guid", []Resource{}, bytes.NewReader(nil), 0)
   472  				Expect(err).To(MatchError(ccerror.ResourceNotFoundError{Message: "Banana"}))
   473  				Expect(warnings).To(ConsistOf("this is a warning"))
   474  			})
   475  		})
   476  
   477  		Context("when passed a nil resources", func() {
   478  			It("returns a NilObjectError", func() {
   479  				_, _, err := client.UploadApplicationPackage("some-app-guid", nil, bytes.NewReader(nil), 0)
   480  				Expect(err).To(MatchError(ccerror.NilObjectError{Object: "existingResources"}))
   481  			})
   482  		})
   483  
   484  		Context("when passed a nil reader", func() {
   485  			It("returns a NilObjectError", func() {
   486  				_, _, err := client.UploadApplicationPackage("some-app-guid", []Resource{}, nil, 0)
   487  				Expect(err).To(MatchError(ccerror.NilObjectError{Object: "newResources"}))
   488  			})
   489  		})
   490  
   491  		Context("when an error is returned from the new resources reader", func() {
   492  			var (
   493  				fakeReader  *ccv2fakes.FakeReader
   494  				expectedErr error
   495  			)
   496  
   497  			BeforeEach(func() {
   498  				expectedErr = errors.New("some read error")
   499  				fakeReader = new(ccv2fakes.FakeReader)
   500  				fakeReader.ReadReturns(0, expectedErr)
   501  
   502  				server.AppendHandlers(
   503  					VerifyRequest(http.MethodPut, "/v2/apps/some-app-guid/bits", "async=true"),
   504  				)
   505  			})
   506  
   507  			It("returns the error", func() {
   508  				_, _, err := client.UploadApplicationPackage("some-app-guid", []Resource{}, fakeReader, 3)
   509  				Expect(err).To(MatchError(expectedErr))
   510  			})
   511  		})
   512  
   513  		Context("when a retryable error occurs", func() {
   514  			BeforeEach(func() {
   515  				wrapper := &wrapper.CustomWrapper{
   516  					CustomMake: func(connection cloudcontroller.Connection, request *cloudcontroller.Request, response *cloudcontroller.Response) error {
   517  						defer GinkgoRecover() // Since this will be running in a thread
   518  
   519  						if strings.HasSuffix(request.URL.String(), "/v2/apps/some-app-guid/bits?async=true") {
   520  							_, err := ioutil.ReadAll(request.Body)
   521  							Expect(err).ToNot(HaveOccurred())
   522  							Expect(request.Body.Close()).ToNot(HaveOccurred())
   523  							return request.ResetBody()
   524  						}
   525  						return connection.Make(request, response)
   526  					},
   527  				}
   528  
   529  				client = NewTestClient(Config{Wrappers: []ConnectionWrapper{wrapper}})
   530  			})
   531  
   532  			It("returns the PipeSeekError", func() {
   533  				_, _, err := client.UploadApplicationPackage("some-app-guid", []Resource{}, strings.NewReader("hello world"), 3)
   534  				Expect(err).To(MatchError(ccerror.PipeSeekError{}))
   535  			})
   536  		})
   537  
   538  		Context("when an http error occurs mid-transfer", func() {
   539  			var expectedErr error
   540  			const UploadSize = 33 * 1024
   541  
   542  			BeforeEach(func() {
   543  				expectedErr = errors.New("some read error")
   544  
   545  				wrapper := &wrapper.CustomWrapper{
   546  					CustomMake: func(connection cloudcontroller.Connection, request *cloudcontroller.Request, response *cloudcontroller.Response) error {
   547  						defer GinkgoRecover() // Since this will be running in a thread
   548  
   549  						if strings.HasSuffix(request.URL.String(), "/v2/apps/some-app-guid/bits?async=true") {
   550  							defer request.Body.Close()
   551  							readBytes, err := ioutil.ReadAll(request.Body)
   552  							Expect(err).ToNot(HaveOccurred())
   553  							Expect(len(readBytes)).To(BeNumerically(">", UploadSize))
   554  							return expectedErr
   555  						}
   556  						return connection.Make(request, response)
   557  					},
   558  				}
   559  
   560  				client = NewTestClient(Config{Wrappers: []ConnectionWrapper{wrapper}})
   561  			})
   562  
   563  			It("returns the http error", func() {
   564  				_, _, err := client.UploadApplicationPackage("some-app-guid", []Resource{}, strings.NewReader(strings.Repeat("a", UploadSize)), 3)
   565  				Expect(err).To(MatchError(expectedErr))
   566  			})
   567  		})
   568  	})
   569  })