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