mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
test(providers,zalo): wave A coverage push
Push two deferred packages above their coverage floors: - internal/providers 57.0% -> 62.2% (+5.2%, target ≥60%) Targeted pure-logic adapter transformation code: registry, openai/dashscope/codex ToRequest/FromResponse/FromStreamChunk, Azure header branch, token source propagation, function_call + incomplete + reasoning parse branches. - internal/channels/zalo 7.2% -> 65.3% (+58.1%, target ≥20%) Factory table-driven creds/config cases, New() defaults, callAPI success/error/malformed, getMe/getUpdates, sendMessage/sendPhoto, Send routing + photo extraction, Stop signal, processUpdate dispatch, handleTextMessage empty-sender drop, downloadMedia four branches (png/jpg fallback/404/empty). Minimal refactor: converted package-level apiBase const to var in internal/channels/zalo/zalo.go so tests can point at httptest server. Zero runtime behavior change; tests restore the original value via t.Cleanup. Ratchet bumps (scripts/coverage_thresholds.json): - internal/providers: 57.00 -> 62.15 - internal/channels/zalo: 7.20 -> 65.25 Race clean, dual build (default + -tags sqliteonly) clean, personal/ sub-packages untouched.
This commit is contained in:
1 parent
0ef3b5cd03
commit
abc6d62c95
8 files changed
+1588
-3
No files matched your search
@@ -0,0 +1,191 @@
|
||||
package zalo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// TestFactory_ValidCredsProducesChannel verifies Factory constructs a live
|
||||
// Channel when credentials and config JSON are well-formed.
|
||||
func TestFactory_ValidCredsProducesChannel(t *testing.T) {
|
||||
creds := []byte(`{"token":"fake-zalo-token","webhook_secret":"hook-sec"}`)
|
||||
cfg := []byte(`{"dm_policy":"open","media_max_mb":7,"allow_from":["+84900000000"],"webhook_url":"https://example.test/hook","block_reply":true}`)
|
||||
|
||||
mb := bus.New()
|
||||
ch, err := Factory("my-zalo", creds, cfg, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Factory error: %v", err)
|
||||
}
|
||||
if ch == nil {
|
||||
t.Fatal("Factory returned nil channel")
|
||||
}
|
||||
if ch.Name() != "my-zalo" {
|
||||
t.Errorf("Name() = %q, want my-zalo", ch.Name())
|
||||
}
|
||||
zc, ok := ch.(*Channel)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected channel type %T", ch)
|
||||
}
|
||||
if zc.token != "fake-zalo-token" {
|
||||
t.Errorf("token = %q, want fake-zalo-token", zc.token)
|
||||
}
|
||||
if zc.dmPolicy != "open" {
|
||||
t.Errorf("dmPolicy = %q, want open", zc.dmPolicy)
|
||||
}
|
||||
if zc.mediaMaxMB != 7 {
|
||||
t.Errorf("mediaMaxMB = %d, want 7", zc.mediaMaxMB)
|
||||
}
|
||||
if zc.blockReply == nil || !*zc.blockReply {
|
||||
t.Errorf("blockReply = %v, want true", zc.blockReply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_ErrorCases covers the error branches in order:
|
||||
// - missing token in creds
|
||||
// - malformed creds JSON
|
||||
// - malformed config JSON
|
||||
// - empty creds bytes (missing required token after unmarshal)
|
||||
func TestFactory_ErrorCases(t *testing.T) {
|
||||
mb := bus.New()
|
||||
cases := []struct {
|
||||
name string
|
||||
creds []byte
|
||||
cfg []byte
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
creds: []byte(`{"webhook_secret":"x"}`),
|
||||
cfg: []byte(`{}`),
|
||||
wantSub: "token is required",
|
||||
},
|
||||
{
|
||||
name: "malformed creds",
|
||||
creds: []byte(`{not-json`),
|
||||
cfg: []byte(`{}`),
|
||||
wantSub: "decode zalo credentials",
|
||||
},
|
||||
{
|
||||
name: "malformed config",
|
||||
creds: []byte(`{"token":"t"}`),
|
||||
cfg: []byte(`{malformed`),
|
||||
wantSub: "decode zalo config",
|
||||
},
|
||||
{
|
||||
name: "empty creds bytes",
|
||||
creds: nil,
|
||||
cfg: []byte(`{}`),
|
||||
wantSub: "token is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Factory("zalo-x", tc.creds, tc.cfg, mb, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("err = %v, want substring %q", err, tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_DefaultsWhenFieldsUnset verifies New() applies defaults:
|
||||
// - dm_policy defaults to "pairing"
|
||||
// - media_max_mb defaults to defaultMediaMaxMB when ≤0
|
||||
func TestNew_DefaultsWhenFieldsUnset(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if ch.dmPolicy != "pairing" {
|
||||
t.Errorf("dmPolicy = %q, want pairing (default)", ch.dmPolicy)
|
||||
}
|
||||
if ch.mediaMaxMB != defaultMediaMaxMB {
|
||||
t.Errorf("mediaMaxMB = %d, want %d", ch.mediaMaxMB, defaultMediaMaxMB)
|
||||
}
|
||||
// block_reply should be nil (inherit gateway default).
|
||||
if got := ch.BlockReplyEnabled(); got != nil {
|
||||
t.Errorf("BlockReplyEnabled() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_EmptyTokenRejected verifies New() rejects empty token.
|
||||
func TestNew_EmptyTokenRejected(t *testing.T) {
|
||||
mb := bus.New()
|
||||
if _, err := New(config.ZaloConfig{}, mb, nil); err == nil {
|
||||
t.Fatal("expected error on empty token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_CustomValuesPreserved verifies caller-provided values stick.
|
||||
func TestNew_CustomValuesPreserved(t *testing.T) {
|
||||
mb := bus.New()
|
||||
blockReply := false
|
||||
ch, err := New(config.ZaloConfig{
|
||||
Token: "xyz",
|
||||
DMPolicy: "open",
|
||||
MediaMaxMB: 12,
|
||||
BlockReply: &blockReply,
|
||||
}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if ch.dmPolicy != "open" {
|
||||
t.Errorf("dmPolicy = %q, want open", ch.dmPolicy)
|
||||
}
|
||||
if ch.mediaMaxMB != 12 {
|
||||
t.Errorf("mediaMaxMB = %d, want 12", ch.mediaMaxMB)
|
||||
}
|
||||
if got := ch.BlockReplyEnabled(); got == nil || *got != false {
|
||||
t.Errorf("BlockReplyEnabled() = %v, want false pointer", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactoryConfigWithoutOptionals verifies minimal config still parses —
|
||||
// zero values for non-required fields are accepted.
|
||||
func TestFactoryConfigWithoutOptionals(t *testing.T) {
|
||||
mb := bus.New()
|
||||
creds := []byte(`{"token":"t"}`)
|
||||
cfg := []byte(`{}`)
|
||||
|
||||
ch, err := Factory("zalo", creds, cfg, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Factory: %v", err)
|
||||
}
|
||||
zc := ch.(*Channel)
|
||||
if zc.dmPolicy != "pairing" {
|
||||
t.Errorf("dmPolicy = %q, want pairing (default)", zc.dmPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity guard: factory must accept the same JSON shapes that
|
||||
// zaloInstanceConfig declares; ensure deserialization matches struct tags.
|
||||
func TestZaloInstanceConfigRoundTrip(t *testing.T) {
|
||||
src := zaloInstanceConfig{
|
||||
DMPolicy: "pairing",
|
||||
WebhookURL: "https://example.test",
|
||||
MediaMaxMB: 3,
|
||||
AllowFrom: []string{"user1", "user2"},
|
||||
}
|
||||
data, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got zaloInstanceConfig
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DMPolicy != src.DMPolicy || got.MediaMaxMB != src.MediaMaxMB {
|
||||
t.Errorf("round-trip mismatch: got %+v, want %+v", got, src)
|
||||
}
|
||||
if len(got.AllowFrom) != 2 {
|
||||
t.Errorf("AllowFrom len = %d, want 2", len(got.AllowFrom))
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://bot-api.zaloplatforms.com"
|
||||
defaultPollTimeout = 30
|
||||
maxTextLength = 2000
|
||||
defaultMediaMaxMB = 5
|
||||
@@ -32,6 +31,10 @@ const (
|
||||
pairingDebounce = 60 * time.Second
|
||||
)
|
||||
|
||||
// apiBase is the Zalo Bot API root. Declared as a variable so tests can
|
||||
// override it with an httptest.NewServer URL.
|
||||
var apiBase = "https://bot-api.zaloplatforms.com"
|
||||
|
||||
// Channel connects to the Zalo OA Bot API.
|
||||
type Channel struct {
|
||||
*channels.BaseChannel
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
package zalo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// swapAPIBase points zalo.apiBase at a test server for the duration of t.
|
||||
// Restores original value automatically via t.Cleanup.
|
||||
func swapAPIBase(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
original := apiBase
|
||||
apiBase = url
|
||||
t.Cleanup(func() { apiBase = original })
|
||||
}
|
||||
|
||||
// newTestChannel returns a Channel wired to the given mock server URL.
|
||||
// Token is fixed to "t" so callers can predict the URL path:
|
||||
//
|
||||
// <base>/bott/<method>
|
||||
func newTestChannel(t *testing.T, srvURL string) *Channel {
|
||||
t.Helper()
|
||||
swapAPIBase(t, srvURL)
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t", DMPolicy: "open"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
return ch
|
||||
}
|
||||
|
||||
// TestCallAPI_SuccessRoutesOKResponse verifies callAPI marshals the body,
|
||||
// hits the correct path with the bot token, and returns apiResp.Result.
|
||||
func TestCallAPI_SuccessRoutesOKResponse(t *testing.T) {
|
||||
var gotPath, gotMethod, gotCT string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":"bot-1","name":"Zalobot"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
|
||||
raw, err := ch.callAPI("getMe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("callAPI: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/bott/getMe" {
|
||||
t.Errorf("path = %q, want /bott/getMe", gotPath)
|
||||
}
|
||||
// No body → Content-Type should not be set by callAPI.
|
||||
if gotCT != "" {
|
||||
t.Errorf("content-type = %q, want empty (no body sent)", gotCT)
|
||||
}
|
||||
// Verify raw result is forwarded verbatim.
|
||||
if !strings.Contains(string(raw), `"bot-1"`) {
|
||||
t.Errorf("raw result = %s, want bot-1", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_ErrorResponseSurfaces verifies callAPI returns an error when
|
||||
// the upstream reports ok=false.
|
||||
func TestCallAPI_ErrorResponseSurfaces(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":1001,"description":"bad token"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
_, err := ch.callAPI("getMe", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "1001") || !strings.Contains(err.Error(), "bad token") {
|
||||
t.Errorf("err = %v, want both code and description", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_MalformedJSONReturnsError verifies malformed upstream JSON
|
||||
// surfaces an unmarshal error.
|
||||
func TestCallAPI_MalformedJSONReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
_, err := ch.callAPI("getMe", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unmarshal error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMe_ParsesBotInfo verifies getMe returns the zaloBotInfo struct.
|
||||
func TestGetMe_ParsesBotInfo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":"bot-xyz","name":"TestBot"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
info, err := ch.getMe()
|
||||
if err != nil {
|
||||
t.Fatalf("getMe: %v", err)
|
||||
}
|
||||
if info.ID != "bot-xyz" || info.Name != "TestBot" {
|
||||
t.Errorf("info = %+v, want bot-xyz/TestBot", info)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMe_UnmarshalError surfaces decode errors when result is malformed.
|
||||
func TestGetMe_UnmarshalError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":"not-an-object"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
if _, err := ch.getMe(); err == nil {
|
||||
t.Fatal("expected unmarshal error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUpdates_ParsesUpdateArray verifies getUpdates decodes the updates array.
|
||||
func TestGetUpdates_ParsesUpdateArray(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":[{"event_name":"message.text.received","message":{"message_id":"m1","text":"hi","from":{"id":"user1"},"chat":{"id":"user1"}}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
updates, err := ch.getUpdates(10)
|
||||
if err != nil {
|
||||
t.Fatalf("getUpdates: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("updates len = %d, want 1", len(updates))
|
||||
}
|
||||
if updates[0].EventName != "message.text.received" {
|
||||
t.Errorf("EventName = %q", updates[0].EventName)
|
||||
}
|
||||
if updates[0].Message == nil || updates[0].Message.Text != "hi" {
|
||||
t.Errorf("Message = %+v", updates[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUpdates_UnmarshalError verifies a malformed result surfaces an error.
|
||||
func TestGetUpdates_UnmarshalError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":"string-instead-of-array"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
if _, err := ch.getUpdates(5); err == nil {
|
||||
t.Fatal("expected unmarshal error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMessage_PostsBodyWithParams verifies sendMessage forwards chat_id
|
||||
// and text through callAPI as a JSON body.
|
||||
func TestSendMessage_PostsBodyWithParams(t *testing.T) {
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
if err := ch.sendMessage("user-1", "hello"); err != nil {
|
||||
t.Fatalf("sendMessage: %v", err)
|
||||
}
|
||||
if got["chat_id"] != "user-1" {
|
||||
t.Errorf("chat_id = %v, want user-1", got["chat_id"])
|
||||
}
|
||||
if got["text"] != "hello" {
|
||||
t.Errorf("text = %v, want hello", got["text"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendPhoto_OmitsEmptyCaption verifies sendPhoto only adds "caption"
|
||||
// when it is non-empty (KISS: don't transmit empty fields).
|
||||
func TestSendPhoto_OmitsEmptyCaption(t *testing.T) {
|
||||
var gotBodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
m := map[string]any{}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
gotBodies = append(gotBodies, m)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
if err := ch.sendPhoto("chat-1", "https://cdn.example.test/a.jpg", ""); err != nil {
|
||||
t.Fatalf("sendPhoto: %v", err)
|
||||
}
|
||||
if err := ch.sendPhoto("chat-1", "https://cdn.example.test/b.jpg", "caption"); err != nil {
|
||||
t.Fatalf("sendPhoto (w/ caption): %v", err)
|
||||
}
|
||||
if len(gotBodies) != 2 {
|
||||
t.Fatalf("want 2 bodies, got %d", len(gotBodies))
|
||||
}
|
||||
if _, present := gotBodies[0]["caption"]; present {
|
||||
t.Errorf("first call should omit caption, got %v", gotBodies[0]["caption"])
|
||||
}
|
||||
if gotBodies[1]["caption"] != "caption" {
|
||||
t.Errorf("second call caption = %v, want caption", gotBodies[1]["caption"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendChunkedText_ChunksLongContent verifies long text is split into
|
||||
// multiple sendMessage calls via ChunkMarkdown + maxTextLength.
|
||||
func TestSendChunkedText_ChunksLongContent(t *testing.T) {
|
||||
var callCount int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&callCount, 1)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
// Build text larger than maxTextLength (2000) to force chunking.
|
||||
long := strings.Repeat("a", maxTextLength*2+10)
|
||||
if err := ch.sendChunkedText("chat-x", long); err != nil {
|
||||
t.Fatalf("sendChunkedText: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&callCount); got < 2 {
|
||||
t.Errorf("expected ≥2 chunked sends, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendChunkedText_PropagatesFirstError verifies that if the first
|
||||
// chunk fails, the method returns the error without attempting later chunks.
|
||||
func TestSendChunkedText_PropagatesFirstError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":500,"description":"boom"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
err := ch.sendChunkedText("chat-x", "short enough")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_NotRunningRejects verifies Send errors when the channel is not running.
|
||||
func TestSend_NotRunningRejects(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// channel is NOT Start()ed → IsRunning() is false.
|
||||
err = ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "x",
|
||||
Content: "hi",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when not running, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not running") {
|
||||
t.Errorf("err = %v, want 'not running'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_PlainTextGoesThroughSendMessage verifies Send routes plain text
|
||||
// through sendChunkedText → sendMessage → callAPI.
|
||||
func TestSend_PlainTextGoesThroughSendMessage(t *testing.T) {
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "user-7",
|
||||
Content: "**hello** world",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
// Content should have markdown stripped (StripMarkdown drops **…**).
|
||||
if text, _ := got["text"].(string); strings.Contains(text, "**") {
|
||||
t.Errorf("markdown not stripped: %q", text)
|
||||
}
|
||||
if got["chat_id"] != "user-7" {
|
||||
t.Errorf("chat_id = %v, want user-7", got["chat_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_PhotoExtractionRoutesToSendPhoto verifies [photo:URL] is
|
||||
// extracted and sent via sendPhoto instead of sendMessage.
|
||||
func TestSend_PhotoExtractionRoutesToSendPhoto(t *testing.T) {
|
||||
var lastPath string
|
||||
var lastBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
lastBody = map[string]any{}
|
||||
_ = json.Unmarshal(raw, &lastBody)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestChannel(t, srv.URL)
|
||||
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "user-8",
|
||||
Content: "look at this [photo:https://cdn.example/test.jpg] nice pic",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(lastPath, "/sendPhoto") {
|
||||
t.Errorf("path = %q, want sendPhoto", lastPath)
|
||||
}
|
||||
if lastBody["photo"] != "https://cdn.example/test.jpg" {
|
||||
t.Errorf("photo = %v", lastBody["photo"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_SignalsLoopAndTogglesRunning verifies Stop closes stopCh and
|
||||
// flips IsRunning() back to false.
|
||||
func TestStop_SignalsLoopAndTogglesRunning(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
if err := ch.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("IsRunning still true after Stop")
|
||||
}
|
||||
// stopCh should be closed — reading from a closed channel returns zero value
|
||||
// immediately instead of blocking.
|
||||
select {
|
||||
case <-ch.stopCh:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("stopCh not closed after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessUpdate_DispatchesByEventName covers the event switch: text,
|
||||
// image, and unknown events. Verifies HandleMessage is invoked for known
|
||||
// events (observed indirectly through bus inbound queue).
|
||||
func TestProcessUpdate_DispatchesByEventName(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t", DMPolicy: "open"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// Unknown event → no-op, no panic.
|
||||
ch.processUpdate(zaloUpdate{EventName: "message.unknown"})
|
||||
|
||||
// Text event with nil message → handler guard returns early without
|
||||
// panicking (nil pointer check).
|
||||
ch.processUpdate(zaloUpdate{EventName: "message.text.received"})
|
||||
|
||||
// Image event with nil message → same guard.
|
||||
ch.processUpdate(zaloUpdate{EventName: "message.image.received"})
|
||||
|
||||
// Text event with populated message → should flow through handleTextMessage.
|
||||
// We cannot easily assert the bus side without draining it, but the call
|
||||
// should not panic and should route through the happy path.
|
||||
ch.processUpdate(zaloUpdate{
|
||||
EventName: "message.text.received",
|
||||
Message: &zaloMessage{
|
||||
MessageID: "m1",
|
||||
Text: "hello",
|
||||
From: zaloFrom{ID: "u1"},
|
||||
Chat: zaloChat{ID: "u1"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleTextMessage_EmptySenderDropped verifies messages with empty
|
||||
// sender IDs are dropped (logged warning, no bus publish).
|
||||
func TestHandleTextMessage_EmptySenderDropped(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t", DMPolicy: "open"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// No panic expected.
|
||||
ch.handleTextMessage(&zaloMessage{
|
||||
MessageID: "m",
|
||||
Text: "hi",
|
||||
From: zaloFrom{ID: ""}, // missing sender
|
||||
Chat: zaloChat{ID: ""},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleImageMessage_EmptySenderDropped mirrors the text variant guard.
|
||||
func TestHandleImageMessage_EmptySenderDropped(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(config.ZaloConfig{Token: "t", DMPolicy: "open"}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ch.handleImageMessage(&zaloMessage{
|
||||
MessageID: "m",
|
||||
From: zaloFrom{ID: ""},
|
||||
})
|
||||
}
|
||||
|
||||
// TestDownloadMedia_SuccessWritesTempFile verifies downloadMedia fetches
|
||||
// the URL and persists a temp file with matching extension.
|
||||
func TestDownloadMedia_SuccessWritesTempFile(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("x"), 128)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
mb := bus.New()
|
||||
ch, _ := New(config.ZaloConfig{Token: "t"}, mb, nil)
|
||||
path, err := ch.downloadMedia(srv.URL + "/photo")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadMedia: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
|
||||
if !strings.HasSuffix(path, ".png") {
|
||||
t.Errorf("path ext = %q, want .png (from Content-Type)", path)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, payload) {
|
||||
t.Errorf("file content len = %d, want %d", len(data), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadMedia_HTTPErrorReturnsError verifies non-200 responses error out.
|
||||
func TestDownloadMedia_HTTPErrorReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
|
||||
if _, err := ch.downloadMedia(srv.URL); err == nil {
|
||||
t.Fatal("expected error on 404, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadMedia_EmptyResponseReturnsError verifies zero-byte responses
|
||||
// are treated as errors (don't leave empty temp files behind).
|
||||
func TestDownloadMedia_EmptyResponseReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
// no body
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
|
||||
if _, err := ch.downloadMedia(srv.URL); err == nil {
|
||||
t.Fatal("expected empty-response error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadMedia_FallbackJPEGExtension verifies an unknown content-type
|
||||
// defaults to .jpg extension.
|
||||
func TestDownloadMedia_FallbackJPEGExtension(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Intentionally no Content-Type → default to .jpg
|
||||
_, _ = w.Write([]byte("binary-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
|
||||
path, err := ch.downloadMedia(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadMedia: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
if !strings.HasSuffix(path, ".jpg") {
|
||||
t.Errorf("path = %q, want .jpg default", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZaloAPIResponse_Roundtrip sanity-checks the wire-type tags.
|
||||
func TestZaloAPIResponse_Roundtrip(t *testing.T) {
|
||||
src := zaloAPIResponse{
|
||||
OK: true,
|
||||
Result: json.RawMessage(`{"id":"x"}`),
|
||||
ErrorCode: 0,
|
||||
Description: "",
|
||||
}
|
||||
data, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got zaloAPIResponse
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Error("OK field lost in round-trip")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// erroringTokenSource is a TokenSource that can return a configured error.
|
||||
// Separate from codex_test.go's staticTokenSource (which returns nil error).
|
||||
type erroringTokenSource struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *erroringTokenSource) Token() (string, error) {
|
||||
return s.token, s.err
|
||||
}
|
||||
|
||||
// TestCodexAdapter_Defaults verifies empty BaseURL/Model get defaults and that
|
||||
// Name() + Capabilities() expose expected Responses-API values.
|
||||
func TestCodexAdapter_Defaults(t *testing.T) {
|
||||
a, err := NewCodexAdapter(ProviderConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexAdapter error: %v", err)
|
||||
}
|
||||
if a.Name() != "codex" {
|
||||
t.Errorf("Name() = %q, want codex", a.Name())
|
||||
}
|
||||
caps := a.Capabilities()
|
||||
if !caps.Streaming || !caps.ToolCalling || !caps.StreamWithTools {
|
||||
t.Errorf("expected full streaming+tools+stream-with-tools, got %+v", caps)
|
||||
}
|
||||
if !caps.Thinking || !caps.Vision {
|
||||
t.Errorf("expected Thinking+Vision, got %+v", caps)
|
||||
}
|
||||
if caps.MaxContextWindow != 1_000_000 {
|
||||
t.Errorf("MaxContextWindow = %d, want 1_000_000", caps.MaxContextWindow)
|
||||
}
|
||||
if caps.TokenizerID != "o200k_base" {
|
||||
t.Errorf("TokenizerID = %q, want o200k_base", caps.TokenizerID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_BaseURLTrimmed verifies trailing slashes are stripped from
|
||||
// the base URL (important because codex_build.go appends a path).
|
||||
func TestCodexAdapter_BaseURLTrimmed(t *testing.T) {
|
||||
a, err := NewCodexAdapter(ProviderConfig{BaseURL: "https://example.test/api/"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
ca, ok := a.(*CodexAdapter)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected type %T", a)
|
||||
}
|
||||
if ca.apiBase != "https://example.test/api" {
|
||||
t.Errorf("apiBase = %q, want trimmed", ca.apiBase)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_ExtractsTokenSource verifies the token source is pulled
|
||||
// from ExtraOpts["token_source"].
|
||||
func TestCodexAdapter_ExtractsTokenSource(t *testing.T) {
|
||||
ts := &erroringTokenSource{token: "tok-ok"}
|
||||
a, err := NewCodexAdapter(ProviderConfig{
|
||||
ExtraOpts: map[string]any{"token_source": TokenSource(ts)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
_, headers, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest: %v", err)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "Bearer tok-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer tok-ok", got)
|
||||
}
|
||||
if got := headers.Get("OpenAI-Beta"); got != "responses=v1" {
|
||||
t.Errorf("OpenAI-Beta = %q, want responses=v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_ToRequest_NoTokenSource verifies a missing token source
|
||||
// omits the Authorization header (caller is responsible for auth) rather
|
||||
// than erroring or panicking.
|
||||
func TestCodexAdapter_ToRequest_NoTokenSource(t *testing.T) {
|
||||
a, _ := NewCodexAdapter(ProviderConfig{})
|
||||
body, headers, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest: %v", err)
|
||||
}
|
||||
if headers.Get("Authorization") != "" {
|
||||
t.Errorf("Authorization should be empty with no token source, got %q", headers.Get("Authorization"))
|
||||
}
|
||||
if !strings.HasPrefix(headers.Get("Content-Type"), "application/json") {
|
||||
t.Errorf("Content-Type missing: %q", headers.Get("Content-Type"))
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Error("empty body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_ToRequest_TokenSourceError verifies a Token() error
|
||||
// surfaces from ToRequest (don't silently build an unauthenticated request).
|
||||
func TestCodexAdapter_ToRequest_TokenSourceError(t *testing.T) {
|
||||
boom := errors.New("token expired")
|
||||
ts := &erroringTokenSource{err: boom}
|
||||
a, _ := NewCodexAdapter(ProviderConfig{
|
||||
ExtraOpts: map[string]any{"token_source": TokenSource(ts)},
|
||||
})
|
||||
_, _, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, boom) {
|
||||
t.Errorf("err = %v, want wrapped %v", err, boom)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_FromResponse_TextAndUsage verifies a simple "message" item
|
||||
// with "output_text" content parses correctly plus usage accounting including
|
||||
// reasoning token detail.
|
||||
func TestCodexAdapter_FromResponse_TextAndUsage(t *testing.T) {
|
||||
a, _ := NewCodexAdapter(ProviderConfig{})
|
||||
raw := []byte(`{
|
||||
"id":"resp_1",
|
||||
"object":"response",
|
||||
"status":"completed",
|
||||
"output":[
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}
|
||||
],
|
||||
"usage":{
|
||||
"input_tokens":10,
|
||||
"output_tokens":3,
|
||||
"total_tokens":13,
|
||||
"output_tokens_details":{"reasoning_tokens":2}
|
||||
}
|
||||
}`)
|
||||
resp, err := a.FromResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("FromResponse: %v", err)
|
||||
}
|
||||
if resp.Content != "hello" {
|
||||
t.Errorf("Content = %q, want hello", resp.Content)
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 13 {
|
||||
t.Errorf("Usage TotalTokens = %+v, want 13", resp.Usage)
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.ThinkingTokens != 2 {
|
||||
t.Errorf("Usage ThinkingTokens = %+v, want 2", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_FromResponse_FunctionCallAndIncomplete covers two branches:
|
||||
// - a function_call output item populates ToolCalls and bumps FinishReason to "tool_calls"
|
||||
// - status="incomplete" overrides FinishReason to "length"
|
||||
func TestCodexAdapter_FromResponse_FunctionCallAndIncomplete(t *testing.T) {
|
||||
a, _ := NewCodexAdapter(ProviderConfig{})
|
||||
|
||||
t.Run("function_call sets tool_calls finish", func(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"id":"resp_2",
|
||||
"status":"completed",
|
||||
"output":[
|
||||
{"type":"function_call","call_id":"fc_abc","name":"lookup","arguments":"{\"q\":\"hi\"}"}
|
||||
]
|
||||
}`)
|
||||
resp, err := a.FromResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.Name != "lookup" {
|
||||
t.Errorf("ToolCall.Name = %q, want lookup", tc.Name)
|
||||
}
|
||||
if q, _ := tc.Arguments["q"].(string); q != "hi" {
|
||||
t.Errorf("Arguments[q] = %v, want hi", tc.Arguments["q"])
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want tool_calls", resp.FinishReason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status=incomplete sets length finish", func(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"id":"resp_3",
|
||||
"status":"incomplete",
|
||||
"output":[
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"partial"}]}
|
||||
]
|
||||
}`)
|
||||
resp, err := a.FromResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp.FinishReason != "length" {
|
||||
t.Errorf("FinishReason = %q, want length", resp.FinishReason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reasoning summary populates Thinking", func(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"id":"resp_4",
|
||||
"status":"completed",
|
||||
"output":[
|
||||
{"type":"reasoning","summary":[{"type":"text","text":"step1 "},{"type":"text","text":"step2"}]}
|
||||
]
|
||||
}`)
|
||||
resp, err := a.FromResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp.Thinking != "step1 step2" {
|
||||
t.Errorf("Thinking = %q, want %q", resp.Thinking, "step1 step2")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCodexAdapter_FromResponse_MalformedReturnsError verifies malformed JSON
|
||||
// errors out instead of panicking.
|
||||
func TestCodexAdapter_FromResponse_MalformedReturnsError(t *testing.T) {
|
||||
a, _ := NewCodexAdapter(ProviderConfig{})
|
||||
if _, err := a.FromResponse([]byte(`{not`)); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAdapter_FromStreamChunk covers the three event-dispatch branches in
|
||||
// adapter_codex.go: output_text.delta, response.completed, unknown → skip.
|
||||
func TestCodexAdapter_FromStreamChunk(t *testing.T) {
|
||||
a, _ := NewCodexAdapter(ProviderConfig{})
|
||||
|
||||
t.Run("done literal", func(t *testing.T) {
|
||||
sc, err := a.FromStreamChunk([]byte("[DONE]"))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || !sc.Done {
|
||||
t.Errorf("want Done, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text delta", func(t *testing.T) {
|
||||
raw := []byte(`{"type":"response.output_text.delta","delta":"tok"}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || sc.Content != "tok" {
|
||||
t.Errorf("want Content=tok, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty text delta skipped", func(t *testing.T) {
|
||||
raw := []byte(`{"type":"response.output_text.delta","delta":""}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc != nil {
|
||||
t.Errorf("empty delta should yield nil, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("completed event signals done", func(t *testing.T) {
|
||||
raw := []byte(`{"type":"response.completed"}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || !sc.Done {
|
||||
t.Errorf("want Done on completed, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed event signals done", func(t *testing.T) {
|
||||
raw := []byte(`{"type":"response.failed"}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || !sc.Done {
|
||||
t.Errorf("want Done on failed, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown event skipped", func(t *testing.T) {
|
||||
raw := []byte(`{"type":"response.reasoning.delta","text":"thinking"}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc != nil {
|
||||
t.Errorf("unknown event should yield nil, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed returns nil", func(t *testing.T) {
|
||||
// Decoder intentionally swallows malformed chunks.
|
||||
sc, err := a.FromStreamChunk([]byte(`not-json`))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc != nil {
|
||||
t.Errorf("want nil, got %+v", sc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCodexAdapter_ToRequest_StreamOption verifies stream option flows into body.
|
||||
func TestCodexAdapter_ToRequest_StreamOption(t *testing.T) {
|
||||
a, _ := NewCodexAdapter(ProviderConfig{})
|
||||
body, _, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{"stream": false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
// Responses-API marshals the stream flag; codex sets stream in buildRequestBody.
|
||||
if s, ok := m["stream"].(bool); !ok || s != false {
|
||||
t.Errorf("stream = %v, want false", m["stream"])
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the adapter satisfies the interface at compile time.
|
||||
var _ ProviderAdapter = (*CodexAdapter)(nil)
|
||||
@@ -0,0 +1,122 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDashScopeAdapter_Basics verifies:
|
||||
// - Name() returns "dashscope" (NOT openai — wire format differs conceptually)
|
||||
// - StreamWithTools is overridden to false (DashScope falls back to non-stream for tool calls)
|
||||
// - Other capabilities inherit from OpenAI
|
||||
func TestDashScopeAdapter_Basics(t *testing.T) {
|
||||
a, err := NewDashScopeAdapter(ProviderConfig{APIKey: "sk-ds"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewDashScopeAdapter error: %v", err)
|
||||
}
|
||||
if a.Name() != "dashscope" {
|
||||
t.Errorf("Name() = %q, want dashscope", a.Name())
|
||||
}
|
||||
caps := a.Capabilities()
|
||||
if caps.StreamWithTools {
|
||||
t.Error("DashScope must NOT support StreamWithTools (non-stream fallback on tool calls)")
|
||||
}
|
||||
if !caps.Streaming {
|
||||
t.Error("expected Streaming=true (inherited)")
|
||||
}
|
||||
if !caps.ToolCalling {
|
||||
t.Error("expected ToolCalling=true (inherited)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeAdapter_DefaultsApplied verifies BaseURL and Model defaults
|
||||
// are injected when the caller leaves them empty.
|
||||
func TestDashScopeAdapter_DefaultsApplied(t *testing.T) {
|
||||
a, err := NewDashScopeAdapter(ProviderConfig{APIKey: "sk-ds"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
body, _, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "ping"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
model, _ := m["model"].(string)
|
||||
if model == "" {
|
||||
t.Fatal("model missing from request body")
|
||||
}
|
||||
// Default model constant is dashscopeDefaultModel — confirm it flowed through.
|
||||
if model != dashscopeDefaultModel {
|
||||
t.Errorf("model = %q, want %q (default)", model, dashscopeDefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeAdapter_ToRequestDelegates verifies the adapter produces the
|
||||
// same wire format as OpenAIAdapter for equivalent input (since DashScope is
|
||||
// an OpenAI-compatible endpoint).
|
||||
func TestDashScopeAdapter_ToRequestDelegates(t *testing.T) {
|
||||
a, _ := NewDashScopeAdapter(ProviderConfig{APIKey: "sk", Model: "qwen-plus"})
|
||||
body, headers, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hello qwen"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(headers.Get("Authorization"), "Bearer sk") {
|
||||
t.Errorf("Authorization = %q, want Bearer sk...", headers.Get("Authorization"))
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
if m["model"] != "qwen-plus" {
|
||||
t.Errorf("model = %v, want qwen-plus", m["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeAdapter_FromResponseDelegates verifies response parsing
|
||||
// works against the OpenAI-compat shape.
|
||||
func TestDashScopeAdapter_FromResponseDelegates(t *testing.T) {
|
||||
a, _ := NewDashScopeAdapter(ProviderConfig{APIKey: "sk"})
|
||||
raw := []byte(`{
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}
|
||||
}`)
|
||||
resp, err := a.FromResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("FromResponse: %v", err)
|
||||
}
|
||||
if resp.Content != "hi" {
|
||||
t.Errorf("Content = %q, want hi", resp.Content)
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 4 {
|
||||
t.Errorf("Usage TotalTokens = %v, want 4", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeAdapter_FromStreamChunkDelegates verifies stream chunk parsing
|
||||
// reuses OpenAI logic end-to-end.
|
||||
func TestDashScopeAdapter_FromStreamChunkDelegates(t *testing.T) {
|
||||
a, _ := NewDashScopeAdapter(ProviderConfig{APIKey: "sk"})
|
||||
|
||||
sc, err := a.FromStreamChunk([]byte("[DONE]"))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || !sc.Done {
|
||||
t.Errorf("want Done, got %+v", sc)
|
||||
}
|
||||
|
||||
sc2, err := a.FromStreamChunk([]byte(`{"choices":[{"delta":{"content":"x"}}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc2 == nil || sc2.Content != "x" {
|
||||
t.Errorf("want Content=x, got %+v", sc2)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure adapter satisfies the interface at compile time.
|
||||
var _ ProviderAdapter = (*DashScopeAdapter)(nil)
|
||||
@@ -0,0 +1,245 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAIAdapter_Basics verifies Name() and Capabilities() delegate to the
|
||||
// wrapped OpenAIProvider rather than returning zero values.
|
||||
func TestOpenAIAdapter_Basics(t *testing.T) {
|
||||
a, err := NewOpenAIAdapter(ProviderConfig{
|
||||
APIKey: "sk-fake",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOpenAIAdapter error: %v", err)
|
||||
}
|
||||
if a.Name() != "openai" {
|
||||
t.Errorf("Name() = %q, want openai", a.Name())
|
||||
}
|
||||
caps := a.Capabilities()
|
||||
if !caps.Streaming {
|
||||
t.Error("expected Streaming=true")
|
||||
}
|
||||
if !caps.ToolCalling {
|
||||
t.Error("expected ToolCalling=true")
|
||||
}
|
||||
if caps.TokenizerID == "" {
|
||||
t.Error("expected non-empty TokenizerID")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_ToRequest_DefaultStreamAndAuth verifies defaults:
|
||||
// - stream=true (when not set in Options)
|
||||
// - Authorization "Bearer <key>" for non-Azure endpoints
|
||||
// - Content-Type header present
|
||||
// - resolved model travels into the body
|
||||
func TestOpenAIAdapter_ToRequest_DefaultStreamAndAuth(t *testing.T) {
|
||||
a, _ := NewOpenAIAdapter(ProviderConfig{
|
||||
APIKey: "sk-abc",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
})
|
||||
|
||||
req := ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
}
|
||||
body, headers, err := a.ToRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest error: %v", err)
|
||||
}
|
||||
|
||||
if got := headers.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", got)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "Bearer sk-abc" {
|
||||
t.Errorf("Authorization = %q, want %q", got, "Bearer sk-abc")
|
||||
}
|
||||
if got := headers.Get("api-key"); got != "" {
|
||||
t.Errorf("api-key should be empty for non-Azure, got %q", got)
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("body unmarshal: %v", err)
|
||||
}
|
||||
if m["stream"] != true {
|
||||
t.Errorf("stream = %v, want true", m["stream"])
|
||||
}
|
||||
if m["model"] == "" || m["model"] == nil {
|
||||
t.Errorf("model missing from body: %v", m["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_ToRequest_AzureUsesAPIKeyHeader verifies Azure endpoints
|
||||
// use the "api-key" header instead of Authorization Bearer.
|
||||
func TestOpenAIAdapter_ToRequest_AzureUsesAPIKeyHeader(t *testing.T) {
|
||||
a, _ := NewOpenAIAdapter(ProviderConfig{
|
||||
APIKey: "az-key-42",
|
||||
BaseURL: "https://my-resource.openai.azure.com/openai",
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
|
||||
_, headers, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest error: %v", err)
|
||||
}
|
||||
|
||||
if got := headers.Get("api-key"); got != "az-key-42" {
|
||||
t.Errorf("api-key = %q, want az-key-42", got)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "" {
|
||||
t.Errorf("Authorization should be empty for Azure, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_ToRequest_StreamFalseOption verifies the stream option
|
||||
// flows through to the request body.
|
||||
func TestOpenAIAdapter_ToRequest_StreamFalseOption(t *testing.T) {
|
||||
a, _ := NewOpenAIAdapter(ProviderConfig{
|
||||
APIKey: "sk-x",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
})
|
||||
|
||||
body, _, err := a.ToRequest(ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{"stream": false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToRequest error: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
if m["stream"] != false {
|
||||
t.Errorf("stream = %v, want false", m["stream"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_FromResponse_ParsesContentAndUsage verifies a canonical
|
||||
// OpenAI chat-completions response parses into ChatResponse with content and
|
||||
// non-zero usage numbers.
|
||||
func TestOpenAIAdapter_FromResponse_ParsesContentAndUsage(t *testing.T) {
|
||||
a, _ := NewOpenAIAdapter(ProviderConfig{APIKey: "k", BaseURL: "https://api.openai.com/v1"})
|
||||
raw := []byte(`{
|
||||
"id":"chatcmpl-1",
|
||||
"object":"chat.completion",
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"hi there"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}
|
||||
}`)
|
||||
resp, err := a.FromResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("FromResponse error: %v", err)
|
||||
}
|
||||
if resp.Content != "hi there" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "hi there")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 12 {
|
||||
t.Errorf("Usage = %+v, want TotalTokens=12", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_FromResponse_MalformedReturnsError verifies malformed JSON
|
||||
// surfaces a decode error rather than panicking.
|
||||
func TestOpenAIAdapter_FromResponse_MalformedReturnsError(t *testing.T) {
|
||||
a, _ := NewOpenAIAdapter(ProviderConfig{APIKey: "k"})
|
||||
if _, err := a.FromResponse([]byte(`{not-json`)); err == nil {
|
||||
t.Fatal("expected error on malformed JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_FromStreamChunk covers the three main chunk shapes:
|
||||
// - "[DONE]" → Done=true
|
||||
// - content delta → Content set
|
||||
// - reasoning delta → Thinking set
|
||||
// - empty/unknown → nil (skip)
|
||||
func TestOpenAIAdapter_FromStreamChunk(t *testing.T) {
|
||||
a, _ := NewOpenAIAdapter(ProviderConfig{APIKey: "k"})
|
||||
|
||||
t.Run("done sentinel", func(t *testing.T) {
|
||||
sc, err := a.FromStreamChunk([]byte("[DONE]"))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || !sc.Done {
|
||||
t.Errorf("want Done=true, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content delta", func(t *testing.T) {
|
||||
raw := []byte(`{"choices":[{"delta":{"content":"hello"}}]}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || sc.Content != "hello" {
|
||||
t.Errorf("want Content=hello, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reasoning_content delta", func(t *testing.T) {
|
||||
raw := []byte(`{"choices":[{"delta":{"reasoning_content":"thinking..."}}]}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || sc.Thinking != "thinking..." {
|
||||
t.Errorf("want Thinking=thinking..., got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reasoning fallback field", func(t *testing.T) {
|
||||
raw := []byte(`{"choices":[{"delta":{"reasoning":"alt"}}]}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc == nil || sc.Thinking != "alt" {
|
||||
t.Errorf("want Thinking=alt, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty choices skipped", func(t *testing.T) {
|
||||
raw := []byte(`{"choices":[]}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc != nil {
|
||||
t.Errorf("want nil skip, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty delta skipped", func(t *testing.T) {
|
||||
raw := []byte(`{"choices":[{"delta":{}}]}`)
|
||||
sc, err := a.FromStreamChunk(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc != nil {
|
||||
t.Errorf("want nil skip (no content), got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed json returns nil", func(t *testing.T) {
|
||||
// Decoder swallows malformed chunks so streaming doesn't die.
|
||||
sc, err := a.FromStreamChunk([]byte(`{broken`))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if sc != nil {
|
||||
t.Errorf("want nil on malformed, got %+v", sc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ensure the adapter satisfies the interface at compile time.
|
||||
var _ ProviderAdapter = (*OpenAIAdapter)(nil)
|
||||
@@ -0,0 +1,148 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdapterRegistry_RegisterAndGet verifies basic registry store/lookup.
|
||||
func TestAdapterRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewAdapterRegistry()
|
||||
if r == nil {
|
||||
t.Fatal("NewAdapterRegistry returned nil")
|
||||
}
|
||||
|
||||
// Register a fake factory returning a stub adapter.
|
||||
r.Register("stub", func(cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
return &stubAdapter{name: "stub"}, nil
|
||||
})
|
||||
|
||||
got, err := r.Get("stub", ProviderConfig{Name: "stub"})
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if got.Name() != "stub" {
|
||||
t.Errorf("Get Name() = %q, want %q", got.Name(), "stub")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterRegistry_GetUnknown verifies lookup of an unregistered name
|
||||
// surfaces an error without panicking.
|
||||
func TestAdapterRegistry_GetUnknown(t *testing.T) {
|
||||
r := NewAdapterRegistry()
|
||||
_, err := r.Get("missing", ProviderConfig{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown provider, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterRegistry_RegisterOverwrite verifies re-registering replaces the factory.
|
||||
func TestAdapterRegistry_RegisterOverwrite(t *testing.T) {
|
||||
r := NewAdapterRegistry()
|
||||
r.Register("x", func(cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
return &stubAdapter{name: "v1"}, nil
|
||||
})
|
||||
r.Register("x", func(cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
return &stubAdapter{name: "v2"}, nil
|
||||
})
|
||||
got, _ := r.Get("x", ProviderConfig{})
|
||||
if got.Name() != "v2" {
|
||||
t.Errorf("Get Name() = %q, want v2 (latest registration wins)", got.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterRegistry_FactoryErrorSurfaces verifies factory errors propagate.
|
||||
func TestAdapterRegistry_FactoryErrorSurfaces(t *testing.T) {
|
||||
r := NewAdapterRegistry()
|
||||
want := errors.New("factory boom")
|
||||
r.Register("boom", func(cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
return nil, want
|
||||
})
|
||||
_, err := r.Get("boom", ProviderConfig{})
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("Get err = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterRegistry_ConcurrentRegister verifies the registry is safe under
|
||||
// concurrent read+write access (RWMutex contract).
|
||||
func TestAdapterRegistry_ConcurrentRegister(t *testing.T) {
|
||||
r := NewAdapterRegistry()
|
||||
// Pre-register baseline so Get path exists during concurrent writes.
|
||||
r.Register("base", func(cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
return &stubAdapter{name: "base"}, nil
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(2)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
r.Register("base", func(cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
return &stubAdapter{name: "base"}, nil
|
||||
})
|
||||
}(i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = r.Get("base", ProviderConfig{})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
got, err := r.Get("base", ProviderConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("post-concurrent Get error: %v", err)
|
||||
}
|
||||
if got.Name() != "base" {
|
||||
t.Errorf("final Name() = %q, want base", got.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultAdapterRegistry verifies the default registry contains all
|
||||
// built-in adapters and each resolves without error.
|
||||
func TestDefaultAdapterRegistry(t *testing.T) {
|
||||
r := DefaultAdapterRegistry()
|
||||
if r == nil {
|
||||
t.Fatal("DefaultAdapterRegistry returned nil")
|
||||
}
|
||||
|
||||
// Codex adapter doesn't need API key (token comes from TokenSource).
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg ProviderConfig
|
||||
}{
|
||||
{"anthropic", ProviderConfig{APIKey: "fake"}},
|
||||
{"openai", ProviderConfig{APIKey: "fake"}},
|
||||
{"dashscope", ProviderConfig{APIKey: "fake"}},
|
||||
{"codex", ProviderConfig{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a, err := r.Get(tc.name, tc.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q) returned error: %v", tc.name, err)
|
||||
}
|
||||
if a == nil {
|
||||
t.Fatalf("Get(%q) returned nil adapter", tc.name)
|
||||
}
|
||||
if a.Name() == "" {
|
||||
t.Errorf("Get(%q) Name() is empty", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubAdapter is a minimal ProviderAdapter used by registry tests.
|
||||
type stubAdapter struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubAdapter) Name() string { return s.name }
|
||||
func (s *stubAdapter) Capabilities() ProviderCapabilities { return ProviderCapabilities{} }
|
||||
func (s *stubAdapter) ToRequest(ChatRequest) ([]byte, http.Header, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (s *stubAdapter) FromResponse([]byte) (*ChatResponse, error) { return nil, nil }
|
||||
func (s *stubAdapter) FromStreamChunk([]byte) (*StreamChunk, error) { return nil, nil }
|
||||
@@ -15,7 +15,7 @@
|
||||
"internal/channels/telegram/voiceguard": 100,
|
||||
"internal/channels/typing": 91.80327868852459,
|
||||
"internal/channels/whatsapp": 21.323529411764707,
|
||||
"internal/channels/zalo": 7.203389830508475,
|
||||
"internal/channels/zalo": 65.2542372881356,
|
||||
"internal/channels/zalo/personal": 0,
|
||||
"internal/channels/zalo/personal/protocol": 19.56989247311828,
|
||||
"internal/channels/zalo/personal/zalomethods": 0,
|
||||
@@ -39,7 +39,7 @@
|
||||
"internal/permissions": 98.18181818181819,
|
||||
"internal/pipeline": 76.90417690417691,
|
||||
"internal/providerresolve": 88.88888888888889,
|
||||
"internal/providers": 56.99965433805738,
|
||||
"internal/providers": 62.15,
|
||||
"internal/providers/acp": 0,
|
||||
"internal/safego": 100,
|
||||
"internal/sandbox": 6.593406593406593,
|
||||
|
||||
Reference in new issue
Block a user