feat(sub2api): add host adapter client and tests

This commit is contained in:
phamnazage-jpg
2026-05-13 00:41:12 +08:00
parent a1d7007397
commit 70ec9d393b
8 changed files with 965 additions and 0 deletions

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}