package sub2api import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" ) func TestHTTPErrorErrorMessage(t *testing.T) { e := newHTTPError("POST", "/api/v1/admin/groups", http.StatusTeapot, []byte("short and stout")) want := "sub2api POST /api/v1/admin/groups returned 418: short and stout" if got := e.Error(); got != want { t.Fatalf("HTTPError.Error() = %q, want %q", got, want) } } func TestWithHTTPClientAndOptions(t *testing.T) { customHTTP := &http.Client{Timeout: 123} client, err := NewClient("http://localhost:8080", WithHTTPClient(customHTTP), WithAPIKey(" sk-abc "), WithBearerToken(" tok-xyz "), ) if err != nil { t.Fatal(err) } if client.httpClient != customHTTP { t.Fatal("WithHTTPClient not applied") } if client.apiKey != "sk-abc" { t.Fatalf("apiKey = %q, want %q", client.apiKey, "sk-abc") } if client.bearerToken != "tok-xyz" { t.Fatalf("bearerToken = %q, want %q", client.bearerToken, "tok-xyz") } } func TestNewClient_RejectsInvalidURLs(t *testing.T) { tests := []struct { name string url string }{ {"empty", ""}, {"no scheme", "localhost:8080"}, {"no host", "http://"}, {"garbage", "://foo"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := NewClient(tt.url) if err == nil { t.Fatalf("NewClient(%q) error = nil, want error", tt.url) } }) } } func TestResolvePath(t *testing.T) { client, err := NewClient("http://host:9090") if err != nil { t.Fatal(err) } tests := []struct { path string want string }{ {"/v1/models", "http://host:9090/v1/models"}, {"v1/models", "http://host:9090/v1/models"}, {"/v1/models?key=val", "http://host:9090/v1/models?key=val"}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { if got := client.resolvePath(tt.path); got != tt.want { t.Fatalf("resolvePath(%q) = %q, want %q", tt.path, got, tt.want) } }) } } func TestApplyAuth(t *testing.T) { t.Run("api key preferred", func(t *testing.T) { c, _ := NewClient("http://h:8080", WithAPIKey("key1"), WithBearerToken("btok")) req, _ := http.NewRequest("GET", "http://h:8080/path", nil) c.applyAuth(req) if h := req.Header.Get("x-api-key"); h != "key1" { t.Fatalf("x-api-key = %q, want %q", h, "key1") } if h := req.Header.Get("Authorization"); h != "" { t.Fatalf("Authorization should be empty, got %q", h) } }) t.Run("bearer token fallback", func(t *testing.T) { c, _ := NewClient("http://h:8080", WithBearerToken("btok")) req, _ := http.NewRequest("GET", "http://h:8080/path", nil) c.applyAuth(req) if h := req.Header.Get("Authorization"); h != "Bearer btok" { t.Fatalf("Authorization = %q, want %q", h, "Bearer btok") } }) t.Run("no auth", func(t *testing.T) { c, _ := NewClient("http://h:8080") req, _ := http.NewRequest("GET", "http://h:8080/path", nil) c.applyAuth(req) if h := req.Header.Get("x-api-key"); h != "" { t.Fatalf("x-api-key should be empty, got %q", h) } if h := req.Header.Get("Authorization"); h != "" { t.Fatalf("Authorization should be empty, got %q", h) } }) } func TestDecodeEnvelopeObject(t *testing.T) { t.Run("standard envelope", func(t *testing.T) { body := []byte(`{"data":{"id":"g1","name":"test"}}`) var ref GroupRef if err := decodeEnvelopeObject(body, &ref); err != nil { t.Fatal(err) } if ref.ID != "g1" || ref.Name != "test" { t.Fatalf("got %+v, want {ID:g1 Name:test}", ref) } }) t.Run("flat response (no data wrapper)", func(t *testing.T) { body := []byte(`{"id":"g2","name":"flat"}`) var ref GroupRef if err := decodeEnvelopeObject(body, &ref); err != nil { t.Fatal(err) } if ref.ID != "g2" || ref.Name != "flat" { t.Fatalf("got %+v, want {ID:g2 Name:flat}", ref) } }) t.Run("data:null returns flat", func(t *testing.T) { body := []byte(`{"data":null,"id":"g3"}`) var ref GroupRef if err := decodeEnvelopeObject(body, &ref); err != nil { t.Fatal(err) } if ref.ID != "g3" { t.Fatalf("id = %q, want %q", ref.ID, "g3") } }) t.Run("invalid json returns error", func(t *testing.T) { var ref GroupRef if err := decodeEnvelopeObject([]byte(`not json`), &ref); err == nil { t.Fatal("expected error") } }) } func TestDecodeGatewayModelIDs(t *testing.T) { t.Run("standard list", func(t *testing.T) { ids := decodeGatewayModelIDs([]byte(`{"data":[{"id":"gpt-4"},{"id":" claude-3 "}]}`)) if len(ids) != 2 || ids[0] != "gpt-4" || ids[1] != "claude-3" { t.Fatalf("got %v, want [gpt-4 claude-3]", ids) } }) t.Run("empty data", func(t *testing.T) { if ids := decodeGatewayModelIDs([]byte(`{}`)); ids != nil { t.Fatalf("expected nil, got %v", ids) } }) t.Run("invalid json", func(t *testing.T) { if ids := decodeGatewayModelIDs([]byte(`not json`)); ids != nil { t.Fatalf("expected nil, got %v", ids) } }) t.Run("empty array", func(t *testing.T) { if ids := decodeGatewayModelIDs([]byte(`{"data":[]}`)); ids != nil { t.Fatalf("expected nil, got %v", ids) } }) } func TestFilterNamedResourcesByName(t *testing.T) { resources := []NamedResource{ {Name: "group-a", ID: "g1"}, {Name: "group-b", ID: "g2"}, {Name: " group-a ", ID: "g3"}, } t.Run("match", func(t *testing.T) { got := filterNamedResourcesByName(resources, "group-a") if len(got) != 2 || got[0].ID != "g1" || got[1].ID != "g3" { t.Fatalf("got %+v, want 2 matches", got) } }) t.Run("no match", func(t *testing.T) { if got := filterNamedResourcesByName(resources, "nonexistent"); len(got) != 0 { t.Fatalf("expected 0, got %d", len(got)) } }) t.Run("empty name returns all", func(t *testing.T) { if got := filterNamedResourcesByName(resources, ""); len(got) != 3 { t.Fatalf("expected 3, got %d", len(got)) } }) } func TestFilterNamedResourcesByPrefix(t *testing.T) { resources := []NamedResource{ {Name: "deepseek-proxy", ID: "r1"}, {Name: "deepseek-us", ID: "r2"}, {Name: "claude-eu", ID: "r3"}, } t.Run("prefix matches", func(t *testing.T) { got := filterNamedResourcesByPrefix(resources, "deepseek") if len(got) != 2 { t.Fatalf("expected 2, got %d", len(got)) } }) t.Run("no prefix match", func(t *testing.T) { if got := filterNamedResourcesByPrefix(resources, "nope"); len(got) != 0 { t.Fatalf("expected 0, got %d", len(got)) } }) t.Run("empty prefix returns all", func(t *testing.T) { if got := filterNamedResourcesByPrefix(resources, ""); len(got) != 3 { t.Fatalf("expected 3, got %d", len(got)) } }) } func TestDecodeNamedResources(t *testing.T) { t.Run("envelope", func(t *testing.T) { resources, pages, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`)) if err != nil { t.Fatal(err) } if pages != 1 { t.Fatalf("pages = %d, want 1", pages) } if len(resources) != 1 || resources[0].ID != "r1" { t.Fatalf("got %+v", resources) } }) t.Run("numeric id", func(t *testing.T) { resources, pages, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":1,"name":"default"}],"pages":2}}`)) if err != nil { t.Fatal(err) } if pages != 2 { t.Fatalf("pages = %d, want 2", pages) } if len(resources) != 1 || resources[0].ID != "1" { t.Fatalf("got %+v", resources) } }) t.Run("wrapper with items", func(t *testing.T) { resources, pages, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`)) if err != nil { t.Fatal(err) } if pages != 1 { t.Fatalf("pages = %d, want 1", pages) } if len(resources) != 1 || resources[0].ID != "r2" { t.Fatalf("got %+v", resources) } }) t.Run("invalid json", func(t *testing.T) { _, _, err := decodeNamedResources([]byte(`not json`)) if err == nil { t.Fatal("expected error") } }) } func TestDecodeAccountRefs(t *testing.T) { t.Run("envelope", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"data":[{"id":"a1"}]}`)) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "a1" { t.Fatalf("got %+v", refs) } }) t.Run("numeric id", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"data":{"items":[{"id":42}]}}`)) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "42" { t.Fatalf("got %+v", refs) } }) t.Run("wrapper with items", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"data":{"items":[{"id":"a2"}]}}`)) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "a2" { t.Fatalf("got %+v", refs) } }) t.Run("batch results", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"success":1,"failed":0,"results":[{"name":"k1","id":123,"success":true}]}`)) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "123" || refs[0].Name != "k1" { t.Fatalf("got %+v", refs) } }) t.Run("batch results ignores failed items", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"success":1,"failed":1,"results":[{"name":"k1","id":123,"success":true},{"name":"k2","id":456,"success":false}]}`)) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "123" { t.Fatalf("got %+v", refs) } }) t.Run("data wrapped batch results", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"code":0,"message":"success","data":{"failed":0,"results":[{"id":5,"name":"deepseek-01","success":true}],"success":1}}`)) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "5" || refs[0].Name != "deepseek-01" { t.Fatalf("got %+v", refs) } }) t.Run("invalid json", func(t *testing.T) { _, err := decodeAccountRefs([]byte(`not json`)) if err == nil { t.Fatal("expected error") } }) } func TestDecodeAccountModels(t *testing.T) { t.Run("envelope", func(t *testing.T) { models, err := decodeAccountModels([]byte(`{"data":[{"id":"gpt4","display_name":"GPT-4","type":"chat"}]}`)) if err != nil { t.Fatal(err) } if len(models) != 1 || models[0].ID != "gpt4" { t.Fatalf("got %+v", models) } }) t.Run("wrapper with items", func(t *testing.T) { models, err := decodeAccountModels([]byte(`{"data":{"items":[{"id":"cl3","display_name":"Claude 3","type":"chat"}]}}`)) if err != nil { t.Fatal(err) } if len(models) != 1 || models[0].ID != "cl3" { t.Fatalf("got %+v", models) } }) t.Run("invalid json", func(t *testing.T) { _, err := decodeAccountModels([]byte(`not json`)) if err == nil { t.Fatal("expected error") } }) } func TestParseProbeResult(t *testing.T) { t.Run("SSE with ok=true", func(t *testing.T) { result, err := parseProbeResult([]byte("data: {\"status\":\"passed\",\"ok\":true}\n")) if err != nil { t.Fatal(err) } if !result.OK || result.Status != "passed" { t.Fatalf("got %+v, want OK=true Status=passed", result) } }) t.Run("SSE with success=true", func(t *testing.T) { result, err := parseProbeResult([]byte("data: {\"status\":\"succeeded\",\"success\":true}\n")) if err != nil { t.Fatal(err) } if !result.OK || result.Status != "passed" { t.Fatalf("got %+v", result) } }) t.Run("SSE with ok=false", func(t *testing.T) { result, err := parseProbeResult([]byte("data: {\"status\":\"failed\",\"ok\":false}\n")) if err != nil { t.Fatal(err) } if result.OK || result.Status != "failed" { t.Fatalf("got %+v", result) } }) t.Run("SSE with status-based ok", func(t *testing.T) { result, err := parseProbeResult([]byte("data: {\"status\":\"pass\",\"message\":\"all good\"}\n")) if err != nil { t.Fatal(err) } if !result.OK || result.Message != "all good" { t.Fatalf("got %+v", result) } }) t.Run("multiple SSE events picks last", func(t *testing.T) { result, err := parseProbeResult([]byte("data: {\"status\":\"running\"}\ndata: {\"status\":\"passed\",\"ok\":true}\n")) if err != nil { t.Fatal(err) } if !result.OK { t.Fatalf("expected OK=true from last event, got %+v", result) } }) t.Run("no data events", func(t *testing.T) { _, err := parseProbeResult([]byte("not data\n")) if err == nil { t.Fatal("expected error") } }) } func TestNormalizeProbeStatus(t *testing.T) { tests := []struct { status string ok bool want string }{ {"pass", true, "passed"}, {"PASSED", true, "passed"}, {"Ok", true, "passed"}, {"success", true, "passed"}, {"succeeded", true, "passed"}, {"fail", false, "failed"}, {"FAILED", false, "failed"}, {"error", false, "failed"}, {"custom_ok", true, "passed"}, {"custom_fail", false, "failed"}, } for _, tt := range tests { t.Run(tt.status, func(t *testing.T) { if got := normalizeProbeStatus(tt.status, tt.ok); got != tt.want { t.Fatalf("normalizeProbeStatus(%q, %v) = %q, want %q", tt.status, tt.ok, got, tt.want) } }) } } func TestLooksLikeExistingEndpoint(t *testing.T) { t.Run("json content type", func(t *testing.T) { h := http.Header{"Content-Type": []string{"application/json"}} if !looksLikeExistingEndpoint(h, nil) { t.Fatal("expected true with json content type") } }) t.Run("sse content type", func(t *testing.T) { h := http.Header{"Content-Type": []string{"text/event-stream"}} if !looksLikeExistingEndpoint(h, nil) { t.Fatal("expected true with sse content type") } }) t.Run("empty body and no content type", func(t *testing.T) { if looksLikeExistingEndpoint(http.Header{}, nil) { t.Fatal("expected false") } }) t.Run("json-like body", func(t *testing.T) { if !looksLikeExistingEndpoint(http.Header{}, []byte(`{"error":"not found"}`)) { t.Fatal("expected true for json body") } }) t.Run("array body", func(t *testing.T) { if !looksLikeExistingEndpoint(http.Header{}, []byte(`[]`)) { t.Fatal("expected true for array body") } }) t.Run("html body", func(t *testing.T) { if looksLikeExistingEndpoint(http.Header{}, []byte(``)) { t.Fatal("expected false for html body") } }) } // Tests for NamedResource type used by the filter functions. // Defined locally since it's in the same package. func TestNewClientWithNilOption(t *testing.T) { client, err := NewClient("http://localhost:8080", nil) if err != nil { t.Fatal(err) } if client == nil { t.Fatal("client is nil") } } func TestNewHTTPError(t *testing.T) { e := newHTTPError("GET", "/v1/models", 200, []byte(`{"ok":true}`)) if e.Method != "GET" || e.Path != "/v1/models" || e.StatusCode != 200 || e.Body != `{"ok":true}` { t.Fatalf("unexpected http error: %+v", e) } } func TestPerformWithMockServer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/admin/system/version": w.Write([]byte(`{"data":{"version":"v1.2.3"}}`)) case "/api/v1/admin/groups": w.Write([]byte(`{"data":{"id":"g1","name":"test-group"}}`)) case "/api/v1/admin/channels": w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"panic"}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() client, err := NewClient(srv.URL, WithAPIKey("test-key")) if err != nil { t.Fatal(err) } t.Run("GetHostVersion", func(t *testing.T) { ver, err := client.GetHostVersion(context.Background()) if err != nil { t.Fatal(err) } if ver != "v1.2.3" { t.Fatalf("version = %q, want %q", ver, "v1.2.3") } }) t.Run("postJSON success", func(t *testing.T) { var ref GroupRef if err := client.postJSON(context.Background(), "/api/v1/admin/groups", CreateGroupRequest{Name: "test"}, &ref); err != nil { t.Fatal(err) } if ref.ID != "g1" || ref.Name != "test-group" { t.Fatalf("got %+v, want {ID:g1 Name:test-group}", ref) } }) t.Run("postJSON error status", func(t *testing.T) { var ref GroupRef err := client.postJSON(context.Background(), "/api/v1/admin/channels", nil, &ref) if err == nil { t.Fatal("expected error") } var httpErr *HTTPError if !errors.As(err, &httpErr) { t.Fatalf("expected HTTPError, got %T: %v", err, err) } if httpErr.StatusCode != 500 { t.Fatalf("status code = %d, want 500", httpErr.StatusCode) } }) t.Run("getJSON success", func(t *testing.T) { var ref GroupRef if err := client.getJSON(context.Background(), "/api/v1/admin/groups", &ref); err != nil { t.Fatal(err) } }) t.Run("getJSON error status", func(t *testing.T) { var ref GroupRef err := client.getJSON(context.Background(), "/bad/path", &ref) if err == nil { t.Fatal("expected error") } }) } func TestCreateGroupWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Platform string `json:"platform"` RateMultiplier float64 `json:"rate_multiplier"` SubscriptionType string `json:"subscription_type"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if req.Name != "demo" || req.Platform != "openai" || req.RateMultiplier != 1.0 { t.Fatalf("unexpected request: %+v", req) } w.Write([]byte(`{"data":{"id":"g1","name":"demo"}}`)) })) defer srv.Close() client, err := NewClient(srv.URL, WithAPIKey("k")) if err != nil { t.Fatal(err) } ref, err := client.CreateGroup(context.Background(), CreateGroupRequest{Name: "demo", Platform: "openai", RateMultiplier: 1.0}) if err != nil { t.Fatal(err) } if ref.ID != "g1" || ref.Name != "demo" { t.Fatalf("got %+v", ref) } } func TestCreateChannelWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` GroupIDs []int64 `json:"group_ids"` ModelMapping map[string]map[string]string `json:"model_mapping"` ModelPricing []struct { Platform string `json:"platform"` Models []string `json:"models"` BillingMode string `json:"billing_mode"` InputPrice *float64 `json:"input_price"` OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` Intervals []any `json:"intervals"` } `json:"model_pricing"` RestrictModels bool `json:"restrict_models"` BillingModelSource string `json:"billing_model_source"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if req.Name != "ch" { t.Fatalf("name = %q, want ch", req.Name) } if len(req.GroupIDs) != 1 || req.GroupIDs[0] != 101 { t.Fatalf("group_ids = %v, want [101]", req.GroupIDs) } if req.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" { t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", req.ModelMapping) } if len(req.ModelPricing) != 1 { t.Fatalf("model_pricing len = %d, want 1", len(req.ModelPricing)) } if req.ModelPricing[0].Platform != "openai" || req.ModelPricing[0].BillingMode != "token" { t.Fatalf("model_pricing[0] = %+v, want openai/token entry", req.ModelPricing[0]) } if len(req.ModelPricing[0].Models) != 1 || req.ModelPricing[0].Models[0] != "deepseek-v4-pro" { t.Fatalf("model_pricing[0].models = %v, want [deepseek-v4-pro]", req.ModelPricing[0].Models) } if !req.RestrictModels { t.Fatal("restrict_models = false, want true") } if req.BillingModelSource != "channel_mapped" { t.Fatalf("billing_model_source = %q, want channel_mapped", req.BillingModelSource) } w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) ref, err := client.CreateChannel(context.Background(), CreateChannelRequest{ Name: "ch", GroupIDs: []string{"101"}, ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, ModelPricing: []ChannelModelPricing{{Platform: "openai", Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, RestrictModels: true, BillingModelSource: "channel_mapped", }) if err != nil { t.Fatal(err) } if ref.ID != "201" { t.Fatalf("id = %q, want 201", ref.ID) } } func TestUpdateChannelWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Fatalf("method = %s, want PUT", r.Method) } if r.URL.Path != "/api/v1/admin/channels/201" { t.Fatalf("path = %s, want /api/v1/admin/channels/201", r.URL.Path) } var req struct { ModelMapping map[string]map[string]string `json:"model_mapping"` ModelPricing []struct { Platform string `json:"platform"` Models []string `json:"models"` BillingMode string `json:"billing_mode"` } `json:"model_pricing"` RestrictModels bool `json:"restrict_models"` BillingModelSource string `json:"billing_model_source"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if req.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" { t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", req.ModelMapping) } if len(req.ModelPricing) != 1 || req.ModelPricing[0].Platform != "openai" || req.ModelPricing[0].BillingMode != "token" { t.Fatalf("model_pricing = %+v, want openai/token entry", req.ModelPricing) } if !req.RestrictModels { t.Fatal("restrict_models = false, want true") } if req.BillingModelSource != "channel_mapped" { t.Fatalf("billing_model_source = %q, want channel_mapped", req.BillingModelSource) } w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) if err := client.UpdateChannel(context.Background(), "201", CreateChannelRequest{ Name: "ch", GroupIDs: []string{"101"}, ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, ModelPricing: []ChannelModelPricing{{Platform: "openai", Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, RestrictModels: true, BillingModelSource: "channel_mapped", }); err != nil { t.Fatal(err) } } func TestCreateChannelRequestMarshalJSONDefaultsPricingPlatform(t *testing.T) { t.Run("request platform", func(t *testing.T) { payload, err := json.Marshal(CreateChannelRequest{ Name: "ch", GroupIDs: []string{"101"}, Platform: "openai", ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, ModelPricing: []ChannelModelPricing{{Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, }) if err != nil { t.Fatalf("Marshal() error = %v", err) } var got struct { ModelMapping map[string]map[string]string `json:"model_mapping"` ModelPricing []struct { Platform string `json:"platform"` } `json:"model_pricing"` } if err := json.Unmarshal(payload, &got); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if got.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" { t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", got.ModelMapping) } if len(got.ModelPricing) != 1 || got.ModelPricing[0].Platform != "openai" { t.Fatalf("model_pricing = %+v, want platform openai", got.ModelPricing) } }) t.Run("openai fallback", func(t *testing.T) { payload, err := json.Marshal(CreateChannelRequest{ Name: "ch", GroupIDs: []string{"101"}, ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, ModelPricing: []ChannelModelPricing{{Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, }) if err != nil { t.Fatalf("Marshal() error = %v", err) } var got struct { ModelPricing []struct { Platform string `json:"platform"` } `json:"model_pricing"` } if err := json.Unmarshal(payload, &got); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(got.ModelPricing) != 1 || got.ModelPricing[0].Platform != "openai" { t.Fatalf("model_pricing = %+v, want platform openai fallback", got.ModelPricing) } }) } func TestCreatePlanWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { GroupID int64 `json:"group_id"` Name string `json:"name"` Price float64 `json:"price"` ValidityDays int `json:"validity_days"` ValidityUnit string `json:"validity_unit"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if req.GroupID != 101 || req.Name != "plan" || req.Price != 19.9 || req.ValidityDays != 30 || req.ValidityUnit != "day" { t.Fatalf("unexpected request: %+v", req) } w.Write([]byte(`{"data":{"id":301,"name":"plan"}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) ref, err := client.CreatePlan(context.Background(), CreatePlanRequest{GroupID: "101", Name: "plan", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"}) if err != nil { t.Fatal(err) } if ref.ID != "301" { t.Fatalf("id = %q, want 301", ref.ID) } } func TestDeleteWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) t.Run("DeleteGroup", func(t *testing.T) { if err := client.DeleteGroup(context.Background(), "g1"); err != nil { t.Fatal(err) } }) t.Run("DeleteChannel", func(t *testing.T) { if err := client.DeleteChannel(context.Background(), "c1"); err != nil { t.Fatal(err) } }) t.Run("DeletePlan", func(t *testing.T) { if err := client.DeletePlan(context.Background(), "p1"); err != nil { t.Fatal(err) } }) t.Run("DeleteAccount", func(t *testing.T) { if err := client.DeleteAccount(context.Background(), "a1"); err != nil { t.Fatal(err) } }) } func TestAssignSubscriptionWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { UserID int64 `json:"user_id"` GroupID int64 `json:"group_id"` DurationDays int `json:"validity_days"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if req.UserID != 501 || req.GroupID != 101 || req.DurationDays != 30 { t.Fatalf("unexpected request: %+v", req) } w.Write([]byte(`{"data":{"id":401}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) ref, err := client.AssignSubscription(context.Background(), AssignSubscriptionRequest{UserID: "501", GroupID: "101", DurationDays: 30}) if err != nil { t.Fatal(err) } if ref.ID != "401" { t.Fatalf("id = %q", ref.ID) } } func TestEnsureSubscriptionAccessWithMock(t *testing.T) { var calls []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls = append(calls, r.Method+" "+r.URL.Path) switch { case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): w.Write([]byte(`{"data":{"items":[]}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": w.Write([]byte(`{"data":{"id":84,"email":"relay-sub-user-1@sub2api.local"}}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84": w.Write([]byte(`{"data":{"id":84}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/84/balance": w.Write([]byte(`{"data":{"id":84}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/subscriptions/assign": var req struct { UserID int64 `json:"user_id"` GroupID int64 `json:"group_id"` DurationDays int `json:"validity_days"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode assign subscription request: %v", err) } if req.UserID != 84 || req.GroupID != 101 || req.DurationDays != 30 { t.Fatalf("unexpected assign subscription request: %+v", req) } w.Write([]byte(`{"data":{"id":401}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/auth/login": w.Write([]byte(`{"data":{"access_token":"user-jwt"}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/keys": var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode managed key request: %v", err) } if _, ok := req["group_id"]; ok { t.Fatalf("managed key request unexpectedly carried group_id: %+v", req) } w.Write([]byte(`{"data":{"id":501,"key":"sk-relay-key","name":"managed-key"}}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501": w.Write([]byte(`{"data":{"api_key":{"id":501}}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() client, _ := NewClient(srv.URL, WithBearerToken("admin-token")) ref, err := client.EnsureSubscriptionAccess(context.Background(), EnsureSubscriptionAccessRequest{UserSelector: "crm-user-1", GroupID: "101"}) if err != nil { t.Fatal(err) } if ref.UserID != "84" { t.Fatalf("user id = %q, want 84", ref.UserID) } if !strings.HasPrefix(ref.APIKey, "sk-relay-") { t.Fatalf("api key = %q, want managed sk-relay-* key", ref.APIKey) } if len(calls) < 7 { t.Fatalf("calls = %v, want managed subscription setup sequence", calls) } } func TestCheckGatewayAccessWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer gk" { t.Fatalf("Authorization = %q, want %q", got, "Bearer gk") } if got := r.Header.Get("x-api-key"); got != "" { t.Fatalf("x-api-key = %q, want empty", got) } w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) result, err := client.CheckGatewayAccess(context.Background(), GatewayAccessCheckRequest{APIKey: "gk", ExpectedModel: "gpt-4"}) if err != nil { t.Fatal(err) } if !result.OK { t.Fatal("expected OK=true") } if !result.HasExpectedModel { t.Fatal("expected HasExpectedModel=true") } } func TestCheckGatewayCompletionWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/chat/completions" { t.Fatalf("path = %q, want /v1/chat/completions", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer gk" { t.Fatalf("Authorization = %q, want %q", got, "Bearer gk") } if got := r.Header.Get("x-api-key"); got != "" { t.Fatalf("x-api-key = %q, want empty", got) } var payload struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode request: %v", err) } if payload.Model != "gpt-4" { t.Fatalf("model = %q, want gpt-4", payload.Model) } if payload.MaxTokens != 8 { t.Fatalf("max_tokens = %d, want 8", payload.MaxTokens) } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) result, err := client.CheckGatewayCompletion(context.Background(), GatewayCompletionCheckRequest{APIKey: "gk", Model: "gpt-4"}) if err != nil { t.Fatal(err) } if !result.OK { t.Fatal("expected completion OK=true") } if result.StatusCode != 200 { t.Fatalf("status = %d, want 200", result.StatusCode) } if result.ContentType != "application/json" { t.Fatalf("content type = %q, want application/json", result.ContentType) } } func TestDisableOpenAIResponsesAPIWithMock(t *testing.T) { var calls []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls = append(calls, r.Method+" "+r.URL.Path) if r.Method != http.MethodPut { t.Fatalf("method = %q, want PUT", r.Method) } var payload struct { Extra map[string]any `json:"extra"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode request: %v", err) } if got, ok := payload.Extra["openai_responses_supported"].(bool); !ok || got { t.Fatalf("openai_responses_supported = %+v, want false", payload.Extra["openai_responses_supported"]) } w.Write([]byte(`{"data":{"id":1}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) if err := client.DisableOpenAIResponsesAPI(context.Background(), []string{"101", "101", " ", "102"}); err != nil { t.Fatalf("DisableOpenAIResponsesAPI() error = %v", err) } if len(calls) != 2 { t.Fatalf("calls = %v, want 2 unique account updates", calls) } if calls[0] != "PUT /api/v1/admin/accounts/101" { t.Fatalf("first call = %q, want PUT /api/v1/admin/accounts/101", calls[0]) } if calls[1] != "PUT /api/v1/admin/accounts/102" { t.Fatalf("second call = %q, want PUT /api/v1/admin/accounts/102", calls[1]) } } func TestBatchCreateAccountsWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Accounts []struct { Name string `json:"name"` Platform string `json:"platform"` Type string `json:"type"` Credentials map[string]any `json:"credentials"` GroupIDs []int64 `json:"group_ids"` } `json:"accounts"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if len(req.Accounts) != 1 { t.Fatalf("accounts len = %d, want 1", len(req.Accounts)) } acct := req.Accounts[0] if acct.Name != "acct1" || acct.Platform != "openai" || acct.Type != "apikey" { t.Fatalf("unexpected account metadata: %+v", acct) } if len(acct.GroupIDs) != 1 || acct.GroupIDs[0] != 101 { t.Fatalf("group_ids = %v, want [101]", acct.GroupIDs) } rawMapping, ok := acct.Credentials["model_mapping"].(map[string]any) if !ok { t.Fatalf("credentials = %+v, want model_mapping map", acct.Credentials) } if got, _ := rawMapping["deepseek-v4-pro"].(string); got != "deepseek-v4-pro" { t.Fatalf("model_mapping = %+v, want deepseek-v4-pro passthrough", rawMapping) } w.Write([]byte(`{"data":[{"id":601,"name":"acct1"}]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) refs, err := client.BatchCreateAccounts(context.Background(), BatchCreateAccountsRequest{ Accounts: []CreateAccountRequest{{Name: "acct1", Platform: "openai", Type: "apikey", GroupIDs: []string{"101"}, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.example.com", "model_mapping": map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}}}}, }) if err != nil { t.Fatal(err) } if len(refs) != 1 || refs[0].ID != "601" { t.Fatalf("got %+v", refs) } } func TestProbeCapabilitiesWithMock(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"data":[]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) caps, err := client.ProbeCapabilities(context.Background()) if err != nil { t.Fatal(err) } if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions { t.Fatalf("all capabilities should be true, got %+v", caps) } if callCount != 7 { t.Fatalf("callCount = %d, want 7", callCount) } } func TestProbeCapabilitiesDoesNotTreat404AsSupportForAccountOrSubscriptionRoutes(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/v1/admin/groups", "/api/v1/admin/channels", "/api/v1/admin/payment/plans", "/api/v1/admin/accounts": w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"data":[]}`)) case "/api/v1/admin/accounts/__probe__/test", "/api/v1/admin/accounts/__probe__/models", "/api/v1/admin/subscriptions/assign": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"not found"}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) caps, err := client.ProbeCapabilities(context.Background()) if err != nil { t.Fatalf("ProbeCapabilities() error = %v", err) } if caps.AccountTest { t.Fatal("AccountTest = true, want false on 404 probe route") } if caps.AccountModels { t.Fatal("AccountModels = true, want false on 404 probe route") } if caps.Subscriptions { t.Fatal("Subscriptions = true, want false on 404 probe route") } } func TestListManagedResourcesWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"data":{"items":[ {"id":"r1","name":"resource-1"} ]}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) snapshot, err := client.ListManagedResources(context.Background(), ListManagedResourcesRequest{}) if err != nil { t.Fatal(err) } if len(snapshot.Groups) != 1 { t.Fatalf("expected 1 group, got %d", len(snapshot.Groups)) } } func TestListManagedResourcesLoadsAllAccountPages(t *testing.T) { accountPages := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/admin/groups", "/api/v1/admin/channels": _, _ = w.Write([]byte(`{"data":{"items":[{"id":"r1","name":"resource-1"}],"total":1,"page":1,"page_size":20,"pages":1}}`)) case "/api/v1/admin/payment/plans": _, _ = w.Write([]byte(`{"data":[{"id":"plan_1","name":"plan-1"}]}`)) case "/api/v1/admin/accounts": accountPages++ page := r.URL.Query().Get("page") if page == "" { page = "1" } if got := r.URL.Query().Get("page_size"); got != "100" { t.Fatalf("page_size = %q, want 100", got) } switch page { case "1": _, _ = w.Write([]byte(`{"data":{"items":[{"id":"account_1","name":"deepseek-01"}],"total":2,"page":1,"page_size":100,"pages":2}}`)) case "2": _, _ = w.Write([]byte(`{"data":{"items":[{"id":"account_2","name":"deepseek-02"}],"total":2,"page":2,"page_size":100,"pages":2}}`)) default: t.Fatalf("unexpected accounts page %q", page) } default: http.NotFound(w, r) } })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) snapshot, err := client.ListManagedResources(context.Background(), ListManagedResourcesRequest{AccountNamePrefix: "deepseek-"}) if err != nil { t.Fatal(err) } if accountPages != 2 { t.Fatalf("account pages fetched = %d, want 2", accountPages) } if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" { t.Fatalf("Accounts = %+v, want both paged accounts", snapshot.Accounts) } } func TestTestAccountWithMock(t *testing.T) { var requestBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { t.Fatalf("decode request body: %v", err) } w.Write([]byte("data: {\"status\":\"passed\",\"ok\":true}\n")) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) result, err := client.TestAccount(context.Background(), "a1", "MiniMax-M2.7-highspeed") if err != nil { t.Fatal(err) } if !result.OK { t.Fatal("expected OK=true") } if got := requestBody["model_id"]; got != "MiniMax-M2.7-highspeed" { t.Fatalf("model_id = %#v, want MiniMax-M2.7-highspeed", got) } } func TestTestAccountWithMockSSEError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Write([]byte("data: {\"type\":\"test_start\",\"model\":\"MiniMax-M2.7-highspeed\"}\n\n")) w.Write([]byte("data: {\"type\":\"error\",\"error\":\"账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。\"}\n\n")) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) result, err := client.TestAccount(context.Background(), "a1", "") if err != nil { t.Fatal(err) } if result.OK { t.Fatal("expected OK=false for SSE error event") } if result.Status != "failed" { t.Fatalf("Status = %q, want failed", result.Status) } if !strings.Contains(result.Message, "测试接口仅支持 Responses API 路径") { t.Fatalf("Message = %q, want propagated SSE error message", result.Message) } } func TestGetAccountModelsWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"data":[{"id":"m1","display_name":"M1","type":"chat"}]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) models, err := client.GetAccountModels(context.Background(), "a1") if err != nil { t.Fatal(err) } if len(models) != 1 || models[0].ID != "m1" { t.Fatalf("got %+v", models) } }