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