diff --git a/internal/app/logical_groups_api_test.go b/internal/app/logical_groups_api_test.go index ef435db7..e78d9c3b 100644 --- a/internal/app/logical_groups_api_test.go +++ b/internal/app/logical_groups_api_test.go @@ -114,6 +114,153 @@ func TestAPIGetLogicalGroupReturnsAggregatedItem(t *testing.T) { } } +func TestAPIListLogicalGroupsReturnsRows(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListLogicalGroups: func(_ context.Context) ([]LogicalGroupInfo, error) { + return []LogicalGroupInfo{{ + LogicalGroupID: "gpt-shared", + DisplayName: "GPT Shared", + Status: "active", + UsageScenario: "适合统一 GPT 产品入口", + VisibilityScope: "login_required", + PackageTier: "pro", + PurchaseCTALabel: "升级到 Pro", + PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro", + }}, nil + }, + }) + + request := httptestRequest(t, http.MethodGet, "/api/logical-groups", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusOK) + var payload struct { + LogicalGroups []LogicalGroupInfo `json:"logical_groups"` + } + if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(payload.LogicalGroups) != 1 || payload.LogicalGroups[0].LogicalGroupID != "gpt-shared" { + t.Fatalf("logical_groups = %+v, want one row gpt-shared", payload.LogicalGroups) + } + if payload.LogicalGroups[0].VisibilityScope != "login_required" { + t.Fatalf("logical_groups[0].visibility_scope = %q, want login_required", payload.LogicalGroups[0].VisibilityScope) + } +} + +func TestAPIUpdateLogicalGroupUsesPathID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + UpdateLogicalGroup: func(_ context.Context, req UpdateLogicalGroupRequest) (LogicalGroupInfo, error) { + if req.LogicalGroupID != "gpt-shared" { + t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID) + } + if req.DisplayName != "GPT Shared Updated" || req.Status != "paused" { + t.Fatalf("request = %+v, want updated display name and status", req) + } + return LogicalGroupInfo{ + LogicalGroupID: req.LogicalGroupID, + DisplayName: req.DisplayName, + Status: req.Status, + Recommendation: req.Recommendation, + VisibilityScope: req.VisibilityScope, + PackageTier: req.PackageTier, + PurchaseCTALabel: req.PurchaseCTALabel, + }, nil + }, + }) + + request := httptestRequest(t, http.MethodPut, "/api/logical-groups/gpt-shared", map[string]any{ + "display_name": "GPT Shared Updated", + "status": "paused", + "recommendation": "先验证高质量推理链路", + "visibility_scope": "entitled_only", + "package_tier": "enterprise", + "purchase_cta_label": "联系销售升级", + }, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusOK) + assertJSONContains(t, response.Body().Bytes(), "logical_group.logical_group_id", "gpt-shared") + assertJSONContains(t, response.Body().Bytes(), "logical_group.package_tier", "enterprise") +} + +func TestAPIDeleteLogicalGroupUsesPathID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + DeleteLogicalGroup: func(_ context.Context, groupID string) error { + if groupID != "gpt-shared" { + t.Fatalf("groupID = %q, want gpt-shared", groupID) + } + return nil + }, + }) + + request := httptestRequest(t, http.MethodDelete, "/api/logical-groups/gpt-shared", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusNoContent) +} + +func TestAPICreateLogicalGroupModelUsesPathGroupID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + CreateLogicalGroupModel: func(_ context.Context, req CreateLogicalGroupModelRequest) (LogicalGroupModelInfo, error) { + if req.LogicalGroupID != "gpt-shared" { + t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID) + } + if req.PublicModel != "gpt-5.4" || req.Status != "active" { + t.Fatalf("request = %+v, want public_model gpt-5.4 active", req) + } + return LogicalGroupModelInfo{ + PublicModel: req.PublicModel, + Status: req.Status, + }, nil + }, + }) + + request := httptestRequest(t, http.MethodPost, "/api/logical-groups/gpt-shared/models", map[string]any{ + "public_model": "gpt-5.4", + "status": "active", + }, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusCreated) + assertJSONContains(t, response.Body().Bytes(), "logical_group_model.public_model", "gpt-5.4") +} + +func TestAPIListLogicalGroupModelsUsesPathGroupID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListLogicalGroupModels: func(_ context.Context, groupID string) ([]LogicalGroupModelInfo, error) { + if groupID != "gpt-shared" { + t.Fatalf("groupID = %q, want gpt-shared", groupID) + } + return []LogicalGroupModelInfo{{PublicModel: "gpt-5.4", Status: "active"}}, nil + }, + }) + + request := httptestRequest(t, http.MethodGet, "/api/logical-groups/gpt-shared/models", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusOK) + var payload struct { + Models []LogicalGroupModelInfo `json:"models"` + } + if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(payload.Models) != 1 || payload.Models[0].PublicModel != "gpt-5.4" { + t.Fatalf("models = %+v, want one row gpt-5.4", payload.Models) + } +} + +func TestAPIDeleteLogicalGroupModelUsesPathValues(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + DeleteLogicalGroupModel: func(_ context.Context, req DeleteLogicalGroupModelRequest) error { + if req.LogicalGroupID != "gpt-shared" || req.PublicModel != "gpt-5.4" { + t.Fatalf("request = %+v, want gpt-shared/gpt-5.4", req) + } + return nil + }, + }) + + request := httptestRequest(t, http.MethodDelete, "/api/logical-groups/gpt-shared/models/gpt-5.4", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusNoContent) +} + func TestAPICreateLogicalGroupRouteUsesPathGroupID(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ CreateLogicalGroupRoute: func(_ context.Context, req CreateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error) { @@ -143,6 +290,89 @@ func TestAPICreateLogicalGroupRouteUsesPathGroupID(t *testing.T) { assertJSONContains(t, response.Body().Bytes(), "route.route_id", "asxs") } +func TestAPIListLogicalGroupRoutesUsesPathGroupID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListLogicalGroupRoutes: func(_ context.Context, groupID string) ([]LogicalGroupRouteInfo, error) { + if groupID != "gpt-shared" { + t.Fatalf("groupID = %q, want gpt-shared", groupID) + } + return []LogicalGroupRouteInfo{{ + RouteID: "asxs", + LogicalGroupID: groupID, + Name: "ASXS", + Status: "active", + ShadowGroupID: "gpt-shared__asxs", + ShadowHostID: "remote43", + }}, nil + }, + }) + + request := httptestRequest(t, http.MethodGet, "/api/logical-groups/gpt-shared/routes", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusOK) + var payload struct { + Routes []LogicalGroupRouteInfo `json:"routes"` + } + if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(payload.Routes) != 1 || payload.Routes[0].RouteID != "asxs" { + t.Fatalf("routes = %+v, want one row asxs", payload.Routes) + } + if payload.Routes[0].ShadowHostID != "remote43" { + t.Fatalf("routes[0].shadow_host_id = %q, want remote43", payload.Routes[0].ShadowHostID) + } +} + +func TestAPIUpdateLogicalGroupRouteUsesPathValues(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + UpdateLogicalGroupRoute: func(_ context.Context, req UpdateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error) { + if req.LogicalGroupID != "gpt-shared" || req.RouteID != "asxs" { + t.Fatalf("request = %+v, want gpt-shared/asxs", req) + } + if req.Priority != 20 || req.Status != "degraded" { + t.Fatalf("request = %+v, want priority 20 degraded", req) + } + return LogicalGroupRouteInfo{ + RouteID: req.RouteID, + LogicalGroupID: req.LogicalGroupID, + Name: req.Name, + Status: req.Status, + Priority: req.Priority, + ShadowGroupID: req.ShadowGroupID, + ShadowHostID: req.ShadowHostID, + }, nil + }, + }) + + request := httptestRequest(t, http.MethodPut, "/api/logical-groups/gpt-shared/routes/asxs", map[string]any{ + "name": "ASXS Updated", + "status": "degraded", + "priority": 20, + "shadow_group_id": "gpt-shared__asxs", + "shadow_host_id": "remote43", + }, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusOK) + assertJSONContains(t, response.Body().Bytes(), "route.route_id", "asxs") + assertJSONContains(t, response.Body().Bytes(), "route.status", "degraded") +} + +func TestAPIDeleteLogicalGroupRouteUsesPathValues(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + DeleteLogicalGroupRoute: func(_ context.Context, req DeleteLogicalGroupRouteRequest) error { + if req.LogicalGroupID != "gpt-shared" || req.RouteID != "asxs" { + t.Fatalf("request = %+v, want gpt-shared/asxs", req) + } + return nil + }, + }) + + request := httptestRequest(t, http.MethodDelete, "/api/logical-groups/gpt-shared/routes/asxs", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusNoContent) +} + func TestAPICreateLogicalGroupRouteModelUsesPathValues(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ CreateLogicalGroupRouteModel: func(_ context.Context, req CreateLogicalGroupRouteModelRequest) (LogicalGroupRouteModelInfo, error) { @@ -170,6 +400,37 @@ func TestAPICreateLogicalGroupRouteModelUsesPathValues(t *testing.T) { assertJSONContains(t, response.Body().Bytes(), "route_model.public_model", "gpt-5.4") } +func TestAPIListLogicalGroupRouteModelsUsesPathValues(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListLogicalGroupRouteModels: func(_ context.Context, req ListLogicalGroupRouteModelsRequest) ([]LogicalGroupRouteModelInfo, error) { + if req.LogicalGroupID != "gpt-shared" || req.RouteID != "asxs" { + t.Fatalf("request = %+v, want gpt-shared/asxs", req) + } + return []LogicalGroupRouteModelInfo{{ + PublicModel: "gpt-5.4", + ShadowModel: "gpt-5.4", + Status: "active", + }}, nil + }, + }) + + request := httptestRequest(t, http.MethodGet, "/api/logical-groups/gpt-shared/routes/asxs/models", nil, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusOK) + var payload struct { + RouteModels []LogicalGroupRouteModelInfo `json:"route_models"` + } + if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(payload.RouteModels) != 1 || payload.RouteModels[0].PublicModel != "gpt-5.4" { + t.Fatalf("route_models = %+v, want one row gpt-5.4", payload.RouteModels) + } + if payload.RouteModels[0].ShadowModel != "gpt-5.4" { + t.Fatalf("route_models[0].shadow_model = %q, want gpt-5.4", payload.RouteModels[0].ShadowModel) + } +} + func TestNewActionSetLogicalGroupCRUDFlow(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "logical-groups.db") dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000" diff --git a/internal/app/provider_accounts_api_test.go b/internal/app/provider_accounts_api_test.go index 75a7884f..31e857ef 100644 --- a/internal/app/provider_accounts_api_test.go +++ b/internal/app/provider_accounts_api_test.go @@ -132,6 +132,46 @@ func TestAPIDisableProviderAccountUsesPathID(t *testing.T) { assertJSONContains(t, response.Body().Bytes(), "provider_account.account_status", "disabled") } +func TestAPIEnableProviderAccountUsesPathID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + EnableProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) { + if req.AccountID != 42 { + t.Fatalf("AccountID = %d, want 42", req.AccountID) + } + if req.AccountStatus != sqlite.ProviderAccountStatusActive { + t.Fatalf("AccountStatus = %q, want active", req.AccountStatus) + } + return ProviderAccountInfo{ID: req.AccountID, AccountStatus: req.AccountStatus}, nil + }, + }) + + request := httptestRequest(t, "POST", "/api/provider-accounts/42/enable", nil, "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", sqlite.ProviderAccountStatusActive) +} + +func TestAPIRetireProviderAccountUsesPathID(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + RetireProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) { + if req.AccountID != 42 { + t.Fatalf("AccountID = %d, want 42", req.AccountID) + } + if req.AccountStatus != sqlite.ProviderAccountStatusDeprecated { + t.Fatalf("AccountStatus = %q, want deprecated", req.AccountStatus) + } + return ProviderAccountInfo{ID: req.AccountID, AccountStatus: req.AccountStatus}, nil + }, + }) + + request := httptestRequest(t, "POST", "/api/provider-accounts/42/retire", nil, "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", sqlite.ProviderAccountStatusDeprecated) +} + func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "provider-accounts.db") dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"