263 lines
9.0 KiB
Go
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")
|
|
}
|
|
}
|