diff --git a/internal/channels/zalo/factory_test.go b/internal/channels/zalo/factory_test.go new file mode 100644 index 00000000..5d8e6a4c --- /dev/null +++ b/internal/channels/zalo/factory_test.go @@ -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)) + } +} diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index d04ae19d..aafa53a2 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -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 diff --git a/internal/channels/zalo/zalo_test.go b/internal/channels/zalo/zalo_test.go new file mode 100644 index 00000000..a03c1c0b --- /dev/null +++ b/internal/channels/zalo/zalo_test.go @@ -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: +// +// /bott/ +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") + } +} diff --git a/internal/providers/adapter_codex_test.go b/internal/providers/adapter_codex_test.go new file mode 100644 index 00000000..fe2967c6 --- /dev/null +++ b/internal/providers/adapter_codex_test.go @@ -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) diff --git a/internal/providers/adapter_dashscope_test.go b/internal/providers/adapter_dashscope_test.go new file mode 100644 index 00000000..3c243539 --- /dev/null +++ b/internal/providers/adapter_dashscope_test.go @@ -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) diff --git a/internal/providers/adapter_openai_test.go b/internal/providers/adapter_openai_test.go new file mode 100644 index 00000000..c7cfcb4f --- /dev/null +++ b/internal/providers/adapter_openai_test.go @@ -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 " 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) diff --git a/internal/providers/adapter_registry_test.go b/internal/providers/adapter_registry_test.go new file mode 100644 index 00000000..ffe53f6d --- /dev/null +++ b/internal/providers/adapter_registry_test.go @@ -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 } diff --git a/scripts/coverage_thresholds.json b/scripts/coverage_thresholds.json index cf7ab03f..fea910b7 100644 --- a/scripts/coverage_thresholds.json +++ b/scripts/coverage_thresholds.json @@ -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,