diff --git a/pkgs/joke/jokedev.go b/pkgs/joke/jokedev.go index 09ac718..d35e5a6 100644 --- a/pkgs/joke/jokedev.go +++ b/pkgs/joke/jokedev.go @@ -10,7 +10,10 @@ import ( "time" ) -const devServiceName = "jokedev" +const ( + rateLimitRemainingHeaderName = "RateLimit-Remaining" + jokeDevAPIUrlTemplate = "https://v2.jokeapi.dev/joke/%s?type=%s" +) type jokeMapper interface { Joke() Joke @@ -26,21 +29,21 @@ type jokeApiFlags struct { } type jokeApiSingleResponse struct { - Error bool `json:"error"` - CategoryRes string `json:"category"` - Type string `json:"type"` - Flags jokeApiFlags `json:"flags"` - Id int `json:"id"` - Safe bool `json:"safe"` - Lang string `json:"lang"` - Content string `json:"joke"` + Error bool `json:"error"` + Category string `json:"category"` + Type string `json:"type"` + Flags jokeApiFlags `json:"flags"` + Id int `json:"id"` + Safe bool `json:"safe"` + Lang string `json:"lang"` + Content string `json:"joke"` } func (j jokeApiSingleResponse) Joke() Joke { return Joke{ Answer: j.Content, Type: Single, - Category: Category(j.CategoryRes), + Category: Category(j.Category), } } @@ -99,7 +102,7 @@ func (d *DevService) Get(ctx context.Context, search SearchParameters) (Joke, er search.Category = Any } - jokeDevApiURL := fmt.Sprintf("https://v2.jokeapi.dev/joke/%s?type=%s", search.Category, search.Category) + jokeDevApiURL := fmt.Sprintf(jokeDevAPIUrlTemplate, search.Category, search.Type) req, err := http.NewRequestWithContext(ctx, http.MethodGet, jokeDevApiURL, nil) if err != nil { return Joke{}, err @@ -112,12 +115,11 @@ func (d *DevService) Get(ctx context.Context, search SearchParameters) (Joke, er defer res.Body.Close() // Check if daily limit exceeded - if res.StatusCode == http.StatusTooManyRequests || res.Header["RateLimit-Remaining"][0] == "0" { - resetTime, err := time.Parse("Sun, 06 Nov 1994 08:49:37 GMT", res.Header["RateLimit-Reset"][0]) - if err != nil { - return Joke{}, err - } + isLimitExceeded := len(res.Header[rateLimitRemainingHeaderName]) > 0 && res.Header[rateLimitRemainingHeaderName][0] == "0" + if res.StatusCode == http.StatusTooManyRequests || isLimitExceeded { + const rateLimitReset = "RateLimit-Reset" + resetTime := prepareResetTime(res.Header[rateLimitReset]) d.active = false go unlockService(d.globalCtx, &d.active, resetTime) @@ -125,6 +127,10 @@ func (d *DevService) Get(ctx context.Context, search SearchParameters) (Joke, er return Joke{}, DevServiceLimitExceededErr } + if res.StatusCode >= 400 { + return Joke{}, errors.New("jokedev: client or server side error") + } + resBody, err := io.ReadAll(res.Body) if err != nil { return Joke{}, err @@ -133,15 +139,36 @@ func (d *DevService) Get(ctx context.Context, search SearchParameters) (Joke, er var jokeMapper jokeMapper switch search.Type { case Single: - jokeMapper = jokeApiSingleResponse{} + singleRes := jokeApiSingleResponse{} + + err = json.Unmarshal(resBody, &singleRes) + + jokeMapper = singleRes case TwoPart: - jokeMapper = jokeApiTwoPartResponse{} + twoPartRes := &jokeApiTwoPartResponse{} + + err = json.Unmarshal(resBody, &twoPartRes) + + jokeMapper = twoPartRes } - err = json.Unmarshal(resBody, &jokeMapper) if err != nil { return Joke{}, err } return jokeMapper.Joke(), nil } + +func prepareResetTime(rateLimitReset []string) (resetTime time.Time) { + var err error + if len(rateLimitReset) > 0 { + resetTime, err = time.Parse("Sun, 06 Nov 1994 08:49:37 GMT", rateLimitReset[0]) + if err != nil { + resetTime = time.Now().Add(24 * time.Hour) + } + } else { + resetTime = time.Now().Add(24 * time.Hour) + } + + return resetTime +} diff --git a/pkgs/joke/jokedev_test.go b/pkgs/joke/jokedev_test.go new file mode 100644 index 0000000..37abc15 --- /dev/null +++ b/pkgs/joke/jokedev_test.go @@ -0,0 +1,130 @@ +package joke + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/jarcoal/httpmock" + "net/http" + "os" + "strconv" + "testing" +) + +var testSingleJokeDev = jokeApiSingleResponse{ + Error: false, + Category: "Any", + Type: "single", + Flags: jokeApiFlags{}, + Id: 0, + Safe: false, + Lang: "", + Content: "testContent", +} + +var testJokeDevUrl = fmt.Sprintf(jokeDevAPIUrlTemplate, testJokeSearch.Category, testJokeSearch.Type) + +func TestDevService_Get(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + response, err := json.Marshal(testSingleJokeDev) + if err != nil { + t.Fatal(err) + } + + httpmock.RegisterResponder("GET", + testJokeDevUrl, + httpmock.NewBytesResponder(http.StatusOK, response)) + + os.Setenv(humorAPIKey, "123") + + ctx := context.Background() + service := NewJokeDevService(ctx) + + joke, err := service.Get(ctx, testJokeSearch) + if err != nil { + t.Fatal(err) + } + + if joke.Answer != testSingleJokeDev.Content { + t.Fatalf("Invalid joke response. Expected: '%s', Result: '%s'", testSingleJokeDev.Content, joke.Answer) + } + + if joke.Category != Category(testSingleJokeDev.Category) { + t.Fatalf("Invalid category. Expected: '%s', Result: '%s'", testJokeSearch, joke.Category) + } +} + +func TestDevService_GetButApiReturnInvalidStatus(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + badResponses := []int{http.StatusTooManyRequests, http.StatusPaymentRequired, http.StatusBadRequest, http.StatusForbidden, http.StatusInternalServerError} + + httpmock.RegisterResponder("GET", + testJokeDevUrl, + httpmock.NewStringResponder(http.StatusOK, "")) + + os.Setenv(humorAPIKey, "123") + + for _, status := range badResponses { + t.Run("API responses status "+strconv.Itoa(status), func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := NewJokeDevService(ctx) + + if _, err := service.Get(ctx, testJokeSearch); err == nil { + t.Fatal("service didn't handle correct a bad/invalid http status") + } + }) + } +} + +func TestDevService_GetButApiLimitWasExceeded(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", + testJokeDevUrl, + httpmock.NewStringResponder(http.StatusTooManyRequests, "").HeaderAdd(http.Header{xAPIQuotaLeftHeaderName: []string{"0"}})) + + os.Setenv(humorAPIKey, "123") + + ctx := context.Background() + service := NewJokeDevService(ctx) + + if _, err := service.Get(ctx, testJokeSearch); !errors.Is(err, DevServiceLimitExceededErr) { + t.Fatal(err) + } +} + +func TestDevService_Active(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", + testJokeDevUrl, + httpmock.NewStringResponder(http.StatusTooManyRequests, "").HeaderAdd(http.Header{rateLimitRemainingHeaderName: []string{"0"}})) + + os.Setenv(humorAPIKey, "123") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := NewJokeDevService(ctx) + + if _, err := service.Get(ctx, testJokeSearch); !errors.Is(err, DevServiceLimitExceededErr) { + t.Fatal(err) + } + + if _, err := service.Get(ctx, testJokeSearch); !errors.Is(err, DevServiceLimitExceededErr) { + t.Fatal(err) + } + + if httpmock.GetTotalCallCount() != 1 { + t.Fatalf("service call DevService after got information about limitation exceeded. Call API %d times", httpmock.GetTotalCallCount()) + } +}