Files
kandev/apps/backend/internal/github/pat_client_testserver_test.go
T
2026-08-14 17:04:04 +01:00

153 lines
4.9 KiB
Go

package github
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync"
"testing"
)
func TestTokenClientFetchRateLimitSeedsAllBuckets(t *testing.T) {
client, requests := newRecordingPATServer(t, map[string]string{
"/rate_limit": `{"resources":{
"core":{"limit":5000,"remaining":4321,"reset":2000000000},
"graphql":{"limit":5000,"remaining":4900,"reset":2000000000},
"search":{"limit":30,"remaining":25,"reset":2000000000}
}}`,
})
tracker := NewRateTracker(nil, nil)
client.WithRateTracker(tracker)
if err := client.FetchRateLimit(context.Background()); err != nil {
t.Fatalf("FetchRateLimit: %v", err)
}
if len(*requests) != 1 || (*requests)[0].Method != http.MethodGet || (*requests)[0].Path != "/rate_limit" {
t.Fatalf("requests = %+v, want one GET /rate_limit", *requests)
}
for resource, wantRemaining := range map[Resource]int{
ResourceCore: 4321,
ResourceGraphQL: 4900,
ResourceSearch: 25,
} {
snapshot, ok := tracker.Snapshot(resource)
if !ok {
t.Fatalf("missing %s rate-limit snapshot", resource)
}
if snapshot.Remaining != wantRemaining {
t.Errorf("%s remaining = %d, want %d", resource, snapshot.Remaining, wantRemaining)
}
}
}
// patRequest records what a PATClient actually put on the wire so a test can
// assert method, path, query, headers and body rather than only the decoded
// result.
type patRequest struct {
Method string
Path string
Query string
Header http.Header
Body string
}
// newRecordingPATServer serves the given path→body map and records every
// request. Any path outside the map returns 404 with a JSON body, which the
// PATClient decode helpers surface as *GitHubAPIError.
func newRecordingPATServer(t *testing.T, routes map[string]string) (*PATClient, *[]patRequest) {
t.Helper()
return newRecordingPATServerFunc(t, func(r *http.Request) (int, string) {
body, ok := routes[r.URL.Path]
if !ok {
return http.StatusNotFound, `{"message":"Not Found"}`
}
return http.StatusOK, body
})
}
// newRecordingPATServerFunc is the general form: respond decides the status
// and body per request, and every request is still recorded. Assertions run in
// the calling goroutine off the returned slice — a handler goroutine must
// never call t.Fatal.
func newRecordingPATServerFunc(
t *testing.T, respond func(*http.Request) (int, string),
) (*PATClient, *[]patRequest) {
t.Helper()
var recorded []patRequest
// GetPRFeedback fans its three upstream reads out concurrently, so the
// recording slice is written from several handler goroutines at once.
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
recorded = append(recorded, patRequest{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Header: r.Header.Clone(),
Body: string(body),
})
mu.Unlock()
status, respBody := respond(r)
w.WriteHeader(status)
_, _ = w.Write([]byte(respBody))
}))
t.Cleanup(srv.Close)
return newPATClientPointingAt(t, srv.URL), &recorded
}
// newLinkPaginatedPATServer serves `pages` in order from a single endpoint,
// advertising the next page through a GitHub-shaped `Link: <...>; rel="next"`
// header on every response but the last. It exercises getPaginated's
// follow-the-link loop.
func newLinkPaginatedPATServer(
t *testing.T, path string, pages []string,
) (*PATClient, *[]patRequest) {
t.Helper()
var recorded []patRequest
served := 0
// getPaginated follows Link headers strictly in order — request N+1 is only
// sent after N's response is fully read — so these handler goroutines never
// overlap today. The mutex mirrors newRecordingPATServer and keeps that from
// becoming a silent race if a future test drives this server concurrently.
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
recorded = append(recorded, patRequest{
Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Header: r.Header.Clone(),
})
index := served
if r.URL.Path == path && served < len(pages) {
served++
}
mu.Unlock()
if r.URL.Path != path || index >= len(pages) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"Not Found"}`))
return
}
if index < len(pages)-1 {
next := githubAPIBase + path + "?per_page=100&page=" + strconv.Itoa(index+2)
w.Header().Set("Link", "<"+next+`>; rel="next"`)
}
_, _ = w.Write([]byte(pages[index]))
}))
t.Cleanup(srv.Close)
return newPATClientPointingAt(t, srv.URL), &recorded
}
// parseQueryValues decodes a raw query string, failing the test on malformed
// input so callers can assert on individual parameters.
func parseQueryValues(t *testing.T, rawQuery string) url.Values {
t.Helper()
values, err := url.ParseQuery(rawQuery)
if err != nil {
t.Fatalf("parse query %q: %v", rawQuery, err)
}
return values
}