diff --git a/internal/host/sub2api/accounts.go b/internal/host/sub2api/accounts.go new file mode 100644 index 00000000..6d40f07c --- /dev/null +++ b/internal/host/sub2api/accounts.go @@ -0,0 +1,163 @@ +package sub2api + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +func (c *Client) CreateAccount(ctx context.Context, req CreateAccountRequest) (AccountRef, error) { + var ref AccountRef + if err := c.postJSON(ctx, "/api/v1/admin/accounts", req, &ref); err != nil { + return AccountRef{}, err + } + return ref, nil +} + +func (c *Client) BatchCreateAccounts(ctx context.Context, req BatchCreateAccountsRequest) ([]AccountRef, error) { + statusCode, _, body, err := c.perform(ctx, http.MethodPost, "/api/v1/admin/accounts/batch", req) + if err != nil { + return nil, err + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return nil, newHTTPError(http.MethodPost, "/api/v1/admin/accounts/batch", statusCode, body) + } + + models, err := decodeAccountRefs(body) + if err != nil { + return nil, fmt.Errorf("decode /api/v1/admin/accounts/batch response: %w", err) + } + return models, nil +} + +func (c *Client) TestAccount(ctx context.Context, accountID string) (ProbeResult, error) { + path := "/api/v1/admin/accounts/" + accountID + "/test" + statusCode, _, body, err := c.perform(ctx, http.MethodPost, path, map[string]any{}) + if err != nil { + return ProbeResult{}, err + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return ProbeResult{}, newHTTPError(http.MethodPost, path, statusCode, body) + } + + result, err := parseProbeResult(body) + if err != nil { + return ProbeResult{}, fmt.Errorf("parse %s sse: %w", path, err) + } + return result, nil +} + +func (c *Client) GetAccountModels(ctx context.Context, accountID string) ([]AccountModel, error) { + path := "/api/v1/admin/accounts/" + accountID + "/models" + statusCode, _, body, err := c.perform(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return nil, newHTTPError(http.MethodGet, path, statusCode, body) + } + + models, err := decodeAccountModels(body) + if err != nil { + return nil, fmt.Errorf("decode %s response: %w", path, err) + } + return models, nil +} + +func decodeAccountRefs(body []byte) ([]AccountRef, error) { + var refs []AccountRef + if err := decodeEnvelopeObject(body, &refs); err == nil { + return refs, nil + } + + var wrapper struct { + Data struct { + Items []AccountRef `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { + return nil, err + } + return wrapper.Data.Items, nil +} + +func decodeAccountModels(body []byte) ([]AccountModel, error) { + var models []AccountModel + if err := decodeEnvelopeObject(body, &models); err == nil { + return models, nil + } + + var wrapper struct { + Data struct { + Items []AccountModel `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { + return nil, err + } + return wrapper.Data.Items, nil +} + +func parseProbeResult(body []byte) (ProbeResult, error) { + scanner := bufio.NewScanner(bytes.NewReader(body)) + var payloads []string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "data:") { + payloads = append(payloads, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + if err := scanner.Err(); err != nil { + return ProbeResult{}, err + } + if len(payloads) == 0 { + return ProbeResult{}, fmt.Errorf("missing data event") + } + + var event struct { + Status string `json:"status"` + Message string `json:"message"` + OK *bool `json:"ok"` + Success *bool `json:"success"` + } + if err := json.Unmarshal([]byte(payloads[len(payloads)-1]), &event); err != nil { + return ProbeResult{}, err + } + + ok := false + switch { + case event.OK != nil: + ok = *event.OK + case event.Success != nil: + ok = *event.Success + default: + switch strings.ToLower(strings.TrimSpace(event.Status)) { + case "ok", "pass", "passed", "success", "succeeded": + ok = true + } + } + + status := normalizeProbeStatus(event.Status, ok) + return ProbeResult{ + OK: ok, + Status: status, + Message: strings.TrimSpace(event.Message), + }, nil +} + +func normalizeProbeStatus(status string, ok bool) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "pass", "passed", "ok", "success", "succeeded": + return "passed" + case "fail", "failed", "error": + return "failed" + } + if ok { + return "passed" + } + return "failed" +} diff --git a/internal/host/sub2api/capability_probe.go b/internal/host/sub2api/capability_probe.go new file mode 100644 index 00000000..ed24dbfe --- /dev/null +++ b/internal/host/sub2api/capability_probe.go @@ -0,0 +1,92 @@ +package sub2api + +import ( + "bytes" + "context" + "net/http" + "strings" +) + +func (c *Client) ProbeCapabilities(ctx context.Context) (HostCapabilities, error) { + groups, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/groups", map[string]any{}) + if err != nil { + return HostCapabilities{}, err + } + + channels, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/channels", map[string]any{}) + if err != nil { + return HostCapabilities{}, err + } + + plans, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/payment/plans", map[string]any{}) + if err != nil { + return HostCapabilities{}, err + } + + accounts, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/accounts", map[string]any{}) + if err != nil { + return HostCapabilities{}, err + } + + accountTest, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/accounts/__probe__/test", map[string]any{}) + if err != nil { + return HostCapabilities{}, err + } + + accountModels, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/accounts/__probe__/models", nil) + if err != nil { + return HostCapabilities{}, err + } + + subscriptions, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/subscriptions/assign", map[string]any{}) + if err != nil { + return HostCapabilities{}, err + } + + return HostCapabilities{ + Groups: groups, + Channels: channels, + Plans: plans, + Accounts: accounts, + AccountTest: accountTest, + AccountModels: accountModels, + Subscriptions: subscriptions, + }, nil +} + +func (c *Client) probeEndpoint(ctx context.Context, method, path string, requestBody any) (bool, error) { + statusCode, headers, body, err := c.perform(ctx, method, path, requestBody) + if err != nil { + return false, err + } + + switch { + case statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices: + return true, nil + case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden: + return false, newHTTPError(method, path, statusCode, body) + case statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed: + return looksLikeExistingEndpoint(headers, body), nil + case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError: + return true, nil + default: + return false, newHTTPError(method, path, statusCode, body) + } +} + +func looksLikeExistingEndpoint(headers http.Header, body []byte) bool { + contentType := strings.ToLower(headers.Get("Content-Type")) + trimmedBody := bytes.TrimSpace(body) + + if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "text/event-stream") { + return true + } + if len(trimmedBody) == 0 { + return false + } + if trimmedBody[0] == '{' || trimmedBody[0] == '[' { + return true + } + + return false +} diff --git a/internal/host/sub2api/channels.go b/internal/host/sub2api/channels.go new file mode 100644 index 00000000..ca382a22 --- /dev/null +++ b/internal/host/sub2api/channels.go @@ -0,0 +1,11 @@ +package sub2api + +import "context" + +func (c *Client) CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error) { + var ref ChannelRef + if err := c.postJSON(ctx, "/api/v1/admin/channels", req, &ref); err != nil { + return ChannelRef{}, err + } + return ref, nil +} diff --git a/internal/host/sub2api/client.go b/internal/host/sub2api/client.go new file mode 100644 index 00000000..ba126226 --- /dev/null +++ b/internal/host/sub2api/client.go @@ -0,0 +1,295 @@ +package sub2api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type HostAdapter interface { + GetHostVersion(ctx context.Context) (string, error) + ProbeCapabilities(ctx context.Context) (HostCapabilities, error) + CreateGroup(ctx context.Context, req CreateGroupRequest) (GroupRef, error) + CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error) + CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanRef, error) + CreateAccount(ctx context.Context, req CreateAccountRequest) (AccountRef, error) + BatchCreateAccounts(ctx context.Context, req BatchCreateAccountsRequest) ([]AccountRef, error) + TestAccount(ctx context.Context, accountID string) (ProbeResult, error) + GetAccountModels(ctx context.Context, accountID string) ([]AccountModel, error) + AssignSubscription(ctx context.Context, req AssignSubscriptionRequest) (SubscriptionRef, error) +} + +type HostCapabilities struct { + Groups bool `json:"groups"` + Channels bool `json:"channels"` + Plans bool `json:"plans"` + Accounts bool `json:"accounts"` + AccountTest bool `json:"account_test"` + AccountModels bool `json:"account_models"` + Subscriptions bool `json:"subscriptions"` +} + +type CreateGroupRequest struct { + Name string `json:"name"` + RateMultiplier float64 `json:"rate_multiplier"` +} + +type GroupRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type CreateChannelRequest struct { + Name string `json:"name"` + GroupIDs []string `json:"group_ids"` +} + +type ChannelRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type CreatePlanRequest struct { + GroupID string `json:"group_id"` + Name string `json:"name"` + Price float64 `json:"price"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` +} + +type PlanRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type CreateAccountRequest struct { + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + GroupIDs []string `json:"group_ids"` +} + +type BatchCreateAccountsRequest struct { + Accounts []CreateAccountRequest `json:"accounts"` +} + +type AccountRef struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + Type string `json:"type,omitempty"` +} + +type ProbeResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type AccountModel struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Type string `json:"type"` +} + +type AssignSubscriptionRequest struct { + UserID string `json:"user_id"` + GroupID string `json:"group_id"` + DurationDays int `json:"duration_days,omitempty"` +} + +type SubscriptionRef struct { + ID string `json:"id"` +} + +type Client struct { + baseURL *url.URL + httpClient *http.Client + apiKey string + bearerToken string +} + +type Option func(*Client) + +func WithHTTPClient(httpClient *http.Client) Option { + return func(client *Client) { + client.httpClient = httpClient + } +} + +func WithAPIKey(apiKey string) Option { + return func(client *Client) { + client.apiKey = strings.TrimSpace(apiKey) + } +} + +func WithBearerToken(token string) Option { + return func(client *Client) { + client.bearerToken = strings.TrimSpace(token) + } +} + +func NewClient(baseURL string, opts ...Option) (*Client, error) { + parsedURL, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return nil, fmt.Errorf("parse base url: %w", err) + } + + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, fmt.Errorf("base url must include scheme and host") + } + + client := &Client{ + baseURL: parsedURL, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } + for _, opt := range opts { + if opt != nil { + opt(client) + } + } + + return client, nil +} + +type HTTPError struct { + Method string + Path string + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("sub2api %s %s returned %d: %s", e.Method, e.Path, e.StatusCode, strings.TrimSpace(e.Body)) +} + +func (c *Client) GetHostVersion(ctx context.Context) (string, error) { + statusCode, _, body, err := c.perform(ctx, http.MethodGet, "/api/v1/admin/system/version", nil) + if err != nil { + return "", err + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return "", newHTTPError(http.MethodGet, "/api/v1/admin/system/version", statusCode, body) + } + + var payload struct { + Version string `json:"version"` + } + if err := decodeEnvelopeObject(body, &payload); err != nil { + return "", fmt.Errorf("decode host version: %w", err) + } + if strings.TrimSpace(payload.Version) == "" { + return "", fmt.Errorf("decode host version: missing data.version") + } + + return payload.Version, nil +} + +func (c *Client) perform(ctx context.Context, method, path string, requestBody any) (int, http.Header, []byte, error) { + var bodyReader io.Reader + if requestBody != nil { + payload, err := json.Marshal(requestBody) + if err != nil { + return 0, nil, nil, fmt.Errorf("marshal %s %s request: %w", method, path, err) + } + bodyReader = bytes.NewReader(payload) + } + + requestURL := c.resolvePath(path) + req, err := http.NewRequestWithContext(ctx, method, requestURL, bodyReader) + if err != nil { + return 0, nil, nil, fmt.Errorf("build %s %s request: %w", method, path, err) + } + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json, text/event-stream") + c.applyAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, nil, nil, fmt.Errorf("perform %s %s request: %w", method, path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, nil, fmt.Errorf("read %s %s response: %w", method, path, err) + } + + return resp.StatusCode, resp.Header.Clone(), body, nil +} + +func (c *Client) postJSON(ctx context.Context, path string, requestBody any, dest any) error { + statusCode, _, body, err := c.perform(ctx, http.MethodPost, path, requestBody) + if err != nil { + return err + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return newHTTPError(http.MethodPost, path, statusCode, body) + } + if dest == nil { + return nil + } + if err := decodeEnvelopeObject(body, dest); err != nil { + return fmt.Errorf("decode %s response: %w", path, err) + } + return nil +} + +func (c *Client) getJSON(ctx context.Context, path string, dest any) error { + statusCode, _, body, err := c.perform(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return newHTTPError(http.MethodGet, path, statusCode, body) + } + if err := decodeEnvelopeObject(body, dest); err != nil { + return fmt.Errorf("decode %s response: %w", path, err) + } + return nil +} + +func (c *Client) resolvePath(path string) string { + base := strings.TrimRight(c.baseURL.String(), "/") + return base + "/" + strings.TrimLeft(path, "/") +} + +func (c *Client) applyAuth(req *http.Request) { + if c.apiKey != "" { + req.Header.Set("x-api-key", c.apiKey) + return + } + if c.bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + } +} + +func newHTTPError(method, path string, statusCode int, body []byte) *HTTPError { + return &HTTPError{ + Method: method, + Path: path, + StatusCode: statusCode, + Body: string(body), + } +} + +func decodeEnvelopeObject(body []byte, dest any) error { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err == nil && len(bytes.TrimSpace(envelope.Data)) > 0 && string(bytes.TrimSpace(envelope.Data)) != "null" { + return json.Unmarshal(envelope.Data, dest) + } + return json.Unmarshal(body, dest) +} diff --git a/internal/host/sub2api/groups.go b/internal/host/sub2api/groups.go new file mode 100644 index 00000000..e7244c97 --- /dev/null +++ b/internal/host/sub2api/groups.go @@ -0,0 +1,11 @@ +package sub2api + +import "context" + +func (c *Client) CreateGroup(ctx context.Context, req CreateGroupRequest) (GroupRef, error) { + var ref GroupRef + if err := c.postJSON(ctx, "/api/v1/admin/groups", req, &ref); err != nil { + return GroupRef{}, err + } + return ref, nil +} diff --git a/internal/host/sub2api/plans.go b/internal/host/sub2api/plans.go new file mode 100644 index 00000000..b2b3e2e8 --- /dev/null +++ b/internal/host/sub2api/plans.go @@ -0,0 +1,11 @@ +package sub2api + +import "context" + +func (c *Client) CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanRef, error) { + var ref PlanRef + if err := c.postJSON(ctx, "/api/v1/admin/payment/plans", req, &ref); err != nil { + return PlanRef{}, err + } + return ref, nil +} diff --git a/internal/host/sub2api/subscriptions.go b/internal/host/sub2api/subscriptions.go new file mode 100644 index 00000000..f74fb9cc --- /dev/null +++ b/internal/host/sub2api/subscriptions.go @@ -0,0 +1,11 @@ +package sub2api + +import "context" + +func (c *Client) AssignSubscription(ctx context.Context, req AssignSubscriptionRequest) (SubscriptionRef, error) { + var ref SubscriptionRef + if err := c.postJSON(ctx, "/api/v1/admin/subscriptions/assign", req, &ref); err != nil { + return SubscriptionRef{}, err + } + return ref, nil +} diff --git a/tests/integration/host_stub_test.go b/tests/integration/host_stub_test.go new file mode 100644 index 00000000..06b8565f --- /dev/null +++ b/tests/integration/host_stub_test.go @@ -0,0 +1,371 @@ +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") + 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]) + } +} + +type sub2APIStubConfig struct { + requireAPIKey bool + version string +} + +func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server { + t.Helper() + + 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 + } + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + 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"], + }, + }) + }) + 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.MethodPost { + http.NotFound(w, r) + return + } + writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) + }) + mux.HandleFunc("/api/v1/admin/payment/plans", 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.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) + }) + mux.HandleFunc("/api/v1/admin/accounts", 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.StatusUnprocessableEntity, map[string]any{"error": "validation failed"}) + }) + 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) != 2 { + http.NotFound(w, r) + return + } + accountID, action := parts[0], parts[1] + switch action { + case "test": + 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": []map[string]any{ + {"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"}, + {"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"}, + }, + }, + }) + 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.MethodPost { + http.NotFound(w, r) + return + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "id": "subscription_1", + }, + }) + }) + + 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) + } +}