From aee3908bc4426ab455c60325b1e0e024f38d7ef5 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 20 Aug 2026 21:33:08 +0100 Subject: [PATCH] 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). --- limits_test.go | 28 ++++ provider_gemini_test.go | 13 ++ redis_store_test.go | 16 +++ resp_test.go | 252 ++++++++++++++++++++++++++++++++++++ routes_media_test.go | 56 ++++++++ routes_unified_test.go | 47 +++++++ translate_anthropic_test.go | 59 +++++++++ translate_gemini_test.go | 59 +++++++++ 8 files changed, 530 insertions(+) diff --git a/limits_test.go b/limits_test.go index a3d838c..ec6c37e 100644 --- a/limits_test.go +++ b/limits_test.go @@ -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) + } + }) + } +} diff --git a/provider_gemini_test.go b/provider_gemini_test.go index 32e9d65..7173757 100644 --- a/provider_gemini_test.go +++ b/provider_gemini_test.go @@ -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()) +} diff --git a/redis_store_test.go b/redis_store_test.go index 49c497e..97a59f5 100644 --- a/redis_store_test.go +++ b/redis_store_test.go @@ -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) +} diff --git a/resp_test.go b/resp_test.go index fb15a47..55c3165 100644 --- a/resp_test.go +++ b/resp_test.go @@ -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) + }) + } +} diff --git a/routes_media_test.go b/routes_media_test.go index 4818c06..ef02098 100644 --- a/routes_media_test.go +++ b/routes_media_test.go @@ -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")) +} diff --git a/routes_unified_test.go b/routes_unified_test.go index a7aca85..bf5c0f4 100644 --- a/routes_unified_test.go +++ b/routes_unified_test.go @@ -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)) + }) + } +} diff --git a/translate_anthropic_test.go b/translate_anthropic_test.go index 02eaf37..610591e 100644 --- a/translate_anthropic_test.go +++ b/translate_anthropic_test.go @@ -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) + }) + } +} diff --git a/translate_gemini_test.go b/translate_gemini_test.go index 1fa9fa2..1faa0de 100644 --- a/translate_gemini_test.go +++ b/translate_gemini_test.go @@ -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)) + }) + } +}