1package helix 2 3import ( 4 "errors" 5 "net/http" 6 "net/http/httptest" 7 "reflect" 8 "strconv" 9 "testing" 10 "time" 11) 12 13type mockHTTPClient struct { 14 mockHandler http.HandlerFunc 15} 16 17func (mtc *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { 18 rr := httptest.NewRecorder() 19 handler := http.HandlerFunc(mtc.mockHandler) 20 handler.ServeHTTP(rr, req) 21 22 return rr.Result(), nil 23} 24 25func newMockClient(options *Options, mockHandler http.HandlerFunc) *Client { 26 options.HTTPClient = &mockHTTPClient{mockHandler} 27 return &Client{opts: options} 28} 29 30func newMockHandler(statusCode int, json string, headers map[string]string) http.HandlerFunc { 31 return func(w http.ResponseWriter, r *http.Request) { 32 if headers != nil && len(headers) > 0 { 33 for key, value := range headers { 34 w.Header().Add(key, value) 35 } 36 } 37 38 w.WriteHeader(statusCode) 39 w.Write([]byte(json)) 40 } 41} 42 43func TestNewClient(t *testing.T) { 44 t.Parallel() 45 46 testCases := []struct { 47 extpectErr bool 48 options *Options 49 }{ 50 { 51 true, 52 &Options{}, // no client id 53 }, 54 { 55 false, 56 &Options{ 57 ClientID: "my-client-id", 58 ClientSecret: "my-client-secret", 59 HTTPClient: &http.Client{}, 60 AppAccessToken: "my-app-access-token", 61 UserAccessToken: "my-user-access-token", 62 UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36", 63 RateLimitFunc: func(*Response) error { return nil }, 64 Scopes: []string{"analytics:read:games", "bits:read", "clips:edit", "user:edit", "user:read:email"}, 65 RedirectURI: "http://localhost/auth/callback", 66 }, 67 }, 68 } 69 70 for _, testCase := range testCases { 71 client, err := NewClient(testCase.options) 72 if err != nil && !testCase.extpectErr { 73 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 74 } 75 76 if testCase.extpectErr { 77 continue 78 } 79 80 opts := client.opts 81 82 if opts.ClientID != testCase.options.ClientID { 83 t.Errorf("expected clientID to be \"%s\", got \"%s\"", testCase.options.ClientID, opts.ClientID) 84 } 85 86 if opts.ClientSecret != testCase.options.ClientSecret { 87 t.Errorf("expected clientSecret to be \"%s\", got \"%s\"", testCase.options.ClientSecret, opts.ClientSecret) 88 } 89 90 if reflect.TypeOf(opts.RateLimitFunc).Kind() != reflect.Func { 91 t.Errorf("expected rateLimitFunc to be a function, got \"%+v\"", reflect.TypeOf(opts.RateLimitFunc).Kind()) 92 } 93 94 if opts.HTTPClient != testCase.options.HTTPClient { 95 t.Errorf("expected httpClient to be \"%s\", got \"%s\"", testCase.options.HTTPClient, opts.HTTPClient) 96 } 97 98 if opts.UserAgent != testCase.options.UserAgent { 99 t.Errorf("expected userAgent to be \"%s\", got \"%s\"", testCase.options.UserAgent, opts.UserAgent) 100 } 101 102 if opts.AppAccessToken != testCase.options.AppAccessToken { 103 t.Errorf("expected accessToken to be \"%s\", got \"%s\"", testCase.options.AppAccessToken, opts.AppAccessToken) 104 } 105 106 if opts.UserAccessToken != testCase.options.UserAccessToken { 107 t.Errorf("expected accessToken to be \"%s\", got \"%s\"", testCase.options.UserAccessToken, opts.UserAccessToken) 108 } 109 110 if len(opts.Scopes) != len(testCase.options.Scopes) { 111 t.Errorf("expected \"%d\" scopes, got \"%d\"", len(testCase.options.Scopes), len(opts.Scopes)) 112 } 113 114 if opts.RedirectURI != testCase.options.RedirectURI { 115 t.Errorf("expected redirectURI to be \"%s\", got \"%s\"", testCase.options.RedirectURI, opts.RedirectURI) 116 } 117 } 118} 119 120func TestNewClientDefault(t *testing.T) { 121 t.Parallel() 122 123 options := &Options{ClientID: "my-client-id"} 124 125 client, err := NewClient(options) 126 if err != nil { 127 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 128 } 129 130 opts := client.opts 131 132 if opts.ClientID != options.ClientID { 133 t.Errorf("expected clientID to be \"%s\", got \"%s\"", options.ClientID, opts.ClientID) 134 } 135 136 if opts.ClientSecret != "" { 137 t.Errorf("expected clientSecret to be \"%s\", got \"%s\"", options.ClientSecret, opts.ClientSecret) 138 } 139 140 if opts.UserAgent != "" { 141 t.Errorf("expected userAgent to be \"%s\", got \"%s\"", "", opts.UserAgent) 142 } 143 144 if opts.UserAccessToken != "" { 145 t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.UserAccessToken) 146 } 147 148 if opts.HTTPClient != http.DefaultClient { 149 t.Errorf("expected httpClient to be \"%v\", got \"%v\"", http.DefaultClient, opts.HTTPClient) 150 } 151 152 if opts.RateLimitFunc != nil { 153 t.Errorf("expected rateLimitFunc to be \"%v\", got \"%v\"", nil, opts.RateLimitFunc) 154 } 155 156 if len(opts.Scopes) != len(options.Scopes) { 157 t.Errorf("expected \"%d\" scopes, got \"%d\"", len(options.Scopes), len(opts.Scopes)) 158 } 159 160 if opts.RedirectURI != options.RedirectURI { 161 t.Errorf("expected redirectURI to be \"%s\", got \"%s\"", options.RedirectURI, opts.RedirectURI) 162 } 163} 164 165func TestRatelimitCallback(t *testing.T) { 166 t.Parallel() 167 168 respBody1 := `{"error":"Too Many Requests","status":429,"message":"Request limit exceeded"}` 169 options1 := &Options{ 170 ClientID: "my-client-id", 171 RateLimitFunc: func(resp *Response) error { 172 return nil 173 }, 174 } 175 176 c := newMockClient(options1, newMockHandler(http.StatusTooManyRequests, respBody1, nil)) 177 go func() { 178 _, err := c.GetStreams(&StreamsParams{}) 179 if err != nil { 180 t.Errorf("Did not expect error, got \"%s\"", err.Error()) 181 } 182 }() 183 184 time.Sleep(5 * time.Millisecond) 185 186 respBody2 := `{"data":[{"id":"EncouragingPluckySlothSSSsss","url":"https://clips.twitch.tv/EncouragingPluckySlothSSSsss","embed_url":"https://clips.twitch.tv/embed?clip=EncouragingPluckySlothSSSsss","broadcaster_id":"26490481","creator_id":"143839181","video_id":"222004532","game_id":"490377","language":"en","title":"summit and fat tim discover how to use maps","view_count":81808,"created_at":"2018-01-25T04:04:15Z","thumbnail_url":"https://clips-media-assets.twitch.tv/182509178-preview-480x272.jpg"}]}` 187 options2 := &Options{ 188 ClientID: "my-client-id", 189 } 190 191 c = newMockClient(options2, newMockHandler(http.StatusOK, respBody2, nil)) 192 _, err := c.GetStreams(&StreamsParams{}) 193 if err != nil { 194 t.Errorf("Did not expect error, got \"%s\"", err.Error()) 195 } 196} 197 198func TestRatelimitCallbackFailsOnError(t *testing.T) { 199 t.Parallel() 200 201 errMsg := "Oops! Your rate limiter funciton is broken :(" 202 respBody1 := `{"error":"Too Many Requests","status":429,"message":"Request limit exceeded"}` 203 options1 := &Options{ 204 ClientID: "my-client-id", 205 RateLimitFunc: func(resp *Response) error { 206 return errors.New(errMsg) 207 }, 208 } 209 210 c := newMockClient(options1, newMockHandler(http.StatusTooManyRequests, respBody1, nil)) 211 _, err := c.GetStreams(&StreamsParams{}) 212 if err == nil { 213 t.Error("Expected error, got none") 214 } 215 216 if err.Error() != errMsg { 217 t.Errorf("Expected error to be \"%s\", got \"%s\"", errMsg, err.Error()) 218 } 219} 220 221func TestSetRequestHeaders(t *testing.T) { 222 t.Parallel() 223 224 testCases := []struct { 225 endpoint string 226 method string 227 userBearerToken string 228 appBearerToken string 229 }{ 230 {"/users", "GET", "my-user-access-token", "my-app-access-token"}, 231 {"/entitlements/upload", "POST", "", "my-app-access-token"}, 232 {"/streams", "GET", "", ""}, 233 } 234 235 for _, testCase := range testCases { 236 client, err := NewClient(&Options{ 237 ClientID: "my-client-id", 238 UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36", 239 AppAccessToken: testCase.appBearerToken, 240 UserAccessToken: testCase.userBearerToken, 241 }) 242 if err != nil { 243 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 244 } 245 246 req, _ := http.NewRequest(testCase.method, testCase.endpoint, nil) 247 client.setRequestHeaders(req) 248 249 if testCase.userBearerToken != "" { 250 expectedAuthHeader := "Bearer " + testCase.userBearerToken 251 if req.Header.Get("Authorization") != expectedAuthHeader { 252 t.Errorf("expected Authorization header to be \"%s\", got \"%s\"", expectedAuthHeader, req.Header.Get("Authorization")) 253 } 254 } 255 256 if testCase.userBearerToken == "" && testCase.appBearerToken != "" { 257 expectedAuthHeader := "Bearer " + testCase.appBearerToken 258 if req.Header.Get("Authorization") != expectedAuthHeader { 259 t.Errorf("expected Authorization header to be \"%s\", got \"%s\"", expectedAuthHeader, req.Header.Get("Authorization")) 260 } 261 } 262 263 if testCase.userBearerToken == "" && testCase.appBearerToken == "" { 264 if req.Header.Get("Authorization") != "" { 265 t.Error("did not expect Authorization header to be set") 266 } 267 } 268 269 if req.Header.Get("User-Agent") != client.opts.UserAgent { 270 t.Errorf("expected User-Agent header to be \"%s\", got \"%s\"", client.opts.UserAgent, req.Header.Get("User-Agent")) 271 } 272 } 273} 274 275func TestGetRateLimitHeaders(t *testing.T) { 276 t.Parallel() 277 278 testCases := []struct { 279 statusCode int 280 options *Options 281 Logins []string 282 respBody string 283 headerLimit string 284 headerRemaining string 285 headerReset string 286 }{ 287 { 288 http.StatusOK, 289 &Options{ClientID: "my-client-id"}, 290 []string{"summit1g"}, 291 `{"data":[{"id":"26490481","login":"summit1g","display_name":"summit1g","type":"","broadcaster_type":"partner","description":"I'm a competitive CounterStrike player who likes to play casually now and many other games. You will mostly see me play CS, H1Z1,and single player games at night. There will be many othergames played on this stream in the future as they come out:D","profile_image_url":"https://static-cdn.jtvnw.net/jtv_user_pictures/200cea12142f2384-profile_image-300x300.png","offline_image_url":"https://static-cdn.jtvnw.net/jtv_user_pictures/summit1g-channel_offline_image-e2f9a1df9e695ec1-1920x1080.png","view_count":202707885}]}`, 292 "30", 293 "29", 294 "1529183210", 295 }, 296 { 297 http.StatusOK, 298 &Options{ClientID: "my-client-id"}, 299 []string{"summit1g"}, 300 `{"data":[{"id":"26490481","login":"summit1g","display_name":"summit1g","type":"","broadcaster_type":"partner","description":"I'm a competitive CounterStrike player who likes to play casually now and many other games. You will mostly see me play CS, H1Z1,and single player games at night. There will be many othergames played on this stream in the future as they come out:D","profile_image_url":"https://static-cdn.jtvnw.net/jtv_user_pictures/200cea12142f2384-profile_image-300x300.png","offline_image_url":"https://static-cdn.jtvnw.net/jtv_user_pictures/summit1g-channel_offline_image-e2f9a1df9e695ec1-1920x1080.png","view_count":202707885}]}`, 301 "", 302 "", 303 "", 304 }, 305 } 306 307 for _, testCase := range testCases { 308 mockRespHeaders := make(map[string]string) 309 310 if testCase.headerLimit != "" { 311 mockRespHeaders["Ratelimit-Limit"] = testCase.headerLimit 312 mockRespHeaders["Ratelimit-Remaining"] = testCase.headerRemaining 313 mockRespHeaders["Ratelimit-Reset"] = testCase.headerReset 314 } 315 316 c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, mockRespHeaders)) 317 318 resp, err := c.GetUsers(&UsersParams{ 319 Logins: testCase.Logins, 320 }) 321 if err != nil { 322 t.Error(err) 323 } 324 325 expctedHeaderLimit, _ := strconv.Atoi(testCase.headerLimit) 326 if resp.GetRateLimit() != expctedHeaderLimit { 327 t.Errorf("expected \"Ratelimit-Limit\" to be \"%d\", got \"%d\"", expctedHeaderLimit, resp.GetRateLimit()) 328 } 329 330 expctedHeaderRemaining, _ := strconv.Atoi(testCase.headerRemaining) 331 if resp.GetRateLimitRemaining() != expctedHeaderRemaining { 332 t.Errorf("expected \"Ratelimit-Remaining\" to be \"%d\", got \"%d\"", expctedHeaderRemaining, resp.GetRateLimitRemaining()) 333 } 334 335 expctedHeaderReset, _ := strconv.Atoi(testCase.headerReset) 336 if resp.GetRateLimitReset() != expctedHeaderReset { 337 t.Errorf("expected \"Ratelimit-Reset\" to be \"%d\", got \"%d\"", expctedHeaderReset, resp.GetRateLimitReset()) 338 } 339 } 340} 341 342type badMockHTTPClient struct { 343 mockHandler http.HandlerFunc 344} 345 346func (mtc *badMockHTTPClient) Do(req *http.Request) (*http.Response, error) { 347 return nil, errors.New("Oops, that's bad :(") 348} 349 350func TestFailedHTTPClientDoRequest(t *testing.T) { 351 t.Parallel() 352 353 options := &Options{ 354 ClientID: "my-client-id", 355 HTTPClient: &badMockHTTPClient{ 356 newMockHandler(0, "", nil), 357 }, 358 } 359 360 c := &Client{ 361 opts: options, 362 } 363 364 _, err := c.GetUsers(&UsersParams{ 365 Logins: []string{"summit1g"}, 366 }) 367 if err == nil { 368 t.Error("expected error but got nil") 369 } 370 371 if err.Error() != "Failed to execute API request: Oops, that's bad :(" { 372 t.Error("expected error does match return error") 373 } 374} 375 376func TestDecodingBadJSON(t *testing.T) { 377 t.Parallel() 378 379 // Invalid JSON (missing `"` from the beginning) 380 c := newMockClient(&Options{ClientID: "my-client-id"}, newMockHandler(http.StatusOK, `data":["some":"data"]}`, nil)) 381 382 _, err := c.GetUsers(&UsersParams{ 383 Logins: []string{"summit1g"}, 384 }) 385 if err == nil { 386 t.Error("expected error but got nil") 387 } 388 389 if err.Error() != "Failed to decode API response: invalid character 'd' looking for beginning of value" { 390 t.Error("expected error does match return error") 391 } 392} 393 394func TestSetAppAccessToken(t *testing.T) { 395 t.Parallel() 396 397 accessToken := "my-app-access-token" 398 399 client, err := NewClient(&Options{ 400 ClientID: "cid", 401 }) 402 if err != nil { 403 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 404 } 405 406 client.SetAppAccessToken(accessToken) 407 408 if client.opts.AppAccessToken != accessToken { 409 t.Errorf("expected accessToken to be \"%s\", got \"%s\"", accessToken, client.opts.AppAccessToken) 410 } 411} 412 413func TestSetUserAccessToken(t *testing.T) { 414 t.Parallel() 415 416 accessToken := "my-user-access-token" 417 418 client, err := NewClient(&Options{ 419 ClientID: "cid", 420 }) 421 if err != nil { 422 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 423 } 424 425 client.SetUserAccessToken(accessToken) 426 427 if client.opts.UserAccessToken != accessToken { 428 t.Errorf("expected accessToken to be \"%s\", got \"%s\"", accessToken, client.opts.UserAccessToken) 429 } 430} 431 432func TestSetUserAgent(t *testing.T) { 433 t.Parallel() 434 435 client, err := NewClient(&Options{ 436 ClientID: "cid", 437 }) 438 if err != nil { 439 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 440 } 441 442 userAgent := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36" 443 client.SetUserAgent(userAgent) 444 445 if client.opts.UserAgent != userAgent { 446 t.Errorf("expected accessToken to be \"%s\", got \"%s\"", userAgent, client.opts.UserAgent) 447 } 448} 449 450func TestSetScopes(t *testing.T) { 451 t.Parallel() 452 453 client, err := NewClient(&Options{ 454 ClientID: "cid", 455 }) 456 if err != nil { 457 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 458 } 459 460 scopes := []string{"analytics:read:games", "bits:read", "clips:edit", "user:edit", "user:read:email"} 461 client.SetScopes(scopes) 462 463 if len(client.opts.Scopes) != len(scopes) { 464 t.Errorf("expected \"%d\" scopes, got \"%d\"", len(scopes), len(client.opts.Scopes)) 465 } 466} 467 468func TestSetRedirectURI(t *testing.T) { 469 t.Parallel() 470 471 client, err := NewClient(&Options{ 472 ClientID: "cid", 473 }) 474 if err != nil { 475 t.Errorf("Did not expect an error, got \"%s\"", err.Error()) 476 } 477 478 redirectURI := "http://localhost/auth/callback" 479 client.SetRedirectURI(redirectURI) 480 481 if client.opts.RedirectURI != redirectURI { 482 t.Errorf("expected redirectURI to be \"%s\", got \"%s\"", redirectURI, client.opts.RedirectURI) 483 } 484} 485 486func TestHydrateRequestCommon(t *testing.T) { 487 t.Parallel() 488 var sourceResponse Response 489 sampleStatusCode := 200 490 sampleHeaders := http.Header{} 491 sampleHeaders.Set("Content-Type", "application/json") 492 sampleError := "foo" 493 sampleErrorStatus := 1 494 sampleErrorMessage := "something done broke" 495 sourceResponse.ResponseCommon.StatusCode = sampleStatusCode 496 sourceResponse.ResponseCommon.Header = sampleHeaders 497 sourceResponse.ResponseCommon.Error = sampleError 498 sourceResponse.ResponseCommon.ErrorStatus = sampleErrorStatus 499 sourceResponse.ResponseCommon.ErrorMessage = sampleErrorMessage 500 501 var targetResponse Response 502 sourceResponse.HydrateResponseCommon(&targetResponse.ResponseCommon) 503 if targetResponse.StatusCode != sampleStatusCode { 504 t.Errorf("expected StatusCode to be \"%d\", got \"%d\"", sampleStatusCode, targetResponse.ResponseCommon.StatusCode) 505 } 506 507 if targetResponse.Header.Get("Content-Type") != "application/json" { 508 t.Errorf("expected headers to match") 509 } 510 511 if targetResponse.Error != sampleError { 512 t.Errorf("expected Error to be \"%s\", got \"%s\"", sampleError, targetResponse.ResponseCommon.Error) 513 } 514 515 if targetResponse.ErrorStatus != sampleErrorStatus { 516 t.Errorf("expected ErrorStatus to be \"%d\", got \"%d\"", sampleErrorStatus, targetResponse.ResponseCommon.ErrorStatus) 517 } 518 519 if targetResponse.ErrorMessage != sampleErrorMessage { 520 t.Errorf("expected ErrorMessage to be \"%s\", got \"%s\"", sampleErrorMessage, targetResponse.ResponseCommon.ErrorMessage) 521 } 522} 523