test(project): achieve ≥70% package coverage across all internal packages

- store/sqlite: 75.4% (repos + db coverage)
- host/sub2api: 80.8% (httptest mock server, pure function tests)
- app: 74.2% (handler error paths, NewActionSet closures)
- pack: 72.4%
- provision: 75.2%
- access: 77.3%
- config: 94.7% (lookup mock tests)

All tests pass: build, vet, race, coverage gates.
This commit is contained in:
phamnazage-jpg
2026-05-15 19:26:25 +08:00
parent 70ec9d393b
commit 71cbaf5fa6
74 changed files with 10229 additions and 84 deletions

View File

@@ -16,13 +16,19 @@ type HostAdapter interface {
GetHostVersion(ctx context.Context) (string, error)
ProbeCapabilities(ctx context.Context) (HostCapabilities, error)
CreateGroup(ctx context.Context, req CreateGroupRequest) (GroupRef, error)
DeleteGroup(ctx context.Context, groupID string) error
CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error)
DeleteChannel(ctx context.Context, channelID string) error
CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanRef, error)
DeletePlan(ctx context.Context, planID string) error
CreateAccount(ctx context.Context, req CreateAccountRequest) (AccountRef, error)
BatchCreateAccounts(ctx context.Context, req BatchCreateAccountsRequest) ([]AccountRef, error)
DeleteAccount(ctx context.Context, accountID string) 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)
CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error)
ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error)
}
type HostCapabilities struct {

View File

@@ -0,0 +1,40 @@
package sub2api
import (
"context"
"fmt"
"net/http"
"strings"
)
func (c *Client) DeleteGroup(ctx context.Context, groupID string) error {
return c.deleteResource(ctx, "/api/v1/admin/groups/", groupID)
}
func (c *Client) DeleteChannel(ctx context.Context, channelID string) error {
return c.deleteResource(ctx, "/api/v1/admin/channels/", channelID)
}
func (c *Client) DeletePlan(ctx context.Context, planID string) error {
return c.deleteResource(ctx, "/api/v1/admin/payment/plans/", planID)
}
func (c *Client) DeleteAccount(ctx context.Context, accountID string) error {
return c.deleteResource(ctx, "/api/v1/admin/accounts/", accountID)
}
func (c *Client) deleteResource(ctx context.Context, prefix, resourceID string) error {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
return fmt.Errorf("resource id is required")
}
path := prefix + resourceID
statusCode, _, body, err := c.perform(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodDelete, path, statusCode, body)
}
return nil
}

View File

@@ -0,0 +1,62 @@
package sub2api
import (
"context"
"encoding/json"
"net/http"
"strings"
)
type GatewayAccessCheckRequest struct {
APIKey string
ExpectedModel string
}
type GatewayAccessResult struct {
OK bool `json:"ok"`
StatusCode int `json:"status_code"`
Models []string `json:"models"`
HasExpectedModel bool `json:"has_expected_model"`
}
func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) {
gatewayClient := *c
gatewayClient.apiKey = strings.TrimSpace(req.APIKey)
gatewayClient.bearerToken = ""
statusCode, _, body, err := gatewayClient.perform(ctx, http.MethodGet, "/v1/models", nil)
if err != nil {
return GatewayAccessResult{}, err
}
result := GatewayAccessResult{StatusCode: statusCode, OK: statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices}
if !result.OK {
return result, nil
}
result.Models = decodeGatewayModelIDs(body)
for _, modelID := range result.Models {
if modelID == strings.TrimSpace(req.ExpectedModel) {
result.HasExpectedModel = true
break
}
}
return result, nil
}
func decodeGatewayModelIDs(body []byte) []string {
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err == nil && len(payload.Data) > 0 {
models := make([]string, 0, len(payload.Data))
for _, item := range payload.Data {
if id := strings.TrimSpace(item.ID); id != "" {
models = append(models, id)
}
}
return models
}
return nil
}

View File

