feat(sub2api): add host adapter client and tests
This commit is contained in:
163
internal/host/sub2api/accounts.go
Normal file
163
internal/host/sub2api/accounts.go
Normal 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"
|
||||
}
|
||||
92
internal/host/sub2api/capability_probe.go
Normal file
92
internal/host/sub2api/capability_probe.go
Normal 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
|
||||
}
|
||||
11
internal/host/sub2api/channels.go
Normal file
11
internal/host/sub2api/channels.go
Normal 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
|
||||
}
|
||||
295
internal/host/sub2api/client.go
Normal file
295
internal/host/sub2api/client.go
Normal 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)
|
||||
}
|
||||
11
internal/host/sub2api/groups.go
Normal file
11
internal/host/sub2api/groups.go
Normal 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
|
||||
}
|
||||
11
internal/host/sub2api/plans.go
Normal file
11
internal/host/sub2api/plans.go
Normal 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
|
||||
}
|
||||
11
internal/host/sub2api/subscriptions.go
Normal file
11
internal/host/sub2api/subscriptions.go
Normal 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
|
||||
}
|
||||
371
tests/integration/host_stub_test.go
Normal file
371
tests/integration/host_stub_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user