Files
sub2api-cn-relay-manager/internal/app/route_sticky_api_test.go
2026-05-29 07:43:29 +08:00

263 lines
9.0 KiB
Go

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