Files
sub2api-cn-relay-manager/internal/app/provider_accounts_api_test.go
2026-05-29 19:07:01 +08:00

283 lines
11 KiB
Go

package app
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListProviderAccounts: func(_ context.Context, req ListProviderAccountsRequest) ([]ProviderAccountInfo, error) {
if req.ProviderID != "deepseek-official" {
t.Fatalf("ProviderID = %q, want deepseek-official", req.ProviderID)
}
if req.LogicalGroupID != "gpt-shared" {
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
}
if req.AccountStatus != "disabled" {
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
}
if req.BindingState != "conflict" {
t.Fatalf("BindingState = %q, want conflict", req.BindingState)
}
return []ProviderAccountInfo{{
ID: 7,
HostID: "remote43",
HostBaseURL: "https://host.example.com",
ProviderID: "deepseek-official",
ProviderName: "DeepSeek Official",
RouteID: "route-1",
RouteName: "Primary Route",
LogicalGroupID: "gpt-shared",
ShadowGroupID: "group-9",
ShadowHostID: "remote43",
HostAccountID: "9",
AccountName: "deepseek-01",
AccountStatus: "disabled",
BindingState: "conflict",
BindingCandidateCount: 2,
DisabledReason: "manual_disable",
UpstreamBaseURLHint: "https://api.deepseek.com",
}}, nil
},
})
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&logical_group_id=gpt-shared&account_status=disabled&binding_state=conflict", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
var payload map[string][]ProviderAccountInfo
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
accounts := payload["provider_accounts"]
if len(accounts) != 1 || accounts[0].ID != 7 || accounts[0].AccountStatus != "disabled" {
t.Fatalf("provider_accounts = %+v, want one disabled row id=7", accounts)
}
if accounts[0].LogicalGroupID != "gpt-shared" || accounts[0].RouteName != "Primary Route" {
t.Fatalf("provider_accounts relationship fields = %+v", accounts[0])
}
}
func TestAPIGetProviderAccountBindingCandidatesUsesPathID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderAccountBindingCandidates: func(_ context.Context, req GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
if req.AccountID != 7 {
t.Fatalf("AccountID = %d, want 7", req.AccountID)
}
return ProviderAccountBindingCandidatesResult{
ProviderAccount: ProviderAccountInfo{ID: 7, BindingState: "conflict"},
CandidateRoutes: []LogicalGroupRouteInfo{{RouteID: "route-a"}, {RouteID: "route-b"}},
}, nil
},
})
request := httptestRequest(t, "GET", "/api/provider-accounts/7/binding-candidates", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
var payload struct {
ProviderAccount ProviderAccountInfo `json:"provider_account"`
CandidateRoutes []LogicalGroupRouteInfo `json:"candidate_routes"`
}
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.ProviderAccount.ID != 7 || len(payload.CandidateRoutes) != 2 || payload.CandidateRoutes[0].RouteID != "route-a" {
t.Fatalf("binding candidates payload = %+v", payload)
}
}
func TestAPIUpdateProviderAccountBindingUsesPathID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
UpdateProviderAccountBinding: func(_ context.Context, req UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
if req.AccountID != 42 {
t.Fatalf("AccountID = %d, want 42", req.AccountID)
}
if req.RouteID != "route-9" || req.Clear {
t.Fatalf("request = %+v, want route-9 clear=false", req)
}
return ProviderAccountInfo{ID: req.AccountID, RouteID: req.RouteID, BindingState: "assigned"}, nil
},
})
request := httptestRequest(t, "POST", "/api/provider-accounts/42/binding", map[string]any{"route_id": "route-9"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
assertJSONContains(t, response.Body().Bytes(), "provider_account.route_id", "route-9")
}
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
DisableProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
if req.AccountID != 42 {
t.Fatalf("AccountID = %d, want 42", req.AccountID)
}
if req.AccountStatus != "disabled" {
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
}
if req.DisabledReason != "manual_disable" {
t.Fatalf("DisabledReason = %q, want manual_disable", req.DisabledReason)
}
return ProviderAccountInfo{ID: req.AccountID, AccountStatus: req.AccountStatus, DisabledReason: req.DisabledReason}, nil
},
})
request := httptestRequest(t, "POST", "/api/provider-accounts/42/disable", map[string]any{"reason": "manual_disable"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
assertJSONContains(t, response.Body().Bytes(), "provider_account.id", float64(42))
assertJSONContains(t, response.Body().Bytes(), "provider_account.account_status", "disabled")
}
func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "provider-accounts.db")
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"
actions := NewActionSet(dsn)
ctx := context.Background()
store, err := sqlite.Open(ctx, dsn)
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
defer store.Close()
hostID, err := store.Hosts().Create(ctx, sqlite.Host{
HostID: "remote43",
BaseURL: "https://host.example.com",
HostVersion: "0.1.129",
CapabilityProbeJSON: `{"accounts":true}`,
AuthType: "apikey",
AuthToken: "host-key",
})
if err != nil {
t.Fatalf("Hosts().Create() error = %v", err)
}
hostRow, err := store.Hosts().GetByID(ctx, hostID)
if err != nil {
t.Fatalf("Hosts().GetByID() error = %v", err)
}
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", Checksum: "chk"})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerRowID, err := store.Providers().Create(ctx, sqlite.Provider{
PackID: packID,
ProviderID: "deepseek-official",
DisplayName: "DeepSeek Official",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
StickyMode: "conversation_preferred",
ConversationTTLSeconds: 7200,
UserModelTTLSeconds: 1800,
FailoverThreshold: 2,
CooldownSeconds: 600,
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
RouteID: "route-1",
LogicalGroupID: "gpt-shared",
Name: "Primary Route",
Status: "active",
Priority: 10,
Weight: 100,
ShadowGroupID: "group-9",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://api.deepseek.com",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
providerAccountID, err := store.ProviderAccounts().Create(ctx, sqlite.ProviderAccount{
HostID: hostRow.ID,
ProviderID: providerRowID,
RouteID: "route-1",
ShadowGroupID: "group-9",
HostAccountID: "9",
KeyFingerprint: "sha256:abc",
AccountName: "deepseek-01",
AccountStatus: sqlite.ProviderAccountStatusActive,
LastProbeStatus: "passed",
LastProbeAt: "2026-05-29T00:00:00Z",
})
if err != nil {
t.Fatalf("ProviderAccounts().Create() error = %v", err)
}
listed, err := actions.ListProviderAccounts(ctx, ListProviderAccountsRequest{HostID: "remote43", ProviderID: "deepseek-official"})
if err != nil {
t.Fatalf("ListProviderAccounts() error = %v", err)
}
if len(listed) != 1 || listed[0].ID != providerAccountID {
t.Fatalf("ListProviderAccounts() = %+v, want one row for id %d", listed, providerAccountID)
}
if listed[0].LogicalGroupID != "gpt-shared" || listed[0].RouteName != "Primary Route" || listed[0].ShadowHostID != "remote43" {
t.Fatalf("ListProviderAccounts() relationship fields = %+v", listed[0])
}
if listed[0].BindingState != sqlite.ProviderAccountBindingStateAssigned || listed[0].BindingCandidateCount != 1 {
t.Fatalf("ListProviderAccounts() binding view = %+v", listed[0])
}
candidates, err := actions.GetProviderAccountBindingCandidates(ctx, GetProviderAccountBindingCandidatesRequest{AccountID: providerAccountID})
if err != nil {
t.Fatalf("GetProviderAccountBindingCandidates() error = %v", err)
}
if len(candidates.CandidateRoutes) != 1 || candidates.CandidateRoutes[0].RouteID != "route-1" {
t.Fatalf("GetProviderAccountBindingCandidates() = %+v", candidates)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
RouteID: "route-2",
LogicalGroupID: "gpt-shared",
Name: "Fallback Route",
Status: "active",
Priority: 20,
Weight: 100,
ShadowGroupID: "group-9",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://api.backup.example.com",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create(route-2) error = %v", err)
}
updatedBinding, err := actions.UpdateProviderAccountBinding(ctx, UpdateProviderAccountBindingRequest{
AccountID: providerAccountID,
RouteID: "route-2",
})
if err != nil {
t.Fatalf("UpdateProviderAccountBinding() error = %v", err)
}
if updatedBinding.RouteID != "route-2" || updatedBinding.BindingState != sqlite.ProviderAccountBindingStateAssigned {
t.Fatalf("UpdateProviderAccountBinding() = %+v", updatedBinding)
}
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
AccountID: providerAccountID,
DisabledReason: "manual_disable",
})
if err != nil {
t.Fatalf("DisableProviderAccount() error = %v", err)
}
if disabled.AccountStatus != sqlite.ProviderAccountStatusDisabled || disabled.DisabledReason != "manual_disable" {
t.Fatalf("DisableProviderAccount() = %+v", disabled)
}
enabled, err := actions.EnableProviderAccount(ctx, UpdateProviderAccountStatusRequest{AccountID: providerAccountID})
if err != nil {
t.Fatalf("EnableProviderAccount() error = %v", err)
}
if enabled.AccountStatus != sqlite.ProviderAccountStatusActive {
t.Fatalf("EnableProviderAccount() = %+v, want active", enabled)
}
}