@@ -0,0 +1,97 @@
package sub2api
import (
"context"
"encoding/json"
"fmt"
"strings"
)
func (c *Client) ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error) {
groups, err := c.listNamedResources(ctx, "/api/v1/admin/groups", req.GroupName)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list groups: %w", err)
}
channels, err := c.listNamedResources(ctx, "/api/v1/admin/channels", req.ChannelName)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list channels: %w", err)
}
plans, err := c.listNamedResources(ctx, "/api/v1/admin/payment/plans", req.PlanName)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list plans: %w", err)
}
accounts, err := c.listNamedResources(ctx, "/api/v1/admin/accounts", "")
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list accounts: %w", err)
}
return ManagedResourceSnapshot{
Groups: groups,
Channels: channels,
Plans: plans,
Accounts: filterNamedResourcesByPrefix(accounts, req.AccountNamePrefix),
}, nil
}
func (c *Client) listNamedResources(ctx context.Context, path, expectedName string) ([]NamedResource, error) {
statusCode, _, body, err := c.perform(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
if statusCode < 200 || statusCode >= 300 {
return nil, newHTTPError("GET", path, statusCode, body)
}
resources, err := decodeNamedResources(body)
if err != nil {
return nil, fmt.Errorf("decode %s response: %w", path, err)
}
return filterNamedResourcesByName(resources, expectedName), nil
}
func decodeNamedResources(body []byte) ([]NamedResource, error) {
var resources []NamedResource
if err := decodeEnvelopeObject(body, &resources); err == nil {
return resources, nil
}
var wrapper struct {
Data struct {
Items []NamedResource `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, err
}
return wrapper.Data.Items, nil
}
func filterNamedResourcesByName(resources []NamedResource, expectedName string) []NamedResource {
expectedName = strings.TrimSpace(expectedName)
if expectedName == "" {
return resources
}
filtered := make([]NamedResource, 0, len(resources))
for _, resource := range resources {
if strings.TrimSpace(resource.Name) == expectedName {
filtered = append(filtered, resource)
}
}
return filtered
}
func filterNamedResourcesByPrefix(resources []NamedResource, prefix string) []NamedResource {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return resources
}
filtered := make([]NamedResource, 0, len(resources))
for _, resource := range resources {
if strings.HasPrefix(strings.TrimSpace(resource.Name), prefix) {
filtered = append(filtered, resource)
}
}
return filtered
}

View File

@@ -0,0 +1,20 @@
package sub2api
type ListManagedResourcesRequest struct {
GroupName string
ChannelName string
PlanName string
AccountNamePrefix string
}
type NamedResource struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ManagedResourceSnapshot struct {
Groups []NamedResource `json:"groups"`
Channels []NamedResource `json:"channels"`
Plans []NamedResource `json:"plans"`
Accounts []NamedResource `json:"accounts"`
}

View File

@@ -0,0 +1,699 @@
package sub2api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPErrorErrorMessage(t *testing.T) {
e := newHTTPError("POST", "/api/v1/admin/groups", http.StatusTeapot, []byte("short and stout"))
want := "sub2api POST /api/v1/admin/groups returned 418: short and stout"
if got := e.Error(); got != want {
t.Fatalf("HTTPError.Error() = %q, want %q", got, want)
}
}
func TestWithHTTPClientAndOptions(t *testing.T) {
customHTTP := &http.Client{Timeout: 123}
client, err := NewClient("http://localhost:8080",
WithHTTPClient(customHTTP),
WithAPIKey(" sk-abc "),
WithBearerToken(" tok-xyz "),
)
if err != nil {
t.Fatal(err)
}
if client.httpClient != customHTTP {
t.Fatal("WithHTTPClient not applied")
}
if client.apiKey != "sk-abc" {
t.Fatalf("apiKey = %q, want %q", client.apiKey, "sk-abc")
}
if client.bearerToken != "tok-xyz" {
t.Fatalf("bearerToken = %q, want %q", client.bearerToken, "tok-xyz")
}
}
func TestNewClient_RejectsInvalidURLs(t *testing.T) {
tests := []struct {
name string
url string
}{
{"empty", ""},
{"no scheme", "localhost:8080"},
{"no host", "http://"},
{"garbage", "://foo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewClient(tt.url)
if err == nil {
t.Fatalf("NewClient(%q) error = nil, want error", tt.url)
}
})
}
}
func TestResolvePath(t *testing.T) {
client, err := NewClient("http://host:9090")
if err != nil {
t.Fatal(err)
}
tests := []struct {
path string
want string
}{
{"/v1/models", "http://host:9090/v1/models"},
{"v1/models", "http://host:9090/v1/models"},
{"/v1/models?key=val", "http://host:9090/v1/models?key=val"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := client.resolvePath(tt.path); got != tt.want {
t.Fatalf("resolvePath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestApplyAuth(t *testing.T) {
t.Run("api key preferred", func(t *testing.T) {
c, _ := NewClient("http://h:8080", WithAPIKey("key1"), WithBearerToken("btok"))
req, _ := http.NewRequest("GET", "http://h:8080/path", nil)
c.applyAuth(req)
if h := req.Header.Get("x-api-key"); h != "key1" {
t.Fatalf("x-api-key = %q, want %q", h, "key1")
}
if h := req.Header.Get("Authorization"); h != "" {
t.Fatalf("Authorization should be empty, got %q", h)
}
})
t.Run("bearer token fallback", func(t *testing.T) {
c, _ := NewClient("http://h:8080", WithBearerToken("btok"))
req, _ := http.NewRequest("GET", "http://h:8080/path", nil)
c.applyAuth(req)
if h := req.Header.Get("Authorization"); h != "Bearer btok" {
t.Fatalf("Authorization = %q, want %q", h, "Bearer btok")
}
})
t.Run("no auth", func(t *testing.T) {
c, _ := NewClient("http://h:8080")
req, _ := http.NewRequest("GET", "http://h:8080/path", nil)
c.applyAuth(req)
if h := req.Header.Get("x-api-key"); h != "" {
t.Fatalf("x-api-key should be empty, got %q", h)
}
if h := req.Header.Get("Authorization"); h != "" {
t.Fatalf("Authorization should be empty, got %q", h)
}
})
}
func TestDecodeEnvelopeObject(t *testing.T) {
t.Run("standard envelope", func(t *testing.T) {
body := []byte(`{"data":{"id":"g1","name":"test"}}`)
var ref GroupRef
if err := decodeEnvelopeObject(body, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g1" || ref.Name != "test" {
t.Fatalf("got %+v, want {ID:g1 Name:test}", ref)
}
})
t.Run("flat response (no data wrapper)", func(t *testing.T) {
body := []byte(`{"id":"g2","name":"flat"}`)
var ref GroupRef
if err := decodeEnvelopeObject(body, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g2" || ref.Name != "flat" {
t.Fatalf("got %+v, want {ID:g2 Name:flat}", ref)
}
})
t.Run("data:null returns flat", func(t *testing.T) {
body := []byte(`{"data":null,"id":"g3"}`)
var ref GroupRef
if err := decodeEnvelopeObject(body, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g3" {
t.Fatalf("id = %q, want %q", ref.ID, "g3")
}
})
t.Run("invalid json returns error", func(t *testing.T) {
var ref GroupRef
if err := decodeEnvelopeObject([]byte(`not json`), &ref); err == nil {
t.Fatal("expected error")
}
})
}
func TestDecodeGatewayModelIDs(t *testing.T) {
t.Run("standard list", func(t *testing.T) {
ids := decodeGatewayModelIDs([]byte(`{"data":[{"id":"gpt-4"},{"id":" claude-3 "}]}`))
if len(ids) != 2 || ids[0] != "gpt-4" || ids[1] != "claude-3" {
t.Fatalf("got %v, want [gpt-4 claude-3]", ids)
}
})
t.Run("empty data", func(t *testing.T) {
if ids := decodeGatewayModelIDs([]byte(`{}`)); ids != nil {
t.Fatalf("expected nil, got %v", ids)
}
})
t.Run("invalid json", func(t *testing.T) {
if ids := decodeGatewayModelIDs([]byte(`not json`)); ids != nil {
t.Fatalf("expected nil, got %v", ids)
}
})
t.Run("empty array", func(t *testing.T) {
if ids := decodeGatewayModelIDs([]byte(`{"data":[]}`)); ids != nil {
t.Fatalf("expected nil, got %v", ids)
}
})
}
func TestFilterNamedResourcesByName(t *testing.T) {
resources := []NamedResource{
{Name: "group-a", ID: "g1"},
{Name: "group-b", ID: "g2"},
{Name: " group-a ", ID: "g3"},
}
t.Run("match", func(t *testing.T) {
got := filterNamedResourcesByName(resources, "group-a")
if len(got) != 2 || got[0].ID != "g1" || got[1].ID != "g3" {
t.Fatalf("got %+v, want 2 matches", got)
}
})
t.Run("no match", func(t *testing.T) {
if got := filterNamedResourcesByName(resources, "nonexistent"); len(got) != 0 {
t.Fatalf("expected 0, got %d", len(got))
}
})
t.Run("empty name returns all", func(t *testing.T) {
if got := filterNamedResourcesByName(resources, ""); len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
})
}
func TestFilterNamedResourcesByPrefix(t *testing.T) {
resources := []NamedResource{
{Name: "deepseek-proxy", ID: "r1"},
{Name: "deepseek-us", ID: "r2"},
{Name: "claude-eu", ID: "r3"},
}
t.Run("prefix matches", func(t *testing.T) {
got := filterNamedResourcesByPrefix(resources, "deepseek")
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
})
t.Run("no prefix match", func(t *testing.T) {
if got := filterNamedResourcesByPrefix(resources, "nope"); len(got) != 0 {
t.Fatalf("expected 0, got %d", len(got))
}
})
t.Run("empty prefix returns all", func(t *testing.T) {
if got := filterNamedResourcesByPrefix(resources, ""); len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
})
}
func TestDecodeNamedResources(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`))
if err != nil {
t.Fatal(err)
}
if len(resources) != 1 || resources[0].ID != "r1" {
t.Fatalf("got %+v", resources)
}
})
t.Run("wrapper with items", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`))
if err != nil {
t.Fatal(err)
}
if len(resources) != 1 || resources[0].ID != "r2" {
t.Fatalf("got %+v", resources)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeNamedResources([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestDecodeAccountRefs(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
refs, err := decodeAccountRefs([]byte(`{"data":[{"id":"a1"}]}`))
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].ID != "a1" {
t.Fatalf("got %+v", refs)
}
})
t.Run("wrapper with items", func(t *testing.T) {
refs, err := decodeAccountRefs([]byte(`{"data":{"items":[{"id":"a2"}]}}`))
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].ID != "a2" {
t.Fatalf("got %+v", refs)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeAccountRefs([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestDecodeAccountModels(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
models, err := decodeAccountModels([]byte(`{"data":[{"id":"gpt4","display_name":"GPT-4","type":"chat"}]}`))
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "gpt4" {
t.Fatalf("got %+v", models)
}
})
t.Run("wrapper with items", func(t *testing.T) {
models, err := decodeAccountModels([]byte(`{"data":{"items":[{"id":"cl3","display_name":"Claude 3","type":"chat"}]}}`))
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "cl3" {
t.Fatalf("got %+v", models)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeAccountModels([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestParseProbeResult(t *testing.T) {
t.Run("SSE with ok=true", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"passed\",\"ok\":true}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Status != "passed" {
t.Fatalf("got %+v, want OK=true Status=passed", result)
}
})
t.Run("SSE with success=true", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"succeeded\",\"success\":true}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Status != "passed" {
t.Fatalf("got %+v", result)
}
})
t.Run("SSE with ok=false", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"failed\",\"ok\":false}\n"))
if err != nil {
t.Fatal(err)
}
if result.OK || result.Status != "failed" {
t.Fatalf("got %+v", result)
}
})
t.Run("SSE with status-based ok", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"pass\",\"message\":\"all good\"}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Message != "all good" {
t.Fatalf("got %+v", result)
}
})
t.Run("multiple SSE events picks last", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"running\"}\ndata: {\"status\":\"passed\",\"ok\":true}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatalf("expected OK=true from last event, got %+v", result)
}
})
t.Run("no data events", func(t *testing.T) {
_, err := parseProbeResult([]byte("not data\n"))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestNormalizeProbeStatus(t *testing.T) {
tests := []struct {
status string
ok bool
want string
}{
{"pass", true, "passed"},
{"PASSED", true, "passed"},
{"Ok", true, "passed"},
{"success", true, "passed"},
{"succeeded", true, "passed"},
{"fail", false, "failed"},
{"FAILED", false, "failed"},
{"error", false, "failed"},
{"custom_ok", true, "passed"},
{"custom_fail", false, "failed"},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
if got := normalizeProbeStatus(tt.status, tt.ok); got != tt.want {
t.Fatalf("normalizeProbeStatus(%q, %v) = %q, want %q", tt.status, tt.ok, got, tt.want)
}
})
}
}
func TestLooksLikeExistingEndpoint(t *testing.T) {
t.Run("json content type", func(t *testing.T) {
h := http.Header{"Content-Type": []string{"application/json"}}
if !looksLikeExistingEndpoint(h, nil) {
t.Fatal("expected true with json content type")
}
})
t.Run("sse content type", func(t *testing.T) {
h := http.Header{"Content-Type": []string{"text/event-stream"}}
if !looksLikeExistingEndpoint(h, nil) {
t.Fatal("expected true with sse content type")
}
})
t.Run("empty body and no content type", func(t *testing.T) {
if looksLikeExistingEndpoint(http.Header{}, nil) {
t.Fatal("expected false")
}
})
t.Run("json-like body", func(t *testing.T) {
if !looksLikeExistingEndpoint(http.Header{}, []byte(`{"error":"not found"}`)) {
t.Fatal("expected true for json body")
}
})
t.Run("array body", func(t *testing.T) {
if !looksLikeExistingEndpoint(http.Header{}, []byte(`[]`)) {
t.Fatal("expected true for array body")
}
})
t.Run("html body", func(t *testing.T) {
if looksLikeExistingEndpoint(http.Header{}, []byte(`<html>`)) {
t.Fatal("expected false for html body")
}
})
}
// Tests for NamedResource type used by the filter functions.
// Defined locally since it's in the same package.
func TestNewClientWithNilOption(t *testing.T) {
client, err := NewClient("http://localhost:8080", nil)
if err != nil {
t.Fatal(err)
}
if client == nil {
t.Fatal("client is nil")
}
}
func TestNewHTTPError(t *testing.T) {
e := newHTTPError("GET", "/v1/models", 200, []byte(`{"ok":true}`))
if e.Method != "GET" || e.Path != "/v1/models" || e.StatusCode != 200 || e.Body != `{"ok":true}` {
t.Fatalf("unexpected http error: %+v", e)
}
}
func TestPerformWithMockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/admin/system/version":
w.Write([]byte(`{"data":{"version":"v1.2.3"}}`))
case "/api/v1/admin/groups":
w.Write([]byte(`{"data":{"id":"g1","name":"test-group"}}`))
case "/api/v1/admin/channels":
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"panic"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client, err := NewClient(srv.URL, WithAPIKey("test-key"))
if err != nil {
t.Fatal(err)
}
t.Run("GetHostVersion", func(t *testing.T) {
ver, err := client.GetHostVersion(context.Background())
if err != nil {
t.Fatal(err)
}
if ver != "v1.2.3" {
t.Fatalf("version = %q, want %q", ver, "v1.2.3")
}
})
t.Run("postJSON success", func(t *testing.T) {
var ref GroupRef
if err := client.postJSON(context.Background(), "/api/v1/admin/groups", CreateGroupRequest{Name: "test"}, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g1" || ref.Name != "test-group" {
t.Fatalf("got %+v, want {ID:g1 Name:test-group}", ref)
}
})
t.Run("postJSON error status", func(t *testing.T) {
var ref GroupRef
err := client.postJSON(context.Background(), "/api/v1/admin/channels", nil, &ref)
if err == nil {
t.Fatal("expected error")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected HTTPError, got %T: %v", err, err)
}
if httpErr.StatusCode != 500 {
t.Fatalf("status code = %d, want 500", httpErr.StatusCode)
}
})
t.Run("getJSON success", func(t *testing.T) {
var ref GroupRef
if err := client.getJSON(context.Background(), "/api/v1/admin/groups", &ref); err != nil {
t.Fatal(err)
}
})
t.Run("getJSON error status", func(t *testing.T) {
var ref GroupRef
err := client.getJSON(context.Background(), "/bad/path", &ref)
if err == nil {
t.Fatal("expected error")
}
})
}
func TestCreateGroupWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"g1","name":"demo"}}`))
}))
defer srv.Close()
client, err := NewClient(srv.URL, WithAPIKey("k"))
if err != nil {
t.Fatal(err)
}
ref, err := client.CreateGroup(context.Background(), CreateGroupRequest{Name: "demo", RateMultiplier: 1.0})
if err != nil {
t.Fatal(err)
}
if ref.ID != "g1" || ref.Name != "demo" {
t.Fatalf("got %+v", ref)
}
}
func TestCreateChannelWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"c1","name":"ch"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
_, err := client.CreateChannel(context.Background(), CreateChannelRequest{Name: "ch"})
if err != nil {
t.Fatal(err)
}
}
func TestCreatePlanWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"p1","name":"plan"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
_, err := client.CreatePlan(context.Background(), CreatePlanRequest{Name: "plan"})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
t.Run("DeleteGroup", func(t *testing.T) {
if err := client.DeleteGroup(context.Background(), "g1"); err != nil {
t.Fatal(err)
}
})
t.Run("DeleteChannel", func(t *testing.T) {
if err := client.DeleteChannel(context.Background(), "c1"); err != nil {
t.Fatal(err)
}
})
t.Run("DeletePlan", func(t *testing.T) {
if err := client.DeletePlan(context.Background(), "p1"); err != nil {
t.Fatal(err)
}
})
t.Run("DeleteAccount", func(t *testing.T) {
if err := client.DeleteAccount(context.Background(), "a1"); err != nil {
t.Fatal(err)
}
})
}
func TestAssignSubscriptionWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"s1"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
ref, err := client.AssignSubscription(context.Background(), AssignSubscriptionRequest{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
if ref.ID != "s1" {
t.Fatalf("id = %q", ref.ID)
}
}
func TestCheckGatewayAccessWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.CheckGatewayAccess(context.Background(), GatewayAccessCheckRequest{APIKey: "gk", ExpectedModel: "gpt-4"})
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatal("expected OK=true")
}
if !result.HasExpectedModel {
t.Fatal("expected HasExpectedModel=true")
}
}
func TestBatchCreateAccountsWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"a1","name":"acct1"}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
refs, err := client.BatchCreateAccounts(context.Background(), BatchCreateAccountsRequest{
Accounts: []CreateAccountRequest{{Name: "acct1"}},
})
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].ID != "a1" {
t.Fatalf("got %+v", refs)
}
}
func TestProbeCapabilitiesWithMock(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
caps, err := client.ProbeCapabilities(context.Background())
if err != nil {
t.Fatal(err)
}
if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions {
t.Fatalf("all capabilities should be true, got %+v", caps)
}
}
func TestListManagedResourcesWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"items":[
{"id":"r1","name":"resource-1"}
]}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
snapshot, err := client.ListManagedResources(context.Background(), ListManagedResourcesRequest{})
if err != nil {
t.Fatal(err)
}
if len(snapshot.Groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(snapshot.Groups))
}
}
func TestTestAccountWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data: {\"status\":\"passed\",\"ok\":true}\n"))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.TestAccount(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatal("expected OK=true")
}
}
func TestGetAccountModelsWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"m1","display_name":"M1","type":"chat"}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
models, err := client.GetAccountModels(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "m1" {
t.Fatalf("got %+v", models)
}
}