mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 22:09:44 +02:00
* feat(composio): add standalone Go SDK client (MVP)
Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.
MVP surface (just the endpoints Stage 2 needs):
- POST /connected_accounts/link Client.CreateLink
- POST /tool_router/session Client.CreateSession
- GET /connected_accounts Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id} Client.DeleteConnectedAccount
(404 -> nil, idempotent)
- GET /toolkits Client.ListToolkits
- GET /toolkits/{slug} Client.GetToolkit
- POST /tools/execute/{slug} Client.ExecuteTool
- Webhook HMAC-SHA256 verification composio.VerifyWebhook /
VerifyHTTPRequest + ParseEvent
Other notes:
- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
IsRateLimited helpers; falls back to raw body when upstream returns
non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
comma-separated multi-version list; 300s replay tolerance by default,
honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.
Tests:
- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
decoding, signature verify (good / tampered / stale / multi-version /
bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.
Follow-ups (separate PRs):
- server/internal/integrations/composio - DB schema, REST handlers,
registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.
Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)
Addresses GPT-Boy's review on PR #4603:
Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
our singular form silently dropped the user-filter on the wire. Also
surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
`version` (the actual v3.1 body field). Marks AllowTracing as
deprecated per the spec.
Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
and Timeout all carry through — the prior code only forwarded
Transport + Timeout despite the field comment promising the full
*http.Client.
Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
(user_ids, auth_config_ids, connected_account_ids, statuses) and
explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
recordingTransport and asserts the inbound *http.Client actually
handled it.
Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.
Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
568 lines
18 KiB
Go
568 lines
18 KiB
Go
package composio_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/composio"
|
|
)
|
|
|
|
// newTestServer wires up a httptest.Server with the provided handler and
|
|
// returns a composio.Client pointed at it.
|
|
func newTestServer(t *testing.T, h http.HandlerFunc) (*composio.Client, *httptest.Server) {
|
|
t.Helper()
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
c, err := composio.NewClient(composio.Options{
|
|
APIKey: "test-key",
|
|
BaseURL: srv.URL,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
return c, srv
|
|
}
|
|
|
|
func readJSON(t *testing.T, r *http.Request, out any) {
|
|
t.Helper()
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
t.Fatalf("unmarshal body %q: %v", string(body), err)
|
|
}
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
t.Fatalf("write json: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client construction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestNewClient_Defaults(t *testing.T) {
|
|
c, err := composio.NewClient(composio.Options{APIKey: "k"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := c.BaseURL(); got != composio.DefaultBaseURL {
|
|
t.Errorf("BaseURL = %q, want %q", got, composio.DefaultBaseURL)
|
|
}
|
|
}
|
|
|
|
func TestNewClient_RequiresAPIKey(t *testing.T) {
|
|
_, err := composio.NewClient(composio.Options{})
|
|
if err == nil {
|
|
t.Fatal("expected error when APIKey is empty")
|
|
}
|
|
}
|
|
|
|
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
|
|
c, err := composio.NewClient(composio.Options{APIKey: "k", BaseURL: "https://x.example.com/"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got, want := c.BaseURL(), "https://x.example.com"; got != want {
|
|
t.Errorf("BaseURL = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// recordingTransport observes whether it actually handled a request.
|
|
type recordingTransport struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
last *http.Request
|
|
status int
|
|
}
|
|
|
|
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
rt.mu.Lock()
|
|
rt.calls++
|
|
rt.last = r
|
|
rt.mu.Unlock()
|
|
body := io.NopCloser(strings.NewReader(`{"items":[]}`))
|
|
return &http.Response{
|
|
StatusCode: rt.statusOr(200),
|
|
Body: body,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Request: r,
|
|
}, nil
|
|
}
|
|
func (rt *recordingTransport) statusOr(d int) int {
|
|
if rt.status == 0 {
|
|
return d
|
|
}
|
|
return rt.status
|
|
}
|
|
|
|
// TestNewClient_HonorsInjectedHTTPClient asserts that when Options.HTTPClient
|
|
// is non-nil the SDK actually routes requests through *that* client — full
|
|
// fidelity, not just transport+timeout. GPT-Boy's PR review against #4603
|
|
// caught the partial behavior; this test locks the fix in.
|
|
func TestNewClient_HonorsInjectedHTTPClient(t *testing.T) {
|
|
rt := &recordingTransport{}
|
|
hc := &http.Client{Transport: rt}
|
|
c, err := composio.NewClient(composio.Options{
|
|
APIKey: "k",
|
|
BaseURL: "https://api.example.invalid",
|
|
HTTPClient: hc,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
if _, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{}); err != nil {
|
|
t.Fatalf("ListToolkits: %v", err)
|
|
}
|
|
rt.mu.Lock()
|
|
defer rt.mu.Unlock()
|
|
if rt.calls != 1 {
|
|
t.Fatalf("expected 1 call through injected transport, got %d", rt.calls)
|
|
}
|
|
if rt.last == nil || rt.last.URL.Host != "api.example.invalid" {
|
|
t.Errorf("request did not flow through injected client: %+v", rt.last)
|
|
}
|
|
if got := rt.last.Header.Get("x-api-key"); got != "k" {
|
|
t.Errorf("api key header lost in injected client: %q", got)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connect Link
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreateLink_Success(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/link" {
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("x-api-key"); got != "test-key" {
|
|
t.Errorf("missing api key header, got %q", got)
|
|
}
|
|
var body composio.CreateLinkRequest
|
|
readJSON(t, r, &body)
|
|
if body.AuthConfigID != "ac_abc" || body.UserID != "u_1" {
|
|
t.Errorf("unexpected body: %+v", body)
|
|
}
|
|
writeJSON(t, w, http.StatusCreated, map[string]any{
|
|
"link_token": "ltok_xyz",
|
|
"redirect_url": "https://connect.composio.dev/ln_xyz",
|
|
"expires_at": "2026-12-31T00:00:00Z",
|
|
"connected_account_id": "ca_pending",
|
|
})
|
|
})
|
|
resp, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
|
|
AuthConfigID: "ac_abc",
|
|
UserID: "u_1",
|
|
CallbackURL: "https://example.com/cb",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
if resp.RedirectURL == "" || resp.LinkToken != "ltok_xyz" || resp.ConnectedAccountID != "ca_pending" {
|
|
t.Errorf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestCreateLink_ValidatesInputs(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("server should not be hit when inputs are invalid")
|
|
})
|
|
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{UserID: "u"}); err == nil {
|
|
t.Error("expected error when AuthConfigID is empty")
|
|
}
|
|
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{AuthConfigID: "ac"}); err == nil {
|
|
t.Error("expected error when UserID is empty")
|
|
}
|
|
}
|
|
|
|
func TestCreateLink_APIError(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(t, w, http.StatusBadRequest, map[string]any{
|
|
"error": map[string]any{
|
|
"message": "bad input",
|
|
"code": 400,
|
|
"slug": "INVALID_INPUT",
|
|
"request_id": "req_1",
|
|
},
|
|
})
|
|
})
|
|
_, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
|
|
AuthConfigID: "ac", UserID: "u",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var apiErr *composio.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
|
}
|
|
if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Slug != "INVALID_INPUT" || apiErr.Message != "bad input" {
|
|
t.Errorf("unexpected APIError: %+v", apiErr)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connected accounts list / revoke / delete
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestListConnectedAccounts_QueryString(t *testing.T) {
|
|
var seen *http.Request
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"items": []map[string]any{
|
|
{"id": "ca_1", "user_id": "u_1", "status": "ACTIVE",
|
|
"toolkit": map[string]any{"slug": "notion"}},
|
|
},
|
|
"next_cursor": "cur_2",
|
|
})
|
|
})
|
|
resp, err := c.ListConnectedAccounts(context.Background(), composio.ListConnectedAccountsRequest{
|
|
UserIDs: []string{"u_1", "u_2"},
|
|
ToolkitSlugs: []string{"notion", "slack"},
|
|
AuthConfigIDs: []string{"ac_a"},
|
|
ConnectedAccountIDs: []string{"ca_x"},
|
|
Statuses: []string{"ACTIVE"},
|
|
OrderBy: "updated_at",
|
|
OrderDirection: "desc",
|
|
AccountType: "PRIVATE",
|
|
Limit: 25,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListConnectedAccounts: %v", err)
|
|
}
|
|
if len(resp.Items) != 1 || resp.Items[0].Toolkit.Slug != "notion" || resp.NextCursor != "cur_2" {
|
|
t.Errorf("unexpected response: %+v", resp)
|
|
}
|
|
q := seen.URL.Query()
|
|
|
|
// Per Composio v3.1 these are plural array params.
|
|
if got := q["user_ids"]; len(got) != 2 || got[0] != "u_1" || got[1] != "u_2" {
|
|
t.Errorf("user_ids = %v", got)
|
|
}
|
|
if got := q["toolkit_slugs"]; len(got) != 2 || got[0] != "notion" || got[1] != "slack" {
|
|
t.Errorf("toolkit_slugs = %v", got)
|
|
}
|
|
if got := q["auth_config_ids"]; len(got) != 1 || got[0] != "ac_a" {
|
|
t.Errorf("auth_config_ids = %v", got)
|
|
}
|
|
if got := q["connected_account_ids"]; len(got) != 1 || got[0] != "ca_x" {
|
|
t.Errorf("connected_account_ids = %v", got)
|
|
}
|
|
if got := q["statuses"]; len(got) != 1 || got[0] != "ACTIVE" {
|
|
t.Errorf("statuses = %v", got)
|
|
}
|
|
|
|
// Singular legacy keys must NOT appear — guard against regression.
|
|
if q.Has("user_id") || q.Has("auth_config_id") {
|
|
t.Errorf("legacy singular query keys leaked: %s", seen.URL.RawQuery)
|
|
}
|
|
|
|
if q.Get("order_by") != "updated_at" {
|
|
t.Errorf("order_by = %q", q.Get("order_by"))
|
|
}
|
|
if q.Get("order_direction") != "desc" {
|
|
t.Errorf("order_direction = %q", q.Get("order_direction"))
|
|
}
|
|
if q.Get("account_type") != "PRIVATE" {
|
|
t.Errorf("account_type = %q", q.Get("account_type"))
|
|
}
|
|
if q.Get("limit") != "25" {
|
|
t.Errorf("limit = %q", q.Get("limit"))
|
|
}
|
|
}
|
|
|
|
func TestRevokeConnection_Success(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/ca_42/revoke" {
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
if err := c.RevokeConnection(context.Background(), "ca_42"); err != nil {
|
|
t.Errorf("RevokeConnection: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeConnection_RequiresID(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("server should not be hit")
|
|
})
|
|
if err := c.RevokeConnection(context.Background(), ""); err == nil {
|
|
t.Error("expected error for empty id")
|
|
}
|
|
}
|
|
|
|
func TestDeleteConnectedAccount_IdempotentOn404(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
t.Errorf("method = %s", r.Method)
|
|
}
|
|
writeJSON(t, w, http.StatusNotFound, map[string]any{
|
|
"error": map[string]any{"message": "not found", "status": 404, "slug": "NOT_FOUND"},
|
|
})
|
|
})
|
|
if err := c.DeleteConnectedAccount(context.Background(), "ca_gone"); err != nil {
|
|
t.Errorf("expected nil on 404, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteConnectedAccount_PropagatesOtherErrors(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(t, w, http.StatusInternalServerError, map[string]any{
|
|
"error": map[string]any{"message": "boom", "status": 500, "slug": "INTERNAL"},
|
|
})
|
|
})
|
|
err := c.DeleteConnectedAccount(context.Background(), "ca_1")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var apiErr *composio.APIError
|
|
if !errors.As(err, &apiErr) || apiErr.HTTPStatus != http.StatusInternalServerError {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sessions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreateSession_Success(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/tool_router/session" {
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
var body composio.CreateSessionRequest
|
|
readJSON(t, r, &body)
|
|
if body.UserID != "u_1" {
|
|
t.Errorf("user_id = %q", body.UserID)
|
|
}
|
|
if body.ManageConnections == nil || body.ManageConnections.CallbackURL != "https://cb" {
|
|
t.Errorf("manage_connections = %+v", body.ManageConnections)
|
|
}
|
|
writeJSON(t, w, http.StatusCreated, map[string]any{
|
|
"session_id": "trs_1",
|
|
"mcp": map[string]any{"type": "http", "url": "https://mcp.example/trs_1"},
|
|
})
|
|
})
|
|
enable := true
|
|
resp, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{
|
|
UserID: "u_1",
|
|
ManageConnections: &composio.ManageConnections{
|
|
Enable: &enable,
|
|
CallbackURL: "https://cb",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
if resp.MCP.URL == "" || resp.SessionID != "trs_1" {
|
|
t.Errorf("unexpected response: %+v", resp)
|
|
}
|
|
hdr := c.MCPAuthHeaders()
|
|
if hdr["x-api-key"] != "test-key" {
|
|
t.Errorf("MCPAuthHeaders = %v", hdr)
|
|
}
|
|
}
|
|
|
|
func TestCreateSession_RequiresUserID(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("server should not be hit")
|
|
})
|
|
if _, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{}); err == nil {
|
|
t.Error("expected error for empty UserID")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toolkits / Tools
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestListToolkits_Success(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/toolkits" || r.URL.Query().Get("category") != "productivity" {
|
|
t.Errorf("unexpected request: %s ?%s", r.URL.Path, r.URL.RawQuery)
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"items": []map[string]any{
|
|
{"slug": "notion", "name": "Notion"},
|
|
{"slug": "slack", "name": "Slack"},
|
|
},
|
|
})
|
|
})
|
|
resp, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{Category: "productivity"})
|
|
if err != nil {
|
|
t.Fatalf("ListToolkits: %v", err)
|
|
}
|
|
if len(resp.Items) != 2 || resp.Items[0].Slug != "notion" {
|
|
t.Errorf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestGetToolkit_Success(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/toolkits/notion" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{"slug": "notion", "name": "Notion"})
|
|
})
|
|
tk, err := c.GetToolkit(context.Background(), "notion")
|
|
if err != nil {
|
|
t.Fatalf("GetToolkit: %v", err)
|
|
}
|
|
if tk.Slug != "notion" {
|
|
t.Errorf("slug = %q", tk.Slug)
|
|
}
|
|
}
|
|
|
|
func TestGetToolkit_RequiresSlug(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("server should not be hit")
|
|
})
|
|
if _, err := c.GetToolkit(context.Background(), ""); err == nil {
|
|
t.Error("expected error for empty slug")
|
|
}
|
|
}
|
|
|
|
func TestExecuteTool_Success(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/tools/execute/GITHUB_CREATE_ISSUE" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
// Decode into a raw map first so we can assert wire keys directly.
|
|
raw, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
var wire map[string]any
|
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
|
t.Fatalf("unmarshal wire: %v", err)
|
|
}
|
|
if wire["user_id"] != "u_1" {
|
|
t.Errorf("user_id = %v", wire["user_id"])
|
|
}
|
|
// The spec field is `version`, not `toolkit_versions`.
|
|
if wire["version"] != "latest" {
|
|
t.Errorf("version = %v (want %q)", wire["version"], "latest")
|
|
}
|
|
if _, leaked := wire["toolkit_versions"]; leaked {
|
|
t.Errorf("legacy toolkit_versions field leaked to wire: %s", raw)
|
|
}
|
|
args, _ := wire["arguments"].(map[string]any)
|
|
if args["title"] != "hi" {
|
|
t.Errorf("arguments.title = %v", args["title"])
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"successful": true,
|
|
"data": map[string]any{"issue_number": float64(42)},
|
|
"log_id": "log_1",
|
|
})
|
|
})
|
|
resp, err := c.ExecuteTool(context.Background(), "GITHUB_CREATE_ISSUE", composio.ExecuteToolRequest{
|
|
UserID: "u_1",
|
|
Arguments: map[string]any{"title": "hi"},
|
|
Version: "latest",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteTool: %v", err)
|
|
}
|
|
if !resp.Successful || resp.Data["issue_number"].(float64) != 42 || resp.LogID != "log_1" {
|
|
t.Errorf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolRequest_VersionSerialization locks in the json tag for the
|
|
// Version field — GPT-Boy's review against PR #4603 caught that the field
|
|
// used to serialize as `toolkit_versions`, which is not a v3.1 wire key.
|
|
func TestExecuteToolRequest_VersionSerialization(t *testing.T) {
|
|
req := composio.ExecuteToolRequest{
|
|
UserID: "u_1",
|
|
Version: "20251027_00",
|
|
}
|
|
b, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
got := string(b)
|
|
if !strings.Contains(got, `"version":"20251027_00"`) {
|
|
t.Errorf("version not serialized as `version`: %s", got)
|
|
}
|
|
if strings.Contains(got, "toolkit_versions") {
|
|
t.Errorf("legacy toolkit_versions key leaked: %s", got)
|
|
}
|
|
|
|
// Zero-value Version must omit the field entirely (omitempty).
|
|
bEmpty, _ := json.Marshal(composio.ExecuteToolRequest{UserID: "u_1"})
|
|
if strings.Contains(string(bEmpty), "version") {
|
|
t.Errorf("empty Version should omit, got: %s", bEmpty)
|
|
}
|
|
}
|
|
|
|
func TestExecuteTool_ValidatesInputs(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("server should not be hit")
|
|
})
|
|
if _, err := c.ExecuteTool(context.Background(), "", composio.ExecuteToolRequest{UserID: "u"}); err == nil {
|
|
t.Error("expected error for empty tool slug")
|
|
}
|
|
if _, err := c.ExecuteTool(context.Background(), "X", composio.ExecuteToolRequest{}); err == nil {
|
|
t.Error("expected error when neither UserID nor ConnectedAccountID is set")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestAPIError_FallbackOnNonJSONBody(t *testing.T) {
|
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("<html>upstream down</html>"))
|
|
})
|
|
_, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var apiErr *composio.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
|
}
|
|
if apiErr.HTTPStatus != http.StatusBadGateway {
|
|
t.Errorf("status = %d", apiErr.HTTPStatus)
|
|
}
|
|
if !strings.Contains(string(apiErr.RawBody), "upstream down") {
|
|
t.Errorf("raw body lost: %q", apiErr.RawBody)
|
|
}
|
|
}
|
|
|
|
func TestAPIError_HelperPredicates(t *testing.T) {
|
|
e := &composio.APIError{HTTPStatus: http.StatusNotFound}
|
|
if !e.IsNotFound() {
|
|
t.Error("IsNotFound() = false")
|
|
}
|
|
e2 := &composio.APIError{HTTPStatus: http.StatusUnauthorized}
|
|
if !e2.IsUnauthorized() {
|
|
t.Error("IsUnauthorized() = false")
|
|
}
|
|
e3 := &composio.APIError{HTTPStatus: http.StatusTooManyRequests}
|
|
if !e3.IsRateLimited() {
|
|
t.Error("IsRateLimited() = false")
|
|
}
|
|
}
|