package integration_test import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "sub2api-cn-relay-manager/internal/host/sub2api" ) func TestSub2APIHostAdapterGetHostVersion(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"), sub2api.WithBearerToken("bearer-token")) if err != nil { t.Fatalf("NewClient() error = %v", err) } version, err := client.GetHostVersion(context.Background()) if err != nil { t.Fatalf("GetHostVersion() error = %v", err) } if version != "0.1.126" { t.Fatalf("version = %q, want %q", version, "0.1.126") } } func TestSub2APIHostAdapterProbeCapabilities(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } caps, err := client.ProbeCapabilities(context.Background()) if err != nil { t.Fatalf("ProbeCapabilities() error = %v", err) } if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions { t.Fatalf("ProbeCapabilities() = %+v, want all capabilities true", caps) } } func TestSub2APIHostAdapterMapsUnauthorizedErrors(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL) if err != nil { t.Fatalf("NewClient() error = %v", err) } _, err = client.GetHostVersion(context.Background()) if err == nil { t.Fatal("GetHostVersion() error = nil, want 401 error") } var httpErr *sub2api.HTTPError if !errors.As(err, &httpErr) { t.Fatalf("GetHostVersion() error type = %T, want *sub2api.HTTPError", err) } if httpErr.StatusCode != http.StatusUnauthorized { t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusUnauthorized) } } func TestSub2APIHostAdapterMapsNotFoundErrors(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } _, err = client.GetHostVersion(context.Background()) if err == nil { t.Fatal("GetHostVersion() error = nil, want 404 error") } var httpErr *sub2api.HTTPError if !errors.As(err, &httpErr) { t.Fatalf("GetHostVersion() error type = %T, want *sub2api.HTTPError", err) } if httpErr.StatusCode != http.StatusNotFound { t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusNotFound) } } func TestSub2APIHostAdapterCreateGroup(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"), sub2api.WithBearerToken("bearer-token")) if err != nil { t.Fatalf("NewClient() error = %v", err) } group, err := client.CreateGroup(context.Background(), sub2api.CreateGroupRequest{ Name: "relay-group", RateMultiplier: 1.5, }) if err != nil { t.Fatalf("CreateGroup() error = %v", err) } if group.ID != "group_1" { t.Fatalf("group.ID = %q, want %q", group.ID, "group_1") } if group.Name != "relay-group" { t.Fatalf("group.Name = %q, want %q", group.Name, "relay-group") } } func TestSub2APIHostAdapterTestAccountParsesSSE(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } result, err := client.TestAccount(context.Background(), "account_1", "deepseek-chat") if err != nil { t.Fatalf("TestAccount() error = %v", err) } if !result.OK { t.Fatal("TestAccount() OK = false, want true") } if result.Status != "passed" { t.Fatalf("TestAccount() Status = %q, want %q", result.Status, "passed") } if result.Message != "smoke passed" { t.Fatalf("TestAccount() Message = %q, want %q", result.Message, "smoke passed") } } func TestSub2APIHostAdapterGetAccountModelsParsesEnvelope(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } models, err := client.GetAccountModels(context.Background(), "account_1") if err != nil { t.Fatalf("GetAccountModels() error = %v", err) } if len(models) != 2 { t.Fatalf("len(models) = %d, want 2", len(models)) } if models[0].ID != "deepseek-chat" || models[0].DisplayName != "DeepSeek Chat" || models[0].Type != "chat" { t.Fatalf("first model = %+v, want id/display_name/type from envelope", models[0]) } } func TestSub2APIHostAdapterChecksGatewayAccess(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL) if err != nil { t.Fatalf("NewClient() error = %v", err) } result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ APIKey: "user-api-key", ExpectedModel: "deepseek-chat", }) if err != nil { t.Fatalf("CheckGatewayAccess() error = %v", err) } if !result.OK || !result.HasExpectedModel { t.Fatalf("CheckGatewayAccess() = %+v, want ok with expected model", result) } } func TestSub2APIHostAdapterSeparatesAccountModelsFromGatewayModels(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", accountModels: []map[string]any{{"id": "deepseek-account-only", "display_name": "DeepSeek Account Only", "type": "chat"}}, gatewayModels: []map[string]any{{"id": "deepseek-gateway-only"}}, gatewayExpectedKey: "managed-user-key", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } accountModels, err := client.GetAccountModels(context.Background(), "account_1") if err != nil { t.Fatalf("GetAccountModels() error = %v", err) } if len(accountModels) != 1 || accountModels[0].ID != "deepseek-account-only" { t.Fatalf("GetAccountModels() = %+v, want admin account models only", accountModels) } gatewayResult, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ APIKey: "managed-user-key", ExpectedModel: "deepseek-gateway-only", }) if err != nil { t.Fatalf("CheckGatewayAccess() error = %v", err) } if !gatewayResult.OK || !gatewayResult.HasExpectedModel { t.Fatalf("CheckGatewayAccess() = %+v, want gateway models only", gatewayResult) } if len(gatewayResult.Models) != 1 || gatewayResult.Models[0] != "deepseek-gateway-only" { t.Fatalf("gateway models = %+v, want gateway-only model list", gatewayResult.Models) } if gatewayResult.Models[0] == accountModels[0].ID { t.Fatalf("gateway models = %+v unexpectedly matched account models %+v", gatewayResult.Models, accountModels) } } func TestSub2APIHostAdapterGatewayProbeDoesNotReuseAdminCredential(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", gatewayExpectedKey: "managed-user-key", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ APIKey: "managed-user-key", ExpectedModel: "deepseek-chat", }) if err != nil { t.Fatalf("CheckGatewayAccess() error = %v", err) } if !result.OK { t.Fatalf("CheckGatewayAccess() = %+v, want OK=true with managed user key", result) } wrongKeyResult, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ APIKey: "api-key", ExpectedModel: "deepseek-chat", }) if err != nil { t.Fatalf("CheckGatewayAccess() with admin key error = %v", err) } if wrongKeyResult.OK { t.Fatalf("CheckGatewayAccess() with admin key = %+v, want OK=false", wrongKeyResult) } if wrongKeyResult.StatusCode != http.StatusUnauthorized { t.Fatalf("StatusCode = %d, want %d", wrongKeyResult.StatusCode, http.StatusUnauthorized) } if wrongKeyResult.HasExpectedModel { t.Fatalf("CheckGatewayAccess() with admin key = %+v, want HasExpectedModel=false", wrongKeyResult) } } func TestSub2APIHostAdapterDeletesManagedResources(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } if err := client.DeleteAccount(context.Background(), "account_1"); err != nil { t.Fatalf("DeleteAccount() error = %v", err) } if err := client.DeleteChannel(context.Background(), "channel_1"); err != nil { t.Fatalf("DeleteChannel() error = %v", err) } if err := client.DeleteGroup(context.Background(), "group_1"); err != nil { t.Fatalf("DeleteGroup() error = %v", err) } } func TestSub2APIHostAdapterListManagedResources(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, version: "0.1.126", }) defer server.Close() client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) if err != nil { t.Fatalf("NewClient() error = %v", err) } snapshot, err := client.ListManagedResources(context.Background(), sub2api.ListManagedResourcesRequest{ GroupName: "crm-deepseek-group", ChannelName: "crm-deepseek-channel", PlanName: "crm-deepseek-plan", AccountNamePrefix: "deepseek-", }) if err != nil { t.Fatalf("ListManagedResources() error = %v", err) } if len(snapshot.Groups) != 1 || snapshot.Groups[0].ID != "group_1" { t.Fatalf("Groups = %+v, want one group_1 match", snapshot.Groups) } if len(snapshot.Channels) != 2 || snapshot.Channels[0].ID != "channel_1" || snapshot.Channels[1].ID != "channel_2" { t.Fatalf("Channels = %+v, want two matching channels", snapshot.Channels) } if len(snapshot.Plans) != 1 || snapshot.Plans[0].ID != "plan_1" { t.Fatalf("Plans = %+v, want one plan_1 match", snapshot.Plans) } if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" { t.Fatalf("Accounts = %+v, want two deepseek account matches", snapshot.Accounts) } } type sub2APIStubConfig struct { requireAPIKey bool version string accountModels []map[string]any gatewayModels []map[string]any gatewayExpectedKey string } func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server { t.Helper() accountModels := cfg.accountModels if len(accountModels) == 0 { accountModels = []map[string]any{ {"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"}, {"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"}, } } gatewayModels := cfg.gatewayModels if len(gatewayModels) == 0 { gatewayModels = []map[string]any{ {"id": "deepseek-chat"}, {"id": "deepseek-reasoner"}, } } gatewayExpectedKey := cfg.gatewayExpectedKey if gatewayExpectedKey == "" { gatewayExpectedKey = "user-api-key" } mux := http.NewServeMux() mux.HandleFunc("/api/v1/admin/system/version", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } if r.Method != http.MethodGet { http.NotFound(w, r) return } writeJSON(t, w, http.StatusOK, map[string]any{ "data": map[string]any{ "version": cfg.version, }, }) }) mux.HandleFunc("/api/v1/admin/groups", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } switch r.Method { case http.MethodGet: writeJSON(t, w, http.StatusOK, map[string]any{ "data": []map[string]any{ {"id": "group_1", "name": "crm-deepseek-group"}, {"id": "group_2", "name": "other-group"}, }, }) case http.MethodPost: var payload map[string]any _ = json.NewDecoder(r.Body).Decode(&payload) if payload["name"] == "" || payload["rate_multiplier"] == nil { writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) return } writeJSON(t, w, http.StatusCreated, map[string]any{ "data": map[string]any{ "id": "group_1", "name": payload["name"], }, }) default: http.NotFound(w, r) } }) mux.HandleFunc("/api/v1/admin/groups/", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } if r.Method != http.MethodDelete { http.NotFound(w, r) return } w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/api/v1/admin/channels", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } switch r.Method { case http.MethodGet: writeJSON(t, w, http.StatusOK, map[string]any{ "data": []map[string]any{ {"id": "channel_1", "name": "crm-deepseek-channel"}, {"id": "channel_2", "name": "crm-deepseek-channel"}, {"id": "channel_3", "name": "other-channel"}, }, }) case http.MethodPost: writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) default: http.NotFound(w, r) } }) mux.HandleFunc("/api/v1/admin/channels/", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } if r.Method != http.MethodDelete { http.NotFound(w, r) return } w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/api/v1/admin/payment/plans", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } switch r.Method { case http.MethodGet: writeJSON(t, w, http.StatusOK, map[string]any{ "data": []map[string]any{ {"id": "plan_1", "name": "crm-deepseek-plan"}, {"id": "plan_2", "name": "other-plan"}, }, }) case http.MethodPost: writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) default: http.NotFound(w, r) } }) mux.HandleFunc("/api/v1/admin/accounts", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } switch r.Method { case http.MethodGet: writeJSON(t, w, http.StatusOK, map[string]any{ "data": []map[string]any{ {"id": "account_1", "name": "deepseek-01"}, {"id": "account_2", "name": "deepseek-02"}, {"id": "account_3", "name": "other-01"}, }, }) case http.MethodPost: writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) default: http.NotFound(w, r) } }) mux.HandleFunc("/api/v1/admin/accounts/batch", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } if r.Method != http.MethodPost { http.NotFound(w, r) return } writeJSON(t, w, http.StatusOK, map[string]any{ "data": []map[string]any{ {"id": "account_1"}, {"id": "account_2"}, }, }) }) mux.HandleFunc("/api/v1/admin/accounts/", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/admin/accounts/"), "/") if len(parts) == 1 { if r.Method != http.MethodDelete { http.NotFound(w, r) return } w.WriteHeader(http.StatusNoContent) return } if len(parts) != 2 { http.NotFound(w, r) return } accountID, action := parts[0], parts[1] switch action { case "test": if r.Method == http.MethodGet { writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{"supported": true}}) return } if r.Method != http.MethodPost { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "event: result\n") fmt.Fprintf(w, "data: {\"status\":\"passed\",\"message\":\"smoke passed\",\"ok\":true,\"account_id\":\"%s\"}\n\n", accountID) case "models": if r.Method != http.MethodGet { http.NotFound(w, r) return } writeJSON(t, w, http.StatusOK, map[string]any{ "data": map[string]any{ "items": accountModels, }, }) default: http.NotFound(w, r) } }) mux.HandleFunc("/api/v1/admin/subscriptions/assign", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } if r.Method == http.MethodGet { writeJSON(t, w, http.StatusOK, map[string]any{ "data": map[string]any{"supported": true}, }) return } if r.Method != http.MethodPost { http.NotFound(w, r) return } writeJSON(t, w, http.StatusOK, map[string]any{ "data": map[string]any{ "id": "subscription_1", }, }) }) mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer "+gatewayExpectedKey { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) return } writeJSON(t, w, http.StatusOK, map[string]any{ "data": gatewayModels, }) }) mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer "+gatewayExpectedKey { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) return } writeJSON(t, w, http.StatusOK, map[string]any{ "id": "chatcmpl_stub", "object": "chat.completion", "choices": []map[string]any{ { "index": 0, "message": map[string]any{ "role": "assistant", "content": "pong", }, }, }, }) }) return httptest.NewServer(mux) } func mustStubAuth(t *testing.T, w http.ResponseWriter, r *http.Request, requireAPIKey bool) bool { t.Helper() if !requireAPIKey { return true } if got := r.Header.Get("x-api-key"); got == "api-key" { if r.Header.Get("Authorization") != "" { t.Fatalf("Authorization header = %q, want empty when x-api-key is configured", r.Header.Get("Authorization")) } return true } if got := r.Header.Get("Authorization"); got == "Bearer bearer-token" { return true } w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) return false } func writeJSON(t *testing.T, w http.ResponseWriter, statusCode int, payload any) { t.Helper() w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(payload); err != nil { t.Fatalf("json.Encode() error = %v", err) } }