package app import ( "context" "net/http" "net/url" "path/filepath" "testing" "time" "sub2api-cn-relay-manager/internal/routing" ) func TestAPIResolveRouteReturnsSelectedRoute(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ ResolveRoute: func(_ context.Context, req ResolveRouteRequest) (ResolveRouteInfo, error) { if req.LogicalGroupID != "gpt-shared" { t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID) } return ResolveRouteInfo{ RequestID: "req-1", Backend: "memory", LogicalGroupID: req.LogicalGroupID, PublicModel: req.PublicModel, StickyKey: "lg:gpt-shared:m:gpt-5.4:conv:conv-1", StickyHit: false, StickyAction: "bind", RouteID: "asxs", ShadowGroupID: "gpt-shared__asxs", ShadowHostID: "remote43", ShadowModel: "gpt-5.4", }, nil }, }) request := httptestRequest(t, http.MethodPost, "/api/routing/resolve", map[string]any{ "logical_group_id": "gpt-shared", "public_model": "gpt-5.4", "scope": "conversation", "subject_id": "conv-1", "sync": true, }, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) assertJSONContains(t, response.Body().Bytes(), "resolve.route_id", "asxs") assertJSONContains(t, response.Body().Bytes(), "resolve.sticky_action", "bind") } func TestNewActionSetResolveRouteFlow(t *testing.T) { dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-resolve.db")) + "?_busy_timeout=5000" actions := NewActionSet(dsn) ctx := context.Background() _, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{ LogicalGroupID: "gpt-shared", DisplayName: "GPT Shared", Status: "active", RoutePolicy: "priority", StickyMode: "conversation_preferred", ConversationTTLSeconds: 1200, UserModelTTLSeconds: 600, FailoverThreshold: 2, CooldownSeconds: 300, }) if err != nil { t.Fatalf("CreateLogicalGroup() error = %v", err) } if _, err := actions.CreateLogicalGroupModel(ctx, CreateLogicalGroupModelRequest{ LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", Status: "active", }); err != nil { t.Fatalf("CreateLogicalGroupModel() error = %v", err) } if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{ LogicalGroupID: "gpt-shared", RouteID: "asxs", Name: "ASXS", Status: "active", Priority: 20, ShadowGroupID: "gpt-shared__asxs", ShadowHostID: "remote43", UpstreamBaseURLHint: "https://api.asxs.top/v1", }); err != nil { t.Fatalf("CreateLogicalGroupRoute(asxs) error = %v", err) } if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{ LogicalGroupID: "gpt-shared", RouteID: "asxs", PublicModel: "gpt-5.4", ShadowModel: "gpt-5.4-asxs", Status: "active", }); err != nil { t.Fatalf("CreateLogicalGroupRouteModel(asxs) error = %v", err) } if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{ LogicalGroupID: "gpt-shared", RouteID: "codex2api", Name: "Codex2API", Status: "active", Priority: 10, ShadowGroupID: "gpt-shared__codex2api", ShadowHostID: "remote43", UpstreamBaseURLHint: "https://www.codex2api.com/v1", }); err != nil { t.Fatalf("CreateLogicalGroupRoute(codex2api) error = %v", err) } if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{ LogicalGroupID: "gpt-shared", RouteID: "codex2api", PublicModel: "gpt-5.4", ShadowModel: "gpt-5.4", Status: "active", }); err != nil { t.Fatalf("CreateLogicalGroupRouteModel(codex2api) error = %v", err) } if _, err := actions.SetRouteFailure(ctx, SetRouteFailureRequest{ RouteID: "codex2api", FailureCount: 2, LastErrorClass: "timeout", TTLSeconds: 600, }); err != nil { t.Fatalf("SetRouteFailure(codex2api) error = %v", err) } first, err := actions.ResolveRoute(ctx, ResolveRouteRequest{ RequestID: "req-resolve-1", LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", Scope: "conversation", SubjectID: "conv-1", Sync: true, }) if err != nil { t.Fatalf("ResolveRoute(first) error = %v", err) } if first.RouteID != "asxs" || first.StickyHit || first.StickyAction != "bind" { t.Fatalf("ResolveRoute(first) = %+v, want asxs bind miss", first) } second, err := actions.ResolveRoute(ctx, ResolveRouteRequest{ RequestID: "req-resolve-2", LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", Scope: "conversation", SubjectID: "conv-1", Sync: true, }) if err != nil { t.Fatalf("ResolveRoute(second) error = %v", err) } if second.RouteID != "asxs" || !second.StickyHit || second.StickyAction != "hit" { t.Fatalf("ResolveRoute(second) = %+v, want asxs sticky hit", second) } sticky, err := actions.GetStickyBinding(ctx, GetStickyBindingRequest{ Scope: "conversation", LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", SubjectID: "conv-1", }) if err != nil { t.Fatalf("GetStickyBinding() error = %v", err) } if sticky.RouteID != "asxs" { t.Fatalf("GetStickyBinding() = %+v, want route asxs", sticky) } decisions, err := actions.ListRouteDecisionLogs(ctx, ListRouteDecisionLogsRequest{ LogicalGroupID: "gpt-shared", StickyKey: first.StickyKey, Limit: 10, }) if err != nil { t.Fatalf("ListRouteDecisionLogs() error = %v", err) } if len(decisions) != 2 { t.Fatalf("ListRouteDecisionLogs() len = %d, want 2", len(decisions)) } if !decisions[0].StickyHit || decisions[1].StickyHit { t.Fatalf("ListRouteDecisionLogs() = %+v, want latest hit then miss", decisions) } if !decisions[1].FallbackUsed { t.Fatalf("ListRouteDecisionLogs()[1] = %+v, want fallback_used true", decisions[1]) } stickyAudits, err := actions.ListRouteStickyAudit(ctx, ListRouteStickyAuditRequest{ StickyKey: first.StickyKey, Limit: 10, }) if err != nil { t.Fatalf("ListRouteStickyAudit() error = %v", err) } if len(stickyAudits) != 2 { t.Fatalf("ListRouteStickyAudit() len = %d, want 2", len(stickyAudits)) } if stickyAudits[0].Action != "hit" || stickyAudits[1].Action != "bind" { t.Fatalf("ListRouteStickyAudit() = %+v, want latest hit then bind", stickyAudits) } failovers, err := actions.ListRouteFailoverEvents(ctx, ListRouteFailoverEventsRequest{ RequestID: "req-resolve-1", Limit: 10, }) if err != nil { t.Fatalf("ListRouteFailoverEvents() error = %v", err) } if len(failovers) != 1 { t.Fatalf("ListRouteFailoverEvents() len = %d, want 1", len(failovers)) } if failovers[0].FromRouteID != "codex2api" || failovers[0].ToRouteID != "asxs" || failovers[0].FailureCount != 2 { t.Fatalf("ListRouteFailoverEvents()[0] = %+v, want codex2api -> asxs failure_count 2", failovers[0]) } } func TestResolveRouteHelpers(t *testing.T) { t.Parallel() req, stickyKey, requestID, err := normalizeResolveRouteRequest(ResolveRouteRequest{ LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", Scope: "conversation", SubjectID: "conv-1", }) if err != nil { t.Fatalf("normalizeResolveRouteRequest() error = %v", err) } if req.Scope != "conversation" || stickyKey == "" || requestID == "" { t.Fatalf("normalizeResolveRouteRequest() = (%+v, %q, %q), want normalized values", req, stickyKey, requestID) } if _, _, _, err := normalizeResolveRouteRequest(ResolveRouteRequest{}); err == nil { t.Fatal("normalizeResolveRouteRequest(empty) error = nil, want error") } if got := resolveUserKey(ResolveRouteRequest{Scope: "user", SubjectID: "user-1"}); got != "user-1" { t.Fatalf("resolveUserKey(user) = %q, want user-1", got) } if got := resolveConversationKey(ResolveRouteRequest{Scope: "conversation", SubjectID: "conv-1"}); got != "conv-1" { t.Fatalf("resolveConversationKey(conversation) = %q, want conv-1", got) } if got := failureSkipReason(routing.RouteFailureState{LastErrorClass: "timeout"}); got != "failure_threshold_exceeded:timeout" { t.Fatalf("failureSkipReason() = %q, want timeout reason", got) } if got := cooldownSkipReason(routing.RouteCooldownState{Reason: "degraded"}); got != "active_cooldown:degraded" { t.Fatalf("cooldownSkipReason() = %q, want degraded reason", got) } if !routeExitsCooldown("") { t.Fatal("routeExitsCooldown(empty) = false, want true") } future := time.Now().UTC().Add(time.Minute).Format(time.RFC3339) if routeExitsCooldown(future) { t.Fatalf("routeExitsCooldown(%q) = true, want false", future) } if !isActiveStatus(" active ") { t.Fatal("isActiveStatus(active) = false, want true") } } func TestResolveRouteWithCooldownFallback(t *testing.T) { dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-resolve-cooldown.db")) + "?_busy_timeout=5000" actions := NewActionSet(dsn) ctx := context.Background() _, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{ LogicalGroupID: "cooldown-group", DisplayName: "Cooldown Group", Status: "active", RoutePolicy: "priority", StickyMode: "conversation_preferred", ConversationTTLSeconds: 1200, UserModelTTLSeconds: 600, FailoverThreshold: 2, CooldownSeconds: 300, }) if err != nil { t.Fatalf("CreateLogicalGroup() error = %v", err) } if _, err := actions.CreateLogicalGroupModel(ctx, CreateLogicalGroupModelRequest{ LogicalGroupID: "cooldown-group", PublicModel: "gpt-5.4", Status: "active", }); err != nil { t.Fatalf("CreateLogicalGroupModel() error = %v", err) } if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{ LogicalGroupID: "cooldown-group", RouteID: "route-a", Name: "Route A", Status: "active", Priority: 10, ShadowGroupID: "cooldown-group__a", ShadowHostID: "remote43", }); err != nil { t.Fatalf("CreateLogicalGroupRoute(route-a) error = %v", err) } if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{ LogicalGroupID: "cooldown-group", RouteID: "route-a", PublicModel: "gpt-5.4", Status: "active", }); err != nil { t.Fatalf("CreateLogicalGroupRouteModel(route-a) error = %v", err) } if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{ LogicalGroupID: "cooldown-group", RouteID: "route-b", Name: "Route B", Status: "active", Priority: 20, ShadowGroupID: "cooldown-group__b", ShadowHostID: "remote43", }); err != nil { t.Fatalf("CreateLogicalGroupRoute(route-b) error = %v", err) } if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{ LogicalGroupID: "cooldown-group", RouteID: "route-b", PublicModel: "gpt-5.4", Status: "active", }); err != nil { t.Fatalf("CreateLogicalGroupRouteModel(route-b) error = %v", err) } if _, err := actions.SetRouteCooldown(ctx, SetRouteCooldownRequest{ RouteID: "route-a", Reason: "degraded", TTLSeconds: 600, }); err != nil { t.Fatalf("SetRouteCooldown(route-a) error = %v", err) } resolved, err := actions.ResolveRoute(ctx, ResolveRouteRequest{ RequestID: "req-cooldown-1", LogicalGroupID: "cooldown-group", PublicModel: "gpt-5.4", Scope: "conversation", SubjectID: "conv-cooldown-1", Sync: true, }) if err != nil { t.Fatalf("ResolveRoute() error = %v", err) } if resolved.RouteID != "route-b" || resolved.StickyHit || resolved.StickyAction != "bind" { t.Fatalf("ResolveRoute() = %+v, want route-b bind miss", resolved) } failovers, err := actions.ListRouteFailoverEvents(ctx, ListRouteFailoverEventsRequest{ RequestID: "req-cooldown-1", Limit: 10, }) if err != nil { t.Fatalf("ListRouteFailoverEvents() error = %v", err) } if len(failovers) != 1 || failovers[0].FromRouteID != "route-a" || failovers[0].ToRouteID != "route-b" { t.Fatalf("ListRouteFailoverEvents() = %+v, want route-a -> route-b", failovers) } if failovers[0].Reason != "active_cooldown:degraded" { t.Fatalf("ListRouteFailoverEvents()[0].Reason = %q, want active_cooldown:degraded", failovers[0].Reason) } reqURL := &http.Request{URL: &url.URL{RawQuery: "route_id=route-a"}} cooldownReq, cooldownErr := decodeGetRouteCooldownRequest(reqURL) if cooldownErr != nil || cooldownReq.RouteID != "route-a" { t.Fatalf("decodeGetRouteCooldownRequest() = (%+v, %v), want route-a nil", cooldownReq, cooldownErr) } }