231 lines
8.6 KiB
Go
231 lines
8.6 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gpt-load/internal/platform/httpclient"
|
|
)
|
|
|
|
func TestNewClientUsesFixedManagedTransportContract(t *testing.T) {
|
|
manager := httpclient.NewHTTPClientManager()
|
|
client := NewClient(manager, "http://proxy.example:8080")
|
|
if client.endpoint != modelsDevEndpoint {
|
|
t.Fatalf("production endpoint = %q, want %q", client.endpoint, modelsDevEndpoint)
|
|
}
|
|
if client.httpClient.Timeout != 30*time.Second {
|
|
t.Fatalf("request timeout = %s, want 30s", client.httpClient.Timeout)
|
|
}
|
|
transport, ok := client.httpClient.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("transport = %T, want *http.Transport", client.httpClient.Transport)
|
|
}
|
|
if transport.ResponseHeaderTimeout != 30*time.Second || transport.DisableCompression {
|
|
t.Fatalf("transport header timeout/compression = %s/%t", transport.ResponseHeaderTimeout, transport.DisableCompression)
|
|
}
|
|
request := &http.Request{URL: &url.URL{Scheme: "https", Host: "models.dev"}}
|
|
proxy, err := transport.Proxy(request)
|
|
if err != nil || proxy == nil || proxy.String() != "http://proxy.example:8080" {
|
|
t.Fatalf("transport proxy = %v, %v", proxy, err)
|
|
}
|
|
}
|
|
|
|
func TestClientSyncSendsConditionalHeadersAndReturnsValidated200(t *testing.T) {
|
|
raw := []byte(`{"openai":{"id":"openai","name":"OpenAI","models":{}}}`)
|
|
body := &trackedReadCloser{Reader: strings.NewReader(string(raw))}
|
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
if request.Method != http.MethodGet || request.URL.String() != "https://catalog.test/api.json" {
|
|
t.Fatalf("request = %s %s", request.Method, request.URL)
|
|
}
|
|
if got := request.Header.Get("Accept"); got != "application/json" {
|
|
t.Fatalf("Accept = %q", got)
|
|
}
|
|
if got := request.Header.Get("If-None-Match"); got != `"old"` {
|
|
t.Fatalf("If-None-Match = %q", got)
|
|
}
|
|
if got := request.Header.Get("If-Modified-Since"); got != "Mon, 03 Aug 2026 00:00:00 GMT" {
|
|
t.Fatalf("If-Modified-Since = %q", got)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Etag": {`"new"`}, "Last-Modified": {"Mon, 03 Aug 2026 01:00:00 GMT"}},
|
|
Body: body,
|
|
ContentLength: int64(len(raw)),
|
|
Request: request,
|
|
}, nil
|
|
})
|
|
client := newClientForTest(
|
|
&http.Client{Transport: transport},
|
|
"https://catalog.test/api.json",
|
|
func() time.Time { return time.UnixMilli(1_754_180_400_123) },
|
|
)
|
|
previous := Metadata{ETag: `"old"`, LastModified: "Mon, 03 Aug 2026 00:00:00 GMT", SuccessfulFetchAtMillis: 17}
|
|
|
|
result, err := client.Sync(context.Background(), previous)
|
|
if err != nil {
|
|
t.Fatalf("Sync() error = %v", err)
|
|
}
|
|
if result.NotModified || result.Snapshot == nil || result.Snapshot.Providers["openai"].Name != "OpenAI" {
|
|
t.Fatalf("Sync() result = %#v", result)
|
|
}
|
|
if string(result.RawJSON) != string(raw) {
|
|
t.Fatalf("raw JSON = %s, want %s", result.RawJSON, raw)
|
|
}
|
|
wantMetadata := Metadata{
|
|
ETag: `"new"`,
|
|
LastModified: "Mon, 03 Aug 2026 01:00:00 GMT",
|
|
CheckedAtMillis: 1_754_180_400_123,
|
|
SuccessfulFetchAtMillis: 1_754_180_400_123,
|
|
}
|
|
if result.Metadata != wantMetadata {
|
|
t.Fatalf("metadata = %#v, want %#v", result.Metadata, wantMetadata)
|
|
}
|
|
if !body.closed {
|
|
t.Fatal("response body was not closed")
|
|
}
|
|
}
|
|
|
|
func TestClientSyncTreats304AsSuccessfulCheckAndPreservesValidators(t *testing.T) {
|
|
body := &trackedReadCloser{Reader: strings.NewReader("ignored")}
|
|
client := newClientForTest(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusNotModified, Header: http.Header{"Etag": {`"must-not-replace"`}}, Body: body, Request: request}, nil
|
|
})}, "https://catalog.test/api.json", func() time.Time { return time.UnixMilli(99) })
|
|
previous := Metadata{ETag: `"old"`, LastModified: "yesterday", CheckedAtMillis: 1, SuccessfulFetchAtMillis: 2}
|
|
|
|
result, err := client.Sync(context.Background(), previous)
|
|
if err != nil {
|
|
t.Fatalf("Sync() error = %v", err)
|
|
}
|
|
want := previous
|
|
want.CheckedAtMillis = 99
|
|
if !result.NotModified || result.Metadata != want || result.RawJSON != nil || result.Snapshot != nil {
|
|
t.Fatalf("304 result = %#v, want metadata %#v and no payload", result, want)
|
|
}
|
|
if !body.closed {
|
|
t.Fatal("304 response body was not closed")
|
|
}
|
|
}
|
|
|
|
func TestClientSyncRejectsNonSuccessCancellationAndOversizeBodies(t *testing.T) {
|
|
t.Run("non success", func(t *testing.T) {
|
|
body := &trackedReadCloser{Reader: strings.NewReader("upstream detail")}
|
|
client := newClientForTest(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusBadGateway, Body: body, Request: request}, nil
|
|
})}, "https://catalog.test/api.json", time.Now)
|
|
if _, err := client.Sync(context.Background(), Metadata{}); err == nil {
|
|
t.Fatal("Sync() accepted non-200/304 response")
|
|
}
|
|
if !body.closed {
|
|
t.Fatal("non-success response body was not closed")
|
|
}
|
|
})
|
|
|
|
t.Run("cancellation", func(t *testing.T) {
|
|
client := newClientForTest(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
<-request.Context().Done()
|
|
return nil, request.Context().Err()
|
|
})}, "https://catalog.test/api.json", time.Now)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := client.Sync(ctx, Metadata{}); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Sync() error = %v, want context.Canceled", err)
|
|
}
|
|
})
|
|
|
|
t.Run("declared content length", func(t *testing.T) {
|
|
body := &trackedReadCloser{Reader: strings.NewReader(`{}`)}
|
|
client := newClientForTest(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: body, ContentLength: maxCatalogBodyBytes + 1, Request: request}, nil
|
|
})}, "https://catalog.test/api.json", time.Now)
|
|
if _, err := client.Sync(context.Background(), Metadata{}); err == nil || !strings.Contains(err.Error(), "32 MiB") {
|
|
t.Fatalf("Sync() error = %v, want 32 MiB limit", err)
|
|
}
|
|
if !body.closed {
|
|
t.Fatal("oversize response body was not closed")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown content length", func(t *testing.T) {
|
|
body := &trackedReadCloser{Reader: io.LimitReader(repeatingReader(' '), maxCatalogBodyBytes+1)}
|
|
client := newClientForTest(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: body, ContentLength: -1, Request: request}, nil
|
|
})}, "https://catalog.test/api.json", time.Now)
|
|
if _, err := client.Sync(context.Background(), Metadata{}); err == nil || !strings.Contains(err.Error(), "32 MiB") {
|
|
t.Fatalf("Sync() error = %v, want streaming 32 MiB limit", err)
|
|
}
|
|
if !body.closed {
|
|
t.Fatal("streaming oversize response body was not closed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClientSyncUsesManagedSameOriginRedirectPolicy(t *testing.T) {
|
|
targetCalls := 0
|
|
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
targetCalls++
|
|
writer.WriteHeader(http.StatusOK)
|
|
_, _ = writer.Write([]byte(`{}`))
|
|
}))
|
|
defer target.Close()
|
|
|
|
source := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
switch request.URL.Path {
|
|
case "/same":
|
|
http.Redirect(writer, request, "/final", http.StatusFound)
|
|
case "/cross":
|
|
http.Redirect(writer, request, target.URL+"/final", http.StatusFound)
|
|
case "/final":
|
|
writer.WriteHeader(http.StatusOK)
|
|
_, _ = writer.Write([]byte(`{}`))
|
|
default:
|
|
http.NotFound(writer, request)
|
|
}
|
|
}))
|
|
defer source.Close()
|
|
|
|
client := NewClient(httpclient.NewHTTPClientManager(), "")
|
|
client.endpoint = source.URL + "/same"
|
|
if _, err := client.Sync(context.Background(), Metadata{}); err != nil {
|
|
t.Fatalf("same-origin redirect Sync() error = %v", err)
|
|
}
|
|
client.endpoint = source.URL + "/cross"
|
|
if _, err := client.Sync(context.Background(), Metadata{}); err == nil {
|
|
t.Fatal("cross-origin redirect Sync() error = nil")
|
|
}
|
|
if targetCalls != 0 {
|
|
t.Fatalf("cross-origin redirect target received %d requests", targetCalls)
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return function(request)
|
|
}
|
|
|
|
type trackedReadCloser struct {
|
|
io.Reader
|
|
closed bool
|
|
}
|
|
|
|
func (body *trackedReadCloser) Close() error {
|
|
body.closed = true
|
|
return nil
|
|
}
|
|
|
|
type repeatingReader byte
|
|
|
|
func (reader repeatingReader) Read(buffer []byte) (int, error) {
|
|
for index := range buffer {
|
|
buffer[index] = byte(reader)
|
|
}
|
|
return len(buffer), nil
|
|
}
|