mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
test: testify table-driven coverage for low-coverage branches
Lifts all 14 functions the operator flagged as <75% (measured at ed2119c)
to >=75%, using table-driven tests (require for fatals, assert for checks):
- limits.go: failPolicyGet (50.0% -> 100.0%)
- provider_gemini.go: httpClient (0.0% -> 100.0%)
- redis_store.go: get (71.4% -> 78.6%)
- resp.go: dialTimeoutFor, respDeadlineExceededErr.{Error,Timeout,Temporary},
ensureConnLocked, getBatch (66.7-30.0% -> 91.3-100.0%)
- routes_media.go: resolveMediaRequest (50.0% -> 100.0%)
- routes_unified.go: unifiedCostMicros (66.7% -> 100.0%)
- translate_anthropic.go: isTruthy, toFloat64 (50.0/33.3% -> 100.0%)
- translate_gemini.go: geminiToolConfigFromOpenAI (66.7% -> 100.0%)
resp_test.go adds a hand-rolled fakeConn (net.Conn with injectable
Write/SetDeadline errors) to reach two respClient error branches a real
TCP connection cannot be coaxed into deterministically.
Total coverage: 90.7% -> 92.4% (no regression; report has the full
per-file table). Zero production-code changes. Existing tests untouched:
every diff is a pure addition of new test functions and their imports.
Gates: go test -count=1 (pass), -race (pass, 5x incl. an isolated
baseline-vs-branch comparison after one flaky run implicated pre-existing
registry_test.go timing tests this task never touched), gofmt/vet (clean),
golangci-lint incl. gosec and fieldalignment (0 issues), make yaegi-check
(OK), make integration (pass, untouched-green).
This commit is contained in:
@@ -6,6 +6,9 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLimiterRequestWindow is the brief's Step-1 case: a per-minute
|
||||
@@ -632,3 +635,28 @@ func TestLimitsConfigValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLimiter_FailPolicyGet covers failPolicyGet's own fail-open/fail-closed
|
||||
// branching in isolation (storeGet's error/latched paths already drive it
|
||||
// indirectly elsewhere in this file): failOpen=true reads through to the
|
||||
// in-process fallback, failOpen=false refuses outright.
|
||||
func TestLimiter_FailPolicyGet(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
failOpen bool
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "failOpen true reads the fallback", failOpen: true, wantOK: true},
|
||||
{name: "failOpen false refuses", failOpen: false, wantOK: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
l := newLimiter(nil, c.failOpen)
|
||||
v, ok := l.failPolicyGet("k")
|
||||
require.Equal(t, c.wantOK, ok)
|
||||
if c.wantOK {
|
||||
assert.Equal(t, int64(0), v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNewGeminiAdapter_KeylessIsAConstructorError proves an empty resolved
|
||||
@@ -566,3 +569,13 @@ func TestGeminiAdapter_ListModels(t *testing.T) {
|
||||
t.Errorf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiAdapter_HTTPClient proves httpClient returns the adapter's own
|
||||
// *http.Client — the one newGeminiAdapter constructed via
|
||||
// newAdapterHTTPClient — not a fresh or nil client.
|
||||
func TestGeminiAdapter_HTTPClient(t *testing.T) {
|
||||
a, err := newGeminiAdapter("p1", "https://generativelanguage.googleapis.com", "AIza-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, a.httpClient())
|
||||
assert.Same(t, a.client, a.httpClient())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package traefikllmgateway
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRedisStore_IncrBy_PipelinesIncrbyAndExpire is the brief's Step-1
|
||||
@@ -166,3 +168,17 @@ func TestLimiter_FailOpenTrue_HungRedisServer_FallsBackQuickly(t *testing.T) {
|
||||
t.Fatalf("elapsed = %v, want < 3s (one respCallTimeout for the first op, the second latched and skipping the network entirely)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedisStore_Get_DownServer_ReturnsError asserts get surfaces a
|
||||
// transport failure (dead address) as an error, mirroring incrBy's own
|
||||
// contract for the same failure — the limiter's storeGet relies on this to
|
||||
// treat a Redis outage as a store error rather than a false zero reading.
|
||||
func TestRedisStore_Get_DownServer_ReturnsError(t *testing.T) {
|
||||
ln := newFakeListener(t)
|
||||
deadAddr := ln.Addr().String()
|
||||
require.NoError(t, ln.Close())
|
||||
|
||||
store := newRedisStore(newRESPClient(deadAddr, "", 0))
|
||||
_, err := store.get("k")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
+252
@@ -3,6 +3,7 @@ package traefikllmgateway
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -10,6 +11,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEncodeCommand pins the exact RESP2 wire format every command must
|
||||
@@ -594,3 +598,251 @@ func TestRESPClient_GetBytes_DownServer_ReturnsError(t *testing.T) {
|
||||
t.Fatal("want an error dialing a dead address")
|
||||
}
|
||||
}
|
||||
|
||||
// --- fakeConn: a net.Conn whose Write/SetDeadline fail on command, for
|
||||
// the handful of respClient error branches a real TCP connection cannot be
|
||||
// coaxed into deterministically (a write failing on an otherwise-live
|
||||
// connection, SetDeadline failing on a reused connection) ---
|
||||
|
||||
// fakeAddr is a trivial net.Addr for fakeConn's LocalAddr/RemoteAddr.
|
||||
type fakeAddr struct{}
|
||||
|
||||
func (fakeAddr) Network() string { return "fake" }
|
||||
func (fakeAddr) String() string { return "fake" }
|
||||
|
||||
// fakeConn implements net.Conn, returning writeErr from Write and
|
||||
// setDeadlineErr from SetDeadline when set, so a test can drive respClient
|
||||
// methods directly against a connection already known bad — bypassing
|
||||
// ensureConnLocked's real net.DialTimeout, which always succeeds against a
|
||||
// live listener and so cannot itself be made to fail this way.
|
||||
type fakeConn struct {
|
||||
writeErr error
|
||||
setDeadlineErr error
|
||||
}
|
||||
|
||||
func (fakeConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
|
||||
func (c fakeConn) Write(b []byte) (int, error) {
|
||||
if c.writeErr != nil {
|
||||
return 0, c.writeErr
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (fakeConn) Close() error { return nil }
|
||||
func (fakeConn) LocalAddr() net.Addr { return fakeAddr{} }
|
||||
func (fakeConn) RemoteAddr() net.Addr { return fakeAddr{} }
|
||||
|
||||
func (c fakeConn) SetDeadline(time.Time) error {
|
||||
if c.setDeadlineErr != nil {
|
||||
return c.setDeadlineErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
// --- dialTimeoutFor / respDeadlineExceededErr ---
|
||||
|
||||
// TestDialTimeoutFor covers dialTimeoutFor's clamp: a deadline farther away
|
||||
// than respDialTimeout gets the fixed respDialTimeout, never the (larger)
|
||||
// remaining time — only a near deadline gets clamped down to the smaller
|
||||
// remaining value.
|
||||
func TestDialTimeoutFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
check func(t *testing.T, got time.Duration)
|
||||
name string
|
||||
delta time.Duration
|
||||
}{
|
||||
{
|
||||
name: "deadline farther away than respDialTimeout clamps to respDialTimeout",
|
||||
delta: 10 * time.Second,
|
||||
check: func(t *testing.T, got time.Duration) {
|
||||
assert.Equal(t, respDialTimeout, got)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deadline closer than respDialTimeout returns the remaining time",
|
||||
delta: 50 * time.Millisecond,
|
||||
check: func(t *testing.T, got time.Duration) {
|
||||
assert.Greater(t, got, time.Duration(0))
|
||||
assert.Less(t, got, respDialTimeout)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
c.check(t, dialTimeoutFor(time.Now().Add(c.delta)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespDeadlineExceededErr pins the three methods that let pipeline's
|
||||
// isTimeout check treat an already-elapsed call deadline exactly like any
|
||||
// other timed-out I/O error.
|
||||
func TestRespDeadlineExceededErr(t *testing.T) {
|
||||
var err respDeadlineExceededErr
|
||||
assert.Equal(t, "resp: call deadline already elapsed", err.Error())
|
||||
assert.True(t, err.Timeout())
|
||||
assert.True(t, err.Temporary())
|
||||
}
|
||||
|
||||
// --- ensureConnLocked / attemptPipelineLocked / handshakeLocked error
|
||||
// branches only reachable via a pre-set connection, not a real dial ---
|
||||
|
||||
// TestRESPClient_EnsureConnLocked_Errors covers ensureConnLocked's two
|
||||
// error returns that never touch the network: a reused connection whose
|
||||
// SetDeadline itself fails, and a deadline that has already elapsed before
|
||||
// a fresh dial would even start.
|
||||
func TestRESPClient_EnsureConnLocked_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
client func() *respClient
|
||||
deadline time.Time
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "reused connection: SetDeadline failure surfaces",
|
||||
client: func() *respClient {
|
||||
return &respClient{conn: fakeConn{setDeadlineErr: errors.New("stub: set deadline failed")}}
|
||||
},
|
||||
deadline: time.Now().Add(time.Second),
|
||||
wantErrContains: "set deadline",
|
||||
},
|
||||
{
|
||||
name: "no connection yet: an already-elapsed deadline never dials",
|
||||
client: func() *respClient {
|
||||
return &respClient{addr: "127.0.0.1:1"}
|
||||
},
|
||||
deadline: time.Now().Add(-time.Second),
|
||||
wantErrContains: "call deadline already elapsed",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.client().ensureConnLocked(c.deadline)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), c.wantErrContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESPClient_AttemptPipelineLocked_WriteError covers the command-write
|
||||
// failure attemptPipelineLocked returns when an already-connected socket
|
||||
// refuses a write (a broken pipe) — distinct from a dial failure, which
|
||||
// never reaches this code path.
|
||||
func TestRESPClient_AttemptPipelineLocked_WriteError(t *testing.T) {
|
||||
c := &respClient{
|
||||
conn: fakeConn{writeErr: errors.New("stub: broken pipe")},
|
||||
r: bufio.NewReader(strings.NewReader("")),
|
||||
}
|
||||
_, err := c.attemptPipelineLocked([][]string{{"GET", "k"}}, time.Now().Add(time.Second))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "write")
|
||||
}
|
||||
|
||||
// TestRESPClient_HandshakeLocked_WriteError covers handshakeLocked's own
|
||||
// write failure, the same broken-pipe shape as
|
||||
// TestRESPClient_AttemptPipelineLocked_WriteError but for the AUTH/SELECT
|
||||
// handshake command specifically.
|
||||
func TestRESPClient_HandshakeLocked_WriteError(t *testing.T) {
|
||||
c := &respClient{conn: fakeConn{writeErr: errors.New("stub: broken pipe")}}
|
||||
err := c.handshakeLocked("AUTH", "pw")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "AUTH")
|
||||
}
|
||||
|
||||
// TestRESPClient_AuthHandshakeFailure_ErrorReplySurfaces covers
|
||||
// ensureConnLocked's AUTH-failure branch and handshakeLocked's respErr
|
||||
// branch together: a RESP error reply to AUTH must fail the call, not be
|
||||
// silently treated as success. It calls attemptPipelineLocked directly
|
||||
// (one attempt, one scripted connection) rather than the public do/
|
||||
// pipeline, which would retry once more against a second connection the
|
||||
// fakeRESPServer fixture — built for a server-initiated closeConn, not a
|
||||
// client-initiated close on handshake failure — cannot script cleanly.
|
||||
func TestRESPClient_AuthHandshakeFailure_ErrorReplySurfaces(t *testing.T) {
|
||||
ln := newFakeListener(t)
|
||||
runFakeRESPServer(t, ln, []respStep{
|
||||
{wantArgs: []string{"AUTH", "wrong-pw"}, reply: []byte("-ERR invalid password\r\n")},
|
||||
})
|
||||
|
||||
c := newRESPClient(ln.Addr().String(), "wrong-pw", 0)
|
||||
_, err := c.attemptPipelineLocked([][]string{{"GET", "k"}}, time.Now().Add(2*time.Second))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "AUTH failed")
|
||||
}
|
||||
|
||||
// --- getBatch ---
|
||||
|
||||
// TestRESPClient_GetBatch covers every reply shape getBatch's decode loop
|
||||
// handles: a mix of hits and a missing key (RESP null bulk -> 0) on
|
||||
// success, and the three ways a single bad reply fails the whole batch —
|
||||
// a RESP error reply, an unexpected (non-bulk) reply type, and a bulk
|
||||
// value that is not a base-10 integer.
|
||||
func TestRESPClient_GetBatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
wantErrSubstr string
|
||||
keys []string
|
||||
steps []respStep
|
||||
want []int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "mixed hits and a missing key",
|
||||
keys: []string{"a", "b", "c"},
|
||||
steps: []respStep{
|
||||
{wantArgs: []string{"GET", "a"}, reply: []byte("$3\r\n123\r\n")},
|
||||
{wantArgs: []string{"GET", "b"}, reply: []byte("$-1\r\n")},
|
||||
{wantArgs: []string{"GET", "c"}, reply: []byte("$3\r\n456\r\n")},
|
||||
},
|
||||
want: []int64{123, 0, 456},
|
||||
},
|
||||
{
|
||||
name: "a RESP error reply fails the whole batch",
|
||||
keys: []string{"a", "b"},
|
||||
steps: []respStep{
|
||||
{wantArgs: []string{"GET", "a"}, reply: []byte("$1\r\n1\r\n")},
|
||||
{wantArgs: []string{"GET", "b"}, reply: []byte("-ERR busy\r\n")},
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrSubstr: "ERR busy",
|
||||
},
|
||||
{
|
||||
name: "an unexpected reply type fails the whole batch",
|
||||
keys: []string{"a"},
|
||||
steps: []respStep{
|
||||
{wantArgs: []string{"GET", "a"}, reply: []byte(":5\r\n")},
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrSubstr: "unexpected reply type",
|
||||
},
|
||||
{
|
||||
name: "a non-integer value fails the whole batch",
|
||||
keys: []string{"a"},
|
||||
steps: []respStep{
|
||||
{wantArgs: []string{"GET", "a"}, reply: []byte("$3\r\nabc\r\n")},
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrSubstr: "non-integer value",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ln := newFakeListener(t)
|
||||
steps := append([]respStep{{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")}}, c.steps...)
|
||||
runFakeRESPServer(t, ln, steps)
|
||||
|
||||
client := newRESPClient(ln.Addr().String(), "", 0)
|
||||
got, err := client.getBatch(c.keys)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), c.wantErrSubstr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newMultipartTranscriptionRequest builds a POST /v1/audio/transcriptions
|
||||
@@ -1035,3 +1038,56 @@ func TestAnthropicMediaEndpoints_Return501_NeverCallUpstream(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- resolveMediaRequest: the shared resolve/limit failure branches ----
|
||||
//
|
||||
// Every other media-route test above drives resolveMediaRequest's success
|
||||
// path only; these two cover its two failure returns (routes_media.go) —
|
||||
// a model no configured provider knows, and a limit violation — using
|
||||
// images.generations as the representative caller, since all three media
|
||||
// routes share the same resolveMediaRequest call.
|
||||
|
||||
// TestHandleImagesGenerations_UnknownModel_404 covers resolveMediaRequest's
|
||||
// registry.resolve error branch: a model id no configured provider knows at
|
||||
// all.
|
||||
func TestHandleImagesGenerations_UnknownModel_404(t *testing.T) {
|
||||
gw := newMediaTestGateway(t, newMediaTestConfig("http://127.0.0.1:1", "img-test"))
|
||||
|
||||
body := map[string]any{"model": "no-such-model", "prompt": "a cat"}
|
||||
req := newUnifiedRequest(t, http.MethodPost, imagesGenerationsPath, "sk-alice", body)
|
||||
rec := httptest.NewRecorder()
|
||||
gw.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestHandleImagesGenerations_LimitExceeded_429WithRetryAfter covers
|
||||
// resolveMediaRequest's checkAndCount violation branch: a second request
|
||||
// past a one-per-minute limit is refused before the adapter is ever called.
|
||||
func TestHandleImagesGenerations_LimitExceeded_429WithRetryAfter(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.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"created":1734000000,"data":[{"b64_json":"xyz"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := newMediaTestConfig(srv.URL, "img-test")
|
||||
cfg.Users = &UsersConfig{Inline: []*UserConfig{
|
||||
{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerMinute: 1}},
|
||||
}}
|
||||
gw := newMediaTestGateway(t, cfg)
|
||||
|
||||
body := map[string]any{"model": "img-test", "prompt": "a cat"}
|
||||
req1 := newUnifiedRequest(t, http.MethodPost, imagesGenerationsPath, "sk-alice", body)
|
||||
rec1 := httptest.NewRecorder()
|
||||
gw.ServeHTTP(rec1, req1)
|
||||
require.Equal(t, http.StatusOK, rec1.Code, "first request body=%s", rec1.Body.String())
|
||||
|
||||
req2 := newUnifiedRequest(t, http.MethodPost, imagesGenerationsPath, "sk-alice", body)
|
||||
rec2 := httptest.NewRecorder()
|
||||
gw.ServeHTTP(rec2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec2.Code, "body=%s", rec2.Body.String())
|
||||
assert.NotEmpty(t, rec2.Header().Get("Retry-After"))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// newUnifiedRequest builds a POST request against the unified routes,
|
||||
@@ -1609,3 +1611,48 @@ func TestCacheCaptureWriter_ImplicitStatus_CapturesContentType(t *testing.T) {
|
||||
t.Errorf("contentType = %q, want %q (must be captured on the implicit-200 path too)", cw.contentType, "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnifiedCostMicros covers ruling (d)'s two-id price lookup order:
|
||||
// canonical ("provider/model") first, falling back to bare — the upstream
|
||||
// model id alone — only when canonical has no configured price at all, in
|
||||
// neither overrides nor the built-in table.
|
||||
func TestUnifiedCostMicros(t *testing.T) {
|
||||
u := usage{prompt: 1_000_000, completion: 1_000_000}
|
||||
|
||||
cases := []struct {
|
||||
overrides map[string]*ModelPricing
|
||||
name string
|
||||
canonical string
|
||||
bare string
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "canonical override price wins over bare",
|
||||
canonical: "openai/gpt-5",
|
||||
bare: "gpt-5",
|
||||
overrides: map[string]*ModelPricing{
|
||||
"openai/gpt-5": {InputPerM: 1, OutputPerM: 2},
|
||||
},
|
||||
want: 3_000_000, // (1M prompt * $1/M) + (1M completion * $2/M), in micros
|
||||
},
|
||||
{
|
||||
name: "canonical unpriced falls back to bare's built-in price",
|
||||
canonical: "unknown-provider/gpt-5",
|
||||
bare: "gpt-5",
|
||||
overrides: nil,
|
||||
want: costMicros("gpt-5", u, nil),
|
||||
},
|
||||
{
|
||||
name: "neither id priced returns zero",
|
||||
canonical: "unknown-provider/unknown-model",
|
||||
bare: "unknown-model",
|
||||
overrides: nil,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, unifiedCostMicros(c.canonical, c.bare, u, c.overrides))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// goldenTestdataDir is where every fixture this file walks lives.
|
||||
@@ -448,3 +450,60 @@ func TestAnthropicRequestFromOpenAI_MaxCompletionTokensPrecedence(t *testing.T)
|
||||
t.Errorf("max_tokens = %v, want 200 (max_completion_tokens takes precedence)", got["max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTruthy pins isTruthy's truthy-only semantics (per its own doc
|
||||
// comment): only a present AND non-empty value counts as "the client
|
||||
// actually asked for this" — nil, false, 0, "", and an empty map/slice
|
||||
// must not trip a truthy-only unsupported-field check.
|
||||
func TestIsTruthy(t *testing.T) {
|
||||
cases := []struct {
|
||||
v any
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{name: "nil is falsy", v: nil, want: false},
|
||||
{name: "bool true is truthy", v: true, want: true},
|
||||
{name: "bool false is falsy", v: false, want: false},
|
||||
{name: "nonzero float64 is truthy", v: float64(1.5), want: true},
|
||||
{name: "zero float64 is falsy", v: float64(0), want: false},
|
||||
{name: "nonempty string is truthy", v: "x", want: true},
|
||||
{name: "empty string is falsy", v: "", want: false},
|
||||
{name: "nonempty map is truthy", v: map[string]any{"a": 1}, want: true},
|
||||
{name: "empty map is falsy", v: map[string]any{}, want: false},
|
||||
{name: "nonempty slice is truthy", v: []any{1}, want: true},
|
||||
{name: "empty slice is falsy", v: []any{}, want: false},
|
||||
{name: "an unrecognized non-nil type is conservatively truthy", v: 42, want: true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, isTruthy(c.v))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestToFloat64 covers every numeric shape toFloat64 accepts — the normal
|
||||
// post-json.Unmarshal float64, plus the Go-native int/int64/float32 shapes
|
||||
// a test or hand-built request can supply — and the fallback for anything
|
||||
// else.
|
||||
func TestToFloat64(t *testing.T) {
|
||||
cases := []struct {
|
||||
v any
|
||||
name string
|
||||
want float64
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "float64", v: float64(3.5), want: 3.5, wantOK: true},
|
||||
{name: "float32", v: float32(2.5), want: 2.5, wantOK: true},
|
||||
{name: "int", v: int(7), want: 7, wantOK: true},
|
||||
{name: "int64", v: int64(9), want: 9, wantOK: true},
|
||||
{name: "unsupported type returns ok=false", v: "7", want: 0, wantOK: false},
|
||||
{name: "nil returns ok=false", v: nil, want: 0, wantOK: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := toFloat64(c.v)
|
||||
assert.Equal(t, c.wantOK, ok)
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// geminiGoldenTestdataDir is where every fixture this file walks lives.
|
||||
@@ -508,3 +510,60 @@ func TestGeminiEmbeddingInputTexts_RejectsNonStringArrayEntry(t *testing.T) {
|
||||
t.Fatalf("err = %v (%T), want *translateError", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiToolConfigFromOpenAI covers every tool_choice shape the request
|
||||
// mapping table lists: the three recognized string modes, a function-name
|
||||
// object, and the two "no mapping" cases (an unrecognized string, and an
|
||||
// object without a valid function.name) that must return nil so the caller
|
||||
// omits toolConfig entirely rather than sending a malformed one.
|
||||
func TestGeminiToolConfigFromOpenAI(t *testing.T) {
|
||||
cases := []struct {
|
||||
tc any
|
||||
want map[string]any
|
||||
name string
|
||||
}{
|
||||
{
|
||||
name: "none",
|
||||
tc: "none",
|
||||
want: map[string]any{"functionCallingConfig": map[string]any{"mode": "NONE"}},
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
tc: "auto",
|
||||
want: map[string]any{"functionCallingConfig": map[string]any{"mode": "AUTO"}},
|
||||
},
|
||||
{
|
||||
name: "required",
|
||||
tc: "required",
|
||||
want: map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY"}},
|
||||
},
|
||||
{
|
||||
name: "function object names the allowed function",
|
||||
tc: map[string]any{"type": "function", "function": map[string]any{"name": "get_weather"}},
|
||||
want: map[string]any{"functionCallingConfig": map[string]any{
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": []any{"get_weather"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "unrecognized string has no mapping",
|
||||
tc: "bogus",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "object without a function.name has no mapping",
|
||||
tc: map[string]any{"type": "function"},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "an unrecognized type has no mapping",
|
||||
tc: 42,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, geminiToolConfigFromOpenAI(c.tc))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user