package app import ( "context" "net/http" "net/url" "path/filepath" "testing" "sub2api-cn-relay-manager/internal/config" ) func TestAPISetStickyBindingReturnsCreated(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ SetStickyBinding: func(_ context.Context, req SetStickyBindingRequest) (StickyBindingInfo, error) { if req.Scope != "conversation" { t.Fatalf("Scope = %q, want conversation", req.Scope) } return StickyBindingInfo{ Backend: "memory", Key: "lg:gpt-shared:m:gpt-5.4:conv:conv-1", Scope: req.Scope, RouteID: req.RouteID, }, nil }, }) request := httptestRequest(t, http.MethodPost, "/api/routing/sticky/bindings", map[string]any{ "scope": "conversation", "logical_group_id": "gpt-shared", "public_model": "gpt-5.4", "subject_id": "conv-1", "route_id": "asxs", "shadow_group_id": "gpt-shared__asxs", "ttl_seconds": 600, }, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusCreated) assertJSONContains(t, response.Body().Bytes(), "binding.backend", "memory") assertJSONContains(t, response.Body().Bytes(), "binding.route_id", "asxs") } func TestAPIGetStickyBindingRejectsMissingQuery(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ GetStickyBinding: func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error) { t.Fatal("GetStickyBinding should not be called") return StickyBindingInfo{}, nil }, }) request := httptestRequest(t, http.MethodGet, "/api/routing/sticky/bindings?scope=conversation", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusBadRequest) } func TestAPISetAndGetRouteFailureAndCooldown(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ SetRouteFailure: func(_ context.Context, req SetRouteFailureRequest) (RouteFailureInfo, error) { return RouteFailureInfo{ Backend: "memory", Key: "routefail:" + req.RouteID, RouteID: req.RouteID, FailureCount: req.FailureCount, }, nil }, GetRouteFailure: func(_ context.Context, req GetRouteFailureRequest) (RouteFailureInfo, error) { return RouteFailureInfo{ Backend: "memory", Key: "routefail:" + req.RouteID, RouteID: req.RouteID, FailureCount: 2, }, nil }, SetRouteCooldown: func(_ context.Context, req SetRouteCooldownRequest) (RouteCooldownInfo, error) { return RouteCooldownInfo{ Backend: "memory", Key: "routecool:" + req.RouteID, RouteID: req.RouteID, Reason: req.Reason, }, nil }, GetRouteCooldown: func(_ context.Context, req GetRouteCooldownRequest) (RouteCooldownInfo, error) { return RouteCooldownInfo{ Backend: "memory", Key: "routecool:" + req.RouteID, RouteID: req.RouteID, Reason: "cooldown", }, nil }, }) setFailureReq := httptestRequest(t, http.MethodPost, "/api/routing/sticky/route-failures", map[string]any{ "route_id": "asxs", "failure_count": 2, "ttl_seconds": 600, }, "secret-token") setFailureResp := httptestRecorder(handler, setFailureReq) assertStatusCode(t, setFailureResp, http.StatusCreated) assertJSONContains(t, setFailureResp.Body().Bytes(), "route_failure.key", "routefail:asxs") getFailureReq := httptestRequest(t, http.MethodGet, "/api/routing/sticky/route-failures?route_id=asxs", nil, "secret-token") getFailureResp := httptestRecorder(handler, getFailureReq) assertStatusCode(t, getFailureResp, http.StatusOK) assertJSONContains(t, getFailureResp.Body().Bytes(), "route_failure.failure_count", float64(2)) setCooldownReq := httptestRequest(t, http.MethodPost, "/api/routing/sticky/cooldowns", map[string]any{ "route_id": "asxs", "reason": "cooldown", "ttl_seconds": 600, }, "secret-token") setCooldownResp := httptestRecorder(handler, setCooldownReq) assertStatusCode(t, setCooldownResp, http.StatusCreated) assertJSONContains(t, setCooldownResp.Body().Bytes(), "route_cooldown.key", "routecool:asxs") getCooldownReq := httptestRequest(t, http.MethodGet, "/api/routing/sticky/cooldowns?route_id=asxs", nil, "secret-token") getCooldownResp := httptestRecorder(handler, getCooldownReq) assertStatusCode(t, getCooldownResp, http.StatusOK) assertJSONContains(t, getCooldownResp.Body().Bytes(), "route_cooldown.reason", "cooldown") } func TestNewActionSetStickyRuntimeFlow(t *testing.T) { dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sticky.db")) + "?_busy_timeout=5000" actions := NewActionSet(dsn) ctx := context.Background() binding, err := actions.SetStickyBinding(ctx, SetStickyBindingRequest{ Scope: "conversation", LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", SubjectID: "conv-1", RouteID: "asxs", ShadowGroupID: "gpt-shared__asxs", TTLSeconds: 600, }) if err != nil { t.Fatalf("SetStickyBinding() error = %v", err) } if binding.Backend != "memory" || binding.RouteID != "asxs" { t.Fatalf("SetStickyBinding() = %+v, want memory/asxs", binding) } loadedBinding, 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 loadedBinding.Key != binding.Key { t.Fatalf("GetStickyBinding().Key = %q, want %q", loadedBinding.Key, binding.Key) } failure, err := actions.SetRouteFailure(ctx, SetRouteFailureRequest{ RouteID: "asxs", FailureCount: 2, LastErrorClass: "timeout", TTLSeconds: 600, }) if err != nil { t.Fatalf("SetRouteFailure() error = %v", err) } if failure.FailureCount != 2 { t.Fatalf("SetRouteFailure() = %+v, want count 2", failure) } loadedFailure, err := actions.GetRouteFailure(ctx, GetRouteFailureRequest{RouteID: "asxs"}) if err != nil { t.Fatalf("GetRouteFailure() error = %v", err) } if loadedFailure.Key != failure.Key { t.Fatalf("GetRouteFailure().Key = %q, want %q", loadedFailure.Key, failure.Key) } cooldown, err := actions.SetRouteCooldown(ctx, SetRouteCooldownRequest{ RouteID: "asxs", Reason: "degraded", TTLSeconds: 600, }) if err != nil { t.Fatalf("SetRouteCooldown() error = %v", err) } if cooldown.Reason != "degraded" { t.Fatalf("SetRouteCooldown() = %+v, want reason degraded", cooldown) } loadedCooldown, err := actions.GetRouteCooldown(ctx, GetRouteCooldownRequest{RouteID: "asxs"}) if err != nil { t.Fatalf("GetRouteCooldown() error = %v", err) } if loadedCooldown.Key != cooldown.Key { t.Fatalf("GetRouteCooldown().Key = %q, want %q", loadedCooldown.Key, cooldown.Key) } } func TestStickyRuntimeHelpers(t *testing.T) { t.Parallel() runtime, err := newStickyStoreRuntime(context.Background(), config.RouteRuntimeConfig{ Backend: "memory", }) if err != nil { t.Fatalf("newStickyStoreRuntime(memory) error = %v", err) } if runtime.backend != "memory" || runtime.store == nil { t.Fatalf("newStickyStoreRuntime(memory) = %+v, want memory backend with store", runtime) } if _, err := newStickyStoreRuntime(context.Background(), config.RouteRuntimeConfig{ Backend: "bad", }); err == nil { t.Fatal("newStickyStoreRuntime(bad) error = nil, want error") } if got := defaultStickyStoreRuntime(); got.backend != "memory" || got.store == nil { t.Fatalf("defaultStickyStoreRuntime() = %+v, want memory backend with store", got) } } func TestStickyRequestDecodersAndHelpers(t *testing.T) { t.Parallel() req := &http.Request{URL: &url.URL{RawQuery: "scope=conversation&logical_group_id=gpt-shared&public_model=gpt-5.4&subject_id=conv-1"}} stickyReq, stickyErr := decodeGetStickyBindingRequest(req) if stickyErr != nil { t.Fatalf("decodeGetStickyBindingRequest() error = %v", stickyErr) } if stickyReq.SubjectID != "conv-1" { t.Fatalf("decodeGetStickyBindingRequest() = %+v, want subject_id conv-1", stickyReq) } req = &http.Request{URL: &url.URL{RawQuery: "route_id=asxs"}} failureReq, failureErr := decodeGetRouteFailureRequest(req) if failureErr != nil { t.Fatalf("decodeGetRouteFailureRequest() error = %v", failureErr) } if failureReq.RouteID != "asxs" { t.Fatalf("decodeGetRouteFailureRequest() = %+v, want route_id asxs", failureReq) } cooldownReq, cooldownErr := decodeGetRouteCooldownRequest(req) if cooldownErr != nil { t.Fatalf("decodeGetRouteCooldownRequest() error = %v", cooldownErr) } if cooldownReq.RouteID != "asxs" { t.Fatalf("decodeGetRouteCooldownRequest() = %+v, want route_id asxs", cooldownReq) } if _, err := secondsToDuration(-1, defaultStickyTTLSeconds); err == nil { t.Fatal("secondsToDuration(-1) error = nil, want error") } duration, err := secondsToDuration(0, defaultStickyTTLSeconds) if err != nil { t.Fatalf("secondsToDuration(default) error = %v", err) } if duration <= 0 { t.Fatalf("secondsToDuration(default) = %s, want positive", duration) } if _, err := parseTTLQuery("bad"); err == nil { t.Fatal("parseTTLQuery(bad) error = nil, want error") } }