package routing import ( "bufio" "context" "fmt" "io" "net" "path/filepath" "strconv" "strings" "sync" "testing" "time" ) func TestBuildStickyKey(t *testing.T) { t.Parallel() tests := []struct { name string scope string want string wantErr bool }{ {name: "conversation", scope: StickyScopeConversation, want: "lg:gpt-shared:m:gpt-5.4:conv:conversation-1"}, {name: "session", scope: StickyScopeSession, want: "lg:gpt-shared:m:gpt-5.4:sess:session-1"}, {name: "user", scope: StickyScopeUser, want: "lg:gpt-shared:m:gpt-5.4:user:user-1"}, {name: "invalid", scope: "bad", wantErr: true}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := BuildStickyKey(tt.scope, "gpt-shared", "gpt-5.4", tt.name+"-1") if tt.wantErr { if err == nil { t.Fatal("BuildStickyKey() error = nil, want error") } return } if err != nil { t.Fatalf("BuildStickyKey() error = %v", err) } if got != tt.want { t.Fatalf("BuildStickyKey() = %q, want %q", got, tt.want) } }) } } func TestInMemoryStickyStoreBindingFailureAndCooldown(t *testing.T) { t.Parallel() store := NewInMemoryStickyStore() ctx := context.Background() key, err := BuildStickyKey(StickyScopeConversation, "gpt-shared", "gpt-5.4", "conv-1") if err != nil { t.Fatalf("BuildStickyKey() error = %v", err) } if err := store.Set(ctx, key, StickyBinding{ LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", RouteID: "asxs", ShadowGroupID: "gpt-shared__asxs", }, 2*time.Second); err != nil { t.Fatalf("Set() error = %v", err) } binding, ok, err := store.Get(ctx, key) if err != nil || !ok { t.Fatalf("Get() = (%+v, %v, %v), want binding", binding, ok, err) } if binding.RouteID != "asxs" { t.Fatalf("binding.RouteID = %q, want asxs", binding.RouteID) } if err := store.Delete(ctx, key); err != nil { t.Fatalf("Delete() error = %v", err) } if _, ok, err := store.Get(ctx, key); err != nil || ok { t.Fatalf("Get() after delete = (ok=%v, err=%v), want false nil", ok, err) } if err := store.SetRouteFailure(ctx, "asxs", RouteFailureState{ FailureCount: 2, LastErrorClass: "timeout", }, time.Second); err != nil { t.Fatalf("SetRouteFailure() error = %v", err) } failure, ok, err := store.GetRouteFailure(ctx, "asxs") if err != nil || !ok || failure.FailureCount != 2 { t.Fatalf("GetRouteFailure() = (%+v, %v, %v), want count 2", failure, ok, err) } if err := store.ClearRouteFailure(ctx, "asxs"); err != nil { t.Fatalf("ClearRouteFailure() error = %v", err) } if err := store.SetCooldown(ctx, "asxs", RouteCooldownState{ Reason: "cooldown", }, time.Second); err != nil { t.Fatalf("SetCooldown() error = %v", err) } cooldown, ok, err := store.GetCooldown(ctx, "asxs") if err != nil || !ok || cooldown.RouteID != "asxs" { t.Fatalf("GetCooldown() = (%+v, %v, %v), want route asxs", cooldown, ok, err) } if err := store.ClearCooldown(ctx, "asxs"); err != nil { t.Fatalf("ClearCooldown() error = %v", err) } } func TestInMemoryStickyStoreTTlExpiry(t *testing.T) { t.Parallel() store := NewInMemoryStickyStore() ctx := context.Background() key, err := BuildStickyKey(StickyScopeUser, "gpt-shared", "gpt-5.4", "user-1") if err != nil { t.Fatalf("BuildStickyKey() error = %v", err) } if err := store.Set(ctx, key, StickyBinding{ LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", RouteID: "asxs", ShadowGroupID: "gpt-shared__asxs", }, 40*time.Millisecond); err != nil { t.Fatalf("Set() error = %v", err) } time.Sleep(60 * time.Millisecond) if _, ok, err := store.Get(ctx, key); err != nil || ok { t.Fatalf("Get() after ttl = (ok=%v, err=%v), want false nil", ok, err) } } func TestRedisStickyStoreRoundTripWithFakeServer(t *testing.T) { t.Parallel() server := newFakeRedisServer(t) defer server.Close() store, err := NewRedisStickyStore(context.Background(), RedisConfig{ Addr: server.Addr(), Password: "secret", DB: 2, }) if err != nil { t.Fatalf("NewRedisStickyStore() error = %v", err) } ctx := context.Background() key, err := BuildStickyKey(StickyScopeSession, "gpt-shared", "gpt-5.4", "sess-1") if err != nil { t.Fatalf("BuildStickyKey() error = %v", err) } if err := store.Set(ctx, key, StickyBinding{ LogicalGroupID: "gpt-shared", PublicModel: "gpt-5.4", RouteID: "asxs", ShadowGroupID: "gpt-shared__asxs", }, time.Minute); err != nil { t.Fatalf("Set() error = %v", err) } if binding, ok, err := store.Get(ctx, key); err != nil || !ok || binding.RouteID != "asxs" { t.Fatalf("Get() = (%+v, %v, %v), want route asxs", binding, ok, err) } if err := store.SetRouteFailure(ctx, "asxs", RouteFailureState{ FailureCount: 3, LastErrorClass: "timeout", }, time.Minute); err != nil { t.Fatalf("SetRouteFailure() error = %v", err) } if state, ok, err := store.GetRouteFailure(ctx, "asxs"); err != nil || !ok || state.FailureCount != 3 { t.Fatalf("GetRouteFailure() = (%+v, %v, %v), want count 3", state, ok, err) } if err := store.SetCooldown(ctx, "asxs", RouteCooldownState{ Reason: "degraded", }, time.Minute); err != nil { t.Fatalf("SetCooldown() error = %v", err) } if state, ok, err := store.GetCooldown(ctx, "asxs"); err != nil || !ok || state.Reason != "degraded" { t.Fatalf("GetCooldown() = (%+v, %v, %v), want reason degraded", state, ok, err) } if err := store.Delete(ctx, key); err != nil { t.Fatalf("Delete() error = %v", err) } if _, ok, err := store.Get(ctx, key); err != nil || ok { t.Fatalf("Get() after delete = (ok=%v, err=%v), want false nil", ok, err) } } type fakeRedisServer struct { t *testing.T listener net.Listener password string mu sync.Mutex values map[int]map[string]fakeRedisValue } type fakeRedisValue struct { value string expiresAt time.Time } func newFakeRedisServer(t *testing.T) *fakeRedisServer { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen() error = %v", err) } server := &fakeRedisServer{ t: t, listener: ln, password: "secret", values: make(map[int]map[string]fakeRedisValue), } go server.serve() return server } func (s *fakeRedisServer) Addr() string { return s.listener.Addr().String() } func (s *fakeRedisServer) Close() { _ = s.listener.Close() } func (s *fakeRedisServer) serve() { for { conn, err := s.listener.Accept() if err != nil { return } go s.handleConn(conn) } } func (s *fakeRedisServer) handleConn(conn net.Conn) { defer conn.Close() reader := bufio.NewReader(conn) currentDB := 0 authed := false for { command, err := readRESPArray(reader) if err != nil { if err == io.EOF { return } s.writeError(conn, err.Error()) return } if len(command) == 0 { s.writeError(conn, "empty command") continue } switch strings.ToUpper(command[0]) { case "PING": s.writeSimpleString(conn, "PONG") case "AUTH": if len(command) != 2 || command[1] != s.password { s.writeError(conn, "ERR invalid password") continue } authed = true s.writeSimpleString(conn, "OK") case "SELECT": if len(command) != 2 { s.writeError(conn, "ERR bad select") continue } db, err := strconv.Atoi(command[1]) if err != nil { s.writeError(conn, "ERR bad db") continue } currentDB = db s.writeSimpleString(conn, "OK") case "SET": if !authed { s.writeError(conn, "NOAUTH") continue } if len(command) != 5 || strings.ToUpper(command[3]) != "EX" { s.writeError(conn, "ERR bad set") continue } ttl, err := strconv.Atoi(command[4]) if err != nil { s.writeError(conn, "ERR bad ttl") continue } s.setValue(currentDB, command[1], command[2], time.Duration(ttl)*time.Second) s.writeSimpleString(conn, "OK") case "GET": if !authed { s.writeError(conn, "NOAUTH") continue } if len(command) != 2 { s.writeError(conn, "ERR bad get") continue } value, ok := s.getValue(currentDB, command[1]) if !ok { s.writeNullBulk(conn) continue } s.writeBulk(conn, value) case "DEL": if !authed { s.writeError(conn, "NOAUTH") continue } if len(command) != 2 { s.writeError(conn, "ERR bad del") continue } s.deleteValue(currentDB, command[1]) s.writeInteger(conn, 1) default: s.writeError(conn, "ERR unknown command") } } } func readRESPArray(reader *bufio.Reader) ([]string, error) { line, err := reader.ReadString('\n') if err != nil { return nil, err } line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") if !strings.HasPrefix(line, "*") { return nil, fmt.Errorf("expected array, got %q", line) } count, err := strconv.Atoi(strings.TrimPrefix(line, "*")) if err != nil { return nil, err } parts := make([]string, 0, count) for i := 0; i < count; i++ { header, err := reader.ReadString('\n') if err != nil { return nil, err } header = strings.TrimSuffix(strings.TrimSuffix(header, "\n"), "\r") if !strings.HasPrefix(header, "$") { return nil, fmt.Errorf("expected bulk header, got %q", header) } size, err := strconv.Atoi(strings.TrimPrefix(header, "$")) if err != nil { return nil, err } payload := make([]byte, size+2) if _, err := io.ReadFull(reader, payload); err != nil { return nil, err } parts = append(parts, string(payload[:size])) } return parts, nil } func (s *fakeRedisServer) writeSimpleString(w io.Writer, value string) { _, _ = io.WriteString(w, "+"+value+"\r\n") } func (s *fakeRedisServer) writeBulk(w io.Writer, value string) { _, _ = io.WriteString(w, fmt.Sprintf("$%d\r\n%s\r\n", len(value), value)) } func (s *fakeRedisServer) writeNullBulk(w io.Writer) { _, _ = io.WriteString(w, "$-1\r\n") } func (s *fakeRedisServer) writeInteger(w io.Writer, value int) { _, _ = io.WriteString(w, fmt.Sprintf(":%d\r\n", value)) } func (s *fakeRedisServer) writeError(w io.Writer, message string) { _, _ = io.WriteString(w, "-"+message+"\r\n") } func (s *fakeRedisServer) setValue(db int, key, value string, ttl time.Duration) { s.mu.Lock() defer s.mu.Unlock() if s.values[db] == nil { s.values[db] = make(map[string]fakeRedisValue) } s.values[db][key] = fakeRedisValue{value: value, expiresAt: time.Now().Add(ttl)} } func (s *fakeRedisServer) getValue(db int, key string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() value, ok := s.values[db][key] if !ok { return "", false } if !value.expiresAt.IsZero() && !value.expiresAt.After(time.Now()) { delete(s.values[db], key) return "", false } return value.value, true } func (s *fakeRedisServer) deleteValue(db int, key string) { s.mu.Lock() defer s.mu.Unlock() if s.values[db] == nil { return } delete(s.values[db], key) } func TestRedisStickyStoreRequiresAddr(t *testing.T) { t.Parallel() if _, err := NewRedisStickyStore(context.Background(), RedisConfig{}); err == nil { t.Fatal("NewRedisStickyStore() error = nil, want missing addr") } } func TestNormalizeRuntimeBackend(t *testing.T) { t.Parallel() if got, err := NormalizeRuntimeBackend(""); err != nil || got != RuntimeBackendMemory { t.Fatalf("NormalizeRuntimeBackend(\"\") = (%q, %v), want memory nil", got, err) } if got, err := NormalizeRuntimeBackend("redis"); err != nil || got != RuntimeBackendRedis { t.Fatalf("NormalizeRuntimeBackend(redis) = (%q, %v), want redis nil", got, err) } if _, err := NormalizeRuntimeBackend("bad"); err == nil { t.Fatal("NormalizeRuntimeBackend(bad) error = nil, want error") } } func TestRouteFailureAndCooldownKeyBuilders(t *testing.T) { t.Parallel() failureKey, err := BuildRouteFailureKey("asxs") if err != nil || failureKey != "routefail:asxs" { t.Fatalf("BuildRouteFailureKey() = (%q, %v), want routefail:asxs nil", failureKey, err) } cooldownKey, err := BuildRouteCooldownKey("asxs") if err != nil || cooldownKey != "routecool:asxs" { t.Fatalf("BuildRouteCooldownKey() = (%q, %v), want routecool:asxs nil", cooldownKey, err) } } func TestRedisStickyStoreFixturePathExists(t *testing.T) { t.Parallel() if filepath.Base(t.TempDir()) == "" { t.Fatal("temp dir base should not be empty") } }