mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 00:01:24 +00:00
233 lines
7.7 KiB
Go
233 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizePath(t *testing.T) {
|
|
cases := map[string]string{
|
|
"/campaigns": "/v1/campaigns",
|
|
"campaigns": "/v1/campaigns",
|
|
"/v1/campaigns": "/v1/campaigns",
|
|
"/v2/campaigns": "/v2/campaigns",
|
|
// "/verify" starts with /v but is not a version, so it must be
|
|
// prefixed rather than treated as v-something.
|
|
"/verify": "/v1/verify",
|
|
}
|
|
for in, want := range cases {
|
|
if got := NormalizePath(in); got != want {
|
|
t.Errorf("NormalizePath(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestErrorEnvelopeSurvives(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
fmt.Fprint(w, `{"error":"forbidden","message":"missing scope","code":"insufficient_scope","request_id":"req_123"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
_, err := c.Do(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"})
|
|
var apiErr *Error
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("got %T, want *api.Error", err)
|
|
}
|
|
if apiErr.Code != "insufficient_scope" || apiErr.RequestID != "req_123" {
|
|
t.Errorf("the machine-readable fields were lost: %+v", apiErr)
|
|
}
|
|
if !strings.Contains(apiErr.Error(), "req_123") {
|
|
t.Errorf("the request id must reach the message: %s", apiErr.Error())
|
|
}
|
|
if StatusOf(err) != http.StatusForbidden {
|
|
t.Errorf("StatusOf = %d", StatusOf(err))
|
|
}
|
|
}
|
|
|
|
func TestAnonymousRequestSendsNoBearer(t *testing.T) {
|
|
var sawAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sawAuth = r.Header.Get("Authorization")
|
|
fmt.Fprint(w, `{}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "", "test")
|
|
if _, err := c.Do(context.Background(), Request{Method: http.MethodPost, Path: "/auth/cli/code", Anonymous: true}); err != nil {
|
|
t.Fatalf("anonymous request failed: %v", err)
|
|
}
|
|
if sawAuth != "" {
|
|
t.Errorf("an anonymous request carried %q", sawAuth)
|
|
}
|
|
|
|
// A normal request with no token must fail before it reaches the network.
|
|
if _, err := c.Do(context.Background(), Request{Method: http.MethodGet, Path: "/me"}); err == nil {
|
|
t.Error("a request with no token should not be attempted")
|
|
}
|
|
}
|
|
|
|
func TestPaginateFollowsTheCursor(t *testing.T) {
|
|
pages := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cursor := r.URL.Query().Get("cursor")
|
|
switch cursor {
|
|
case "":
|
|
pages++
|
|
fmt.Fprint(w, `{"data":[{"id":"a"},{"id":"b"}],"pagination":{"has_more":true,"next_cursor":"c2"}}`)
|
|
case "c2":
|
|
pages++
|
|
fmt.Fprint(w, `{"data":[{"id":"c"}],"pagination":{"has_more":false,"next_cursor":null}}`)
|
|
default:
|
|
t.Errorf("unexpected cursor %q", cursor)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 10)
|
|
if err != nil {
|
|
t.Fatalf("paginate: %v", err)
|
|
}
|
|
if pages != 2 {
|
|
t.Errorf("fetched %d pages, want 2", pages)
|
|
}
|
|
var doc struct {
|
|
Data []map[string]string `json:"data"`
|
|
Pagination struct {
|
|
HasMore bool `json:"has_more"`
|
|
} `json:"pagination"`
|
|
}
|
|
if err := json.Unmarshal(merged, &doc); err != nil {
|
|
t.Fatalf("merged payload is not JSON: %v", err)
|
|
}
|
|
if len(doc.Data) != 3 {
|
|
t.Errorf("merged %d rows, want 3", len(doc.Data))
|
|
}
|
|
if doc.Pagination.HasMore {
|
|
t.Error("the merged envelope must report no more pages")
|
|
}
|
|
}
|
|
|
|
// An endpoint that does not use the cursor envelope comes back untouched
|
|
// rather than being flattened into an empty list.
|
|
func TestPaginateLeavesNonListPayloadsAlone(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"count":7}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
out, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/unibox/count"}, 10)
|
|
if err != nil {
|
|
t.Fatalf("paginate: %v", err)
|
|
}
|
|
if !strings.Contains(string(out), `"count":7`) {
|
|
t.Errorf("payload was rewritten: %s", out)
|
|
}
|
|
}
|
|
|
|
// A long --all walk will meet the per-key minute budget. The client waits the
|
|
// Retry-After rather than returning a partial list the caller cannot
|
|
// distinguish from a complete one.
|
|
func TestPaginateWaitsOutARateLimit(t *testing.T) {
|
|
limited := true
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if limited {
|
|
limited = false
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
fmt.Fprint(w, `{"code":"rate_limit_exceeded","message":"slow down"}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"id":"a"}],"pagination":{"has_more":false,"next_cursor":null}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 5)
|
|
if err != nil {
|
|
t.Fatalf("paginate should have waited and retried: %v", err)
|
|
}
|
|
if !strings.Contains(string(merged), `"id":"a"`) {
|
|
t.Errorf("the retried page was lost: %s", merged)
|
|
}
|
|
}
|
|
|
|
// A rate limit that never clears must still end, rather than looping until
|
|
// max-pages with the caller none the wiser.
|
|
func TestPaginateGivesUpOnAPermanentRateLimit(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
fmt.Fprint(w, `{"code":"rate_limit_exceeded"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
if _, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 2); err == nil {
|
|
t.Fatal("a permanent rate limit must surface as an error")
|
|
}
|
|
}
|
|
|
|
// A walk cut short by --max-pages has to say so. A caller that cannot tell a
|
|
// truncated list from a complete one will act on a partial answer.
|
|
func TestPaginateReportsTruncation(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Always another page.
|
|
fmt.Fprint(w, `{"data":[{"id":"x"}],"pagination":{"has_more":true,"next_cursor":"more"}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 2)
|
|
if err != nil {
|
|
t.Fatalf("paginate: %v", err)
|
|
}
|
|
var doc struct {
|
|
Data []map[string]string `json:"data"`
|
|
Pagination struct {
|
|
HasMore bool `json:"has_more"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
Total int `json:"total"`
|
|
} `json:"pagination"`
|
|
}
|
|
if err := json.Unmarshal(merged, &doc); err != nil {
|
|
t.Fatalf("merged payload: %v", err)
|
|
}
|
|
if len(doc.Data) != 2 {
|
|
t.Errorf("collected %d rows over 2 pages, want 2", len(doc.Data))
|
|
}
|
|
if !doc.Pagination.HasMore {
|
|
t.Error("a walk stopped by max-pages must report has_more")
|
|
}
|
|
if doc.Pagination.NextCursor == nil || *doc.Pagination.NextCursor != "more" {
|
|
t.Errorf("the cursor to resume from was lost: %+v", doc.Pagination.NextCursor)
|
|
}
|
|
}
|
|
|
|
// And a walk that genuinely ran out still reports the end.
|
|
func TestPaginateReportsCompletion(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"data":[{"id":"x"}],"pagination":{"has_more":false,"next_cursor":null}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL, "wmbly_x", "test")
|
|
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 10)
|
|
if err != nil {
|
|
t.Fatalf("paginate: %v", err)
|
|
}
|
|
if !strings.Contains(string(merged), `"has_more":false`) || !strings.Contains(string(merged), `"next_cursor":null`) {
|
|
t.Errorf("a completed walk must report the end: %s", merged)
|
|
}
|
|
}
|