clone

an unofficial pure go sdk for the typesafe api: typed answers, retries and no dependencies

main
typesafe-go / client_test.go
client_test.go 6.3K
1package typesafe23import (4 "context"5 "encoding/json/jsontext"6 "encoding/json/v2"7 "errors"8 "fmt"9 "net/http"10 "net/http/httptest"11 "strings"12 "sync"13 "sync/atomic"14 "testing"15 "time"16)1718const testResponse = `{"model":"jev-test","answers":{"urgent":{"type":"noul","noul":0}},"usage":{"input_tokens":3,"output_tokens":1}}`1920func testClient(t *testing.T, handler http.HandlerFunc, options ...Option) *Client {21 t.Helper()22 server := httptest.NewServer(handler)23 t.Cleanup(server.Close)24 defaults := []Option{WithAPIKey("test-key"), WithBaseURL(server.URL), WithMaxRetries(0)}25 client, err := NewClient(append(defaults, options...)...)26 if err != nil {27 t.Fatal(err)28 }29 return client30}3132func testRequest() SystemOneRequest {33 return SystemOneRequest{State: "a ticket", Questions: map[string]Question{34 "urgent": NoulQuestion{Instructions: "Is this urgent?"},35 }}36}3738func writeResponse(t *testing.T, w http.ResponseWriter, body string) {39 t.Helper()40 if _, err := fmt.Fprint(w, body); err != nil {41 t.Error(err)42 }43}4445func TestSystemOneWireContract(t *testing.T) {46 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {47 if r.Method != http.MethodPost || r.URL.Path != "/v1/systemone" {48 t.Errorf("unexpected route: %s %s", r.Method, r.URL.Path)49 }50 if got := r.Header.Get("Authorization"); got != "Bearer test-key" {51 t.Errorf("authorization: %q", got)52 }53 if got := r.Header.Get("Content-Type"); got != "application/json" {54 t.Errorf("content type: %q", got)55 }56 var body map[string]jsontext.Value57 if err := json.UnmarshalRead(r.Body, &body); err != nil {58 t.Error(err)59 }60 if string(body["model"]) != `"jev-custom"` || string(body["state"]) != `"a ticket"` {61 t.Errorf("unexpected body: %s", body)62 }63 w.Header().Set("x-request-id", "req_test")64 writeResponse(t, w, testResponse)65 }, WithModel("jev-custom"))66 response, err := client.SystemOne(context.Background(), testRequest())67 if err != nil {68 t.Fatal(err)69 }70 answer, err := response.Noul("urgent")71 if err != nil || answer.Noul != 0 || response.RequestID != "req_test" {72 t.Fatalf("response: %#v; answer: %#v; error: %v", response, answer, err)73 }74}7576func TestListModels(t *testing.T) {77 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {78 if r.Method != http.MethodGet || r.URL.Path != "/v1/models" {79 t.Errorf("unexpected route: %s %s", r.Method, r.URL.Path)80 }81 w.Header().Set("request-id", "req_models")82 writeResponse(t, w, `{"models":[{"name":"jev-test","description":"Test model","release_date":"2026-09-17"}]}`)83 })84 response, err := client.ListModels(context.Background())85 if err != nil {86 t.Fatal(err)87 }88 if len(response.Models) != 1 || response.Models[0].Name != "jev-test" || response.RequestID != "req_models" {89 t.Fatalf("unexpected response: %#v", response)90 }91}9293func TestModelOverrideAndBasePath(t *testing.T) {94 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {95 if r.URL.Path != "/proxy/v1/systemone" {96 t.Errorf("path: %q", r.URL.Path)97 }98 var body struct {99 Model string `json:"model"`100 }101 if err := json.UnmarshalRead(r.Body, &body); err != nil {102 t.Error(err)103 }104 if body.Model != "pinned-model" {105 t.Errorf("model: %q", body.Model)106 }107 writeResponse(t, w, testResponse)108 }))109 defer server.Close()110 client, err := NewClient(WithAPIKey("key"), WithBaseURL(server.URL+"/proxy/"))111 if err != nil {112 t.Fatal(err)113 }114 request := testRequest()115 request.Model = "pinned-model"116 if _, err := client.SystemOne(context.Background(), request); err != nil {117 t.Fatal(err)118 }119}120121func TestAPIErrorPreservesMetadata(t *testing.T) {122 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {123 w.Header().Set("x-request-id", "req_bad")124 w.WriteHeader(http.StatusUnprocessableEntity)125 writeResponse(t, w, `{"detail":"private request content"}`)126 })127 _, err := client.SystemOne(context.Background(), testRequest())128 apiErr, ok := errors.AsType[*APIError](err)129 if !ok {130 t.Fatalf("expected APIError, got %v", err)131 }132 if apiErr.StatusCode != 422 || apiErr.RequestID != "req_bad" || !strings.Contains(string(apiErr.Body), "private") {133 t.Fatalf("missing error metadata: %#v", apiErr)134 }135 if strings.Contains(err.Error(), "private") || strings.Contains(err.Error(), "test-key") {136 t.Fatalf("error leaked content: %s", err)137 }138}139140func TestMalformedResponses(t *testing.T) {141 for _, body := range []string{"", "not JSON", `null`, `{}`, testResponse + `{}`, `{"models":null}`} {142 t.Run(body, func(t *testing.T) {143 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {144 w.Header().Set("x-request-id", "req_decode")145 writeResponse(t, w, body)146 })147 _, err := client.SystemOne(context.Background(), testRequest())148 responseErr, ok := errors.AsType[*ResponseError](err)149 if !ok || responseErr.RequestID != "req_decode" {150 t.Fatalf("expected response error and request ID, got %v", err)151 }152 })153 }154}155156func TestCancellationAndTimeout(t *testing.T) {157 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {158 <-r.Context().Done()159 }, WithTimeout(20*time.Millisecond))160 ctx, cancel := context.WithCancel(context.Background())161 cancel()162 if _, err := client.ListModels(ctx); !errors.Is(err, context.Canceled) {163 t.Fatalf("canceled context: %v", err)164 }165 if _, err := client.ListModels(context.Background()); !errors.Is(err, context.DeadlineExceeded) {166 t.Fatalf("timeout must wrap DeadlineExceeded: %v", err)167 }168}169170func TestRedirectDoesNotReplayCredentials(t *testing.T) {171 var reached atomic.Bool172 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {173 reached.Store(true)174 }))175 defer target.Close()176 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {177 http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)178 })179 _, err := client.SystemOne(context.Background(), testRequest())180 apiErr, ok := errors.AsType[*APIError](err)181 if reached.Load() || !ok || apiErr.StatusCode != 307 {182 t.Fatalf("redirect was followed or unexpected error: %v", err)183 }184}185186func TestClientConcurrentUse(t *testing.T) {187 client := testClient(t, func(w http.ResponseWriter, r *http.Request) {188 writeResponse(t, w, testResponse)189 })190 var group sync.WaitGroup191 for range 12 {192 group.Go(func() {193 if _, err := client.SystemOne(context.Background(), testRequest()); err != nil {194 t.Error(err)195 }196 })197 }198 group.Wait()199}